mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-04-07 19:03:38 +00:00
upload ai slop
This commit is contained in:
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"
|
||||
}
|
||||
}
|
||||
38
frontend/src/App.tsx
Normal file
38
frontend/src/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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 { ClientProvider } from "contexts/ClientProvider";
|
||||
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 />
|
||||
<ClientProvider>
|
||||
<AuthProvider>
|
||||
<RepositoryProvider>
|
||||
<NotificationProvider>
|
||||
<AppLayout />
|
||||
</NotificationProvider>
|
||||
</RepositoryProvider>
|
||||
</AuthProvider>
|
||||
</ClientProvider>
|
||||
</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,
|
||||
};
|
||||
218
frontend/src/api/client/AhrimanClient.ts
Normal file
218
frontend/src/api/client/AhrimanClient.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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> = { ...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" });
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
5
frontend/src/api/defaultInterval.ts
Normal file
5
frontend/src/api/defaultInterval.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||
|
||||
export function defaultInterval(intervals: AutoRefreshInterval[]): number {
|
||||
return intervals.find((i) => i.is_active)?.interval ?? 0;
|
||||
}
|
||||
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;
|
||||
}
|
||||
1
frontend/src/api/types/PackageSource.ts
Normal file
1
frontend/src/api/types/PackageSource.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type PackageSource = "auto" | "archive" | "aur" | "directory" | "local" | "remote" | "repository";
|
||||
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;
|
||||
}
|
||||
9
frontend/src/api/types/Remote.ts
Normal file
9
frontend/src/api/types/Remote.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { PackageSource } from "api/types/PackageSource";
|
||||
|
||||
export interface Remote {
|
||||
branch?: string;
|
||||
git_url?: string;
|
||||
path?: string;
|
||||
source: PackageSource;
|
||||
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;
|
||||
}
|
||||
22
frontend/src/chartSetup.ts
Normal file
22
frontend/src/chartSetup.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
ArcElement,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
} from "chart.js";
|
||||
|
||||
ChartJS.register(
|
||||
ArcElement,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
);
|
||||
30
frontend/src/components/charts/EventDurationLineChart.tsx
Normal file
30
frontend/src/components/charts/EventDurationLineChart.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type React from "react";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import type { Event } from "api/types/Event";
|
||||
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||
|
||||
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?.took as number | undefined) ?? 0),
|
||||
cubicInterpolationMode: "monotone" as const,
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <Line data={data} options={{ responsive: true }} />;
|
||||
}
|
||||
40
frontend/src/components/charts/PackageCountBarChart.tsx
Normal file
40
frontend/src/components/charts/PackageCountBarChart.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type React from "react";
|
||||
import { Bar } from "react-chartjs-2";
|
||||
import { blue, indigo } from "@mui/material/colors";
|
||||
import type { RepositoryStats } from "api/types/RepositoryStats";
|
||||
|
||||
interface PackageCountBarChartProps {
|
||||
stats: RepositoryStats;
|
||||
}
|
||||
|
||||
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
|
||||
const data = {
|
||||
labels: ["packages"],
|
||||
datasets: [
|
||||
{
|
||||
label: "archives",
|
||||
data: [stats.packages ?? 0],
|
||||
backgroundColor: blue[500],
|
||||
},
|
||||
{
|
||||
label: "bases",
|
||||
data: [stats.bases ?? 0],
|
||||
backgroundColor: indigo[300],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<Bar
|
||||
data={data}
|
||||
options={{
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: { stacked: true },
|
||||
y: { stacked: true },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/charts/StatusPieChart.tsx
Normal file
24
frontend/src/components/charts/StatusPieChart.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type React from "react";
|
||||
import { Pie } from "react-chartjs-2";
|
||||
import type { Counters } from "api/types/Counters";
|
||||
import { StatusColors } from "theme/status/StatusColors";
|
||||
|
||||
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 }} />;
|
||||
}
|
||||
76
frontend/src/components/common/AutoRefreshControl.tsx
Normal file
76
frontend/src/components/common/AutoRefreshControl.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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);
|
||||
onToggle(true);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{enabled && iv.interval === currentInterval && <CheckIcon fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText>{iv.text}</ListItemText>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/common/CodeBlock.tsx
Normal file
56
frontend/src/components/common/CodeBlock.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { type RefObject } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import CopyButton from "components/common/CopyButton";
|
||||
|
||||
interface CodeBlockProps {
|
||||
codeRef?: RefObject<HTMLElement | null>;
|
||||
preRef?: RefObject<HTMLElement | null>;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
getText: () => string;
|
||||
maxHeight?: number | string;
|
||||
height?: number | string;
|
||||
onScroll?: () => void;
|
||||
wordBreak?: boolean;
|
||||
}
|
||||
|
||||
export default function CodeBlock({
|
||||
codeRef,
|
||||
preRef,
|
||||
className,
|
||||
children,
|
||||
getText,
|
||||
maxHeight,
|
||||
height,
|
||||
onScroll,
|
||||
wordBreak,
|
||||
}: CodeBlockProps): React.JSX.Element {
|
||||
return (
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<Box
|
||||
ref={preRef}
|
||||
component="pre"
|
||||
onScroll={onScroll}
|
||||
sx={{
|
||||
backgroundColor: "grey.100",
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
overflow: "auto",
|
||||
maxHeight,
|
||||
height,
|
||||
fontSize: "0.8rem",
|
||||
fontFamily: "monospace",
|
||||
...(wordBreak ? { whiteSpace: "pre-wrap", wordBreak: "break-all" } : {}),
|
||||
}}
|
||||
>
|
||||
{codeRef
|
||||
? <code ref={codeRef} className={className} />
|
||||
: <code className={className}>{children}</code>
|
||||
}
|
||||
</Box>
|
||||
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||
<CopyButton getText={getText} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/common/CopyButton.tsx
Normal file
30
frontend/src/components/common/CopyButton.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { useEffect, useRef, 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 timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
await navigator.clipboard.writeText(getText());
|
||||
setCopied(true);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={copied ? "Copied!" : "Copy"}>
|
||||
<IconButton size="small" aria-label={copied ? "Copied" : "Copy"} onClick={() => void handleCopy()}>
|
||||
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/common/DialogHeader.tsx
Normal file
20
frontend/src/components/common/DialogHeader.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type React from "react";
|
||||
import { DialogTitle, IconButton, type SxProps, type Theme } from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
interface DialogHeaderProps {
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
sx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element {
|
||||
return (
|
||||
<DialogTitle sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", ...sx }}>
|
||||
{children}
|
||||
<IconButton aria-label="Close" onClick={onClose} size="small" sx={{ color: "inherit" }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
);
|
||||
}
|
||||
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())}`;
|
||||
}
|
||||
90
frontend/src/components/dialogs/DashboardDialog.tsx
Normal file
90
frontend/src/components/dialogs/DashboardDialog.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type React from "react";
|
||||
import { Dialog, DialogContent, Grid, Typography, Box } from "@mui/material";
|
||||
import DialogHeader from "components/common/DialogHeader";
|
||||
import { skipToken, useQuery } from "@tanstack/react-query";
|
||||
import StatusPieChart from "components/charts/StatusPieChart";
|
||||
import PackageCountBarChart from "components/charts/PackageCountBarChart";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import { useClient } from "hooks/useClient";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { useDialogClose } from "hooks/useDialogClose";
|
||||
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 client = useClient();
|
||||
const { current } = useRepository();
|
||||
const { isOpen, requestClose, transitionProps } = useDialogClose(open, onClose);
|
||||
|
||||
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={isOpen} onClose={requestClose} maxWidth="lg" fullWidth slotProps={{ transition: transitionProps }}>
|
||||
<DialogHeader onClose={requestClose} sx={headerStyle}>
|
||||
System health
|
||||
</DialogHeader>
|
||||
<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, display: "flex", justifyContent: "center", alignItems: "center" }}>
|
||||
<StatusPieChart counters={status.packages} />
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/dialogs/KeyImportDialog.tsx
Normal file
99
frontend/src/components/dialogs/KeyImportDialog.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogActions, Button,
|
||||
TextField, Box,
|
||||
} from "@mui/material";
|
||||
import DialogHeader from "components/common/DialogHeader";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import { useNotification } from "hooks/useNotification";
|
||||
import { useClient } from "hooks/useClient";
|
||||
import { useDialogClose } from "hooks/useDialogClose";
|
||||
import { ApiError } from "api/client/ApiError";
|
||||
import CodeBlock from "components/common/CodeBlock";
|
||||
|
||||
interface KeyImportDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element {
|
||||
const client = useClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const [fingerprint, setFingerprint] = useState("");
|
||||
const [server, setServer] = useState("keyserver.ubuntu.com");
|
||||
const [keyBody, setKeyBody] = useState("");
|
||||
|
||||
const onOpen = useCallback(() => {
|
||||
setFingerprint("");
|
||||
setServer("keyserver.ubuntu.com");
|
||||
setKeyBody("");
|
||||
}, []);
|
||||
|
||||
const { isOpen, requestClose, transitionProps } = useDialogClose(open, onClose, onOpen);
|
||||
|
||||
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 });
|
||||
requestClose();
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={requestClose} maxWidth="lg" fullWidth slotProps={{ transition: transitionProps }}>
|
||||
<DialogHeader onClose={requestClose}>
|
||||
Import key from PGP server
|
||||
</DialogHeader>
|
||||
<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={{ mt: 2 }}>
|
||||
<CodeBlock getText={() => keyBody} maxHeight={300}>
|
||||
{keyBody}
|
||||
</CodeBlock>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/dialogs/LoginDialog.tsx
Normal file
98
frontend/src/components/dialogs/LoginDialog.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogActions, Button, TextField,
|
||||
InputAdornment, IconButton,
|
||||
} from "@mui/material";
|
||||
import DialogHeader from "components/common/DialogHeader";
|
||||
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 { useDialogClose } from "hooks/useDialogClose";
|
||||
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 onOpen = useCallback(() => {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setShowPassword(false);
|
||||
}, []);
|
||||
|
||||
const { isOpen, requestClose, transitionProps } = useDialogClose(open, onClose, onOpen);
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (!username || !password) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await login(username, password);
|
||||
requestClose();
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={requestClose} maxWidth="xs" fullWidth slotProps={{ transition: transitionProps }}>
|
||||
<DialogHeader onClose={requestClose}>
|
||||
Login
|
||||
</DialogHeader>
|
||||
<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 aria-label={showPassword ? "Hide password" : "Show password"} 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>
|
||||
);
|
||||
}
|
||||
184
frontend/src/components/dialogs/PackageAddDialog.tsx
Normal file
184
frontend/src/components/dialogs/PackageAddDialog.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogActions, Button,
|
||||
TextField, Autocomplete, Box, IconButton, FormControlLabel, Checkbox,
|
||||
} from "@mui/material";
|
||||
import DialogHeader from "components/common/DialogHeader";
|
||||
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 { useClient } from "hooks/useClient";
|
||||
import { useDialogClose } from "hooks/useDialogClose";
|
||||
import { ApiError } from "api/client/ApiError";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import type { AURPackage } from "api/types/AURPackage";
|
||||
|
||||
interface EnvVar {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface PackageAddDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
|
||||
const client = useClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const repoSelect = useSelectedRepository();
|
||||
|
||||
const [packageName, setPackageName] = useState("");
|
||||
const [refresh, setRefresh] = useState(true);
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>([]);
|
||||
const envIdCounter = useRef(0);
|
||||
|
||||
const { reset: resetRepoSelect } = repoSelect;
|
||||
const onOpen = useCallback(() => {
|
||||
setPackageName("");
|
||||
resetRepoSelect();
|
||||
setRefresh(true);
|
||||
setEnvVars([]);
|
||||
}, [resetRepoSelect]);
|
||||
|
||||
const { isOpen, requestClose, transitionProps } = useDialogClose(open, onClose, onOpen);
|
||||
|
||||
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,
|
||||
});
|
||||
requestClose();
|
||||
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,
|
||||
});
|
||||
requestClose();
|
||||
showSuccess("Success", `Packages ${packageName} have been requested`);
|
||||
} catch (e) {
|
||||
const detail = ApiError.errorDetail(e);
|
||||
showError("Action failed", `Package request failed: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={requestClose} maxWidth="md" fullWidth slotProps={{ transition: transitionProps }}>
|
||||
<DialogHeader onClose={requestClose}>
|
||||
Add new packages
|
||||
</DialogHeader>
|
||||
<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={() => {
|
||||
const id = envIdCounter.current++;
|
||||
setEnvVars((prev) => [...prev, { id, key: "", value: "" }]);
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
add environment variable
|
||||
</Button>
|
||||
|
||||
{envVars.map((env) => (
|
||||
<Box key={env.id} sx={{ display: "flex", gap: 1, mt: 1, alignItems: "center" }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="name"
|
||||
value={env.key}
|
||||
onChange={(e) => {
|
||||
const newKey = e.target.value;
|
||||
setEnvVars((prev) => prev.map((v) => v.id === env.id ? { ...v, key: newKey } : v));
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Box>=</Box>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="value"
|
||||
value={env.value}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setEnvVars((prev) => prev.map((v) => v.id === env.id ? { ...v, value: newValue } : v));
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<IconButton size="small" color="error" aria-label="Remove variable" onClick={() => setEnvVars((prev) => prev.filter((v) => v.id !== env.id))}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
178
frontend/src/components/dialogs/PackageInfoDialog.tsx
Normal file
178
frontend/src/components/dialogs/PackageInfoDialog.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Dialog, DialogContent, Box, Tab, Tabs } from "@mui/material";
|
||||
import DialogHeader from "components/common/DialogHeader";
|
||||
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 { useClient } from "hooks/useClient";
|
||||
import { useDialogClose } from "hooks/useDialogClose";
|
||||
import { ApiError } from "api/client/ApiError";
|
||||
import { defaultInterval } from "api/defaultInterval";
|
||||
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 client = useClient();
|
||||
const { current } = useRepository();
|
||||
const { isAuthorized } = useAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [tabIndex, setTabIndex] = useState(0);
|
||||
const [refreshDb, setRefreshDb] = useState(true);
|
||||
|
||||
const onOpen = useCallback(() => {
|
||||
setTabIndex(0);
|
||||
setRefreshDb(true);
|
||||
}, []);
|
||||
|
||||
const { isOpen, requestClose, transitionProps } = useDialogClose(open, onClose, onOpen);
|
||||
|
||||
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autorefreshIntervals));
|
||||
|
||||
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`);
|
||||
requestClose();
|
||||
} 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) });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={requestClose} maxWidth="lg" fullWidth slotProps={{ transition: transitionProps }}>
|
||||
<DialogHeader onClose={requestClose} sx={headerStyle}>
|
||||
{pkg && status
|
||||
? `${pkg.base} ${status.status} at ${formatTimestamp(status.timestamp)}`
|
||||
: packageBase ?? ""}
|
||||
</DialogHeader>
|
||||
<DialogContent>
|
||||
{pkg && (
|
||||
<>
|
||||
<PackageDetailsGrid pkg={pkg} dependencies={dependencies} />
|
||||
<PackagePatchesList
|
||||
patches={patches}
|
||||
editable={isAuthorized}
|
||||
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
|
||||
isAuthorized={isAuthorized}
|
||||
refreshDb={refreshDb}
|
||||
onRefreshDbChange={setRefreshDb}
|
||||
onUpdate={() => void handleUpdate()}
|
||||
onRemove={() => void handleRemove()}
|
||||
onReload={handleReload}
|
||||
autorefreshIntervals={autorefreshIntervals}
|
||||
autoRefreshEnabled={autoRefresh.enabled}
|
||||
autoRefreshInterval={autoRefresh.interval}
|
||||
onAutoRefreshToggle={autoRefresh.setEnabled}
|
||||
onAutoRefreshIntervalChange={autoRefresh.changeInterval}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/dialogs/PackageRebuildDialog.tsx
Normal file
75
frontend/src/components/dialogs/PackageRebuildDialog.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogActions, Button,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import DialogHeader from "components/common/DialogHeader";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import RepositorySelect from "components/common/RepositorySelect";
|
||||
import { useNotification } from "hooks/useNotification";
|
||||
import { useSelectedRepository } from "hooks/useSelectedRepository";
|
||||
import { useClient } from "hooks/useClient";
|
||||
import { useDialogClose } from "hooks/useDialogClose";
|
||||
import { ApiError } from "api/client/ApiError";
|
||||
|
||||
interface PackageRebuildDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
|
||||
const client = useClient();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const repoSelect = useSelectedRepository();
|
||||
|
||||
const [dependency, setDependency] = useState("");
|
||||
|
||||
const { reset: resetRepoSelect } = repoSelect;
|
||||
const onOpen = useCallback(() => {
|
||||
setDependency("");
|
||||
resetRepoSelect();
|
||||
}, [resetRepoSelect]);
|
||||
|
||||
const { isOpen, requestClose, transitionProps } = useDialogClose(open, onClose, onOpen);
|
||||
|
||||
const handleRebuild = async (): Promise<void> => {
|
||||
if (!dependency) {
|
||||
return;
|
||||
}
|
||||
const repo = repoSelect.selectedRepo;
|
||||
if (!repo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.rebuildPackages(repo, [dependency]);
|
||||
requestClose();
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onClose={requestClose} maxWidth="md" fullWidth slotProps={{ transition: transitionProps }}>
|
||||
<DialogHeader onClose={requestClose}>
|
||||
Rebuild depending packages
|
||||
</DialogHeader>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
59
frontend/src/components/layout/AppLayout.tsx
Normal file
59
frontend/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
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 { useClient } from "hooks/useClient";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import type { InfoResponse } from "api/types/InfoResponse";
|
||||
|
||||
export default function AppLayout(): React.JSX.Element {
|
||||
const client = useClient();
|
||||
const { setAuthState } = useAuth();
|
||||
const { setRepositories } = useRepository();
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
const { data: info } = useQuery<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>
|
||||
);
|
||||
}
|
||||
176
frontend/src/components/package/BuildLogsTab.tsx
Normal file
176
frontend/src/components/package/BuildLogsTab.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
|
||||
import ListIcon from "@mui/icons-material/List";
|
||||
import { keepPreviousData, 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 { useClient } from "hooks/useClient";
|
||||
import { useAutoScroll } from "hooks/useAutoScroll";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||
import CodeBlock from "components/common/CodeBlock";
|
||||
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 {
|
||||
const filtered = filter ? records.filter(filter) : records;
|
||||
return filtered
|
||||
.map((r) => `[${new Date(r.created * 1000).toISOString()}] ${r.message}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export default function BuildLogsTab({ packageBase, repo, refetchInterval }: BuildLogsTabProps): React.JSX.Element {
|
||||
const client = useClient();
|
||||
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const codeRef = useRef<HTMLElement>(null);
|
||||
|
||||
const { data: allLogs } = useQuery<LogRecord[]>({
|
||||
queryKey: QueryKeys.logs(packageBase, repo),
|
||||
queryFn: () => client.fetchLogs(packageBase, repo),
|
||||
enabled: !!packageBase,
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
// 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}`;
|
||||
const existing = grouped[key];
|
||||
if (!existing) {
|
||||
grouped[key] = { ...record, minCreated: record.created };
|
||||
} else {
|
||||
existing.minCreated = Math.min(existing.minCreated, record.created);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(grouped)
|
||||
.sort((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]);
|
||||
|
||||
// Compute active index from selected version key, defaulting to newest (index 0)
|
||||
const activeIndex = useMemo(() => {
|
||||
if (selectedVersionKey) {
|
||||
const idx = versions.findIndex((v) => `${v.version}-${v.processId}` === selectedVersionKey);
|
||||
if (idx >= 0) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}, [versions, selectedVersionKey]);
|
||||
|
||||
const activeVersion = versions[activeIndex];
|
||||
const activeVersionKey = activeVersion ? `${activeVersion.version}-${activeVersion.processId}` : null;
|
||||
|
||||
// Refresh active version logs
|
||||
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,
|
||||
placeholderData: keepPreviousData,
|
||||
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]);
|
||||
|
||||
const { preRef, handleScroll, scrollToBottom, resetScroll } = useAutoScroll();
|
||||
|
||||
// Reset scroll tracking when active version changes
|
||||
useEffect(() => {
|
||||
resetScroll();
|
||||
}, [activeVersionKey, resetScroll]);
|
||||
|
||||
// Highlight code, then scroll to bottom
|
||||
useEffect(() => {
|
||||
if (codeRef.current && displayedLogs) {
|
||||
codeRef.current.innerHTML = hljs.highlight(displayedLogs, { language: "plaintext" }).value;
|
||||
}
|
||||
scrollToBottom();
|
||||
}, [displayedLogs, scrollToBottom]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
||||
<Box>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label="Select version"
|
||||
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={() => {
|
||||
setSelectedVersionKey(`${v.version}-${v.processId}`);
|
||||
setAnchorEl(null);
|
||||
resetScroll();
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">{formatTimestamp(v.created)}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
{versions.length === 0 && (
|
||||
<MenuItem disabled>No logs available</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<CodeBlock
|
||||
codeRef={codeRef}
|
||||
preRef={preRef}
|
||||
className="language-plaintext"
|
||||
getText={() => displayedLogs}
|
||||
height={400}
|
||||
onScroll={handleScroll}
|
||||
wordBreak
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/package/ChangesTab.tsx
Normal file
43
frontend/src/components/package/ChangesTab.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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 { useClient } from "hooks/useClient";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import CodeBlock from "components/common/CodeBlock";
|
||||
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 client = useClient();
|
||||
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.innerHTML = hljs.highlight(changesText, { language: "diff" }).value;
|
||||
}
|
||||
}, [changesText]);
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<CodeBlock codeRef={codeRef} className="language-diff" getText={() => changesText} maxHeight={400} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/package/EventsTab.tsx
Normal file
63
frontend/src/components/package/EventsTab.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
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 { useClient } from "hooks/useClient";
|
||||
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 client = useClient();
|
||||
|
||||
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]}
|
||||
autoHeight
|
||||
sx={{ mt: 1 }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/package/PackageDetailsGrid.tsx
Normal file
98
frontend/src/components/package/PackageDetailsGrid.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
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 }}>
|
||||
<Typography variant="body2">
|
||||
{aurUrl && (
|
||||
<Link href={aurUrl} target="_blank" rel="noopener" underline="hover">AUR link</Link>
|
||||
)}
|
||||
</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">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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/package/PackageInfoActions.tsx
Normal file
64
frontend/src/components/package/PackageInfoActions.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import type React from "react";
|
||||
import { DialogActions, Button, FormControlLabel, Checkbox } from "@mui/material";
|
||||
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 {
|
||||
isAuthorized: boolean;
|
||||
refreshDb: boolean;
|
||||
onRefreshDbChange: (checked: boolean) => void;
|
||||
onUpdate: () => void;
|
||||
onRemove: () => void;
|
||||
onReload: () => void;
|
||||
autorefreshIntervals: AutoRefreshInterval[];
|
||||
autoRefreshEnabled: boolean;
|
||||
autoRefreshInterval: number;
|
||||
onAutoRefreshToggle: (enabled: boolean) => void;
|
||||
onAutoRefreshIntervalChange: (interval: number) => void;
|
||||
}
|
||||
|
||||
export default function PackageInfoActions({
|
||||
isAuthorized,
|
||||
refreshDb,
|
||||
onRefreshDbChange,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
onReload,
|
||||
autorefreshIntervals,
|
||||
autoRefreshEnabled,
|
||||
autoRefreshInterval,
|
||||
onAutoRefreshToggle,
|
||||
onAutoRefreshIntervalChange,
|
||||
}: PackageInfoActionsProps): React.JSX.Element {
|
||||
return (
|
||||
<DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
|
||||
{isAuthorized && (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
185
frontend/src/components/table/PackageTable.tsx
Normal file
185
frontend/src/components/table/PackageTable.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
DataGrid,
|
||||
GRID_CHECKBOX_SELECTION_COL_DEF,
|
||||
useGridApiRef,
|
||||
type GridColDef,
|
||||
type GridFilterModel,
|
||||
type GridRenderCellParams,
|
||||
type GridRowId,
|
||||
} 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 "models/PackageRow";
|
||||
|
||||
interface PackageTableProps {
|
||||
autorefreshIntervals: AutoRefreshInterval[];
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||
|
||||
function createListColumn(
|
||||
field: keyof PackageRow,
|
||||
headerName: string,
|
||||
opts?: { flex?: number; minWidth?: number; width?: number },
|
||||
): GridColDef<PackageRow> {
|
||||
return {
|
||||
field,
|
||||
headerName,
|
||||
...opts,
|
||||
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
||||
renderCell: (params: GridRenderCellParams<PackageRow>) =>
|
||||
((params.row[field] as string[]) ?? []).map((item, i, arr) => (
|
||||
<React.Fragment key={`${item}-${String(i)}`}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
|
||||
)),
|
||||
sortComparator: (v1: string, v2: string) => v1.localeCompare(v2),
|
||||
};
|
||||
}
|
||||
|
||||
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" },
|
||||
createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
|
||||
createListColumn("groups", "groups", { width: 150 }),
|
||||
createListColumn("licenses", "licenses", { width: 150 }),
|
||||
{ field: "packager", headerName: "packager", width: 150 },
|
||||
{
|
||||
field: "timestamp",
|
||||
headerName: "last update",
|
||||
width: 180,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
},
|
||||
{
|
||||
field: "status",
|
||||
headerName: "status",
|
||||
width: 120,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
renderCell: (params: GridRenderCellParams<PackageRow>) => <StatusCell status={params.row.status} />,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PackageTableToolbar
|
||||
hasSelection={table.selectionModel.length > 0}
|
||||
isAuthorized={table.isAuthorized}
|
||||
repoStatus={table.repoStatus}
|
||||
searchText={table.searchText}
|
||||
onSearchChange={table.setSearchText}
|
||||
autoRefresh={{
|
||||
intervals: autorefreshIntervals,
|
||||
enabled: table.autoRefreshEnabled,
|
||||
currentInterval: table.autoRefreshInterval,
|
||||
onToggle: table.onAutoRefreshToggle,
|
||||
onIntervalChange: table.onAutoRefreshIntervalChange,
|
||||
}}
|
||||
actions={{
|
||||
onDashboardClick: () => table.setDialogOpen("dashboard"),
|
||||
onAddClick: () => table.setDialogOpen("add"),
|
||||
onUpdateClick: () => void table.handleUpdate(),
|
||||
onRefreshDbClick: () => void table.handleRefreshDb(),
|
||||
onRebuildClick: () => table.setDialogOpen("rebuild"),
|
||||
onRemoveClick: () => void table.handleRemove(),
|
||||
onKeyImportClick: () => table.setDialogOpen("keyImport"),
|
||||
onReloadClick: table.handleReload,
|
||||
onExportClick: () => apiRef.current?.exportDataAsCsv(),
|
||||
}}
|
||||
/>
|
||||
|
||||
<DataGrid
|
||||
apiRef={apiRef}
|
||||
rows={table.rows}
|
||||
columns={columns}
|
||||
loading={table.isLoading}
|
||||
getRowHeight={() => "auto"}
|
||||
checkboxSelection
|
||||
disableRowSelectionOnClick
|
||||
rowSelectionModel={{ type: "include", ids: new Set<GridRowId>(table.selectionModel) }}
|
||||
onRowSelectionModelChange={(model) => {
|
||||
if (model.type === "exclude") {
|
||||
const excludeIds = new Set([...model.ids].map(String));
|
||||
table.setSelectionModel(table.rows.map((r) => r.id).filter((id) => !excludeIds.has(id)));
|
||||
} else {
|
||||
table.setSelectionModel([...model.ids].map(String));
|
||||
}
|
||||
}}
|
||||
paginationModel={table.paginationModel}
|
||||
onPaginationModelChange={table.setPaginationModel}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
columnVisibilityModel={table.columnVisibility}
|
||||
onColumnVisibilityModelChange={table.setColumnVisibility}
|
||||
filterModel={effectiveFilterModel}
|
||||
onFilterModelChange={table.setFilterModel}
|
||||
initialState={{
|
||||
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
|
||||
}}
|
||||
onCellClick={(params, event) => {
|
||||
// Don't open info dialog when clicking checkbox or link
|
||||
if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
|
||||
return;
|
||||
}
|
||||
if ((event.target as HTMLElement).closest("a")) {
|
||||
return;
|
||||
}
|
||||
table.setSelectedPackage(String(params.id));
|
||||
}}
|
||||
autoHeight
|
||||
sx={{
|
||||
"& .MuiDataGrid-row": { cursor: "pointer" },
|
||||
}}
|
||||
density="compact"
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
172
frontend/src/components/table/PackageTableToolbar.tsx
Normal file
172
frontend/src/components/table/PackageTableToolbar.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
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";
|
||||
|
||||
export interface AutoRefreshProps {
|
||||
intervals: AutoRefreshInterval[];
|
||||
enabled: boolean;
|
||||
currentInterval: number;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onIntervalChange: (interval: number) => void;
|
||||
}
|
||||
|
||||
export interface ToolbarActions {
|
||||
onDashboardClick: () => void;
|
||||
onAddClick: () => void;
|
||||
onUpdateClick: () => void;
|
||||
onRefreshDbClick: () => void;
|
||||
onRebuildClick: () => void;
|
||||
onRemoveClick: () => void;
|
||||
onKeyImportClick: () => void;
|
||||
onReloadClick: () => void;
|
||||
onExportClick: () => void;
|
||||
}
|
||||
|
||||
interface PackageTableToolbarProps {
|
||||
hasSelection: boolean;
|
||||
isAuthorized: boolean;
|
||||
repoStatus?: BuildStatus;
|
||||
searchText: string;
|
||||
onSearchChange: (text: string) => void;
|
||||
autoRefresh: AutoRefreshProps;
|
||||
actions: ToolbarActions;
|
||||
}
|
||||
|
||||
export default function PackageTableToolbar({
|
||||
hasSelection,
|
||||
isAuthorized,
|
||||
repoStatus,
|
||||
searchText,
|
||||
onSearchChange,
|
||||
autoRefresh,
|
||||
actions,
|
||||
}: 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
|
||||
aria-label="System health"
|
||||
onClick={actions.onDashboardClick}
|
||||
sx={{
|
||||
borderColor: repoStatus ? StatusColors[repoStatus] : undefined,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
color: repoStatus ? StatusColors[repoStatus] : undefined,
|
||||
}}
|
||||
>
|
||||
<InfoOutlinedIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{isAuthorized && (
|
||||
<>
|
||||
<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); actions.onAddClick();
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ mr: 1 }} /> add
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
setPackagesAnchorEl(null); actions.onUpdateClick();
|
||||
}}>
|
||||
<PlayArrowIcon fontSize="small" sx={{ mr: 1 }} /> update
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
setPackagesAnchorEl(null); actions.onRefreshDbClick();
|
||||
}}>
|
||||
<DownloadIcon fontSize="small" sx={{ mr: 1 }} /> update pacman databases
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
setPackagesAnchorEl(null); actions.onRebuildClick();
|
||||
}}>
|
||||
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => {
|
||||
setPackagesAnchorEl(null); actions.onRemoveClick();
|
||||
}} disabled={!hasSelection}>
|
||||
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Button variant="contained" color="info" startIcon={<VpnKeyIcon />} onClick={actions.onKeyImportClick}>
|
||||
import key
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="outlined" color="secondary" startIcon={<RefreshIcon />} onClick={actions.onReloadClick}>
|
||||
reload
|
||||
</Button>
|
||||
|
||||
<AutoRefreshControl
|
||||
intervals={autoRefresh.intervals}
|
||||
enabled={autoRefresh.enabled}
|
||||
currentInterval={autoRefresh.currentInterval}
|
||||
onToggle={autoRefresh.onToggle}
|
||||
onIntervalChange={autoRefresh.onIntervalChange}
|
||||
/>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
aria-label="Search packages"
|
||||
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" aria-label="Clear search" onClick={() => onSearchChange("")}>
|
||||
<ClearIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
) : undefined,
|
||||
},
|
||||
}}
|
||||
sx={{ minWidth: 200 }}
|
||||
/>
|
||||
|
||||
<Tooltip title="Export CSV">
|
||||
<IconButton size="small" aria-label="Export CSV" onClick={actions.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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
frontend/src/contexts/AuthContext.ts
Normal file
15
frontend/src/contexts/AuthContext.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
interface AuthState {
|
||||
enabled: boolean;
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
export interface AuthContextValue extends AuthState {
|
||||
isAuthorized: boolean;
|
||||
setAuthState: (state: AuthState) => void;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
30
frontend/src/contexts/AuthProvider.tsx
Normal file
30
frontend/src/contexts/AuthProvider.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { useState, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { useClient } from "hooks/useClient";
|
||||
import { AuthContext } from "contexts/AuthContext";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
const client = useClient();
|
||||
const [state, setState] = useState({ enabled: false, username: null as string | null });
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
await client.login({ username, password });
|
||||
setState((prev) => ({ ...prev, username }));
|
||||
}, [client]);
|
||||
|
||||
const doLogout = useCallback(async () => {
|
||||
await client.logout();
|
||||
setState((prev) => ({ ...prev, username: null }));
|
||||
}, [client]);
|
||||
|
||||
const isAuthorized = useMemo(() => !state.enabled || state.username !== null, [state.enabled, state.username]);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
...state, isAuthorized, setAuthState: setState, login, logout: doLogout,
|
||||
}), [state, isAuthorized, login, doLogout]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
4
frontend/src/contexts/ClientContext.ts
Normal file
4
frontend/src/contexts/ClientContext.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createContext } from "react";
|
||||
import type { AhrimanClient } from "api/client/AhrimanClient";
|
||||
|
||||
export const ClientContext = createContext<AhrimanClient | null>(null);
|
||||
13
frontend/src/contexts/ClientProvider.tsx
Normal file
13
frontend/src/contexts/ClientProvider.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React, { useMemo, type ReactNode } from "react";
|
||||
import { AhrimanClient } from "api/client/AhrimanClient";
|
||||
import { ClientContext } from "contexts/ClientContext";
|
||||
|
||||
export function ClientProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
const client = useMemo(() => new AhrimanClient(), []);
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={client}>
|
||||
{children}
|
||||
</ClientContext.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>
|
||||
);
|
||||
}
|
||||
55
frontend/src/contexts/NotificationProvider.tsx
Normal file
55
frontend/src/contexts/NotificationProvider.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState, useCallback, useMemo, 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],
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ showSuccess, showError }), [showSuccess, showError]);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{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);
|
||||
39
frontend/src/contexts/RepositoryProvider.tsx
Normal file
39
frontend/src/contexts/RepositoryProvider.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
repositories, current, setRepositories, setCurrent,
|
||||
}), [repositories, current, setCurrent]);
|
||||
|
||||
return (
|
||||
<RepositoryContext.Provider value={value}>
|
||||
{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;
|
||||
changeInterval: (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 changeInterval = useCallback(
|
||||
(interval: number) => {
|
||||
setStored((prev) => ({ ...prev, interval }));
|
||||
},
|
||||
[setStored],
|
||||
);
|
||||
|
||||
return {
|
||||
enabled: stored.enabled,
|
||||
interval: stored.interval,
|
||||
paused,
|
||||
refetchInterval,
|
||||
setEnabled,
|
||||
changeInterval,
|
||||
setPaused,
|
||||
};
|
||||
}
|
||||
46
frontend/src/hooks/useAutoScroll.ts
Normal file
46
frontend/src/hooks/useAutoScroll.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useCallback, useRef, type RefObject } from "react";
|
||||
|
||||
interface UseAutoScrollResult {
|
||||
preRef: RefObject<HTMLElement | null>;
|
||||
handleScroll: () => void;
|
||||
scrollToBottom: () => void;
|
||||
resetScroll: () => void;
|
||||
}
|
||||
|
||||
export function useAutoScroll(): UseAutoScrollResult {
|
||||
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 resetScroll = useCallback(() => {
|
||||
initialScrollDone.current = false;
|
||||
}, []);
|
||||
|
||||
// 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).
|
||||
// Call this after DOM content has been updated (e.g. after highlighting).
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (!preRef.current) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { preRef, handleScroll, scrollToBottom, resetScroll };
|
||||
}
|
||||
11
frontend/src/hooks/useClient.ts
Normal file
11
frontend/src/hooks/useClient.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
import type { AhrimanClient } from "api/client/AhrimanClient";
|
||||
import { ClientContext } from "contexts/ClientContext";
|
||||
|
||||
export function useClient(): AhrimanClient {
|
||||
const ctx = useContext(ClientContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useClient must be used within ClientProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
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;
|
||||
}
|
||||
53
frontend/src/hooks/useDialogClose.ts
Normal file
53
frontend/src/hooks/useDialogClose.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
||||
|
||||
interface UseDialogCloseResult {
|
||||
isOpen: boolean;
|
||||
requestClose: () => void;
|
||||
transitionProps: { onExited: () => void; onEnter: () => void };
|
||||
}
|
||||
|
||||
export function useDialogClose(open: boolean, onClose: () => void, onOpen?: () => void): UseDialogCloseResult {
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
const onCloseRef = useRef(onClose);
|
||||
const onOpenRef = useRef(onOpen);
|
||||
// Keep refs in sync with the latest callbacks on every render so that
|
||||
// the stable transitionProps object always invokes the current handlers.
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
onOpenRef.current = onOpen;
|
||||
});
|
||||
|
||||
// Reset closing state when the parent signals the dialog should open.
|
||||
// Without this, a stale closing=true from a previous close prevents
|
||||
// the dialog from ever re-opening (onEnter never fires).
|
||||
// Uses the React-recommended "adjust state during render" pattern
|
||||
// instead of useEffect to avoid cascading renders.
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setClosing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
}, []);
|
||||
|
||||
const transitionProps = useMemo(() => ({
|
||||
onExited: () => {
|
||||
onCloseRef.current();
|
||||
},
|
||||
onEnter: () => {
|
||||
setClosing(false);
|
||||
onOpenRef.current?.();
|
||||
},
|
||||
}), []);
|
||||
|
||||
return {
|
||||
isOpen: open && !closing,
|
||||
requestClose,
|
||||
transitionProps,
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
95
frontend/src/hooks/usePackageActions.ts
Normal file
95
frontend/src/hooks/usePackageActions.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { useClient } from "hooks/useClient";
|
||||
import { useNotification } from "hooks/useNotification";
|
||||
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 client = useClient();
|
||||
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(", ")}`);
|
||||
}
|
||||
setSelectionModel([]);
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
|
||||
} catch (e) {
|
||||
const detail = ApiError.errorDetail(e);
|
||||
showError("Action failed", `Packages update failed: ${detail}`);
|
||||
}
|
||||
}, [client, current, selectionModel, setSelectionModel, showSuccess, showError, queryClient]);
|
||||
|
||||
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");
|
||||
setSelectionModel([]);
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
|
||||
} catch (e) {
|
||||
const detail = ApiError.errorDetail(e);
|
||||
showError("Action failed", `Could not update pacman databases: ${detail}`);
|
||||
}
|
||||
}, [client, current, setSelectionModel, showSuccess, showError, queryClient]);
|
||||
|
||||
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([]);
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
|
||||
} catch (e) {
|
||||
const detail = ApiError.errorDetail(e);
|
||||
showError("Action failed", `Could not remove packages: ${detail}`);
|
||||
}
|
||||
}, [client, current, selectionModel, setSelectionModel, showSuccess, showError, queryClient]);
|
||||
|
||||
return {
|
||||
handleReload,
|
||||
handleUpdate,
|
||||
handleRefreshDb,
|
||||
handleRemove,
|
||||
};
|
||||
}
|
||||
76
frontend/src/hooks/usePackageData.ts
Normal file
76
frontend/src/hooks/usePackageData.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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 { useClient } from "hooks/useClient";
|
||||
import { defaultInterval } from "api/defaultInterval";
|
||||
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 "models/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;
|
||||
isAuthorized: boolean;
|
||||
repoStatus: BuildStatus | undefined;
|
||||
autoRefresh: ReturnType<typeof useAutoRefresh>;
|
||||
}
|
||||
|
||||
export function usePackageData(autorefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
|
||||
const client = useClient();
|
||||
const { current } = useRepository();
|
||||
const { isAuthorized } = useAuth();
|
||||
|
||||
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autorefreshIntervals));
|
||||
|
||||
const { data: packages = [], isLoading } = useQuery<PackageStatus[]>({
|
||||
queryKey: current ? QueryKeys.packages(current) : ["packages"],
|
||||
queryFn: current ? () => client.fetchPackages(current) : skipToken,
|
||||
refetchInterval: autoRefresh.refetchInterval,
|
||||
});
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: current ? QueryKeys.status(current) : ["status"],
|
||||
queryFn: current ? () => client.fetchStatus(current) : skipToken,
|
||||
refetchInterval: autoRefresh.refetchInterval,
|
||||
});
|
||||
|
||||
const rows = useMemo(() => packages.map(toRow), [packages]);
|
||||
|
||||
return {
|
||||
rows,
|
||||
isLoading,
|
||||
isAuthorized,
|
||||
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 "models/PackageRow";
|
||||
|
||||
export type { DialogType } from "hooks/useTableState";
|
||||
|
||||
export interface UsePackageTableResult {
|
||||
rows: PackageRow[];
|
||||
isLoading: boolean;
|
||||
isAuthorized: 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, isAuthorized, 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,
|
||||
isAuthorized,
|
||||
repoStatus,
|
||||
|
||||
...tableState,
|
||||
|
||||
autoRefreshEnabled: autoRefresh.enabled,
|
||||
autoRefreshInterval: autoRefresh.interval,
|
||||
onAutoRefreshToggle: autoRefresh.setEnabled,
|
||||
onAutoRefreshIntervalChange: autoRefresh.changeInterval,
|
||||
|
||||
...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,
|
||||
};
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "chartSetup";
|
||||
import App from "App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
15
frontend/src/models/PackageRow.ts
Normal file
15
frontend/src/models/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;
|
||||
}
|
||||
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;
|
||||
29
frontend/src/theme/status/StatusColors.ts
Normal file
29
frontend/src/theme/status/StatusColors.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { alpha } from "@mui/material/styles";
|
||||
import { amber, green, grey, orange, red } from "@mui/material/colors";
|
||||
import type { BuildStatus } from "api/types/BuildStatus";
|
||||
|
||||
const base: Record<BuildStatus, string> = {
|
||||
unknown: grey[800],
|
||||
pending: amber[900],
|
||||
building: orange[900],
|
||||
failed: red[900],
|
||||
success: green[800],
|
||||
};
|
||||
|
||||
const headerBase: Record<BuildStatus, string> = {
|
||||
unknown: grey[800],
|
||||
pending: amber[700],
|
||||
building: orange[600],
|
||||
failed: red[500],
|
||||
success: green[600],
|
||||
};
|
||||
|
||||
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(headerBase).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.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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user