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
|
||||
*.pyd
|
||||
*.pyo
|
||||
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- ${{ github.workspace }}:/build
|
||||
|
||||
steps:
|
||||
- run: pacman --noconfirm -Syu base-devel git python-tox
|
||||
- run: pacman --noconfirm -Syu base-devel git npm python-tox
|
||||
|
||||
- run: git config --global --add safe.directory *
|
||||
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -99,3 +99,9 @@ status_cache.json
|
||||
*.db
|
||||
|
||||
docs/html/
|
||||
|
||||
# Frontend
|
||||
node_modules/
|
||||
package-lock.json
|
||||
package/share/ahriman/templates/static/index.js
|
||||
package/share/ahriman/templates/static/index.css
|
||||
|
||||
@@ -23,6 +23,7 @@ COPY "docker/install-aur-package.sh" "/usr/local/bin/install-aur-package"
|
||||
RUN pacman -S --noconfirm --asdeps \
|
||||
devtools \
|
||||
git \
|
||||
npm \
|
||||
pyalpm \
|
||||
python-bcrypt \
|
||||
python-filelock \
|
||||
|
||||
@@ -188,6 +188,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
|
||||
* ``port`` - port to bind, integer, optional.
|
||||
* ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``.
|
||||
* ``static_path`` - path to directory with static files, string, required.
|
||||
* ``template`` - Jinja2 template name for the index page, string, optional, default ``build-status.jinja2``.
|
||||
* ``templates`` - path to templates directories, space separated list of paths, required.
|
||||
* ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization.
|
||||
* ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration.
|
||||
|
||||
50
frontend/eslint.config.js
Normal file
50
frontend/eslint.config.js
Normal file
@@ -0,0 +1,50 @@
|
||||
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/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
||||
"@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": "^7.3.8",
|
||||
"@mui/material": "^7.3.8",
|
||||
"@mui/x-data-grid": "^8.27.3",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"chart.js": "^4.5.0",
|
||||
"highlight.js": "^11.11.0",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@stylistic/eslint-plugin": "^5.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"typescript": "^5.3.0",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1",
|
||||
"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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/api/QueryKeys.ts
Normal file
27
frontend/src/api/QueryKeys.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { repoKey } from "api/types/RepositoryId";
|
||||
import type { RepositoryId } from "api/types/RepositoryId";
|
||||
|
||||
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,
|
||||
};
|
||||
220
frontend/src/api/client/AhrimanClient.ts
Normal file
220
frontend/src/api/client/AhrimanClient.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
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 headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (json !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
method: method || (json ? "POST" : "GET"),
|
||||
headers,
|
||||
};
|
||||
|
||||
if (json !== undefined) {
|
||||
requestInit.body = JSON.stringify(json);
|
||||
}
|
||||
|
||||
const response = await fetch(fullUrl, requestInit);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new ApiError(response.status, response.statusText, body);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("Content-Type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return await response.json() as T;
|
||||
}
|
||||
return await response.text() as T;
|
||||
}
|
||||
|
||||
// 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();
|
||||
25
frontend/src/api/client/ApiError.ts
Normal file
25
frontend/src/api/client/ApiError.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
statusText: string;
|
||||
body: string;
|
||||
|
||||
constructor(status: number, statusText: string, body: string) {
|
||||
super(`${status} ${statusText}`);
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
get detail(): string {
|
||||
try {
|
||||
const parsed = JSON.parse(this.body) as Record<string, string>;
|
||||
return parsed.error ?? (this.body || this.message);
|
||||
} catch {
|
||||
return this.body || this.message;
|
||||
}
|
||||
}
|
||||
|
||||
static errorDetail(e: unknown): string {
|
||||
return e instanceof ApiError ? e.detail : String(e);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
12
frontend/src/api/types/RepositoryId.ts
Normal file
12
frontend/src/api/types/RepositoryId.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface RepositoryId {
|
||||
architecture: string;
|
||||
repository: string;
|
||||
}
|
||||
|
||||
export function repoKey(repo: RepositoryId): string {
|
||||
return `${repo.architecture}-${repo.repository}`;
|
||||
}
|
||||
|
||||
export function repoLabel(repo: RepositoryId): string {
|
||||
return `${repo.repository} (${repo.architecture})`;
|
||||
}
|
||||
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],
|
||||
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>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/common/RepositorySelect.tsx
Normal file
30
frontend/src/components/common/RepositorySelect.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type React from "react";
|
||||
import { Select, MenuItem, InputLabel, FormControl } from "@mui/material";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { repoKey, repoLabel } from "api/types/RepositoryId";
|
||||
import type { SelectedRepositoryResult } from "hooks/useSelectedRepository";
|
||||
|
||||
interface RepositorySelectProps {
|
||||
repoSelect: SelectedRepositoryResult;
|
||||
}
|
||||
|
||||
export default function RepositorySelect({ repoSelect }: RepositorySelectProps): React.JSX.Element {
|
||||
const { repositories, current } = useRepository();
|
||||
|
||||
return (
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>repository</InputLabel>
|
||||
<Select
|
||||
value={repoSelect.selectedKey || (current ? repoKey(current) : "")}
|
||||
label="repository"
|
||||
onChange={(e) => repoSelect.setSelectedKey(e.target.value)}
|
||||
>
|
||||
{repositories.map((r) => (
|
||||
<MenuItem key={repoKey(r)} value={repoKey(r)}>
|
||||
{repoLabel(r)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
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 { skipToken, useQuery } from "@tanstack/react-query";
|
||||
import StatusPieChart from "components/charts/StatusPieChart";
|
||||
import PackageCountBarChart from "components/charts/PackageCountBarChart";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { StatusHeaderStyles } from "theme/status/StatusColors";
|
||||
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||
import type { InternalStatus } from "api/types/InternalStatus";
|
||||
|
||||
interface DashboardDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
|
||||
const { current } = useRepository();
|
||||
|
||||
const { data: status } = useQuery<InternalStatus>({
|
||||
queryKey: current ? QueryKeys.status(current) : ["status"],
|
||||
queryFn: current ? () => Client.fetchStatus(current) : skipToken,
|
||||
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 size={{ xs: 6, md: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" align="right">Repository name</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Typography variant="body2">{status.repository}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" align="right">Repository architecture</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Typography variant="body2">{status.architecture}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" align="right">Current status</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Typography variant="body2">{status.status.status}</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 6, md: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" align="right">Updated at</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ 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 size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ maxHeight: 300 }}>
|
||||
<PackageCountBarChart stats={status.stats} />
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ 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 = ApiError.errorDetail(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 = ApiError.errorDetail(e);
|
||||
showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setFingerprint("");
|
||||
setServer("keyserver.ubuntu.com");
|
||||
setKeyBody("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/dialogs/LoginDialog.tsx
Normal file
93
frontend/src/components/dialogs/LoginDialog.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
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.reload();
|
||||
} catch (e) {
|
||||
const detail = ApiError.errorDetail(e);
|
||||
if (username === "admin" && password === "admin") {
|
||||
showError("Login error", "You've entered a password for user \"root\", did you make a typo in username?");
|
||||
} else {
|
||||
showError("Login error", `Could not login as ${username}: ${detail}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
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>
|
||||
);
|
||||
}
|
||||
180
frontend/src/components/dialogs/PackageAddDialog.tsx
Normal file
180
frontend/src/components/dialogs/PackageAddDialog.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||
TextField, Autocomplete, Box, IconButton, FormControlLabel, Checkbox,
|
||||
} 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 RepositorySelect from "components/common/RepositorySelect";
|
||||
import { useNotification } from "hooks/useNotification";
|
||||
import { useSelectedRepository } from "hooks/useSelectedRepository";
|
||||
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";
|
||||
|
||||
interface EnvVar {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface PackageAddDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const repoSelect = useSelectedRepository();
|
||||
|
||||
const [packageName, setPackageName] = useState("");
|
||||
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 handleAdd = async (): Promise<void> => {
|
||||
if (!packageName) {
|
||||
return;
|
||||
}
|
||||
const repo = repoSelect.selectedRepo;
|
||||
if (!repo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const patches = envVars.filter((v) => v.key);
|
||||
await Client.addPackages(repo, {
|
||||
packages: [packageName],
|
||||
patches: patches.length > 0 ? patches : undefined,
|
||||
refresh,
|
||||
});
|
||||
onClose();
|
||||
showSuccess("Success", `Packages ${packageName} have been added`);
|
||||
} catch (e) {
|
||||
const detail = ApiError.errorDetail(e);
|
||||
showError("Action failed", `Package addition failed: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequest = async (): Promise<void> => {
|
||||
if (!packageName) {
|
||||
return;
|
||||
}
|
||||
const repo = repoSelect.selectedRepo;
|
||||
if (!repo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const patches = envVars.filter((v) => v.key);
|
||||
await Client.requestPackages(repo, {
|
||||
packages: [packageName],
|
||||
patches: patches.length > 0 ? patches : undefined,
|
||||
});
|
||||
onClose();
|
||||
showSuccess("Success", `Packages ${packageName} have been requested`);
|
||||
} catch (e) {
|
||||
const detail = ApiError.errorDetail(e);
|
||||
showError("Action failed", `Package request failed: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setPackageName("");
|
||||
repoSelect.reset();
|
||||
setRefresh(true);
|
||||
setEnvVars([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Add new packages</DialogTitle>
|
||||
<DialogContent>
|
||||
<RepositorySelect repoSelect={repoSelect} />
|
||||
|
||||
<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];
|
||||
const current = updated[index];
|
||||
if (current) {
|
||||
updated[index] = { ...current, key: e.target.value };
|
||||
setEnvVars(updated);
|
||||
}
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Box>=</Box>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="value"
|
||||
value={env.value}
|
||||
onChange={(e) => {
|
||||
const updated = [...envVars];
|
||||
const current = updated[index];
|
||||
if (current) {
|
||||
updated[index] = { ...current, 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>
|
||||
);
|
||||
}
|
||||
176
frontend/src/components/dialogs/PackageInfoDialog.tsx
Normal file
176
frontend/src/components/dialogs/PackageInfoDialog.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useState } from "react";
|
||||
import { Dialog, DialogTitle, DialogContent, Box, Tab, Tabs } from "@mui/material";
|
||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import PackageDetailsGrid from "components/package/PackageDetailsGrid";
|
||||
import PackagePatchesList from "components/package/PackagePatchesList";
|
||||
import PackageInfoActions from "components/package/PackageInfoActions";
|
||||
import BuildLogsTab from "components/package/BuildLogsTab";
|
||||
import ChangesTab from "components/package/ChangesTab";
|
||||
import EventsTab from "components/package/EventsTab";
|
||||
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 { PackageStatus } from "api/types/PackageStatus";
|
||||
import type { Patch } from "api/types/Patch";
|
||||
|
||||
interface PackageInfoDialogProps {
|
||||
packageBase: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
autorefreshIntervals: AutoRefreshInterval[];
|
||||
}
|
||||
|
||||
export default function PackageInfoDialog({ packageBase, open, onClose, autorefreshIntervals }: PackageInfoDialogProps): React.JSX.Element {
|
||||
const { current } = useRepository();
|
||||
const { enabled: authEnabled, username } = useAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
const hasAuth = !authEnabled || username !== null;
|
||||
|
||||
const [tabIndex, setTabIndex] = useState(0);
|
||||
const [refreshDb, setRefreshDb] = useState(true);
|
||||
|
||||
const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
|
||||
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval);
|
||||
|
||||
const { data: packageData } = useQuery<PackageStatus[]>({
|
||||
queryKey: packageBase && current ? QueryKeys.package(packageBase, current) : ["package-none"],
|
||||
queryFn: packageBase && current ? () => Client.fetchPackage(packageBase, current) : skipToken,
|
||||
enabled: !!packageBase && !!current && open,
|
||||
refetchInterval: autoRefresh.refetchInterval,
|
||||
});
|
||||
|
||||
const { data: dependencies } = useQuery<Dependencies>({
|
||||
queryKey: packageBase && current ? QueryKeys.dependencies(packageBase, current) : ["deps-none"],
|
||||
queryFn: packageBase && current ? () => Client.fetchDependencies(packageBase, current) : skipToken,
|
||||
enabled: !!packageBase && !!current && open,
|
||||
});
|
||||
|
||||
const { data: patches = [] } = useQuery<Patch[]>({
|
||||
queryKey: packageBase ? QueryKeys.patches(packageBase) : ["patches-none"],
|
||||
queryFn: packageBase ? () => Client.fetchPatches(packageBase) : skipToken,
|
||||
enabled: !!packageBase && open,
|
||||
});
|
||||
|
||||
const description: PackageStatus | undefined = packageData?.[0];
|
||||
const pkg = description?.package;
|
||||
const status = description?.status;
|
||||
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
|
||||
|
||||
const handleUpdate = async (): Promise<void> => {
|
||||
if (!packageBase || !current) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Client.addPackages(current, { packages: [packageBase], refresh: refreshDb });
|
||||
showSuccess("Success", `Run update for packages ${packageBase}`);
|
||||
} catch (e) {
|
||||
showError("Action failed", `Package update failed: ${ApiError.errorDetail(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (): Promise<void> => {
|
||||
if (!packageBase || !current) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Client.removePackages(current, [packageBase]);
|
||||
showSuccess("Success", `Packages ${packageBase} have been removed`);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
showError("Action failed", `Could not remove package: ${ApiError.errorDetail(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePatch = async (key: string): Promise<void> => {
|
||||
if (!packageBase) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Client.deletePatch(packageBase, key);
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
|
||||
} catch (e) {
|
||||
showError("Action failed", `Could not delete variable: ${ApiError.errorDetail(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReload = (): void => {
|
||||
if (!packageBase || !current) {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(packageBase, current) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.logs(packageBase, current) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.changes(packageBase, current) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.events(current, packageBase) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.dependencies(packageBase, current) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
|
||||
};
|
||||
|
||||
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 && (
|
||||
<>
|
||||
<PackageDetailsGrid pkg={pkg} dependencies={dependencies} />
|
||||
<PackagePatchesList
|
||||
patches={patches}
|
||||
editable={hasAuth}
|
||||
onDelete={(key) => void handleDeletePatch(key)}
|
||||
/>
|
||||
|
||||
<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 && current && (
|
||||
<BuildLogsTab packageBase={packageBase} repo={current} refetchInterval={autoRefresh.refetchInterval} />
|
||||
)}
|
||||
{tabIndex === 1 && packageBase && current && (
|
||||
<ChangesTab packageBase={packageBase} repo={current} />
|
||||
)}
|
||||
{tabIndex === 2 && packageBase && current && (
|
||||
<EventsTab packageBase={packageBase} repo={current} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<PackageInfoActions
|
||||
hasAuth={hasAuth}
|
||||
refreshDb={refreshDb}
|
||||
onRefreshDbChange={setRefreshDb}
|
||||
onUpdate={() => void handleUpdate()}
|
||||
onRemove={() => void handleRemove()}
|
||||
onReload={handleReload}
|
||||
onClose={handleClose}
|
||||
autorefreshIntervals={autorefreshIntervals}
|
||||
autoRefreshEnabled={autoRefresh.enabled}
|
||||
autoRefreshInterval={autoRefresh.interval}
|
||||
onAutoRefreshToggle={autoRefresh.setEnabled}
|
||||
onAutoRefreshIntervalChange={autoRefresh.setInterval}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
68
frontend/src/components/dialogs/PackageRebuildDialog.tsx
Normal file
68
frontend/src/components/dialogs/PackageRebuildDialog.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import RepositorySelect from "components/common/RepositorySelect";
|
||||
import { useNotification } from "hooks/useNotification";
|
||||
import { useSelectedRepository } from "hooks/useSelectedRepository";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { ApiError } from "api/client/ApiError";
|
||||
|
||||
interface PackageRebuildDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const repoSelect = useSelectedRepository();
|
||||
|
||||
const [dependency, setDependency] = useState("");
|
||||
|
||||
const handleRebuild = async (): Promise<void> => {
|
||||
if (!dependency) {
|
||||
return;
|
||||
}
|
||||
const repo = repoSelect.selectedRepo;
|
||||
if (!repo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Client.rebuildPackages(repo, [dependency]);
|
||||
onClose();
|
||||
showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`);
|
||||
} catch (e) {
|
||||
const detail = ApiError.errorDetail(e);
|
||||
showError("Action failed", `Repository rebuild failed: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setDependency("");
|
||||
repoSelect.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Rebuild depending packages</DialogTitle>
|
||||
<DialogContent>
|
||||
<RepositorySelect repoSelect={repoSelect} />
|
||||
|
||||
<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.reload();
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
39
frontend/src/components/layout/Navbar.tsx
Normal file
39
frontend/src/components/layout/Navbar.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type React from "react";
|
||||
import { Tabs, Tab, Box } from "@mui/material";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { repoKey, repoLabel } from "api/types/RepositoryId";
|
||||
|
||||
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) => {
|
||||
const repo = repositories[newValue];
|
||||
if (repo) {
|
||||
setCurrent(repo);
|
||||
}
|
||||
}}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<Tab
|
||||
key={repoKey(repo)}
|
||||
label={repoLabel(repo)}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
205
frontend/src/components/package/BuildLogsTab.tsx
Normal file
205
frontend/src/components/package/BuildLogsTab.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
|
||||
import ListIcon from "@mui/icons-material/List";
|
||||
import { skipToken, 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 wasAtBottom = useRef(true);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (preRef.current) {
|
||||
const el = preRef.current;
|
||||
wasAtBottom.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 50;
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 logs data changes (React "adjusting state from props" pattern)
|
||||
const [prevAllLogs, setPrevAllLogs] = useState(allLogs);
|
||||
if (prevAllLogs !== allLogs) {
|
||||
setPrevAllLogs(allLogs);
|
||||
setActiveIndex(0);
|
||||
}
|
||||
|
||||
// Reset scroll tracking when active version changes
|
||||
const activeVersionKey = versions[activeIndex] ? `${versions[activeIndex].version}-${versions[activeIndex].processId}` : null;
|
||||
useEffect(() => {
|
||||
initialScrollDone.current = false;
|
||||
}, [activeVersionKey]);
|
||||
|
||||
// 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)
|
||||
: skipToken,
|
||||
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
|
||||
// and the user has no active text selection (to avoid disrupting copy workflows).
|
||||
// wasAtBottom is tracked via onScroll so it reflects position *before* new content arrives.
|
||||
useEffect(() => {
|
||||
if (preRef.current && displayedLogs) {
|
||||
const el = preRef.current;
|
||||
if (!initialScrollDone.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
initialScrollDone.current = true;
|
||||
} else {
|
||||
const hasSelection = !document.getSelection()?.isCollapsed;
|
||||
if (wasAtBottom.current && !hasSelection) {
|
||||
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"
|
||||
onScroll={handleScroll}
|
||||
sx={{
|
||||
backgroundColor: "grey.100",
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
overflow: "auto",
|
||||
height: 400,
|
||||
fontSize: "0.8rem",
|
||||
fontFamily: "monospace",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
96
frontend/src/components/package/PackageDetailsGrid.tsx
Normal file
96
frontend/src/components/package/PackageDetailsGrid.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from "react";
|
||||
import { Grid, Typography, Link } from "@mui/material";
|
||||
import type { Package } from "api/types/Package";
|
||||
import type { Dependencies } from "api/types/Dependencies";
|
||||
import type { PackageProperties } from "api/types/PackageProperties";
|
||||
|
||||
interface PackageDetailsGridProps {
|
||||
pkg: Package;
|
||||
dependencies?: Dependencies;
|
||||
}
|
||||
|
||||
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 PackageDetailsGrid({ pkg, dependencies }: PackageDetailsGridProps): React.JSX.Element {
|
||||
const packagesList = Object.entries(pkg.packages)
|
||||
.map(([name, p]) => `${name}${p.description ? ` (${p.description})` : ""}`);
|
||||
|
||||
const groups = Object.values(pkg.packages)
|
||||
.flatMap((p: PackageProperties) => p.groups ?? []);
|
||||
|
||||
const licenses = Object.values(pkg.packages)
|
||||
.flatMap((p: PackageProperties) => p.licenses ?? []);
|
||||
|
||||
const upstreamUrls = [...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 pkgNames = Object.keys(pkg.packages);
|
||||
const allDepends = Object.values(pkg.packages).flatMap((p: PackageProperties) => {
|
||||
const deps = (p.depends ?? []).filter((d) => !pkgNames.includes(d));
|
||||
const makeDeps = (p.make_depends ?? []).filter((d) => !pkgNames.includes(d)).map((d) => `${d} (make)`);
|
||||
const optDeps = (p.opt_depends ?? []).filter((d) => !pkgNames.includes(d)).map((d) => `${d} (optional)`);
|
||||
return [...deps, ...makeDeps, ...optDeps];
|
||||
});
|
||||
|
||||
const implicitDepends = dependencies
|
||||
? Object.values(dependencies.paths).flat()
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
|
||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(packagesList)}</Typography></Grid>
|
||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid>
|
||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.version}</Typography></Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packager</Typography></Grid>
|
||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
|
||||
<Grid size={{ xs: 4, md: 1 }} />
|
||||
<Grid size={{ xs: 8, md: 5 }} />
|
||||
</Grid>
|
||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
|
||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(groups)}</Typography></Grid>
|
||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid>
|
||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(licenses)}</Typography></Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">upstream</Typography></Grid>
|
||||
<Grid size={{ 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 size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid>
|
||||
<Grid size={{ 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 size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
|
||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(allDepends)}</Typography></Grid>
|
||||
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid>
|
||||
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(implicitDepends)}</Typography></Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/package/PackageInfoActions.tsx
Normal file
70
frontend/src/components/package/PackageInfoActions.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import type React from "react";
|
||||
import { DialogActions, Button, 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 AutoRefreshControl from "components/common/AutoRefreshControl";
|
||||
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||
|
||||
interface PackageInfoActionsProps {
|
||||
hasAuth: boolean;
|
||||
refreshDb: boolean;
|
||||
onRefreshDbChange: (checked: boolean) => void;
|
||||
onUpdate: () => void;
|
||||
onRemove: () => void;
|
||||
onReload: () => void;
|
||||
onClose: () => void;
|
||||
autorefreshIntervals: AutoRefreshInterval[];
|
||||
autoRefreshEnabled: boolean;
|
||||
autoRefreshInterval: number;
|
||||
onAutoRefreshToggle: (enabled: boolean) => void;
|
||||
onAutoRefreshIntervalChange: (interval: number) => void;
|
||||
}
|
||||
|
||||
export default function PackageInfoActions({
|
||||
hasAuth,
|
||||
refreshDb,
|
||||
onRefreshDbChange,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
onReload,
|
||||
onClose,
|
||||
autorefreshIntervals,
|
||||
autoRefreshEnabled,
|
||||
autoRefreshInterval,
|
||||
onAutoRefreshToggle,
|
||||
onAutoRefreshIntervalChange,
|
||||
}: PackageInfoActionsProps): React.JSX.Element {
|
||||
return (
|
||||
<DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
|
||||
{hasAuth && (
|
||||
<>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={refreshDb} onChange={(_, checked) => onRefreshDbChange(checked)} size="small" />}
|
||||
label="update pacman databases"
|
||||
/>
|
||||
<Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
|
||||
update
|
||||
</Button>
|
||||
<Button onClick={onRemove} variant="contained" color="error" startIcon={<DeleteIcon />} size="small">
|
||||
remove
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={onReload} variant="outlined" color="secondary" startIcon={<RefreshIcon />} size="small">
|
||||
reload
|
||||
</Button>
|
||||
<AutoRefreshControl
|
||||
intervals={autorefreshIntervals}
|
||||
enabled={autoRefreshEnabled}
|
||||
currentInterval={autoRefreshInterval}
|
||||
onToggle={onAutoRefreshToggle}
|
||||
onIntervalChange={onAutoRefreshIntervalChange}
|
||||
/>
|
||||
<Button onClick={onClose} variant="contained" startIcon={<CloseIcon />} size="small">
|
||||
close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/package/PackagePatchesList.tsx
Normal file
34
frontend/src/components/package/PackagePatchesList.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type React from "react";
|
||||
import { Box, Typography, Chip, IconButton } from "@mui/material";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import type { Patch } from "api/types/Patch";
|
||||
|
||||
interface PackagePatchesListProps {
|
||||
patches: Patch[];
|
||||
editable: boolean;
|
||||
onDelete: (key: string) => void;
|
||||
}
|
||||
|
||||
export default function PackagePatchesList({ patches, editable, onDelete }: PackagePatchesListProps): React.JSX.Element | null {
|
||||
if (patches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
{editable && (
|
||||
<IconButton size="small" color="error" onClick={() => onDelete(patch.key)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
182
frontend/src/components/table/PackageTable.tsx
Normal file
182
frontend/src/components/table/PackageTable.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
DataGrid,
|
||||
GRID_CHECKBOX_SELECTION_COL_DEF,
|
||||
useGridApiRef,
|
||||
type GridColDef,
|
||||
type GridFilterModel,
|
||||
type GridRenderCellParams,
|
||||
type GridRowSelectionModel,
|
||||
} from "@mui/x-data-grid";
|
||||
import { Box, Link } from "@mui/material";
|
||||
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 { usePackageTable } from "hooks/usePackageTable";
|
||||
import { useDebounce } from "hooks/useDebounce";
|
||||
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||
import type { PackageRow } from "api/types/PackageRow";
|
||||
|
||||
interface PackageTableProps {
|
||||
autorefreshIntervals: AutoRefreshInterval[];
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||
|
||||
export default function PackageTable({ autorefreshIntervals }: PackageTableProps): React.JSX.Element {
|
||||
const table = usePackageTable(autorefreshIntervals);
|
||||
const apiRef = useGridApiRef();
|
||||
const debouncedSearch = useDebounce(table.searchText, 300);
|
||||
|
||||
const effectiveFilterModel: GridFilterModel = useMemo(
|
||||
() => ({
|
||||
...table.filterModel,
|
||||
quickFilterValues: debouncedSearch ? debouncedSearch.split(/\s+/) : undefined,
|
||||
}),
|
||||
[table.filterModel, debouncedSearch],
|
||||
);
|
||||
|
||||
const columns: GridColDef<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={table.selectionModel.length > 0}
|
||||
hasAuth={table.hasAuth}
|
||||
repoStatus={table.repoStatus}
|
||||
searchText={table.searchText}
|
||||
onSearchChange={table.setSearchText}
|
||||
autorefreshIntervals={autorefreshIntervals}
|
||||
autoRefreshEnabled={table.autoRefreshEnabled}
|
||||
autoRefreshInterval={table.autoRefreshInterval}
|
||||
onAutoRefreshToggle={table.onAutoRefreshToggle}
|
||||
onAutoRefreshIntervalChange={table.onAutoRefreshIntervalChange}
|
||||
onDashboardClick={() => table.setDialogOpen("dashboard")}
|
||||
onAddClick={() => table.setDialogOpen("add")}
|
||||
onUpdateClick={() => void table.handleUpdate()}
|
||||
onRefreshDbClick={() => void table.handleRefreshDb()}
|
||||
onRebuildClick={() => table.setDialogOpen("rebuild")}
|
||||
onRemoveClick={() => void table.handleRemove()}
|
||||
onKeyImportClick={() => table.setDialogOpen("keyImport")}
|
||||
onReloadClick={table.handleReload}
|
||||
onExportClick={() => apiRef.current?.exportDataAsCsv()}
|
||||
/>
|
||||
|
||||
<DataGrid
|
||||
apiRef={apiRef}
|
||||
rows={table.rows}
|
||||
columns={columns}
|
||||
loading={table.isLoading}
|
||||
getRowHeight={() => "auto"}
|
||||
checkboxSelection
|
||||
disableRowSelectionOnClick
|
||||
rowSelectionModel={{ type: "include", ids: new Set(table.selectionModel) } as GridRowSelectionModel}
|
||||
onRowSelectionModelChange={(model) => table.setSelectionModel([...model.ids] as string[])}
|
||||
paginationModel={table.paginationModel}
|
||||
onPaginationModelChange={table.setPaginationModel}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
columnVisibilityModel={table.columnVisibility}
|
||||
onColumnVisibilityModelChange={table.setColumnVisibility}
|
||||
filterModel={effectiveFilterModel}
|
||||
onFilterModelChange={table.setFilterModel}
|
||||
initialState={{
|
||||
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
|
||||
}}
|
||||
onCellClick={(params, event) => {
|
||||
// Don't open info dialog when clicking checkbox or link
|
||||
if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
|
||||
return;
|
||||
}
|
||||
if ((event.target as HTMLElement).closest("a")) {
|
||||
return;
|
||||
}
|
||||
table.setSelectedPackage(String(params.id));
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiDataGrid-row": { cursor: "pointer" },
|
||||
height: 600,
|
||||
}}
|
||||
density="compact"
|
||||
/>
|
||||
|
||||
<DashboardDialog open={table.dialogOpen === "dashboard"} onClose={() => table.setDialogOpen(null)} />
|
||||
<PackageAddDialog open={table.dialogOpen === "add"} onClose={() => table.setDialogOpen(null)} />
|
||||
<PackageRebuildDialog open={table.dialogOpen === "rebuild"} onClose={() => table.setDialogOpen(null)} />
|
||||
<KeyImportDialog open={table.dialogOpen === "keyImport"} onClose={() => table.setDialogOpen(null)} />
|
||||
<PackageInfoDialog
|
||||
packageBase={table.selectedPackage}
|
||||
open={table.selectedPackage !== null}
|
||||
onClose={() => table.setSelectedPackage(null)}
|
||||
autorefreshIntervals={autorefreshIntervals}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
174
frontend/src/components/table/PackageTableToolbar.tsx
Normal file
174
frontend/src/components/table/PackageTableToolbar.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Menu, MenuItem, Box, Tooltip, IconButton, Divider, TextField, InputAdornment } 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 FileDownloadIcon from "@mui/icons-material/FileDownload";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import ClearIcon from "@mui/icons-material/Clear";
|
||||
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;
|
||||
searchText: string;
|
||||
onSearchChange: (text: string) => void;
|
||||
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;
|
||||
onExportClick: () => void;
|
||||
}
|
||||
|
||||
export default function PackageTableToolbar({
|
||||
hasSelection,
|
||||
hasAuth,
|
||||
repoStatus,
|
||||
searchText,
|
||||
onSearchChange,
|
||||
autorefreshIntervals,
|
||||
autoRefreshEnabled,
|
||||
autoRefreshInterval,
|
||||
onAutoRefreshToggle,
|
||||
onAutoRefreshIntervalChange,
|
||||
onDashboardClick,
|
||||
onAddClick,
|
||||
onUpdateClick,
|
||||
onRefreshDbClick,
|
||||
onRebuildClick,
|
||||
onRemoveClick,
|
||||
onKeyImportClick,
|
||||
onReloadClick,
|
||||
onExportClick,
|
||||
}: 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 sx={{ flexGrow: 1 }} />
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="search packages..."
|
||||
value={searchText}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: searchText ? (
|
||||
<InputAdornment position="end">
|
||||
<IconButton size="small" onClick={() => onSearchChange("")}>
|
||||
<ClearIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
) : undefined,
|
||||
},
|
||||
}}
|
||||
sx={{ minWidth: 200 }}
|
||||
/>
|
||||
|
||||
<Tooltip title="Export CSV">
|
||||
<IconButton size="small" onClick={onExportClick}>
|
||||
<FileDownloadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</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/Notification.ts
Normal file
8
frontend/src/contexts/Notification.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { AlertColor } from "@mui/material";
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
title: string;
|
||||
message: string;
|
||||
severity: AlertColor;
|
||||
}
|
||||
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);
|
||||
31
frontend/src/contexts/NotificationItem.tsx
Normal file
31
frontend/src/contexts/NotificationItem.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Alert, Slide } from "@mui/material";
|
||||
import type { Notification } from "contexts/Notification";
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: Notification;
|
||||
onClose: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function NotificationItem({ notification, onClose }: NotificationItemProps): React.JSX.Element {
|
||||
const [show, setShow] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShow(false), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Slide direction="down" in={show} mountOnEnter unmountOnExit onExited={() => onClose(notification.id)}>
|
||||
<Alert
|
||||
onClose={() => setShow(false)}
|
||||
severity={notification.severity}
|
||||
variant="filled"
|
||||
sx={{ width: "100%", pointerEvents: "auto" }}
|
||||
>
|
||||
<strong>{notification.title}</strong>
|
||||
{notification.message && ` - ${notification.message}`}
|
||||
</Alert>
|
||||
</Slide>
|
||||
);
|
||||
}
|
||||
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 { Box, type AlertColor } from "@mui/material";
|
||||
import { NotificationContext } from "contexts/NotificationContext";
|
||||
import NotificationItem from "contexts/NotificationItem";
|
||||
import type { Notification } from "contexts/Notification";
|
||||
|
||||
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}
|
||||
<Box
|
||||
sx={{
|
||||
position: "fixed",
|
||||
top: 16,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: (theme) => theme.zIndex.snackbar,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
maxWidth: 500,
|
||||
width: "100%",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{notifications.map((n) => (
|
||||
<NotificationItem key={n.id} notification={n} onClose={removeNotification} />
|
||||
))}
|
||||
</Box>
|
||||
</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);
|
||||
35
frontend/src/contexts/RepositoryProvider.tsx
Normal file
35
frontend/src/contexts/RepositoryProvider.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { useState, useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { repoKey } from "api/types/RepositoryId";
|
||||
import type { RepositoryId } from "api/types/RepositoryId";
|
||||
import { RepositoryContext } from "contexts/RepositoryContext";
|
||||
|
||||
function subscribeToHash(callback: () => void): () => void {
|
||||
window.addEventListener("hashchange", callback);
|
||||
return () => window.removeEventListener("hashchange", callback);
|
||||
}
|
||||
|
||||
function getHashSnapshot(): string {
|
||||
return window.location.hash.replace("#", "");
|
||||
}
|
||||
|
||||
export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
const [repositories, setRepositories] = useState<RepositoryId[]>([]);
|
||||
const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
|
||||
|
||||
const current = useMemo(() => {
|
||||
if (repositories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return repositories.find((r) => repoKey(r) === hash) ?? repositories[0] ?? null;
|
||||
}, [repositories, hash]);
|
||||
|
||||
const setCurrent = useCallback((repo: RepositoryId) => {
|
||||
window.location.hash = repoKey(repo);
|
||||
}, []);
|
||||
|
||||
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;
|
||||
}
|
||||
86
frontend/src/hooks/usePackageActions.ts
Normal file
86
frontend/src/hooks/usePackageActions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { useNotification } from "hooks/useNotification";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { ApiError } from "api/client/ApiError";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
|
||||
export interface UsePackageActionsResult {
|
||||
handleReload: () => void;
|
||||
handleUpdate: () => Promise<void>;
|
||||
handleRefreshDb: () => Promise<void>;
|
||||
handleRemove: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePackageActions(
|
||||
selectionModel: string[],
|
||||
setSelectionModel: (model: string[]) => void,
|
||||
): UsePackageActionsResult {
|
||||
const { current } = useRepository();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
|
||||
}, [current, queryClient]);
|
||||
|
||||
const handleUpdate = useCallback(async () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (selectionModel.length === 0) {
|
||||
await Client.updatePackages(current, { packages: [] });
|
||||
showSuccess("Success", "Repository update has been run");
|
||||
} else {
|
||||
await Client.addPackages(current, { packages: selectionModel });
|
||||
showSuccess("Success", `Run update for packages ${selectionModel.join(", ")}`);
|
||||
}
|
||||
} catch (e) {
|
||||
const detail = ApiError.errorDetail(e);
|
||||
showError("Action failed", `Packages update failed: ${detail}`);
|
||||
}
|
||||
}, [current, selectionModel, showSuccess, showError]);
|
||||
|
||||
const handleRefreshDb = useCallback(async () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Client.updatePackages(current, { packages: [], refresh: true, aur: false, local: false, manual: false });
|
||||
showSuccess("Success", "Pacman database update has been requested");
|
||||
} catch (e) {
|
||||
const detail = ApiError.errorDetail(e);
|
||||
showError("Action failed", `Could not update pacman databases: ${detail}`);
|
||||
}
|
||||
}, [current, showSuccess, showError]);
|
||||
|
||||
const handleRemove = useCallback(async () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
if (selectionModel.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Client.removePackages(current, selectionModel);
|
||||
showSuccess("Success", `Packages ${selectionModel.join(", ")} have been removed`);
|
||||
setSelectionModel([]);
|
||||
} catch (e) {
|
||||
const detail = ApiError.errorDetail(e);
|
||||
showError("Action failed", `Could not remove packages: ${detail}`);
|
||||
}
|
||||
}, [current, selectionModel, setSelectionModel, showSuccess, showError]);
|
||||
|
||||
return {
|
||||
handleReload,
|
||||
handleUpdate,
|
||||
handleRefreshDb,
|
||||
handleRemove,
|
||||
};
|
||||
}
|
||||
79
frontend/src/hooks/usePackageData.ts
Normal file
79
frontend/src/hooks/usePackageData.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useMemo } from "react";
|
||||
import { skipToken, useQuery } from "@tanstack/react-query";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { useAuth } from "hooks/useAuth";
|
||||
import { useAutoRefresh } from "hooks/useAutoRefresh";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||
import type { BuildStatus } from "api/types/BuildStatus";
|
||||
import type { PackageRow } from "api/types/PackageRow";
|
||||
import type { PackageStatus } from "api/types/PackageStatus";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export interface UsePackageDataResult {
|
||||
rows: PackageRow[];
|
||||
isLoading: boolean;
|
||||
hasAuth: boolean;
|
||||
repoStatus: BuildStatus | undefined;
|
||||
autoRefresh: ReturnType<typeof useAutoRefresh>;
|
||||
}
|
||||
|
||||
export function usePackageData(autorefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
|
||||
const { current } = useRepository();
|
||||
const { enabled: authEnabled, username } = useAuth();
|
||||
|
||||
const hasAuth = !authEnabled || username !== null;
|
||||
|
||||
const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
|
||||
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval);
|
||||
|
||||
const { data: packages = [], isLoading } = useQuery<PackageStatus[]>({
|
||||
queryKey: current ? QueryKeys.packages(current) : ["packages"],
|
||||
queryFn: current ? () => Client.fetchPackages(current) : skipToken,
|
||||
enabled: !!current,
|
||||
refetchInterval: autoRefresh.refetchInterval,
|
||||
});
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: current ? QueryKeys.status(current) : ["status"],
|
||||
queryFn: current ? () => Client.fetchStatus(current) : skipToken,
|
||||
enabled: !!current,
|
||||
refetchInterval: autoRefresh.refetchInterval,
|
||||
});
|
||||
|
||||
const rows = useMemo(() => packages.map(toRow), [packages]);
|
||||
|
||||
return {
|
||||
rows,
|
||||
isLoading,
|
||||
hasAuth,
|
||||
repoStatus: status?.status.status,
|
||||
autoRefresh,
|
||||
};
|
||||
}
|
||||
73
frontend/src/hooks/usePackageTable.ts
Normal file
73
frontend/src/hooks/usePackageTable.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect } from "react";
|
||||
import type { GridFilterModel } from "@mui/x-data-grid";
|
||||
import { usePackageData } from "hooks/usePackageData";
|
||||
import { useTableState } from "hooks/useTableState";
|
||||
import { usePackageActions } from "hooks/usePackageActions";
|
||||
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||
import type { BuildStatus } from "api/types/BuildStatus";
|
||||
import type { PackageRow } from "api/types/PackageRow";
|
||||
|
||||
export type { DialogType } from "hooks/useTableState";
|
||||
|
||||
export interface UsePackageTableResult {
|
||||
rows: PackageRow[];
|
||||
isLoading: boolean;
|
||||
hasAuth: boolean;
|
||||
repoStatus: BuildStatus | undefined;
|
||||
|
||||
selectionModel: string[];
|
||||
setSelectionModel: (model: string[]) => void;
|
||||
|
||||
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
|
||||
setDialogOpen: (dialog: "dashboard" | "add" | "rebuild" | "keyImport" | null) => void;
|
||||
selectedPackage: string | null;
|
||||
setSelectedPackage: (base: string | null) => void;
|
||||
|
||||
paginationModel: { pageSize: number; page: number };
|
||||
setPaginationModel: (model: { pageSize: number; page: number }) => void;
|
||||
columnVisibility: Record<string, boolean>;
|
||||
setColumnVisibility: (model: Record<string, boolean>) => void;
|
||||
filterModel: GridFilterModel;
|
||||
setFilterModel: (model: GridFilterModel) => void;
|
||||
searchText: string;
|
||||
setSearchText: (text: string) => void;
|
||||
|
||||
autoRefreshEnabled: boolean;
|
||||
autoRefreshInterval: number;
|
||||
onAutoRefreshToggle: (enabled: boolean) => void;
|
||||
onAutoRefreshIntervalChange: (interval: number) => void;
|
||||
|
||||
handleReload: () => void;
|
||||
handleUpdate: () => Promise<void>;
|
||||
handleRefreshDb: () => Promise<void>;
|
||||
handleRemove: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function usePackageTable(autorefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
|
||||
const { rows, isLoading, hasAuth, repoStatus, autoRefresh } = usePackageData(autorefreshIntervals);
|
||||
const tableState = useTableState();
|
||||
const actions = usePackageActions(tableState.selectionModel, tableState.setSelectionModel);
|
||||
|
||||
// Pause auto-refresh when dialog is open
|
||||
const isDialogOpen = tableState.dialogOpen !== null || tableState.selectedPackage !== null;
|
||||
const setPaused = autoRefresh.setPaused;
|
||||
useEffect(() => {
|
||||
setPaused(isDialogOpen);
|
||||
}, [isDialogOpen, setPaused]);
|
||||
|
||||
return {
|
||||
rows,
|
||||
isLoading,
|
||||
hasAuth,
|
||||
repoStatus,
|
||||
|
||||
...tableState,
|
||||
|
||||
autoRefreshEnabled: autoRefresh.enabled,
|
||||
autoRefreshInterval: autoRefresh.interval,
|
||||
onAutoRefreshToggle: autoRefresh.setEnabled,
|
||||
onAutoRefreshIntervalChange: autoRefresh.setInterval,
|
||||
|
||||
...actions,
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
32
frontend/src/hooks/useSelectedRepository.ts
Normal file
32
frontend/src/hooks/useSelectedRepository.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { repoKey } from "api/types/RepositoryId";
|
||||
import type { RepositoryId } from "api/types/RepositoryId";
|
||||
|
||||
export interface SelectedRepositoryResult {
|
||||
selectedKey: string;
|
||||
setSelectedKey: (key: string) => void;
|
||||
selectedRepo: RepositoryId | null;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useSelectedRepository(): SelectedRepositoryResult {
|
||||
const { repositories, current } = useRepository();
|
||||
const [selectedKey, setSelectedKey] = useState("");
|
||||
|
||||
const selectedRepo: RepositoryId | null = (() => {
|
||||
if (selectedKey) {
|
||||
const repo = repositories.find((r) => repoKey(r) === selectedKey);
|
||||
if (repo) {
|
||||
return repo;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
})();
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSelectedKey("");
|
||||
}, []);
|
||||
|
||||
return { selectedKey, setSelectedKey, selectedRepo, reset };
|
||||
}
|
||||
63
frontend/src/hooks/useTableState.ts
Normal file
63
frontend/src/hooks/useTableState.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import type { GridFilterModel } from "@mui/x-data-grid";
|
||||
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||
|
||||
export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport";
|
||||
|
||||
export interface UseTableStateResult {
|
||||
selectionModel: string[];
|
||||
setSelectionModel: (model: string[]) => void;
|
||||
|
||||
dialogOpen: DialogType | null;
|
||||
setDialogOpen: (dialog: DialogType | null) => void;
|
||||
selectedPackage: string | null;
|
||||
setSelectedPackage: (base: string | null) => void;
|
||||
|
||||
paginationModel: { pageSize: number; page: number };
|
||||
setPaginationModel: (model: { pageSize: number; page: number }) => void;
|
||||
columnVisibility: Record<string, boolean>;
|
||||
setColumnVisibility: (model: Record<string, boolean>) => void;
|
||||
filterModel: GridFilterModel;
|
||||
setFilterModel: (model: GridFilterModel) => void;
|
||||
searchText: string;
|
||||
setSearchText: (text: string) => void;
|
||||
}
|
||||
|
||||
export function useTableState(): UseTableStateResult {
|
||||
const [selectionModel, setSelectionModel] = useState<string[]>([]);
|
||||
const [dialogOpen, setDialogOpen] = useState<DialogType | null>(null);
|
||||
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
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: [] },
|
||||
);
|
||||
|
||||
return {
|
||||
selectionModel,
|
||||
setSelectionModel,
|
||||
|
||||
dialogOpen,
|
||||
setDialogOpen,
|
||||
selectedPackage,
|
||||
setSelectedPackage,
|
||||
|
||||
paginationModel,
|
||||
setPaginationModel,
|
||||
columnVisibility,
|
||||
setColumnVisibility,
|
||||
filterModel,
|
||||
setFilterModel,
|
||||
searchText,
|
||||
setSearchText,
|
||||
};
|
||||
}
|
||||
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 }>;
|
||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"baseUrl": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
37
frontend/vite.config.ts
Normal file
37
frontend/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import path from "path";
|
||||
|
||||
function renameHtml(newName: string): Plugin {
|
||||
return {
|
||||
name: "rename-html",
|
||||
enforce: "post",
|
||||
generateBundle(_, bundle) {
|
||||
if (bundle["index.html"]) {
|
||||
bundle["index.html"].fileName = newName;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tsconfigPaths(), renameHtml("build-status-v2.jinja2")],
|
||||
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",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -9,7 +9,7 @@ arch=('any')
|
||||
url="https://ahriman.readthedocs.io/"
|
||||
license=('GPL-3.0-or-later')
|
||||
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-bcrypt' 'python-filelock' 'python-inflection' 'python-pyelftools' 'python-requests')
|
||||
makedepends=('python-build' 'python-flit' 'python-installer' 'python-wheel')
|
||||
makedepends=('npm' 'python-build' 'python-flit' 'python-installer' 'python-wheel')
|
||||
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgbase-$pkgver.tar.gz"
|
||||
"$pkgbase.sysusers"
|
||||
"$pkgbase.tmpfiles")
|
||||
@@ -18,6 +18,10 @@ build() {
|
||||
cd "$pkgbase-$pkgver"
|
||||
|
||||
python -m build --wheel --no-isolation
|
||||
|
||||
cd "frontend"
|
||||
npm install
|
||||
npm run build
|
||||
}
|
||||
|
||||
package_ahriman() {
|
||||
|
||||
@@ -46,6 +46,8 @@ host = 127.0.0.1
|
||||
;service_only = no
|
||||
; Path to directory with static files.
|
||||
static_path = ${templates}/static
|
||||
; Jinja2 template name for the index page.
|
||||
;template = build-status.jinja2
|
||||
; List of directories with templates.
|
||||
templates[] = ${prefix}/share/ahriman/templates
|
||||
; Path to unix socket. If none set, unix socket will be disabled.
|
||||
|
||||
17
package/share/ahriman/templates/build-status-v2.jinja2
Normal file
17
package/share/ahriman/templates/build-status-v2.jinja2
Normal file
@@ -0,0 +1,17 @@
|
||||
<!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" />
|
||||
<script type="module" crossorigin src="/static/index.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/index.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -118,11 +118,14 @@ include = [
|
||||
"CONTRIBUTING.md",
|
||||
"SECURITY.md",
|
||||
"package",
|
||||
"frontend",
|
||||
"subpackages.py",
|
||||
"web.png",
|
||||
]
|
||||
exclude = [
|
||||
"package/archlinux",
|
||||
"frontend/node_modules",
|
||||
"frontend/package-lock.json",
|
||||
]
|
||||
|
||||
[tool.flit.external-data]
|
||||
|
||||
@@ -398,6 +398,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
|
||||
"path_exists": True,
|
||||
"path_type": "dir",
|
||||
},
|
||||
"template": {
|
||||
"type": "string",
|
||||
"empty": False,
|
||||
},
|
||||
"templates": {
|
||||
"type": "list",
|
||||
"coerce": "list",
|
||||
|
||||
@@ -35,7 +35,7 @@ from enum import Enum
|
||||
from filelock import FileLock
|
||||
from pathlib import Path
|
||||
from pwd import getpwuid
|
||||
from typing import Any, IO, TypeVar
|
||||
from typing import Any, IO, TypeVar, cast
|
||||
|
||||
from ahriman.core.exceptions import CalledProcessError, OptionError, UnsafeRunError
|
||||
from ahriman.core.types import Comparable
|
||||
@@ -285,16 +285,17 @@ def filelock(path: Path) -> Iterator[FileLock]:
|
||||
lock_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str, Any]:
|
||||
def filter_json(source: T, known_fields: Iterable[str] | None = None) -> T:
|
||||
"""
|
||||
filter json object by fields used for json-to-object conversion
|
||||
recursively filter json object removing ``None`` values and optionally filtering by known fields
|
||||
|
||||
Args:
|
||||
source(dict[str, Any]): raw json object
|
||||
known_fields(Iterable[str]): list of fields which have to be known for the target object
|
||||
source(T): raw json object (dict, list, or scalar)
|
||||
known_fields(Iterable[str] | None, optional): list of fields which have to be known for the target object
|
||||
(Default value = None)
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: json object without unknown and empty fields
|
||||
T: json without ``None`` values
|
||||
|
||||
Examples:
|
||||
This wrapper is mainly used for the dataclasses, thus the flow must be something like this::
|
||||
@@ -306,7 +307,15 @@ def filter_json(source: dict[str, Any], known_fields: Iterable[str]) -> dict[str
|
||||
>>> properties = filter_json(dump, known_fields)
|
||||
>>> package = Package(**properties)
|
||||
"""
|
||||
return {key: value for key, value in source.items() if key in known_fields and value is not None}
|
||||
if isinstance(source, dict):
|
||||
return cast(T, {
|
||||
key: filter_json(value)
|
||||
for key, value in source.items()
|
||||
if value is not None and (known_fields is None or key in known_fields)
|
||||
})
|
||||
if isinstance(source, list):
|
||||
return cast(T, [filter_json(value) for value in source if value is not None])
|
||||
return source
|
||||
|
||||
|
||||
def full_version(epoch: str | int | None, pkgver: str, pkgrel: str) -> str:
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
#
|
||||
from ahriman.web.schemas.any_schema import AnySchema
|
||||
from ahriman.web.schemas.aur_package_schema import AURPackageSchema
|
||||
from ahriman.web.schemas.auth_info_schema import AuthInfoSchema
|
||||
from ahriman.web.schemas.auth_schema import AuthSchema
|
||||
from ahriman.web.schemas.auto_refresh_interval_schema import AutoRefreshIntervalSchema
|
||||
from ahriman.web.schemas.build_options_schema import BuildOptionsSchema
|
||||
from ahriman.web.schemas.changes_schema import ChangesSchema
|
||||
from ahriman.web.schemas.configuration_schema import ConfigurationSchema
|
||||
@@ -30,6 +32,7 @@ from ahriman.web.schemas.event_schema import EventSchema
|
||||
from ahriman.web.schemas.event_search_schema import EventSearchSchema
|
||||
from ahriman.web.schemas.file_schema import FileSchema
|
||||
from ahriman.web.schemas.info_schema import InfoSchema
|
||||
from ahriman.web.schemas.info_v2_schema import InfoV2Schema
|
||||
from ahriman.web.schemas.internal_status_schema import InternalStatusSchema
|
||||
from ahriman.web.schemas.log_schema import LogSchema
|
||||
from ahriman.web.schemas.login_schema import LoginSchema
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
auth = fields.Boolean(dump_default=False, required=True, metadata={
|
||||
auth = fields.Boolean(required=True, metadata={
|
||||
"description": "Whether authentication is enabled or not",
|
||||
})
|
||||
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
|
||||
|
||||
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",
|
||||
"example": "x86_64",
|
||||
})
|
||||
id = fields.String(metadata={
|
||||
"description": "Unique repository identifier",
|
||||
"example": "aur-x86_64",
|
||||
})
|
||||
repository = fields.String(metadata={
|
||||
"description": "Repository name",
|
||||
"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
|
||||
# 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 typing import ClassVar
|
||||
|
||||
@@ -96,4 +96,4 @@ class SwaggerView(BaseView):
|
||||
for key, value in schema.items()
|
||||
}
|
||||
|
||||
return json_response(spec)
|
||||
return self.json_response(spec)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user