+
+
+ ahriman
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 00000000..fd465971
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "ahriman-frontend",
+ "private": true,
+ "type": "module",
+ "version": "2.20.0-rc4",
+ "scripts": {
+ "build": "tsc && vite build",
+ "dev": "vite",
+ "lint": "eslint src/",
+ "lint:fix": "eslint --fix src/",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@emotion/react": "^11.11.0",
+ "@emotion/styled": "^11.11.0",
+ "@mui/icons-material": "^7.3.8",
+ "@mui/material": "^7.3.8",
+ "@mui/x-data-grid": "^8.27.3",
+ "@tanstack/react-query": "^5.0.0",
+ "chart.js": "^4.5.0",
+ "highlight.js": "^11.11.0",
+ "react": "^19.2.4",
+ "react-chartjs-2": "^5.2.0",
+ "react-dom": "^19.2.4"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.3",
+ "@stylistic/eslint-plugin": "^5.9.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.0.0",
+ "eslint": "^9.39.3",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "eslint-plugin-simple-import-sort": "^12.1.1",
+ "typescript": "^5.3.0",
+ "typescript-eslint": "^8.56.1",
+ "vite": "^7.3.1",
+ "vite-tsconfig-paths": "^6.1.1"
+ }
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 00000000..7ec3be07
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import CssBaseline from "@mui/material/CssBaseline";
+import { ThemeProvider } from "@mui/material/styles";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import AppLayout from "components/layout/AppLayout";
+import { AuthProvider } from "contexts/AuthProvider";
+import { ClientProvider } from "contexts/ClientProvider";
+import { NotificationProvider } from "contexts/NotificationProvider";
+import { RepositoryProvider } from "contexts/RepositoryProvider";
+import type React from "react";
+import Theme from "theme/Theme";
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ retry: 1,
+ },
+ },
+});
+
+export default function App(): React.JSX.Element {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/api/client/AhrimanClient.ts b/frontend/src/api/client/AhrimanClient.ts
new file mode 100644
index 00000000..7db9aca6
--- /dev/null
+++ b/frontend/src/api/client/AhrimanClient.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { BaseClient } from "api/client/BaseClient";
+import { FetchMixin } from "api/client/FetchMixin";
+import { ServiceMixin } from "api/client/ServiceMixin";
+import type { LoginRequest } from "models/LoginRequest";
+import { applyMixins } from "utils";
+
+/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
+export class AhrimanClient extends BaseClient {
+
+ async login(data: LoginRequest): Promise {
+ return this.request("/api/v1/login", { method: "POST", json: data });
+ }
+
+ async logout(): Promise {
+ return this.request("/api/v1/logout", { method: "POST" });
+ }
+}
+
+export interface AhrimanClient extends FetchMixin, ServiceMixin {}
+/* eslint-enable @typescript-eslint/no-unsafe-declaration-merging */
+applyMixins(AhrimanClient, [FetchMixin, ServiceMixin]);
diff --git a/frontend/src/api/client/ApiError.ts b/frontend/src/api/client/ApiError.ts
new file mode 100644
index 00000000..1462d48b
--- /dev/null
+++ b/frontend/src/api/client/ApiError.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export class ApiError extends Error {
+ status: number;
+ statusText: string;
+ body: string;
+
+ constructor(status: number, statusText: string, body: string) {
+ super(`${status} ${statusText}`);
+ this.status = status;
+ this.statusText = statusText;
+ this.body = body;
+ }
+
+ get detail(): string {
+ try {
+ const parsed = JSON.parse(this.body) as Record;
+ return parsed.error ?? (this.body || this.message);
+ } catch {
+ return this.body || this.message;
+ }
+ }
+
+ static errorDetail(exception: unknown): string {
+ return exception instanceof ApiError ? exception.detail : String(exception);
+ }
+}
diff --git a/frontend/src/api/client/BaseClient.ts b/frontend/src/api/client/BaseClient.ts
new file mode 100644
index 00000000..38f6b9e0
--- /dev/null
+++ b/frontend/src/api/client/BaseClient.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { ApiError } from "api/client/ApiError";
+import type { RequestOptions } from "api/client/RequestOptions";
+
+export class BaseClient {
+
+ protected async request(url: string, options: RequestOptions = {}): Promise {
+ const { method, query, json } = options;
+
+ let fullUrl = url;
+ if (query) {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(query)) {
+ if (value !== undefined && value !== null) {
+ params.set(key, String(value));
+ }
+ }
+ fullUrl += `?${params.toString()}`;
+ }
+
+ const headers: Record = {
+ Accept: "application/json",
+ };
+ if (json !== undefined) {
+ headers["Content-Type"] = "application/json";
+ }
+
+ const requestInit: RequestInit = {
+ method: method || (json ? "POST" : "GET"),
+ headers,
+ };
+
+ if (json !== undefined) {
+ requestInit.body = JSON.stringify(json);
+ }
+
+ const response = await fetch(fullUrl, requestInit);
+
+ if (!response.ok) {
+ const body = await response.text();
+ throw new ApiError(response.status, response.statusText, body);
+ }
+
+ const contentType = response.headers.get("Content-Type") ?? "";
+ if (contentType.includes("application/json")) {
+ return await response.json() as T;
+ }
+ return await response.text() as T;
+ }
+}
diff --git a/frontend/src/api/client/FetchMixin.ts b/frontend/src/api/client/FetchMixin.ts
new file mode 100644
index 00000000..f35ca633
--- /dev/null
+++ b/frontend/src/api/client/FetchMixin.ts
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { BaseClient } from "api/client/BaseClient";
+import type { Changes } from "models/Changes";
+import type { Dependencies } from "models/Dependencies";
+import type { Event } from "models/Event";
+import type { InfoResponse } from "models/InfoResponse";
+import type { InternalStatus } from "models/InternalStatus";
+import type { LogRecord } from "models/LogRecord";
+import type { PackageStatus } from "models/PackageStatus";
+import type { Patch } from "models/Patch";
+import { RepositoryId } from "models/RepositoryId";
+
+export class FetchMixin extends BaseClient {
+
+ async fetchPackage(packageBase: string, repository: RepositoryId): Promise {
+ return this.request(`/api/v1/packages/${encodeURIComponent(packageBase)}`, {
+ query: repository.toQuery(),
+ });
+ }
+
+ async fetchPackageChanges(packageBase: string, repository: RepositoryId): Promise {
+ return this.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/changes`, {
+ query: repository.toQuery(),
+ });
+ }
+
+ async fetchPackageDependencies(packageBase: string, repository: RepositoryId): Promise {
+ return this.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/dependencies`, {
+ query: repository.toQuery(),
+ });
+ }
+
+ async fetchPackageEvents(repository: RepositoryId, objectId?: string, limit?: number): Promise {
+ const query: Record = repository.toQuery();
+ if (objectId) {
+ query.object_id = objectId;
+ }
+ if (limit) {
+ query.limit = limit;
+ }
+ return this.request("/api/v1/events", { query });
+ }
+
+ async fetchPackageLogs(
+ packageBase: string,
+ repository: RepositoryId,
+ version?: string,
+ processId?: string,
+ head?: boolean,
+ ): Promise {
+ const query: Record = { ...repository.toQuery() };
+ if (version) {
+ query.version = version;
+ }
+ if (processId) {
+ query.process_id = processId;
+ }
+ if (head) {
+ query.head = true;
+ }
+ return this.request(`/api/v2/packages/${encodeURIComponent(packageBase)}/logs`, { query });
+ }
+
+ async fetchPackagePatches(packageBase: string): Promise {
+ return this.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches`);
+ }
+
+ async fetchPackages(repository: RepositoryId): Promise {
+ return this.request("/api/v1/packages", { query: repository.toQuery() });
+ }
+
+ async fetchServerInfo(): Promise {
+ const info = await this.request("/api/v2/info");
+ info.repositories = info.repositories.map(repositories =>
+ new RepositoryId(repositories.architecture, repositories.repository),
+ );
+ return info;
+ }
+
+ async fetchServerStatus(repository: RepositoryId): Promise {
+ return this.request("/api/v1/status", { query: repository.toQuery() });
+ }
+}
diff --git a/frontend/src/api/client/RequestOptions.ts b/frontend/src/api/client/RequestOptions.ts
new file mode 100644
index 00000000..ae4f3f8b
--- /dev/null
+++ b/frontend/src/api/client/RequestOptions.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface RequestOptions {
+ method?: string;
+ query?: Record;
+ json?: unknown;
+}
diff --git a/frontend/src/api/client/ServiceMixin.ts b/frontend/src/api/client/ServiceMixin.ts
new file mode 100644
index 00000000..35cc7b0f
--- /dev/null
+++ b/frontend/src/api/client/ServiceMixin.ts
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { BaseClient } from "api/client/BaseClient";
+import type { AURPackage } from "models/AURPackage";
+import type { PackageActionRequest } from "models/PackageActionRequest";
+import type { PGPKey } from "models/PGPKey";
+import type { PGPKeyRequest } from "models/PGPKeyRequest";
+import type { RepositoryId } from "models/RepositoryId";
+
+export class ServiceMixin extends BaseClient {
+
+ async servicePackageAdd(repository: RepositoryId, data: PackageActionRequest): Promise {
+ return this.request("/api/v1/service/add", { method: "POST", query: repository.toQuery(), json: data });
+ }
+
+ async servicePackagePatchRemove(packageBase: string, key: string): Promise {
+ return this.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, {
+ method: "DELETE",
+ });
+ }
+
+ async servicePackageRemove(repository: RepositoryId, packages: string[]): Promise {
+ return this.request("/api/v1/service/remove", {
+ method: "POST",
+ query: repository.toQuery(),
+ json: { packages },
+ });
+ }
+
+ async servicePackageRequest(repository: RepositoryId, data: PackageActionRequest): Promise {
+ return this.request("/api/v1/service/request", {
+ method: "POST",
+ query: repository.toQuery(),
+ json: data,
+ });
+ }
+
+ async servicePackageSearch(query: string): Promise {
+ return this.request("/api/v1/service/search", { query: { for: query } });
+ }
+
+ async servicePackageUpdate(repository: RepositoryId, data: PackageActionRequest): Promise {
+ return this.request("/api/v1/service/update", {
+ method: "POST",
+ query: repository.toQuery(),
+ json: data,
+ });
+ }
+
+ async servicePGPFetch(key: string, server: string): Promise {
+ return this.request("/api/v1/service/pgp", { query: { key, server } });
+ }
+
+ async servicePGPImport(data: PGPKeyRequest): Promise {
+ return this.request("/api/v1/service/pgp", { method: "POST", json: data });
+ }
+
+ async serviceRebuild(repository: RepositoryId, packages: string[]): Promise {
+ return this.request("/api/v1/service/rebuild", {
+ method: "POST",
+ query: repository.toQuery(),
+ json: { packages },
+ });
+ }
+}
diff --git a/frontend/src/chartSetup.ts b/frontend/src/chartSetup.ts
new file mode 100644
index 00000000..f2463226
--- /dev/null
+++ b/frontend/src/chartSetup.ts
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import {
+ ArcElement,
+ BarElement,
+ CategoryScale,
+ Chart as ChartJS,
+ Legend,
+ LinearScale,
+ LineElement,
+ PointElement,
+ Tooltip,
+} from "chart.js";
+
+ChartJS.register(
+ ArcElement,
+ BarElement,
+ CategoryScale,
+ Legend,
+ LinearScale,
+ LineElement,
+ PointElement,
+ Tooltip,
+);
diff --git a/frontend/src/components/charts/EventDurationLineChart.tsx b/frontend/src/components/charts/EventDurationLineChart.tsx
new file mode 100644
index 00000000..15768204
--- /dev/null
+++ b/frontend/src/components/charts/EventDurationLineChart.tsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { Event } from "models/Event";
+import type React from "react";
+import { Line } from "react-chartjs-2";
+
+interface EventDurationLineChartProps {
+ events: Event[];
+}
+
+export default function EventDurationLineChart({ events }: EventDurationLineChartProps): React.JSX.Element {
+ const updateEvents = events.filter(event => event.event === "package-updated");
+ const data = {
+ labels: updateEvents.map(event => new Date(event.created * 1000).toISOStringShort()),
+ datasets: [
+ {
+ label: "update duration, s",
+ data: updateEvents.map(event => event.data?.took ?? 0),
+ cubicInterpolationMode: "monotone" as const,
+ tension: 0.4,
+ },
+ ],
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/charts/PackageCountBarChart.tsx b/frontend/src/components/charts/PackageCountBarChart.tsx
new file mode 100644
index 00000000..22f542a9
--- /dev/null
+++ b/frontend/src/components/charts/PackageCountBarChart.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { blue, indigo } from "@mui/material/colors";
+import type { RepositoryStats } from "models/RepositoryStats";
+import type React from "react";
+import { Bar } from "react-chartjs-2";
+
+interface PackageCountBarChartProps {
+ stats: RepositoryStats;
+}
+
+export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
+ return ;
+}
diff --git a/frontend/src/components/charts/StatusPieChart.tsx b/frontend/src/components/charts/StatusPieChart.tsx
new file mode 100644
index 00000000..6f8827c3
--- /dev/null
+++ b/frontend/src/components/charts/StatusPieChart.tsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { BuildStatus } from "models/BuildStatus.ts";
+import type { Counters } from "models/Counters";
+import type React from "react";
+import { Pie } from "react-chartjs-2";
+import { StatusColors } from "theme/StatusColors";
+
+interface StatusPieChartProps {
+ counters: Counters;
+}
+
+export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
+ const labels = ["unknown", "pending", "building", "failed", "success"] as BuildStatus[];
+ const data = {
+ labels: labels,
+ datasets: [
+ {
+ label: "packages in status",
+ data: labels.map(label => counters[label]),
+ backgroundColor: labels.map(label => StatusColors[label]),
+ },
+ ],
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/common/AutoRefreshControl.tsx b/frontend/src/components/common/AutoRefreshControl.tsx
new file mode 100644
index 00000000..aa872554
--- /dev/null
+++ b/frontend/src/components/common/AutoRefreshControl.tsx
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import CheckIcon from "@mui/icons-material/Check";
+import TimerIcon from "@mui/icons-material/Timer";
+import TimerOffIcon from "@mui/icons-material/TimerOff";
+import { IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Tooltip } from "@mui/material";
+import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
+import React, { useState } from "react";
+
+interface AutoRefreshControlProps {
+ intervals: AutoRefreshInterval[];
+ currentInterval: number;
+ onIntervalChange: (interval: number) => void;
+}
+
+export default function AutoRefreshControl({
+ intervals,
+ currentInterval,
+ onIntervalChange,
+}: AutoRefreshControlProps): React.JSX.Element | null {
+ const [anchorEl, setAnchorEl] = useState(null);
+
+ if (intervals.length === 0) {
+ return null;
+ }
+
+ const enabled = currentInterval > 0;
+
+ return <>
+
+ setAnchorEl(event.currentTarget)}
+ color={enabled ? "primary" : "default"}
+ >
+ {enabled ? : }
+
+
+
+ >;
+}
diff --git a/frontend/src/components/common/CodeBlock.tsx b/frontend/src/components/common/CodeBlock.tsx
new file mode 100644
index 00000000..bca6bf7e
--- /dev/null
+++ b/frontend/src/components/common/CodeBlock.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { Box } from "@mui/material";
+import CopyButton from "components/common/CopyButton";
+import React, { type RefObject } from "react";
+
+interface CodeBlockProps {
+ codeRef?: RefObject;
+ preRef?: RefObject;
+ className?: string;
+ getText: () => string;
+ height?: number | string;
+ onScroll?: () => void;
+ wordBreak?: boolean;
+}
+
+export default function CodeBlock({
+ codeRef,
+ preRef,
+ className,
+ getText,
+ height,
+ onScroll,
+ wordBreak,
+}: CodeBlockProps): React.JSX.Element {
+ return
+
+
+ {!codeRef && getText()}
+
+
+
+
+
+ ;
+}
diff --git a/frontend/src/components/common/CopyButton.tsx b/frontend/src/components/common/CopyButton.tsx
new file mode 100644
index 00000000..2f68feb2
--- /dev/null
+++ b/frontend/src/components/common/CopyButton.tsx
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import CheckIcon from "@mui/icons-material/Check";
+import ContentCopyIcon from "@mui/icons-material/ContentCopy";
+import { IconButton, Tooltip } from "@mui/material";
+import React, { useEffect, useRef, useState } from "react";
+
+interface CopyButtonProps {
+ getText: () => string;
+}
+
+export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element {
+ const [copied, setCopied] = useState(false);
+ const timer = useRef>(undefined);
+
+ useEffect(() => () => clearTimeout(timer.current), []);
+
+ const handleCopy: () => Promise = async () => {
+ await navigator.clipboard.writeText(getText());
+ setCopied(true);
+ clearTimeout(timer.current);
+ timer.current = setTimeout(() => setCopied(false), 2000);
+ };
+
+ return
+ void handleCopy()}>
+ {copied ? : }
+
+ ;
+}
diff --git a/frontend/src/components/common/DialogHeader.tsx b/frontend/src/components/common/DialogHeader.tsx
new file mode 100644
index 00000000..f4271df3
--- /dev/null
+++ b/frontend/src/components/common/DialogHeader.tsx
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import CloseIcon from "@mui/icons-material/Close";
+import { DialogTitle, IconButton, type SxProps, type Theme } from "@mui/material";
+import type React from "react";
+
+interface DialogHeaderProps {
+ children: React.ReactNode;
+ onClose: () => void;
+ sx?: SxProps;
+}
+
+export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element {
+ return
+ {children}
+
+
+
+ ;
+}
diff --git a/frontend/src/components/common/RepositorySelect.tsx b/frontend/src/components/common/RepositorySelect.tsx
new file mode 100644
index 00000000..b80783f2
--- /dev/null
+++ b/frontend/src/components/common/RepositorySelect.tsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
+import { useRepository } from "hooks/useRepository";
+import type { SelectedRepositoryResult } from "hooks/useSelectedRepository";
+import type React from "react";
+
+export default function RepositorySelect({
+ repositorySelect,
+}: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element {
+ const { repositories, current } = useRepository();
+
+ return
+ repository
+
+ ;
+}
diff --git a/frontend/src/components/dialogs/DashboardDialog.tsx b/frontend/src/components/dialogs/DashboardDialog.tsx
new file mode 100644
index 00000000..bdabd098
--- /dev/null
+++ b/frontend/src/components/dialogs/DashboardDialog.tsx
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { Box, Dialog, DialogContent, Grid, Typography } from "@mui/material";
+import { skipToken, useQuery } from "@tanstack/react-query";
+import PackageCountBarChart from "components/charts/PackageCountBarChart";
+import StatusPieChart from "components/charts/StatusPieChart";
+import DialogHeader from "components/common/DialogHeader";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useClient } from "hooks/useClient";
+import { useRepository } from "hooks/useRepository";
+import type { InternalStatus } from "models/InternalStatus";
+import type React from "react";
+import { StatusHeaderStyles } from "theme/StatusColors";
+
+interface DashboardDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
+ const client = useClient();
+ const { current } = useRepository();
+
+ const { data: status } = useQuery({
+ queryKey: current ? QueryKeys.status(current) : ["status"],
+ queryFn: current ? () => client.fetchServerStatus(current) : skipToken,
+ enabled: open,
+ });
+
+ const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
+
+ return ;
+}
diff --git a/frontend/src/components/dialogs/KeyImportDialog.tsx b/frontend/src/components/dialogs/KeyImportDialog.tsx
new file mode 100644
index 00000000..4e5c3d31
--- /dev/null
+++ b/frontend/src/components/dialogs/KeyImportDialog.tsx
@@ -0,0 +1,122 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import RefreshIcon from "@mui/icons-material/Refresh";
+import {
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ TextField,
+} from "@mui/material";
+import { ApiError } from "api/client/ApiError";
+import CodeBlock from "components/common/CodeBlock";
+import DialogHeader from "components/common/DialogHeader";
+import { useClient } from "hooks/useClient";
+import { useNotification } from "hooks/useNotification";
+import React, { useState } from "react";
+
+interface KeyImportDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element {
+ const client = useClient();
+ const { showSuccess, showError } = useNotification();
+
+ const [fingerprint, setFingerprint] = useState("");
+ const [server, setServer] = useState("keyserver.ubuntu.com");
+ const [keyBody, setKeyBody] = useState("");
+
+ const resetFields = (): void => {
+ setFingerprint("");
+ setServer("keyserver.ubuntu.com");
+ setKeyBody("");
+ };
+
+ const handleClose = (): void => {
+ resetFields();
+ onClose();
+ };
+
+ const handleFetch: () => Promise = async () => {
+ if (!fingerprint || !server) {
+ return;
+ }
+ try {
+ const result = await client.servicePGPFetch(fingerprint, server);
+ setKeyBody(result.key);
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ showError("Action failed", `Could not fetch key: ${detail}`);
+ }
+ };
+
+ const handleImport: () => Promise = async () => {
+ if (!fingerprint || !server) {
+ return;
+ }
+ try {
+ await client.servicePGPImport({ key: fingerprint, server });
+ handleClose();
+ showSuccess("Success", `Key ${fingerprint} has been imported`);
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`);
+ }
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/dialogs/LoginDialog.tsx b/frontend/src/components/dialogs/LoginDialog.tsx
new file mode 100644
index 00000000..2497799f
--- /dev/null
+++ b/frontend/src/components/dialogs/LoginDialog.tsx
@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import PersonIcon from "@mui/icons-material/Person";
+import VisibilityIcon from "@mui/icons-material/Visibility";
+import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ IconButton,
+ InputAdornment,
+ TextField,
+} from "@mui/material";
+import { ApiError } from "api/client/ApiError";
+import DialogHeader from "components/common/DialogHeader";
+import { useAuth } from "hooks/useAuth";
+import { useNotification } from "hooks/useNotification";
+import React, { useState } from "react";
+
+interface LoginDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function LoginDialog({ open, onClose }: LoginDialogProps): React.JSX.Element {
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [showPassword, setShowPassword] = useState(false);
+ const { login } = useAuth();
+ const { showSuccess, showError } = useNotification();
+
+ const handleClose = (): void => {
+ setUsername("");
+ setPassword("");
+ setShowPassword(false);
+ onClose();
+ };
+
+ const handleSubmit: () => Promise = async () => {
+ if (!username || !password) {
+ return;
+ }
+ try {
+ await login(username, password);
+ handleClose();
+ showSuccess("Logged in", `Successfully logged in as ${username}`);
+ window.location.reload();
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ if (username === "admin" && password === "admin") {
+ showError("Login error", "You've entered a password for user \"root\", did you make a typo in username?");
+ } else {
+ showError("Login error", `Could not login as ${username}: ${detail}`);
+ }
+ }
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/dialogs/PackageAddDialog.tsx b/frontend/src/components/dialogs/PackageAddDialog.tsx
new file mode 100644
index 00000000..713eae12
--- /dev/null
+++ b/frontend/src/components/dialogs/PackageAddDialog.tsx
@@ -0,0 +1,212 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import AddIcon from "@mui/icons-material/Add";
+import DeleteIcon from "@mui/icons-material/Delete";
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import {
+ Autocomplete,
+ Box,
+ Button,
+ Checkbox,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ FormControlLabel,
+ IconButton,
+ TextField,
+} from "@mui/material";
+import { useQuery } from "@tanstack/react-query";
+import { ApiError } from "api/client/ApiError";
+import DialogHeader from "components/common/DialogHeader";
+import RepositorySelect from "components/common/RepositorySelect";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useClient } from "hooks/useClient";
+import { useDebounce } from "hooks/useDebounce";
+import { useNotification } from "hooks/useNotification";
+import { useSelectedRepository } from "hooks/useSelectedRepository";
+import type { AURPackage } from "models/AURPackage";
+import React, { useRef, useState } from "react";
+
+interface EnvironmentVariable {
+ id: number;
+ key: string;
+ value: string;
+}
+
+interface PackageAddDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
+ const client = useClient();
+ const { showSuccess, showError } = useNotification();
+ const repositorySelect = useSelectedRepository();
+
+ const [packageName, setPackageName] = useState("");
+ const [refreshDatabase, setRefreshDatabase] = useState(true);
+ const [environmentVariables, setEnvironmentVariables] = useState([]);
+ const variableIdCounter = useRef(0);
+
+ const handleClose = (): void => {
+ setPackageName("");
+ repositorySelect.reset();
+ setRefreshDatabase(true);
+ setEnvironmentVariables([]);
+ onClose();
+ };
+
+ const debouncedSearch = useDebounce(packageName, 500);
+
+ const { data: searchResults = [] } = useQuery({
+ queryKey: QueryKeys.search(debouncedSearch),
+ queryFn: () => client.servicePackageSearch(debouncedSearch),
+ enabled: debouncedSearch.length >= 3,
+ });
+
+ const handleAdd: () => Promise = async () => {
+ if (!packageName) {
+ return;
+ }
+ const repository = repositorySelect.selectedRepository;
+ if (!repository) {
+ return;
+ }
+ try {
+ const patches = environmentVariables.filter(variable => variable.key);
+ await client.servicePackageAdd(repository, {
+ packages: [packageName],
+ patches,
+ refresh: refreshDatabase,
+ });
+ handleClose();
+ showSuccess("Success", `Packages ${packageName} have been added`);
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ showError("Action failed", `Package addition failed: ${detail}`);
+ }
+ };
+
+ const handleRequest: () => Promise = async () => {
+ if (!packageName) {
+ return;
+ }
+ const repository = repositorySelect.selectedRepository;
+ if (!repository) {
+ return;
+ }
+ try {
+ const patches = environmentVariables.filter(variable => variable.key);
+ await client.servicePackageRequest(repository, {
+ packages: [packageName],
+ patches,
+ });
+ handleClose();
+ showSuccess("Success", `Packages ${packageName} have been requested`);
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ showError("Action failed", `Package request failed: ${detail}`);
+ }
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/dialogs/PackageInfoDialog.tsx b/frontend/src/components/dialogs/PackageInfoDialog.tsx
new file mode 100644
index 00000000..3f7c51a7
--- /dev/null
+++ b/frontend/src/components/dialogs/PackageInfoDialog.tsx
@@ -0,0 +1,188 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { Box, Dialog, DialogContent, Tab, Tabs } from "@mui/material";
+import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ApiError } from "api/client/ApiError";
+import DialogHeader from "components/common/DialogHeader";
+import BuildLogsTab from "components/package/BuildLogsTab";
+import ChangesTab from "components/package/ChangesTab";
+import EventsTab from "components/package/EventsTab";
+import PackageDetailsGrid from "components/package/PackageDetailsGrid";
+import PackageInfoActions from "components/package/PackageInfoActions";
+import PackagePatchesList from "components/package/PackagePatchesList";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useAuth } from "hooks/useAuth";
+import { useAutoRefresh } from "hooks/useAutoRefresh";
+import { useClient } from "hooks/useClient";
+import { useNotification } from "hooks/useNotification";
+import { useRepository } from "hooks/useRepository";
+import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
+import type { Dependencies } from "models/Dependencies";
+import type { PackageStatus } from "models/PackageStatus";
+import type { Patch } from "models/Patch";
+import React, { useState } from "react";
+import { StatusHeaderStyles } from "theme/StatusColors";
+import { defaultInterval } from "utils";
+
+interface PackageInfoDialogProps {
+ packageBase: string | null;
+ open: boolean;
+ onClose: () => void;
+ autoRefreshIntervals: AutoRefreshInterval[];
+}
+
+export default function PackageInfoDialog({
+ packageBase,
+ open,
+ onClose,
+ autoRefreshIntervals,
+}: PackageInfoDialogProps): React.JSX.Element {
+ const client = useClient();
+ const { current } = useRepository();
+ const { isAuthorized } = useAuth();
+ const { showSuccess, showError } = useNotification();
+ const queryClient = useQueryClient();
+
+ const [tabIndex, setTabIndex] = useState(0);
+ const [refreshDatabase, setRefreshDatabase] = useState(true);
+
+ const handleClose = (): void => {
+ setTabIndex(0);
+ setRefreshDatabase(true);
+ onClose();
+ };
+
+ const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
+
+ const { data: packageData } = useQuery({
+ queryKey: packageBase && current ? QueryKeys.package(packageBase, current) : ["packages"],
+ queryFn: packageBase && current ? () => client.fetchPackage(packageBase, current) : skipToken,
+ enabled: open,
+ refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
+ });
+
+ const { data: dependencies } = useQuery({
+ queryKey: packageBase && current ? QueryKeys.dependencies(packageBase, current) : ["dependencies"],
+ queryFn: packageBase && current ? () => client.fetchPackageDependencies(packageBase, current) : skipToken,
+ enabled: open,
+ });
+
+ const { data: patches = [] } = useQuery({
+ queryKey: packageBase ? QueryKeys.patches(packageBase) : ["patches"],
+ queryFn: packageBase ? () => client.fetchPackagePatches(packageBase) : skipToken,
+ enabled: open,
+ });
+
+ const description: PackageStatus | undefined = packageData?.[0];
+ const pkg = description?.package;
+ const status = description?.status;
+ const headerStyle = status ? StatusHeaderStyles[status.status] : {};
+
+ const handleUpdate: () => Promise = async () => {
+ if (!packageBase || !current) {
+ return;
+ }
+ try {
+ await client.servicePackageAdd(current, { packages: [packageBase], refresh: refreshDatabase });
+ showSuccess("Success", `Run update for packages ${packageBase}`);
+ } catch (exception) {
+ showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`);
+ }
+ };
+
+ const handleRemove: () => Promise = async () => {
+ if (!packageBase || !current) {
+ return;
+ }
+ try {
+ await client.servicePackageRemove(current, [packageBase]);
+ showSuccess("Success", `Packages ${packageBase} have been removed`);
+ onClose();
+ } catch (exception) {
+ showError("Action failed", `Could not remove package: ${ApiError.errorDetail(exception)}`);
+ }
+ };
+
+ const handleDeletePatch: (key: string) => Promise = async key => {
+ if (!packageBase) {
+ return;
+ }
+ try {
+ await client.servicePackagePatchRemove(packageBase, key);
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
+ } catch (exception) {
+ showError("Action failed", `Could not delete variable: ${ApiError.errorDetail(exception)}`);
+ }
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/dialogs/PackageRebuildDialog.tsx b/frontend/src/components/dialogs/PackageRebuildDialog.tsx
new file mode 100644
index 00000000..4078b77a
--- /dev/null
+++ b/frontend/src/components/dialogs/PackageRebuildDialog.tsx
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import { Button, Dialog, DialogActions, DialogContent, TextField } from "@mui/material";
+import { ApiError } from "api/client/ApiError";
+import DialogHeader from "components/common/DialogHeader";
+import RepositorySelect from "components/common/RepositorySelect";
+import { useClient } from "hooks/useClient";
+import { useNotification } from "hooks/useNotification";
+import { useSelectedRepository } from "hooks/useSelectedRepository";
+import React, { useState } from "react";
+
+interface PackageRebuildDialogProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
+ const client = useClient();
+ const { showSuccess, showError } = useNotification();
+ const repositorySelect = useSelectedRepository();
+
+ const [dependency, setDependency] = useState("");
+
+ const handleClose = (): void => {
+ setDependency("");
+ repositorySelect.reset();
+ onClose();
+ };
+
+ const handleRebuild: () => Promise = async () => {
+ if (!dependency) {
+ return;
+ }
+ const repository = repositorySelect.selectedRepository;
+ if (!repository) {
+ return;
+ }
+ try {
+ await client.serviceRebuild(repository, [dependency]);
+ handleClose();
+ showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`);
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ showError("Action failed", `Repository rebuild failed: ${detail}`);
+ }
+ };
+
+ return ;
+}
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
new file mode 100644
index 00000000..b5cbc326
--- /dev/null
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { Box, Container } from "@mui/material";
+import { useQuery } from "@tanstack/react-query";
+import LoginDialog from "components/dialogs/LoginDialog";
+import Footer from "components/layout/Footer";
+import Navbar from "components/layout/Navbar";
+import PackageTable from "components/table/PackageTable";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useAuth } from "hooks/useAuth";
+import { useClient } from "hooks/useClient";
+import { useRepository } from "hooks/useRepository";
+import type { InfoResponse } from "models/InfoResponse";
+import React, { useEffect, useState } from "react";
+
+export default function AppLayout(): React.JSX.Element {
+ const client = useClient();
+ const { setAuthState } = useAuth();
+ const { setRepositories } = useRepository();
+ const [loginOpen, setLoginOpen] = useState(false);
+
+ const { data: info } = useQuery({
+ queryKey: QueryKeys.info,
+ queryFn: () => client.fetchServerInfo(),
+ staleTime: Infinity,
+ });
+
+ // Sync info to contexts when loaded
+ useEffect(() => {
+ if (info) {
+ setAuthState({ enabled: info.auth.enabled, username: info.auth.username ?? null });
+ setRepositories(info.repositories);
+ }
+ }, [info, setAuthState, setRepositories]);
+
+ return
+
+
+
+
+
+
+
+
+
+
+
+ ;
+}
diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx
new file mode 100644
index 00000000..1170d2ba
--- /dev/null
+++ b/frontend/src/components/layout/Footer.tsx
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import GitHubIcon from "@mui/icons-material/GitHub";
+import HomeIcon from "@mui/icons-material/Home";
+import LogoutIcon from "@mui/icons-material/Logout";
+import { Box, Button, Link, Typography } from "@mui/material";
+import { useAuth } from "hooks/useAuth";
+import type React from "react";
+
+interface FooterProps {
+ version: string;
+ docsEnabled: boolean;
+ indexUrl?: string;
+ onLoginClick: () => void;
+}
+
+export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }: FooterProps): React.JSX.Element {
+ const { enabled: authEnabled, username, logout } = useAuth();
+
+ const handleLogout: () => Promise = async () => {
+ await logout();
+ window.location.reload();
+ };
+
+ 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..9275c651
--- /dev/null
+++ b/frontend/src/components/layout/Navbar.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { Box, Tab, Tabs } from "@mui/material";
+import { useRepository } from "hooks/useRepository";
+import type React from "react";
+
+export default function Navbar(): React.JSX.Element | null {
+ const { repositories, current, setCurrent } = useRepository();
+
+ if (repositories.length === 0 || !current) {
+ return null;
+ }
+
+ const currentIndex = repositories.findIndex(repository =>
+ repository.architecture === current.architecture && repository.repository === current.repository,
+ );
+
+ return
+ = 0 ? currentIndex : 0}
+ onChange={(_, newValue: number) => {
+ const repository = repositories[newValue];
+ if (repository) {
+ setCurrent(repository);
+ }
+ }}
+ variant="scrollable"
+ scrollButtons="auto"
+ >
+ {repositories.map(repository =>
+ ,
+ )}
+
+ ;
+}
diff --git a/frontend/src/components/package/BuildLogsTab.tsx b/frontend/src/components/package/BuildLogsTab.tsx
new file mode 100644
index 00000000..c84ee5c8
--- /dev/null
+++ b/frontend/src/components/package/BuildLogsTab.tsx
@@ -0,0 +1,183 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import ListIcon from "@mui/icons-material/List";
+import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
+import { keepPreviousData, skipToken, useQuery } from "@tanstack/react-query";
+import CodeBlock from "components/common/CodeBlock";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useAutoScroll } from "hooks/useAutoScroll";
+import { useClient } from "hooks/useClient";
+import type { LogRecord } from "models/LogRecord";
+import type { RepositoryId } from "models/RepositoryId";
+import React, { useEffect, useMemo, useState } from "react";
+
+interface Logs {
+ version: string;
+ processId: string;
+ created: number;
+ logs: string;
+}
+
+interface BuildLogsTabProps {
+ packageBase: string;
+ repository: RepositoryId;
+ refreshInterval: number;
+}
+
+function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string {
+ const filtered = filter ? records.filter(filter) : records;
+ return filtered
+ .map(record => `[${new Date(record.created * 1000).toISOString()}] ${record.message}`)
+ .join("\n");
+}
+
+export default function BuildLogsTab({
+ packageBase,
+ repository,
+ refreshInterval,
+}: BuildLogsTabProps): React.JSX.Element {
+ const client = useClient();
+ const [selectedVersionKey, setSelectedVersionKey] = useState(null);
+ const [anchorEl, setAnchorEl] = useState(null);
+
+ const { data: allLogs } = useQuery({
+ queryKey: QueryKeys.logs(packageBase, repository),
+ queryFn: () => client.fetchPackageLogs(packageBase, repository),
+ enabled: !!packageBase,
+ refetchInterval: refreshInterval > 0 ? refreshInterval : false,
+ });
+
+ // Build version selectors from all logs
+ const versions = useMemo(() => {
+ if (!allLogs || allLogs.length === 0) {
+ return [];
+ }
+
+ const grouped: Record = {};
+ for (const record of allLogs) {
+ const key = `${record.version}-${record.process_id}`;
+ const existing = grouped[key];
+ if (!existing) {
+ grouped[key] = { ...record, minCreated: record.created };
+ } else {
+ existing.minCreated = Math.min(existing.minCreated, record.created);
+ }
+ }
+
+ return Object.values(grouped)
+ .sort((left, right) => right.minCreated - left.minCreated)
+ .map(record => ({
+ version: record.version,
+ processId: record.process_id,
+ created: record.minCreated,
+ logs: convertLogs(
+ allLogs,
+ right => record.version === right.version && record.process_id === right.process_id,
+ ),
+ }));
+ }, [allLogs]);
+
+ // Compute active index from selected version key, defaulting to newest (index 0)
+ const activeIndex = useMemo(() => {
+ if (selectedVersionKey) {
+ const index = versions.findIndex(record => `${record.version}-${record.processId}` === selectedVersionKey);
+ if (index >= 0) {
+ return index;
+ }
+ }
+ return 0;
+ }, [versions, selectedVersionKey]);
+
+ const activeVersion = versions[activeIndex];
+ const activeVersionKey = activeVersion ? `${activeVersion.version}-${activeVersion.processId}` : null;
+
+ // Refresh active version logs
+ const { data: versionLogs } = useQuery({
+ queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
+ queryFn: activeVersion
+ ? () => client.fetchPackageLogs(packageBase, repository, activeVersion.version, activeVersion.processId)
+ : skipToken,
+ placeholderData: keepPreviousData,
+ refetchInterval: refreshInterval > 0 ? refreshInterval : false,
+ });
+
+ // Derive displayed logs: prefer fresh polled data when available
+ const displayedLogs = useMemo(() => {
+ if (versionLogs && versionLogs.length > 0) {
+ return convertLogs(versionLogs);
+ }
+ return activeVersion?.logs ?? "";
+ }, [versionLogs, activeVersion]);
+
+ const { preRef, handleScroll, scrollToBottom, resetScroll } = useAutoScroll();
+
+ // Reset scroll tracking when active version changes
+ useEffect(() => {
+ resetScroll();
+ }, [activeVersionKey, resetScroll]);
+
+ // Scroll to bottom on new logs
+ useEffect(() => {
+ scrollToBottom();
+ }, [displayedLogs, scrollToBottom]);
+
+ return
+
+ }
+ onClick={event => setAnchorEl(event.currentTarget)}
+ />
+
+
+
+
+ displayedLogs}
+ height={400}
+ onScroll={handleScroll}
+ wordBreak
+ />
+
+ ;
+}
diff --git a/frontend/src/components/package/ChangesTab.tsx b/frontend/src/components/package/ChangesTab.tsx
new file mode 100644
index 00000000..b3700bee
--- /dev/null
+++ b/frontend/src/components/package/ChangesTab.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import "highlight.js/styles/github.css";
+
+import { Box } from "@mui/material";
+import { useQuery } from "@tanstack/react-query";
+import CodeBlock from "components/common/CodeBlock";
+import hljs from "highlight.js/lib/core";
+import diff from "highlight.js/lib/languages/diff";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useClient } from "hooks/useClient";
+import type { Changes } from "models/Changes";
+import type { RepositoryId } from "models/RepositoryId";
+import React, { useEffect, useRef } from "react";
+
+hljs.registerLanguage("diff", diff);
+
+interface ChangesTabProps {
+ packageBase: string;
+ repository: RepositoryId;
+}
+
+export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
+ const client = useClient();
+ const codeRef = useRef(null);
+
+ const { data } = useQuery({
+ queryKey: QueryKeys.changes(packageBase, repository),
+ queryFn: () => client.fetchPackageChanges(packageBase, repository),
+ enabled: !!packageBase,
+ });
+
+ const changesText = data?.changes ?? "";
+
+ useEffect(() => {
+ if (codeRef.current) {
+ codeRef.current.innerHTML = hljs.highlight(changesText, { language: "diff" }).value;
+ }
+ }, [changesText]);
+
+ return
+ changesText} height={400} />
+ ;
+}
diff --git a/frontend/src/components/package/EventsTab.tsx b/frontend/src/components/package/EventsTab.tsx
new file mode 100644
index 00000000..c4a5eb4f
--- /dev/null
+++ b/frontend/src/components/package/EventsTab.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { Box } from "@mui/material";
+import { DataGrid, type GridColDef } from "@mui/x-data-grid";
+import { useQuery } from "@tanstack/react-query";
+import EventDurationLineChart from "components/charts/EventDurationLineChart";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useClient } from "hooks/useClient";
+import type { Event } from "models/Event";
+import type { RepositoryId } from "models/RepositoryId";
+import type React from "react";
+
+interface EventsTabProps {
+ packageBase: string;
+ repository: RepositoryId;
+}
+
+interface EventRow {
+ id: number;
+ timestamp: string;
+ event: string;
+ message: string;
+}
+
+const columns: GridColDef[] = [
+ { field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
+ { field: "event", headerName: "event", flex: 1 },
+ { field: "message", headerName: "description", flex: 2 },
+];
+
+export default function EventsTab({ packageBase, repository }: EventsTabProps): React.JSX.Element {
+ const client = useClient();
+
+ const { data: events = [] } = useQuery({
+ queryKey: QueryKeys.events(repository, packageBase),
+ queryFn: () => client.fetchPackageEvents(repository, packageBase, 30),
+ enabled: !!packageBase,
+ });
+
+ const rows: EventRow[] = events.map((event, index) => ({
+ id: index,
+ timestamp: new Date(event.created * 1000).toISOStringShort(),
+ event: event.event,
+ message: event.message ?? "",
+ }));
+
+ return
+
+
+ ;
+}
diff --git a/frontend/src/components/package/PackageDetailsGrid.tsx b/frontend/src/components/package/PackageDetailsGrid.tsx
new file mode 100644
index 00000000..dad390fd
--- /dev/null
+++ b/frontend/src/components/package/PackageDetailsGrid.tsx
@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { Grid, Link, Typography } from "@mui/material";
+import type { Dependencies } from "models/Dependencies";
+import type { Package } from "models/Package";
+import React from "react";
+
+interface PackageDetailsGridProps {
+ pkg: Package;
+ dependencies?: Dependencies;
+}
+
+function listToString(items: string[]): React.ReactNode {
+ const unique = [...new Set(items)].sort();
+ return unique.map((item, index) =>
+
+ {item}
+ {index < unique.length - 1 && }
+ ,
+ );
+}
+
+export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetailsGridProps): React.JSX.Element {
+ const packagesList = Object.entries(pkg.packages)
+ .map(([name, properties]) => `${name}${properties.description ? ` (${properties.description})` : ""}`);
+
+ const groups = Object.values(pkg.packages)
+ .flatMap(properties => properties.groups ?? []);
+
+ const licenses = Object.values(pkg.packages)
+ .flatMap(properties => properties.licenses ?? []);
+
+ const upstreamUrls = [...new Set(
+ Object.values(pkg.packages)
+ .map(properties => properties.url)
+ .filter((url): url is string => !!url),
+ )].sort();
+
+ const aurUrl = pkg.remote.web_url;
+
+ const pkgNames = Object.keys(pkg.packages);
+ const allDepends = Object.values(pkg.packages).flatMap(properties => {
+ const deps = (properties.depends ?? []).filter(dep => !pkgNames.includes(dep));
+ const makeDeps = (properties.make_depends ?? []).filter(dep => !pkgNames.includes(dep)).map(dep => `${dep} (make)`);
+ const optDeps = (properties.opt_depends ?? []).filter(dep => !pkgNames.includes(dep)).map(dep => `${dep} (optional)`);
+ return [...deps, ...makeDeps, ...optDeps];
+ });
+
+ const implicitDepends = dependencies
+ ? Object.values(dependencies.paths).flat()
+ : [];
+
+ return <>
+
+ packages
+ {listToString(packagesList)}
+ version
+ {pkg.version}
+
+
+
+ packager
+ {pkg.packager ?? ""}
+
+
+
+
+
+ groups
+ {listToString(groups)}
+ licenses
+ {listToString(licenses)}
+
+
+
+ upstream
+
+ {upstreamUrls.map(url =>
+
+ {url}
+ ,
+ )}
+
+ AUR
+
+
+ {aurUrl &&
+ AUR link
+ }
+
+
+
+
+
+ depends
+ {listToString(allDepends)}
+ implicitly depends
+ {listToString(implicitDepends)}
+
+ >;
+}
diff --git a/frontend/src/components/package/PackageInfoActions.tsx b/frontend/src/components/package/PackageInfoActions.tsx
new file mode 100644
index 00000000..eabbdac7
--- /dev/null
+++ b/frontend/src/components/package/PackageInfoActions.tsx
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import DeleteIcon from "@mui/icons-material/Delete";
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material";
+import AutoRefreshControl from "components/common/AutoRefreshControl";
+import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
+import type React from "react";
+
+interface PackageInfoActionsProps {
+ isAuthorized: boolean;
+ refreshDatabase: boolean;
+ onRefreshDatabaseChange: (checked: boolean) => void;
+ onUpdate: () => void;
+ onRemove: () => void;
+ autoRefreshIntervals: AutoRefreshInterval[];
+ autoRefreshInterval: number;
+ onAutoRefreshIntervalChange: (interval: number) => void;
+}
+
+export default function PackageInfoActions({
+ isAuthorized,
+ refreshDatabase,
+ onRefreshDatabaseChange,
+ onUpdate,
+ onRemove,
+ autoRefreshIntervals,
+ autoRefreshInterval,
+ onAutoRefreshIntervalChange,
+}: PackageInfoActionsProps): React.JSX.Element {
+ return
+ {isAuthorized &&
+ <>
+ onRefreshDatabaseChange(checked)} size="small" />}
+ label="update pacman databases"
+ />
+ } size="small">
+ update
+
+ } size="small">
+ remove
+
+ >
+ }
+
+ ;
+}
diff --git a/frontend/src/components/package/PackagePatchesList.tsx b/frontend/src/components/package/PackagePatchesList.tsx
new file mode 100644
index 00000000..823610ba
--- /dev/null
+++ b/frontend/src/components/package/PackagePatchesList.tsx
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import DeleteIcon from "@mui/icons-material/Delete";
+import { Box, IconButton, TextField, Typography } from "@mui/material";
+import type { Patch } from "models/Patch";
+import type React from "react";
+
+interface PackagePatchesListProps {
+ patches: Patch[];
+ editable: boolean;
+ onDelete: (key: string) => void;
+}
+
+export default function PackagePatchesList({
+ patches,
+ editable,
+ onDelete,
+}: PackagePatchesListProps): React.JSX.Element | null {
+ if (patches.length === 0) {
+ return null;
+ }
+
+ return
+ Environment variables
+ {patches.map(patch =>
+
+
+ =
+
+ {editable &&
+ onDelete(patch.key)}>
+
+
+ }
+ ,
+ )}
+ ;
+}
diff --git a/frontend/src/components/table/PackageTable.tsx b/frontend/src/components/table/PackageTable.tsx
new file mode 100644
index 00000000..e32f7ce9
--- /dev/null
+++ b/frontend/src/components/table/PackageTable.tsx
@@ -0,0 +1,198 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { Box, Link } from "@mui/material";
+import {
+ DataGrid,
+ GRID_CHECKBOX_SELECTION_COL_DEF,
+ type GridColDef,
+ type GridFilterModel,
+ type GridRenderCellParams,
+ type GridRowId,
+ useGridApiRef,
+} from "@mui/x-data-grid";
+import DashboardDialog from "components/dialogs/DashboardDialog";
+import KeyImportDialog from "components/dialogs/KeyImportDialog";
+import PackageAddDialog from "components/dialogs/PackageAddDialog";
+import PackageInfoDialog from "components/dialogs/PackageInfoDialog";
+import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog";
+import PackageTableToolbar from "components/table/PackageTableToolbar";
+import StatusCell from "components/table/StatusCell";
+import { useDebounce } from "hooks/useDebounce";
+import { usePackageTable } from "hooks/usePackageTable";
+import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
+import type { PackageRow } from "models/PackageRow";
+import React, { useMemo } from "react";
+
+interface PackageTableProps {
+ autoRefreshIntervals: AutoRefreshInterval[];
+}
+
+const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
+
+function createListColumn(
+ field: keyof PackageRow,
+ headerName: string,
+ options: { flex?: number; minWidth?: number; width?: number },
+): GridColDef {
+ return {
+ field,
+ headerName,
+ ...options,
+ valueGetter: (value: string[]) => (value ?? []).join(" "),
+ renderCell: (params: GridRenderCellParams) =>
+ ((params.row[field] as string[]) ?? []).map((item, index, items) =>
+ {item}{index < items.length - 1 && },
+ ),
+ sortComparator: (left: string, right: string) => left.localeCompare(right),
+ };
+}
+
+export default function PackageTable({ autoRefreshIntervals }: PackageTableProps): React.JSX.Element {
+ const table = usePackageTable(autoRefreshIntervals);
+ const apiRef = useGridApiRef();
+ const debouncedSearch = useDebounce(table.searchText, 300);
+
+ const effectiveFilterModel: GridFilterModel = useMemo(
+ () => ({
+ ...table.filterModel,
+ quickFilterValues: debouncedSearch ? debouncedSearch.split(/\s+/) : undefined,
+ }),
+ [table.filterModel, debouncedSearch],
+ );
+
+ const columns: GridColDef[] = useMemo(
+ () => [
+ {
+ field: "base",
+ headerName: "package base",
+ flex: 1,
+ minWidth: 150,
+ renderCell: (params: GridRenderCellParams) =>
+ params.row.webUrl ?
+
+ {params.value as string}
+
+ : params.value as string,
+ },
+ { field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
+ createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
+ createListColumn("groups", "groups", { width: 150 }),
+ createListColumn("licenses", "licenses", { width: 150 }),
+ { field: "packager", headerName: "packager", width: 150 },
+ {
+ field: "timestamp",
+ headerName: "last update",
+ width: 180,
+ align: "right",
+ headerAlign: "right",
+ },
+ {
+ field: "status",
+ headerName: "status",
+ width: 120,
+ align: "center",
+ headerAlign: "center",
+ renderCell: (params: GridRenderCellParams) => ,
+ },
+ ],
+ [],
+ );
+
+ return
+ 0}
+ isAuthorized={table.isAuthorized}
+ status={table.status}
+ searchText={table.searchText}
+ onSearchChange={table.setSearchText}
+ autoRefresh={{
+ autoRefreshIntervals,
+ currentInterval: table.autoRefreshInterval,
+ onIntervalChange: table.onAutoRefreshIntervalChange,
+ }}
+ actions={{
+ onDashboardClick: () => table.setDialogOpen("dashboard"),
+ onAddClick: () => table.setDialogOpen("add"),
+ onUpdateClick: () => void table.handleUpdate(),
+ onRefreshDbClick: () => void table.handleRefreshDb(),
+ onRebuildClick: () => table.setDialogOpen("rebuild"),
+ onRemoveClick: () => void table.handleRemove(),
+ onKeyImportClick: () => table.setDialogOpen("keyImport"),
+ onReloadClick: table.handleReload,
+ onExportClick: () => apiRef.current?.exportDataAsCsv(),
+ }}
+ />
+
+ "auto"}
+ checkboxSelection
+ disableRowSelectionOnClick
+ rowSelectionModel={{ type: "include", ids: new Set(table.selectionModel) }}
+ onRowSelectionModelChange={model => {
+ if (model.type === "exclude") {
+ const excludeIds = new Set([...model.ids].map(String));
+ table.setSelectionModel(table.rows.map(row => row.id).filter(id => !excludeIds.has(id)));
+ } else {
+ table.setSelectionModel([...model.ids].map(String));
+ }
+ }}
+ paginationModel={table.paginationModel}
+ onPaginationModelChange={table.setPaginationModel}
+ pageSizeOptions={PAGE_SIZE_OPTIONS}
+ columnVisibilityModel={table.columnVisibility}
+ onColumnVisibilityModelChange={table.setColumnVisibility}
+ filterModel={effectiveFilterModel}
+ onFilterModelChange={table.setFilterModel}
+ initialState={{
+ sorting: { sortModel: [{ field: "base", sort: "asc" }] },
+ }}
+ onCellClick={(params, event) => {
+ // Don't open info dialog when clicking checkbox or link
+ if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
+ return;
+ }
+ if ((event.target as HTMLElement).closest("a")) {
+ return;
+ }
+ table.setSelectedPackage(String(params.id));
+ }}
+ autoHeight
+ sx={{
+ "& .MuiDataGrid-row": { cursor: "pointer" },
+ }}
+ density="compact"
+ />
+
+ table.setDialogOpen(null)} />
+ table.setDialogOpen(null)} />
+ table.setDialogOpen(null)} />
+ table.setDialogOpen(null)} />
+ table.setSelectedPackage(null)}
+ autoRefreshIntervals={autoRefreshIntervals}
+ />
+ ;
+}
diff --git a/frontend/src/components/table/PackageTableToolbar.tsx b/frontend/src/components/table/PackageTableToolbar.tsx
new file mode 100644
index 00000000..34ee465b
--- /dev/null
+++ b/frontend/src/components/table/PackageTableToolbar.tsx
@@ -0,0 +1,185 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import AddIcon from "@mui/icons-material/Add";
+import ClearIcon from "@mui/icons-material/Clear";
+import DeleteIcon from "@mui/icons-material/Delete";
+import DownloadIcon from "@mui/icons-material/Download";
+import FileDownloadIcon from "@mui/icons-material/FileDownload";
+import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
+import InventoryIcon from "@mui/icons-material/Inventory";
+import PlayArrowIcon from "@mui/icons-material/PlayArrow";
+import RefreshIcon from "@mui/icons-material/Refresh";
+import ReplayIcon from "@mui/icons-material/Replay";
+import SearchIcon from "@mui/icons-material/Search";
+import VpnKeyIcon from "@mui/icons-material/VpnKey";
+import { Box, Button, Divider, IconButton, InputAdornment, Menu, MenuItem, TextField, Tooltip } from "@mui/material";
+import AutoRefreshControl from "components/common/AutoRefreshControl";
+import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
+import type { BuildStatus } from "models/BuildStatus";
+import React, { useState } from "react";
+import { StatusColors } from "theme/StatusColors";
+
+export interface AutoRefreshProps {
+ autoRefreshIntervals: AutoRefreshInterval[];
+ currentInterval: number;
+ onIntervalChange: (interval: number) => void;
+}
+
+export interface ToolbarActions {
+ onDashboardClick: () => void;
+ onAddClick: () => void;
+ onUpdateClick: () => void;
+ onRefreshDbClick: () => void;
+ onRebuildClick: () => void;
+ onRemoveClick: () => void;
+ onKeyImportClick: () => void;
+ onReloadClick: () => void;
+ onExportClick: () => void;
+}
+
+interface PackageTableToolbarProps {
+ hasSelection: boolean;
+ isAuthorized: boolean;
+ status?: BuildStatus;
+ searchText: string;
+ onSearchChange: (text: string) => void;
+ autoRefresh: AutoRefreshProps;
+ actions: ToolbarActions;
+}
+
+export default function PackageTableToolbar({
+ hasSelection,
+ isAuthorized,
+ status,
+ searchText,
+ onSearchChange,
+ autoRefresh,
+ actions,
+}: PackageTableToolbarProps): React.JSX.Element {
+ const [packagesAnchorEl, setPackagesAnchorEl] = useState(null);
+
+ return
+
+
+
+
+
+
+ {isAuthorized &&
+ <>
+ }
+ onClick={event => setPackagesAnchorEl(event.currentTarget)}
+ >
+ packages
+
+
+
+ } onClick={actions.onKeyImportClick}>
+ import key
+
+ >
+ }
+
+ } onClick={actions.onReloadClick}>
+ reload
+
+
+
+
+
+
+ onSearchChange(event.target.value)}
+ slotProps={{
+ input: {
+ startAdornment:
+
+
+
+ ,
+ endAdornment: searchText ?
+
+ onSearchChange("")}>
+
+
+
+ : undefined,
+ },
+ }}
+ sx={{ minWidth: 200 }}
+ />
+
+
+
+
+
+
+ ;
+}
diff --git a/frontend/src/components/table/StatusCell.tsx b/frontend/src/components/table/StatusCell.tsx
new file mode 100644
index 00000000..f74c12e8
--- /dev/null
+++ b/frontend/src/components/table/StatusCell.tsx
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { Chip } from "@mui/material";
+import type { BuildStatus } from "models/BuildStatus";
+import type React from "react";
+import { StatusColors } from "theme/StatusColors";
+
+interface StatusCellProps {
+ status: BuildStatus;
+}
+
+export default function StatusCell({ status }: StatusCellProps): React.JSX.Element {
+ return ;
+}
diff --git a/frontend/src/contexts/AuthContext.ts b/frontend/src/contexts/AuthContext.ts
new file mode 100644
index 00000000..87bfa1a7
--- /dev/null
+++ b/frontend/src/contexts/AuthContext.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { createContext } from "react";
+
+interface AuthState {
+ enabled: boolean;
+ username: string | null;
+}
+
+export interface AuthContextValue extends AuthState {
+ isAuthorized: boolean;
+ setAuthState: (state: AuthState) => void;
+ login: (username: string, password: string) => Promise;
+ logout: () => Promise;
+}
+
+export const AuthContext = createContext(null);
diff --git a/frontend/src/contexts/AuthProvider.tsx b/frontend/src/contexts/AuthProvider.tsx
new file mode 100644
index 00000000..a733167a
--- /dev/null
+++ b/frontend/src/contexts/AuthProvider.tsx
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { AuthContext } from "contexts/AuthContext";
+import { useClient } from "hooks/useClient";
+import React, { type ReactNode, useCallback, useMemo, useState } from "react";
+
+export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
+ const client = useClient();
+ const [state, setState] = useState({ enabled: false, username: null as string | null });
+
+ const login = useCallback(async (username: string, password: string) => {
+ await client.login({ username, password });
+ setState(prev => ({ ...prev, username }));
+ }, [client]);
+
+ const doLogout = useCallback(async () => {
+ await client.logout();
+ setState(prev => ({ ...prev, username: null }));
+ }, [client]);
+
+ const isAuthorized = useMemo(() => !state.enabled || state.username !== null, [state.enabled, state.username]);
+
+ const value = useMemo(() => ({
+ ...state, isAuthorized, setAuthState: setState, login, logout: doLogout,
+ }), [state, isAuthorized, login, doLogout]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/contexts/ClientContext.ts b/frontend/src/contexts/ClientContext.ts
new file mode 100644
index 00000000..b8f89174
--- /dev/null
+++ b/frontend/src/contexts/ClientContext.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { AhrimanClient } from "api/client/AhrimanClient";
+import { createContext } from "react";
+
+export const ClientContext = createContext(null);
diff --git a/frontend/src/contexts/ClientProvider.tsx b/frontend/src/contexts/ClientProvider.tsx
new file mode 100644
index 00000000..af71dfc9
--- /dev/null
+++ b/frontend/src/contexts/ClientProvider.tsx
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { AhrimanClient } from "api/client/AhrimanClient";
+import { ClientContext } from "contexts/ClientContext";
+import React, { type ReactNode, useMemo } from "react";
+
+export function ClientProvider({ children }: { children: ReactNode }): React.JSX.Element {
+ const client = useMemo(() => new AhrimanClient(), []);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/contexts/Notification.ts b/frontend/src/contexts/Notification.ts
new file mode 100644
index 00000000..b5b5c61d
--- /dev/null
+++ b/frontend/src/contexts/Notification.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { AlertColor } from "@mui/material";
+
+export interface Notification {
+ id: number;
+ title: string;
+ message: string;
+ severity: AlertColor;
+}
diff --git a/frontend/src/contexts/NotificationContext.ts b/frontend/src/contexts/NotificationContext.ts
new file mode 100644
index 00000000..0e5ed5f7
--- /dev/null
+++ b/frontend/src/contexts/NotificationContext.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { createContext } from "react";
+
+export interface NotificationContextValue {
+ showSuccess: (title: string, message: string) => void;
+ showError: (title: string, message: string) => void;
+}
+
+export const NotificationContext = createContext(null);
diff --git a/frontend/src/contexts/NotificationItem.tsx b/frontend/src/contexts/NotificationItem.tsx
new file mode 100644
index 00000000..91194cfa
--- /dev/null
+++ b/frontend/src/contexts/NotificationItem.tsx
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { Alert, Slide } from "@mui/material";
+import type { Notification } from "contexts/Notification";
+import React, { useEffect, useState } from "react";
+
+interface NotificationItemProps {
+ notification: Notification;
+ onClose: (id: number) => void;
+}
+
+export default function NotificationItem({ notification, onClose }: NotificationItemProps): React.JSX.Element {
+ const [show, setShow] = useState(true);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setShow(false), 5000);
+ return () => clearTimeout(timer);
+ }, []);
+
+ return (
+ onClose(notification.id)}>
+ setShow(false)}
+ severity={notification.severity}
+ variant="filled"
+ sx={{ width: "100%", pointerEvents: "auto" }}
+ >
+ {notification.title}
+ {notification.message && ` - ${notification.message}`}
+
+
+ );
+}
diff --git a/frontend/src/contexts/NotificationProvider.tsx b/frontend/src/contexts/NotificationProvider.tsx
new file mode 100644
index 00000000..37211a05
--- /dev/null
+++ b/frontend/src/contexts/NotificationProvider.tsx
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { type AlertColor, Box } from "@mui/material";
+import type { Notification } from "contexts/Notification";
+import { NotificationContext } from "contexts/NotificationContext";
+import NotificationItem from "contexts/NotificationItem";
+import React, { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
+
+export function NotificationProvider({ children }: { children: ReactNode }): React.JSX.Element {
+ const nextId = useRef(0);
+ const [notifications, setNotifications] = useState([]);
+
+ const addNotification = useCallback((title: string, message: string, severity: AlertColor) => {
+ const id = nextId.current++;
+ setNotifications(prev => [...prev, { id, title, message, severity }]);
+ }, []);
+
+ const removeNotification = useCallback((id: number) => {
+ setNotifications(prev => prev.filter(notification => notification.id !== id));
+ }, []);
+
+ const showSuccess = useCallback(
+ (title: string, message: string) => addNotification(title, message, "success"),
+ [addNotification],
+ );
+ const showError = useCallback(
+ (title: string, message: string) => addNotification(title, message, "error"),
+ [addNotification],
+ );
+
+ const value = useMemo(() => ({ showSuccess, showError }), [showSuccess, showError]);
+
+ return (
+
+ {children}
+ theme.zIndex.snackbar,
+ display: "flex",
+ flexDirection: "column",
+ gap: 1,
+ maxWidth: 500,
+ width: "100%",
+ pointerEvents: "none",
+ }}
+ >
+ {notifications.map(notification =>
+ ,
+ )}
+
+
+ );
+}
diff --git a/frontend/src/contexts/RepositoryContext.ts b/frontend/src/contexts/RepositoryContext.ts
new file mode 100644
index 00000000..1f25ad2a
--- /dev/null
+++ b/frontend/src/contexts/RepositoryContext.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { RepositoryId } from "models/RepositoryId";
+import { createContext } from "react";
+
+export interface RepositoryContextValue {
+ repositories: RepositoryId[];
+ current: RepositoryId | null;
+ setRepositories: (repositories: RepositoryId[]) => void;
+ setCurrent: (repository: RepositoryId) => void;
+}
+
+export const RepositoryContext = createContext(null);
diff --git a/frontend/src/contexts/RepositoryProvider.tsx b/frontend/src/contexts/RepositoryProvider.tsx
new file mode 100644
index 00000000..f41fca89
--- /dev/null
+++ b/frontend/src/contexts/RepositoryProvider.tsx
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { RepositoryContext } from "contexts/RepositoryContext";
+import type { RepositoryId } from "models/RepositoryId";
+import React, { type ReactNode, useCallback, useMemo, useState, useSyncExternalStore } from "react";
+
+function subscribeToHash(callback: () => void): () => void {
+ window.addEventListener("hashchange", callback);
+ return () => window.removeEventListener("hashchange", callback);
+}
+
+function getHashSnapshot(): string {
+ return window.location.hash.replace("#", "");
+}
+
+export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element {
+ const [repositories, setRepositories] = useState([]);
+ const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
+
+ const current = useMemo(() => {
+ if (repositories.length === 0) {
+ return null;
+ }
+ return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null;
+ }, [repositories, hash]);
+
+ const setCurrent = useCallback((repository: RepositoryId) => {
+ window.location.hash = repository.key;
+ }, []);
+
+ const value = useMemo(() => ({
+ repositories, current, setRepositories, setCurrent,
+ }), [repositories, current, setCurrent]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/hooks/QueryKeys.ts b/frontend/src/hooks/QueryKeys.ts
new file mode 100644
index 00000000..a9e78613
--- /dev/null
+++ b/frontend/src/hooks/QueryKeys.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { RepositoryId } from "models/RepositoryId";
+
+export const QueryKeys = {
+
+ changes: (packageBase: string, repository: RepositoryId) => ["changes", repository.key, packageBase] as const,
+
+ dependencies: (packageBase: string, repository: RepositoryId) => ["dependencies", repository.key, packageBase] as const,
+
+ events: (repository: RepositoryId, objectId?: string) => ["events", repository.key, objectId] as const,
+
+ info: ["info"] as const,
+
+ logs: (packageBase: string, repository: RepositoryId) => ["logs", repository.key, packageBase] as const,
+
+ logsVersion: (packageBase: string, repository: RepositoryId, version: string, processId: string) =>
+ ["logs", repository.key, packageBase, version, processId] as const,
+
+ package: (packageBase: string, repository: RepositoryId) => ["packages", repository.key, packageBase] as const,
+
+ packages: (repository: RepositoryId) => ["packages", repository.key] as const,
+
+ patches: (packageBase: string) => ["patches", packageBase] as const,
+
+ search: (query: string) => ["search", query] as const,
+
+ status: (repository: RepositoryId) => ["status", repository.key] as const,
+};
diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts
new file mode 100644
index 00000000..c36f05fb
--- /dev/null
+++ b/frontend/src/hooks/useAuth.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { AuthContext, type AuthContextValue } from "contexts/AuthContext";
+import { useContextNotNull } from "hooks/useContextNotNull";
+
+export function useAuth(): AuthContextValue {
+ return useContextNotNull(AuthContext);
+}
diff --git a/frontend/src/hooks/useAutoRefresh.ts b/frontend/src/hooks/useAutoRefresh.ts
new file mode 100644
index 00000000..df3674b4
--- /dev/null
+++ b/frontend/src/hooks/useAutoRefresh.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { useLocalStorage } from "hooks/useLocalStorage";
+import { type Dispatch, type SetStateAction, useState } from "react";
+
+interface AutoRefreshResult {
+ interval: number;
+ paused: boolean;
+ setInterval: Dispatch>;
+ setPaused: Dispatch>;
+}
+
+export function useAutoRefresh(key: string, defaultInterval: number = 0): AutoRefreshResult {
+ const [interval, setInterval] = useLocalStorage(`ahriman-${key}`, defaultInterval);
+ const [paused, setPaused] = useState(false);
+
+ return {
+ interval,
+ paused,
+ setInterval,
+ setPaused,
+ };
+}
diff --git a/frontend/src/hooks/useAutoScroll.ts b/frontend/src/hooks/useAutoScroll.ts
new file mode 100644
index 00000000..957fb36f
--- /dev/null
+++ b/frontend/src/hooks/useAutoScroll.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { type RefObject, useRef } from "react";
+
+interface UseAutoScrollResult {
+ preRef: RefObject;
+ handleScroll: () => void;
+ scrollToBottom: () => void;
+ resetScroll: () => void;
+}
+
+export function useAutoScroll(): UseAutoScrollResult {
+ const preRef = useRef(null);
+ const initialScrollDone = useRef(false);
+ const wasAtBottom = useRef(true);
+
+ const handleScroll: () => void = () => {
+ if (preRef.current) {
+ const element = preRef.current;
+ wasAtBottom.current = element.scrollTop + element.clientHeight >= element.scrollHeight - 50;
+ }
+ };
+
+ const resetScroll: () => void = () => {
+ initialScrollDone.current = false;
+ };
+
+ // scroll to bottom on initial load, then only if already near bottom and no active text selection
+ const scrollToBottom: () => void = () => {
+ if (!preRef.current) {
+ return;
+ }
+ const element = preRef.current;
+ if (!initialScrollDone.current) {
+ element.scrollTop = element.scrollHeight;
+ initialScrollDone.current = true;
+ } else {
+ const hasSelection = !document.getSelection()?.isCollapsed;
+ if (wasAtBottom.current && !hasSelection) {
+ element.scrollTop = element.scrollHeight;
+ }
+ }
+ };
+
+ return { preRef, handleScroll, scrollToBottom, resetScroll };
+}
diff --git a/frontend/src/hooks/useClient.ts b/frontend/src/hooks/useClient.ts
new file mode 100644
index 00000000..cb07d833
--- /dev/null
+++ b/frontend/src/hooks/useClient.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { AhrimanClient } from "api/client/AhrimanClient";
+import { ClientContext } from "contexts/ClientContext";
+import { useContextNotNull } from "hooks/useContextNotNull";
+
+export function useClient(): AhrimanClient {
+ return useContextNotNull(ClientContext);
+}
diff --git a/frontend/src/hooks/useContextNotNull.ts b/frontend/src/hooks/useContextNotNull.ts
new file mode 100644
index 00000000..82f47b6a
--- /dev/null
+++ b/frontend/src/hooks/useContextNotNull.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { type Context, useContext } from "react";
+
+export function useContextNotNull(context: Context): T {
+ const ctx = useContext(context);
+ if (!ctx) {
+ throw new Error("must be used within a Provider");
+ }
+ return ctx;
+}
diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts
new file mode 100644
index 00000000..ac0b22b1
--- /dev/null
+++ b/frontend/src/hooks/useDebounce.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { useEffect, useState } from "react";
+
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => setDebouncedValue(value), delay);
+ return () => clearTimeout(handler);
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/frontend/src/hooks/useLocalStorage.ts b/frontend/src/hooks/useLocalStorage.ts
new file mode 100644
index 00000000..e532cedd
--- /dev/null
+++ b/frontend/src/hooks/useLocalStorage.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { type Dispatch, type SetStateAction, useCallback, useState } from "react";
+
+export function useLocalStorage(key: string, initialValue: T): [T, Dispatch>] {
+ const [storedValue, setStoredValue] = useState(() => {
+ try {
+ const item = window.localStorage.getItem(key);
+ return item ? (JSON.parse(item) as T) : initialValue;
+ } catch {
+ return initialValue;
+ }
+ });
+
+ const setValue: Dispatch> = useCallback(
+ value => {
+ setStoredValue(prev => {
+ const nextValue = value instanceof Function ? value(prev) : value;
+ window.localStorage.setItem(key, JSON.stringify(nextValue));
+ return nextValue;
+ });
+ },
+ [key],
+ );
+
+ return [storedValue, setValue];
+}
diff --git a/frontend/src/hooks/useNotification.ts b/frontend/src/hooks/useNotification.ts
new file mode 100644
index 00000000..80e22ccb
--- /dev/null
+++ b/frontend/src/hooks/useNotification.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { NotificationContext, type NotificationContextValue } from "contexts/NotificationContext";
+import { useContextNotNull } from "hooks/useContextNotNull";
+
+export function useNotification(): NotificationContextValue {
+ return useContextNotNull(NotificationContext);
+}
diff --git a/frontend/src/hooks/usePackageActions.ts b/frontend/src/hooks/usePackageActions.ts
new file mode 100644
index 00000000..3c48b1b1
--- /dev/null
+++ b/frontend/src/hooks/usePackageActions.ts
@@ -0,0 +1,119 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { useQueryClient } from "@tanstack/react-query";
+import { ApiError } from "api/client/ApiError";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useClient } from "hooks/useClient";
+import { useNotification } from "hooks/useNotification";
+import { useRepository } from "hooks/useRepository";
+
+export interface UsePackageActionsResult {
+ handleReload: () => void;
+ handleUpdate: () => Promise;
+ handleRefreshDb: () => Promise;
+ handleRemove: () => Promise;
+}
+
+export function usePackageActions(
+ selectionModel: string[],
+ setSelectionModel: (model: string[]) => void,
+): UsePackageActionsResult {
+ const client = useClient();
+ const { current } = useRepository();
+ const { showSuccess, showError } = useNotification();
+ const queryClient = useQueryClient();
+
+ const handleReload: () => void = () => {
+ if (!current) {
+ return;
+ }
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
+ };
+
+ const handleUpdate: () => Promise = async () => {
+ if (!current) {
+ return;
+ }
+ try {
+ if (selectionModel.length === 0) {
+ await client.servicePackageUpdate(current, { packages: [] });
+ showSuccess("Success", "Repository update has been run");
+ } else {
+ await client.servicePackageAdd(current, { packages: selectionModel });
+ showSuccess("Success", `Run update for packages ${selectionModel.join(", ")}`);
+ }
+ setSelectionModel([]);
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ showError("Action failed", `Packages update failed: ${detail}`);
+ }
+ };
+
+ const handleRefreshDb: () => Promise = async () => {
+ if (!current) {
+ return;
+ }
+ try {
+ await client.servicePackageUpdate(current, {
+ packages: [],
+ refresh: true,
+ aur: false,
+ local: false,
+ manual: false,
+ });
+ showSuccess("Success", "Pacman database update has been requested");
+ setSelectionModel([]);
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ showError("Action failed", `Could not update pacman databases: ${detail}`);
+ }
+ };
+
+ const handleRemove: () => Promise = async () => {
+ if (!current) {
+ return;
+ }
+ if (selectionModel.length === 0) {
+ return;
+ }
+ try {
+ await client.servicePackageRemove(current, selectionModel);
+ showSuccess("Success", `Packages ${selectionModel.join(", ")} have been removed`);
+ setSelectionModel([]);
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
+ void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
+ } catch (exception) {
+ const detail = ApiError.errorDetail(exception);
+ showError("Action failed", `Could not remove packages: ${detail}`);
+ }
+ };
+
+ return {
+ handleReload,
+ handleUpdate,
+ handleRefreshDb,
+ handleRemove,
+ };
+}
diff --git a/frontend/src/hooks/usePackageData.ts b/frontend/src/hooks/usePackageData.ts
new file mode 100644
index 00000000..a8547848
--- /dev/null
+++ b/frontend/src/hooks/usePackageData.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { skipToken, useQuery } from "@tanstack/react-query";
+import { QueryKeys } from "hooks/QueryKeys";
+import { useAuth } from "hooks/useAuth";
+import { useAutoRefresh } from "hooks/useAutoRefresh";
+import { useClient } from "hooks/useClient";
+import { useRepository } from "hooks/useRepository";
+import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
+import type { BuildStatus } from "models/BuildStatus";
+import { PackageRow } from "models/PackageRow";
+import { useMemo } from "react";
+import { defaultInterval } from "utils";
+
+export interface UsePackageDataResult {
+ rows: PackageRow[];
+ isLoading: boolean;
+ isAuthorized: boolean;
+ status: BuildStatus | undefined;
+ autoRefresh: ReturnType;
+}
+
+export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
+ const client = useClient();
+ const { current } = useRepository();
+ const { isAuthorized } = useAuth();
+
+ const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
+
+ const { data: packages = [], isLoading } = useQuery({
+ queryKey: current ? QueryKeys.packages(current) : ["packages"],
+ queryFn: current ? () => client.fetchPackages(current) : skipToken,
+ refetchInterval: autoRefresh.interval,
+ });
+
+ const { data: status } = useQuery({
+ queryKey: current ? QueryKeys.status(current) : ["status"],
+ queryFn: current ? () => client.fetchServerStatus(current) : skipToken,
+ refetchInterval: autoRefresh.interval,
+ });
+
+ const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);
+
+ return {
+ rows,
+ isLoading,
+ isAuthorized,
+ status: status?.status.status,
+ autoRefresh,
+ };
+}
diff --git a/frontend/src/hooks/usePackageTable.ts b/frontend/src/hooks/usePackageTable.ts
new file mode 100644
index 00000000..f7d63aaa
--- /dev/null
+++ b/frontend/src/hooks/usePackageTable.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { GridFilterModel } from "@mui/x-data-grid";
+import { usePackageActions } from "hooks/usePackageActions";
+import { usePackageData } from "hooks/usePackageData";
+import { useTableState } from "hooks/useTableState";
+import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
+import type { BuildStatus } from "models/BuildStatus";
+import type { PackageRow } from "models/PackageRow";
+import { useEffect } from "react";
+
+export interface UsePackageTableResult {
+ rows: PackageRow[];
+ isLoading: boolean;
+ isAuthorized: boolean;
+ status: BuildStatus | undefined;
+
+ selectionModel: string[];
+ setSelectionModel: (model: string[]) => void;
+
+ dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
+ setDialogOpen: (dialog: "dashboard" | "add" | "rebuild" | "keyImport" | null) => void;
+ selectedPackage: string | null;
+ setSelectedPackage: (base: string | null) => void;
+
+ paginationModel: { pageSize: number; page: number };
+ setPaginationModel: (model: { pageSize: number; page: number }) => void;
+ columnVisibility: Record;
+ setColumnVisibility: (model: Record) => void;
+ filterModel: GridFilterModel;
+ setFilterModel: (model: GridFilterModel) => void;
+ searchText: string;
+ setSearchText: (text: string) => void;
+
+ autoRefreshInterval: number;
+ onAutoRefreshIntervalChange: (interval: number) => void;
+
+ handleReload: () => void;
+ handleUpdate: () => Promise;
+ handleRefreshDb: () => Promise;
+ handleRemove: () => Promise;
+}
+
+export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
+ const { rows, isLoading, isAuthorized, status, autoRefresh } = usePackageData(autoRefreshIntervals);
+ const tableState = useTableState();
+ const actions = usePackageActions(tableState.selectionModel, tableState.setSelectionModel);
+
+ // Pause auto-refresh when dialog is open
+ const isDialogOpen = tableState.dialogOpen !== null || tableState.selectedPackage !== null;
+ const setPaused = autoRefresh.setPaused;
+ useEffect(() => {
+ setPaused(isDialogOpen);
+ }, [isDialogOpen, setPaused]);
+
+ return {
+ rows,
+ isLoading,
+ isAuthorized,
+ status,
+
+ ...tableState,
+
+ autoRefreshInterval: autoRefresh.interval,
+ onAutoRefreshIntervalChange: autoRefresh.setInterval,
+
+ ...actions,
+ };
+}
diff --git a/frontend/src/hooks/useRepository.ts b/frontend/src/hooks/useRepository.ts
new file mode 100644
index 00000000..52afb0f8
--- /dev/null
+++ b/frontend/src/hooks/useRepository.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { RepositoryContext, type RepositoryContextValue } from "contexts/RepositoryContext";
+import { useContextNotNull } from "hooks/useContextNotNull";
+
+export function useRepository(): RepositoryContextValue {
+ return useContextNotNull(RepositoryContext);
+}
diff --git a/frontend/src/hooks/useSelectedRepository.ts b/frontend/src/hooks/useSelectedRepository.ts
new file mode 100644
index 00000000..a9405cc4
--- /dev/null
+++ b/frontend/src/hooks/useSelectedRepository.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { useRepository } from "hooks/useRepository";
+import type { RepositoryId } from "models/RepositoryId";
+import { useState } from "react";
+
+export interface SelectedRepositoryResult {
+ selectedKey: string;
+ setSelectedKey: (key: string) => void;
+ selectedRepository: RepositoryId | null;
+ reset: () => void;
+}
+
+export function useSelectedRepository(): SelectedRepositoryResult {
+ const { repositories, current } = useRepository();
+ const [selectedKey, setSelectedKey] = useState("");
+
+ let selectedRepository: RepositoryId | null = current;
+ if (selectedKey) {
+ const repository = repositories.find(repository => repository.key === selectedKey);
+ if (repository) {
+ selectedRepository = repository;
+ }
+ }
+
+ const reset: () => void = () => {
+ setSelectedKey("");
+ };
+
+ return { selectedKey, setSelectedKey, selectedRepository, reset };
+}
diff --git a/frontend/src/hooks/useTableState.ts b/frontend/src/hooks/useTableState.ts
new file mode 100644
index 00000000..4e28a4be
--- /dev/null
+++ b/frontend/src/hooks/useTableState.ts
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { GridFilterModel } from "@mui/x-data-grid";
+import { useLocalStorage } from "hooks/useLocalStorage";
+import { useState } from "react";
+
+export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport";
+
+export interface UseTableStateResult {
+ selectionModel: string[];
+ setSelectionModel: (model: string[]) => void;
+
+ dialogOpen: DialogType | null;
+ setDialogOpen: (dialog: DialogType | null) => void;
+ selectedPackage: string | null;
+ setSelectedPackage: (base: string | null) => void;
+
+ paginationModel: { pageSize: number; page: number };
+ setPaginationModel: (model: { pageSize: number; page: number }) => void;
+ columnVisibility: Record;
+ setColumnVisibility: (model: Record) => void;
+ filterModel: GridFilterModel;
+ setFilterModel: (model: GridFilterModel) => void;
+ searchText: string;
+ setSearchText: (text: string) => void;
+}
+
+export function useTableState(): UseTableStateResult {
+ const [selectionModel, setSelectionModel] = useState([]);
+ const [dialogOpen, setDialogOpen] = useState(null);
+ const [selectedPackage, setSelectedPackage] = useState(null);
+ const [searchText, setSearchText] = useState("");
+
+ const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
+ pageSize: 10,
+ page: 0,
+ });
+ const [columnVisibility, setColumnVisibility] = useLocalStorage>(
+ "ahriman-packages-columns",
+ { groups: false, licenses: false, packager: false },
+ );
+ const [filterModel, setFilterModel] = useLocalStorage(
+ "ahriman-packages-filters",
+ { items: [] },
+ );
+
+ return {
+ selectionModel,
+ setSelectionModel,
+
+ dialogOpen,
+ setDialogOpen,
+ selectedPackage,
+ setSelectedPackage,
+
+ paginationModel,
+ setPaginationModel,
+ columnVisibility,
+ setColumnVisibility,
+ filterModel,
+ setFilterModel,
+ searchText,
+ setSearchText,
+ };
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 00000000..6e74ebae
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import "chartSetup";
+import "utils";
+
+import App from "App";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/frontend/src/models/AURPackage.ts b/frontend/src/models/AURPackage.ts
new file mode 100644
index 00000000..a7523f3e
--- /dev/null
+++ b/frontend/src/models/AURPackage.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface AURPackage {
+ package: string;
+ description: string;
+}
diff --git a/frontend/src/models/AuthInfo.ts b/frontend/src/models/AuthInfo.ts
new file mode 100644
index 00000000..448be7d4
--- /dev/null
+++ b/frontend/src/models/AuthInfo.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface AuthInfo {
+ control: string;
+ enabled: boolean;
+ username?: string;
+}
diff --git a/frontend/src/models/AutoRefreshInterval.ts b/frontend/src/models/AutoRefreshInterval.ts
new file mode 100644
index 00000000..bc9f43f1
--- /dev/null
+++ b/frontend/src/models/AutoRefreshInterval.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface AutoRefreshInterval {
+ interval: number;
+ is_active: boolean;
+ text: string;
+}
diff --git a/frontend/src/models/BuildStatus.ts b/frontend/src/models/BuildStatus.ts
new file mode 100644
index 00000000..a0aa37a3
--- /dev/null
+++ b/frontend/src/models/BuildStatus.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export type BuildStatus = "unknown" | "pending" | "building" | "failed" | "success";
diff --git a/frontend/src/models/Changes.ts b/frontend/src/models/Changes.ts
new file mode 100644
index 00000000..28e479ff
--- /dev/null
+++ b/frontend/src/models/Changes.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface Changes {
+ changes?: string;
+ last_commit_sha?: string;
+}
diff --git a/frontend/src/models/Counters.ts b/frontend/src/models/Counters.ts
new file mode 100644
index 00000000..717c694c
--- /dev/null
+++ b/frontend/src/models/Counters.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface Counters {
+ building: number;
+ failed: number;
+ pending: number;
+ success: number;
+ total: number;
+ unknown: number;
+}
diff --git a/frontend/src/models/Dependencies.ts b/frontend/src/models/Dependencies.ts
new file mode 100644
index 00000000..e02c02cc
--- /dev/null
+++ b/frontend/src/models/Dependencies.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface Dependencies {
+ paths: Record;
+}
diff --git a/frontend/src/models/Event.ts b/frontend/src/models/Event.ts
new file mode 100644
index 00000000..ac28d93b
--- /dev/null
+++ b/frontend/src/models/Event.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface Event {
+ created: number;
+ data?: Record;
+ event: string;
+ message?: string;
+ object_id: string;
+}
diff --git a/frontend/src/models/InfoResponse.ts b/frontend/src/models/InfoResponse.ts
new file mode 100644
index 00000000..a323fa77
--- /dev/null
+++ b/frontend/src/models/InfoResponse.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { AuthInfo } from "models/AuthInfo";
+import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
+import type { RepositoryId } from "models/RepositoryId";
+
+export interface InfoResponse {
+ auth: AuthInfo;
+ repositories: RepositoryId[];
+ version: string;
+ autorefresh_intervals: AutoRefreshInterval[];
+ docs_enabled: boolean;
+ index_url?: string;
+}
diff --git a/frontend/src/models/InternalStatus.ts b/frontend/src/models/InternalStatus.ts
new file mode 100644
index 00000000..3b25bc4d
--- /dev/null
+++ b/frontend/src/models/InternalStatus.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { Counters } from "models/Counters";
+import type { RepositoryStats } from "models/RepositoryStats";
+import type { Status } from "models/Status";
+
+export interface InternalStatus {
+ architecture: string;
+ repository: string;
+ packages: Counters;
+ stats: RepositoryStats;
+ status: Status;
+ version: string;
+}
diff --git a/frontend/src/models/LogRecord.ts b/frontend/src/models/LogRecord.ts
new file mode 100644
index 00000000..92f3971e
--- /dev/null
+++ b/frontend/src/models/LogRecord.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface LogRecord {
+ created: number;
+ message: string;
+ process_id: string;
+ version: string;
+}
diff --git a/frontend/src/models/LoginRequest.ts b/frontend/src/models/LoginRequest.ts
new file mode 100644
index 00000000..53ef5fe1
--- /dev/null
+++ b/frontend/src/models/LoginRequest.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface LoginRequest {
+ username: string;
+ password: string;
+}
diff --git a/frontend/src/models/PGPKey.ts b/frontend/src/models/PGPKey.ts
new file mode 100644
index 00000000..c8cb510a
--- /dev/null
+++ b/frontend/src/models/PGPKey.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface PGPKey {
+ key: string;
+}
diff --git a/frontend/src/models/PGPKeyRequest.ts b/frontend/src/models/PGPKeyRequest.ts
new file mode 100644
index 00000000..01d189a2
--- /dev/null
+++ b/frontend/src/models/PGPKeyRequest.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface PGPKeyRequest {
+ key: string;
+ server: string;
+}
diff --git a/frontend/src/models/Package.ts b/frontend/src/models/Package.ts
new file mode 100644
index 00000000..aca2518a
--- /dev/null
+++ b/frontend/src/models/Package.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { PackageProperties } from "models/PackageProperties";
+import type { Remote } from "models/Remote";
+
+export interface Package {
+ base: string;
+ packager?: string;
+ packages: Record;
+ remote: Remote;
+ version: string;
+}
diff --git a/frontend/src/models/PackageActionRequest.ts b/frontend/src/models/PackageActionRequest.ts
new file mode 100644
index 00000000..d84134e5
--- /dev/null
+++ b/frontend/src/models/PackageActionRequest.ts
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { Patch } from "models/Patch";
+
+export interface PackageActionRequest {
+ packages: string[];
+ patches?: Patch[];
+ refresh?: boolean;
+ aur?: boolean;
+ local?: boolean;
+ manual?: boolean;
+}
diff --git a/frontend/src/models/PackageProperties.ts b/frontend/src/models/PackageProperties.ts
new file mode 100644
index 00000000..021af495
--- /dev/null
+++ b/frontend/src/models/PackageProperties.ts
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface PackageProperties {
+ architecture?: string;
+ archive_size?: number;
+ build_date?: number;
+ check_depends?: string[];
+ depends?: string[];
+ description?: string;
+ filename?: string;
+ groups?: string[];
+ installed_size?: number;
+ licenses?: string[];
+ make_depends?: string[];
+ opt_depends?: string[];
+ provides?: string[];
+ url?: string;
+}
diff --git a/frontend/src/models/PackageRow.ts b/frontend/src/models/PackageRow.ts
new file mode 100644
index 00000000..0012cea4
--- /dev/null
+++ b/frontend/src/models/PackageRow.ts
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { BuildStatus } from "models/BuildStatus";
+import type { PackageStatus } from "models/PackageStatus";
+
+export class PackageRow {
+ id: string;
+ base: string;
+ webUrl?: string;
+ version: string;
+ packages: string[];
+ groups: string[];
+ licenses: string[];
+ packager: string;
+ timestamp: string;
+ timestampValue: number;
+ status: BuildStatus;
+
+ constructor(descriptor: PackageStatus) {
+ this.id = descriptor.package.base;
+ this.base = descriptor.package.base;
+ this.webUrl = descriptor.package.remote.web_url ?? undefined;
+ this.version = descriptor.package.version;
+ this.packages = Object.keys(descriptor.package.packages).sort();
+ this.groups = PackageRow.extractListProperties(descriptor.package, "groups");
+ this.licenses = PackageRow.extractListProperties(descriptor.package, "licenses");
+ this.packager = descriptor.package.packager ?? "";
+ this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort();
+ this.timestampValue = descriptor.status.timestamp;
+ this.status = descriptor.status.status;
+ }
+
+ private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
+ return [
+ ...new Set(
+ Object.values(pkg.packages)
+ .flatMap(properties => properties[property] ?? []),
+ ),
+ ].sort();
+ }
+}
diff --git a/frontend/src/models/PackageSource.ts b/frontend/src/models/PackageSource.ts
new file mode 100644
index 00000000..1221fad9
--- /dev/null
+++ b/frontend/src/models/PackageSource.ts
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export type PackageSource = "auto" | "archive" | "aur" | "directory" | "local" | "remote" | "repository";
diff --git a/frontend/src/models/PackageStatus.ts b/frontend/src/models/PackageStatus.ts
new file mode 100644
index 00000000..ce516aa7
--- /dev/null
+++ b/frontend/src/models/PackageStatus.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { Package } from "models/Package";
+import type { Status } from "models/Status";
+
+export interface PackageStatus {
+ package: Package;
+ status: Status;
+}
diff --git a/frontend/src/models/Patch.ts b/frontend/src/models/Patch.ts
new file mode 100644
index 00000000..350750f2
--- /dev/null
+++ b/frontend/src/models/Patch.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface Patch {
+ key: string;
+ value: string;
+}
diff --git a/frontend/src/models/Remote.ts b/frontend/src/models/Remote.ts
new file mode 100644
index 00000000..b2e2dd82
--- /dev/null
+++ b/frontend/src/models/Remote.ts
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { PackageSource } from "models/PackageSource";
+
+export interface Remote {
+ branch?: string;
+ git_url?: string;
+ path?: string;
+ source: PackageSource;
+ web_url?: string;
+}
diff --git a/frontend/src/models/RepositoryId.ts b/frontend/src/models/RepositoryId.ts
new file mode 100644
index 00000000..1d0547fc
--- /dev/null
+++ b/frontend/src/models/RepositoryId.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export class RepositoryId {
+ readonly architecture: string;
+ readonly repository: string;
+
+ constructor(architecture: string, repository: string) {
+ this.architecture = architecture;
+ this.repository = repository;
+ }
+
+ get key(): string {
+ return `${this.architecture}-${this.repository}`;
+ }
+
+ get label(): string {
+ return `${this.repository} (${this.architecture})`;
+ }
+
+ toQuery(): Record {
+ return { architecture: this.architecture, repository: this.repository };
+ }
+}
diff --git a/frontend/src/models/RepositoryStats.ts b/frontend/src/models/RepositoryStats.ts
new file mode 100644
index 00000000..42e97613
--- /dev/null
+++ b/frontend/src/models/RepositoryStats.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+export interface RepositoryStats {
+ archive_size?: number;
+ bases?: number;
+ installed_size?: number;
+ packages?: number;
+}
diff --git a/frontend/src/models/Status.ts b/frontend/src/models/Status.ts
new file mode 100644
index 00000000..01715863
--- /dev/null
+++ b/frontend/src/models/Status.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { BuildStatus } from "models/BuildStatus";
+
+export interface Status {
+ status: BuildStatus;
+ timestamp: number;
+}
diff --git a/frontend/src/theme/StatusColors.ts b/frontend/src/theme/StatusColors.ts
new file mode 100644
index 00000000..fa900fb4
--- /dev/null
+++ b/frontend/src/theme/StatusColors.ts
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { amber, green, grey, orange, red } from "@mui/material/colors";
+import type { BuildStatus } from "models/BuildStatus";
+
+const base: Record = {
+ unknown: grey[800],
+ pending: amber[900],
+ building: orange[900],
+ failed: red[900],
+ success: green[800],
+};
+
+const headerBase: Record = {
+ unknown: grey[800],
+ pending: amber[700],
+ building: orange[600],
+ failed: red[500],
+ success: green[600],
+};
+
+export const StatusColors = base;
+
+export const StatusHeaderStyles: Record = Object.fromEntries(
+ Object.entries(headerBase).map(([key, value]) => [key, { backgroundColor: value, color: "#fff" }]),
+) as Record;
diff --git a/frontend/src/theme/Theme.ts b/frontend/src/theme/Theme.ts
new file mode 100644
index 00000000..d08acc1b
--- /dev/null
+++ b/frontend/src/theme/Theme.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import { createTheme } from "@mui/material/styles";
+
+const Theme = createTheme({
+ components: {
+ MuiDialog: {
+ defaultProps: {
+ maxWidth: "lg",
+ fullWidth: true,
+ },
+ },
+ },
+});
+
+export default Theme;
diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts
new file mode 100644
index 00000000..5a919fc1
--- /dev/null
+++ b/frontend/src/utils.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2021-2026 ahriman team.
+ *
+ * This file is part of ahriman
+ * (see https://github.com/arcan1s/ahriman).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
+
+// https://www.typescriptlang.org/docs/handbook/mixins.html#alternative-pattern
+/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */
+export function applyMixins(clazz: any, classes: any[]): void {
+ classes.forEach(baseClass => {
+ Object.getOwnPropertyNames(baseClass.prototype).forEach(name => {
+ Object.defineProperty(
+ clazz.prototype,
+ name,
+ Object.getOwnPropertyDescriptor(baseClass.prototype, name) || Object.create(null),
+ );
+ });
+ });
+}
+/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument */
+
+export function defaultInterval(intervals: AutoRefreshInterval[]): number {
+ return intervals.find(interval => interval.is_active)?.interval ?? 0;
+}
+
+declare global {
+ interface Date {
+ toISOStringShort(): string;
+ }
+}
+
+// custom formatter to print pretty date, because there is no builtin for this
+Date.prototype.toISOStringShort = function (): string {
+ const pad: (num: number) => string = num => String(num).padStart(2, "0");
+ return `${this.getFullYear()}-${pad(this.getMonth() + 1)}-${pad(this.getDate())} ${pad(this.getHours())}:${pad(this.getMinutes())}:${pad(this.getSeconds())}`;
+};
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 00000000..018317b7
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "allowImportingTsExtensions": true,
+ "baseUrl": "src",
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ES2020",
+ "useDefineForClassFields": true
+ },
+ "include": ["src"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 00000000..65a11cb1
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,38 @@
+import { defineConfig, type Plugin } from "vite";
+import react from "@vitejs/plugin-react";
+import tsconfigPaths from "vite-tsconfig-paths";
+import path from "path";
+
+function renameHtml(newName: string): Plugin {
+ return {
+ name: "rename-html",
+ enforce: "post",
+ generateBundle(_, bundle) {
+ if (bundle["index.html"]) {
+ bundle["index.html"].fileName = newName;
+ }
+ },
+ };
+}
+
+export default defineConfig({
+ plugins: [react(), tsconfigPaths(), renameHtml("build-status.jinja2")],
+ base: "/",
+ build: {
+ chunkSizeWarningLimit: 10000,
+ emptyOutDir: false,
+ outDir: path.resolve(__dirname, "../package/share/ahriman/templates"),
+ rollupOptions: {
+ output: {
+ entryFileNames: "static/[name].js",
+ chunkFileNames: "static/[name].js",
+ assetFileNames: "static/[name].[ext]",
+ },
+ },
+ },
+ server: {
+ proxy: {
+ "/api": "http://localhost:8080",
+ },
+ },
+});
diff --git a/package/archlinux/PKGBUILD b/package/archlinux/PKGBUILD
index ecc7907e..c60ea2f8 100644
--- a/package/archlinux/PKGBUILD
+++ b/package/archlinux/PKGBUILD
@@ -9,7 +9,7 @@ arch=('any')
url="https://ahriman.readthedocs.io/"
license=('GPL-3.0-or-later')
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-bcrypt' 'python-filelock' 'python-inflection' 'python-pyelftools' 'python-requests')
-makedepends=('python-build' 'python-flit' 'python-installer' 'python-wheel')
+makedepends=('npm' 'python-build' 'python-flit' 'python-installer' 'python-wheel')
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgbase-$pkgver.tar.gz"
"$pkgbase.sysusers"
"$pkgbase.tmpfiles")
@@ -18,6 +18,10 @@ build() {
cd "$pkgbase-$pkgver"
python -m build --wheel --no-isolation
+
+ cd "frontend"
+ npm install --cache "$srcdir/npm-cache"
+ npm run build
}
package_ahriman() {
diff --git a/package/share/ahriman/settings/ahriman.ini.d/00-web.ini b/package/share/ahriman/settings/ahriman.ini.d/00-web.ini
index 804584a3..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-legacy.jinja2 b/package/share/ahriman/templates/build-status-legacy.jinja2
new file mode 100644
index 00000000..a1a62940
--- /dev/null
+++ b/package/share/ahriman/templates/build-status-legacy.jinja2
@@ -0,0 +1,191 @@
+
+
+
+ ahriman
+
+
+
+ {% include "utils/style.jinja2" %}
+ {% include "user-style.jinja2" ignore missing %}
+
+
+
+
+ {% include "utils/bootstrap-scripts.jinja2" %}
+
+
+
+
+
+
+
+
+
+
+
+ {% if not auth.enabled or auth.username is not none %}
+
+
-
- {% if auth.enabled %}
- {% include "build-status/login-modal.jinja2" %}
- {% endif %}
-
- {% include "build-status/alerts.jinja2" %}
-
- {% include "build-status/dashboard.jinja2" %}
- {% include "build-status/package-add-modal.jinja2" %}
- {% include "build-status/package-rebuild-modal.jinja2" %}
- {% include "build-status/key-import-modal.jinja2" %}
-
- {% include "build-status/package-info-modal.jinja2" %}
-
- {% include "build-status/table.jinja2" %}
-
-
+
+
diff --git a/pyproject.toml b/pyproject.toml
index dc82e6ca..aae0ba62 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -118,11 +118,14 @@ include = [
"CONTRIBUTING.md",
"SECURITY.md",
"package",
+ "frontend",
"subpackages.py",
"web.png",
]
exclude = [
"package/archlinux",
+ "frontend/node_modules",
+ "frontend/package-lock.json",
]
[tool.flit.external-data]
diff --git a/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..79b71424 100644
--- a/src/ahriman/core/utils.py
+++ b/src/ahriman/core/utils.py
@@ -35,7 +35,7 @@ from enum import Enum
from filelock import FileLock
from pathlib import Path
from pwd import getpwuid
-from typing import Any, IO, TypeVar
+from typing import Any, IO, TypeVar, cast
from ahriman.core.exceptions import CalledProcessError, OptionError, UnsafeRunError
from ahriman.core.types import Comparable
@@ -285,16 +285,17 @@ def filelock(path: Path) -> Iterator[FileLock]:
lock_path.unlink(missing_ok=True)
-def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str, Any]:
+def filter_json(source: T, known_fields: Iterable[str] | None = None) -> T:
"""
- filter json object by fields used for json-to-object conversion
+ recursively filter json object removing ``None`` values and optionally filtering by known fields
Args:
- source(dict[str, Any]): raw json object
- known_fields(Iterable[str]): list of fields which have to be known for the target object
+ source(T): raw json object (dict, list, or scalar)
+ known_fields(Iterable[str] | None, optional): list of fields which have to be known for the target object
+ (Default value = None)
Returns:
- dict[str, Any]: json object without unknown and empty fields
+ T: json without ``None`` values
Examples:
This wrapper is mainly used for the dataclasses, thus the flow must be something like this::
@@ -306,7 +307,15 @@ def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str
>>> properties = filter_json(dump, known_fields)
>>> package = Package(**properties)
"""
- return {key: value for key, value in source.items() if key in known_fields and value is not None}
+ if isinstance(source, dict):
+ return cast(T, {
+ key: filter_json(value)
+ for key, value in source.items()
+ if value is not None and (known_fields is None or key in known_fields)
+ })
+ if isinstance(source, list):
+ return cast(T, [filter_json(value) for value in source])
+ return source
def full_version(epoch: str | int | None, pkgver: str, pkgrel: str) -> str:
diff --git a/src/ahriman/web/schemas/__init__.py b/src/ahriman/web/schemas/__init__.py
index 2710f174..b04d2621 100644
--- a/src/ahriman/web/schemas/__init__.py
+++ b/src/ahriman/web/schemas/__init__.py
@@ -19,7 +19,9 @@
#
from ahriman.web.schemas.any_schema import AnySchema
from ahriman.web.schemas.aur_package_schema import AURPackageSchema
+from ahriman.web.schemas.auth_info_schema import AuthInfoSchema
from ahriman.web.schemas.auth_schema import AuthSchema
+from ahriman.web.schemas.auto_refresh_interval_schema import AutoRefreshIntervalSchema
from ahriman.web.schemas.build_options_schema import BuildOptionsSchema
from ahriman.web.schemas.changes_schema import ChangesSchema
from ahriman.web.schemas.configuration_schema import ConfigurationSchema
@@ -30,6 +32,7 @@ from ahriman.web.schemas.event_schema import EventSchema
from ahriman.web.schemas.event_search_schema import EventSearchSchema
from ahriman.web.schemas.file_schema import FileSchema
from ahriman.web.schemas.info_schema import InfoSchema
+from ahriman.web.schemas.info_v2_schema import InfoV2Schema
from ahriman.web.schemas.internal_status_schema import InternalStatusSchema
from ahriman.web.schemas.log_schema import LogSchema
from ahriman.web.schemas.login_schema import LoginSchema
diff --git a/src/ahriman/web/schemas/auth_info_schema.py b/src/ahriman/web/schemas/auth_info_schema.py
new file mode 100644
index 00000000..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..9c20bab5
--- /dev/null
+++ b/src/ahriman/web/server_info.py
@@ -0,0 +1,72 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from collections.abc import Callable
+from typing import Any
+
+from ahriman import __version__
+from ahriman.core.auth.helpers import authorized_userid
+from ahriman.core.types import Comparable
+from ahriman.core.utils import pretty_interval
+from ahriman.web.apispec import aiohttp_apispec
+from ahriman.web.views.base import BaseView
+
+
+__all__ = ["server_info"]
+
+
+async def server_info(view: BaseView) -> dict[str, Any]:
+ """
+ generate server info which can be used in responses directly
+
+ Args:
+ view(BaseView): view of the request
+
+ Returns:
+ dict[str, Any]: server info as a json response
+ """
+ autorefresh_intervals = [
+ {
+ "interval": interval * 1000, # milliseconds
+ "is_active": index == 0, # first element is always default
+ "text": pretty_interval(interval),
+ }
+ for index, interval in enumerate(view.configuration.getintlist("web", "autorefresh_intervals", fallback=[]))
+ if interval > 0 # special case if 0 exists and first, refresh will not be turned on by default
+ ]
+ comparator: Callable[[dict[str, Any]], Comparable] = lambda interval: interval["interval"]
+
+ return {
+ "auth": {
+ "control": view.validator.auth_control,
+ "enabled": view.validator.enabled,
+ "username": await authorized_userid(view.request),
+ },
+ "autorefresh_intervals": sorted(autorefresh_intervals, key=comparator),
+ "docs_enabled": aiohttp_apispec is not None,
+ "index_url": view.configuration.get("web", "index_url", fallback=None),
+ "repositories": [
+ {
+ "id": repository_id.id,
+ **repository_id.view(),
+ }
+ for repository_id in sorted(view.services)
+ ],
+ "version": __version__,
+ }
diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py
index 4a28e44a..5a3f271f 100644
--- a/src/ahriman/web/views/base.py
+++ b/src/ahriman/web/views/base.py
@@ -17,10 +17,10 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNotFound, Request, StreamResponse, View
+from aiohttp.web import HTTPBadRequest, HTTPNotFound, Request, Response, StreamResponse, View, json_response
from aiohttp_cors import CorsViewMixin
from collections.abc import Awaitable, Callable
-from typing import ClassVar, TypeVar
+from typing import Any, ClassVar, TypeVar
from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration
@@ -29,6 +29,7 @@ from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.sign.gpg import GPG
from ahriman.core.spawn import Spawn
from ahriman.core.status.watcher import Watcher
+from ahriman.core.utils import filter_json
from ahriman.models.repository_id import RepositoryId
from ahriman.models.user_access import UserAccess
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
@@ -162,6 +163,20 @@ class BaseView(View, CorsViewMixin):
raise KeyError(f"Key {key} is missing or empty") from None
return value
+ @staticmethod
+ def json_response(data: dict[str, Any] | list[Any], **kwargs: Any) -> Response:
+ """
+ filter and convert data and return :class:`aiohttp.web.Response` object
+
+ Args:
+ data(dict[str, Any] | list[Any]): response in json format
+ **kwargs(Any): keyword arguments for :func:`aiohttp.web.json_response` function
+
+ Returns:
+ Response: generated response object
+ """
+ return json_response(filter_json(data), **kwargs)
+
# pylint: disable=not-callable,protected-access
async def head(self) -> StreamResponse:
"""
diff --git a/src/ahriman/web/views/index.py b/src/ahriman/web/views/index.py
index ec9dfd07..b2046441 100644
--- a/src/ahriman/web/views/index.py
+++ b/src/ahriman/web/views/index.py
@@ -19,12 +19,11 @@
#
import aiohttp_jinja2
-from typing import Any, ClassVar
+from aiohttp.web import Response
+from typing import ClassVar
-from ahriman.core.auth.helpers import authorized_userid
-from ahriman.core.utils import pretty_interval
from ahriman.models.user_access import UserAccess
-from ahriman.web.apispec import aiohttp_apispec
+from ahriman.web.server_info import server_info
from ahriman.web.views.base import BaseView
@@ -48,6 +47,7 @@ class IndexView(BaseView):
* id - unique repository identifier, string, required
* repository - repository name, string, required
* architecture - repository architecture, string, required
+ * version - service version, string, required
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
@@ -56,41 +56,14 @@ class IndexView(BaseView):
GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
ROUTES = ["/", "/index.html"]
- @aiohttp_jinja2.template("build-status.jinja2")
- async def get(self) -> dict[str, Any]:
+ async def get(self) -> Response:
"""
process get request. No parameters supported here
Returns:
- dict[str, Any]: parameters for jinja template
+ Response: 200 with rendered index page
"""
- auth_username = await authorized_userid(self.request)
- auth = {
- "control": self.validator.auth_control,
- "enabled": self.validator.enabled,
- "username": auth_username,
- }
+ context = await server_info(self)
- autorefresh_intervals = [
- {
- "interval": interval * 1000, # milliseconds
- "is_active": index == 0, # first element is always default
- "text": pretty_interval(interval),
- }
- for index, interval in enumerate(self.configuration.getintlist("web", "autorefresh_intervals", fallback=[]))
- if interval > 0 # special case if 0 exists and first, refresh will not be turned on by default
- ]
-
- return {
- "auth": auth,
- "autorefresh_intervals": sorted(autorefresh_intervals, key=lambda interval: interval["interval"]),
- "docs_enabled": aiohttp_apispec is not None,
- "index_url": self.configuration.get("web", "index_url", fallback=None),
- "repositories": [
- {
- "id": repository.id,
- **repository.view(),
- }
- for repository in sorted(self.services)
- ]
- }
+ template = self.configuration.get("web", "template", fallback="build-status.jinja2")
+ return aiohttp_jinja2.render_template(template, self.request, context)
diff --git a/src/ahriman/web/views/v1/auditlog/events.py b/src/ahriman/web/views/v1/auditlog/events.py
index 53a945a0..968e174c 100644
--- a/src/ahriman/web/views/v1/auditlog/events.py
+++ b/src/ahriman/web/views/v1/auditlog/events.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman.models.event import Event
@@ -70,7 +70,7 @@ class EventsView(BaseView):
events = self.service().event_get(event, object_id, from_date, to_date, limit, offset)
response = [event.view() for event in events]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Audit log"],
diff --git a/src/ahriman/web/views/v1/distributed/workers.py b/src/ahriman/web/views/v1/distributed/workers.py
index 56bb1cdf..d5de4f27 100644
--- a/src/ahriman/web/views/v1/distributed/workers.py
+++ b/src/ahriman/web/views/v1/distributed/workers.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from collections.abc import Callable
from typing import ClassVar
@@ -78,7 +78,7 @@ class WorkersView(BaseView):
comparator: Callable[[Worker], Comparable] = lambda item: item.identifier
response = [worker.view() for worker in sorted(workers, key=comparator)]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Distributed"],
diff --git a/src/ahriman/web/views/v1/packages/changes.py b/src/ahriman/web/views/v1/packages/changes.py
index 22b504ea..00b3f619 100644
--- a/src/ahriman/web/views/v1/packages/changes.py
+++ b/src/ahriman/web/views/v1/packages/changes.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman.models.changes import Changes
@@ -65,7 +65,7 @@ class ChangesView(StatusViewGuard, BaseView):
changes = self.service(package_base=package_base).package_changes_get(package_base)
- return json_response(changes.view())
+ return self.json_response(changes.view())
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/dependencies.py b/src/ahriman/web/views/v1/packages/dependencies.py
index 611f0167..9d4de53e 100644
--- a/src/ahriman/web/views/v1/packages/dependencies.py
+++ b/src/ahriman/web/views/v1/packages/dependencies.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman.models.dependencies import Dependencies
@@ -65,7 +65,7 @@ class DependenciesView(StatusViewGuard, BaseView):
dependencies = self.service(package_base=package_base).package_dependencies_get(package_base)
- return json_response(dependencies.view())
+ return self.json_response(dependencies.view())
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/logs.py b/src/ahriman/web/views/v1/packages/logs.py
index 5d712d83..26f4bf10 100644
--- a/src/ahriman/web/views/v1/packages/logs.py
+++ b/src/ahriman/web/views/v1/packages/logs.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response
from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError
@@ -99,7 +99,7 @@ class LogsView(StatusViewGuard, BaseView):
"status": status.view(),
"logs": "\n".join(f"[{pretty_datetime(log_record.created)}] {log_record.message}" for log_record in logs)
}
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/package.py b/src/ahriman/web/views/v1/packages/package.py
index d119c8cd..79e2546b 100644
--- a/src/ahriman/web/views/v1/packages/package.py
+++ b/src/ahriman/web/views/v1/packages/package.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response
from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError
@@ -105,7 +105,7 @@ class PackageView(StatusViewGuard, BaseView):
"repository": repository_id.view(),
}
]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/packages.py b/src/ahriman/web/views/v1/packages/packages.py
index e914d54d..a93d82b9 100644
--- a/src/ahriman/web/views/v1/packages/packages.py
+++ b/src/ahriman/web/views/v1/packages/packages.py
@@ -19,7 +19,7 @@
#
import itertools
-from aiohttp.web import HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPNoContent, Response
from collections.abc import Callable
from typing import ClassVar
@@ -78,7 +78,7 @@ class PackagesView(StatusViewGuard, BaseView):
} for package, status in itertools.islice(sorted(packages, key=comparator), offset, stop)
]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/packages/patch.py b/src/ahriman/web/views/v1/packages/patch.py
index 8850f5e8..9ec13bd4 100644
--- a/src/ahriman/web/views/v1/packages/patch.py
+++ b/src/ahriman/web/views/v1/packages/patch.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPNoContent, HTTPNotFound, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -89,4 +89,4 @@ class PatchView(StatusViewGuard, BaseView):
if selected is None:
raise HTTPNotFound(reason=f"Patch {variable} is unknown")
- return json_response(selected.view())
+ return self.json_response(selected.view())
diff --git a/src/ahriman/web/views/v1/packages/patches.py b/src/ahriman/web/views/v1/packages/patches.py
index 031af8b5..dd8346e0 100644
--- a/src/ahriman/web/views/v1/packages/patches.py
+++ b/src/ahriman/web/views/v1/packages/patches.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -60,7 +60,7 @@ class PatchesView(StatusViewGuard, BaseView):
patches = self.service().package_patches_get(package_base, None)
response = [patch.view() for patch in patches]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Packages"],
diff --git a/src/ahriman/web/views/v1/service/add.py b/src/ahriman/web/views/v1/service/add.py
index 9307bb64..a0356f77 100644
--- a/src/ahriman/web/views/v1/service/add.py
+++ b/src/ahriman/web/views/v1/service/add.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -78,4 +78,4 @@ class AddView(BaseView):
refresh=data.get("refresh", False),
)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/config.py b/src/ahriman/web/views/v1/service/config.py
index bdb74920..0801bce4 100644
--- a/src/ahriman/web/views/v1/service/config.py
+++ b/src/ahriman/web/views/v1/service/config.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPNoContent, Response
from typing import ClassVar
from ahriman.core.formatters import ConfigurationPrinter
@@ -64,7 +64,7 @@ class ConfigView(BaseView):
for key, value in values.items()
if key not in ConfigurationPrinter.HIDE_KEYS
]
- return json_response(response)
+ return self.json_response(response)
@apidocs(
tags=["Actions"],
diff --git a/src/ahriman/web/views/v1/service/pgp.py b/src/ahriman/web/views/v1/service/pgp.py
index fd7b1b98..7c682d91 100644
--- a/src/ahriman/web/views/v1/service/pgp.py
+++ b/src/ahriman/web/views/v1/service/pgp.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -71,7 +71,7 @@ class PGPView(BaseView):
except Exception:
raise HTTPNotFound(reason=f"Key {key} is unknown")
- return json_response({"key": key})
+ return self.json_response({"key": key})
@apidocs(
tags=["Actions"],
@@ -100,4 +100,4 @@ class PGPView(BaseView):
process_id = self.spawner.key_import(key, data.get("server"))
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/process.py b/src/ahriman/web/views/v1/service/process.py
index 6e32f685..bd8d42e1 100644
--- a/src/ahriman/web/views/v1/service/process.py
+++ b/src/ahriman/web/views/v1/service/process.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPNotFound, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -66,4 +66,4 @@ class ProcessView(BaseView):
"is_alive": is_alive,
}
- return json_response(response)
+ return self.json_response(response)
diff --git a/src/ahriman/web/views/v1/service/rebuild.py b/src/ahriman/web/views/v1/service/rebuild.py
index a0469b45..0e88749d 100644
--- a/src/ahriman/web/views/v1/service/rebuild.py
+++ b/src/ahriman/web/views/v1/service/rebuild.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -74,4 +74,4 @@ class RebuildView(BaseView):
increment=data.get("increment", True),
)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/remove.py b/src/ahriman/web/views/v1/service/remove.py
index b2e1668e..8a21589a 100644
--- a/src/ahriman/web/views/v1/service/remove.py
+++ b/src/ahriman/web/views/v1/service/remove.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -67,4 +67,4 @@ class RemoveView(BaseView):
repository_id = self.repository_id()
process_id = self.spawner.packages_remove(repository_id, packages)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/request.py b/src/ahriman/web/views/v1/service/request.py
index 93ae3f38..55eb2e28 100644
--- a/src/ahriman/web/views/v1/service/request.py
+++ b/src/ahriman/web/views/v1/service/request.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -78,4 +78,4 @@ class RequestView(BaseView):
refresh=False, # refresh doesn't work here
)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/service/search.py b/src/ahriman/web/views/v1/service/search.py
index 08b958e8..bd8ffb41 100644
--- a/src/ahriman/web/views/v1/service/search.py
+++ b/src/ahriman/web/views/v1/service/search.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response
from collections.abc import Callable
from typing import ClassVar
@@ -83,4 +83,4 @@ class SearchView(BaseView):
"description": package.description,
} for package in sorted(packages, key=comparator)
]
- return json_response(response)
+ return self.json_response(response)
diff --git a/src/ahriman/web/views/v1/service/update.py b/src/ahriman/web/views/v1/service/update.py
index 5eb14eaa..bc95b457 100644
--- a/src/ahriman/web/views/v1/service/update.py
+++ b/src/ahriman/web/views/v1/service/update.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, Response, json_response
+from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -75,4 +75,4 @@ class UpdateView(BaseView):
refresh=data.get("refresh", False),
)
- return json_response({"process_id": process_id})
+ return self.json_response({"process_id": process_id})
diff --git a/src/ahriman/web/views/v1/status/info.py b/src/ahriman/web/views/v1/status/info.py
index 7e92cd63..e1635f91 100644
--- a/src/ahriman/web/views/v1/status/info.py
+++ b/src/ahriman/web/views/v1/status/info.py
@@ -17,13 +17,13 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import Response, json_response
+from aiohttp.web import Response
from typing import ClassVar
-from ahriman import __version__
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import InfoSchema
+from ahriman.web.server_info import server_info
from ahriman.web.views.base import BaseView
@@ -52,13 +52,11 @@ class InfoView(BaseView):
Returns:
Response: 200 with service information object
"""
+ info = await server_info(self)
response = {
- "auth": self.validator.enabled,
- "repositories": [
- repository_id.view()
- for repository_id in sorted(self.services)
- ],
- "version": __version__,
+ "auth": info["auth"]["enabled"],
+ "repositories": info["repositories"],
+ "version": info["version"],
}
- return json_response(response)
+ return self.json_response(response)
diff --git a/src/ahriman/web/views/v1/status/repositories.py b/src/ahriman/web/views/v1/status/repositories.py
index 5e3ecc6a..81e333e3 100644
--- a/src/ahriman/web/views/v1/status/repositories.py
+++ b/src/ahriman/web/views/v1/status/repositories.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import Response, json_response
+from aiohttp.web import Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
@@ -56,4 +56,4 @@ class RepositoriesView(BaseView):
for repository_id in sorted(self.services)
]
- return json_response(repositories)
+ return self.json_response(repositories)
diff --git a/src/ahriman/web/views/v1/status/status.py b/src/ahriman/web/views/v1/status/status.py
index bc72f709..55a851a0 100644
--- a/src/ahriman/web/views/v1/status/status.py
+++ b/src/ahriman/web/views/v1/status/status.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
#
-from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
+from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
from typing import ClassVar
from ahriman import __version__
@@ -75,7 +75,7 @@ class StatusView(StatusViewGuard, BaseView):
version=__version__,
)
- return json_response(status.view())
+ return self.json_response(status.view())
@apidocs(
tags=["Status"],
diff --git a/src/ahriman/web/views/v2/packages/logs.py b/src/ahriman/web/views/v2/packages/logs.py
index b2ab43a1..231e7091 100644
--- a/src/ahriman/web/views/v2/packages/logs.py
+++ b/src/ahriman/web/views/v2/packages/logs.py
@@ -19,7 +19,7 @@
#
import itertools
-from aiohttp.web import Response, json_response
+from aiohttp.web import Response
from dataclasses import replace
from typing import ClassVar
@@ -31,8 +31,7 @@ from ahriman.web.views.status_view_guard import StatusViewGuard
class LogsView(StatusViewGuard, BaseView):
- """ else:
-
+ """
package logs web view
Attributes:
@@ -80,4 +79,4 @@ class LogsView(StatusViewGuard, BaseView):
]
response = [log_record.view() for log_record in logs]
- return json_response(response)
+ return self.json_response(response)
diff --git a/src/ahriman/web/views/v2/status/__init__.py b/src/ahriman/web/views/v2/status/__init__.py
new file mode 100644
index 00000000..cddc28d6
--- /dev/null
+++ b/src/ahriman/web/views/v2/status/__init__.py
@@ -0,0 +1,19 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
diff --git a/src/ahriman/web/views/v2/status/info.py b/src/ahriman/web/views/v2/status/info.py
new file mode 100644
index 00000000..c486173b
--- /dev/null
+++ b/src/ahriman/web/views/v2/status/info.py
@@ -0,0 +1,56 @@
+#
+# Copyright (c) 2021-2026 ahriman team.
+#
+# This file is part of ahriman
+# (see https://github.com/arcan1s/ahriman).
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+#
+from aiohttp.web import Response
+from typing import ClassVar
+
+from ahriman.models.user_access import UserAccess
+from ahriman.web.apispec.decorators import apidocs
+from ahriman.web.schemas import InfoV2Schema
+from ahriman.web.server_info import server_info
+from ahriman.web.views.base import BaseView
+
+
+class InfoView(BaseView):
+ """
+ web service information view
+
+ Attributes:
+ GET_PERMISSION(UserAccess): (class attribute) get permissions of self
+ """
+
+ GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
+ ROUTES = ["/api/v2/info"]
+
+ @apidocs(
+ tags=["Status"],
+ summary="Service information",
+ description="Perform basic service health check and returns its information",
+ permission=GET_PERMISSION,
+ schema=InfoV2Schema,
+ )
+ async def get(self) -> Response:
+ """
+ get service information
+
+ Returns:
+ Response: 200 with service information object
+ """
+ response = await server_info(self)
+ return self.json_response(response)
diff --git a/subpackages.py b/subpackages.py
index ec871c39..e5ba144c 100644
--- a/subpackages.py
+++ b/subpackages.py
@@ -45,10 +45,12 @@ SUBPACKAGES = {
prefix / "lib" / "systemd" / "system" / "ahriman-web.service",
prefix / "lib" / "systemd" / "system" / "ahriman-web@.service",
prefix / "share" / "ahriman" / "settings" / "ahriman.ini.d" / "00-web.ini",
- prefix / "share" / "ahriman" / "templates" / "api.jinja2",
prefix / "share" / "ahriman" / "templates" / "build-status",
prefix / "share" / "ahriman" / "templates" / "build-status.jinja2",
+ prefix / "share" / "ahriman" / "templates" / "build-status-legacy.jinja2",
+ prefix / "share" / "ahriman" / "templates" / "api.jinja2",
prefix / "share" / "ahriman" / "templates" / "error.jinja2",
+ prefix / "share" / "ahriman" / "templates" / "static",
site_packages / "ahriman" / "application" / "handlers" / "web.py",
site_packages / "ahriman" / "core" / "auth",
site_packages / "ahriman" / "web",
diff --git a/tests/ahriman/core/test_utils.py b/tests/ahriman/core/test_utils.py
index 10a84aea..ee9566bb 100644
--- a/tests/ahriman/core/test_utils.py
+++ b/tests/ahriman/core/test_utils.py
@@ -583,6 +583,7 @@ def test_walk(resource_path_root: Path) -> None:
resource_path_root / "web" / "templates" / "utils" / "style.jinja2",
resource_path_root / "web" / "templates" / "api.jinja2",
resource_path_root / "web" / "templates" / "build-status.jinja2",
+ resource_path_root / "web" / "templates" / "build-status-legacy.jinja2",
resource_path_root / "web" / "templates" / "email-index.jinja2",
resource_path_root / "web" / "templates" / "error.jinja2",
resource_path_root / "web" / "templates" / "repo-index.jinja2",
@@ -590,5 +591,5 @@ def test_walk(resource_path_root: Path) -> None:
resource_path_root / "web" / "templates" / "shell",
resource_path_root / "web" / "templates" / "telegram-index.jinja2",
])
- local_files = list(sorted(walk(resource_path_root)))
+ local_files = list(path for path in sorted(walk(resource_path_root)) if path.name not in ("index.js", "index.css"))
assert local_files == expected
diff --git a/tests/ahriman/web/schemas/test_auth_info_schema.py b/tests/ahriman/web/schemas/test_auth_info_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_auth_info_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/schemas/test_auto_refresh_interval_schema.py b/tests/ahriman/web/schemas/test_auto_refresh_interval_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_auto_refresh_interval_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/schemas/test_info_v2_schema.py b/tests/ahriman/web/schemas/test_info_v2_schema.py
new file mode 100644
index 00000000..1982fb6b
--- /dev/null
+++ b/tests/ahriman/web/schemas/test_info_v2_schema.py
@@ -0,0 +1 @@
+# schema testing goes in view class tests
diff --git a/tests/ahriman/web/test_server_info.py b/tests/ahriman/web/test_server_info.py
new file mode 100644
index 00000000..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..b59f572a 100644
--- a/tox.toml
+++ b/tox.toml
@@ -9,6 +9,16 @@ labels.release = [
"publish",
]
+[commands]
+npm_install = [
+ [
+ "npm",
+ { replace = "ref", of = ["flags", "npm"], extend = true },
+ "install",
+ "--cache", "{envtmpdir}/npm-cache",
+ ],
+]
+
[flags]
autopep8 = [
"--max-line-length", "120",
@@ -32,6 +42,9 @@ mypy = [
"--allow-untyped-decorators",
"--allow-subclassing-any",
]
+npm = [
+ "--prefix", "frontend",
+]
pydeps = [
"--no-config",
"--cluster",
@@ -78,6 +91,9 @@ commands = [
[env.check]
description = "Run common checks like linter, mypy, etc"
+allowlist_externals = [
+ "npm",
+]
dependency_groups = [
"check",
]
@@ -123,6 +139,13 @@ commands = [
"--non-interactive",
"--package", "{[project]name}",
],
+ { replace = "ref", of = ["commands", "npm_install"], extend = true },
+ [
+ "npm",
+ { replace = "ref", of = ["flags", "npm"], extend = true },
+ "run",
+ "lint",
+ ],
]
[env.docs]
@@ -193,6 +216,21 @@ commands = [
],
]
+[env.frontend]
+description = "Build frontend HTML and JS"
+allowlist_externals = [
+ "npm",
+]
+commands = [
+ { replace = "ref", of = ["commands", "npm_install"], extend = true },
+ [
+ "npm",
+ { replace = "ref", of = ["flags", "npm"], extend = true },
+ "run",
+ "build",
+ ],
+]
+
[env.html]
description = "Generate html documentation"
dependency_groups = [
@@ -278,6 +316,7 @@ commands = [
[env.version]
description = "Bump package version"
allowlist_externals = [
+ "npm",
"sed",
]
deps = [
@@ -295,6 +334,12 @@ commands = [
"s/^__version__ = .*/__version__ = \"{posargs}\"/",
"src/ahriman/__init__.py",
],
+ [
+ "npm",
+ { replace = "ref", of = ["flags", "npm"], extend = true },
+ "version",
+ "{posargs}",
+ ],
[
"sed",
"--in-place",