mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-04-07 02:53:38 +00:00
upload ai slop
This commit is contained in:
@@ -12,3 +12,6 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.pyd
|
*.pyd
|
||||||
*.pyo
|
*.pyo
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
frontend/
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -99,3 +99,9 @@ status_cache.json
|
|||||||
*.db
|
*.db
|
||||||
|
|
||||||
docs/html/
|
docs/html/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
package/share/ahriman/templates/static/index.js
|
||||||
|
package/share/ahriman/templates/static/index.css
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
|
|||||||
* ``port`` - port to bind, integer, optional.
|
* ``port`` - port to bind, integer, optional.
|
||||||
* ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``.
|
* ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``.
|
||||||
* ``static_path`` - path to directory with static files, string, required.
|
* ``static_path`` - path to directory with static files, string, required.
|
||||||
|
* ``template`` - Jinja2 template name for the index page, string, optional, default ``build-status.jinja2``.
|
||||||
* ``templates`` - path to templates directories, space separated list of paths, required.
|
* ``templates`` - path to templates directories, space separated list of paths, required.
|
||||||
* ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization.
|
* ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization.
|
||||||
* ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration.
|
* ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration.
|
||||||
|
|||||||
49
frontend/eslint.config.js
Normal file
49
frontend/eslint.config.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import stylistic from "@stylistic/eslint-plugin";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
|
||||||
|
files: ["src/**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
"@stylistic": stylistic,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
|
||||||
|
"curly": "error",
|
||||||
|
"@stylistic/brace-style": ["error", "1tbs"],
|
||||||
|
|
||||||
|
// stylistic
|
||||||
|
"@stylistic/indent": ["error", 4],
|
||||||
|
"@stylistic/quotes": ["error", "double"],
|
||||||
|
"@stylistic/semi": ["error", "always"],
|
||||||
|
"@stylistic/comma-dangle": ["error", "always-multiline"],
|
||||||
|
"@stylistic/object-curly-spacing": ["error", "always"],
|
||||||
|
"@stylistic/array-bracket-spacing": ["error", "never"],
|
||||||
|
"@stylistic/arrow-parens": ["error", "always"],
|
||||||
|
"@stylistic/eol-last": ["error", "always"],
|
||||||
|
"@stylistic/no-trailing-spaces": "error",
|
||||||
|
"@stylistic/no-multiple-empty-lines": ["error", { max: 1 }],
|
||||||
|
"@stylistic/jsx-quotes": ["error", "prefer-double"],
|
||||||
|
|
||||||
|
// typescript
|
||||||
|
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
|
||||||
|
"@typescript-eslint/no-deprecated": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ahriman</title>
|
||||||
|
|
||||||
|
<link rel="icon" href="/static/favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
frontend/package.json
Normal file
40
frontend/package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "ahriman-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"lint:fix": "eslint --fix src/",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.0",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@mui/icons-material": "^5.15.0",
|
||||||
|
"@mui/material": "^5.15.0",
|
||||||
|
"@mui/x-data-grid": "^7.0.0",
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
|
"highlight.js": "^11.11.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.3",
|
||||||
|
"@stylistic/eslint-plugin": "^5.9.0",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"eslint": "^9.39.3",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"typescript-eslint": "^8.56.1",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vite-tsconfig-paths": "^6.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/src/App.tsx
Normal file
35
frontend/src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
|
import CssBaseline from "@mui/material/CssBaseline";
|
||||||
|
import Theme from "theme/Theme";
|
||||||
|
import { AuthProvider } from "contexts/AuthProvider";
|
||||||
|
import { RepositoryProvider } from "contexts/RepositoryProvider";
|
||||||
|
import { NotificationProvider } from "contexts/NotificationProvider";
|
||||||
|
import AppLayout from "components/layout/AppLayout";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function App(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider theme={Theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<AuthProvider>
|
||||||
|
<RepositoryProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<AppLayout />
|
||||||
|
</NotificationProvider>
|
||||||
|
</RepositoryProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/api/QueryKeys.ts
Normal file
30
frontend/src/api/QueryKeys.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
function repoKey(repo: RepositoryId): string {
|
||||||
|
return `${repo.architecture}-${repo.repository}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueryKeys = {
|
||||||
|
info: ["info"] as const,
|
||||||
|
|
||||||
|
packages: (repo: RepositoryId) => ["packages", repoKey(repo)] as const,
|
||||||
|
package: (base: string, repo: RepositoryId) => ["packages", repoKey(repo), base] as const,
|
||||||
|
|
||||||
|
status: (repo: RepositoryId) => ["status", repoKey(repo)] as const,
|
||||||
|
|
||||||
|
logs: (base: string, repo: RepositoryId) => ["logs", repoKey(repo), base] as const,
|
||||||
|
logsVersion: (base: string, repo: RepositoryId, version: string, processId: string) =>
|
||||||
|
["logs", repoKey(repo), base, version, processId] as const,
|
||||||
|
|
||||||
|
changes: (base: string, repo: RepositoryId) => ["changes", repoKey(repo), base] as const,
|
||||||
|
|
||||||
|
dependencies: (base: string, repo: RepositoryId) => ["dependencies", repoKey(repo), base] as const,
|
||||||
|
|
||||||
|
patches: (base: string) => ["patches", base] as const,
|
||||||
|
|
||||||
|
events: (repo: RepositoryId, objectId?: string) => ["events", repoKey(repo), objectId] as const,
|
||||||
|
|
||||||
|
search: (query: string) => ["search", query] as const,
|
||||||
|
|
||||||
|
pgpKey: (key: string, server: string) => ["pgp", key, server] as const,
|
||||||
|
};
|
||||||
216
frontend/src/api/client/AhrimanClient.ts
Normal file
216
frontend/src/api/client/AhrimanClient.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import type { AURPackage } from "api/types/AURPackage";
|
||||||
|
import type { Changes } from "api/types/Changes";
|
||||||
|
import type { Dependencies } from "api/types/Dependencies";
|
||||||
|
import type { Event } from "api/types/Event";
|
||||||
|
import type { InfoResponse } from "api/types/InfoResponse";
|
||||||
|
import type { InternalStatus } from "api/types/InternalStatus";
|
||||||
|
import type { LogRecord } from "api/types/LogRecord";
|
||||||
|
import type { LoginRequest } from "api/types/LoginRequest";
|
||||||
|
import type { PackageActionRequest } from "api/types/PackageActionRequest";
|
||||||
|
import type { PackageStatus } from "api/types/PackageStatus";
|
||||||
|
import type { Patch } from "api/types/Patch";
|
||||||
|
import type { PGPKey } from "api/types/PGPKey";
|
||||||
|
import type { PGPKeyRequest } from "api/types/PGPKeyRequest";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import type { RequestOptions } from "api/client/RequestOptions";
|
||||||
|
|
||||||
|
export class AhrimanClient {
|
||||||
|
private static repoQuery(repo: RepositoryId): Record<string, string> {
|
||||||
|
return { architecture: repo.architecture, repository: repo.repository };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(url: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const { method, query, json } = options;
|
||||||
|
|
||||||
|
let fullUrl = url;
|
||||||
|
if (query) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fullUrl += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
method: method || (json ? "POST" : "GET"),
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (json !== undefined) {
|
||||||
|
requestInit.body = JSON.stringify(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(fullUrl, requestInit);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
throw new ApiError(response.status, response.statusText, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("Content-Type") ?? "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
return await response.text() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info
|
||||||
|
|
||||||
|
async fetchInfo(): Promise<InfoResponse> {
|
||||||
|
return this.request<InfoResponse>("/api/v2/info");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packages
|
||||||
|
|
||||||
|
async fetchPackages(repo: RepositoryId): Promise<PackageStatus[]> {
|
||||||
|
return this.request<PackageStatus[]>("/api/v1/packages", { query: AhrimanClient.repoQuery(repo) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchPackage(base: string, repo: RepositoryId): Promise<PackageStatus[]> {
|
||||||
|
return this.request<PackageStatus[]>(`/api/v1/packages/${encodeURIComponent(base)}`, {
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status
|
||||||
|
|
||||||
|
async fetchStatus(repo: RepositoryId): Promise<InternalStatus> {
|
||||||
|
return this.request<InternalStatus>("/api/v1/status", { query: AhrimanClient.repoQuery(repo) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
|
||||||
|
async fetchLogs(
|
||||||
|
base: string,
|
||||||
|
repo: RepositoryId,
|
||||||
|
version?: string,
|
||||||
|
processId?: string,
|
||||||
|
head?: boolean,
|
||||||
|
): Promise<LogRecord[]> {
|
||||||
|
const query: Record<string, string | boolean> = AhrimanClient.repoQuery(repo);
|
||||||
|
if (version) {
|
||||||
|
query.version = version;
|
||||||
|
}
|
||||||
|
if (processId) {
|
||||||
|
query.process_id = processId;
|
||||||
|
}
|
||||||
|
if (head) {
|
||||||
|
query.head = true;
|
||||||
|
}
|
||||||
|
return this.request<LogRecord[]>(`/api/v2/packages/${encodeURIComponent(base)}/logs`, { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changes
|
||||||
|
|
||||||
|
async fetchChanges(base: string, repo: RepositoryId): Promise<Changes> {
|
||||||
|
return this.request<Changes>(`/api/v1/packages/${encodeURIComponent(base)}/changes`, {
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
|
||||||
|
async fetchDependencies(base: string, repo: RepositoryId): Promise<Dependencies> {
|
||||||
|
return this.request<Dependencies>(`/api/v1/packages/${encodeURIComponent(base)}/dependencies`, {
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patches
|
||||||
|
|
||||||
|
async fetchPatches(base: string): Promise<Patch[]> {
|
||||||
|
return this.request<Patch[]>(`/api/v1/packages/${encodeURIComponent(base)}/patches`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePatch(base: string, key: string): Promise<void> {
|
||||||
|
return this.request(`/api/v1/packages/${encodeURIComponent(base)}/patches/${encodeURIComponent(key)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events
|
||||||
|
|
||||||
|
async fetchEvents(repo: RepositoryId, objectId?: string, limit?: number): Promise<Event[]> {
|
||||||
|
const query: Record<string, string | number> = AhrimanClient.repoQuery(repo);
|
||||||
|
if (objectId) {
|
||||||
|
query.object_id = objectId;
|
||||||
|
}
|
||||||
|
if (limit) {
|
||||||
|
query.limit = limit;
|
||||||
|
}
|
||||||
|
return this.request<Event[]>("/api/v1/events", { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service actions
|
||||||
|
|
||||||
|
async addPackages(repo: RepositoryId, data: PackageActionRequest): Promise<void> {
|
||||||
|
return this.request("/api/v1/service/add", { method: "POST", query: AhrimanClient.repoQuery(repo), json: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePackages(repo: RepositoryId, packages: string[]): Promise<void> {
|
||||||
|
return this.request("/api/v1/service/remove", {
|
||||||
|
method: "POST",
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
json: { packages },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePackages(repo: RepositoryId, data: PackageActionRequest): Promise<void> {
|
||||||
|
return this.request("/api/v1/service/update", {
|
||||||
|
method: "POST",
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
json: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rebuildPackages(repo: RepositoryId, packages: string[]): Promise<void> {
|
||||||
|
return this.request("/api/v1/service/rebuild", {
|
||||||
|
method: "POST",
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
json: { packages },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestPackages(repo: RepositoryId, data: PackageActionRequest): Promise<void> {
|
||||||
|
return this.request("/api/v1/service/request", {
|
||||||
|
method: "POST",
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
json: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
|
||||||
|
async searchPackages(query: string): Promise<AURPackage[]> {
|
||||||
|
return this.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PGP
|
||||||
|
|
||||||
|
async fetchPGPKey(key: string, server: string): Promise<PGPKey> {
|
||||||
|
return this.request<PGPKey>("/api/v1/service/pgp", { query: { key, server } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async importPGPKey(data: PGPKeyRequest): Promise<void> {
|
||||||
|
return this.request("/api/v1/service/pgp", { method: "POST", json: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
|
||||||
|
async login(data: LoginRequest): Promise<void> {
|
||||||
|
return this.request("/api/v1/login", { method: "POST", json: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
return this.request("/api/v1/logout", { method: "POST" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Client = new AhrimanClient();
|
||||||
21
frontend/src/api/client/ApiError.ts
Normal file
21
frontend/src/api/client/ApiError.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
body: string;
|
||||||
|
|
||||||
|
constructor(status: number, statusText: string, body: string) {
|
||||||
|
super(`${status} ${statusText}`);
|
||||||
|
this.status = status;
|
||||||
|
this.statusText = statusText;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
get detail(): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.body) as Record<string, string>;
|
||||||
|
return parsed.error ?? (this.body || this.message);
|
||||||
|
} catch {
|
||||||
|
return this.body || this.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/src/api/client/RequestOptions.ts
Normal file
5
frontend/src/api/client/RequestOptions.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface RequestOptions {
|
||||||
|
method?: string;
|
||||||
|
query?: Record<string, string | number | boolean>;
|
||||||
|
json?: unknown;
|
||||||
|
}
|
||||||
4
frontend/src/api/types/AURPackage.ts
Normal file
4
frontend/src/api/types/AURPackage.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface AURPackage {
|
||||||
|
package: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
5
frontend/src/api/types/AuthInfo.ts
Normal file
5
frontend/src/api/types/AuthInfo.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface AuthInfo {
|
||||||
|
control: string;
|
||||||
|
enabled: boolean;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
5
frontend/src/api/types/AutoRefreshInterval.ts
Normal file
5
frontend/src/api/types/AutoRefreshInterval.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface AutoRefreshInterval {
|
||||||
|
interval: number;
|
||||||
|
is_active: boolean;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
1
frontend/src/api/types/BuildStatus.ts
Normal file
1
frontend/src/api/types/BuildStatus.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type BuildStatus = "unknown" | "pending" | "building" | "failed" | "success";
|
||||||
4
frontend/src/api/types/Changes.ts
Normal file
4
frontend/src/api/types/Changes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface Changes {
|
||||||
|
changes?: string;
|
||||||
|
last_commit_sha?: string;
|
||||||
|
}
|
||||||
8
frontend/src/api/types/Counters.ts
Normal file
8
frontend/src/api/types/Counters.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface Counters {
|
||||||
|
building: number;
|
||||||
|
failed: number;
|
||||||
|
pending: number;
|
||||||
|
success: number;
|
||||||
|
total: number;
|
||||||
|
unknown: number;
|
||||||
|
}
|
||||||
3
frontend/src/api/types/Dependencies.ts
Normal file
3
frontend/src/api/types/Dependencies.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface Dependencies {
|
||||||
|
paths: Record<string, string[]>;
|
||||||
|
}
|
||||||
7
frontend/src/api/types/Event.ts
Normal file
7
frontend/src/api/types/Event.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface Event {
|
||||||
|
created: number;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
event: string;
|
||||||
|
message?: string;
|
||||||
|
object_id: string;
|
||||||
|
}
|
||||||
12
frontend/src/api/types/InfoResponse.ts
Normal file
12
frontend/src/api/types/InfoResponse.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { AuthInfo } from "api/types/AuthInfo";
|
||||||
|
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
export interface InfoResponse {
|
||||||
|
auth: AuthInfo;
|
||||||
|
repositories: RepositoryId[];
|
||||||
|
version: string;
|
||||||
|
autorefresh_intervals: AutoRefreshInterval[];
|
||||||
|
docs_enabled: boolean;
|
||||||
|
index_url?: string;
|
||||||
|
}
|
||||||
12
frontend/src/api/types/InternalStatus.ts
Normal file
12
frontend/src/api/types/InternalStatus.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Counters } from "api/types/Counters";
|
||||||
|
import type { RepositoryStats } from "api/types/RepositoryStats";
|
||||||
|
import type { Status } from "api/types/Status";
|
||||||
|
|
||||||
|
export interface InternalStatus {
|
||||||
|
architecture: string;
|
||||||
|
repository: string;
|
||||||
|
packages: Counters;
|
||||||
|
stats: RepositoryStats;
|
||||||
|
status: Status;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
6
frontend/src/api/types/LogRecord.ts
Normal file
6
frontend/src/api/types/LogRecord.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface LogRecord {
|
||||||
|
created: number;
|
||||||
|
message: string;
|
||||||
|
process_id: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
4
frontend/src/api/types/LoginRequest.ts
Normal file
4
frontend/src/api/types/LoginRequest.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
3
frontend/src/api/types/PGPKey.ts
Normal file
3
frontend/src/api/types/PGPKey.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface PGPKey {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
4
frontend/src/api/types/PGPKeyRequest.ts
Normal file
4
frontend/src/api/types/PGPKeyRequest.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface PGPKeyRequest {
|
||||||
|
key: string;
|
||||||
|
server: string;
|
||||||
|
}
|
||||||
10
frontend/src/api/types/Package.ts
Normal file
10
frontend/src/api/types/Package.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { PackageProperties } from "api/types/PackageProperties";
|
||||||
|
import type { Remote } from "api/types/Remote";
|
||||||
|
|
||||||
|
export interface Package {
|
||||||
|
base: string;
|
||||||
|
packager?: string;
|
||||||
|
packages: Record<string, PackageProperties>;
|
||||||
|
remote: Remote;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
10
frontend/src/api/types/PackageActionRequest.ts
Normal file
10
frontend/src/api/types/PackageActionRequest.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Patch } from "api/types/Patch";
|
||||||
|
|
||||||
|
export interface PackageActionRequest {
|
||||||
|
packages: string[];
|
||||||
|
patches?: Patch[];
|
||||||
|
refresh?: boolean;
|
||||||
|
aur?: boolean;
|
||||||
|
local?: boolean;
|
||||||
|
manual?: boolean;
|
||||||
|
}
|
||||||
16
frontend/src/api/types/PackageProperties.ts
Normal file
16
frontend/src/api/types/PackageProperties.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface PackageProperties {
|
||||||
|
architecture?: string;
|
||||||
|
archive_size?: number;
|
||||||
|
build_date?: number;
|
||||||
|
check_depends?: string[];
|
||||||
|
depends?: string[];
|
||||||
|
description?: string;
|
||||||
|
filename?: string;
|
||||||
|
groups?: string[];
|
||||||
|
installed_size?: number;
|
||||||
|
licenses?: string[];
|
||||||
|
make_depends?: string[];
|
||||||
|
opt_depends?: string[];
|
||||||
|
provides?: string[];
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
15
frontend/src/api/types/PackageRow.ts
Normal file
15
frontend/src/api/types/PackageRow.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
|
||||||
|
export interface PackageRow {
|
||||||
|
id: string;
|
||||||
|
base: string;
|
||||||
|
webUrl?: string;
|
||||||
|
version: string;
|
||||||
|
packages: string[];
|
||||||
|
groups: string[];
|
||||||
|
licenses: string[];
|
||||||
|
packager: string;
|
||||||
|
timestamp: string;
|
||||||
|
timestampValue: number;
|
||||||
|
status: BuildStatus;
|
||||||
|
}
|
||||||
9
frontend/src/api/types/PackageStatus.ts
Normal file
9
frontend/src/api/types/PackageStatus.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Package } from "api/types/Package";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
import type { Status } from "api/types/Status";
|
||||||
|
|
||||||
|
export interface PackageStatus {
|
||||||
|
package: Package;
|
||||||
|
status: Status;
|
||||||
|
repository: RepositoryId;
|
||||||
|
}
|
||||||
4
frontend/src/api/types/Patch.ts
Normal file
4
frontend/src/api/types/Patch.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface Patch {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
7
frontend/src/api/types/Remote.ts
Normal file
7
frontend/src/api/types/Remote.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface Remote {
|
||||||
|
branch?: string;
|
||||||
|
git_url?: string;
|
||||||
|
path?: string;
|
||||||
|
source: string;
|
||||||
|
web_url?: string;
|
||||||
|
}
|
||||||
4
frontend/src/api/types/RepositoryId.ts
Normal file
4
frontend/src/api/types/RepositoryId.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface RepositoryId {
|
||||||
|
architecture: string;
|
||||||
|
repository: string;
|
||||||
|
}
|
||||||
6
frontend/src/api/types/RepositoryStats.ts
Normal file
6
frontend/src/api/types/RepositoryStats.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface RepositoryStats {
|
||||||
|
archive_size?: number;
|
||||||
|
bases?: number;
|
||||||
|
installed_size?: number;
|
||||||
|
packages?: number;
|
||||||
|
}
|
||||||
6
frontend/src/api/types/Status.ts
Normal file
6
frontend/src/api/types/Status.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
|
||||||
|
export interface Status {
|
||||||
|
status: BuildStatus;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
33
frontend/src/components/charts/EventDurationLineChart.tsx
Normal file
33
frontend/src/components/charts/EventDurationLineChart.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Line } from "react-chartjs-2";
|
||||||
|
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend } from "chart.js";
|
||||||
|
import type { Event } from "api/types/Event";
|
||||||
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
interface EventDurationLineChartProps {
|
||||||
|
events: Event[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventDurationLineChart({ events }: EventDurationLineChartProps): React.JSX.Element | null {
|
||||||
|
const updateEvents = events.filter((e) => e.event === "package-updated" && e.data?.took);
|
||||||
|
|
||||||
|
if (updateEvents.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: updateEvents.map((e) => formatTimestamp(e.created)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "update duration, s",
|
||||||
|
data: updateEvents.map((e) => (e.data as Record<string, number>).took),
|
||||||
|
cubicInterpolationMode: "monotone" as const,
|
||||||
|
tension: 0.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Line data={data} options={{ responsive: true }} />;
|
||||||
|
}
|
||||||
39
frontend/src/components/charts/PackageCountBarChart.tsx
Normal file
39
frontend/src/components/charts/PackageCountBarChart.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Bar } from "react-chartjs-2";
|
||||||
|
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from "chart.js";
|
||||||
|
import type { RepositoryStats } from "api/types/RepositoryStats";
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
interface PackageCountBarChartProps {
|
||||||
|
stats: RepositoryStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
|
||||||
|
const data = {
|
||||||
|
labels: ["packages"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "archives",
|
||||||
|
data: [stats.packages ?? 0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "bases",
|
||||||
|
data: [stats.bases ?? 0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Bar
|
||||||
|
data={data}
|
||||||
|
options={{
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
x: { stacked: true },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
frontend/src/components/charts/StatusPieChart.tsx
Normal file
27
frontend/src/components/charts/StatusPieChart.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Pie } from "react-chartjs-2";
|
||||||
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
|
||||||
|
import type { Counters } from "api/types/Counters";
|
||||||
|
import { StatusColors } from "theme/status/StatusColors";
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
interface StatusPieChartProps {
|
||||||
|
counters: Counters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
|
||||||
|
const labels = ["unknown", "pending", "building", "failed", "success"] as const;
|
||||||
|
const data = {
|
||||||
|
labels: labels.map((l) => l),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "packages in status",
|
||||||
|
data: labels.map((label) => counters[label]),
|
||||||
|
backgroundColor: labels.map((label) => StatusColors[label]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Pie data={data} options={{ responsive: true }} />;
|
||||||
|
}
|
||||||
75
frontend/src/components/common/AutoRefreshControl.tsx
Normal file
75
frontend/src/components/common/AutoRefreshControl.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { IconButton, Menu, MenuItem, Tooltip, ListItemIcon, ListItemText } from "@mui/material";
|
||||||
|
import TimerIcon from "@mui/icons-material/Timer";
|
||||||
|
import TimerOffIcon from "@mui/icons-material/TimerOff";
|
||||||
|
import CheckIcon from "@mui/icons-material/Check";
|
||||||
|
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||||
|
|
||||||
|
interface AutoRefreshControlProps {
|
||||||
|
intervals: AutoRefreshInterval[];
|
||||||
|
enabled: boolean;
|
||||||
|
currentInterval: number;
|
||||||
|
onToggle: (enabled: boolean) => void;
|
||||||
|
onIntervalChange: (interval: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AutoRefreshControl({
|
||||||
|
intervals,
|
||||||
|
enabled,
|
||||||
|
currentInterval,
|
||||||
|
onToggle,
|
||||||
|
onIntervalChange,
|
||||||
|
}: AutoRefreshControlProps): React.JSX.Element | null {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
if (intervals.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Auto-refresh">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||||
|
color={enabled ? "primary" : "default"}
|
||||||
|
>
|
||||||
|
{enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
selected={!enabled}
|
||||||
|
onClick={() => {
|
||||||
|
onToggle(false);
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
{!enabled && <CheckIcon fontSize="small" />}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>Off</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
{intervals.map((iv) => (
|
||||||
|
<MenuItem
|
||||||
|
key={iv.interval}
|
||||||
|
selected={enabled && iv.interval === currentInterval}
|
||||||
|
onClick={() => {
|
||||||
|
onIntervalChange(iv.interval);
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
{enabled && iv.interval === currentInterval && <CheckIcon fontSize="small" />}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{iv.text}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/components/common/CopyButton.tsx
Normal file
26
frontend/src/components/common/CopyButton.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { IconButton, Tooltip } from "@mui/material";
|
||||||
|
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||||
|
import CheckIcon from "@mui/icons-material/Check";
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
getText: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async (): Promise<void> => {
|
||||||
|
await navigator.clipboard.writeText(getText());
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={copied ? "Copied!" : "Copy"}>
|
||||||
|
<IconButton size="small" onClick={() => void handleCopy()}>
|
||||||
|
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/components/common/formatTimestamp.ts
Normal file
5
frontend/src/components/common/formatTimestamp.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function formatTimestamp(unixSeconds: number): string {
|
||||||
|
const d = new Date(unixSeconds * 1000);
|
||||||
|
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
88
frontend/src/components/dialogs/DashboardDialog.tsx
Normal file
88
frontend/src/components/dialogs/DashboardDialog.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, Typography, Box } from "@mui/material";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import StatusPieChart from "components/charts/StatusPieChart";
|
||||||
|
import PackageCountBarChart from "components/charts/PackageCountBarChart";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import { StatusHeaderStyles } from "theme/status/StatusColors";
|
||||||
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||||
|
import type { InternalStatus } from "api/types/InternalStatus";
|
||||||
|
|
||||||
|
interface DashboardDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
|
||||||
|
const { current } = useRepository();
|
||||||
|
|
||||||
|
const { data: status } = useQuery<InternalStatus>({
|
||||||
|
queryKey: current ? QueryKeys.status(current) : ["status"],
|
||||||
|
queryFn: () => Client.fetchStatus(current!),
|
||||||
|
enabled: !!current && open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||||
|
<DialogTitle sx={headerStyle}>System health</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{status && (
|
||||||
|
<>
|
||||||
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="right">Repository name</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2">{status.repository}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="right">Repository architecture</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2">{status.architecture}</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="right">Current status</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2">{status.status.status}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="right">Updated at</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2">{formatTimestamp(status.status.timestamp)}</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{status.packages.total > 0 && (
|
||||||
|
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Box sx={{ maxHeight: 300 }}>
|
||||||
|
<PackageCountBarChart stats={status.stats} />
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Box sx={{ maxHeight: 300 }}>
|
||||||
|
<StatusPieChart counters={status.packages} />
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} variant="contained" startIcon={<CloseIcon />}>close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/components/dialogs/KeyImportDialog.tsx
Normal file
106
frontend/src/components/dialogs/KeyImportDialog.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||||
|
TextField, Box,
|
||||||
|
} from "@mui/material";
|
||||||
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import CopyButton from "components/common/CopyButton";
|
||||||
|
|
||||||
|
interface KeyImportDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element {
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const [fingerprint, setFingerprint] = useState("");
|
||||||
|
const [server, setServer] = useState("keyserver.ubuntu.com");
|
||||||
|
const [keyBody, setKeyBody] = useState("");
|
||||||
|
|
||||||
|
const handleFetch = async (): Promise<void> => {
|
||||||
|
if (!fingerprint || !server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await Client.fetchPGPKey(fingerprint, server);
|
||||||
|
setKeyBody(result.key);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Could not fetch key: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async (): Promise<void> => {
|
||||||
|
if (!fingerprint || !server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Client.importPGPKey({ key: fingerprint, server });
|
||||||
|
onClose();
|
||||||
|
showSuccess("Success", `Key ${fingerprint} has been imported`);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
setFingerprint("");
|
||||||
|
setServer("keyserver.ubuntu.com");
|
||||||
|
setKeyBody("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
|
||||||
|
<DialogTitle>Import key from PGP server</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
label="fingerprint"
|
||||||
|
placeholder="PGP key fingerprint"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={fingerprint}
|
||||||
|
onChange={(e) => setFingerprint(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="key server"
|
||||||
|
placeholder="PGP key server"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={server}
|
||||||
|
onChange={(e) => setServer(e.target.value)}
|
||||||
|
/>
|
||||||
|
{keyBody && (
|
||||||
|
<Box sx={{ position: "relative", mt: 2 }}>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "grey.100",
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
maxHeight: 300,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code>{keyBody}</code>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||||
|
<CopyButton getText={() => keyBody} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => void handleImport()} variant="contained" startIcon={<PlayArrowIcon />}>import</Button>
|
||||||
|
<Button onClick={() => void handleFetch()} variant="contained" color="success" startIcon={<RefreshIcon />}>fetch</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
frontend/src/components/dialogs/LoginDialog.tsx
Normal file
91
frontend/src/components/dialogs/LoginDialog.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField,
|
||||||
|
InputAdornment, IconButton,
|
||||||
|
} from "@mui/material";
|
||||||
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
|
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||||
|
import PersonIcon from "@mui/icons-material/Person";
|
||||||
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
|
||||||
|
interface LoginDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginDialog({ open, onClose }: LoginDialogProps): React.JSX.Element {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const handleSubmit = async (): Promise<void> => {
|
||||||
|
if (!username || !password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
onClose();
|
||||||
|
showSuccess("Logged in", `Successfully logged in as ${username}`);
|
||||||
|
window.location.href = "/";
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
if (username === "admin" && password === "admin") {
|
||||||
|
showError("Login error", "You've entered a password for user \"root\", did you make a typo in username?");
|
||||||
|
} else {
|
||||||
|
showError("Login error", `Could not login as ${username}: ${detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
setShowPassword(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Login</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
label="username"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="password"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
void handleSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end" size="small">
|
||||||
|
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => void handleSubmit()} variant="contained" startIcon={<PersonIcon />}>login</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
frontend/src/components/dialogs/PackageAddDialog.tsx
Normal file
194
frontend/src/components/dialogs/PackageAddDialog.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||||
|
TextField, Autocomplete, Box, IconButton, FormControlLabel, Checkbox, Select, MenuItem, InputLabel, FormControl,
|
||||||
|
} from "@mui/material";
|
||||||
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import { useDebounce } from "hooks/useDebounce";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import type { AURPackage } from "api/types/AURPackage";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
interface EnvVar {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageAddDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
|
||||||
|
const { repositories, current } = useRepository();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const [packageName, setPackageName] = useState("");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
||||||
|
const [refresh, setRefresh] = useState(true);
|
||||||
|
const [envVars, setEnvVars] = useState<EnvVar[]>([]);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(packageName, 500);
|
||||||
|
|
||||||
|
const { data: searchResults = [] } = useQuery<AURPackage[]>({
|
||||||
|
queryKey: QueryKeys.search(debouncedSearch),
|
||||||
|
queryFn: () => Client.searchPackages(debouncedSearch),
|
||||||
|
enabled: debouncedSearch.length >= 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSelectedRepo = useCallback((): RepositoryId => {
|
||||||
|
if (selectedRepo) {
|
||||||
|
const repo = repositories.find(
|
||||||
|
(r) => `${r.architecture}-${r.repository}` === selectedRepo,
|
||||||
|
);
|
||||||
|
if (repo) {
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current!;
|
||||||
|
}, [selectedRepo, repositories, current]);
|
||||||
|
|
||||||
|
const handleAdd = async (): Promise<void> => {
|
||||||
|
if (!packageName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const repo = getSelectedRepo();
|
||||||
|
try {
|
||||||
|
const patches = envVars.filter((v) => v.key);
|
||||||
|
await Client.addPackages(repo, {
|
||||||
|
packages: [packageName],
|
||||||
|
patches: patches.length > 0 ? patches : undefined,
|
||||||
|
refresh,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
showSuccess("Success", `Packages ${packageName} have been added`);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Package addition failed: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequest = async (): Promise<void> => {
|
||||||
|
if (!packageName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const repo = getSelectedRepo();
|
||||||
|
try {
|
||||||
|
const patches = envVars.filter((v) => v.key);
|
||||||
|
await Client.requestPackages(repo, {
|
||||||
|
packages: [packageName],
|
||||||
|
patches: patches.length > 0 ? patches : undefined,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
showSuccess("Success", `Packages ${packageName} have been requested`);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Package request failed: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
setPackageName("");
|
||||||
|
setSelectedRepo("");
|
||||||
|
setRefresh(true);
|
||||||
|
setEnvVars([]);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>Add new packages</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<FormControl fullWidth margin="normal">
|
||||||
|
<InputLabel>repository</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedRepo || (current ? `${current.architecture}-${current.repository}` : "")}
|
||||||
|
label="repository"
|
||||||
|
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||||
|
>
|
||||||
|
{repositories.map((r) => (
|
||||||
|
<MenuItem key={`${r.architecture}-${r.repository}`} value={`${r.architecture}-${r.repository}`}>
|
||||||
|
{r.repository} ({r.architecture})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={searchResults.map((p) => p.package)}
|
||||||
|
inputValue={packageName}
|
||||||
|
onInputChange={(_, value) => setPackageName(value)}
|
||||||
|
renderOption={(props, option) => {
|
||||||
|
const pkg = searchResults.find((p) => p.package === option);
|
||||||
|
return (
|
||||||
|
<li {...props} key={option}>
|
||||||
|
{option}{pkg ? ` (${pkg.description})` : ""}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="package" placeholder="AUR package" margin="normal" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={refresh} onChange={(_, checked) => setRefresh(checked)} />}
|
||||||
|
label="update pacman databases"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setEnvVars([...envVars, { key: "", value: "" }])}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
add environment variable
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{envVars.map((env, index) => (
|
||||||
|
<Box key={index} sx={{ display: "flex", gap: 1, mt: 1, alignItems: "center" }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="name"
|
||||||
|
value={env.key}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...envVars];
|
||||||
|
updated[index] = { ...updated[index], key: e.target.value };
|
||||||
|
setEnvVars(updated);
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Box>=</Box>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="value"
|
||||||
|
value={env.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...envVars];
|
||||||
|
updated[index] = { ...updated[index], value: e.target.value };
|
||||||
|
setEnvVars(updated);
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<IconButton size="small" color="error" onClick={() => setEnvVars(envVars.filter((_, i) => i !== index))}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => void handleAdd()} variant="contained" startIcon={<PlayArrowIcon />}>add</Button>
|
||||||
|
<Button onClick={() => void handleRequest()} variant="contained" color="success" startIcon={<AddIcon />}>request</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
299
frontend/src/components/dialogs/PackageInfoDialog.tsx
Normal file
299
frontend/src/components/dialogs/PackageInfoDialog.tsx
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||||
|
Grid, Typography, Link, Box, Tab, Tabs, IconButton, Chip, FormControlLabel, Checkbox,
|
||||||
|
} from "@mui/material";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import BuildLogsTab from "components/package/BuildLogsTab";
|
||||||
|
import ChangesTab from "components/package/ChangesTab";
|
||||||
|
import EventsTab from "components/package/EventsTab";
|
||||||
|
import AutoRefreshControl from "components/common/AutoRefreshControl";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import { useAutoRefresh } from "hooks/useAutoRefresh";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import { StatusHeaderStyles } from "theme/status/StatusColors";
|
||||||
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||||
|
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||||
|
import type { Dependencies } from "api/types/Dependencies";
|
||||||
|
import type { PackageProperties } from "api/types/PackageProperties";
|
||||||
|
import type { PackageStatus } from "api/types/PackageStatus";
|
||||||
|
import type { Patch } from "api/types/Patch";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
interface PackageInfoDialogProps {
|
||||||
|
packageBase: string | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
autorefreshIntervals: AutoRefreshInterval[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function listToString(items: string[]): React.ReactNode {
|
||||||
|
const unique = [...new Set(items)].sort();
|
||||||
|
return unique.map((item, i) => (
|
||||||
|
<React.Fragment key={item}>
|
||||||
|
{item}
|
||||||
|
{i < unique.length - 1 && <br />}
|
||||||
|
</React.Fragment>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageInfoDialog({ packageBase, open, onClose, autorefreshIntervals }: PackageInfoDialogProps): React.JSX.Element {
|
||||||
|
const { current } = useRepository();
|
||||||
|
const { enabled: authEnabled, username } = useAuth();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const hasAuth = !authEnabled || username !== null;
|
||||||
|
|
||||||
|
const [tabIndex, setTabIndex] = useState(0);
|
||||||
|
const [refreshDb, setRefreshDb] = useState(true);
|
||||||
|
|
||||||
|
const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
|
||||||
|
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval);
|
||||||
|
|
||||||
|
const repo = current as RepositoryId;
|
||||||
|
|
||||||
|
const { data: packageData } = useQuery<PackageStatus[]>({
|
||||||
|
queryKey: packageBase && repo ? QueryKeys.package(packageBase, repo) : ["package-none"],
|
||||||
|
queryFn: () => Client.fetchPackage(packageBase!, repo),
|
||||||
|
enabled: !!packageBase && !!repo && open,
|
||||||
|
refetchInterval: autoRefresh.refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: dependencies } = useQuery<Dependencies>({
|
||||||
|
queryKey: packageBase && repo ? QueryKeys.dependencies(packageBase, repo) : ["deps-none"],
|
||||||
|
queryFn: () => Client.fetchDependencies(packageBase!, repo),
|
||||||
|
enabled: !!packageBase && !!repo && open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: patches = [] } = useQuery<Patch[]>({
|
||||||
|
queryKey: packageBase ? QueryKeys.patches(packageBase) : ["patches-none"],
|
||||||
|
queryFn: () => Client.fetchPatches(packageBase!),
|
||||||
|
enabled: !!packageBase && open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const description: PackageStatus | undefined = packageData?.[0];
|
||||||
|
const pkg = description?.package;
|
||||||
|
const status = description?.status;
|
||||||
|
|
||||||
|
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
|
||||||
|
|
||||||
|
// Flatten depends from all sub-packages
|
||||||
|
const allDepends: string[] = pkg
|
||||||
|
? Object.values(pkg.packages).flatMap((p: PackageProperties) => {
|
||||||
|
const pkgNames = Object.keys(pkg.packages);
|
||||||
|
const deps = (p.depends ?? []).filter((d: string) => !pkgNames.includes(d));
|
||||||
|
const makeDeps = (p.make_depends ?? []).filter((d: string) => !pkgNames.includes(d)).map((d: string) => `${d} (make)`);
|
||||||
|
const optDeps = (p.opt_depends ?? []).filter((d: string) => !pkgNames.includes(d)).map((d: string) => `${d} (optional)`);
|
||||||
|
return [...deps, ...makeDeps, ...optDeps];
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const implicitDepends: string[] = dependencies
|
||||||
|
? Object.values(dependencies.paths).flat()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const groups: string[] = pkg
|
||||||
|
? Object.values(pkg.packages).flatMap((p: PackageProperties) => p.groups ?? [])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const licenses: string[] = pkg
|
||||||
|
? Object.values(pkg.packages).flatMap((p: PackageProperties) => p.licenses ?? [])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const upstreamUrls: string[] = pkg
|
||||||
|
? [...new Set(Object.values(pkg.packages).map((p: PackageProperties) => p.url).filter((u): u is string => !!u))].sort()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const aurUrl = pkg?.remote.web_url;
|
||||||
|
|
||||||
|
const packagesList: string[] = pkg
|
||||||
|
? Object.entries(pkg.packages).map(([name, p]) => `${name}${p.description ? ` (${p.description})` : ""}`)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleUpdate = async (): Promise<void> => {
|
||||||
|
if (!packageBase || !repo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Client.addPackages(repo, { packages: [packageBase], refresh: refreshDb });
|
||||||
|
showSuccess("Success", `Run update for packages ${packageBase}`);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Package update failed: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (): Promise<void> => {
|
||||||
|
if (!packageBase || !repo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Client.removePackages(repo, [packageBase]);
|
||||||
|
showSuccess("Success", `Packages ${packageBase} have been removed`);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Could not remove package: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePatch = async (key: string): Promise<void> => {
|
||||||
|
if (!packageBase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Client.deletePatch(packageBase, key);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Could not delete variable: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReload = (): void => {
|
||||||
|
if (!packageBase || !repo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(packageBase, repo) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.logs(packageBase, repo) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.changes(packageBase, repo) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.events(repo, packageBase) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.dependencies(packageBase, repo) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
setTabIndex(0);
|
||||||
|
setRefreshDb(true);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
|
||||||
|
<DialogTitle sx={headerStyle}>
|
||||||
|
{pkg && status
|
||||||
|
? `${pkg.base} ${status.status} at ${formatTimestamp(status.timestamp)}`
|
||||||
|
: packageBase ?? ""}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{pkg && (
|
||||||
|
<>
|
||||||
|
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(packagesList)}</Typography></Grid>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{pkg.version}</Typography></Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">packager</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
|
||||||
|
<Grid item xs={4} md={1} />
|
||||||
|
<Grid item xs={8} md={5} />
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(groups)}</Typography></Grid>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(licenses)}</Typography></Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">upstream</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}>
|
||||||
|
{upstreamUrls.map((url) => (
|
||||||
|
<Link key={url} href={url} target="_blank" rel="noopener" underline="hover" display="block" variant="body2">
|
||||||
|
{url}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}>
|
||||||
|
{aurUrl && (
|
||||||
|
<Link href={aurUrl} target="_blank" rel="noopener" underline="hover" variant="body2">AUR link</Link>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(allDepends)}</Typography></Grid>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(implicitDepends)}</Typography></Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{patches.length > 0 && (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Environment variables</Typography>
|
||||||
|
{patches.map((patch) => (
|
||||||
|
<Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
|
||||||
|
<Chip label={patch.key} size="small" />
|
||||||
|
<Typography variant="body2">=</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontFamily: "monospace" }}>{JSON.stringify(patch.value)}</Typography>
|
||||||
|
{hasAuth && (
|
||||||
|
<IconButton size="small" color="error" onClick={() => void handleDeletePatch(patch.key)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
|
||||||
|
<Tabs value={tabIndex} onChange={(_, v: number) => setTabIndex(v)}>
|
||||||
|
<Tab label="Build logs" />
|
||||||
|
<Tab label="Changes" />
|
||||||
|
<Tab label="Events" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{tabIndex === 0 && packageBase && repo && (
|
||||||
|
<BuildLogsTab packageBase={packageBase} repo={repo} refetchInterval={autoRefresh.refetchInterval} />
|
||||||
|
)}
|
||||||
|
{tabIndex === 1 && packageBase && repo && (
|
||||||
|
<ChangesTab packageBase={packageBase} repo={repo} />
|
||||||
|
)}
|
||||||
|
{tabIndex === 2 && packageBase && repo && (
|
||||||
|
<EventsTab packageBase={packageBase} repo={repo} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
|
||||||
|
{hasAuth && (
|
||||||
|
<>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={refreshDb} onChange={(_, checked) => setRefreshDb(checked)} size="small" />}
|
||||||
|
label="update pacman databases"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => void handleUpdate()} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
|
||||||
|
update
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleRemove()} variant="contained" color="error" startIcon={<DeleteIcon />} size="small">
|
||||||
|
remove
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleReload} variant="outlined" color="secondary" startIcon={<RefreshIcon />} size="small">
|
||||||
|
reload
|
||||||
|
</Button>
|
||||||
|
<AutoRefreshControl
|
||||||
|
intervals={autorefreshIntervals}
|
||||||
|
enabled={autoRefresh.enabled}
|
||||||
|
currentInterval={autoRefresh.interval}
|
||||||
|
onToggle={autoRefresh.setEnabled}
|
||||||
|
onIntervalChange={autoRefresh.setInterval}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleClose} variant="contained" startIcon={<CloseIcon />} size="small">
|
||||||
|
close
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
frontend/src/components/dialogs/PackageRebuildDialog.tsx
Normal file
89
frontend/src/components/dialogs/PackageRebuildDialog.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||||
|
TextField, Select, MenuItem, InputLabel, FormControl,
|
||||||
|
} from "@mui/material";
|
||||||
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
interface PackageRebuildDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
|
||||||
|
const { repositories, current } = useRepository();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const [dependency, setDependency] = useState("");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
||||||
|
|
||||||
|
const getSelectedRepo = (): RepositoryId => {
|
||||||
|
if (selectedRepo) {
|
||||||
|
const repo = repositories.find((r) => `${r.architecture}-${r.repository}` === selectedRepo);
|
||||||
|
if (repo) {
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current!;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRebuild = async (): Promise<void> => {
|
||||||
|
if (!dependency) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const repo = getSelectedRepo();
|
||||||
|
try {
|
||||||
|
await Client.rebuildPackages(repo, [dependency]);
|
||||||
|
onClose();
|
||||||
|
showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Repository rebuild failed: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
setDependency("");
|
||||||
|
setSelectedRepo("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>Rebuild depending packages</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<FormControl fullWidth margin="normal">
|
||||||
|
<InputLabel>repository</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedRepo || (current ? `${current.architecture}-${current.repository}` : "")}
|
||||||
|
label="repository"
|
||||||
|
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||||
|
>
|
||||||
|
{repositories.map((r) => (
|
||||||
|
<MenuItem key={`${r.architecture}-${r.repository}`} value={`${r.architecture}-${r.repository}`}>
|
||||||
|
{r.repository} ({r.architecture})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="dependency"
|
||||||
|
placeholder="packages dependency"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={dependency}
|
||||||
|
onChange={(e) => setDependency(e.target.value)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => void handleRebuild()} variant="contained" startIcon={<PlayArrowIcon />}>rebuild</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/components/layout/AppLayout.tsx
Normal file
58
frontend/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Container, Box } from "@mui/material";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Navbar from "components/layout/Navbar";
|
||||||
|
import Footer from "components/layout/Footer";
|
||||||
|
import PackageTable from "components/table/PackageTable";
|
||||||
|
import LoginDialog from "components/dialogs/LoginDialog";
|
||||||
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import type { InfoResponse } from "api/types/InfoResponse";
|
||||||
|
|
||||||
|
export default function AppLayout(): React.JSX.Element {
|
||||||
|
const { setAuthState } = useAuth();
|
||||||
|
const { setRepositories } = useRepository();
|
||||||
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: info } = useQuery<InfoResponse>({
|
||||||
|
queryKey: QueryKeys.info,
|
||||||
|
queryFn: () => Client.fetchInfo(),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync info to contexts when loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (info) {
|
||||||
|
setAuthState({ enabled: info.auth.enabled, username: info.auth.username ?? null });
|
||||||
|
setRepositories(info.repositories);
|
||||||
|
}
|
||||||
|
}, [info, setAuthState, setRepositories]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl">
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}>
|
||||||
|
<a href="https://github.com/arcan1s/ahriman" title="logo">
|
||||||
|
<img src="/static/logo.svg" width={30} height={30} alt="" />
|
||||||
|
</a>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Navbar />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<PackageTable
|
||||||
|
autorefreshIntervals={info?.autorefresh_intervals ?? []}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Footer
|
||||||
|
version={info?.version ?? ""}
|
||||||
|
docsEnabled={info?.docs_enabled ?? false}
|
||||||
|
indexUrl={info?.index_url}
|
||||||
|
onLoginClick={() => setLoginOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
frontend/src/components/layout/Footer.tsx
Normal file
79
frontend/src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Box, Link, Button, Typography } from "@mui/material";
|
||||||
|
import GitHubIcon from "@mui/icons-material/GitHub";
|
||||||
|
import LogoutIcon from "@mui/icons-material/Logout";
|
||||||
|
import HomeIcon from "@mui/icons-material/Home";
|
||||||
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
version: string;
|
||||||
|
docsEnabled: boolean;
|
||||||
|
indexUrl?: string;
|
||||||
|
onLoginClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }: FooterProps): React.JSX.Element {
|
||||||
|
const { enabled: authEnabled, username, logout } = useAuth();
|
||||||
|
|
||||||
|
const handleLogout = async (): Promise<void> => {
|
||||||
|
await logout();
|
||||||
|
window.location.href = "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="footer"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
mt: 2,
|
||||||
|
py: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
|
||||||
|
<Link href="https://github.com/arcan1s/ahriman" underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
|
<GitHubIcon fontSize="small" />
|
||||||
|
<Typography variant="body2">ahriman {version}</Typography>
|
||||||
|
</Link>
|
||||||
|
<Link href="https://github.com/arcan1s/ahriman/releases" underline="hover" color="text.secondary" variant="body2">
|
||||||
|
releases
|
||||||
|
</Link>
|
||||||
|
<Link href="https://github.com/arcan1s/ahriman/issues" underline="hover" color="text.secondary" variant="body2">
|
||||||
|
report a bug
|
||||||
|
</Link>
|
||||||
|
{docsEnabled && (
|
||||||
|
<Link href="/api-docs" underline="hover" color="text.secondary" variant="body2">
|
||||||
|
api
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{indexUrl && (
|
||||||
|
<Box>
|
||||||
|
<Link href={indexUrl} underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
|
<HomeIcon fontSize="small" />
|
||||||
|
<Typography variant="body2">repo index</Typography>
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authEnabled && (
|
||||||
|
<Box>
|
||||||
|
{username ? (
|
||||||
|
<Button size="small" startIcon={<LogoutIcon />} onClick={() => void handleLogout()} sx={{ textTransform: "none" }}>
|
||||||
|
logout ({username})
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="small" onClick={onLoginClick} sx={{ textTransform: "none" }}>
|
||||||
|
login
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/components/layout/Navbar.tsx
Normal file
33
frontend/src/components/layout/Navbar.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Tabs, Tab, Box } from "@mui/material";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
|
||||||
|
export default function Navbar(): React.JSX.Element | null {
|
||||||
|
const { repositories, current, setCurrent } = useRepository();
|
||||||
|
|
||||||
|
if (repositories.length === 0 || !current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = repositories.findIndex(
|
||||||
|
(r) => r.architecture === current.architecture && r.repository === current.repository,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||||
|
<Tabs
|
||||||
|
value={currentIndex >= 0 ? currentIndex : 0}
|
||||||
|
onChange={(_, newValue: number) => setCurrent(repositories[newValue])}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
>
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<Tab
|
||||||
|
key={`${repo.architecture}-${repo.repository}`}
|
||||||
|
label={`${repo.repository} (${repo.architecture})`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
frontend/src/components/package/BuildLogsTab.tsx
Normal file
192
frontend/src/components/package/BuildLogsTab.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
|
||||||
|
import ListIcon from "@mui/icons-material/List";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import hljs from "highlight.js/lib/core";
|
||||||
|
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
|
import "highlight.js/styles/github.css";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||||
|
import CopyButton from "components/common/CopyButton";
|
||||||
|
import type { LogRecord } from "api/types/LogRecord";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
hljs.registerLanguage("plaintext", plaintext);
|
||||||
|
|
||||||
|
interface LogVersion {
|
||||||
|
version: string;
|
||||||
|
processId: string;
|
||||||
|
created: number;
|
||||||
|
logs: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildLogsTabProps {
|
||||||
|
packageBase: string;
|
||||||
|
repo: RepositoryId;
|
||||||
|
refetchInterval: number | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertLogs(records: LogRecord[], filter?: (r: LogRecord) => boolean): string {
|
||||||
|
return records
|
||||||
|
.filter(filter || Boolean)
|
||||||
|
.map((r) => `[${new Date(r.created * 1000).toISOString()}] ${r.message}`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuildLogsTab({ packageBase, repo, refetchInterval }: BuildLogsTabProps): React.JSX.Element {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const codeRef = useRef<HTMLElement>(null);
|
||||||
|
const preRef = useRef<HTMLElement>(null);
|
||||||
|
const initialScrollDone = useRef(false);
|
||||||
|
|
||||||
|
const { data: allLogs } = useQuery<LogRecord[]>({
|
||||||
|
queryKey: QueryKeys.logs(packageBase, repo),
|
||||||
|
queryFn: () => Client.fetchLogs(packageBase, repo),
|
||||||
|
enabled: !!packageBase,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build version selectors from all logs
|
||||||
|
const versions = useMemo<LogVersion[]>(() => {
|
||||||
|
if (!allLogs || allLogs.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped: Record<string, LogRecord & { minCreated: number }> = {};
|
||||||
|
for (const record of allLogs) {
|
||||||
|
const key = `${record.version}-${record.process_id}`;
|
||||||
|
if (!grouped[key]) {
|
||||||
|
grouped[key] = { ...record, minCreated: record.created };
|
||||||
|
} else {
|
||||||
|
grouped[key].minCreated = Math.min(grouped[key].minCreated, record.created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(grouped)
|
||||||
|
.sort((a, b) => b.minCreated - a.minCreated)
|
||||||
|
.map((v) => ({
|
||||||
|
version: v.version,
|
||||||
|
processId: v.process_id,
|
||||||
|
created: v.minCreated,
|
||||||
|
logs: convertLogs(
|
||||||
|
allLogs,
|
||||||
|
(r) => r.version === v.version && r.process_id === v.process_id,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, [allLogs]);
|
||||||
|
|
||||||
|
// Reset active index when data changes
|
||||||
|
const [prevAllLogs, setPrevAllLogs] = useState(allLogs);
|
||||||
|
if (allLogs !== prevAllLogs) {
|
||||||
|
setPrevAllLogs(allLogs);
|
||||||
|
setActiveIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset scroll tracking when logs data changes
|
||||||
|
useEffect(() => {
|
||||||
|
initialScrollDone.current = false;
|
||||||
|
}, [allLogs]);
|
||||||
|
|
||||||
|
// Refresh active version logs when using auto-refresh
|
||||||
|
const activeVersion = versions[activeIndex];
|
||||||
|
const { data: versionLogs } = useQuery<LogRecord[]>({
|
||||||
|
queryKey: activeVersion
|
||||||
|
? QueryKeys.logsVersion(packageBase, repo, activeVersion.version, activeVersion.processId)
|
||||||
|
: ["logs-none"],
|
||||||
|
queryFn: () =>
|
||||||
|
activeVersion
|
||||||
|
? Client.fetchLogs(packageBase, repo, activeVersion.version, activeVersion.processId)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
enabled: !!activeVersion && !!refetchInterval,
|
||||||
|
refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derive displayed logs: prefer fresh polled data when available
|
||||||
|
const displayedLogs = useMemo(() => {
|
||||||
|
if (versionLogs && versionLogs.length > 0) {
|
||||||
|
return convertLogs(versionLogs);
|
||||||
|
}
|
||||||
|
return activeVersion?.logs ?? "";
|
||||||
|
}, [versionLogs, activeVersion]);
|
||||||
|
|
||||||
|
// Highlight code
|
||||||
|
useEffect(() => {
|
||||||
|
if (codeRef.current && displayedLogs) {
|
||||||
|
codeRef.current.textContent = displayedLogs;
|
||||||
|
delete codeRef.current.dataset.highlighted;
|
||||||
|
hljs.highlightElement(codeRef.current);
|
||||||
|
}
|
||||||
|
}, [displayedLogs]);
|
||||||
|
|
||||||
|
// Auto-scroll: always scroll to bottom on initial load, then only if already near bottom
|
||||||
|
useEffect(() => {
|
||||||
|
if (preRef.current && displayedLogs) {
|
||||||
|
const el = preRef.current;
|
||||||
|
if (!initialScrollDone.current) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
initialScrollDone.current = true;
|
||||||
|
} else {
|
||||||
|
const isAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 50;
|
||||||
|
if (isAtBottom) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [displayedLogs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<ListIcon />}
|
||||||
|
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||||
|
/>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
|
>
|
||||||
|
{versions.map((v, idx) => (
|
||||||
|
<MenuItem
|
||||||
|
key={`${v.version}-${v.processId}`}
|
||||||
|
selected={idx === activeIndex}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveIndex(idx);
|
||||||
|
setAnchorEl(null);
|
||||||
|
initialScrollDone.current = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">{formatTimestamp(v.created)}</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
{versions.length === 0 && (
|
||||||
|
<MenuItem disabled>No logs available</MenuItem>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, position: "relative" }}>
|
||||||
|
<Box
|
||||||
|
ref={preRef}
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "grey.100",
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
maxHeight: 400,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code ref={codeRef} className="language-plaintext" />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||||
|
<CopyButton getText={() => displayedLogs} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/components/package/ChangesTab.tsx
Normal file
60
frontend/src/components/package/ChangesTab.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import hljs from "highlight.js/lib/core";
|
||||||
|
import diff from "highlight.js/lib/languages/diff";
|
||||||
|
import "highlight.js/styles/github.css";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import CopyButton from "components/common/CopyButton";
|
||||||
|
import type { Changes } from "api/types/Changes";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
hljs.registerLanguage("diff", diff);
|
||||||
|
|
||||||
|
interface ChangesTabProps {
|
||||||
|
packageBase: string;
|
||||||
|
repo: RepositoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChangesTab({ packageBase, repo }: ChangesTabProps): React.JSX.Element {
|
||||||
|
const codeRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
const { data } = useQuery<Changes>({
|
||||||
|
queryKey: QueryKeys.changes(packageBase, repo),
|
||||||
|
queryFn: () => Client.fetchChanges(packageBase, repo),
|
||||||
|
enabled: !!packageBase,
|
||||||
|
});
|
||||||
|
|
||||||
|
const changesText = data?.changes ?? "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (codeRef.current) {
|
||||||
|
codeRef.current.textContent = changesText;
|
||||||
|
delete codeRef.current.dataset.highlighted;
|
||||||
|
hljs.highlightElement(codeRef.current);
|
||||||
|
}
|
||||||
|
}, [changesText]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ position: "relative", mt: 1 }}>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "grey.100",
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
maxHeight: 400,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code ref={codeRef} className="language-diff" />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||||
|
<CopyButton getText={() => changesText} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/components/package/EventsTab.tsx
Normal file
60
frontend/src/components/package/EventsTab.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import EventDurationLineChart from "components/charts/EventDurationLineChart";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||||
|
import type { Event } from "api/types/Event";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
interface EventsTabProps {
|
||||||
|
packageBase: string;
|
||||||
|
repo: RepositoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventRow {
|
||||||
|
id: number;
|
||||||
|
timestamp: string;
|
||||||
|
event: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: GridColDef<EventRow>[] = [
|
||||||
|
{ field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
|
||||||
|
{ field: "event", headerName: "event", flex: 1 },
|
||||||
|
{ field: "message", headerName: "description", flex: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EventsTab({ packageBase, repo }: EventsTabProps): React.JSX.Element {
|
||||||
|
const { data: events = [] } = useQuery<Event[]>({
|
||||||
|
queryKey: QueryKeys.events(repo, packageBase),
|
||||||
|
queryFn: () => Client.fetchEvents(repo, packageBase, 30),
|
||||||
|
enabled: !!packageBase,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: EventRow[] = events.map((e, idx) => ({
|
||||||
|
id: idx,
|
||||||
|
timestamp: formatTimestamp(e.created),
|
||||||
|
event: e.event,
|
||||||
|
message: e.message ?? "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<EventDurationLineChart events={events} />
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
density="compact"
|
||||||
|
initialState={{
|
||||||
|
sorting: { sortModel: [{ field: "timestamp", sort: "desc" }] },
|
||||||
|
}}
|
||||||
|
pageSizeOptions={[10, 25]}
|
||||||
|
sx={{ height: 300, mt: 1 }}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
frontend/src/components/table/PackageTable.tsx
Normal file
317
frontend/src/components/table/PackageTable.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
DataGrid,
|
||||||
|
GridToolbarQuickFilter,
|
||||||
|
GridToolbarFilterButton,
|
||||||
|
type GridColDef,
|
||||||
|
type GridFilterModel,
|
||||||
|
type GridRowSelectionModel,
|
||||||
|
type GridRenderCellParams,
|
||||||
|
} from "@mui/x-data-grid";
|
||||||
|
import { Box, Link, Stack } from "@mui/material";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import PackageTableToolbar from "components/table/PackageTableToolbar";
|
||||||
|
import StatusCell from "components/table/StatusCell";
|
||||||
|
import DashboardDialog from "components/dialogs/DashboardDialog";
|
||||||
|
import PackageAddDialog from "components/dialogs/PackageAddDialog";
|
||||||
|
import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog";
|
||||||
|
import KeyImportDialog from "components/dialogs/KeyImportDialog";
|
||||||
|
import PackageInfoDialog from "components/dialogs/PackageInfoDialog";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import { useAutoRefresh } from "hooks/useAutoRefresh";
|
||||||
|
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||||
|
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||||
|
import type { InternalStatus } from "api/types/InternalStatus";
|
||||||
|
import type { PackageRow } from "api/types/PackageRow";
|
||||||
|
import type { PackageStatus } from "api/types/PackageStatus";
|
||||||
|
|
||||||
|
interface PackageTableProps {
|
||||||
|
autorefreshIntervals: AutoRefreshInterval[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
Object.values(pkg.packages)
|
||||||
|
.flatMap((p) => p[property] ?? []),
|
||||||
|
),
|
||||||
|
].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRow(ps: PackageStatus): PackageRow {
|
||||||
|
return {
|
||||||
|
id: ps.package.base,
|
||||||
|
base: ps.package.base,
|
||||||
|
webUrl: ps.package.remote.web_url ?? undefined,
|
||||||
|
version: ps.package.version,
|
||||||
|
packages: Object.keys(ps.package.packages).sort(),
|
||||||
|
groups: extractListProperties(ps.package, "groups"),
|
||||||
|
licenses: extractListProperties(ps.package, "licenses"),
|
||||||
|
packager: ps.package.packager ?? "",
|
||||||
|
timestamp: formatTimestamp(ps.status.timestamp),
|
||||||
|
timestampValue: ps.status.timestamp,
|
||||||
|
status: ps.status.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
function SearchToolbar(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 1, py: 0.5 }}>
|
||||||
|
<GridToolbarFilterButton />
|
||||||
|
<GridToolbarQuickFilter debounceMs={300} sx={{ flex: 1 }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageTable({ autorefreshIntervals }: PackageTableProps): React.JSX.Element {
|
||||||
|
const { current } = useRepository();
|
||||||
|
const { enabled: authEnabled, username } = useAuth();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const hasAuth = !authEnabled || username !== null;
|
||||||
|
|
||||||
|
const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
|
||||||
|
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval);
|
||||||
|
|
||||||
|
const [selectionModel, setSelectionModel] = useState<GridRowSelectionModel>([]);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState<string | null>(null);
|
||||||
|
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
|
||||||
|
pageSize: 10,
|
||||||
|
page: 0,
|
||||||
|
});
|
||||||
|
const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>(
|
||||||
|
"ahriman-packages-columns",
|
||||||
|
{ groups: false, licenses: false, packager: false },
|
||||||
|
);
|
||||||
|
const [filterModel, setFilterModel] = useLocalStorage<GridFilterModel>(
|
||||||
|
"ahriman-packages-filters",
|
||||||
|
{ items: [] },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pause auto-refresh when dialog is open
|
||||||
|
const isDialogOpen = dialogOpen !== null || selectedPackage !== null;
|
||||||
|
const setPaused = autoRefresh.setPaused;
|
||||||
|
useEffect(() => {
|
||||||
|
setPaused(isDialogOpen);
|
||||||
|
}, [isDialogOpen, setPaused]);
|
||||||
|
|
||||||
|
const { data: packages = [], isLoading } = useQuery<PackageStatus[]>({
|
||||||
|
queryKey: current ? QueryKeys.packages(current) : ["packages"],
|
||||||
|
queryFn: () => (current ? Client.fetchPackages(current) : Promise.resolve([])),
|
||||||
|
enabled: !!current,
|
||||||
|
refetchInterval: autoRefresh.refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: status } = useQuery<InternalStatus>({
|
||||||
|
queryKey: current ? QueryKeys.status(current) : ["status"],
|
||||||
|
queryFn: () => Client.fetchStatus(current!),
|
||||||
|
enabled: !!current,
|
||||||
|
refetchInterval: autoRefresh.refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = useMemo(() => packages.map(toRow), [packages]);
|
||||||
|
|
||||||
|
const handleReload = useCallback(() => {
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
|
||||||
|
}, [current, queryClient]);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(async () => {
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selected = selectionModel as string[];
|
||||||
|
try {
|
||||||
|
if (selected.length === 0) {
|
||||||
|
await Client.updatePackages(current, { packages: [] });
|
||||||
|
showSuccess("Success", "Repository update has been run");
|
||||||
|
} else {
|
||||||
|
await Client.addPackages(current, { packages: selected });
|
||||||
|
showSuccess("Success", `Run update for packages ${selected.join(", ")}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Packages update failed: ${detail}`);
|
||||||
|
}
|
||||||
|
}, [current, selectionModel, showSuccess, showError]);
|
||||||
|
|
||||||
|
const handleRefreshDb = useCallback(async () => {
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Client.updatePackages(current, { packages: [], refresh: true, aur: false, local: false, manual: false });
|
||||||
|
showSuccess("Success", "Pacman database update has been requested");
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Could not update pacman databases: ${detail}`);
|
||||||
|
}
|
||||||
|
}, [current, showSuccess, showError]);
|
||||||
|
|
||||||
|
const handleRemove = useCallback(async () => {
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selected = selectionModel as string[];
|
||||||
|
if (selected.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Client.removePackages(current, selected);
|
||||||
|
showSuccess("Success", `Packages ${selected.join(", ")} have been removed`);
|
||||||
|
setSelectionModel([]);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Could not remove packages: ${detail}`);
|
||||||
|
}
|
||||||
|
}, [current, selectionModel, showSuccess, showError]);
|
||||||
|
|
||||||
|
const columns: GridColDef<PackageRow>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
field: "base",
|
||||||
|
headerName: "package base",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 150,
|
||||||
|
renderCell: (params: GridRenderCellParams<PackageRow>) =>
|
||||||
|
params.row.webUrl ? (
|
||||||
|
<Link href={params.row.webUrl} target="_blank" rel="noopener" underline="hover">
|
||||||
|
{params.value as string}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
params.value as string
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
|
||||||
|
{
|
||||||
|
field: "packages",
|
||||||
|
headerName: "packages",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 120,
|
||||||
|
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
||||||
|
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.packages ?? []).map((item, i, arr) => (
|
||||||
|
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
|
||||||
|
)),
|
||||||
|
sortComparator: (v1: string, v2: string) => v1.localeCompare(v2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "groups",
|
||||||
|
headerName: "groups",
|
||||||
|
width: 150,
|
||||||
|
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
||||||
|
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.groups ?? []).map((item, i, arr) => (
|
||||||
|
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "licenses",
|
||||||
|
headerName: "licenses",
|
||||||
|
width: 150,
|
||||||
|
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
||||||
|
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.licenses ?? []).map((item, i, arr) => (
|
||||||
|
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
{ 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<PackageRow>) => <StatusCell status={params.row.status} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<PackageTableToolbar
|
||||||
|
hasSelection={(selectionModel as string[]).length > 0}
|
||||||
|
hasAuth={hasAuth}
|
||||||
|
repoStatus={status?.status.status}
|
||||||
|
autorefreshIntervals={autorefreshIntervals}
|
||||||
|
autoRefreshEnabled={autoRefresh.enabled}
|
||||||
|
autoRefreshInterval={autoRefresh.interval}
|
||||||
|
onAutoRefreshToggle={autoRefresh.setEnabled}
|
||||||
|
onAutoRefreshIntervalChange={autoRefresh.setInterval}
|
||||||
|
onDashboardClick={() => setDialogOpen("dashboard")}
|
||||||
|
onAddClick={() => setDialogOpen("add")}
|
||||||
|
onUpdateClick={() => void handleUpdate()}
|
||||||
|
onRefreshDbClick={() => void handleRefreshDb()}
|
||||||
|
onRebuildClick={() => setDialogOpen("rebuild")}
|
||||||
|
onRemoveClick={() => void handleRemove()}
|
||||||
|
onKeyImportClick={() => setDialogOpen("keyImport")}
|
||||||
|
onReloadClick={handleReload}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
loading={isLoading}
|
||||||
|
getRowHeight={() => "auto"}
|
||||||
|
checkboxSelection
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
rowSelectionModel={selectionModel}
|
||||||
|
onRowSelectionModelChange={setSelectionModel}
|
||||||
|
paginationModel={paginationModel}
|
||||||
|
onPaginationModelChange={setPaginationModel}
|
||||||
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||||
|
columnVisibilityModel={columnVisibility}
|
||||||
|
onColumnVisibilityModelChange={setColumnVisibility}
|
||||||
|
filterModel={filterModel}
|
||||||
|
onFilterModelChange={setFilterModel}
|
||||||
|
slots={{ toolbar: SearchToolbar }}
|
||||||
|
initialState={{
|
||||||
|
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
|
||||||
|
}}
|
||||||
|
onRowClick={(params: { row: PackageRow }, event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
// Don't open info dialog when clicking checkbox or link
|
||||||
|
if (target.closest("input[type=\"checkbox\"]") || target.closest("a")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedPackage(params.row.id);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
"& .MuiDataGrid-row": { cursor: "pointer" },
|
||||||
|
height: 600,
|
||||||
|
}}
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DashboardDialog open={dialogOpen === "dashboard"} onClose={() => setDialogOpen(null)} />
|
||||||
|
<PackageAddDialog open={dialogOpen === "add"} onClose={() => setDialogOpen(null)} />
|
||||||
|
<PackageRebuildDialog open={dialogOpen === "rebuild"} onClose={() => setDialogOpen(null)} />
|
||||||
|
<KeyImportDialog open={dialogOpen === "keyImport"} onClose={() => setDialogOpen(null)} />
|
||||||
|
<PackageInfoDialog
|
||||||
|
packageBase={selectedPackage}
|
||||||
|
open={selectedPackage !== null}
|
||||||
|
onClose={() => setSelectedPackage(null)}
|
||||||
|
autorefreshIntervals={autorefreshIntervals}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
frontend/src/components/table/PackageTableToolbar.tsx
Normal file
133
frontend/src/components/table/PackageTableToolbar.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button, Menu, MenuItem, Box, Tooltip, IconButton, Divider } from "@mui/material";
|
||||||
|
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||||
|
import InventoryIcon from "@mui/icons-material/Inventory";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import ReplayIcon from "@mui/icons-material/Replay";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import VpnKeyIcon from "@mui/icons-material/VpnKey";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import AutoRefreshControl from "components/common/AutoRefreshControl";
|
||||||
|
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
import { StatusColors } from "theme/status/StatusColors";
|
||||||
|
|
||||||
|
interface PackageTableToolbarProps {
|
||||||
|
hasSelection: boolean;
|
||||||
|
hasAuth: boolean;
|
||||||
|
repoStatus?: BuildStatus;
|
||||||
|
autorefreshIntervals: AutoRefreshInterval[];
|
||||||
|
autoRefreshEnabled: boolean;
|
||||||
|
autoRefreshInterval: number;
|
||||||
|
onAutoRefreshToggle: (enabled: boolean) => void;
|
||||||
|
onAutoRefreshIntervalChange: (interval: number) => void;
|
||||||
|
onDashboardClick: () => void;
|
||||||
|
onAddClick: () => void;
|
||||||
|
onUpdateClick: () => void;
|
||||||
|
onRefreshDbClick: () => void;
|
||||||
|
onRebuildClick: () => void;
|
||||||
|
onRemoveClick: () => void;
|
||||||
|
onKeyImportClick: () => void;
|
||||||
|
onReloadClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageTableToolbar({
|
||||||
|
hasSelection,
|
||||||
|
hasAuth,
|
||||||
|
repoStatus,
|
||||||
|
autorefreshIntervals,
|
||||||
|
autoRefreshEnabled,
|
||||||
|
autoRefreshInterval,
|
||||||
|
onAutoRefreshToggle,
|
||||||
|
onAutoRefreshIntervalChange,
|
||||||
|
onDashboardClick,
|
||||||
|
onAddClick,
|
||||||
|
onUpdateClick,
|
||||||
|
onRefreshDbClick,
|
||||||
|
onRebuildClick,
|
||||||
|
onRemoveClick,
|
||||||
|
onKeyImportClick,
|
||||||
|
onReloadClick,
|
||||||
|
}: PackageTableToolbarProps): React.JSX.Element {
|
||||||
|
const [packagesAnchorEl, setPackagesAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", gap: 1, mb: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<Tooltip title="System health">
|
||||||
|
<IconButton
|
||||||
|
onClick={onDashboardClick}
|
||||||
|
sx={{
|
||||||
|
borderColor: repoStatus ? StatusColors[repoStatus] : undefined,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
color: repoStatus ? StatusColors[repoStatus] : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{hasAuth && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<InventoryIcon />}
|
||||||
|
onClick={(e) => setPackagesAnchorEl(e.currentTarget)}
|
||||||
|
>
|
||||||
|
packages
|
||||||
|
</Button>
|
||||||
|
<Menu
|
||||||
|
anchorEl={packagesAnchorEl}
|
||||||
|
open={Boolean(packagesAnchorEl)}
|
||||||
|
onClose={() => setPackagesAnchorEl(null)}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
setPackagesAnchorEl(null); onAddClick();
|
||||||
|
}}>
|
||||||
|
<AddIcon fontSize="small" sx={{ mr: 1 }} /> add
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
setPackagesAnchorEl(null); onUpdateClick();
|
||||||
|
}}>
|
||||||
|
<PlayArrowIcon fontSize="small" sx={{ mr: 1 }} /> update
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
setPackagesAnchorEl(null); onRefreshDbClick();
|
||||||
|
}}>
|
||||||
|
<DownloadIcon fontSize="small" sx={{ mr: 1 }} /> update pacman databases
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
setPackagesAnchorEl(null); onRebuildClick();
|
||||||
|
}}>
|
||||||
|
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
|
||||||
|
</MenuItem>
|
||||||
|
<Divider />
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
setPackagesAnchorEl(null); onRemoveClick();
|
||||||
|
}} disabled={!hasSelection}>
|
||||||
|
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<Button variant="contained" color="info" startIcon={<VpnKeyIcon />} onClick={onKeyImportClick}>
|
||||||
|
import key
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="outlined" color="secondary" startIcon={<RefreshIcon />} onClick={onReloadClick}>
|
||||||
|
reload
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AutoRefreshControl
|
||||||
|
intervals={autorefreshIntervals}
|
||||||
|
enabled={autoRefreshEnabled}
|
||||||
|
currentInterval={autoRefreshInterval}
|
||||||
|
onToggle={onAutoRefreshToggle}
|
||||||
|
onIntervalChange={onAutoRefreshIntervalChange}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/components/table/StatusCell.tsx
Normal file
22
frontend/src/components/table/StatusCell.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Chip } from "@mui/material";
|
||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
import { StatusColors } from "theme/status/StatusColors";
|
||||||
|
|
||||||
|
interface StatusCellProps {
|
||||||
|
status: BuildStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusCell({ status }: StatusCellProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={status}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: StatusColors[status],
|
||||||
|
color: "common.white",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/src/contexts/AuthContext.ts
Normal file
14
frontend/src/contexts/AuthContext.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
enabled: boolean;
|
||||||
|
username: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthContextValue extends AuthState {
|
||||||
|
setAuthState: (state: AuthState) => void;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
23
frontend/src/contexts/AuthProvider.tsx
Normal file
23
frontend/src/contexts/AuthProvider.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React, { useState, useCallback, type ReactNode } from "react";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { AuthContext } from "contexts/AuthContext";
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
|
const [state, setState] = useState({ enabled: false, username: null as string | null });
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
await Client.login({ username, password });
|
||||||
|
setState((prev) => ({ ...prev, username }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const doLogout = useCallback(async () => {
|
||||||
|
await Client.logout();
|
||||||
|
setState((prev) => ({ ...prev, username: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ ...state, setAuthState: setState, login, logout: doLogout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend/src/contexts/NotificationContext.ts
Normal file
8
frontend/src/contexts/NotificationContext.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export interface NotificationContextValue {
|
||||||
|
showSuccess: (title: string, message: string) => void;
|
||||||
|
showError: (title: string, message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||||
53
frontend/src/contexts/NotificationProvider.tsx
Normal file
53
frontend/src/contexts/NotificationProvider.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React, { useState, useCallback, useRef, type ReactNode } from "react";
|
||||||
|
import { Snackbar, Alert, type AlertColor } from "@mui/material";
|
||||||
|
import { NotificationContext } from "contexts/NotificationContext";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
severity: AlertColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
|
const nextId = useRef(0);
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
|
||||||
|
const addNotification = useCallback((title: string, message: string, severity: AlertColor) => {
|
||||||
|
const id = nextId.current++;
|
||||||
|
setNotifications((prev) => [...prev, { id, title, message, severity }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeNotification = useCallback((id: number) => {
|
||||||
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSuccess = useCallback(
|
||||||
|
(title: string, message: string) => addNotification(title, message, "success"),
|
||||||
|
[addNotification],
|
||||||
|
);
|
||||||
|
const showError = useCallback(
|
||||||
|
(title: string, message: string) => addNotification(title, message, "error"),
|
||||||
|
[addNotification],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider value={{ showSuccess, showError }}>
|
||||||
|
{children}
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<Snackbar
|
||||||
|
key={n.id}
|
||||||
|
open
|
||||||
|
autoHideDuration={5000}
|
||||||
|
onClose={() => removeNotification(n.id)}
|
||||||
|
anchorOrigin={{ vertical: "top", horizontal: "center" }}
|
||||||
|
>
|
||||||
|
<Alert onClose={() => removeNotification(n.id)} severity={n.severity} variant="filled" sx={{ width: "100%", maxWidth: 500 }}>
|
||||||
|
<strong>{n.title}</strong>
|
||||||
|
{n.message && ` - ${n.message}`}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
))}
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/src/contexts/RepositoryContext.ts
Normal file
11
frontend/src/contexts/RepositoryContext.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
export interface RepositoryContextValue {
|
||||||
|
repositories: RepositoryId[];
|
||||||
|
current: RepositoryId | null;
|
||||||
|
setRepositories: (repos: RepositoryId[]) => void;
|
||||||
|
setCurrent: (repo: RepositoryId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RepositoryContext = createContext<RepositoryContextValue | null>(null);
|
||||||
47
frontend/src/contexts/RepositoryProvider.tsx
Normal file
47
frontend/src/contexts/RepositoryProvider.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React, { useState, useCallback, useEffect, type ReactNode } from "react";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
import { RepositoryContext } from "contexts/RepositoryContext";
|
||||||
|
|
||||||
|
function repoId(repo: RepositoryId): string {
|
||||||
|
return `${repo.architecture}-${repo.repository}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
|
const [repositories, setRepositories] = useState<RepositoryId[]>([]);
|
||||||
|
const [current, setCurrentState] = useState<RepositoryId | null>(null);
|
||||||
|
|
||||||
|
const setCurrent = useCallback((repo: RepositoryId) => {
|
||||||
|
setCurrentState(repo);
|
||||||
|
window.location.hash = repoId(repo);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync current repository when repositories list changes
|
||||||
|
const [prevRepositories, setPrevRepositories] = useState(repositories);
|
||||||
|
if (repositories !== prevRepositories) {
|
||||||
|
setPrevRepositories(repositories);
|
||||||
|
if (repositories.length > 0) {
|
||||||
|
const hash = window.location.hash.replace("#", "");
|
||||||
|
const match = repositories.find((r) => repoId(r) === hash);
|
||||||
|
setCurrentState(match || repositories[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for hash changes
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (): void => {
|
||||||
|
const hash = window.location.hash.replace("#", "");
|
||||||
|
const match = repositories.find((r) => repoId(r) === hash);
|
||||||
|
if (match) {
|
||||||
|
setCurrentState(match);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("hashchange", handler);
|
||||||
|
return () => window.removeEventListener("hashchange", handler);
|
||||||
|
}, [repositories]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RepositoryContext.Provider value={{ repositories, current, setRepositories, setCurrent }}>
|
||||||
|
{children}
|
||||||
|
</RepositoryContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/hooks/useAuth.ts
Normal file
10
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { AuthContext, type AuthContextValue } from "contexts/AuthContext";
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
52
frontend/src/hooks/useAutoRefresh.ts
Normal file
52
frontend/src/hooks/useAutoRefresh.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState, useCallback, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||||
|
|
||||||
|
interface AutoRefreshState {
|
||||||
|
enabled: boolean;
|
||||||
|
interval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoRefreshResult {
|
||||||
|
enabled: boolean;
|
||||||
|
interval: number;
|
||||||
|
paused: boolean;
|
||||||
|
refetchInterval: number | false;
|
||||||
|
setEnabled: (enabled: boolean) => void;
|
||||||
|
setInterval: (interval: number) => void;
|
||||||
|
setPaused: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoRefresh(key: string, defaultInterval: number = 0): AutoRefreshResult {
|
||||||
|
const [stored, setStored] = useLocalStorage<AutoRefreshState>(`ahriman-${key}`, {
|
||||||
|
enabled: defaultInterval > 0,
|
||||||
|
interval: defaultInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
|
const refetchInterval: number | false = stored.enabled && !paused && stored.interval > 0 ? stored.interval : false;
|
||||||
|
|
||||||
|
const setEnabled = useCallback(
|
||||||
|
(enabled: boolean) => {
|
||||||
|
setStored((prev) => ({ ...prev, enabled }));
|
||||||
|
},
|
||||||
|
[setStored],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setInterval = useCallback(
|
||||||
|
(interval: number) => {
|
||||||
|
setStored({ enabled: true, interval });
|
||||||
|
},
|
||||||
|
[setStored],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: stored.enabled,
|
||||||
|
interval: stored.interval,
|
||||||
|
paused,
|
||||||
|
refetchInterval,
|
||||||
|
setEnabled,
|
||||||
|
setInterval,
|
||||||
|
setPaused,
|
||||||
|
};
|
||||||
|
}
|
||||||
12
frontend/src/hooks/useDebounce.ts
Normal file
12
frontend/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||||
|
return () => clearTimeout(handler);
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
25
frontend/src/hooks/useLocalStorage.ts
Normal file
25
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState, useCallback, type Dispatch, type SetStateAction } from "react";
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(key: string, initialValue: T): [T, Dispatch<SetStateAction<T>>] {
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key);
|
||||||
|
return item ? (JSON.parse(item) as T) : initialValue;
|
||||||
|
} catch {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setValue: Dispatch<SetStateAction<T>> = 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];
|
||||||
|
}
|
||||||
10
frontend/src/hooks/useNotification.ts
Normal file
10
frontend/src/hooks/useNotification.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { NotificationContext, type NotificationContextValue } from "contexts/NotificationContext";
|
||||||
|
|
||||||
|
export function useNotification(): NotificationContextValue {
|
||||||
|
const ctx = useContext(NotificationContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useNotification must be used within NotificationProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
10
frontend/src/hooks/useRepository.ts
Normal file
10
frontend/src/hooks/useRepository.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { RepositoryContext, type RepositoryContextValue } from "contexts/RepositoryContext";
|
||||||
|
|
||||||
|
export function useRepository(): RepositoryContextValue {
|
||||||
|
const ctx = useContext(RepositoryContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useRepository must be used within RepositoryProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
67
frontend/src/theme/Theme.ts
Normal file
67
frontend/src/theme/Theme.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
// Bootswatch Cosmo-inspired palette
|
||||||
|
const Theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
main: "#2780e3",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: "#373a3c",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
main: "#3fb618",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
main: "#ff0039",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
main: "#ff7518",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
main: "#9954bb",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: "#fff",
|
||||||
|
paper: "#fff",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: "#373a3c",
|
||||||
|
secondary: "#6c757d",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: "\"Source Sans Pro\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
textTransform: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTab: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
textTransform: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiDialog: {
|
||||||
|
defaultProps: {
|
||||||
|
maxWidth: "lg",
|
||||||
|
fullWidth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Theme;
|
||||||
20
frontend/src/theme/status/StatusColors.ts
Normal file
20
frontend/src/theme/status/StatusColors.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { alpha } from "@mui/material/styles";
|
||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
|
||||||
|
const base: Record<BuildStatus, string> = {
|
||||||
|
unknown: "#373a3c",
|
||||||
|
pending: "#ff7518",
|
||||||
|
building: "#ff7518",
|
||||||
|
failed: "#ff0039",
|
||||||
|
success: "#3fb618",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StatusColors = base;
|
||||||
|
|
||||||
|
export const StatusBackgrounds: Record<BuildStatus, string> = Object.fromEntries(
|
||||||
|
Object.entries(base).map(([k, v]) => [k, alpha(v, 0.1)]),
|
||||||
|
) as Record<BuildStatus, string>;
|
||||||
|
|
||||||
|
export const StatusHeaderStyles: Record<BuildStatus, { backgroundColor: string; color: string }> = Object.fromEntries(
|
||||||
|
Object.entries(base).map(([k, v]) => [k, { backgroundColor: v, color: "#fff" }]),
|
||||||
|
) as Record<BuildStatus, { backgroundColor: string; color: string }>;
|
||||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
41
frontend/vite.config.ts
Normal file
41
frontend/vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import type { Plugin } from "vite";
|
||||||
|
|
||||||
|
function renameIndexHtml(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "rename-index-html",
|
||||||
|
closeBundle() {
|
||||||
|
const outDir = path.resolve(__dirname, "../package/share/ahriman/templates");
|
||||||
|
const src = path.join(outDir, "index.html");
|
||||||
|
const dest = path.join(outDir, "build-status-v2.jinja2");
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
fs.renameSync(src, dest);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tsconfigPaths(), renameIndexHtml()],
|
||||||
|
base: "/",
|
||||||
|
build: {
|
||||||
|
outDir: path.resolve(__dirname, "../package/share/ahriman/templates"),
|
||||||
|
emptyOutDir: false,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: "static/[name].js",
|
||||||
|
chunkFileNames: "static/[name].js",
|
||||||
|
assetFileNames: "static/[name].[ext]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -46,6 +46,8 @@ host = 127.0.0.1
|
|||||||
;service_only = no
|
;service_only = no
|
||||||
; Path to directory with static files.
|
; Path to directory with static files.
|
||||||
static_path = ${templates}/static
|
static_path = ${templates}/static
|
||||||
|
; Jinja2 template name for the index page.
|
||||||
|
;template = build-status.jinja2
|
||||||
; List of directories with templates.
|
; List of directories with templates.
|
||||||
templates[] = ${prefix}/share/ahriman/templates
|
templates[] = ${prefix}/share/ahriman/templates
|
||||||
; Path to unix socket. If none set, unix socket will be disabled.
|
; Path to unix socket. If none set, unix socket will be disabled.
|
||||||
|
|||||||
@@ -398,6 +398,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
|
|||||||
"path_exists": True,
|
"path_exists": True,
|
||||||
"path_type": "dir",
|
"path_type": "dir",
|
||||||
},
|
},
|
||||||
|
"template": {
|
||||||
|
"type": "string",
|
||||||
|
"empty": False,
|
||||||
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
"type": "list",
|
"type": "list",
|
||||||
"coerce": "list",
|
"coerce": "list",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from enum import Enum
|
|||||||
from filelock import FileLock
|
from filelock import FileLock
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pwd import getpwuid
|
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.exceptions import CalledProcessError, OptionError, UnsafeRunError
|
||||||
from ahriman.core.types import Comparable
|
from ahriman.core.types import Comparable
|
||||||
@@ -285,16 +285,17 @@ def filelock(path: Path) -> Iterator[FileLock]:
|
|||||||
lock_path.unlink(missing_ok=True)
|
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:
|
Args:
|
||||||
source(dict[str, Any]): raw json object
|
source(T): raw json object (dict, list, or scalar)
|
||||||
known_fields(Iterable[str]): list of fields which have to be known for the target object
|
known_fields(Iterable[str] | None, optional): list of fields which have to be known for the target object
|
||||||
|
(Default value = None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: json object without unknown and empty fields
|
T: json without ``None`` values
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
This wrapper is mainly used for the dataclasses, thus the flow must be something like this::
|
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)
|
>>> properties = filter_json(dump, known_fields)
|
||||||
>>> package = Package(**properties)
|
>>> package = Package(**properties)
|
||||||
"""
|
"""
|
||||||
return {key: value for key, value in source.items() if key in known_fields and value is not None}
|
if isinstance(source, dict):
|
||||||
|
return cast(T, {
|
||||||
|
key: filter_json(value)
|
||||||
|
for key, value in source.items()
|
||||||
|
if value is not None and (known_fields is None or key in known_fields)
|
||||||
|
})
|
||||||
|
if isinstance(source, list):
|
||||||
|
return cast(T, [filter_json(value) for value in source if value is not None])
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
def full_version(epoch: str | int | None, pkgver: str, pkgrel: str) -> str:
|
def full_version(epoch: str | int | None, pkgver: str, pkgrel: str) -> str:
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
#
|
#
|
||||||
from ahriman.web.schemas.any_schema import AnySchema
|
from ahriman.web.schemas.any_schema import AnySchema
|
||||||
from ahriman.web.schemas.aur_package_schema import AURPackageSchema
|
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.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.build_options_schema import BuildOptionsSchema
|
||||||
from ahriman.web.schemas.changes_schema import ChangesSchema
|
from ahriman.web.schemas.changes_schema import ChangesSchema
|
||||||
from ahriman.web.schemas.configuration_schema import ConfigurationSchema
|
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.event_search_schema import EventSearchSchema
|
||||||
from ahriman.web.schemas.file_schema import FileSchema
|
from ahriman.web.schemas.file_schema import FileSchema
|
||||||
from ahriman.web.schemas.info_schema import InfoSchema
|
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.internal_status_schema import InternalStatusSchema
|
||||||
from ahriman.web.schemas.log_schema import LogSchema
|
from ahriman.web.schemas.log_schema import LogSchema
|
||||||
from ahriman.web.schemas.login_schema import LoginSchema
|
from ahriman.web.schemas.login_schema import LoginSchema
|
||||||
|
|||||||
36
src/ahriman/web/schemas/auth_info_schema.py
Normal file
36
src/ahriman/web/schemas/auth_info_schema.py
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
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",
|
||||||
|
})
|
||||||
36
src/ahriman/web/schemas/auto_refresh_interval_schema.py
Normal file
36
src/ahriman/web/schemas/auto_refresh_interval_schema.py
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
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",
|
||||||
|
})
|
||||||
@@ -27,7 +27,7 @@ class InfoSchema(Schema):
|
|||||||
response service information 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",
|
"description": "Whether authentication is enabled or not",
|
||||||
})
|
})
|
||||||
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
|
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
|
||||||
|
|||||||
50
src/ahriman/web/schemas/info_v2_schema.py
Normal file
50
src/ahriman/web/schemas/info_v2_schema.py
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
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__,
|
||||||
|
})
|
||||||
@@ -29,6 +29,10 @@ class RepositoryIdSchema(Schema):
|
|||||||
"description": "Repository architecture",
|
"description": "Repository architecture",
|
||||||
"example": "x86_64",
|
"example": "x86_64",
|
||||||
})
|
})
|
||||||
|
id = fields.String(metadata={
|
||||||
|
"description": "Unique repository identifier",
|
||||||
|
"example": "aur-x86_64",
|
||||||
|
})
|
||||||
repository = fields.String(metadata={
|
repository = fields.String(metadata={
|
||||||
"description": "Repository name",
|
"description": "Repository name",
|
||||||
"example": "aur",
|
"example": "aur",
|
||||||
|
|||||||
69
src/ahriman/web/server_info.py
Normal file
69
src/ahriman/web/server_info.py
Normal file
@@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ahriman import __version__
|
||||||
|
from ahriman.core.auth.helpers import authorized_userid
|
||||||
|
from ahriman.core.types import Comparable
|
||||||
|
from ahriman.core.utils import pretty_interval
|
||||||
|
from ahriman.web.apispec import aiohttp_apispec
|
||||||
|
from ahriman.web.views.base import BaseView
|
||||||
|
|
||||||
|
|
||||||
|
async def server_info(view: BaseView) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
generate server info which can be used in responses directly
|
||||||
|
|
||||||
|
Args:
|
||||||
|
view(BaseView): view of the request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: server info as a json response
|
||||||
|
"""
|
||||||
|
autorefresh_intervals = [
|
||||||
|
{
|
||||||
|
"interval": interval * 1000, # milliseconds
|
||||||
|
"is_active": index == 0, # first element is always default
|
||||||
|
"text": pretty_interval(interval),
|
||||||
|
}
|
||||||
|
for index, interval in enumerate(view.configuration.getintlist("web", "autorefresh_intervals", fallback=[]))
|
||||||
|
if interval > 0 # special case if 0 exists and first, refresh will not be turned on by default
|
||||||
|
]
|
||||||
|
comparator: Callable[[dict[str, Any]], Comparable] = lambda interval: interval["interval"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"auth": {
|
||||||
|
"control": view.validator.auth_control,
|
||||||
|
"enabled": view.validator.enabled,
|
||||||
|
"username": await authorized_userid(view.request),
|
||||||
|
},
|
||||||
|
"autorefresh_intervals": sorted(autorefresh_intervals, key=comparator),
|
||||||
|
"docs_enabled": aiohttp_apispec is not None,
|
||||||
|
"index_url": view.configuration.get("web", "index_url", fallback=None),
|
||||||
|
"repositories": [
|
||||||
|
{
|
||||||
|
"id": repository_id.id,
|
||||||
|
**repository_id.view(),
|
||||||
|
}
|
||||||
|
for repository_id in sorted(view.services)
|
||||||
|
],
|
||||||
|
"version": __version__,
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import Response, json_response
|
from aiohttp.web import Response
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
@@ -96,4 +96,4 @@ class SwaggerView(BaseView):
|
|||||||
for key, value in schema.items()
|
for key, value in schema.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
return json_response(spec)
|
return self.json_response(spec)
|
||||||
|
|||||||
@@ -17,10 +17,10 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
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 aiohttp_cors import CorsViewMixin
|
||||||
from collections.abc import Awaitable, Callable
|
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.auth import Auth
|
||||||
from ahriman.core.configuration import Configuration
|
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.sign.gpg import GPG
|
||||||
from ahriman.core.spawn import Spawn
|
from ahriman.core.spawn import Spawn
|
||||||
from ahriman.core.status.watcher import Watcher
|
from ahriman.core.status.watcher import Watcher
|
||||||
|
from ahriman.core.utils import filter_json
|
||||||
from ahriman.models.repository_id import RepositoryId
|
from ahriman.models.repository_id import RepositoryId
|
||||||
from ahriman.models.user_access import UserAccess
|
from ahriman.models.user_access import UserAccess
|
||||||
from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey
|
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
|
raise KeyError(f"Key {key} is missing or empty") from None
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def json_response(data: dict[str, Any] | list[Any], **kwargs: Any) -> Response:
|
||||||
|
"""
|
||||||
|
filter and convert data and return :class:`aiohttp.web.Response` object
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data(dict[str, Any]): response in json format
|
||||||
|
**kwargs(Any): keyword arguments for :func:`aiohttp.web.json_response` function
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response: generated response object
|
||||||
|
"""
|
||||||
|
return json_response(filter_json(data), **kwargs)
|
||||||
|
|
||||||
# pylint: disable=not-callable,protected-access
|
# pylint: disable=not-callable,protected-access
|
||||||
async def head(self) -> StreamResponse:
|
async def head(self) -> StreamResponse:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -19,12 +19,11 @@
|
|||||||
#
|
#
|
||||||
import aiohttp_jinja2
|
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.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
|
from ahriman.web.views.base import BaseView
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +47,7 @@ class IndexView(BaseView):
|
|||||||
* id - unique repository identifier, string, required
|
* id - unique repository identifier, string, required
|
||||||
* repository - repository name, string, required
|
* repository - repository name, string, required
|
||||||
* architecture - repository architecture, string, required
|
* architecture - repository architecture, string, required
|
||||||
|
* version - service version, string, required
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
|
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
|
||||||
@@ -56,41 +56,14 @@ class IndexView(BaseView):
|
|||||||
GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
|
GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized
|
||||||
ROUTES = ["/", "/index.html"]
|
ROUTES = ["/", "/index.html"]
|
||||||
|
|
||||||
@aiohttp_jinja2.template("build-status.jinja2")
|
async def get(self) -> Response:
|
||||||
async def get(self) -> dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
process get request. No parameters supported here
|
process get request. No parameters supported here
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: parameters for jinja template
|
Response: 200 with rendered index page
|
||||||
"""
|
"""
|
||||||
auth_username = await authorized_userid(self.request)
|
context = await server_info(self)
|
||||||
auth = {
|
|
||||||
"control": self.validator.auth_control,
|
|
||||||
"enabled": self.validator.enabled,
|
|
||||||
"username": auth_username,
|
|
||||||
}
|
|
||||||
|
|
||||||
autorefresh_intervals = [
|
template = self.configuration.get("web", "template", fallback="build-status.jinja2")
|
||||||
{
|
return await aiohttp_jinja2.render_template_async(template, self.request, context)
|
||||||
"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)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
|
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from ahriman.models.event import Event
|
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)
|
events = self.service().event_get(event, object_id, from_date, to_date, limit, offset)
|
||||||
response = [event.view() for event in events]
|
response = [event.view() for event in events]
|
||||||
|
|
||||||
return json_response(response)
|
return self.json_response(response)
|
||||||
|
|
||||||
@apidocs(
|
@apidocs(
|
||||||
tags=["Audit log"],
|
tags=["Audit log"],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
|
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ class WorkersView(BaseView):
|
|||||||
comparator: Callable[[Worker], Comparable] = lambda item: item.identifier
|
comparator: Callable[[Worker], Comparable] = lambda item: item.identifier
|
||||||
response = [worker.view() for worker in sorted(workers, key=comparator)]
|
response = [worker.view() for worker in sorted(workers, key=comparator)]
|
||||||
|
|
||||||
return json_response(response)
|
return self.json_response(response)
|
||||||
|
|
||||||
@apidocs(
|
@apidocs(
|
||||||
tags=["Distributed"],
|
tags=["Distributed"],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
|
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from ahriman.models.changes import Changes
|
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)
|
changes = self.service(package_base=package_base).package_changes_get(package_base)
|
||||||
|
|
||||||
return json_response(changes.view())
|
return self.json_response(changes.view())
|
||||||
|
|
||||||
@apidocs(
|
@apidocs(
|
||||||
tags=["Packages"],
|
tags=["Packages"],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
|
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from ahriman.models.dependencies import Dependencies
|
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)
|
dependencies = self.service(package_base=package_base).package_dependencies_get(package_base)
|
||||||
|
|
||||||
return json_response(dependencies.view())
|
return self.json_response(dependencies.view())
|
||||||
|
|
||||||
@apidocs(
|
@apidocs(
|
||||||
tags=["Packages"],
|
tags=["Packages"],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
|
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from ahriman.core.exceptions import UnknownPackageError
|
from ahriman.core.exceptions import UnknownPackageError
|
||||||
@@ -99,7 +99,7 @@ class LogsView(StatusViewGuard, BaseView):
|
|||||||
"status": status.view(),
|
"status": status.view(),
|
||||||
"logs": "\n".join(f"[{pretty_datetime(log_record.created)}] {log_record.message}" for log_record in logs)
|
"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(
|
@apidocs(
|
||||||
tags=["Packages"],
|
tags=["Packages"],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response
|
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from ahriman.core.exceptions import UnknownPackageError
|
from ahriman.core.exceptions import UnknownPackageError
|
||||||
@@ -105,7 +105,7 @@ class PackageView(StatusViewGuard, BaseView):
|
|||||||
"repository": repository_id.view(),
|
"repository": repository_id.view(),
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
return json_response(response)
|
return self.json_response(response)
|
||||||
|
|
||||||
@apidocs(
|
@apidocs(
|
||||||
tags=["Packages"],
|
tags=["Packages"],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
#
|
#
|
||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
from aiohttp.web import HTTPNoContent, Response, json_response
|
from aiohttp.web import HTTPNoContent, Response
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ class PackagesView(StatusViewGuard, BaseView):
|
|||||||
} for package, status in itertools.islice(sorted(packages, key=comparator), offset, stop)
|
} for package, status in itertools.islice(sorted(packages, key=comparator), offset, stop)
|
||||||
]
|
]
|
||||||
|
|
||||||
return json_response(response)
|
return self.json_response(response)
|
||||||
|
|
||||||
@apidocs(
|
@apidocs(
|
||||||
tags=["Packages"],
|
tags=["Packages"],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response
|
from aiohttp.web import HTTPNoContent, HTTPNotFound, Response
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from ahriman.models.user_access import UserAccess
|
from ahriman.models.user_access import UserAccess
|
||||||
@@ -89,4 +89,4 @@ class PatchView(StatusViewGuard, BaseView):
|
|||||||
if selected is None:
|
if selected is None:
|
||||||
raise HTTPNotFound(reason=f"Patch {variable} is unknown")
|
raise HTTPNotFound(reason=f"Patch {variable} is unknown")
|
||||||
|
|
||||||
return json_response(selected.view())
|
return self.json_response(selected.view())
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
|
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from ahriman.models.pkgbuild_patch import PkgbuildPatch
|
from ahriman.models.pkgbuild_patch import PkgbuildPatch
|
||||||
@@ -60,7 +60,7 @@ class PatchesView(StatusViewGuard, BaseView):
|
|||||||
patches = self.service().package_patches_get(package_base, None)
|
patches = self.service().package_patches_get(package_base, None)
|
||||||
|
|
||||||
response = [patch.view() for patch in patches]
|
response = [patch.view() for patch in patches]
|
||||||
return json_response(response)
|
return self.json_response(response)
|
||||||
|
|
||||||
@apidocs(
|
@apidocs(
|
||||||
tags=["Packages"],
|
tags=["Packages"],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPBadRequest, Response, json_response
|
from aiohttp.web import HTTPBadRequest, Response
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from ahriman.models.pkgbuild_patch import PkgbuildPatch
|
from ahriman.models.pkgbuild_patch import PkgbuildPatch
|
||||||
@@ -78,4 +78,4 @@ class AddView(BaseView):
|
|||||||
refresh=data.get("refresh", False),
|
refresh=data.get("refresh", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
return json_response({"process_id": process_id})
|
return self.json_response({"process_id": process_id})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPNoContent, Response, json_response
|
from aiohttp.web import HTTPNoContent, Response
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from ahriman.core.formatters import ConfigurationPrinter
|
from ahriman.core.formatters import ConfigurationPrinter
|
||||||
@@ -64,7 +64,7 @@ class ConfigView(BaseView):
|
|||||||
for key, value in values.items()
|
for key, value in values.items()
|
||||||
if key not in ConfigurationPrinter.HIDE_KEYS
|
if key not in ConfigurationPrinter.HIDE_KEYS
|
||||||
]
|
]
|
||||||
return json_response(response)
|
return self.json_response(response)
|
||||||
|
|
||||||
@apidocs(
|
@apidocs(
|
||||||
tags=["Actions"],
|
tags=["Actions"],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response, json_response
|
from aiohttp.web import HTTPBadRequest, HTTPNotFound, Response
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from ahriman.models.user_access import UserAccess
|
from ahriman.models.user_access import UserAccess
|
||||||
@@ -71,7 +71,7 @@ class PGPView(BaseView):
|
|||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPNotFound(reason=f"Key {key} is unknown")
|
raise HTTPNotFound(reason=f"Key {key} is unknown")
|
||||||
|
|
||||||
return json_response({"key": key})
|
return self.json_response({"key": key})
|
||||||
|
|
||||||
@apidocs(
|
@apidocs(
|
||||||
tags=["Actions"],
|
tags=["Actions"],
|
||||||
@@ -100,4 +100,4 @@ class PGPView(BaseView):
|
|||||||
|
|
||||||
process_id = self.spawner.key_import(key, data.get("server"))
|
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})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPNotFound, Response, json_response
|
from aiohttp.web import HTTPNotFound, Response
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from ahriman.models.user_access import UserAccess
|
from ahriman.models.user_access import UserAccess
|
||||||
@@ -66,4 +66,4 @@ class ProcessView(BaseView):
|
|||||||
"is_alive": is_alive,
|
"is_alive": is_alive,
|
||||||
}
|
}
|
||||||
|
|
||||||
return json_response(response)
|
return self.json_response(response)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import HTTPBadRequest, Response, json_response
|
from aiohttp.web import HTTPBadRequest, Response
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from ahriman.models.user_access import UserAccess
|
from ahriman.models.user_access import UserAccess
|
||||||
@@ -74,4 +74,4 @@ class RebuildView(BaseView):
|
|||||||
increment=data.get("increment", True),
|
increment=data.get("increment", True),
|
||||||
)
|
)
|
||||||
|
|
||||||
return json_response({"process_id": process_id})
|
return self.json_response({"process_id": process_id})
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user