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/tests.yml b/.github/workflows/tests.yml
index 57781a0c..2ad62ad0 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -26,7 +26,7 @@ jobs:
- ${{ github.workspace }}:/build
steps:
- - run: pacman --noconfirm -Syu base-devel git python-tox
+ - run: pacman --noconfirm -Syu base-devel git npm python-tox
- run: git config --global --add safe.directory *
diff --git a/.gitignore b/.gitignore
index 1fa4cc9d..5a856dcf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -99,3 +99,9 @@ status_cache.json
*.db
docs/html/
+
+# Frontend
+node_modules/
+package-lock.json
+package/share/ahriman/templates/static/index.js
+package/share/ahriman/templates/static/index.css
diff --git a/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/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..5413581c
--- /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..f8baeb9f
--- /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..43c171d0
--- /dev/null
+++ b/frontend/src/api/client/ApiError.ts
@@ -0,0 +1,25 @@
+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/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..bbdffce1
--- /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],
+ 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 ? : }
+
+
+
+ >
+ );
+}
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..0cbf3d1e
--- /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 { skipToken, 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: current ? () => Client.fetchStatus(current) : skipToken,
+ enabled: !!current && open,
+ });
+
+ const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/dialogs/KeyImportDialog.tsx b/frontend/src/components/dialogs/KeyImportDialog.tsx
new file mode 100644
index 00000000..dac87eda
--- /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 = ApiError.errorDetail(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 = ApiError.errorDetail(e);
+ showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`);
+ }
+ };
+
+ const handleClose = (): void => {
+ setFingerprint("");
+ setServer("keyserver.ubuntu.com");
+ setKeyBody("");
+ onClose();
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/dialogs/LoginDialog.tsx b/frontend/src/components/dialogs/LoginDialog.tsx
new file mode 100644
index 00000000..f61be315
--- /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 = 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}`);
+ }
+ }
+ };
+
+ const handleClose = (): void => {
+ setUsername("");
+ setPassword("");
+ setShowPassword(false);
+ onClose();
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/dialogs/PackageAddDialog.tsx b/frontend/src/components/dialogs/PackageAddDialog.tsx
new file mode 100644
index 00000000..e2a638de
--- /dev/null
+++ b/frontend/src/components/dialogs/PackageAddDialog.tsx
@@ -0,0 +1,200 @@
+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 | null => {
+ 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();
+ if (!repo) {
+ return;
+ }
+ 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 = ApiError.errorDetail(e);
+ showError("Action failed", `Package addition failed: ${detail}`);
+ }
+ };
+
+ const handleRequest = async (): Promise => {
+ if (!packageName) {
+ return;
+ }
+ const repo = getSelectedRepo();
+ if (!repo) {
+ return;
+ }
+ 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 = ApiError.errorDetail(e);
+ showError("Action failed", `Package request failed: ${detail}`);
+ }
+ };
+
+ const handleClose = (): void => {
+ setPackageName("");
+ setSelectedRepo("");
+ setRefresh(true);
+ setEnvVars([]);
+ onClose();
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/dialogs/PackageInfoDialog.tsx b/frontend/src/components/dialogs/PackageInfoDialog.tsx
new file mode 100644
index 00000000..5b9387c3
--- /dev/null
+++ b/frontend/src/components/dialogs/PackageInfoDialog.tsx
@@ -0,0 +1,296 @@
+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 { skipToken, 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";
+
+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 { data: packageData } = useQuery({
+ queryKey: packageBase && current ? QueryKeys.package(packageBase, current) : ["package-none"],
+ queryFn: packageBase && current ? () => Client.fetchPackage(packageBase, current) : skipToken,
+ enabled: !!packageBase && !!current && open,
+ refetchInterval: autoRefresh.refetchInterval,
+ });
+
+ const { data: dependencies } = useQuery({
+ queryKey: packageBase && current ? QueryKeys.dependencies(packageBase, current) : ["deps-none"],
+ queryFn: packageBase && current ? () => Client.fetchDependencies(packageBase, current) : skipToken,
+ enabled: !!packageBase && !!current && open,
+ });
+
+ const { data: patches = [] } = useQuery({
+ queryKey: packageBase ? QueryKeys.patches(packageBase) : ["patches-none"],
+ queryFn: packageBase ? () => Client.fetchPatches(packageBase) : skipToken,
+ 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 || !current) {
+ return;
+ }
+ try {
+ await Client.addPackages(current, { packages: [packageBase], refresh: refreshDb });
+ showSuccess("Success", `Run update for packages ${packageBase}`);
+ } catch (e) {
+ const detail = ApiError.errorDetail(e);
+ showError("Action failed", `Package update failed: ${detail}`);
+ }
+ };
+
+ const handleRemove = async (): Promise => {
+ if (!packageBase || !current) {
+ return;
+ }
+ try {
+ await Client.removePackages(current, [packageBase]);
+ showSuccess("Success", `Packages ${packageBase} have been removed`);
+ onClose();
+ } catch (e) {
+ const detail = ApiError.errorDetail(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 = ApiError.errorDetail(e);
+ showError("Action failed", `Could not delete variable: ${detail}`);
+ }
+ };
+
+ 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) });
+ };
+
+ const handleClose = (): void => {
+ setTabIndex(0);
+ setRefreshDb(true);
+ onClose();
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/dialogs/PackageRebuildDialog.tsx b/frontend/src/components/dialogs/PackageRebuildDialog.tsx
new file mode 100644
index 00000000..0af22c6d
--- /dev/null
+++ b/frontend/src/components/dialogs/PackageRebuildDialog.tsx
@@ -0,0 +1,92 @@
+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 | null => {
+ 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();
+ if (!repo) {
+ return;
+ }
+ try {
+ await Client.rebuildPackages(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}`);
+ }
+ };
+
+ const handleClose = (): void => {
+ setDependency("");
+ setSelectedRepo("");
+ onClose();
+ };
+
+ return (
+
+ );
+}
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx
new file mode 100644
index 00000000..9480f5ab
--- /dev/null
+++ b/frontend/src/components/layout/Footer.tsx
@@ -0,0 +1,79 @@
+import type React from "react";
+import { Box, Link, Button, Typography } from "@mui/material";
+import GitHubIcon from "@mui/icons-material/GitHub";
+import LogoutIcon from "@mui/icons-material/Logout";
+import HomeIcon from "@mui/icons-material/Home";
+import { useAuth } from "hooks/useAuth";
+
+interface FooterProps {
+ version: string;
+ docsEnabled: boolean;
+ indexUrl?: string;
+ onLoginClick: () => void;
+}
+
+export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }: FooterProps): React.JSX.Element {
+ const { enabled: authEnabled, username, logout } = useAuth();
+
+ const handleLogout = async (): Promise => {
+ await logout();
+ window.location.href = "/";
+ };
+
+ return (
+
+
+
+
+ ahriman {version}
+
+
+ releases
+
+
+ report a bug
+
+ {docsEnabled && (
+
+ api
+
+ )}
+
+
+ {indexUrl && (
+
+
+
+ repo index
+
+
+ )}
+
+ {authEnabled && (
+
+ {username ? (
+ } onClick={() => void handleLogout()} sx={{ textTransform: "none" }}>
+ logout ({username})
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/layout/Navbar.tsx b/frontend/src/components/layout/Navbar.tsx
new file mode 100644
index 00000000..3b3e0771
--- /dev/null
+++ b/frontend/src/components/layout/Navbar.tsx
@@ -0,0 +1,33 @@
+import type React from "react";
+import { Tabs, Tab, Box } from "@mui/material";
+import { useRepository } from "hooks/useRepository";
+
+export default function Navbar(): React.JSX.Element | null {
+ const { repositories, current, setCurrent } = useRepository();
+
+ if (repositories.length === 0 || !current) {
+ return null;
+ }
+
+ const currentIndex = repositories.findIndex(
+ (r) => r.architecture === current.architecture && r.repository === current.repository,
+ );
+
+ return (
+
+ = 0 ? currentIndex : 0}
+ onChange={(_, newValue: number) => setCurrent(repositories[newValue])}
+ variant="scrollable"
+ scrollButtons="auto"
+ >
+ {repositories.map((repo) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/package/BuildLogsTab.tsx b/frontend/src/components/package/BuildLogsTab.tsx
new file mode 100644
index 00000000..38164db4
--- /dev/null
+++ b/frontend/src/components/package/BuildLogsTab.tsx
@@ -0,0 +1,192 @@
+import React, { useState, useEffect, useMemo, useRef } from "react";
+import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
+import ListIcon from "@mui/icons-material/List";
+import { useQuery } from "@tanstack/react-query";
+import hljs from "highlight.js/lib/core";
+import plaintext from "highlight.js/lib/languages/plaintext";
+import "highlight.js/styles/github.css";
+import { Client } from "api/client/AhrimanClient";
+import { QueryKeys } from "api/QueryKeys";
+import { formatTimestamp } from "components/common/formatTimestamp";
+import CopyButton from "components/common/CopyButton";
+import type { LogRecord } from "api/types/LogRecord";
+import type { RepositoryId } from "api/types/RepositoryId";
+
+hljs.registerLanguage("plaintext", plaintext);
+
+interface LogVersion {
+ version: string;
+ processId: string;
+ created: number;
+ logs: string;
+}
+
+interface BuildLogsTabProps {
+ packageBase: string;
+ repo: RepositoryId;
+ refetchInterval: number | false;
+}
+
+function convertLogs(records: LogRecord[], filter?: (r: LogRecord) => boolean): string {
+ return records
+ .filter(filter || Boolean)
+ .map((r) => `[${new Date(r.created * 1000).toISOString()}] ${r.message}`)
+ .join("\n");
+}
+
+export default function BuildLogsTab({ packageBase, repo, refetchInterval }: BuildLogsTabProps): React.JSX.Element {
+ const [activeIndex, setActiveIndex] = useState(0);
+ const [anchorEl, setAnchorEl] = useState(null);
+ const codeRef = useRef(null);
+ const preRef = useRef(null);
+ const initialScrollDone = useRef(false);
+
+ const { data: allLogs } = useQuery({
+ queryKey: QueryKeys.logs(packageBase, repo),
+ queryFn: () => Client.fetchLogs(packageBase, repo),
+ enabled: !!packageBase,
+ });
+
+ // Build version selectors from all logs
+ const versions = useMemo(() => {
+ if (!allLogs || allLogs.length === 0) {
+ return [];
+ }
+
+ const grouped: Record = {};
+ for (const record of allLogs) {
+ const key = `${record.version}-${record.process_id}`;
+ if (!grouped[key]) {
+ grouped[key] = { ...record, minCreated: record.created };
+ } else {
+ grouped[key].minCreated = Math.min(grouped[key].minCreated, record.created);
+ }
+ }
+
+ return Object.values(grouped)
+ .sort((a, b) => b.minCreated - a.minCreated)
+ .map((v) => ({
+ version: v.version,
+ processId: v.process_id,
+ created: v.minCreated,
+ logs: convertLogs(
+ allLogs,
+ (r) => r.version === v.version && r.process_id === v.process_id,
+ ),
+ }));
+ }, [allLogs]);
+
+ // Reset active index when data changes
+ const [prevAllLogs, setPrevAllLogs] = useState(allLogs);
+ if (allLogs !== prevAllLogs) {
+ setPrevAllLogs(allLogs);
+ setActiveIndex(0);
+ }
+
+ // Reset scroll tracking when logs data changes
+ useEffect(() => {
+ initialScrollDone.current = false;
+ }, [allLogs]);
+
+ // Refresh active version logs when using auto-refresh
+ const activeVersion = versions[activeIndex];
+ const { data: versionLogs } = useQuery({
+ queryKey: activeVersion
+ ? QueryKeys.logsVersion(packageBase, repo, activeVersion.version, activeVersion.processId)
+ : ["logs-none"],
+ queryFn: () =>
+ activeVersion
+ ? Client.fetchLogs(packageBase, repo, activeVersion.version, activeVersion.processId)
+ : Promise.resolve([]),
+ enabled: !!activeVersion && !!refetchInterval,
+ refetchInterval,
+ });
+
+ // Derive displayed logs: prefer fresh polled data when available
+ const displayedLogs = useMemo(() => {
+ if (versionLogs && versionLogs.length > 0) {
+ return convertLogs(versionLogs);
+ }
+ return activeVersion?.logs ?? "";
+ }, [versionLogs, activeVersion]);
+
+ // Highlight code
+ useEffect(() => {
+ if (codeRef.current && displayedLogs) {
+ codeRef.current.textContent = displayedLogs;
+ delete codeRef.current.dataset.highlighted;
+ hljs.highlightElement(codeRef.current);
+ }
+ }, [displayedLogs]);
+
+ // Auto-scroll: always scroll to bottom on initial load, then only if already near bottom
+ useEffect(() => {
+ if (preRef.current && displayedLogs) {
+ const el = preRef.current;
+ if (!initialScrollDone.current) {
+ el.scrollTop = el.scrollHeight;
+ initialScrollDone.current = true;
+ } else {
+ const isAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 50;
+ if (isAtBottom) {
+ el.scrollTop = el.scrollHeight;
+ }
+ }
+ }
+ }, [displayedLogs]);
+
+ return (
+
+
+ }
+ onClick={(e) => setAnchorEl(e.currentTarget)}
+ />
+
+
+
+
+
+
+
+
+ displayedLogs} />
+
+
+
+ );
+}
diff --git a/frontend/src/components/package/ChangesTab.tsx b/frontend/src/components/package/ChangesTab.tsx
new file mode 100644
index 00000000..8fcacff5
--- /dev/null
+++ b/frontend/src/components/package/ChangesTab.tsx
@@ -0,0 +1,60 @@
+import React, { useEffect, useRef } from "react";
+import { Box } from "@mui/material";
+import { useQuery } from "@tanstack/react-query";
+import hljs from "highlight.js/lib/core";
+import diff from "highlight.js/lib/languages/diff";
+import "highlight.js/styles/github.css";
+import { Client } from "api/client/AhrimanClient";
+import { QueryKeys } from "api/QueryKeys";
+import CopyButton from "components/common/CopyButton";
+import type { Changes } from "api/types/Changes";
+import type { RepositoryId } from "api/types/RepositoryId";
+
+hljs.registerLanguage("diff", diff);
+
+interface ChangesTabProps {
+ packageBase: string;
+ repo: RepositoryId;
+}
+
+export default function ChangesTab({ packageBase, repo }: ChangesTabProps): React.JSX.Element {
+ const codeRef = useRef(null);
+
+ const { data } = useQuery({
+ queryKey: QueryKeys.changes(packageBase, repo),
+ queryFn: () => Client.fetchChanges(packageBase, repo),
+ enabled: !!packageBase,
+ });
+
+ const changesText = data?.changes ?? "";
+
+ useEffect(() => {
+ if (codeRef.current) {
+ codeRef.current.textContent = changesText;
+ delete codeRef.current.dataset.highlighted;
+ hljs.highlightElement(codeRef.current);
+ }
+ }, [changesText]);
+
+ return (
+
+
+
+
+
+ changesText} />
+
+
+ );
+}
diff --git a/frontend/src/components/package/EventsTab.tsx b/frontend/src/components/package/EventsTab.tsx
new file mode 100644
index 00000000..23b54073
--- /dev/null
+++ b/frontend/src/components/package/EventsTab.tsx
@@ -0,0 +1,60 @@
+import type React from "react";
+import { Box } from "@mui/material";
+import { DataGrid, type GridColDef } from "@mui/x-data-grid";
+import { useQuery } from "@tanstack/react-query";
+import EventDurationLineChart from "components/charts/EventDurationLineChart";
+import { Client } from "api/client/AhrimanClient";
+import { QueryKeys } from "api/QueryKeys";
+import { formatTimestamp } from "components/common/formatTimestamp";
+import type { Event } from "api/types/Event";
+import type { RepositoryId } from "api/types/RepositoryId";
+
+interface EventsTabProps {
+ packageBase: string;
+ repo: RepositoryId;
+}
+
+interface EventRow {
+ id: number;
+ timestamp: string;
+ event: string;
+ message: string;
+}
+
+const columns: GridColDef[] = [
+ { field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
+ { field: "event", headerName: "event", flex: 1 },
+ { field: "message", headerName: "description", flex: 2 },
+];
+
+export default function EventsTab({ packageBase, repo }: EventsTabProps): React.JSX.Element {
+ const { data: events = [] } = useQuery({
+ queryKey: QueryKeys.events(repo, packageBase),
+ queryFn: () => Client.fetchEvents(repo, packageBase, 30),
+ enabled: !!packageBase,
+ });
+
+ const rows: EventRow[] = events.map((e, idx) => ({
+ id: idx,
+ timestamp: formatTimestamp(e.created),
+ event: e.event,
+ message: e.message ?? "",
+ }));
+
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/table/PackageTable.tsx b/frontend/src/components/table/PackageTable.tsx
new file mode 100644
index 00000000..31615e06
--- /dev/null
+++ b/frontend/src/components/table/PackageTable.tsx
@@ -0,0 +1,314 @@
+import React, { useState, useMemo, useCallback, useEffect } from "react";
+import {
+ DataGrid,
+ GridToolbarQuickFilter,
+ GridToolbarFilterButton,
+ type GridColDef,
+ type GridFilterModel,
+ type GridRenderCellParams,
+} from "@mui/x-data-grid";
+import { Box, Link, Stack } from "@mui/material";
+import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
+import PackageTableToolbar from "components/table/PackageTableToolbar";
+import StatusCell from "components/table/StatusCell";
+import DashboardDialog from "components/dialogs/DashboardDialog";
+import PackageAddDialog from "components/dialogs/PackageAddDialog";
+import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog";
+import KeyImportDialog from "components/dialogs/KeyImportDialog";
+import PackageInfoDialog from "components/dialogs/PackageInfoDialog";
+import { useRepository } from "hooks/useRepository";
+import { useAuth } from "hooks/useAuth";
+import { useNotification } from "hooks/useNotification";
+import { useAutoRefresh } from "hooks/useAutoRefresh";
+import { useLocalStorage } from "hooks/useLocalStorage";
+import { Client } from "api/client/AhrimanClient";
+import { ApiError } from "api/client/ApiError";
+import { QueryKeys } from "api/QueryKeys";
+import { formatTimestamp } from "components/common/formatTimestamp";
+import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
+import type { InternalStatus } from "api/types/InternalStatus";
+import type { PackageRow } from "api/types/PackageRow";
+import type { PackageStatus } from "api/types/PackageStatus";
+
+interface PackageTableProps {
+ autorefreshIntervals: AutoRefreshInterval[];
+}
+
+function extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
+ return [
+ ...new Set(
+ Object.values(pkg.packages)
+ .flatMap((p) => p[property] ?? []),
+ ),
+ ].sort();
+}
+
+function toRow(ps: PackageStatus): PackageRow {
+ return {
+ id: ps.package.base,
+ base: ps.package.base,
+ webUrl: ps.package.remote.web_url ?? undefined,
+ version: ps.package.version,
+ packages: Object.keys(ps.package.packages).sort(),
+ groups: extractListProperties(ps.package, "groups"),
+ licenses: extractListProperties(ps.package, "licenses"),
+ packager: ps.package.packager ?? "",
+ timestamp: formatTimestamp(ps.status.timestamp),
+ timestampValue: ps.status.timestamp,
+ status: ps.status.status,
+ };
+}
+
+const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
+
+function SearchToolbar(): React.JSX.Element {
+ return (
+
+
+
+
+ );
+}
+
+export default function PackageTable({ autorefreshIntervals }: PackageTableProps): React.JSX.Element {
+ const { current } = useRepository();
+ const { enabled: authEnabled, username } = useAuth();
+ const { showSuccess, showError } = useNotification();
+ const queryClient = useQueryClient();
+
+ const hasAuth = !authEnabled || username !== null;
+
+ const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
+ const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval);
+
+ const [selectionModel, setSelectionModel] = useState([]);
+ const [dialogOpen, setDialogOpen] = useState(null);
+ const [selectedPackage, setSelectedPackage] = useState(null);
+
+ const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
+ pageSize: 10,
+ page: 0,
+ });
+ const [columnVisibility, setColumnVisibility] = useLocalStorage>(
+ "ahriman-packages-columns",
+ { groups: false, licenses: false, packager: false },
+ );
+ const [filterModel, setFilterModel] = useLocalStorage(
+ "ahriman-packages-filters",
+ { items: [] },
+ );
+
+ // Pause auto-refresh when dialog is open
+ const isDialogOpen = dialogOpen !== null || selectedPackage !== null;
+ const setPaused = autoRefresh.setPaused;
+ useEffect(() => {
+ setPaused(isDialogOpen);
+ }, [isDialogOpen, setPaused]);
+
+ const { data: packages = [], isLoading } = useQuery({
+ queryKey: current ? QueryKeys.packages(current) : ["packages"],
+ queryFn: () => (current ? Client.fetchPackages(current) : Promise.resolve([])),
+ enabled: !!current,
+ refetchInterval: autoRefresh.refetchInterval,
+ });
+
+ const { data: status } = useQuery({
+ queryKey: current ? QueryKeys.status(current) : ["status"],
+ queryFn: current ? () => Client.fetchStatus(current) : skipToken,
+ enabled: !!current,
+ refetchInterval: autoRefresh.refetchInterval,
+ });
+
+ const rows = useMemo(() => packages.map(toRow), [packages]);
+
+ const handleReload = useCallback(() => {
+ if (!current) {
+ return;
+ }
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
+ }, [current, queryClient]);
+
+ const handleUpdate = useCallback(async () => {
+ if (!current) {
+ return;
+ }
+ try {
+ if (selectionModel.length === 0) {
+ await Client.updatePackages(current, { packages: [] });
+ showSuccess("Success", "Repository update has been run");
+ } else {
+ await Client.addPackages(current, { packages: selectionModel });
+ showSuccess("Success", `Run update for packages ${selectionModel.join(", ")}`);
+ }
+ } catch (e) {
+ const detail = ApiError.errorDetail(e);
+ showError("Action failed", `Packages update failed: ${detail}`);
+ }
+ }, [current, selectionModel, showSuccess, showError]);
+
+ const handleRefreshDb = useCallback(async () => {
+ if (!current) {
+ return;
+ }
+ try {
+ await Client.updatePackages(current, { packages: [], refresh: true, aur: false, local: false, manual: false });
+ showSuccess("Success", "Pacman database update has been requested");
+ } catch (e) {
+ const detail = ApiError.errorDetail(e);
+ showError("Action failed", `Could not update pacman databases: ${detail}`);
+ }
+ }, [current, showSuccess, showError]);
+
+ const handleRemove = useCallback(async () => {
+ if (!current) {
+ return;
+ }
+ if (selectionModel.length === 0) {
+ return;
+ }
+ try {
+ await Client.removePackages(current, selectionModel);
+ showSuccess("Success", `Packages ${selectionModel.join(", ")} have been removed`);
+ setSelectionModel([]);
+ } catch (e) {
+ const detail = ApiError.errorDetail(e);
+ showError("Action failed", `Could not remove packages: ${detail}`);
+ }
+ }, [current, selectionModel, showSuccess, showError]);
+
+ const columns: GridColDef[] = useMemo(
+ () => [
+ {
+ field: "base",
+ headerName: "package base",
+ flex: 1,
+ minWidth: 150,
+ renderCell: (params: GridRenderCellParams) =>
+ params.row.webUrl ? (
+
+ {params.value as string}
+
+ ) : (
+ params.value as string
+ ),
+ },
+ { field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
+ {
+ field: "packages",
+ headerName: "packages",
+ flex: 1,
+ minWidth: 120,
+ valueGetter: (value: string[]) => (value ?? []).join(" "),
+ renderCell: (params: GridRenderCellParams) => (params.row.packages ?? []).map((item, i, arr) => (
+ {item}{i < arr.length - 1 &&
}
+ )),
+ sortComparator: (v1: string, v2: string) => v1.localeCompare(v2),
+ },
+ {
+ field: "groups",
+ headerName: "groups",
+ width: 150,
+ valueGetter: (value: string[]) => (value ?? []).join(" "),
+ renderCell: (params: GridRenderCellParams) => (params.row.groups ?? []).map((item, i, arr) => (
+ {item}{i < arr.length - 1 &&
}
+ )),
+ },
+ {
+ field: "licenses",
+ headerName: "licenses",
+ width: 150,
+ valueGetter: (value: string[]) => (value ?? []).join(" "),
+ renderCell: (params: GridRenderCellParams) => (params.row.licenses ?? []).map((item, i, arr) => (
+ {item}{i < arr.length - 1 &&
}
+ )),
+ },
+ { field: "packager", headerName: "packager", width: 150 },
+ {
+ field: "timestamp",
+ headerName: "last update",
+ width: 180,
+ align: "right",
+ headerAlign: "right",
+ },
+ {
+ field: "status",
+ headerName: "status",
+ width: 120,
+ align: "center",
+ headerAlign: "center",
+ renderCell: (params: GridRenderCellParams) => ,
+ },
+ ],
+ [],
+ );
+
+ return (
+
+ 0}
+ hasAuth={hasAuth}
+ repoStatus={status?.status.status}
+ autorefreshIntervals={autorefreshIntervals}
+ autoRefreshEnabled={autoRefresh.enabled}
+ autoRefreshInterval={autoRefresh.interval}
+ onAutoRefreshToggle={autoRefresh.setEnabled}
+ onAutoRefreshIntervalChange={autoRefresh.setInterval}
+ onDashboardClick={() => setDialogOpen("dashboard")}
+ onAddClick={() => setDialogOpen("add")}
+ onUpdateClick={() => void handleUpdate()}
+ onRefreshDbClick={() => void handleRefreshDb()}
+ onRebuildClick={() => setDialogOpen("rebuild")}
+ onRemoveClick={() => void handleRemove()}
+ onKeyImportClick={() => setDialogOpen("keyImport")}
+ onReloadClick={handleReload}
+ />
+
+ "auto"}
+ checkboxSelection
+ disableRowSelectionOnClick
+ rowSelectionModel={selectionModel}
+ onRowSelectionModelChange={(model) => setSelectionModel(model as string[])}
+ paginationModel={paginationModel}
+ onPaginationModelChange={setPaginationModel}
+ pageSizeOptions={PAGE_SIZE_OPTIONS}
+ columnVisibilityModel={columnVisibility}
+ onColumnVisibilityModelChange={setColumnVisibility}
+ filterModel={filterModel}
+ onFilterModelChange={setFilterModel}
+ slots={{ toolbar: SearchToolbar }}
+ initialState={{
+ sorting: { sortModel: [{ field: "base", sort: "asc" }] },
+ }}
+ onRowClick={(params: { row: PackageRow }, event) => {
+ const target = event.target as HTMLElement;
+ // Don't open info dialog when clicking checkbox or link
+ if (target.closest("input[type=\"checkbox\"]") || target.closest("a")) {
+ return;
+ }
+ setSelectedPackage(params.row.id);
+ }}
+ sx={{
+ "& .MuiDataGrid-row": { cursor: "pointer" },
+ height: 600,
+ }}
+ density="compact"
+ />
+
+ setDialogOpen(null)} />
+ setDialogOpen(null)} />
+ setDialogOpen(null)} />
+ setDialogOpen(null)} />
+ setSelectedPackage(null)}
+ autorefreshIntervals={autorefreshIntervals}
+ />
+
+ );
+}
diff --git a/frontend/src/components/table/PackageTableToolbar.tsx b/frontend/src/components/table/PackageTableToolbar.tsx
new file mode 100644
index 00000000..d0e4d59b
--- /dev/null
+++ b/frontend/src/components/table/PackageTableToolbar.tsx
@@ -0,0 +1,133 @@
+import React, { useState } from "react";
+import { Button, Menu, MenuItem, Box, Tooltip, IconButton, Divider } from "@mui/material";
+import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
+import InventoryIcon from "@mui/icons-material/Inventory";
+import AddIcon from "@mui/icons-material/Add";
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import DownloadIcon from "@mui/icons-material/Download";
+import ReplayIcon from "@mui/icons-material/Replay";
+import DeleteIcon from "@mui/icons-material/Delete";
+import VpnKeyIcon from "@mui/icons-material/VpnKey";
+import RefreshIcon from "@mui/icons-material/Refresh";
+import AutoRefreshControl from "components/common/AutoRefreshControl";
+import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
+import type { BuildStatus } from "api/types/BuildStatus";
+import { StatusColors } from "theme/status/StatusColors";
+
+interface PackageTableToolbarProps {
+ hasSelection: boolean;
+ hasAuth: boolean;
+ repoStatus?: BuildStatus;
+ autorefreshIntervals: AutoRefreshInterval[];
+ autoRefreshEnabled: boolean;
+ autoRefreshInterval: number;
+ onAutoRefreshToggle: (enabled: boolean) => void;
+ onAutoRefreshIntervalChange: (interval: number) => void;
+ onDashboardClick: () => void;
+ onAddClick: () => void;
+ onUpdateClick: () => void;
+ onRefreshDbClick: () => void;
+ onRebuildClick: () => void;
+ onRemoveClick: () => void;
+ onKeyImportClick: () => void;
+ onReloadClick: () => void;
+}
+
+export default function PackageTableToolbar({
+ hasSelection,
+ hasAuth,
+ repoStatus,
+ autorefreshIntervals,
+ autoRefreshEnabled,
+ autoRefreshInterval,
+ onAutoRefreshToggle,
+ onAutoRefreshIntervalChange,
+ onDashboardClick,
+ onAddClick,
+ onUpdateClick,
+ onRefreshDbClick,
+ onRebuildClick,
+ onRemoveClick,
+ onKeyImportClick,
+ onReloadClick,
+}: PackageTableToolbarProps): React.JSX.Element {
+ const [packagesAnchorEl, setPackagesAnchorEl] = useState(null);
+
+ return (
+
+
+
+
+
+
+
+ {hasAuth && (
+ <>
+ }
+ onClick={(e) => setPackagesAnchorEl(e.currentTarget)}
+ >
+ packages
+
+
+
+ } onClick={onKeyImportClick}>
+ import key
+
+ >
+ )}
+
+ } onClick={onReloadClick}>
+ reload
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/table/StatusCell.tsx b/frontend/src/components/table/StatusCell.tsx
new file mode 100644
index 00000000..05ddd40d
--- /dev/null
+++ b/frontend/src/components/table/StatusCell.tsx
@@ -0,0 +1,22 @@
+import type React from "react";
+import { Chip } from "@mui/material";
+import type { BuildStatus } from "api/types/BuildStatus";
+import { StatusColors } from "theme/status/StatusColors";
+
+interface StatusCellProps {
+ status: BuildStatus;
+}
+
+export default function StatusCell({ status }: StatusCellProps): React.JSX.Element {
+ return (
+
+ );
+}
diff --git a/frontend/src/contexts/AuthContext.ts b/frontend/src/contexts/AuthContext.ts
new file mode 100644
index 00000000..3183e78f
--- /dev/null
+++ b/frontend/src/contexts/AuthContext.ts
@@ -0,0 +1,14 @@
+import { createContext } from "react";
+
+interface AuthState {
+ enabled: boolean;
+ username: string | null;
+}
+
+export interface AuthContextValue extends AuthState {
+ setAuthState: (state: AuthState) => void;
+ login: (username: string, password: string) => Promise;
+ logout: () => Promise;
+}
+
+export const AuthContext = createContext(null);
diff --git a/frontend/src/contexts/AuthProvider.tsx b/frontend/src/contexts/AuthProvider.tsx
new file mode 100644
index 00000000..5b7aa459
--- /dev/null
+++ b/frontend/src/contexts/AuthProvider.tsx
@@ -0,0 +1,23 @@
+import React, { useState, useCallback, type ReactNode } from "react";
+import { Client } from "api/client/AhrimanClient";
+import { AuthContext } from "contexts/AuthContext";
+
+export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
+ const [state, setState] = useState({ enabled: false, username: null as string | null });
+
+ const login = useCallback(async (username: string, password: string) => {
+ await Client.login({ username, password });
+ setState((prev) => ({ ...prev, username }));
+ }, []);
+
+ const doLogout = useCallback(async () => {
+ await Client.logout();
+ setState((prev) => ({ ...prev, username: null }));
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/contexts/NotificationContext.ts b/frontend/src/contexts/NotificationContext.ts
new file mode 100644
index 00000000..0b4793d8
--- /dev/null
+++ b/frontend/src/contexts/NotificationContext.ts
@@ -0,0 +1,8 @@
+import { createContext } from "react";
+
+export interface NotificationContextValue {
+ showSuccess: (title: string, message: string) => void;
+ showError: (title: string, message: string) => void;
+}
+
+export const NotificationContext = createContext(null);
diff --git a/frontend/src/contexts/NotificationProvider.tsx b/frontend/src/contexts/NotificationProvider.tsx
new file mode 100644
index 00000000..b8ff6305
--- /dev/null
+++ b/frontend/src/contexts/NotificationProvider.tsx
@@ -0,0 +1,53 @@
+import React, { useState, useCallback, useRef, type ReactNode } from "react";
+import { Snackbar, Alert, type AlertColor } from "@mui/material";
+import { NotificationContext } from "contexts/NotificationContext";
+
+interface Notification {
+ id: number;
+ title: string;
+ message: string;
+ severity: AlertColor;
+}
+
+export function NotificationProvider({ children }: { children: ReactNode }): React.JSX.Element {
+ const nextId = useRef(0);
+ const [notifications, setNotifications] = useState([]);
+
+ const addNotification = useCallback((title: string, message: string, severity: AlertColor) => {
+ const id = nextId.current++;
+ setNotifications((prev) => [...prev, { id, title, message, severity }]);
+ }, []);
+
+ const removeNotification = useCallback((id: number) => {
+ setNotifications((prev) => prev.filter((n) => n.id !== id));
+ }, []);
+
+ const showSuccess = useCallback(
+ (title: string, message: string) => addNotification(title, message, "success"),
+ [addNotification],
+ );
+ const showError = useCallback(
+ (title: string, message: string) => addNotification(title, message, "error"),
+ [addNotification],
+ );
+
+ return (
+
+ {children}
+ {notifications.map((n) => (
+ removeNotification(n.id)}
+ anchorOrigin={{ vertical: "top", horizontal: "center" }}
+ >
+ removeNotification(n.id)} severity={n.severity} variant="filled" sx={{ width: "100%", maxWidth: 500 }}>
+ {n.title}
+ {n.message && ` - ${n.message}`}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/contexts/RepositoryContext.ts b/frontend/src/contexts/RepositoryContext.ts
new file mode 100644
index 00000000..62b23e74
--- /dev/null
+++ b/frontend/src/contexts/RepositoryContext.ts
@@ -0,0 +1,11 @@
+import { createContext } from "react";
+import type { RepositoryId } from "api/types/RepositoryId";
+
+export interface RepositoryContextValue {
+ repositories: RepositoryId[];
+ current: RepositoryId | null;
+ setRepositories: (repos: RepositoryId[]) => void;
+ setCurrent: (repo: RepositoryId) => void;
+}
+
+export const RepositoryContext = createContext(null);
diff --git a/frontend/src/contexts/RepositoryProvider.tsx b/frontend/src/contexts/RepositoryProvider.tsx
new file mode 100644
index 00000000..ef35be8a
--- /dev/null
+++ b/frontend/src/contexts/RepositoryProvider.tsx
@@ -0,0 +1,47 @@
+import React, { useState, useCallback, useEffect, type ReactNode } from "react";
+import type { RepositoryId } from "api/types/RepositoryId";
+import { RepositoryContext } from "contexts/RepositoryContext";
+
+function repoId(repo: RepositoryId): string {
+ return `${repo.architecture}-${repo.repository}`;
+}
+
+export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element {
+ const [repositories, setRepositories] = useState([]);
+ const [current, setCurrentState] = useState(null);
+
+ const setCurrent = useCallback((repo: RepositoryId) => {
+ setCurrentState(repo);
+ window.location.hash = repoId(repo);
+ }, []);
+
+ // Sync current repository when repositories list changes
+ const [prevRepositories, setPrevRepositories] = useState(repositories);
+ if (repositories !== prevRepositories) {
+ setPrevRepositories(repositories);
+ if (repositories.length > 0) {
+ const hash = window.location.hash.replace("#", "");
+ const match = repositories.find((r) => repoId(r) === hash);
+ setCurrentState(match || repositories[0]);
+ }
+ }
+
+ // Listen for hash changes
+ useEffect(() => {
+ const handler = (): void => {
+ const hash = window.location.hash.replace("#", "");
+ const match = repositories.find((r) => repoId(r) === hash);
+ if (match) {
+ setCurrentState(match);
+ }
+ };
+ window.addEventListener("hashchange", handler);
+ return () => window.removeEventListener("hashchange", handler);
+ }, [repositories]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
new file mode 100644
index 00000000..f5264b37
--- /dev/null
+++ b/frontend/src/hooks/useAuth.ts
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+import { AuthContext, type AuthContextValue } from "contexts/AuthContext";
+
+export function useAuth(): AuthContextValue {
+ const ctx = useContext(AuthContext);
+ if (!ctx) {
+ throw new Error("useAuth must be used within AuthProvider");
+ }
+ return ctx;
+}
diff --git a/frontend/src/hooks/useAutoRefresh.ts b/frontend/src/hooks/useAutoRefresh.ts
new file mode 100644
index 00000000..307cf721
--- /dev/null
+++ b/frontend/src/hooks/useAutoRefresh.ts
@@ -0,0 +1,52 @@
+import { useState, useCallback, type Dispatch, type SetStateAction } from "react";
+import { useLocalStorage } from "hooks/useLocalStorage";
+
+interface AutoRefreshState {
+ enabled: boolean;
+ interval: number;
+}
+
+interface AutoRefreshResult {
+ enabled: boolean;
+ interval: number;
+ paused: boolean;
+ refetchInterval: number | false;
+ setEnabled: (enabled: boolean) => void;
+ setInterval: (interval: number) => void;
+ setPaused: Dispatch>;
+}
+
+export function useAutoRefresh(key: string, defaultInterval: number = 0): AutoRefreshResult {
+ const [stored, setStored] = useLocalStorage(`ahriman-${key}`, {
+ enabled: defaultInterval > 0,
+ interval: defaultInterval,
+ });
+
+ const [paused, setPaused] = useState(false);
+
+ const refetchInterval: number | false = stored.enabled && !paused && stored.interval > 0 ? stored.interval : false;
+
+ const setEnabled = useCallback(
+ (enabled: boolean) => {
+ setStored((prev) => ({ ...prev, enabled }));
+ },
+ [setStored],
+ );
+
+ const setInterval = useCallback(
+ (interval: number) => {
+ setStored({ enabled: true, interval });
+ },
+ [setStored],
+ );
+
+ return {
+ enabled: stored.enabled,
+ interval: stored.interval,
+ paused,
+ refetchInterval,
+ setEnabled,
+ setInterval,
+ setPaused,
+ };
+}
diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts
new file mode 100644
index 00000000..886e8d57
--- /dev/null
+++ b/frontend/src/hooks/useDebounce.ts
@@ -0,0 +1,12 @@
+import { useState, useEffect } from "react";
+
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => setDebouncedValue(value), delay);
+ return () => clearTimeout(handler);
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/frontend/src/hooks/useLocalStorage.ts b/frontend/src/hooks/useLocalStorage.ts
new file mode 100644
index 00000000..ace59df3
--- /dev/null
+++ b/frontend/src/hooks/useLocalStorage.ts
@@ -0,0 +1,25 @@
+import { useState, useCallback, type Dispatch, type SetStateAction } from "react";
+
+export function useLocalStorage(key: string, initialValue: T): [T, Dispatch>] {
+ const [storedValue, setStoredValue] = useState(() => {
+ try {
+ const item = window.localStorage.getItem(key);
+ return item ? (JSON.parse(item) as T) : initialValue;
+ } catch {
+ return initialValue;
+ }
+ });
+
+ const setValue: Dispatch> = useCallback(
+ (value) => {
+ setStoredValue((prev) => {
+ const nextValue = value instanceof Function ? value(prev) : value;
+ window.localStorage.setItem(key, JSON.stringify(nextValue));
+ return nextValue;
+ });
+ },
+ [key],
+ );
+
+ return [storedValue, setValue];
+}
diff --git a/frontend/src/hooks/useNotification.ts b/frontend/src/hooks/useNotification.ts
new file mode 100644
index 00000000..22864c93
--- /dev/null
+++ b/frontend/src/hooks/useNotification.ts
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+import { NotificationContext, type NotificationContextValue } from "contexts/NotificationContext";
+
+export function useNotification(): NotificationContextValue {
+ const ctx = useContext(NotificationContext);
+ if (!ctx) {
+ throw new Error("useNotification must be used within NotificationProvider");
+ }
+ return ctx;
+}
diff --git a/frontend/src/hooks/useRepository.ts b/frontend/src/hooks/useRepository.ts
new file mode 100644
index 00000000..374f64fa
--- /dev/null
+++ b/frontend/src/hooks/useRepository.ts
@@ -0,0 +1,10 @@
+import { useContext } from "react";
+import { RepositoryContext, type RepositoryContextValue } from "contexts/RepositoryContext";
+
+export function useRepository(): RepositoryContextValue {
+ const ctx = useContext(RepositoryContext);
+ if (!ctx) {
+ throw new Error("useRepository must be used within RepositoryProvider");
+ }
+ return ctx;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 00000000..49a06c2b
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,9 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "App";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/frontend/src/theme/Theme.ts b/frontend/src/theme/Theme.ts
new file mode 100644
index 00000000..44be5900
--- /dev/null
+++ b/frontend/src/theme/Theme.ts
@@ -0,0 +1,67 @@
+import { createTheme } from "@mui/material/styles";
+
+// Bootswatch Cosmo-inspired palette
+const Theme = createTheme({
+ palette: {
+ primary: {
+ main: "#2780e3",
+ contrastText: "#fff",
+ },
+ secondary: {
+ main: "#373a3c",
+ contrastText: "#fff",
+ },
+ success: {
+ main: "#3fb618",
+ contrastText: "#fff",
+ },
+ error: {
+ main: "#ff0039",
+ contrastText: "#fff",
+ },
+ warning: {
+ main: "#ff7518",
+ contrastText: "#fff",
+ },
+ info: {
+ main: "#9954bb",
+ contrastText: "#fff",
+ },
+ background: {
+ default: "#fff",
+ paper: "#fff",
+ },
+ text: {
+ primary: "#373a3c",
+ secondary: "#6c757d",
+ },
+ },
+ typography: {
+ fontFamily: "\"Source Sans Pro\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
+ fontSize: 14,
+ },
+ components: {
+ MuiButton: {
+ styleOverrides: {
+ root: {
+ textTransform: "none",
+ },
+ },
+ },
+ MuiTab: {
+ styleOverrides: {
+ root: {
+ textTransform: "none",
+ },
+ },
+ },
+ MuiDialog: {
+ defaultProps: {
+ maxWidth: "lg",
+ fullWidth: true,
+ },
+ },
+ },
+});
+
+export default Theme;
diff --git a/frontend/src/theme/status/StatusColors.ts b/frontend/src/theme/status/StatusColors.ts
new file mode 100644
index 00000000..670b135b
--- /dev/null
+++ b/frontend/src/theme/status/StatusColors.ts
@@ -0,0 +1,20 @@
+import { alpha } from "@mui/material/styles";
+import type { BuildStatus } from "api/types/BuildStatus";
+
+const base: Record = {
+ unknown: "#373a3c",
+ pending: "#ff7518",
+ building: "#ff7518",
+ failed: "#ff0039",
+ success: "#3fb618",
+};
+
+export const StatusColors = base;
+
+export const StatusBackgrounds: Record = Object.fromEntries(
+ Object.entries(base).map(([k, v]) => [k, alpha(v, 0.1)]),
+) as Record;
+
+export const StatusHeaderStyles: Record = Object.fromEntries(
+ Object.entries(base).map(([k, v]) => [k, { backgroundColor: v, color: "#fff" }]),
+) as Record;
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 00000000..dcb11e1c
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": "src"
+ },
+ "include": ["src"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 00000000..30fd3cd2
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,41 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tsconfigPaths from "vite-tsconfig-paths";
+import path from "path";
+import fs from "fs";
+import type { Plugin } from "vite";
+
+function renameIndexHtml(): Plugin {
+ return {
+ name: "rename-index-html",
+ closeBundle() {
+ const outDir = path.resolve(__dirname, "../package/share/ahriman/templates");
+ const src = path.join(outDir, "index.html");
+ const dest = path.join(outDir, "build-status-v2.jinja2");
+ if (fs.existsSync(src)) {
+ fs.renameSync(src, dest);
+ }
+ },
+ };
+}
+
+export default defineConfig({
+ plugins: [react(), tsconfigPaths(), renameIndexHtml()],
+ base: "/",
+ build: {
+ outDir: path.resolve(__dirname, "../package/share/ahriman/templates"),
+ emptyOutDir: false,
+ rollupOptions: {
+ output: {
+ entryFileNames: "static/[name].js",
+ chunkFileNames: "static/[name].js",
+ assetFileNames: "static/[name].[ext]",
+ },
+ },
+ },
+ server: {
+ proxy: {
+ "/api": "http://localhost:8080",
+ },
+ },
+});
diff --git a/package/archlinux/PKGBUILD b/package/archlinux/PKGBUILD
index ecc7907e..088d2645 100644
--- a/package/archlinux/PKGBUILD
+++ b/package/archlinux/PKGBUILD
@@ -9,7 +9,7 @@ arch=('any')
url="https://ahriman.readthedocs.io/"
license=('GPL-3.0-or-later')
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-bcrypt' 'python-filelock' 'python-inflection' 'python-pyelftools' 'python-requests')
-makedepends=('python-build' 'python-flit' 'python-installer' 'python-wheel')
+makedepends=('npm' 'python-build' 'python-flit' 'python-installer' 'python-wheel')
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgbase-$pkgver.tar.gz"
"$pkgbase.sysusers"
"$pkgbase.tmpfiles")
@@ -18,6 +18,10 @@ build() {
cd "$pkgbase-$pkgver"
python -m build --wheel --no-isolation
+
+ cd "frontend"
+ npm install
+ npm run build
}
package_ahriman() {
diff --git a/package/share/ahriman/settings/ahriman.ini.d/00-web.ini b/package/share/ahriman/settings/ahriman.ini.d/00-web.ini
index 804584a3..cd88ea51 100644
--- a/package/share/ahriman/settings/ahriman.ini.d/00-web.ini
+++ b/package/share/ahriman/settings/ahriman.ini.d/00-web.ini
@@ -46,6 +46,8 @@ host = 127.0.0.1
;service_only = no
; Path to directory with static files.
static_path = ${templates}/static
+; Jinja2 template name for the index page.
+;template = build-status.jinja2
; List of directories with templates.
templates[] = ${prefix}/share/ahriman/templates
; Path to unix socket. If none set, unix socket will be disabled.
diff --git a/package/share/ahriman/templates/build-status-v2.jinja2 b/package/share/ahriman/templates/build-status-v2.jinja2
new file mode 100644
index 00000000..cd1d49a1
--- /dev/null
+++ b/package/share/ahriman/templates/build-status-v2.jinja2
@@ -0,0 +1,17 @@
+
+
+
+
+
+ ahriman
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pyproject.toml b/pyproject.toml
index dc82e6ca..aae0ba62 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -118,11 +118,14 @@ include = [
"CONTRIBUTING.md",
"SECURITY.md",
"package",
+ "frontend",
"subpackages.py",
"web.png",
]
exclude = [
"package/archlinux",
+ "frontend/node_modules",
+ "frontend/package-lock.json",
]
[tool.flit.external-data]
diff --git a/src/ahriman/core/configuration/schema.py b/src/ahriman/core/configuration/schema.py
index d1284915..66038637 100644
--- a/src/ahriman/core/configuration/schema.py
+++ b/src/ahriman/core/configuration/schema.py
@@ -398,6 +398,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"path_exists": True,
"path_type": "dir",
},
+ "template": {
+ "type": "string",
+ "empty": False,
+ },
"templates": {
"type": "list",
"coerce": "list",
diff --git a/src/ahriman/core/utils.py b/src/ahriman/core/utils.py
index 6ac48ce8..40e6fe47 100644
--- a/src/ahriman/core/utils.py
+++ b/src/ahriman/core/utils.py
@@ -35,7 +35,7 @@ from enum import Enum
from filelock import FileLock
from pathlib import Path
from pwd import getpwuid
-from typing import Any, IO, TypeVar
+from typing import Any, IO, TypeVar, cast
from ahriman.core.exceptions import CalledProcessError, OptionError, UnsafeRunError
from ahriman.core.types import Comparable
@@ -285,16 +285,17 @@ def filelock(path: Path) -> Iterator[FileLock]:
lock_path.unlink(missing_ok=True)
-def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str, Any]:
+def filter_json(source: T, known_fields: Iterable[str] | None = None) -> T:
"""
- filter json object by fields used for json-to-object conversion
+ recursively filter json object removing ``None`` values and optionally filtering by known fields
Args:
- source(dict[str, Any]): raw json object
- known_fields(Iterable[str]): list of fields which have to be known for the target object
+ source(T): raw json object (dict, list, or scalar)
+ known_fields(Iterable[str] | None, optional): list of fields which have to be known for the target object
+ (Default value = None)
Returns:
- dict[str, Any]: json object without unknown and empty fields
+ T: json without ``None`` values
Examples:
This wrapper is mainly used for the dataclasses, thus the flow must be something like this::
@@ -306,7 +307,15 @@ def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str
>>> properties = filter_json(dump, known_fields)
>>> package = Package(**properties)
"""
- return {key: value for key, value in source.items() if key in known_fields and value is not None}
+ if isinstance(source, dict):
+ return cast(T, {
+ key: filter_json(value)
+ for key, value in source.items()
+ if value is not None and (known_fields is None or key in known_fields)
+ })
+ if isinstance(source, list):
+ return cast(T, [filter_json(value) for value in source if value is not None])
+ return source
def full_version(epoch: str | int | None, pkgver: str, pkgrel: str) -> str:
diff --git a/src/ahriman/web/schemas/__init__.py b/src/ahriman/web/schemas/__init__.py
index 2710f174..b04d2621 100644
--- a/src/ahriman/web/schemas/__init__.py
+++ b/src/ahriman/web/schemas/__init__.py
@@ -19,7 +19,9 @@
#
from ahriman.web.schemas.any_schema import AnySchema
from ahriman.web.schemas.aur_package_schema import AURPackageSchema
+from ahriman.web.schemas.auth_info_schema import AuthInfoSchema
from ahriman.web.schemas.auth_schema import AuthSchema
+from ahriman.web.schemas.auto_refresh_interval_schema import AutoRefreshIntervalSchema
from ahriman.web.schemas.build_options_schema import BuildOptionsSchema
from ahriman.web.schemas.changes_schema import ChangesSchema
from ahriman.web.schemas.configuration_schema import ConfigurationSchema
@@ -30,6 +32,7 @@ from ahriman.web.schemas.event_schema import EventSchema
from ahriman.web.schemas.event_search_schema import EventSearchSchema
from ahriman.web.schemas.file_schema import FileSchema
from ahriman.web.schemas.info_schema import InfoSchema
+from ahriman.web.schemas.info_v2_schema import InfoV2Schema
from ahriman.web.schemas.internal_status_schema import InternalStatusSchema
from ahriman.web.schemas.log_schema import LogSchema
from ahriman.web.schemas.login_schema import LoginSchema
diff --git a/src/ahriman/web/schemas/auth_info_schema.py b/src/ahriman/web/schemas/auth_info_schema.py
new file mode 100644
index 00000000..5c2904a0
--- /dev/null
+++ b/src/ahriman/web/schemas/auth_info_schema.py
@@ -0,0 +1,36 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from ahriman.web.apispec import Schema, fields
+
+
+class AuthInfoSchema(Schema):
+ """
+ authorization information schema
+ """
+
+ control = fields.String(required=True, metadata={
+ "description": "HTML control for login interface",
+ })
+ enabled = fields.Boolean(required=True, metadata={
+ "description": "Whether authentication is enabled or not",
+ })
+ username = fields.String(metadata={
+ "description": "Currently authenticated username if any",
+ })
diff --git a/src/ahriman/web/schemas/auto_refresh_interval_schema.py b/src/ahriman/web/schemas/auto_refresh_interval_schema.py
new file mode 100644
index 00000000..83d01b86
--- /dev/null
+++ b/src/ahriman/web/schemas/auto_refresh_interval_schema.py
@@ -0,0 +1,36 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from ahriman.web.apispec import Schema, fields
+
+
+class AutoRefreshIntervalSchema(Schema):
+ """
+ auto refresh interval schema
+ """
+
+ interval = fields.Integer(required=True, metadata={
+ "description": "Auto refresh interval in milliseconds",
+ })
+ is_active = fields.Boolean(required=True, metadata={
+ "description": "Whether this interval is the default active one",
+ })
+ text = fields.String(required=True, metadata={
+ "description": "Human readable interval description",
+ })
diff --git a/src/ahriman/web/schemas/info_schema.py b/src/ahriman/web/schemas/info_schema.py
index 877d9f2c..5e8bca46 100644
--- a/src/ahriman/web/schemas/info_schema.py
+++ b/src/ahriman/web/schemas/info_schema.py
@@ -27,7 +27,7 @@ class InfoSchema(Schema):
response service information schema
"""
- auth = fields.Boolean(dump_default=False, required=True, metadata={
+ auth = fields.Boolean(required=True, metadata={
"description": "Whether authentication is enabled or not",
})
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
diff --git a/src/ahriman/web/schemas/info_v2_schema.py b/src/ahriman/web/schemas/info_v2_schema.py
new file mode 100644
index 00000000..ea8f7007
--- /dev/null
+++ b/src/ahriman/web/schemas/info_v2_schema.py
@@ -0,0 +1,50 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from ahriman import __version__
+from ahriman.web.apispec import Schema, fields
+from ahriman.web.schemas.auth_info_schema import AuthInfoSchema
+from ahriman.web.schemas.auto_refresh_interval_schema import AutoRefreshIntervalSchema
+from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
+
+
+class InfoV2Schema(Schema):
+ """
+ response service information schema
+ """
+
+ auth = fields.Nested(AuthInfoSchema(), required=True, metadata={
+ "description": "Authorization descriptor",
+ })
+ autorefresh_intervals = fields.Nested(AutoRefreshIntervalSchema(many=True), metadata={
+ "description": "Available auto refresh intervals",
+ })
+ docs_enabled = fields.Boolean(metadata={
+ "description": "Whether API documentation is enabled",
+ })
+ index_url = fields.String(metadata={
+ "description": "URL to the repository index page",
+ })
+ repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
+ "description": "List of loaded repositories",
+ })
+ version = fields.String(required=True, metadata={
+ "description": "Service version",
+ "example": __version__,
+ })
diff --git a/src/ahriman/web/schemas/repository_id_schema.py b/src/ahriman/web/schemas/repository_id_schema.py
index e3dff167..5a0606cb 100644
--- a/src/ahriman/web/schemas/repository_id_schema.py
+++ b/src/ahriman/web/schemas/repository_id_schema.py
@@ -29,6 +29,10 @@ class RepositoryIdSchema(Schema):
"description": "Repository architecture",
"example": "x86_64",
})
+ id = fields.String(metadata={
+ "description": "Unique repository identifier",
+ "example": "aur-x86_64",
+ })
repository = fields.String(metadata={
"description": "Repository name",
"example": "aur",
diff --git a/src/ahriman/web/server_info.py b/src/ahriman/web/server_info.py
new file mode 100644
index 00000000..faf2733f
--- /dev/null
+++ b/src/ahriman/web/server_info.py
@@ -0,0 +1,69 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from collections.abc import Callable
+from typing import Any
+
+from ahriman import __version__
+from ahriman.core.auth.helpers import authorized_userid
+from ahriman.core.types import Comparable
+from ahriman.core.utils import pretty_interval
+from ahriman.web.apispec import aiohttp_apispec
+from ahriman.web.views.base import BaseView
+
+
+async def server_info(view: BaseView) -> dict[str, Any]:
+ """
+ generate server info which can be used in responses directly
+
+ Args:
+ view(BaseView): view of the request
+
+ Returns:
+ dict[str, Any]: server info as a json response
+ """
+ autorefresh_intervals = [
+ {
+ "interval": interval * 1000, # milliseconds
+ "is_active": index == 0, # first element is always default
+ "text": pretty_interval(interval),
+ }
+ for index, interval in enumerate(view.configuration.getintlist("web", "autorefresh_intervals", fallback=[]))
+ if interval > 0 # special case if 0 exists and first, refresh will not be turned on by default
+ ]
+ comparator: Callable[[dict[str, Any]], Comparable] = lambda interval: interval["interval"]
+
+ return {
+ "auth": {
+ "control": view.validator.auth_control,
+ "enabled": view.validator.enabled,
+ "username": await authorized_userid(view.request),
+ },
+ "autorefresh_intervals": sorted(autorefresh_intervals, key=comparator),
+ "docs_enabled": aiohttp_apispec is not None,
+ "index_url": view.configuration.get("web", "index_url", fallback=None),
+ "repositories": [
+ {
+ "id": repository_id.id,
+ **repository_id.view(),
+ }
+ for repository_id in sorted(view.services)
+ ],
+ "version": __version__,
+ }
diff --git a/src/ahriman/web/views/api/swagger.py b/src/ahriman/web/views/api/swagger.py
index f60566ca..36bbc278 100644
--- a/src/ahriman/web/views/api/swagger.py
+++ b/src/ahriman/web/views/api/swagger.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import Response, json_response
+from aiohttp.web import Response
from collections.abc import Callable
from typing import ClassVar
@@ -96,4 +96,4 @@ class SwaggerView(BaseView):
for key, value in schema.items()
}
- return json_response(spec)
+ return self.json_response(spec)
diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py
index 4a28e44a..ba93b746 100644
--- a/src/ahriman/web/views/base.py
+++ b/src/ahriman/web/views/base.py
@@ -17,10 +17,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNotFound, Request, StreamResponse, View
+from aiohttp.web import HTTPBadRequest, HTTPNotFound, Request, Response, StreamResponse, View, json_response
from aiohttp_cors import CorsViewMixin
from collections.abc import Awaitable, Callable
-from typing import ClassVar, TypeVar
+from typing import Any, ClassVar, TypeVar
from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration
@@ -29,6 +29,7 @@ from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.sign.gpg import GPG
from ahriman.core.spawn import Spawn
from ahriman.core.status.watcher import Watcher
+from ahriman.core.utils import filter_json
from ahriman.models.repository_id import RepositoryId
from ahriman.models.user_access import UserAccess
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
@@ -162,6 +163,20 @@ class BaseView(View, CorsViewMixin):
raise KeyError(f"Key {key} is missing or empty") from None
return value
+ @staticmethod
+ def json_response(data: dict[str, Any] | list[Any], **kwargs: Any) -> Response:
+ """
+ filter and convert data and return :class:`aiohttp.web.Response` object
+
+ Args:
+ data(dict[str, Any]): response in json format
+ **kwargs(Any): keyword arguments for :func:`aiohttp.web.json_response` function
+
+ Returns:
+ Response: generated response object
+ """
+ return json_response(filter_json(data), **kwargs)
+
# pylint: disable=not-callable,protected-access
async def head(self) -> StreamResponse:
"""
diff --git a/src/ahriman/web/views/index.py b/src/ahriman/web/views/index.py
index ec9dfd07..b2046441 100644
--- a/src/ahriman/web/views/index.py
+++ b/src/ahriman/web/views/index.py
@@ -19,12 +19,11 @@
#
import aiohttp_jinja2
-from typing import Any, ClassVar
+from aiohttp.web import Response
+from typing import ClassVar
-from ahriman.core.auth.helpers import authorized_userid
-from ahriman.core.utils import pretty_interval
from ahriman.models.user_access import UserAccess
-from ahriman.web.apispec import aiohttp_apispec
+from ahriman.web.server_info import server_info
from ahriman.web.views.base import BaseView
@@ -48,6 +47,7 @@ class IndexView(BaseView):
* id - unique repository identifier, string, required
* repository - repository name, string, required
* architecture - repository architecture, string, required
+ * version - service version, string, required
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
@@ -56,41 +56,14 @@ class IndexView(BaseView):
GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
ROUTES = ["/", "/index.html"]
- @aiohttp_jinja2.template("build-status.jinja2")
- async def get(self) -> dict[str, Any]:
+ async def get(self) -> Response:
"""
process get request. No parameters supported here
Returns:
- dict[str, Any]: parameters for jinja template
+ Response: 200 with rendered index page
"""
- auth_username = await authorized_userid(self.request)
- auth = {
- "control": self.validator.auth_control,
- "enabled": self.validator.enabled,
- "username": auth_username,
- }
+ context = await server_info(self)
- autorefresh_intervals = [
- {
- "interval": interval * 1000, # milliseconds
- "is_active": index == 0, # first element is always default
- "text": pretty_interval(interval),
- }
- for index, interval in enumerate(self.configuration.getintlist("web", "autorefresh_intervals", fallback=[]))
- if interval > 0 # special case if 0 exists and first, refresh will not be turned on by default
- ]
-
- return {
- "auth": auth,
- "autorefresh_intervals": sorted(autorefresh_intervals, key=lambda interval: interval["interval"]),
- "docs_enabled": aiohttp_apispec is not None,
- "index_url": self.configuration.get("web", "index_url", fallback=None),
- "repositories": [
- {
- "id": repository.id,
- **repository.view(),
- }
- for repository in sorted(self.services)
- ]
- }
+ template = self.configuration.get("web", "template", fallback="build-status.jinja2")
+ return aiohttp_jinja2.render_template(template, self.request, context)
diff --git a/src/ahriman/web/views/v1/auditlog/events.py b/src/ahriman/web/views/v1/auditlog/events.py
index 53a945a0..968e174c 100644
--- a/src/ahriman/web/views/v1/auditlog/events.py
+++ b/src/ahriman/web/views/v1/auditlog/events.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman.models.event import Event
@@ -70,7 +70,7 @@ class EventsView(BaseView):
events = self.service().event_get(event, object_id, from_date, to_date, limit, offset)
response = [event.view() for event in events]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Audit log"],
diff --git a/src/ahriman/web/views/v1/distributed/workers.py b/src/ahriman/web/views/v1/distributed/workers.py
index 56bb1cdf..d5de4f27 100644
--- a/src/ahriman/web/views/v1/distributed/workers.py
+++ b/src/ahriman/web/views/v1/distributed/workers.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from collections.abc import Callable
from typing import ClassVar
@@ -78,7 +78,7 @@ class WorkersView(BaseView):
comparator: Callable[[Worker], Comparable] = lambda item: item.identifier
response = [worker.view() for worker in sorted(workers, key=comparator)]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Distributed"],
diff --git a/src/ahriman/web/views/v1/packages/changes.py b/src/ahriman/web/views/v1/packages/changes.py
index 22b504ea..00b3f619 100644
--- a/src/ahriman/web/views/v1/packages/changes.py
+++ b/src/ahriman/web/views/v1/packages/changes.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman.models.changes import Changes
@@ -65,7 +65,7 @@ class ChangesView(StatusViewGuard, BaseView):
changes = self.service(package_base=package_base).package_changes_get(package_base)
- return json_response(changes.view())
+ return self.json_response(changes.view())
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/dependencies.py b/src/ahriman/web/views/v1/packages/dependencies.py
index 611f0167..9d4de53e 100644
--- a/src/ahriman/web/views/v1/packages/dependencies.py
+++ b/src/ahriman/web/views/v1/packages/dependencies.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman.models.dependencies import Dependencies
@@ -65,7 +65,7 @@ class DependenciesView(StatusViewGuard, BaseView):
dependencies = self.service(package_base=package_base).package_dependencies_get(package_base)
- return json_response(dependencies.view())
+ return self.json_response(dependencies.view())
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/logs.py b/src/ahriman/web/views/v1/packages/logs.py
index 5d712d83..26f4bf10 100644
--- a/src/ahriman/web/views/v1/packages/logs.py
+++ b/src/ahriman/web/views/v1/packages/logs.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response
from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError
@@ -99,7 +99,7 @@ class LogsView(StatusViewGuard, BaseView):
"status": status.view(),
"logs": "\n".join(f"[{pretty_datetime(log_record.created)}] {log_record.message}" for log_record in logs)
}
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/package.py b/src/ahriman/web/views/v1/packages/package.py
index d119c8cd..79e2546b 100644
--- a/src/ahriman/web/views/v1/packages/package.py
+++ b/src/ahriman/web/views/v1/packages/package.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response
from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError
@@ -105,7 +105,7 @@ class PackageView(StatusViewGuard, BaseView):
"repository": repository_id.view(),
}
]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/packages.py b/src/ahriman/web/views/v1/packages/packages.py
index e914d54d..a93d82b9 100644
--- a/src/ahriman/web/views/v1/packages/packages.py
+++ b/src/ahriman/web/views/v1/packages/packages.py
@@ -19,7 +19,7 @@
#
import itertools
-from aiohttp.web import HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPNoContent, Response
from collections.abc import Callable
from typing import ClassVar
@@ -78,7 +78,7 @@ class PackagesView(StatusViewGuard, BaseView):
} for package, status in itertools.islice(sorted(packages, key=comparator), offset, stop)
]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/patch.py b/src/ahriman/web/views/v1/packages/patch.py
index 8850f5e8..9ec13bd4 100644
--- a/src/ahriman/web/views/v1/packages/patch.py
+++ b/src/ahriman/web/views/v1/packages/patch.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPNoContent, HTTPNotFound, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -89,4 +89,4 @@ class PatchView(StatusViewGuard, BaseView):
if selected is None:
raise HTTPNotFound(reason=f"Patch {variable} is unknown")
- return json_response(selected.view())
+ return self.json_response(selected.view())
diff --git a/src/ahriman/web/views/v1/packages/patches.py b/src/ahriman/web/views/v1/packages/patches.py
index 031af8b5..dd8346e0 100644
--- a/src/ahriman/web/views/v1/packages/patches.py
+++ b/src/ahriman/web/views/v1/packages/patches.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -60,7 +60,7 @@ class PatchesView(StatusViewGuard, BaseView):
patches = self.service().package_patches_get(package_base, None)
response = [patch.view() for patch in patches]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/service/add.py b/src/ahriman/web/views/v1/service/add.py
index 9307bb64..a0356f77 100644
--- a/src/ahriman/web/views/v1/service/add.py
+++ b/src/ahriman/web/views/v1/service/add.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -78,4 +78,4 @@ class AddView(BaseView):
refresh=data.get("refresh", False),
)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/config.py b/src/ahriman/web/views/v1/service/config.py
index bdb74920..0801bce4 100644
--- a/src/ahriman/web/views/v1/service/config.py
+++ b/src/ahriman/web/views/v1/service/config.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPNoContent, Response
from typing import ClassVar
from ahriman.core.formatters import ConfigurationPrinter
@@ -64,7 +64,7 @@ class ConfigView(BaseView):
for key, value in values.items()
if key not in ConfigurationPrinter.HIDE_KEYS
]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Actions"],
diff --git a/src/ahriman/web/views/v1/service/pgp.py b/src/ahriman/web/views/v1/service/pgp.py
index fd7b1b98..7c682d91 100644
--- a/src/ahriman/web/views/v1/service/pgp.py
+++ b/src/ahriman/web/views/v1/service/pgp.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -71,7 +71,7 @@ class PGPView(BaseView):
except Exception:
raise HTTPNotFound(reason=f"Key {key} is unknown")
- return json_response({"key": key})
+ return self.json_response({"key": key})
@apidocs(
tags=["Actions"],
@@ -100,4 +100,4 @@ class PGPView(BaseView):
process_id = self.spawner.key_import(key, data.get("server"))
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/process.py b/src/ahriman/web/views/v1/service/process.py
index 6e32f685..bd8d42e1 100644
--- a/src/ahriman/web/views/v1/service/process.py
+++ b/src/ahriman/web/views/v1/service/process.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPNotFound, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -66,4 +66,4 @@ class ProcessView(BaseView):
"is_alive": is_alive,
}
- return json_response(response)
+ return self.json_response(response)
diff --git a/src/ahriman/web/views/v1/service/rebuild.py b/src/ahriman/web/views/v1/service/rebuild.py
index a0469b45..0e88749d 100644
--- a/src/ahriman/web/views/v1/service/rebuild.py
+++ b/src/ahriman/web/views/v1/service/rebuild.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -74,4 +74,4 @@ class RebuildView(BaseView):
increment=data.get("increment", True),
)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/remove.py b/src/ahriman/web/views/v1/service/remove.py
index b2e1668e..8a21589a 100644
--- a/src/ahriman/web/views/v1/service/remove.py
+++ b/src/ahriman/web/views/v1/service/remove.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -67,4 +67,4 @@ class RemoveView(BaseView):
repository_id = self.repository_id()
process_id = self.spawner.packages_remove(repository_id, packages)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/request.py b/src/ahriman/web/views/v1/service/request.py
index 93ae3f38..55eb2e28 100644
--- a/src/ahriman/web/views/v1/service/request.py
+++ b/src/ahriman/web/views/v1/service/request.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -78,4 +78,4 @@ class RequestView(BaseView):
refresh=False, # refresh doesn't work here
)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/search.py b/src/ahriman/web/views/v1/service/search.py
index 08b958e8..bd8ffb41 100644
--- a/src/ahriman/web/views/v1/service/search.py
+++ b/src/ahriman/web/views/v1/service/search.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response
from collections.abc import Callable
from typing import ClassVar
@@ -83,4 +83,4 @@ class SearchView(BaseView):
"description": package.description,
} for package in sorted(packages, key=comparator)
]
- return json_response(response)
+ return self.json_response(response)
diff --git a/src/ahriman/web/views/v1/service/update.py b/src/ahriman/web/views/v1/service/update.py
index 5eb14eaa..bc95b457 100644
--- a/src/ahriman/web/views/v1/service/update.py
+++ b/src/ahriman/web/views/v1/service/update.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -75,4 +75,4 @@ class UpdateView(BaseView):
refresh=data.get("refresh", False),
)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/status/info.py b/src/ahriman/web/views/v1/status/info.py
index 7e92cd63..e1635f91 100644
--- a/src/ahriman/web/views/v1/status/info.py
+++ b/src/ahriman/web/views/v1/status/info.py
@@ -17,13 +17,13 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import Response, json_response
+from aiohttp.web import Response
from typing import ClassVar
-from ahriman import __version__
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import InfoSchema
+from ahriman.web.server_info import server_info
from ahriman.web.views.base import BaseView
@@ -52,13 +52,11 @@ class InfoView(BaseView):
Returns:
Response: 200 with service information object
"""
+ info = await server_info(self)
response = {
- "auth": self.validator.enabled,
- "repositories": [
- repository_id.view()
- for repository_id in sorted(self.services)
- ],
- "version": __version__,
+ "auth": info["auth"]["enabled"],
+ "repositories": info["repositories"],
+ "version": info["version"],
}
- return json_response(response)
+ return self.json_response(response)
diff --git a/src/ahriman/web/views/v1/status/repositories.py b/src/ahriman/web/views/v1/status/repositories.py
index 5e3ecc6a..81e333e3 100644
--- a/src/ahriman/web/views/v1/status/repositories.py
+++ b/src/ahriman/web/views/v1/status/repositories.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import Response, json_response
+from aiohttp.web import Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -56,4 +56,4 @@ class RepositoriesView(BaseView):
for repository_id in sorted(self.services)
]
- return json_response(repositories)
+ return self.json_response(repositories)
diff --git a/src/ahriman/web/views/v1/status/status.py b/src/ahriman/web/views/v1/status/status.py
index bc72f709..55a851a0 100644
--- a/src/ahriman/web/views/v1/status/status.py
+++ b/src/ahriman/web/views/v1/status/status.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman import __version__
@@ -75,7 +75,7 @@ class StatusView(StatusViewGuard, BaseView):
version=__version__,
)
- return json_response(status.view())
+ return self.json_response(status.view())
@apidocs(
tags=["Status"],
diff --git a/src/ahriman/web/views/v2/packages/logs.py b/src/ahriman/web/views/v2/packages/logs.py
index b2ab43a1..231e7091 100644
--- a/src/ahriman/web/views/v2/packages/logs.py
+++ b/src/ahriman/web/views/v2/packages/logs.py
@@ -19,7 +19,7 @@
#
import itertools
-from aiohttp.web import Response, json_response
+from aiohttp.web import Response
from dataclasses import replace
from typing import ClassVar
@@ -31,8 +31,7 @@ from ahriman.web.views.status_view_guard import StatusViewGuard
class LogsView(StatusViewGuard, BaseView):
- """ else:
-
+ """
package logs web view
Attributes:
@@ -80,4 +79,4 @@ class LogsView(StatusViewGuard, BaseView):
]
response = [log_record.view() for log_record in logs]
- return json_response(response)
+ return self.json_response(response)
diff --git a/src/ahriman/web/views/v2/status/__init__.py b/src/ahriman/web/views/v2/status/__init__.py
new file mode 100644
index 00000000..cddc28d6
--- /dev/null
+++ b/src/ahriman/web/views/v2/status/__init__.py
@@ -0,0 +1,19 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
diff --git a/src/ahriman/web/views/v2/status/info.py b/src/ahriman/web/views/v2/status/info.py
new file mode 100644
index 00000000..c486173b
--- /dev/null
+++ b/src/ahriman/web/views/v2/status/info.py
@@ -0,0 +1,56 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from aiohttp.web import Response
+from typing import ClassVar
+
+from ahriman.models.user_access import UserAccess
+from ahriman.web.apispec.decorators import apidocs
+from ahriman.web.schemas import InfoV2Schema
+from ahriman.web.server_info import server_info
+from ahriman.web.views.base import BaseView
+
+
+class InfoView(BaseView):
+ """
+ web service information view
+
+ Attributes:
+ GET_PERMISSION(UserAccess): (class attribute) get permissions of self
+ """
+
+ GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
+ ROUTES = ["/api/v2/info"]
+
+ @apidocs(
+ tags=["Status"],
+ summary="Service information",
+ description="Perform basic service health check and returns its information",
+ permission=GET_PERMISSION,
+ schema=InfoV2Schema,
+ )
+ async def get(self) -> Response:
+ """
+ get service information
+
+ Returns:
+ Response: 200 with service information object
+ """
+ response = await server_info(self)
+ return self.json_response(response)
diff --git a/subpackages.py b/subpackages.py
index ec871c39..62a9f13e 100644
--- a/subpackages.py
+++ b/subpackages.py
@@ -45,10 +45,12 @@ SUBPACKAGES = {
prefix / "lib" / "systemd" / "system" / "ahriman-web.service",
prefix / "lib" / "systemd" / "system" / "ahriman-web@.service",
prefix / "share" / "ahriman" / "settings" / "ahriman.ini.d" / "00-web.ini",
- prefix / "share" / "ahriman" / "templates" / "api.jinja2",
prefix / "share" / "ahriman" / "templates" / "build-status",
- prefix / "share" / "ahriman" / "templates" / "build-status.jinja2",
+ prefix / "share" / "ahriman" / "templates" / "build-status.jinja",
+ prefix / "share" / "ahriman" / "templates" / "build-status-v2.jinja",
+ prefix / "share" / "ahriman" / "templates" / "api.jinja2",
prefix / "share" / "ahriman" / "templates" / "error.jinja2",
+ prefix / "share" / "ahriman" / "templates" / "static",
site_packages / "ahriman" / "application" / "handlers" / "web.py",
site_packages / "ahriman" / "core" / "auth",
site_packages / "ahriman" / "web",
diff --git a/tests/ahriman/core/test_utils.py b/tests/ahriman/core/test_utils.py
index 10a84aea..5d1c87cd 100644
--- a/tests/ahriman/core/test_utils.py
+++ b/tests/ahriman/core/test_utils.py
@@ -549,9 +549,6 @@ def test_walk(resource_path_root: Path) -> None:
must traverse directory recursively
"""
expected = sorted([
- resource_path_root / "core" / "ahriman.ini",
- resource_path_root / "core" / "arcanisrepo.files.tar.gz",
- resource_path_root / "core" / "logging.ini",
resource_path_root / "models" / "aur_error",
resource_path_root / "models" / "big_file_checksum",
resource_path_root / "models" / "empty_file_checksum",
@@ -569,26 +566,6 @@ def test_walk(resource_path_root: Path) -> None:
resource_path_root / "models" / "package_yay_pkgbuild",
resource_path_root / "models" / "pkgbuild",
resource_path_root / "models" / "utf8",
- resource_path_root / "web" / "templates" / "build-status" / "alerts.jinja2",
- resource_path_root / "web" / "templates" / "build-status" / "dashboard.jinja2",
- resource_path_root / "web" / "templates" / "build-status" / "key-import-modal.jinja2",
- resource_path_root / "web" / "templates" / "build-status" / "login-modal.jinja2",
- resource_path_root / "web" / "templates" / "build-status" / "package-add-modal.jinja2",
- resource_path_root / "web" / "templates" / "build-status" / "package-info-modal.jinja2",
- resource_path_root / "web" / "templates" / "build-status" / "package-rebuild-modal.jinja2",
- resource_path_root / "web" / "templates" / "build-status" / "table.jinja2",
- resource_path_root / "web" / "templates" / "static" / "favicon.ico",
- resource_path_root / "web" / "templates" / "static" / "logo.svg",
- resource_path_root / "web" / "templates" / "utils" / "bootstrap-scripts.jinja2",
- resource_path_root / "web" / "templates" / "utils" / "style.jinja2",
- resource_path_root / "web" / "templates" / "api.jinja2",
- resource_path_root / "web" / "templates" / "build-status.jinja2",
- resource_path_root / "web" / "templates" / "email-index.jinja2",
- resource_path_root / "web" / "templates" / "error.jinja2",
- resource_path_root / "web" / "templates" / "repo-index.jinja2",
- resource_path_root / "web" / "templates" / "rss.jinja2",
- resource_path_root / "web" / "templates" / "shell",
- resource_path_root / "web" / "templates" / "telegram-index.jinja2",
])
- local_files = list(sorted(walk(resource_path_root)))
+ local_files = list(path for path in sorted(walk(resource_path_root)) if path.parent.name == "models")
assert local_files == expected
diff --git a/tests/ahriman/web/schemas/test_auth_info_schema.py b/tests/ahriman/web/schemas/test_auth_info_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_auth_info_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/schemas/test_auto_refresh_interval_schema.py b/tests/ahriman/web/schemas/test_auto_refresh_interval_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_auto_refresh_interval_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/schemas/test_info_v2_schema.py b/tests/ahriman/web/schemas/test_info_v2_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_info_v2_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/test_server_info.py b/tests/ahriman/web/test_server_info.py
new file mode 100644
index 00000000..6cbec8f0
--- /dev/null
+++ b/tests/ahriman/web/test_server_info.py
@@ -0,0 +1,26 @@
+import pytest
+
+from aiohttp.web import Application
+
+from ahriman import __version__
+from ahriman.models.repository_id import RepositoryId
+from ahriman.web.server_info import server_info
+from ahriman.web.views.index import IndexView
+
+
+async def test_server_info(application: Application, repository_id: RepositoryId) -> None:
+ """
+ must generate server info
+ """
+ request = pytest.helpers.request(application, "", "GET")
+ view = IndexView(request)
+ result = await server_info(view)
+
+ assert result["repositories"] == [{"id": repository_id.id, **repository_id.view()}]
+ assert not result["auth"]["enabled"]
+ assert result["auth"]["username"] is None
+ assert result["auth"]["control"]
+ assert result["version"] == __version__
+ assert result["autorefresh_intervals"] == []
+ assert result["docs_enabled"]
+ assert result["index_url"] is None
diff --git a/tests/ahriman/web/views/v1/status/test_view_v1_status_info.py b/tests/ahriman/web/views/v1/status/test_view_v1_status_info.py
index ff741482..14bc82b0 100644
--- a/tests/ahriman/web/views/v1/status/test_view_v1_status_info.py
+++ b/tests/ahriman/web/views/v1/status/test_view_v1_status_info.py
@@ -35,6 +35,6 @@ async def test_get(client: TestClient, repository_id: RepositoryId) -> None:
json = await response.json()
assert not response_schema.validate(json)
- assert json["repositories"] == [repository_id.view()]
+ assert json["repositories"] == [{"id": repository_id.id, **repository_id.view()}]
assert not json["auth"]
assert json["version"] == __version__
diff --git a/tests/ahriman/web/views/v2/status/test_view_v2_status_info.py b/tests/ahriman/web/views/v2/status/test_view_v2_status_info.py
new file mode 100644
index 00000000..b77ed5e5
--- /dev/null
+++ b/tests/ahriman/web/views/v2/status/test_view_v2_status_info.py
@@ -0,0 +1,43 @@
+import pytest
+
+from aiohttp.test_utils import TestClient
+
+from ahriman import __version__
+from ahriman.models.repository_id import RepositoryId
+from ahriman.models.user_access import UserAccess
+from ahriman.web.views.v2.status.info import InfoView
+
+
+async def test_get_permission() -> None:
+ """
+ must return correct permission for the request
+ """
+ for method in ("GET",):
+ request = pytest.helpers.request("", "", method)
+ assert await InfoView.get_permission(request) == UserAccess.Unauthorized
+
+
+def test_routes() -> None:
+ """
+ must return correct routes
+ """
+ assert InfoView.ROUTES == ["/api/v2/info"]
+
+
+async def test_get(client: TestClient, repository_id: RepositoryId) -> None:
+ """
+ must return service information
+ """
+ response_schema = pytest.helpers.schema_response(InfoView.get)
+
+ response = await client.get("/api/v2/info")
+ assert response.ok
+ json = await response.json()
+ assert not response_schema.validate(json)
+
+ assert json["repositories"] == [{"id": repository_id.id, **repository_id.view()}]
+ assert not json["auth"]["enabled"]
+ assert json["auth"]["control"]
+ assert json["version"] == __version__
+ assert json["autorefresh_intervals"] == []
+ assert json["docs_enabled"]
diff --git a/tox.toml b/tox.toml
index 390f5c84..432f03fe 100644
--- a/tox.toml
+++ b/tox.toml
@@ -78,6 +78,9 @@ commands = [
[env.check]
description = "Run common checks like linter, mypy, etc"
+allowlist_externals = [
+ "npx",
+]
dependency_groups = [
"check",
]
@@ -123,6 +126,11 @@ commands = [
"--non-interactive",
"--package", "{[project]name}",
],
+ [
+ "npx",
+ "eslint",
+ "frontend",
+ ],
]
[env.docs]
@@ -193,6 +201,24 @@ commands = [
],
]
+[env.frontend]
+description = "Build frontend HTML and JS"
+allowlist_externals = [
+ "npm",
+]
+change_dir = "frontend"
+commands = [
+ [
+ "npm",
+ "install",
+ ],
+ [
+ "npm",
+ "run",
+ "build",
+ ],
+]
+
[env.html]
description = "Generate html documentation"
dependency_groups = [