mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-04-07 11:03:37 +00:00
upload ai slop
This commit is contained in:
@@ -12,3 +12,6 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.pyd
|
*.pyd
|
||||||
*.pyo
|
*.pyo
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|||||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- ${{ github.workspace }}:/build
|
- ${{ github.workspace }}:/build
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- run: pacman --noconfirm -Syu base-devel git python-tox
|
- run: pacman --noconfirm -Syu base-devel git npm python-tox
|
||||||
|
|
||||||
- run: git config --global --add safe.directory *
|
- run: git config --global --add safe.directory *
|
||||||
|
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -99,3 +99,9 @@ status_cache.json
|
|||||||
*.db
|
*.db
|
||||||
|
|
||||||
docs/html/
|
docs/html/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
package/share/ahriman/templates/static/index.js
|
||||||
|
package/share/ahriman/templates/static/index.css
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ COPY "docker/install-aur-package.sh" "/usr/local/bin/install-aur-package"
|
|||||||
RUN pacman -S --noconfirm --asdeps \
|
RUN pacman -S --noconfirm --asdeps \
|
||||||
devtools \
|
devtools \
|
||||||
git \
|
git \
|
||||||
|
npm \
|
||||||
pyalpm \
|
pyalpm \
|
||||||
python-bcrypt \
|
python-bcrypt \
|
||||||
python-filelock \
|
python-filelock \
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
|
|||||||
* ``port`` - port to bind, integer, optional.
|
* ``port`` - port to bind, integer, optional.
|
||||||
* ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``.
|
* ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``.
|
||||||
* ``static_path`` - path to directory with static files, string, required.
|
* ``static_path`` - path to directory with static files, string, required.
|
||||||
|
* ``template`` - Jinja2 template name for the index page, string, optional, default ``build-status.jinja2``.
|
||||||
* ``templates`` - path to templates directories, space separated list of paths, required.
|
* ``templates`` - path to templates directories, space separated list of paths, required.
|
||||||
* ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization.
|
* ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization.
|
||||||
* ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration.
|
* ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration.
|
||||||
|
|||||||
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ arch=('any')
|
|||||||
url="https://ahriman.readthedocs.io/"
|
url="https://ahriman.readthedocs.io/"
|
||||||
license=('GPL-3.0-or-later')
|
license=('GPL-3.0-or-later')
|
||||||
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-bcrypt' 'python-filelock' 'python-inflection' 'python-pyelftools' 'python-requests')
|
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-bcrypt' 'python-filelock' 'python-inflection' 'python-pyelftools' 'python-requests')
|
||||||
makedepends=('python-build' 'python-flit' 'python-installer' 'python-wheel')
|
makedepends=('npm' 'python-build' 'python-flit' 'python-installer' 'python-wheel')
|
||||||
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgbase-$pkgver.tar.gz"
|
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgbase-$pkgver.tar.gz"
|
||||||
"$pkgbase.sysusers"
|
"$pkgbase.sysusers"
|
||||||
"$pkgbase.tmpfiles")
|
"$pkgbase.tmpfiles")
|
||||||
@@ -18,6 +18,10 @@ build() {
|
|||||||
cd "$pkgbase-$pkgver"
|
cd "$pkgbase-$pkgver"
|
||||||
|
|
||||||
python -m build --wheel --no-isolation
|
python -m build --wheel --no-isolation
|
||||||
|
|
||||||
|
cd "frontend"
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
}
|
}
|
||||||
|
|
||||||
package_ahriman() {
|
package_ahriman() {
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ host = 127.0.0.1
|
|||||||
;service_only = no
|
;service_only = no
|
||||||
; Path to directory with static files.
|
; Path to directory with static files.
|
||||||
static_path = ${templates}/static
|
static_path = ${templates}/static
|
||||||
|
; Jinja2 template name for the index page.
|
||||||
|
;template = build-status.jinja2
|
||||||
; List of directories with templates.
|
; List of directories with templates.
|
||||||
templates[] = ${prefix}/share/ahriman/templates
|
templates[] = ${prefix}/share/ahriman/templates
|
||||||
; Path to unix socket. If none set, unix socket will be disabled.
|
; Path to unix socket. If none set, unix socket will be disabled.
|
||||||
|
|||||||
191
package/share/ahriman/templates/build-status-legacy.jinja2
Normal file
191
package/share/ahriman/templates/build-status-legacy.jinja2
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>ahriman</title>
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
{% include "utils/style.jinja2" %}
|
||||||
|
{% include "user-style.jinja2" ignore missing %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% include "utils/bootstrap-scripts.jinja2" %}
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<nav class="navbar navbar-expand-lg">
|
||||||
|
<div class="navbar-brand"><a href="https://github.com/arcan1s/ahriman" title="logo"><img src="/static/logo.svg" width="30" height="30" alt=""></a></div>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#repositories-navbar" aria-controls="repositories-navbar" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="repositories-navbar" class="collapse navbar-collapse">
|
||||||
|
<ul id="repositories" class="nav nav-tabs">
|
||||||
|
{% for repository in repositories %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a id="{{ repository.id }}-link" class="nav-link" href="#{{ repository.id }}" data-repository="{{ repository.repository }}" data-architecture="{{ repository.architecture }}">{{ repository.repository }} ({{ repository.architecture }})</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alert-placeholder" class="toast-container p3 top-0 start-50 translate-middle-x"></div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div id="toolbar" class="dropdown">
|
||||||
|
<button id="dashboard-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#dashboard-modal">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% if not auth.enabled or auth.username is not none %}
|
||||||
|
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-box"></i><span class="d-none d-sm-inline"> packages</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>
|
||||||
|
<button id="package-add-button" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-add-modal">
|
||||||
|
<i class="bi bi-plus"></i> add
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button id="package-update-button" class="btn dropdown-item" onclick="packagesUpdate()">
|
||||||
|
<i class="bi bi-play"></i> update
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button id="update-repositories-button" class="btn dropdown-item" onclick="refreshDatabases()">
|
||||||
|
<i class="bi bi-arrow-down-circle"></i> update pacman databases
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button id="package-rebuild-button" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-rebuild-modal">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> rebuild
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button id="package-remove-button" class="btn dropdown-item" onclick="packagesRemove()" disabled>
|
||||||
|
<i class="bi bi-trash"></i> remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button id="key-import-button" type="button" class="btn btn-info" data-bs-toggle="modal" data-bs-target="#key-import-modal">
|
||||||
|
<i class="bi bi-key"></i><span class="d-none d-sm-inline"> import key</span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="reload()">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i><span class="d-none d-sm-inline"> reload</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% if autorefresh_intervals %}
|
||||||
|
<div class="btn-group">
|
||||||
|
<input id="table-autoreload-button" type="checkbox" class="btn-check" autocomplete="off" onclick="toggleTableAutoReload()" checked>
|
||||||
|
<label for="table-autoreload-button" class="btn btn-outline-secondary" title="toggle auto reload"><i class="bi bi-clock"></i></label>
|
||||||
|
<button type="button" class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<span class="visually-hidden">select interval</span>
|
||||||
|
</button>
|
||||||
|
<ul id="table-autoreload-input" class="dropdown-menu">
|
||||||
|
{% for interval in autorefresh_intervals %}
|
||||||
|
<li><a class="dropdown-item {{ "active" if interval.is_active }}" onclick="toggleTableAutoReload({{ interval.interval }})" data-interval="{{ interval.interval }}">{{ interval.text }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table id="packages"
|
||||||
|
data-classes="table table-hover"
|
||||||
|
data-cookie="true"
|
||||||
|
data-cookie-id-table="ahriman-packages"
|
||||||
|
data-cookie-storage="localStorage"
|
||||||
|
data-export-options='{"fileName": "packages"}'
|
||||||
|
data-filter-control="true"
|
||||||
|
data-filter-control-visible="false"
|
||||||
|
data-page-list="[10, 25, 50, 100, all]"
|
||||||
|
data-page-size="10"
|
||||||
|
data-pagination="true"
|
||||||
|
data-resizable="true"
|
||||||
|
data-search="true"
|
||||||
|
data-show-columns="true"
|
||||||
|
data-show-columns-search="true"
|
||||||
|
data-show-columns-toggle-all="true"
|
||||||
|
data-show-export="true"
|
||||||
|
data-show-filter-control-switch="true"
|
||||||
|
data-show-fullscreen="true"
|
||||||
|
data-show-search-clear-button="true"
|
||||||
|
data-sortable="true"
|
||||||
|
data-sort-name="base"
|
||||||
|
data-sort-order="asc"
|
||||||
|
data-toolbar="#toolbar"
|
||||||
|
data-unique-id="id">
|
||||||
|
<thead class="table-primary">
|
||||||
|
<tr>
|
||||||
|
<th data-checkbox="true"></th>
|
||||||
|
<th data-sortable="true" data-switchable="false" data-field="base" data-filter-control="input" data-filter-control-placeholder="(any base)">package base</th>
|
||||||
|
<th data-sortable="true" data-align="right" data-field="version" data-filter-control="input" data-filter-control-placeholder="(any version)">version</th>
|
||||||
|
<th data-sortable="true" data-field="packages" data-filter-control="input" data-filter-control-placeholder="(any package)">packages</th>
|
||||||
|
<th data-sortable="true" data-visible="false" data-field="groups" data-filter-control="select" data-filter-data="func:filterListGroups" data-filter-custom-search="filterList" data-filter-control-placeholder="(any group)">groups</th>
|
||||||
|
<th data-sortable="true" data-visible="false" data-field="licenses" data-filter-control="select" data-filter-data="func:filterListLicenses" data-filter-custom-search="filterList" data-filter-control-placeholder="(any license)">licenses</th>
|
||||||
|
<th data-sortable="true" data-visible="false" data-field="packager" data-filter-control="select" data-filter-custom-search="filterContains" data-filter-control-placeholder="(any packager)">packager</th>
|
||||||
|
<th data-sortable="true" data-align="right" data-field="timestamp" data-filter-control="input" data-filter-custom-search="filterDateRange" data-filter-control-placeholder="(any date)">last update</th>
|
||||||
|
<th data-sortable="true" data-align="center" data-cell-style="statusFormat" data-field="status" data-filter-control="select" data-filter-control-placeholder="(any status)">status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
|
||||||
|
<ul class="nav">
|
||||||
|
<li><a id="badge-version" class="nav-link" href="https://github.com/arcan1s/ahriman" title="sources"><i class="bi bi-github"></i> ahriman</a></li>
|
||||||
|
<li><a class="nav-link" href="https://github.com/arcan1s/ahriman/releases" title="releases list">releases</a></li>
|
||||||
|
<li><a class="nav-link" href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li>
|
||||||
|
{% if docs_enabled %}
|
||||||
|
<li><a class="nav-link" href="/api-docs" title="API documentation">api</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if index_url is not none %}
|
||||||
|
<ul class="nav">
|
||||||
|
<li><a class="nav-link" href="{{ index_url }}" title="repo index"><i class="bi bi-house"></i> repo index</a></li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if auth.enabled %}
|
||||||
|
<ul class="nav">
|
||||||
|
{% if auth.username is none %}
|
||||||
|
<li>{{ auth.control | safe }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li>
|
||||||
|
<form action="/api/v1/logout" method="post">
|
||||||
|
<button class="btn btn-link" style="text-decoration: none"><i class="bi bi-box-arrow-right"></i> logout ({{ auth.username }})</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if auth.enabled %}
|
||||||
|
{% include "build-status/login-modal.jinja2" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% include "build-status/alerts.jinja2" %}
|
||||||
|
|
||||||
|
{% include "build-status/dashboard.jinja2" %}
|
||||||
|
{% include "build-status/package-add-modal.jinja2" %}
|
||||||
|
{% include "build-status/package-rebuild-modal.jinja2" %}
|
||||||
|
{% include "build-status/key-import-modal.jinja2" %}
|
||||||
|
|
||||||
|
{% include "build-status/package-info-modal.jinja2" %}
|
||||||
|
|
||||||
|
{% include "build-status/table.jinja2" %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,191 +1,20 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ahriman</title>
|
<title>ahriman</title>
|
||||||
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<link rel="icon" href="/static/favicon.ico" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
{% include "utils/style.jinja2" %}
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
{% include "user-style.jinja2" ignore missing %}
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@300;400;600;700&display=swap" />
|
||||||
|
<script type="module" crossorigin src="/static/index.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/static/index.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
{% include "utils/bootstrap-scripts.jinja2" %}
|
<div id="root"></div>
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<nav class="navbar navbar-expand-lg">
|
|
||||||
<div class="navbar-brand"><a href="https://github.com/arcan1s/ahriman" title="logo"><img src="/static/logo.svg" width="30" height="30" alt=""></a></div>
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#repositories-navbar" aria-controls="repositories-navbar" aria-expanded="false" aria-label="Toggle navigation">
|
|
||||||
<span class="navbar-toggler-icon"></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div id="repositories-navbar" class="collapse navbar-collapse">
|
|
||||||
<ul id="repositories" class="nav nav-tabs">
|
|
||||||
{% for repository in repositories %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a id="{{ repository.id }}-link" class="nav-link" href="#{{ repository.id }}" data-repository="{{ repository.repository }}" data-architecture="{{ repository.architecture }}">{{ repository.repository }} ({{ repository.architecture }})</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="alert-placeholder" class="toast-container p3 top-0 start-50 translate-middle-x"></div>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div id="toolbar" class="dropdown">
|
|
||||||
<button id="dashboard-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#dashboard-modal">
|
|
||||||
<i class="bi bi-info-circle"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{% if not auth.enabled or auth.username is not none %}
|
|
||||||
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-box"></i><span class="d-none d-sm-inline"> packages</span>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li>
|
|
||||||
<button id="package-add-button" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-add-modal">
|
|
||||||
<i class="bi bi-plus"></i> add
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button id="package-update-button" class="btn dropdown-item" onclick="packagesUpdate()">
|
|
||||||
<i class="bi bi-play"></i> update
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button id="update-repositories-button" class="btn dropdown-item" onclick="refreshDatabases()">
|
|
||||||
<i class="bi bi-arrow-down-circle"></i> update pacman databases
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button id="package-rebuild-button" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-rebuild-modal">
|
|
||||||
<i class="bi bi-arrow-clockwise"></i> rebuild
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button id="package-remove-button" class="btn dropdown-item" onclick="packagesRemove()" disabled>
|
|
||||||
<i class="bi bi-trash"></i> remove
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<button id="key-import-button" type="button" class="btn btn-info" data-bs-toggle="modal" data-bs-target="#key-import-modal">
|
|
||||||
<i class="bi bi-key"></i><span class="d-none d-sm-inline"> import key</span>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="reload()">
|
|
||||||
<i class="bi bi-arrow-clockwise"></i><span class="d-none d-sm-inline"> reload</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{% if autorefresh_intervals %}
|
|
||||||
<div class="btn-group">
|
|
||||||
<input id="table-autoreload-button" type="checkbox" class="btn-check" autocomplete="off" onclick="toggleTableAutoReload()" checked>
|
|
||||||
<label for="table-autoreload-button" class="btn btn-outline-secondary" title="toggle auto reload"><i class="bi bi-clock"></i></label>
|
|
||||||
<button type="button" class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<span class="visually-hidden">select interval</span>
|
|
||||||
</button>
|
|
||||||
<ul id="table-autoreload-input" class="dropdown-menu">
|
|
||||||
{% for interval in autorefresh_intervals %}
|
|
||||||
<li><a class="dropdown-item {{ "active" if interval.is_active }}" onclick="toggleTableAutoReload({{ interval.interval }})" data-interval="{{ interval.interval }}">{{ interval.text }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table id="packages"
|
|
||||||
data-classes="table table-hover"
|
|
||||||
data-cookie="true"
|
|
||||||
data-cookie-id-table="ahriman-packages"
|
|
||||||
data-cookie-storage="localStorage"
|
|
||||||
data-export-options='{"fileName": "packages"}'
|
|
||||||
data-filter-control="true"
|
|
||||||
data-filter-control-visible="false"
|
|
||||||
data-page-list="[10, 25, 50, 100, all]"
|
|
||||||
data-page-size="10"
|
|
||||||
data-pagination="true"
|
|
||||||
data-resizable="true"
|
|
||||||
data-search="true"
|
|
||||||
data-show-columns="true"
|
|
||||||
data-show-columns-search="true"
|
|
||||||
data-show-columns-toggle-all="true"
|
|
||||||
data-show-export="true"
|
|
||||||
data-show-filter-control-switch="true"
|
|
||||||
data-show-fullscreen="true"
|
|
||||||
data-show-search-clear-button="true"
|
|
||||||
data-sortable="true"
|
|
||||||
data-sort-name="base"
|
|
||||||
data-sort-order="asc"
|
|
||||||
data-toolbar="#toolbar"
|
|
||||||
data-unique-id="id">
|
|
||||||
<thead class="table-primary">
|
|
||||||
<tr>
|
|
||||||
<th data-checkbox="true"></th>
|
|
||||||
<th data-sortable="true" data-switchable="false" data-field="base" data-filter-control="input" data-filter-control-placeholder="(any base)">package base</th>
|
|
||||||
<th data-sortable="true" data-align="right" data-field="version" data-filter-control="input" data-filter-control-placeholder="(any version)">version</th>
|
|
||||||
<th data-sortable="true" data-field="packages" data-filter-control="input" data-filter-control-placeholder="(any package)">packages</th>
|
|
||||||
<th data-sortable="true" data-visible="false" data-field="groups" data-filter-control="select" data-filter-data="func:filterListGroups" data-filter-custom-search="filterList" data-filter-control-placeholder="(any group)">groups</th>
|
|
||||||
<th data-sortable="true" data-visible="false" data-field="licenses" data-filter-control="select" data-filter-data="func:filterListLicenses" data-filter-custom-search="filterList" data-filter-control-placeholder="(any license)">licenses</th>
|
|
||||||
<th data-sortable="true" data-visible="false" data-field="packager" data-filter-control="select" data-filter-custom-search="filterContains" data-filter-control-placeholder="(any packager)">packager</th>
|
|
||||||
<th data-sortable="true" data-align="right" data-field="timestamp" data-filter-control="input" data-filter-custom-search="filterDateRange" data-filter-control-placeholder="(any date)">last update</th>
|
|
||||||
<th data-sortable="true" data-align="center" data-cell-style="statusFormat" data-field="status" data-filter-control="select" data-filter-control-placeholder="(any status)">status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
|
|
||||||
<ul class="nav">
|
|
||||||
<li><a id="badge-version" class="nav-link" href="https://github.com/arcan1s/ahriman" title="sources"><i class="bi bi-github"></i> ahriman</a></li>
|
|
||||||
<li><a class="nav-link" href="https://github.com/arcan1s/ahriman/releases" title="releases list">releases</a></li>
|
|
||||||
<li><a class="nav-link" href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li>
|
|
||||||
{% if docs_enabled %}
|
|
||||||
<li><a class="nav-link" href="/api-docs" title="API documentation">api</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{% if index_url is not none %}
|
|
||||||
<ul class="nav">
|
|
||||||
<li><a class="nav-link" href="{{ index_url }}" title="repo index"><i class="bi bi-house"></i> repo index</a></li>
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if auth.enabled %}
|
|
||||||
<ul class="nav">
|
|
||||||
{% if auth.username is none %}
|
|
||||||
<li>{{ auth.control | safe }}</li>
|
|
||||||
{% else %}
|
|
||||||
<li>
|
|
||||||
<form action="/api/v1/logout" method="post">
|
|
||||||
<button class="btn btn-link" style="text-decoration: none"><i class="bi bi-box-arrow-right"></i> logout ({{ auth.username }})</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if auth.enabled %}
|
|
||||||
{% include "build-status/login-modal.jinja2" %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% include "build-status/alerts.jinja2" %}
|
|
||||||
|
|
||||||
{% include "build-status/dashboard.jinja2" %}
|
|
||||||
{% include "build-status/package-add-modal.jinja2" %}
|
|
||||||
{% include "build-status/package-rebuild-modal.jinja2" %}
|
|
||||||
{% include "build-status/key-import-modal.jinja2" %}
|
|
||||||
|
|
||||||
{% include "build-status/package-info-modal.jinja2" %}
|
|
||||||
|
|
||||||
{% include "build-status/table.jinja2" %}
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user