mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-04-07 02:53:38 +00:00
upload ai slop
This commit is contained in:
@@ -12,3 +12,6 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.pyd
|
*.pyd
|
||||||
*.pyo
|
*.pyo
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
frontend/
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -99,3 +99,6 @@ status_cache.json
|
|||||||
*.db
|
*.db
|
||||||
|
|
||||||
docs/html/
|
docs/html/
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
node_modules/
|
||||||
|
|||||||
49
frontend/eslint.config.js
Normal file
49
frontend/eslint.config.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import stylistic from "@stylistic/eslint-plugin";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
|
||||||
|
files: ["src/**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
"@stylistic": stylistic,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
|
||||||
|
"curly": "error",
|
||||||
|
"@stylistic/brace-style": ["error", "1tbs"],
|
||||||
|
|
||||||
|
// stylistic
|
||||||
|
"@stylistic/indent": ["error", 4],
|
||||||
|
"@stylistic/quotes": ["error", "double"],
|
||||||
|
"@stylistic/semi": ["error", "always"],
|
||||||
|
"@stylistic/comma-dangle": ["error", "always-multiline"],
|
||||||
|
"@stylistic/object-curly-spacing": ["error", "always"],
|
||||||
|
"@stylistic/array-bracket-spacing": ["error", "never"],
|
||||||
|
"@stylistic/arrow-parens": ["error", "always"],
|
||||||
|
"@stylistic/eol-last": ["error", "always"],
|
||||||
|
"@stylistic/no-trailing-spaces": "error",
|
||||||
|
"@stylistic/no-multiple-empty-lines": ["error", { max: 1 }],
|
||||||
|
"@stylistic/jsx-quotes": ["error", "prefer-double"],
|
||||||
|
|
||||||
|
// typescript
|
||||||
|
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
|
||||||
|
"@typescript-eslint/no-deprecated": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
4280
frontend/package-lock.json
generated
Normal file
4280
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "ahriman-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint src/",
|
||||||
|
"lint:fix": "eslint --fix src/",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.0",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@mui/icons-material": "^5.15.0",
|
||||||
|
"@mui/material": "^5.15.0",
|
||||||
|
"@mui/x-data-grid": "^7.0.0",
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"chart.js": "^4.5.0",
|
||||||
|
"highlight.js": "^11.11.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.3",
|
||||||
|
"@stylistic/eslint-plugin": "^5.9.0",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"eslint": "^9.39.3",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"typescript-eslint": "^8.56.1",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vite-tsconfig-paths": "^6.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/src/App.tsx
Normal file
35
frontend/src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
|
import CssBaseline from "@mui/material/CssBaseline";
|
||||||
|
import Theme from "theme/Theme";
|
||||||
|
import { AuthProvider } from "contexts/AuthProvider";
|
||||||
|
import { RepositoryProvider } from "contexts/RepositoryProvider";
|
||||||
|
import { NotificationProvider } from "contexts/NotificationProvider";
|
||||||
|
import AppLayout from "components/layout/AppLayout";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function App(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ThemeProvider theme={Theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<AuthProvider>
|
||||||
|
<RepositoryProvider>
|
||||||
|
<NotificationProvider>
|
||||||
|
<AppLayout />
|
||||||
|
</NotificationProvider>
|
||||||
|
</RepositoryProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/api/QueryKeys.ts
Normal file
30
frontend/src/api/QueryKeys.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
function repoKey(repo: RepositoryId): string {
|
||||||
|
return `${repo.architecture}-${repo.repository}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueryKeys = {
|
||||||
|
info: ["info"] as const,
|
||||||
|
|
||||||
|
packages: (repo: RepositoryId) => ["packages", repoKey(repo)] as const,
|
||||||
|
package: (base: string, repo: RepositoryId) => ["packages", repoKey(repo), base] as const,
|
||||||
|
|
||||||
|
status: (repo: RepositoryId) => ["status", repoKey(repo)] as const,
|
||||||
|
|
||||||
|
logs: (base: string, repo: RepositoryId) => ["logs", repoKey(repo), base] as const,
|
||||||
|
logsVersion: (base: string, repo: RepositoryId, version: string, processId: string) =>
|
||||||
|
["logs", repoKey(repo), base, version, processId] as const,
|
||||||
|
|
||||||
|
changes: (base: string, repo: RepositoryId) => ["changes", repoKey(repo), base] as const,
|
||||||
|
|
||||||
|
dependencies: (base: string, repo: RepositoryId) => ["dependencies", repoKey(repo), base] as const,
|
||||||
|
|
||||||
|
patches: (base: string) => ["patches", base] as const,
|
||||||
|
|
||||||
|
events: (repo: RepositoryId, objectId?: string) => ["events", repoKey(repo), objectId] as const,
|
||||||
|
|
||||||
|
search: (query: string) => ["search", query] as const,
|
||||||
|
|
||||||
|
pgpKey: (key: string, server: string) => ["pgp", key, server] as const,
|
||||||
|
};
|
||||||
216
frontend/src/api/client/AhrimanClient.ts
Normal file
216
frontend/src/api/client/AhrimanClient.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import type { AURPackage } from "api/types/AURPackage";
|
||||||
|
import type { Changes } from "api/types/Changes";
|
||||||
|
import type { Dependencies } from "api/types/Dependencies";
|
||||||
|
import type { Event } from "api/types/Event";
|
||||||
|
import type { InfoResponse } from "api/types/InfoResponse";
|
||||||
|
import type { InternalStatus } from "api/types/InternalStatus";
|
||||||
|
import type { LogRecord } from "api/types/LogRecord";
|
||||||
|
import type { LoginRequest } from "api/types/LoginRequest";
|
||||||
|
import type { PackageActionRequest } from "api/types/PackageActionRequest";
|
||||||
|
import type { PackageStatus } from "api/types/PackageStatus";
|
||||||
|
import type { Patch } from "api/types/Patch";
|
||||||
|
import type { PGPKey } from "api/types/PGPKey";
|
||||||
|
import type { PGPKeyRequest } from "api/types/PGPKeyRequest";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import type { RequestOptions } from "api/client/RequestOptions";
|
||||||
|
|
||||||
|
export class AhrimanClient {
|
||||||
|
private static repoQuery(repo: RepositoryId): Record<string, string> {
|
||||||
|
return { architecture: repo.architecture, repository: repo.repository };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(url: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const { method, query, json } = options;
|
||||||
|
|
||||||
|
let fullUrl = url;
|
||||||
|
if (query) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fullUrl += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestInit: RequestInit = {
|
||||||
|
method: method || (json ? "POST" : "GET"),
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (json !== undefined) {
|
||||||
|
requestInit.body = JSON.stringify(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(fullUrl, requestInit);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
throw new ApiError(response.status, response.statusText, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("Content-Type") ?? "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
return await response.text() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info
|
||||||
|
|
||||||
|
async fetchInfo(): Promise<InfoResponse> {
|
||||||
|
return this.request<InfoResponse>("/api/v1/info");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packages
|
||||||
|
|
||||||
|
async fetchPackages(repo: RepositoryId): Promise<PackageStatus[]> {
|
||||||
|
return this.request<PackageStatus[]>("/api/v1/packages", { query: AhrimanClient.repoQuery(repo) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchPackage(base: string, repo: RepositoryId): Promise<PackageStatus[]> {
|
||||||
|
return this.request<PackageStatus[]>(`/api/v1/packages/${encodeURIComponent(base)}`, {
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status
|
||||||
|
|
||||||
|
async fetchStatus(repo: RepositoryId): Promise<InternalStatus> {
|
||||||
|
return this.request<InternalStatus>("/api/v1/status", { query: AhrimanClient.repoQuery(repo) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
|
||||||
|
async fetchLogs(
|
||||||
|
base: string,
|
||||||
|
repo: RepositoryId,
|
||||||
|
version?: string,
|
||||||
|
processId?: string,
|
||||||
|
head?: boolean,
|
||||||
|
): Promise<LogRecord[]> {
|
||||||
|
const query: Record<string, string | boolean> = AhrimanClient.repoQuery(repo);
|
||||||
|
if (version) {
|
||||||
|
query.version = version;
|
||||||
|
}
|
||||||
|
if (processId) {
|
||||||
|
query.process_id = processId;
|
||||||
|
}
|
||||||
|
if (head) {
|
||||||
|
query.head = true;
|
||||||
|
}
|
||||||
|
return this.request<LogRecord[]>(`/api/v2/packages/${encodeURIComponent(base)}/logs`, { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changes
|
||||||
|
|
||||||
|
async fetchChanges(base: string, repo: RepositoryId): Promise<Changes> {
|
||||||
|
return this.request<Changes>(`/api/v1/packages/${encodeURIComponent(base)}/changes`, {
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
|
||||||
|
async fetchDependencies(base: string, repo: RepositoryId): Promise<Dependencies> {
|
||||||
|
return this.request<Dependencies>(`/api/v1/packages/${encodeURIComponent(base)}/dependencies`, {
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patches
|
||||||
|
|
||||||
|
async fetchPatches(base: string): Promise<Patch[]> {
|
||||||
|
return this.request<Patch[]>(`/api/v1/packages/${encodeURIComponent(base)}/patches`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePatch(base: string, key: string): Promise<void> {
|
||||||
|
return this.request(`/api/v1/packages/${encodeURIComponent(base)}/patches/${encodeURIComponent(key)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events
|
||||||
|
|
||||||
|
async fetchEvents(repo: RepositoryId, objectId?: string, limit?: number): Promise<Event[]> {
|
||||||
|
const query: Record<string, string | number> = AhrimanClient.repoQuery(repo);
|
||||||
|
if (objectId) {
|
||||||
|
query.object_id = objectId;
|
||||||
|
}
|
||||||
|
if (limit) {
|
||||||
|
query.limit = limit;
|
||||||
|
}
|
||||||
|
return this.request<Event[]>("/api/v1/events", { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service actions
|
||||||
|
|
||||||
|
async addPackages(repo: RepositoryId, data: PackageActionRequest): Promise<void> {
|
||||||
|
return this.request("/api/v1/service/add", { method: "POST", query: AhrimanClient.repoQuery(repo), json: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePackages(repo: RepositoryId, packages: string[]): Promise<void> {
|
||||||
|
return this.request("/api/v1/service/remove", {
|
||||||
|
method: "POST",
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
json: { packages },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePackages(repo: RepositoryId, data: PackageActionRequest): Promise<void> {
|
||||||
|
return this.request("/api/v1/service/update", {
|
||||||
|
method: "POST",
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
json: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rebuildPackages(repo: RepositoryId, packages: string[]): Promise<void> {
|
||||||
|
return this.request("/api/v1/service/rebuild", {
|
||||||
|
method: "POST",
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
json: { packages },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestPackages(repo: RepositoryId, data: PackageActionRequest): Promise<void> {
|
||||||
|
return this.request("/api/v1/service/request", {
|
||||||
|
method: "POST",
|
||||||
|
query: AhrimanClient.repoQuery(repo),
|
||||||
|
json: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
|
||||||
|
async searchPackages(query: string): Promise<AURPackage[]> {
|
||||||
|
return this.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PGP
|
||||||
|
|
||||||
|
async fetchPGPKey(key: string, server: string): Promise<PGPKey> {
|
||||||
|
return this.request<PGPKey>("/api/v1/service/pgp", { query: { key, server } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async importPGPKey(data: PGPKeyRequest): Promise<void> {
|
||||||
|
return this.request("/api/v1/service/pgp", { method: "POST", json: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
|
||||||
|
async login(data: LoginRequest): Promise<void> {
|
||||||
|
return this.request("/api/v1/login", { method: "POST", json: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
return this.request("/api/v1/logout", { method: "POST" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Client = new AhrimanClient();
|
||||||
21
frontend/src/api/client/ApiError.ts
Normal file
21
frontend/src/api/client/ApiError.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
body: string;
|
||||||
|
|
||||||
|
constructor(status: number, statusText: string, body: string) {
|
||||||
|
super(`${status} ${statusText}`);
|
||||||
|
this.status = status;
|
||||||
|
this.statusText = statusText;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
get detail(): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.body) as Record<string, string>;
|
||||||
|
return parsed.error ?? (this.body || this.message);
|
||||||
|
} catch {
|
||||||
|
return this.body || this.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/src/api/client/RequestOptions.ts
Normal file
5
frontend/src/api/client/RequestOptions.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface RequestOptions {
|
||||||
|
method?: string;
|
||||||
|
query?: Record<string, string | number | boolean>;
|
||||||
|
json?: unknown;
|
||||||
|
}
|
||||||
4
frontend/src/api/types/AURPackage.ts
Normal file
4
frontend/src/api/types/AURPackage.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface AURPackage {
|
||||||
|
package: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
5
frontend/src/api/types/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 { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
export interface InfoResponse {
|
||||||
|
auth: boolean;
|
||||||
|
repositories: RepositoryId[];
|
||||||
|
version: string;
|
||||||
|
autorefresh_intervals: AutoRefreshInterval[];
|
||||||
|
docs_enabled: boolean;
|
||||||
|
index_url?: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
12
frontend/src/api/types/InternalStatus.ts
Normal file
12
frontend/src/api/types/InternalStatus.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Counters } from "api/types/Counters";
|
||||||
|
import type { RepositoryStats } from "api/types/RepositoryStats";
|
||||||
|
import type { Status } from "api/types/Status";
|
||||||
|
|
||||||
|
export interface InternalStatus {
|
||||||
|
architecture: string;
|
||||||
|
repository: string;
|
||||||
|
packages: Counters;
|
||||||
|
stats: RepositoryStats;
|
||||||
|
status: Status;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
6
frontend/src/api/types/LogRecord.ts
Normal file
6
frontend/src/api/types/LogRecord.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface LogRecord {
|
||||||
|
created: number;
|
||||||
|
message: string;
|
||||||
|
process_id: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
4
frontend/src/api/types/LoginRequest.ts
Normal file
4
frontend/src/api/types/LoginRequest.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface LoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
3
frontend/src/api/types/PGPKey.ts
Normal file
3
frontend/src/api/types/PGPKey.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface PGPKey {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
4
frontend/src/api/types/PGPKeyRequest.ts
Normal file
4
frontend/src/api/types/PGPKeyRequest.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface PGPKeyRequest {
|
||||||
|
key: string;
|
||||||
|
server: string;
|
||||||
|
}
|
||||||
10
frontend/src/api/types/Package.ts
Normal file
10
frontend/src/api/types/Package.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { PackageProperties } from "api/types/PackageProperties";
|
||||||
|
import type { Remote } from "api/types/Remote";
|
||||||
|
|
||||||
|
export interface Package {
|
||||||
|
base: string;
|
||||||
|
packager?: string;
|
||||||
|
packages: Record<string, PackageProperties>;
|
||||||
|
remote: Remote;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
10
frontend/src/api/types/PackageActionRequest.ts
Normal file
10
frontend/src/api/types/PackageActionRequest.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Patch } from "api/types/Patch";
|
||||||
|
|
||||||
|
export interface PackageActionRequest {
|
||||||
|
packages: string[];
|
||||||
|
patches?: Patch[];
|
||||||
|
refresh?: boolean;
|
||||||
|
aur?: boolean;
|
||||||
|
local?: boolean;
|
||||||
|
manual?: boolean;
|
||||||
|
}
|
||||||
16
frontend/src/api/types/PackageProperties.ts
Normal file
16
frontend/src/api/types/PackageProperties.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export interface PackageProperties {
|
||||||
|
architecture?: string;
|
||||||
|
archive_size?: number;
|
||||||
|
build_date?: number;
|
||||||
|
check_depends?: string[];
|
||||||
|
depends?: string[];
|
||||||
|
description?: string;
|
||||||
|
filename?: string;
|
||||||
|
groups?: string[];
|
||||||
|
installed_size?: number;
|
||||||
|
licenses?: string[];
|
||||||
|
make_depends?: string[];
|
||||||
|
opt_depends?: string[];
|
||||||
|
provides?: string[];
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
15
frontend/src/api/types/PackageRow.ts
Normal file
15
frontend/src/api/types/PackageRow.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
|
||||||
|
export interface PackageRow {
|
||||||
|
id: string;
|
||||||
|
base: string;
|
||||||
|
webUrl?: string;
|
||||||
|
version: string;
|
||||||
|
packages: string[];
|
||||||
|
groups: string[];
|
||||||
|
licenses: string[];
|
||||||
|
packager: string;
|
||||||
|
timestamp: string;
|
||||||
|
timestampValue: number;
|
||||||
|
status: BuildStatus;
|
||||||
|
}
|
||||||
9
frontend/src/api/types/PackageStatus.ts
Normal file
9
frontend/src/api/types/PackageStatus.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Package } from "api/types/Package";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
import type { Status } from "api/types/Status";
|
||||||
|
|
||||||
|
export interface PackageStatus {
|
||||||
|
package: Package;
|
||||||
|
status: Status;
|
||||||
|
repository: RepositoryId;
|
||||||
|
}
|
||||||
4
frontend/src/api/types/Patch.ts
Normal file
4
frontend/src/api/types/Patch.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface Patch {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
7
frontend/src/api/types/Remote.ts
Normal file
7
frontend/src/api/types/Remote.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface Remote {
|
||||||
|
branch?: string;
|
||||||
|
git_url?: string;
|
||||||
|
path?: string;
|
||||||
|
source: string;
|
||||||
|
web_url?: string;
|
||||||
|
}
|
||||||
4
frontend/src/api/types/RepositoryId.ts
Normal file
4
frontend/src/api/types/RepositoryId.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface RepositoryId {
|
||||||
|
architecture: string;
|
||||||
|
repository: string;
|
||||||
|
}
|
||||||
6
frontend/src/api/types/RepositoryStats.ts
Normal file
6
frontend/src/api/types/RepositoryStats.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface RepositoryStats {
|
||||||
|
archive_size?: number;
|
||||||
|
bases?: number;
|
||||||
|
installed_size?: number;
|
||||||
|
packages?: number;
|
||||||
|
}
|
||||||
6
frontend/src/api/types/Status.ts
Normal file
6
frontend/src/api/types/Status.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
|
||||||
|
export interface Status {
|
||||||
|
status: BuildStatus;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
33
frontend/src/components/charts/EventDurationLineChart.tsx
Normal file
33
frontend/src/components/charts/EventDurationLineChart.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Line } from "react-chartjs-2";
|
||||||
|
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend } from "chart.js";
|
||||||
|
import type { Event } from "api/types/Event";
|
||||||
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
interface EventDurationLineChartProps {
|
||||||
|
events: Event[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventDurationLineChart({ events }: EventDurationLineChartProps): React.JSX.Element | null {
|
||||||
|
const updateEvents = events.filter((e) => e.event === "package-updated" && e.data?.took);
|
||||||
|
|
||||||
|
if (updateEvents.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: updateEvents.map((e) => formatTimestamp(e.created)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "update duration, s",
|
||||||
|
data: updateEvents.map((e) => (e.data as Record<string, number>).took),
|
||||||
|
cubicInterpolationMode: "monotone" as const,
|
||||||
|
tension: 0.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Line data={data} options={{ responsive: true }} />;
|
||||||
|
}
|
||||||
39
frontend/src/components/charts/PackageCountBarChart.tsx
Normal file
39
frontend/src/components/charts/PackageCountBarChart.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Bar } from "react-chartjs-2";
|
||||||
|
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from "chart.js";
|
||||||
|
import type { RepositoryStats } from "api/types/RepositoryStats";
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
interface PackageCountBarChartProps {
|
||||||
|
stats: RepositoryStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
|
||||||
|
const data = {
|
||||||
|
labels: ["packages"],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "archives",
|
||||||
|
data: [stats.packages ?? 0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "bases",
|
||||||
|
data: [stats.bases ?? 0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Bar
|
||||||
|
data={data}
|
||||||
|
options={{
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
x: { stacked: true },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
frontend/src/components/charts/StatusPieChart.tsx
Normal file
27
frontend/src/components/charts/StatusPieChart.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Pie } from "react-chartjs-2";
|
||||||
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
|
||||||
|
import type { Counters } from "api/types/Counters";
|
||||||
|
import { ChartColors } from "theme/status/ChartColors";
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
interface StatusPieChartProps {
|
||||||
|
counters: Counters;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
|
||||||
|
const labels = ["unknown", "pending", "building", "failed", "success"] as const;
|
||||||
|
const data = {
|
||||||
|
labels: labels.map((l) => l),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "packages in status",
|
||||||
|
data: labels.map((label) => counters[label]),
|
||||||
|
backgroundColor: labels.map((label) => ChartColors[label]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Pie data={data} options={{ responsive: true }} />;
|
||||||
|
}
|
||||||
75
frontend/src/components/common/AutoRefreshControl.tsx
Normal file
75
frontend/src/components/common/AutoRefreshControl.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { IconButton, Menu, MenuItem, Tooltip, ListItemIcon, ListItemText } from "@mui/material";
|
||||||
|
import TimerIcon from "@mui/icons-material/Timer";
|
||||||
|
import TimerOffIcon from "@mui/icons-material/TimerOff";
|
||||||
|
import CheckIcon from "@mui/icons-material/Check";
|
||||||
|
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||||
|
|
||||||
|
interface AutoRefreshControlProps {
|
||||||
|
intervals: AutoRefreshInterval[];
|
||||||
|
enabled: boolean;
|
||||||
|
currentInterval: number;
|
||||||
|
onToggle: (enabled: boolean) => void;
|
||||||
|
onIntervalChange: (interval: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AutoRefreshControl({
|
||||||
|
intervals,
|
||||||
|
enabled,
|
||||||
|
currentInterval,
|
||||||
|
onToggle,
|
||||||
|
onIntervalChange,
|
||||||
|
}: AutoRefreshControlProps): React.JSX.Element | null {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
if (intervals.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip title="Auto-refresh">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||||
|
color={enabled ? "primary" : "default"}
|
||||||
|
>
|
||||||
|
{enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
selected={!enabled}
|
||||||
|
onClick={() => {
|
||||||
|
onToggle(false);
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
{!enabled && <CheckIcon fontSize="small" />}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>Off</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
{intervals.map((iv) => (
|
||||||
|
<MenuItem
|
||||||
|
key={iv.interval}
|
||||||
|
selected={enabled && iv.interval === currentInterval}
|
||||||
|
onClick={() => {
|
||||||
|
onIntervalChange(iv.interval);
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
{enabled && iv.interval === currentInterval && <CheckIcon fontSize="small" />}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{iv.text}</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/components/common/CopyButton.tsx
Normal file
26
frontend/src/components/common/CopyButton.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { IconButton, Tooltip } from "@mui/material";
|
||||||
|
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||||
|
import CheckIcon from "@mui/icons-material/Check";
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
getText: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async (): Promise<void> => {
|
||||||
|
await navigator.clipboard.writeText(getText());
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={copied ? "Copied!" : "Copy"}>
|
||||||
|
<IconButton size="small" onClick={() => void handleCopy()}>
|
||||||
|
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/components/common/formatTimestamp.ts
Normal file
5
frontend/src/components/common/formatTimestamp.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function formatTimestamp(unixSeconds: number): string {
|
||||||
|
const d = new Date(unixSeconds * 1000);
|
||||||
|
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
88
frontend/src/components/dialogs/DashboardDialog.tsx
Normal file
88
frontend/src/components/dialogs/DashboardDialog.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, Typography, Box } from "@mui/material";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import StatusPieChart from "components/charts/StatusPieChart";
|
||||||
|
import PackageCountBarChart from "components/charts/PackageCountBarChart";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import { StatusHeaderStyles } from "theme/status/StatusHeaderStyles";
|
||||||
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||||
|
import type { InternalStatus } from "api/types/InternalStatus";
|
||||||
|
|
||||||
|
interface DashboardDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
|
||||||
|
const { current } = useRepository();
|
||||||
|
|
||||||
|
const { data: status } = useQuery<InternalStatus>({
|
||||||
|
queryKey: current ? QueryKeys.status(current) : ["status"],
|
||||||
|
queryFn: () => Client.fetchStatus(current!),
|
||||||
|
enabled: !!current && open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||||
|
<DialogTitle sx={headerStyle}>System health</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{status && (
|
||||||
|
<>
|
||||||
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="right">Repository name</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2">{status.repository}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="right">Repository architecture</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2">{status.architecture}</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="right">Current status</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2">{status.status.status}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2" color="text.secondary" align="right">Updated at</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="body2">{formatTimestamp(status.status.timestamp)}</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{status.packages.total > 0 && (
|
||||||
|
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Box sx={{ maxHeight: 300 }}>
|
||||||
|
<PackageCountBarChart stats={status.stats} />
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Box sx={{ maxHeight: 300 }}>
|
||||||
|
<StatusPieChart counters={status.packages} />
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose} variant="contained" startIcon={<CloseIcon />}>close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/components/dialogs/KeyImportDialog.tsx
Normal file
106
frontend/src/components/dialogs/KeyImportDialog.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||||
|
TextField, Box,
|
||||||
|
} from "@mui/material";
|
||||||
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import CopyButton from "components/common/CopyButton";
|
||||||
|
|
||||||
|
interface KeyImportDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element {
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const [fingerprint, setFingerprint] = useState("");
|
||||||
|
const [server, setServer] = useState("keyserver.ubuntu.com");
|
||||||
|
const [keyBody, setKeyBody] = useState("");
|
||||||
|
|
||||||
|
const handleFetch = async (): Promise<void> => {
|
||||||
|
if (!fingerprint || !server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await Client.fetchPGPKey(fingerprint, server);
|
||||||
|
setKeyBody(result.key);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Could not fetch key: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async (): Promise<void> => {
|
||||||
|
if (!fingerprint || !server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Client.importPGPKey({ key: fingerprint, server });
|
||||||
|
onClose();
|
||||||
|
showSuccess("Success", `Key ${fingerprint} has been imported`);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
setFingerprint("");
|
||||||
|
setServer("keyserver.ubuntu.com");
|
||||||
|
setKeyBody("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
|
||||||
|
<DialogTitle>Import key from PGP server</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
label="fingerprint"
|
||||||
|
placeholder="PGP key fingerprint"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={fingerprint}
|
||||||
|
onChange={(e) => setFingerprint(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="key server"
|
||||||
|
placeholder="PGP key server"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={server}
|
||||||
|
onChange={(e) => setServer(e.target.value)}
|
||||||
|
/>
|
||||||
|
{keyBody && (
|
||||||
|
<Box sx={{ position: "relative", mt: 2 }}>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
maxHeight: 300,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code>{keyBody}</code>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||||
|
<CopyButton getText={() => keyBody} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => void handleImport()} variant="contained" startIcon={<PlayArrowIcon />}>import</Button>
|
||||||
|
<Button onClick={() => void handleFetch()} variant="contained" color="success" startIcon={<RefreshIcon />}>fetch</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
frontend/src/components/dialogs/LoginDialog.tsx
Normal file
91
frontend/src/components/dialogs/LoginDialog.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField,
|
||||||
|
InputAdornment, IconButton,
|
||||||
|
} from "@mui/material";
|
||||||
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
|
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||||
|
import PersonIcon from "@mui/icons-material/Person";
|
||||||
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
|
||||||
|
interface LoginDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginDialog({ open, onClose }: LoginDialogProps): React.JSX.Element {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const { login } = useAuth();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const handleSubmit = async (): Promise<void> => {
|
||||||
|
if (!username || !password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
onClose();
|
||||||
|
showSuccess("Logged in", `Successfully logged in as ${username}`);
|
||||||
|
window.location.href = "/";
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
if (username === "admin" && password === "admin") {
|
||||||
|
showError("Login error", "You've entered a password for user \"root\", did you make a typo in username?");
|
||||||
|
} else {
|
||||||
|
showError("Login error", `Could not login as ${username}: ${detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
setUsername("");
|
||||||
|
setPassword("");
|
||||||
|
setShowPassword(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Login</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
label="username"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="password"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
void handleSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end" size="small">
|
||||||
|
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => void handleSubmit()} variant="contained" startIcon={<PersonIcon />}>login</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
frontend/src/components/dialogs/PackageAddDialog.tsx
Normal file
194
frontend/src/components/dialogs/PackageAddDialog.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||||
|
TextField, Autocomplete, Box, IconButton, FormControlLabel, Checkbox, Select, MenuItem, InputLabel, FormControl,
|
||||||
|
} from "@mui/material";
|
||||||
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import { useDebounce } from "hooks/useDebounce";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import type { AURPackage } from "api/types/AURPackage";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
interface EnvVar {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PackageAddDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
|
||||||
|
const { repositories, current } = useRepository();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const [packageName, setPackageName] = useState("");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
||||||
|
const [refresh, setRefresh] = useState(true);
|
||||||
|
const [envVars, setEnvVars] = useState<EnvVar[]>([]);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(packageName, 500);
|
||||||
|
|
||||||
|
const { data: searchResults = [] } = useQuery<AURPackage[]>({
|
||||||
|
queryKey: QueryKeys.search(debouncedSearch),
|
||||||
|
queryFn: () => Client.searchPackages(debouncedSearch),
|
||||||
|
enabled: debouncedSearch.length >= 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSelectedRepo = useCallback((): RepositoryId => {
|
||||||
|
if (selectedRepo) {
|
||||||
|
const repo = repositories.find(
|
||||||
|
(r) => `${r.architecture}-${r.repository}` === selectedRepo,
|
||||||
|
);
|
||||||
|
if (repo) {
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current!;
|
||||||
|
}, [selectedRepo, repositories, current]);
|
||||||
|
|
||||||
|
const handleAdd = async (): Promise<void> => {
|
||||||
|
if (!packageName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const repo = getSelectedRepo();
|
||||||
|
try {
|
||||||
|
const patches = envVars.filter((v) => v.key);
|
||||||
|
await Client.addPackages(repo, {
|
||||||
|
packages: [packageName],
|
||||||
|
patches: patches.length > 0 ? patches : undefined,
|
||||||
|
refresh,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
showSuccess("Success", `Packages ${packageName} have been added`);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Package addition failed: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRequest = async (): Promise<void> => {
|
||||||
|
if (!packageName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const repo = getSelectedRepo();
|
||||||
|
try {
|
||||||
|
const patches = envVars.filter((v) => v.key);
|
||||||
|
await Client.requestPackages(repo, {
|
||||||
|
packages: [packageName],
|
||||||
|
patches: patches.length > 0 ? patches : undefined,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
showSuccess("Success", `Packages ${packageName} have been requested`);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Package request failed: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
setPackageName("");
|
||||||
|
setSelectedRepo("");
|
||||||
|
setRefresh(true);
|
||||||
|
setEnvVars([]);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>Add new packages</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<FormControl fullWidth margin="normal">
|
||||||
|
<InputLabel>repository</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedRepo || (current ? `${current.architecture}-${current.repository}` : "")}
|
||||||
|
label="repository"
|
||||||
|
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||||
|
>
|
||||||
|
{repositories.map((r) => (
|
||||||
|
<MenuItem key={`${r.architecture}-${r.repository}`} value={`${r.architecture}-${r.repository}`}>
|
||||||
|
{r.repository} ({r.architecture})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={searchResults.map((p) => p.package)}
|
||||||
|
inputValue={packageName}
|
||||||
|
onInputChange={(_, value) => setPackageName(value)}
|
||||||
|
renderOption={(props, option) => {
|
||||||
|
const pkg = searchResults.find((p) => p.package === option);
|
||||||
|
return (
|
||||||
|
<li {...props} key={option}>
|
||||||
|
{option}{pkg ? ` (${pkg.description})` : ""}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="package" placeholder="AUR package" margin="normal" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={refresh} onChange={(_, checked) => setRefresh(checked)} />}
|
||||||
|
label="update pacman databases"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setEnvVars([...envVars, { key: "", value: "" }])}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
add environment variable
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{envVars.map((env, index) => (
|
||||||
|
<Box key={index} sx={{ display: "flex", gap: 1, mt: 1, alignItems: "center" }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="name"
|
||||||
|
value={env.key}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...envVars];
|
||||||
|
updated[index] = { ...updated[index], key: e.target.value };
|
||||||
|
setEnvVars(updated);
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Box>=</Box>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="value"
|
||||||
|
value={env.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...envVars];
|
||||||
|
updated[index] = { ...updated[index], value: e.target.value };
|
||||||
|
setEnvVars(updated);
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<IconButton size="small" color="error" onClick={() => setEnvVars(envVars.filter((_, i) => i !== index))}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => void handleAdd()} variant="contained" startIcon={<PlayArrowIcon />}>add</Button>
|
||||||
|
<Button onClick={() => void handleRequest()} variant="contained" color="success" startIcon={<AddIcon />}>request</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
299
frontend/src/components/dialogs/PackageInfoDialog.tsx
Normal file
299
frontend/src/components/dialogs/PackageInfoDialog.tsx
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||||
|
Grid, Typography, Link, Box, Tab, Tabs, IconButton, Chip, FormControlLabel, Checkbox,
|
||||||
|
} from "@mui/material";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import BuildLogsTab from "components/package/BuildLogsTab";
|
||||||
|
import ChangesTab from "components/package/ChangesTab";
|
||||||
|
import EventsTab from "components/package/EventsTab";
|
||||||
|
import AutoRefreshControl from "components/common/AutoRefreshControl";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import { useAutoRefresh } from "hooks/useAutoRefresh";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import { StatusHeaderStyles } from "theme/status/StatusHeaderStyles";
|
||||||
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||||
|
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||||
|
import type { Dependencies } from "api/types/Dependencies";
|
||||||
|
import type { PackageProperties } from "api/types/PackageProperties";
|
||||||
|
import type { PackageStatus } from "api/types/PackageStatus";
|
||||||
|
import type { Patch } from "api/types/Patch";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
interface PackageInfoDialogProps {
|
||||||
|
packageBase: string | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
autorefreshIntervals: AutoRefreshInterval[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function listToString(items: string[]): React.ReactNode {
|
||||||
|
const unique = [...new Set(items)].sort();
|
||||||
|
return unique.map((item, i) => (
|
||||||
|
<React.Fragment key={item}>
|
||||||
|
{item}
|
||||||
|
{i < unique.length - 1 && <br />}
|
||||||
|
</React.Fragment>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageInfoDialog({ packageBase, open, onClose, autorefreshIntervals }: PackageInfoDialogProps): React.JSX.Element {
|
||||||
|
const { current } = useRepository();
|
||||||
|
const { enabled: authEnabled, username } = useAuth();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const hasAuth = !authEnabled || username !== null;
|
||||||
|
|
||||||
|
const [tabIndex, setTabIndex] = useState(0);
|
||||||
|
const [refreshDb, setRefreshDb] = useState(true);
|
||||||
|
|
||||||
|
const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
|
||||||
|
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval);
|
||||||
|
|
||||||
|
const repo = current as RepositoryId;
|
||||||
|
|
||||||
|
const { data: packageData } = useQuery<PackageStatus[]>({
|
||||||
|
queryKey: packageBase && repo ? QueryKeys.package(packageBase, repo) : ["package-none"],
|
||||||
|
queryFn: () => Client.fetchPackage(packageBase!, repo),
|
||||||
|
enabled: !!packageBase && !!repo && open,
|
||||||
|
refetchInterval: autoRefresh.refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: dependencies } = useQuery<Dependencies>({
|
||||||
|
queryKey: packageBase && repo ? QueryKeys.dependencies(packageBase, repo) : ["deps-none"],
|
||||||
|
queryFn: () => Client.fetchDependencies(packageBase!, repo),
|
||||||
|
enabled: !!packageBase && !!repo && open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: patches = [] } = useQuery<Patch[]>({
|
||||||
|
queryKey: packageBase ? QueryKeys.patches(packageBase) : ["patches-none"],
|
||||||
|
queryFn: () => Client.fetchPatches(packageBase!),
|
||||||
|
enabled: !!packageBase && open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const description: PackageStatus | undefined = packageData?.[0];
|
||||||
|
const pkg = description?.package;
|
||||||
|
const status = description?.status;
|
||||||
|
|
||||||
|
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
|
||||||
|
|
||||||
|
// Flatten depends from all sub-packages
|
||||||
|
const allDepends: string[] = pkg
|
||||||
|
? Object.values(pkg.packages).flatMap((p: PackageProperties) => {
|
||||||
|
const pkgNames = Object.keys(pkg.packages);
|
||||||
|
const deps = (p.depends ?? []).filter((d: string) => !pkgNames.includes(d));
|
||||||
|
const makeDeps = (p.make_depends ?? []).filter((d: string) => !pkgNames.includes(d)).map((d: string) => `${d} (make)`);
|
||||||
|
const optDeps = (p.opt_depends ?? []).filter((d: string) => !pkgNames.includes(d)).map((d: string) => `${d} (optional)`);
|
||||||
|
return [...deps, ...makeDeps, ...optDeps];
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const implicitDepends: string[] = dependencies
|
||||||
|
? Object.values(dependencies.paths).flat()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const groups: string[] = pkg
|
||||||
|
? Object.values(pkg.packages).flatMap((p: PackageProperties) => p.groups ?? [])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const licenses: string[] = pkg
|
||||||
|
? Object.values(pkg.packages).flatMap((p: PackageProperties) => p.licenses ?? [])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const upstreamUrls: string[] = pkg
|
||||||
|
? [...new Set(Object.values(pkg.packages).map((p: PackageProperties) => p.url).filter((u): u is string => !!u))].sort()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const aurUrl = pkg?.remote.web_url;
|
||||||
|
|
||||||
|
const packagesList: string[] = pkg
|
||||||
|
? Object.entries(pkg.packages).map(([name, p]) => `${name}${p.description ? ` (${p.description})` : ""}`)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleUpdate = async (): Promise<void> => {
|
||||||
|
if (!packageBase || !repo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Client.addPackages(repo, { packages: [packageBase], refresh: refreshDb });
|
||||||
|
showSuccess("Success", `Run update for packages ${packageBase}`);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Package update failed: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (): Promise<void> => {
|
||||||
|
if (!packageBase || !repo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Client.removePackages(repo, [packageBase]);
|
||||||
|
showSuccess("Success", `Packages ${packageBase} have been removed`);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Could not remove package: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePatch = async (key: string): Promise<void> => {
|
||||||
|
if (!packageBase) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Client.deletePatch(packageBase, key);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Could not delete variable: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReload = (): void => {
|
||||||
|
if (!packageBase || !repo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(packageBase, repo) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.logs(packageBase, repo) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.changes(packageBase, repo) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.events(repo, packageBase) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.dependencies(packageBase, repo) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
setTabIndex(0);
|
||||||
|
setRefreshDb(true);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
|
||||||
|
<DialogTitle sx={headerStyle}>
|
||||||
|
{pkg && status
|
||||||
|
? `${pkg.base} ${status.status} at ${formatTimestamp(status.timestamp)}`
|
||||||
|
: packageBase ?? ""}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{pkg && (
|
||||||
|
<>
|
||||||
|
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(packagesList)}</Typography></Grid>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{pkg.version}</Typography></Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">packager</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
|
||||||
|
<Grid item xs={4} md={1} />
|
||||||
|
<Grid item xs={8} md={5} />
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(groups)}</Typography></Grid>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(licenses)}</Typography></Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">upstream</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}>
|
||||||
|
{upstreamUrls.map((url) => (
|
||||||
|
<Link key={url} href={url} target="_blank" rel="noopener" underline="hover" display="block" variant="body2">
|
||||||
|
{url}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}>
|
||||||
|
{aurUrl && (
|
||||||
|
<Link href={aurUrl} target="_blank" rel="noopener" underline="hover" variant="body2">AUR link</Link>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(allDepends)}</Typography></Grid>
|
||||||
|
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid>
|
||||||
|
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(implicitDepends)}</Typography></Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{patches.length > 0 && (
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Environment variables</Typography>
|
||||||
|
{patches.map((patch) => (
|
||||||
|
<Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
|
||||||
|
<Chip label={patch.key} size="small" />
|
||||||
|
<Typography variant="body2">=</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontFamily: "monospace" }}>{JSON.stringify(patch.value)}</Typography>
|
||||||
|
{hasAuth && (
|
||||||
|
<IconButton size="small" color="error" onClick={() => void handleDeletePatch(patch.key)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
|
||||||
|
<Tabs value={tabIndex} onChange={(_, v: number) => setTabIndex(v)}>
|
||||||
|
<Tab label="Build logs" />
|
||||||
|
<Tab label="Changes" />
|
||||||
|
<Tab label="Events" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{tabIndex === 0 && packageBase && repo && (
|
||||||
|
<BuildLogsTab packageBase={packageBase} repo={repo} refetchInterval={autoRefresh.refetchInterval} />
|
||||||
|
)}
|
||||||
|
{tabIndex === 1 && packageBase && repo && (
|
||||||
|
<ChangesTab packageBase={packageBase} repo={repo} />
|
||||||
|
)}
|
||||||
|
{tabIndex === 2 && packageBase && repo && (
|
||||||
|
<EventsTab packageBase={packageBase} repo={repo} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
|
||||||
|
{hasAuth && (
|
||||||
|
<>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={refreshDb} onChange={(_, checked) => setRefreshDb(checked)} size="small" />}
|
||||||
|
label="update pacman databases"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => void handleUpdate()} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
|
||||||
|
update
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void handleRemove()} variant="contained" color="error" startIcon={<DeleteIcon />} size="small">
|
||||||
|
remove
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleReload} variant="outlined" color="secondary" startIcon={<RefreshIcon />} size="small">
|
||||||
|
reload
|
||||||
|
</Button>
|
||||||
|
<AutoRefreshControl
|
||||||
|
intervals={autorefreshIntervals}
|
||||||
|
enabled={autoRefresh.enabled}
|
||||||
|
currentInterval={autoRefresh.interval}
|
||||||
|
onToggle={autoRefresh.setEnabled}
|
||||||
|
onIntervalChange={autoRefresh.setInterval}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleClose} variant="contained" startIcon={<CloseIcon />} size="small">
|
||||||
|
close
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
frontend/src/components/dialogs/PackageRebuildDialog.tsx
Normal file
89
frontend/src/components/dialogs/PackageRebuildDialog.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||||
|
TextField, Select, MenuItem, InputLabel, FormControl,
|
||||||
|
} from "@mui/material";
|
||||||
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
interface PackageRebuildDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
|
||||||
|
const { repositories, current } = useRepository();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
|
const [dependency, setDependency] = useState("");
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
||||||
|
|
||||||
|
const getSelectedRepo = (): RepositoryId => {
|
||||||
|
if (selectedRepo) {
|
||||||
|
const repo = repositories.find((r) => `${r.architecture}-${r.repository}` === selectedRepo);
|
||||||
|
if (repo) {
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current!;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRebuild = async (): Promise<void> => {
|
||||||
|
if (!dependency) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const repo = getSelectedRepo();
|
||||||
|
try {
|
||||||
|
await Client.rebuildPackages(repo, [dependency]);
|
||||||
|
onClose();
|
||||||
|
showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Repository rebuild failed: ${detail}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (): void => {
|
||||||
|
setDependency("");
|
||||||
|
setSelectedRepo("");
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||||
|
<DialogTitle>Rebuild depending packages</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<FormControl fullWidth margin="normal">
|
||||||
|
<InputLabel>repository</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedRepo || (current ? `${current.architecture}-${current.repository}` : "")}
|
||||||
|
label="repository"
|
||||||
|
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||||
|
>
|
||||||
|
{repositories.map((r) => (
|
||||||
|
<MenuItem key={`${r.architecture}-${r.repository}`} value={`${r.architecture}-${r.repository}`}>
|
||||||
|
{r.repository} ({r.architecture})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="dependency"
|
||||||
|
placeholder="packages dependency"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
value={dependency}
|
||||||
|
onChange={(e) => setDependency(e.target.value)}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => void handleRebuild()} variant="contained" startIcon={<PlayArrowIcon />}>rebuild</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/components/layout/AppLayout.tsx
Normal file
58
frontend/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Container, Box } from "@mui/material";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Navbar from "components/layout/Navbar";
|
||||||
|
import Footer from "components/layout/Footer";
|
||||||
|
import PackageTable from "components/table/PackageTable";
|
||||||
|
import LoginDialog from "components/dialogs/LoginDialog";
|
||||||
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import type { InfoResponse } from "api/types/InfoResponse";
|
||||||
|
|
||||||
|
export default function AppLayout(): React.JSX.Element {
|
||||||
|
const { setAuthState } = useAuth();
|
||||||
|
const { setRepositories } = useRepository();
|
||||||
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: info } = useQuery<InfoResponse>({
|
||||||
|
queryKey: QueryKeys.info,
|
||||||
|
queryFn: () => Client.fetchInfo(),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync info to contexts when loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (info) {
|
||||||
|
setAuthState({ enabled: info.auth, username: info.username ?? null });
|
||||||
|
setRepositories(info.repositories);
|
||||||
|
}
|
||||||
|
}, [info, setAuthState, setRepositories]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl">
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}>
|
||||||
|
<a href="https://github.com/arcan1s/ahriman" title="logo">
|
||||||
|
<img src="/static/logo.svg" width={30} height={30} alt="" />
|
||||||
|
</a>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Navbar />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<PackageTable
|
||||||
|
autorefreshIntervals={info?.autorefresh_intervals ?? []}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Footer
|
||||||
|
version={info?.version ?? ""}
|
||||||
|
docsEnabled={info?.docs_enabled ?? false}
|
||||||
|
indexUrl={info?.index_url}
|
||||||
|
onLoginClick={() => setLoginOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
frontend/src/components/layout/Footer.tsx
Normal file
79
frontend/src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Box, Link, Button, Typography } from "@mui/material";
|
||||||
|
import GitHubIcon from "@mui/icons-material/GitHub";
|
||||||
|
import LogoutIcon from "@mui/icons-material/Logout";
|
||||||
|
import HomeIcon from "@mui/icons-material/Home";
|
||||||
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
version: string;
|
||||||
|
docsEnabled: boolean;
|
||||||
|
indexUrl?: string;
|
||||||
|
onLoginClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }: FooterProps): React.JSX.Element {
|
||||||
|
const { enabled: authEnabled, username, logout } = useAuth();
|
||||||
|
|
||||||
|
const handleLogout = async (): Promise<void> => {
|
||||||
|
await logout();
|
||||||
|
window.location.href = "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="footer"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
mt: 2,
|
||||||
|
py: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
|
||||||
|
<Link href="https://github.com/arcan1s/ahriman" underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
|
<GitHubIcon fontSize="small" />
|
||||||
|
<Typography variant="body2">ahriman {version}</Typography>
|
||||||
|
</Link>
|
||||||
|
<Link href="https://github.com/arcan1s/ahriman/releases" underline="hover" color="text.secondary" variant="body2">
|
||||||
|
releases
|
||||||
|
</Link>
|
||||||
|
<Link href="https://github.com/arcan1s/ahriman/issues" underline="hover" color="text.secondary" variant="body2">
|
||||||
|
report a bug
|
||||||
|
</Link>
|
||||||
|
{docsEnabled && (
|
||||||
|
<Link href="/api-docs" underline="hover" color="text.secondary" variant="body2">
|
||||||
|
api
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{indexUrl && (
|
||||||
|
<Box>
|
||||||
|
<Link href={indexUrl} underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
|
<HomeIcon fontSize="small" />
|
||||||
|
<Typography variant="body2">repo index</Typography>
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authEnabled && (
|
||||||
|
<Box>
|
||||||
|
{username ? (
|
||||||
|
<Button size="small" startIcon={<LogoutIcon />} onClick={() => void handleLogout()} sx={{ textTransform: "none" }}>
|
||||||
|
logout ({username})
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="small" onClick={onLoginClick} sx={{ textTransform: "none" }}>
|
||||||
|
login
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/components/layout/Navbar.tsx
Normal file
33
frontend/src/components/layout/Navbar.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Tabs, Tab, Box } from "@mui/material";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
|
||||||
|
export default function Navbar(): React.JSX.Element | null {
|
||||||
|
const { repositories, current, setCurrent } = useRepository();
|
||||||
|
|
||||||
|
if (repositories.length === 0 || !current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = repositories.findIndex(
|
||||||
|
(r) => r.architecture === current.architecture && r.repository === current.repository,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||||
|
<Tabs
|
||||||
|
value={currentIndex >= 0 ? currentIndex : 0}
|
||||||
|
onChange={(_, newValue: number) => setCurrent(repositories[newValue])}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
>
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<Tab
|
||||||
|
key={`${repo.architecture}-${repo.repository}`}
|
||||||
|
label={`${repo.repository} (${repo.architecture})`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
frontend/src/components/package/BuildLogsTab.tsx
Normal file
188
frontend/src/components/package/BuildLogsTab.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
|
||||||
|
import ListIcon from "@mui/icons-material/List";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import hljs from "highlight.js/lib/core";
|
||||||
|
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
|
import "highlight.js/styles/github.css";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||||
|
import CopyButton from "components/common/CopyButton";
|
||||||
|
import type { LogRecord } from "api/types/LogRecord";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
hljs.registerLanguage("plaintext", plaintext);
|
||||||
|
|
||||||
|
interface LogVersion {
|
||||||
|
version: string;
|
||||||
|
processId: string;
|
||||||
|
created: number;
|
||||||
|
logs: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildLogsTabProps {
|
||||||
|
packageBase: string;
|
||||||
|
repo: RepositoryId;
|
||||||
|
refetchInterval: number | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertLogs(records: LogRecord[], filter?: (r: LogRecord) => boolean): string {
|
||||||
|
return records
|
||||||
|
.filter(filter || Boolean)
|
||||||
|
.map((r) => `[${new Date(r.created * 1000).toISOString()}] ${r.message}`)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuildLogsTab({ packageBase, repo, refetchInterval }: BuildLogsTabProps): React.JSX.Element {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const codeRef = useRef<HTMLElement>(null);
|
||||||
|
const preRef = useRef<HTMLElement>(null);
|
||||||
|
const initialScrollDone = useRef(false);
|
||||||
|
|
||||||
|
const { data: allLogs } = useQuery<LogRecord[]>({
|
||||||
|
queryKey: QueryKeys.logs(packageBase, repo),
|
||||||
|
queryFn: () => Client.fetchLogs(packageBase, repo),
|
||||||
|
enabled: !!packageBase,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build version selectors from all logs
|
||||||
|
const versions = useMemo<LogVersion[]>(() => {
|
||||||
|
if (!allLogs || allLogs.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped: Record<string, LogRecord & { minCreated: number }> = {};
|
||||||
|
for (const record of allLogs) {
|
||||||
|
const key = `${record.version}-${record.process_id}`;
|
||||||
|
if (!grouped[key]) {
|
||||||
|
grouped[key] = { ...record, minCreated: record.created };
|
||||||
|
} else {
|
||||||
|
grouped[key].minCreated = Math.min(grouped[key].minCreated, record.created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(grouped)
|
||||||
|
.sort((a, b) => b.minCreated - a.minCreated)
|
||||||
|
.map((v) => ({
|
||||||
|
version: v.version,
|
||||||
|
processId: v.process_id,
|
||||||
|
created: v.minCreated,
|
||||||
|
logs: convertLogs(
|
||||||
|
allLogs,
|
||||||
|
(r) => r.version === v.version && r.process_id === v.process_id,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}, [allLogs]);
|
||||||
|
|
||||||
|
// Reset active index when data changes
|
||||||
|
const [prevAllLogs, setPrevAllLogs] = useState(allLogs);
|
||||||
|
if (allLogs !== prevAllLogs) {
|
||||||
|
setPrevAllLogs(allLogs);
|
||||||
|
setActiveIndex(0);
|
||||||
|
initialScrollDone.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh active version logs when using auto-refresh
|
||||||
|
const activeVersion = versions[activeIndex];
|
||||||
|
const { data: versionLogs } = useQuery<LogRecord[]>({
|
||||||
|
queryKey: activeVersion
|
||||||
|
? QueryKeys.logsVersion(packageBase, repo, activeVersion.version, activeVersion.processId)
|
||||||
|
: ["logs-none"],
|
||||||
|
queryFn: () =>
|
||||||
|
activeVersion
|
||||||
|
? Client.fetchLogs(packageBase, repo, activeVersion.version, activeVersion.processId)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
enabled: !!activeVersion && !!refetchInterval,
|
||||||
|
refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derive displayed logs: prefer fresh polled data when available
|
||||||
|
const displayedLogs = useMemo(() => {
|
||||||
|
if (versionLogs && versionLogs.length > 0) {
|
||||||
|
return convertLogs(versionLogs);
|
||||||
|
}
|
||||||
|
return activeVersion?.logs ?? "";
|
||||||
|
}, [versionLogs, activeVersion]);
|
||||||
|
|
||||||
|
// Highlight code
|
||||||
|
useEffect(() => {
|
||||||
|
if (codeRef.current && displayedLogs) {
|
||||||
|
codeRef.current.textContent = displayedLogs;
|
||||||
|
delete codeRef.current.dataset.highlighted;
|
||||||
|
hljs.highlightElement(codeRef.current);
|
||||||
|
}
|
||||||
|
}, [displayedLogs]);
|
||||||
|
|
||||||
|
// Auto-scroll: always scroll to bottom on initial load, then only if already near bottom
|
||||||
|
useEffect(() => {
|
||||||
|
if (preRef.current && displayedLogs) {
|
||||||
|
const el = preRef.current;
|
||||||
|
if (!initialScrollDone.current) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
initialScrollDone.current = true;
|
||||||
|
} else {
|
||||||
|
const isAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 50;
|
||||||
|
if (isAtBottom) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [displayedLogs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<ListIcon />}
|
||||||
|
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||||
|
/>
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
|
>
|
||||||
|
{versions.map((v, idx) => (
|
||||||
|
<MenuItem
|
||||||
|
key={`${v.version}-${v.processId}`}
|
||||||
|
selected={idx === activeIndex}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveIndex(idx);
|
||||||
|
setAnchorEl(null);
|
||||||
|
initialScrollDone.current = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">{formatTimestamp(v.created)}</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
{versions.length === 0 && (
|
||||||
|
<MenuItem disabled>No logs available</MenuItem>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, position: "relative" }}>
|
||||||
|
<Box
|
||||||
|
ref={preRef}
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "#f6f8fa",
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
maxHeight: 400,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code ref={codeRef} className="language-plaintext" />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||||
|
<CopyButton getText={() => displayedLogs} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/components/package/ChangesTab.tsx
Normal file
60
frontend/src/components/package/ChangesTab.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import hljs from "highlight.js/lib/core";
|
||||||
|
import diff from "highlight.js/lib/languages/diff";
|
||||||
|
import "highlight.js/styles/github.css";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import CopyButton from "components/common/CopyButton";
|
||||||
|
import type { Changes } from "api/types/Changes";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
hljs.registerLanguage("diff", diff);
|
||||||
|
|
||||||
|
interface ChangesTabProps {
|
||||||
|
packageBase: string;
|
||||||
|
repo: RepositoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChangesTab({ packageBase, repo }: ChangesTabProps): React.JSX.Element {
|
||||||
|
const codeRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
const { data } = useQuery<Changes>({
|
||||||
|
queryKey: QueryKeys.changes(packageBase, repo),
|
||||||
|
queryFn: () => Client.fetchChanges(packageBase, repo),
|
||||||
|
enabled: !!packageBase,
|
||||||
|
});
|
||||||
|
|
||||||
|
const changesText = data?.changes ?? "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (codeRef.current) {
|
||||||
|
codeRef.current.textContent = changesText;
|
||||||
|
delete codeRef.current.dataset.highlighted;
|
||||||
|
hljs.highlightElement(codeRef.current);
|
||||||
|
}
|
||||||
|
}, [changesText]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ position: "relative", mt: 1 }}>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "#f6f8fa",
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
maxHeight: 400,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code ref={codeRef} className="language-diff" />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||||
|
<CopyButton getText={() => changesText} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/components/package/EventsTab.tsx
Normal file
60
frontend/src/components/package/EventsTab.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import EventDurationLineChart from "components/charts/EventDurationLineChart";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||||
|
import type { Event } from "api/types/Event";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
interface EventsTabProps {
|
||||||
|
packageBase: string;
|
||||||
|
repo: RepositoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventRow {
|
||||||
|
id: number;
|
||||||
|
timestamp: string;
|
||||||
|
event: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: GridColDef<EventRow>[] = [
|
||||||
|
{ field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
|
||||||
|
{ field: "event", headerName: "event", flex: 1 },
|
||||||
|
{ field: "message", headerName: "description", flex: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EventsTab({ packageBase, repo }: EventsTabProps): React.JSX.Element {
|
||||||
|
const { data: events = [] } = useQuery<Event[]>({
|
||||||
|
queryKey: QueryKeys.events(repo, packageBase),
|
||||||
|
queryFn: () => Client.fetchEvents(repo, packageBase, 30),
|
||||||
|
enabled: !!packageBase,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: EventRow[] = events.map((e, idx) => ({
|
||||||
|
id: idx,
|
||||||
|
timestamp: formatTimestamp(e.created),
|
||||||
|
event: e.event,
|
||||||
|
message: e.message ?? "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<EventDurationLineChart events={events} />
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
density="compact"
|
||||||
|
initialState={{
|
||||||
|
sorting: { sortModel: [{ field: "timestamp", sort: "desc" }] },
|
||||||
|
}}
|
||||||
|
pageSizeOptions={[10, 25]}
|
||||||
|
sx={{ height: 300, mt: 1 }}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
frontend/src/components/table/PackageTable.tsx
Normal file
317
frontend/src/components/table/PackageTable.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
DataGrid,
|
||||||
|
GridToolbarQuickFilter,
|
||||||
|
GridToolbarFilterButton,
|
||||||
|
type GridColDef,
|
||||||
|
type GridFilterModel,
|
||||||
|
type GridRowSelectionModel,
|
||||||
|
type GridRenderCellParams,
|
||||||
|
} from "@mui/x-data-grid";
|
||||||
|
import { Box, Link, Stack } from "@mui/material";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import PackageTableToolbar from "components/table/PackageTableToolbar";
|
||||||
|
import StatusCell from "components/table/StatusCell";
|
||||||
|
import DashboardDialog from "components/dialogs/DashboardDialog";
|
||||||
|
import PackageAddDialog from "components/dialogs/PackageAddDialog";
|
||||||
|
import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog";
|
||||||
|
import KeyImportDialog from "components/dialogs/KeyImportDialog";
|
||||||
|
import PackageInfoDialog from "components/dialogs/PackageInfoDialog";
|
||||||
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
import { useNotification } from "hooks/useNotification";
|
||||||
|
import { useAutoRefresh } from "hooks/useAutoRefresh";
|
||||||
|
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { ApiError } from "api/client/ApiError";
|
||||||
|
import { QueryKeys } from "api/QueryKeys";
|
||||||
|
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||||
|
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||||
|
import type { InternalStatus } from "api/types/InternalStatus";
|
||||||
|
import type { PackageRow } from "api/types/PackageRow";
|
||||||
|
import type { PackageStatus } from "api/types/PackageStatus";
|
||||||
|
|
||||||
|
interface PackageTableProps {
|
||||||
|
autorefreshIntervals: AutoRefreshInterval[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
Object.values(pkg.packages)
|
||||||
|
.flatMap((p) => p[property] ?? []),
|
||||||
|
),
|
||||||
|
].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRow(ps: PackageStatus): PackageRow {
|
||||||
|
return {
|
||||||
|
id: ps.package.base,
|
||||||
|
base: ps.package.base,
|
||||||
|
webUrl: ps.package.remote.web_url ?? undefined,
|
||||||
|
version: ps.package.version,
|
||||||
|
packages: Object.keys(ps.package.packages).sort(),
|
||||||
|
groups: extractListProperties(ps.package, "groups"),
|
||||||
|
licenses: extractListProperties(ps.package, "licenses"),
|
||||||
|
packager: ps.package.packager ?? "",
|
||||||
|
timestamp: formatTimestamp(ps.status.timestamp),
|
||||||
|
timestampValue: ps.status.timestamp,
|
||||||
|
status: ps.status.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
function SearchToolbar(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 1, py: 0.5 }}>
|
||||||
|
<GridToolbarFilterButton />
|
||||||
|
<GridToolbarQuickFilter debounceMs={300} sx={{ flex: 1 }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageTable({ autorefreshIntervals }: PackageTableProps): React.JSX.Element {
|
||||||
|
const { current } = useRepository();
|
||||||
|
const { enabled: authEnabled, username } = useAuth();
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const hasAuth = !authEnabled || username !== null;
|
||||||
|
|
||||||
|
const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
|
||||||
|
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval);
|
||||||
|
|
||||||
|
const [selectionModel, setSelectionModel] = useState<GridRowSelectionModel>([]);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState<string | null>(null);
|
||||||
|
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
|
||||||
|
pageSize: 10,
|
||||||
|
page: 0,
|
||||||
|
});
|
||||||
|
const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>(
|
||||||
|
"ahriman-packages-columns",
|
||||||
|
{ groups: false, licenses: false, packager: false },
|
||||||
|
);
|
||||||
|
const [filterModel, setFilterModel] = useLocalStorage<GridFilterModel>(
|
||||||
|
"ahriman-packages-filters",
|
||||||
|
{ items: [] },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pause auto-refresh when dialog is open
|
||||||
|
const isDialogOpen = dialogOpen !== null || selectedPackage !== null;
|
||||||
|
const setPaused = autoRefresh.setPaused;
|
||||||
|
useEffect(() => {
|
||||||
|
setPaused(isDialogOpen);
|
||||||
|
}, [isDialogOpen, setPaused]);
|
||||||
|
|
||||||
|
const { data: packages = [], isLoading } = useQuery<PackageStatus[]>({
|
||||||
|
queryKey: current ? QueryKeys.packages(current) : ["packages"],
|
||||||
|
queryFn: () => (current ? Client.fetchPackages(current) : Promise.resolve([])),
|
||||||
|
enabled: !!current,
|
||||||
|
refetchInterval: autoRefresh.refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: status } = useQuery<InternalStatus>({
|
||||||
|
queryKey: current ? QueryKeys.status(current) : ["status"],
|
||||||
|
queryFn: () => Client.fetchStatus(current!),
|
||||||
|
enabled: !!current,
|
||||||
|
refetchInterval: autoRefresh.refetchInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = useMemo(() => packages.map(toRow), [packages]);
|
||||||
|
|
||||||
|
const handleReload = useCallback(() => {
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
|
||||||
|
}, [current, queryClient]);
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(async () => {
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selected = selectionModel as string[];
|
||||||
|
try {
|
||||||
|
if (selected.length === 0) {
|
||||||
|
await Client.updatePackages(current, { packages: [] });
|
||||||
|
showSuccess("Success", "Repository update has been run");
|
||||||
|
} else {
|
||||||
|
await Client.addPackages(current, { packages: selected });
|
||||||
|
showSuccess("Success", `Run update for packages ${selected.join(", ")}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Packages update failed: ${detail}`);
|
||||||
|
}
|
||||||
|
}, [current, selectionModel, showSuccess, showError]);
|
||||||
|
|
||||||
|
const handleRefreshDb = useCallback(async () => {
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Client.updatePackages(current, { packages: [], refresh: true, aur: false, local: false, manual: false });
|
||||||
|
showSuccess("Success", "Pacman database update has been requested");
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Could not update pacman databases: ${detail}`);
|
||||||
|
}
|
||||||
|
}, [current, showSuccess, showError]);
|
||||||
|
|
||||||
|
const handleRemove = useCallback(async () => {
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selected = selectionModel as string[];
|
||||||
|
if (selected.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Client.removePackages(current, selected);
|
||||||
|
showSuccess("Success", `Packages ${selected.join(", ")} have been removed`);
|
||||||
|
setSelectionModel([]);
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||||
|
showError("Action failed", `Could not remove packages: ${detail}`);
|
||||||
|
}
|
||||||
|
}, [current, selectionModel, showSuccess, showError]);
|
||||||
|
|
||||||
|
const columns: GridColDef<PackageRow>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
field: "base",
|
||||||
|
headerName: "package base",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 150,
|
||||||
|
renderCell: (params: GridRenderCellParams<PackageRow>) =>
|
||||||
|
params.row.webUrl ? (
|
||||||
|
<Link href={params.row.webUrl} target="_blank" rel="noopener" underline="hover">
|
||||||
|
{params.value as string}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
params.value as string
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
|
||||||
|
{
|
||||||
|
field: "packages",
|
||||||
|
headerName: "packages",
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 120,
|
||||||
|
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
||||||
|
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.packages ?? []).map((item, i, arr) => (
|
||||||
|
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
|
||||||
|
)),
|
||||||
|
sortComparator: (v1: string, v2: string) => v1.localeCompare(v2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "groups",
|
||||||
|
headerName: "groups",
|
||||||
|
width: 150,
|
||||||
|
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
||||||
|
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.groups ?? []).map((item, i, arr) => (
|
||||||
|
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "licenses",
|
||||||
|
headerName: "licenses",
|
||||||
|
width: 150,
|
||||||
|
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
||||||
|
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.licenses ?? []).map((item, i, arr) => (
|
||||||
|
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
{ field: "packager", headerName: "packager", width: 150 },
|
||||||
|
{
|
||||||
|
field: "timestamp",
|
||||||
|
headerName: "last update",
|
||||||
|
width: 180,
|
||||||
|
align: "right",
|
||||||
|
headerAlign: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "status",
|
||||||
|
headerName: "status",
|
||||||
|
width: 120,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
renderCell: (params: GridRenderCellParams<PackageRow>) => <StatusCell status={params.row.status} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<PackageTableToolbar
|
||||||
|
hasSelection={(selectionModel as string[]).length > 0}
|
||||||
|
hasAuth={hasAuth}
|
||||||
|
repoStatus={status?.status.status}
|
||||||
|
autorefreshIntervals={autorefreshIntervals}
|
||||||
|
autoRefreshEnabled={autoRefresh.enabled}
|
||||||
|
autoRefreshInterval={autoRefresh.interval}
|
||||||
|
onAutoRefreshToggle={autoRefresh.setEnabled}
|
||||||
|
onAutoRefreshIntervalChange={autoRefresh.setInterval}
|
||||||
|
onDashboardClick={() => setDialogOpen("dashboard")}
|
||||||
|
onAddClick={() => setDialogOpen("add")}
|
||||||
|
onUpdateClick={() => void handleUpdate()}
|
||||||
|
onRefreshDbClick={() => void handleRefreshDb()}
|
||||||
|
onRebuildClick={() => setDialogOpen("rebuild")}
|
||||||
|
onRemoveClick={() => void handleRemove()}
|
||||||
|
onKeyImportClick={() => setDialogOpen("keyImport")}
|
||||||
|
onReloadClick={handleReload}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
loading={isLoading}
|
||||||
|
getRowHeight={() => "auto"}
|
||||||
|
checkboxSelection
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
rowSelectionModel={selectionModel}
|
||||||
|
onRowSelectionModelChange={setSelectionModel}
|
||||||
|
paginationModel={paginationModel}
|
||||||
|
onPaginationModelChange={setPaginationModel}
|
||||||
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||||
|
columnVisibilityModel={columnVisibility}
|
||||||
|
onColumnVisibilityModelChange={setColumnVisibility}
|
||||||
|
filterModel={filterModel}
|
||||||
|
onFilterModelChange={setFilterModel}
|
||||||
|
slots={{ toolbar: SearchToolbar }}
|
||||||
|
initialState={{
|
||||||
|
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
|
||||||
|
}}
|
||||||
|
onRowClick={(params: { row: PackageRow }, event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
// Don't open info dialog when clicking checkbox or link
|
||||||
|
if (target.closest("input[type=\"checkbox\"]") || target.closest("a")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedPackage(params.row.id);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
"& .MuiDataGrid-row": { cursor: "pointer" },
|
||||||
|
height: 600,
|
||||||
|
}}
|
||||||
|
density="compact"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DashboardDialog open={dialogOpen === "dashboard"} onClose={() => setDialogOpen(null)} />
|
||||||
|
<PackageAddDialog open={dialogOpen === "add"} onClose={() => setDialogOpen(null)} />
|
||||||
|
<PackageRebuildDialog open={dialogOpen === "rebuild"} onClose={() => setDialogOpen(null)} />
|
||||||
|
<KeyImportDialog open={dialogOpen === "keyImport"} onClose={() => setDialogOpen(null)} />
|
||||||
|
<PackageInfoDialog
|
||||||
|
packageBase={selectedPackage}
|
||||||
|
open={selectedPackage !== null}
|
||||||
|
onClose={() => setSelectedPackage(null)}
|
||||||
|
autorefreshIntervals={autorefreshIntervals}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
frontend/src/components/table/PackageTableToolbar.tsx
Normal file
133
frontend/src/components/table/PackageTableToolbar.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button, Menu, MenuItem, Box, Tooltip, IconButton, Divider } from "@mui/material";
|
||||||
|
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||||
|
import InventoryIcon from "@mui/icons-material/Inventory";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
|
import ReplayIcon from "@mui/icons-material/Replay";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import VpnKeyIcon from "@mui/icons-material/VpnKey";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import AutoRefreshControl from "components/common/AutoRefreshControl";
|
||||||
|
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
import { StatusColors } from "theme/status/StatusColors";
|
||||||
|
|
||||||
|
interface PackageTableToolbarProps {
|
||||||
|
hasSelection: boolean;
|
||||||
|
hasAuth: boolean;
|
||||||
|
repoStatus?: BuildStatus;
|
||||||
|
autorefreshIntervals: AutoRefreshInterval[];
|
||||||
|
autoRefreshEnabled: boolean;
|
||||||
|
autoRefreshInterval: number;
|
||||||
|
onAutoRefreshToggle: (enabled: boolean) => void;
|
||||||
|
onAutoRefreshIntervalChange: (interval: number) => void;
|
||||||
|
onDashboardClick: () => void;
|
||||||
|
onAddClick: () => void;
|
||||||
|
onUpdateClick: () => void;
|
||||||
|
onRefreshDbClick: () => void;
|
||||||
|
onRebuildClick: () => void;
|
||||||
|
onRemoveClick: () => void;
|
||||||
|
onKeyImportClick: () => void;
|
||||||
|
onReloadClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PackageTableToolbar({
|
||||||
|
hasSelection,
|
||||||
|
hasAuth,
|
||||||
|
repoStatus,
|
||||||
|
autorefreshIntervals,
|
||||||
|
autoRefreshEnabled,
|
||||||
|
autoRefreshInterval,
|
||||||
|
onAutoRefreshToggle,
|
||||||
|
onAutoRefreshIntervalChange,
|
||||||
|
onDashboardClick,
|
||||||
|
onAddClick,
|
||||||
|
onUpdateClick,
|
||||||
|
onRefreshDbClick,
|
||||||
|
onRebuildClick,
|
||||||
|
onRemoveClick,
|
||||||
|
onKeyImportClick,
|
||||||
|
onReloadClick,
|
||||||
|
}: PackageTableToolbarProps): React.JSX.Element {
|
||||||
|
const [packagesAnchorEl, setPackagesAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", gap: 1, mb: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<Tooltip title="System health">
|
||||||
|
<IconButton
|
||||||
|
onClick={onDashboardClick}
|
||||||
|
sx={{
|
||||||
|
borderColor: repoStatus ? StatusColors[repoStatus] : undefined,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
color: repoStatus ? StatusColors[repoStatus] : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{hasAuth && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<InventoryIcon />}
|
||||||
|
onClick={(e) => setPackagesAnchorEl(e.currentTarget)}
|
||||||
|
>
|
||||||
|
packages
|
||||||
|
</Button>
|
||||||
|
<Menu
|
||||||
|
anchorEl={packagesAnchorEl}
|
||||||
|
open={Boolean(packagesAnchorEl)}
|
||||||
|
onClose={() => setPackagesAnchorEl(null)}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
setPackagesAnchorEl(null); onAddClick();
|
||||||
|
}}>
|
||||||
|
<AddIcon fontSize="small" sx={{ mr: 1 }} /> add
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
setPackagesAnchorEl(null); onUpdateClick();
|
||||||
|
}}>
|
||||||
|
<PlayArrowIcon fontSize="small" sx={{ mr: 1 }} /> update
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
setPackagesAnchorEl(null); onRefreshDbClick();
|
||||||
|
}}>
|
||||||
|
<DownloadIcon fontSize="small" sx={{ mr: 1 }} /> update pacman databases
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
setPackagesAnchorEl(null); onRebuildClick();
|
||||||
|
}}>
|
||||||
|
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
|
||||||
|
</MenuItem>
|
||||||
|
<Divider />
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
setPackagesAnchorEl(null); onRemoveClick();
|
||||||
|
}} disabled={!hasSelection}>
|
||||||
|
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<Button variant="contained" color="info" startIcon={<VpnKeyIcon />} onClick={onKeyImportClick}>
|
||||||
|
import key
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="outlined" color="secondary" startIcon={<RefreshIcon />} onClick={onReloadClick}>
|
||||||
|
reload
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AutoRefreshControl
|
||||||
|
intervals={autorefreshIntervals}
|
||||||
|
enabled={autoRefreshEnabled}
|
||||||
|
currentInterval={autoRefreshInterval}
|
||||||
|
onToggle={onAutoRefreshToggle}
|
||||||
|
onIntervalChange={onAutoRefreshIntervalChange}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/components/table/StatusCell.tsx
Normal file
22
frontend/src/components/table/StatusCell.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { Chip } from "@mui/material";
|
||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
import { StatusColors } from "theme/status/StatusColors";
|
||||||
|
|
||||||
|
interface StatusCellProps {
|
||||||
|
status: BuildStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusCell({ status }: StatusCellProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={status}
|
||||||
|
size="small"
|
||||||
|
sx={{
|
||||||
|
backgroundColor: StatusColors[status],
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
frontend/src/contexts/AuthContext.ts
Normal file
14
frontend/src/contexts/AuthContext.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
enabled: boolean;
|
||||||
|
username: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthContextValue extends AuthState {
|
||||||
|
setAuthState: (state: AuthState) => void;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
23
frontend/src/contexts/AuthProvider.tsx
Normal file
23
frontend/src/contexts/AuthProvider.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React, { useState, useCallback, type ReactNode } from "react";
|
||||||
|
import { Client } from "api/client/AhrimanClient";
|
||||||
|
import { AuthContext } from "contexts/AuthContext";
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
|
const [state, setState] = useState({ enabled: false, username: null as string | null });
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
await Client.login({ username, password });
|
||||||
|
setState((prev) => ({ ...prev, username }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const doLogout = useCallback(async () => {
|
||||||
|
await Client.logout();
|
||||||
|
setState((prev) => ({ ...prev, username: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ ...state, setAuthState: setState, login, logout: doLogout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
frontend/src/contexts/NotificationContext.ts
Normal file
8
frontend/src/contexts/NotificationContext.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export interface NotificationContextValue {
|
||||||
|
showSuccess: (title: string, message: string) => void;
|
||||||
|
showError: (title: string, message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||||
54
frontend/src/contexts/NotificationProvider.tsx
Normal file
54
frontend/src/contexts/NotificationProvider.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React, { useState, useCallback, type ReactNode } from "react";
|
||||||
|
import { Snackbar, Alert, type AlertColor } from "@mui/material";
|
||||||
|
import { NotificationContext } from "contexts/NotificationContext";
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
severity: AlertColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0;
|
||||||
|
|
||||||
|
export function NotificationProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
|
||||||
|
const addNotification = useCallback((title: string, message: string, severity: AlertColor) => {
|
||||||
|
const id = nextId++;
|
||||||
|
setNotifications((prev) => [...prev, { id, title, message, severity }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeNotification = useCallback((id: number) => {
|
||||||
|
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSuccess = useCallback(
|
||||||
|
(title: string, message: string) => addNotification(title, message, "success"),
|
||||||
|
[addNotification],
|
||||||
|
);
|
||||||
|
const showError = useCallback(
|
||||||
|
(title: string, message: string) => addNotification(title, message, "error"),
|
||||||
|
[addNotification],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider value={{ showSuccess, showError }}>
|
||||||
|
{children}
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<Snackbar
|
||||||
|
key={n.id}
|
||||||
|
open
|
||||||
|
autoHideDuration={5000}
|
||||||
|
onClose={() => removeNotification(n.id)}
|
||||||
|
anchorOrigin={{ vertical: "top", horizontal: "center" }}
|
||||||
|
>
|
||||||
|
<Alert onClose={() => removeNotification(n.id)} severity={n.severity} variant="filled" sx={{ width: "100%", maxWidth: 500 }}>
|
||||||
|
<strong>{n.title}</strong>
|
||||||
|
{n.message && ` - ${n.message}`}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
))}
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/src/contexts/RepositoryContext.ts
Normal file
11
frontend/src/contexts/RepositoryContext.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
|
||||||
|
export interface RepositoryContextValue {
|
||||||
|
repositories: RepositoryId[];
|
||||||
|
current: RepositoryId | null;
|
||||||
|
setRepositories: (repos: RepositoryId[]) => void;
|
||||||
|
setCurrent: (repo: RepositoryId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RepositoryContext = createContext<RepositoryContextValue | null>(null);
|
||||||
47
frontend/src/contexts/RepositoryProvider.tsx
Normal file
47
frontend/src/contexts/RepositoryProvider.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React, { useState, useCallback, useEffect, type ReactNode } from "react";
|
||||||
|
import type { RepositoryId } from "api/types/RepositoryId";
|
||||||
|
import { RepositoryContext } from "contexts/RepositoryContext";
|
||||||
|
|
||||||
|
function repoId(repo: RepositoryId): string {
|
||||||
|
return `${repo.architecture}-${repo.repository}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
|
const [repositories, setRepositories] = useState<RepositoryId[]>([]);
|
||||||
|
const [current, setCurrentState] = useState<RepositoryId | null>(null);
|
||||||
|
|
||||||
|
const setCurrent = useCallback((repo: RepositoryId) => {
|
||||||
|
setCurrentState(repo);
|
||||||
|
window.location.hash = repoId(repo);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync current repository when repositories list changes
|
||||||
|
const [prevRepositories, setPrevRepositories] = useState(repositories);
|
||||||
|
if (repositories !== prevRepositories) {
|
||||||
|
setPrevRepositories(repositories);
|
||||||
|
if (repositories.length > 0) {
|
||||||
|
const hash = window.location.hash.replace("#", "");
|
||||||
|
const match = repositories.find((r) => repoId(r) === hash);
|
||||||
|
setCurrentState(match || repositories[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for hash changes
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (): void => {
|
||||||
|
const hash = window.location.hash.replace("#", "");
|
||||||
|
const match = repositories.find((r) => repoId(r) === hash);
|
||||||
|
if (match) {
|
||||||
|
setCurrentState(match);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("hashchange", handler);
|
||||||
|
return () => window.removeEventListener("hashchange", handler);
|
||||||
|
}, [repositories]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RepositoryContext.Provider value={{ repositories, current, setRepositories, setCurrent }}>
|
||||||
|
{children}
|
||||||
|
</RepositoryContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/hooks/useAuth.ts
Normal file
10
frontend/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { AuthContext, type AuthContextValue } from "contexts/AuthContext";
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
52
frontend/src/hooks/useAutoRefresh.ts
Normal file
52
frontend/src/hooks/useAutoRefresh.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useState, useCallback, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||||
|
|
||||||
|
interface AutoRefreshState {
|
||||||
|
enabled: boolean;
|
||||||
|
interval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoRefreshResult {
|
||||||
|
enabled: boolean;
|
||||||
|
interval: number;
|
||||||
|
paused: boolean;
|
||||||
|
refetchInterval: number | false;
|
||||||
|
setEnabled: (enabled: boolean) => void;
|
||||||
|
setInterval: (interval: number) => void;
|
||||||
|
setPaused: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoRefresh(key: string, defaultInterval: number = 0): AutoRefreshResult {
|
||||||
|
const [stored, setStored] = useLocalStorage<AutoRefreshState>(`ahriman-${key}`, {
|
||||||
|
enabled: defaultInterval > 0,
|
||||||
|
interval: defaultInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
|
const refetchInterval: number | false = stored.enabled && !paused && stored.interval > 0 ? stored.interval : false;
|
||||||
|
|
||||||
|
const setEnabled = useCallback(
|
||||||
|
(enabled: boolean) => {
|
||||||
|
setStored((prev) => ({ ...prev, enabled }));
|
||||||
|
},
|
||||||
|
[setStored],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setInterval = useCallback(
|
||||||
|
(interval: number) => {
|
||||||
|
setStored({ enabled: true, interval });
|
||||||
|
},
|
||||||
|
[setStored],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: stored.enabled,
|
||||||
|
interval: stored.interval,
|
||||||
|
paused,
|
||||||
|
refetchInterval,
|
||||||
|
setEnabled,
|
||||||
|
setInterval,
|
||||||
|
setPaused,
|
||||||
|
};
|
||||||
|
}
|
||||||
12
frontend/src/hooks/useDebounce.ts
Normal file
12
frontend/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => setDebouncedValue(value), delay);
|
||||||
|
return () => clearTimeout(handler);
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
25
frontend/src/hooks/useLocalStorage.ts
Normal file
25
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState, useCallback, type Dispatch, type SetStateAction } from "react";
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(key: string, initialValue: T): [T, Dispatch<SetStateAction<T>>] {
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key);
|
||||||
|
return item ? (JSON.parse(item) as T) : initialValue;
|
||||||
|
} catch {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setValue: Dispatch<SetStateAction<T>> = useCallback(
|
||||||
|
(value) => {
|
||||||
|
setStoredValue((prev) => {
|
||||||
|
const nextValue = value instanceof Function ? value(prev) : value;
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(nextValue));
|
||||||
|
return nextValue;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[key],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [storedValue, setValue];
|
||||||
|
}
|
||||||
10
frontend/src/hooks/useNotification.ts
Normal file
10
frontend/src/hooks/useNotification.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { NotificationContext, type NotificationContextValue } from "contexts/NotificationContext";
|
||||||
|
|
||||||
|
export function useNotification(): NotificationContextValue {
|
||||||
|
const ctx = useContext(NotificationContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useNotification must be used within NotificationProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
10
frontend/src/hooks/useRepository.ts
Normal file
10
frontend/src/hooks/useRepository.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { RepositoryContext, type RepositoryContextValue } from "contexts/RepositoryContext";
|
||||||
|
|
||||||
|
export function useRepository(): RepositoryContextValue {
|
||||||
|
const ctx = useContext(RepositoryContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useRepository must be used within RepositoryProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
67
frontend/src/theme/Theme.ts
Normal file
67
frontend/src/theme/Theme.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
// Bootswatch Cosmo-inspired palette
|
||||||
|
const Theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
primary: {
|
||||||
|
main: "#2780e3",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: "#373a3c",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
main: "#3fb618",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
main: "#ff0039",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
main: "#ff7518",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
main: "#9954bb",
|
||||||
|
contrastText: "#fff",
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: "#fff",
|
||||||
|
paper: "#fff",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: "#373a3c",
|
||||||
|
secondary: "#6c757d",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: "\"Source Sans Pro\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
textTransform: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiTab: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
textTransform: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiDialog: {
|
||||||
|
defaultProps: {
|
||||||
|
maxWidth: "lg",
|
||||||
|
fullWidth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Theme;
|
||||||
9
frontend/src/theme/status/ChartColors.ts
Normal file
9
frontend/src/theme/status/ChartColors.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
|
||||||
|
export const ChartColors: Record<BuildStatus, string> = {
|
||||||
|
unknown: "rgb(55, 58, 60)",
|
||||||
|
pending: "rgb(255, 117, 24)",
|
||||||
|
building: "rgb(255, 117, 24)",
|
||||||
|
failed: "rgb(255, 0, 57)",
|
||||||
|
success: "rgb(63, 182, 24)",
|
||||||
|
};
|
||||||
9
frontend/src/theme/status/StatusBackgrounds.ts
Normal file
9
frontend/src/theme/status/StatusBackgrounds.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
|
||||||
|
export const StatusBackgrounds: Record<BuildStatus, string> = {
|
||||||
|
unknown: "rgba(55, 58, 60, 0.1)",
|
||||||
|
pending: "rgba(255, 117, 24, 0.1)",
|
||||||
|
building: "rgba(255, 117, 24, 0.1)",
|
||||||
|
failed: "rgba(255, 0, 57, 0.1)",
|
||||||
|
success: "rgba(63, 182, 24, 0.1)",
|
||||||
|
};
|
||||||
9
frontend/src/theme/status/StatusColors.ts
Normal file
9
frontend/src/theme/status/StatusColors.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
|
||||||
|
export const StatusColors: Record<BuildStatus, string> = {
|
||||||
|
unknown: "#373a3c",
|
||||||
|
pending: "#ff7518",
|
||||||
|
building: "#ff7518",
|
||||||
|
failed: "#ff0039",
|
||||||
|
success: "#3fb618",
|
||||||
|
};
|
||||||
9
frontend/src/theme/status/StatusHeaderStyles.ts
Normal file
9
frontend/src/theme/status/StatusHeaderStyles.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { BuildStatus } from "api/types/BuildStatus";
|
||||||
|
|
||||||
|
export const StatusHeaderStyles: Record<BuildStatus, { backgroundColor: string; color: string }> = {
|
||||||
|
unknown: { backgroundColor: "#373a3c", color: "#fff" },
|
||||||
|
pending: { backgroundColor: "#ff7518", color: "#fff" },
|
||||||
|
building: { backgroundColor: "#ff7518", color: "#fff" },
|
||||||
|
failed: { backgroundColor: "#ff0039", color: "#fff" },
|
||||||
|
success: { backgroundColor: "#3fb618", color: "#fff" },
|
||||||
|
};
|
||||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
41
frontend/vite.config.ts
Normal file
41
frontend/vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import type { Plugin } from "vite";
|
||||||
|
|
||||||
|
function renameIndexHtml(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "rename-index-html",
|
||||||
|
closeBundle() {
|
||||||
|
const outDir = path.resolve(__dirname, "../package/share/ahriman/templates");
|
||||||
|
const src = path.join(outDir, "index.html");
|
||||||
|
const dest = path.join(outDir, "build-status.jinja2");
|
||||||
|
if (fs.existsSync(src)) {
|
||||||
|
fs.renameSync(src, dest);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tsconfigPaths(), renameIndexHtml()],
|
||||||
|
base: "/",
|
||||||
|
build: {
|
||||||
|
outDir: path.resolve(__dirname, "../package/share/ahriman/templates"),
|
||||||
|
emptyOutDir: false,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: "static/[name].js",
|
||||||
|
chunkFileNames: "static/[name].js",
|
||||||
|
assetFileNames: "static/[name].[ext]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,191 +1,14 @@
|
|||||||
<!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>
|
||||||
|
<link rel="icon" href="/static/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<script type="module" crossorigin src="/static/index.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/static/index.css">
|
||||||
{% include "utils/style.jinja2" %}
|
|
||||||
{% include "user-style.jinja2" ignore missing %}
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
{% 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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
<script>
|
|
||||||
const alertPlaceholder = document.getElementById("alert-placeholder");
|
|
||||||
|
|
||||||
function createAlert(title, message, clz, action, id) {
|
|
||||||
id ??= md5(title + message); // MD5 id from the content
|
|
||||||
if (alertPlaceholder.querySelector(`#alert-${id}`)) {
|
|
||||||
return; // check if there are duplicates
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapper = document.createElement("div");
|
|
||||||
wrapper.id = `alert-${id}`;
|
|
||||||
wrapper.classList.add("toast", clz);
|
|
||||||
wrapper.role = "alert";
|
|
||||||
wrapper.ariaLive = "assertive";
|
|
||||||
wrapper.ariaAtomic = "true";
|
|
||||||
wrapper.style.width = "500px"; // 500px is default modal size
|
|
||||||
|
|
||||||
const header = document.createElement("div");
|
|
||||||
header.classList.add("toast-header");
|
|
||||||
header.innerHTML = `<strong class="me-auto">${safe(title)}</strong> <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="close"></button>`;
|
|
||||||
wrapper.appendChild(header);
|
|
||||||
|
|
||||||
const body = document.createElement("div");
|
|
||||||
body.classList.add("toast-body", "text-bg-light");
|
|
||||||
body.innerText = message;
|
|
||||||
wrapper.appendChild(body);
|
|
||||||
|
|
||||||
alertPlaceholder.appendChild(wrapper);
|
|
||||||
const toast = new bootstrap.Toast(wrapper);
|
|
||||||
wrapper.addEventListener("hidden.bs.toast", _ => {
|
|
||||||
wrapper.remove(); // bootstrap doesn't remove elements
|
|
||||||
(action || reload)();
|
|
||||||
});
|
|
||||||
toast.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFailure(title, description, error) {
|
|
||||||
let details;
|
|
||||||
try {
|
|
||||||
details = JSON.parse(error.text).error; // execution handler json error response
|
|
||||||
} catch (_) {
|
|
||||||
details = error.text ?? error.message ?? error;
|
|
||||||
}
|
|
||||||
createAlert(title, description(details), "text-bg-danger");
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSuccess(title, description, action) {
|
|
||||||
createAlert(title, description, "text-bg-success", action);
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
<div id="dashboard-modal" tabindex="-1" role="dialog" class="modal fade">
|
|
||||||
<div class="modal-dialog modal-xl" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div id="dashboard-modal-header" class="modal-header">
|
|
||||||
<h4 class="modal-title">System health</h4>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="form-group row mt-2">
|
|
||||||
<div class="col-4 col-lg-2" style="text-align: right">Repository name</div>
|
|
||||||
<div id="dashboard-name" class="col-8 col-lg-3"></div>
|
|
||||||
<div class="col-4 col-lg-2" style="text-align: right">Repository architecture</div>
|
|
||||||
<div id="dashboard-architecture" class="col-8 col-lg-3"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group row mt-2">
|
|
||||||
<div class="col-4 col-lg-2" style="text-align: right">Current status</div>
|
|
||||||
<div id="dashboard-status" class="col-8 col-lg-3"></div>
|
|
||||||
<div class="col-4 col-lg-2" style="text-align: right">Updated at</div>
|
|
||||||
<div id="dashboard-status-timestamp" class="col-8 col-lg-3"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="dashboard-canvas" class="form-group row mt-2">
|
|
||||||
<div class="col-8 col-lg-6">
|
|
||||||
<canvas id="dashboard-packages-count-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
<div class="col-8 col-lg-6">
|
|
||||||
<canvas id="dashboard-packages-statuses-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal"><i class="bi bi-x"></i><span class="d-none d-sm-inline"> close</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const dashboardModal = document.getElementById("dashboard-modal");
|
|
||||||
const dashboardModalHeader = document.getElementById("dashboard-modal-header");
|
|
||||||
|
|
||||||
const dashboardName = document.getElementById("dashboard-name");
|
|
||||||
const dashboardArchitecture = document.getElementById("dashboard-architecture");
|
|
||||||
const dashboardStatus = document.getElementById("dashboard-status");
|
|
||||||
const dashboardStatusTimestamp = document.getElementById("dashboard-status-timestamp");
|
|
||||||
|
|
||||||
const dashboardCanvas = document.getElementById("dashboard-canvas");
|
|
||||||
const dashboardPackagesStatusesChartCanvas = document.getElementById("dashboard-packages-statuses-chart");
|
|
||||||
let dashboardPackagesStatusesChart = null;
|
|
||||||
const dashboardPackagesCountChartCanvas = document.getElementById("dashboard-packages-count-chart");
|
|
||||||
let dashboardPackagesCountChart = null;
|
|
||||||
|
|
||||||
function statusLoad() {
|
|
||||||
const badgeClass = status => {
|
|
||||||
if (status === "pending") return "btn-outline-warning";
|
|
||||||
if (status === "building") return "btn-outline-warning";
|
|
||||||
if (status === "failed") return "btn-outline-danger";
|
|
||||||
if (status === "success") return "btn-outline-success";
|
|
||||||
return "btn-outline-secondary";
|
|
||||||
};
|
|
||||||
|
|
||||||
makeRequest(
|
|
||||||
"/api/v1/status",
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
architecture: repository.architecture,
|
|
||||||
repository: repository.repository,
|
|
||||||
},
|
|
||||||
convert: response => response.json(),
|
|
||||||
},
|
|
||||||
data => {
|
|
||||||
versionBadge.innerHTML = `<i class="bi bi-github"></i> ahriman ${safe(data.version)}`;
|
|
||||||
|
|
||||||
dashboardButton.classList.remove(...dashboardButton.classList);
|
|
||||||
dashboardButton.classList.add("btn");
|
|
||||||
dashboardButton.classList.add(badgeClass(data.status.status));
|
|
||||||
|
|
||||||
dashboardModalHeader.classList.remove(...dashboardModalHeader.classList);
|
|
||||||
dashboardModalHeader.classList.add("modal-header");
|
|
||||||
headerClass(data.status.status).forEach(clz => dashboardModalHeader.classList.add(clz));
|
|
||||||
|
|
||||||
dashboardName.textContent = data.repository;
|
|
||||||
dashboardArchitecture.textContent = data.architecture;
|
|
||||||
dashboardStatus.textContent = data.status.status;
|
|
||||||
dashboardStatusTimestamp.textContent = new Date(1000 * data.status.timestamp).toISOStringShort();
|
|
||||||
|
|
||||||
if (dashboardPackagesStatusesChart) {
|
|
||||||
const labels = [
|
|
||||||
"unknown",
|
|
||||||
"pending",
|
|
||||||
"building",
|
|
||||||
"failed",
|
|
||||||
"success",
|
|
||||||
];
|
|
||||||
dashboardPackagesStatusesChart.config.data = {
|
|
||||||
labels: labels,
|
|
||||||
datasets: [{
|
|
||||||
label: "packages in status",
|
|
||||||
data: labels.map(label => data.packages[label]),
|
|
||||||
backgroundColor: [
|
|
||||||
"rgb(55, 58, 60)",
|
|
||||||
"rgb(255, 117, 24)",
|
|
||||||
"rgb(255, 117, 24)",
|
|
||||||
"rgb(255, 0, 57)",
|
|
||||||
"rgb(63, 182, 24)", // copy-paste from current style
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
dashboardPackagesStatusesChart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dashboardPackagesCountChart) {
|
|
||||||
dashboardPackagesCountChart.config.data = {
|
|
||||||
labels: ["packages"],
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: "archives",
|
|
||||||
data: [data.stats.packages],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "bases",
|
|
||||||
data: [data.stats.bases],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
dashboardPackagesCountChart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
dashboardCanvas.hidden = data.status.total > 0;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ready(_ => {
|
|
||||||
dashboardPackagesStatusesChart = new Chart(dashboardPackagesStatusesChartCanvas, {
|
|
||||||
type: "pie",
|
|
||||||
data: {},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
dashboardPackagesCountChart = new Chart(dashboardPackagesCountChartCanvas, {
|
|
||||||
type: "bar",
|
|
||||||
data: {},
|
|
||||||
options: {
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
responsive: true,
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
stacked: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<div id="key-import-modal" tabindex="-1" role="dialog" class="modal fade">
|
|
||||||
<div class="modal-dialog modal-xl" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<form id="key-import-form" onsubmit="return false">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h4 class="modal-title">Import key from PGP server</h4>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="form-group row">
|
|
||||||
<label for="key-import-fingerprint-input" class="col-2 col-form-label">fingerprint</label>
|
|
||||||
<div class="col-10">
|
|
||||||
<input id="key-import-fingerprint-input" type="text" class="form-control" placeholder="PGP key fingerprint" name="key" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row">
|
|
||||||
<label for="key-import-server-input" class="col-2 col-form-label">key server</label>
|
|
||||||
<div class="col-10">
|
|
||||||
<input id="key-import-server-input" type="text" class="form-control" placeholder="PGP key server" name="server" value="keyserver.ubuntu.com" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row">
|
|
||||||
<div class="col-2"></div>
|
|
||||||
<div class="col-10">
|
|
||||||
<pre class="language-less"><samp id="key-import-body-input" class="pre-scrollable language-less"></samp><button id="key-import-copy-button" type="button" class="btn language-less" onclick="copyPgpKey()"><i class="bi bi-clipboard"></i> copy</button></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="submit" class="btn btn-primary" onclick="importPgpKey()"><i class="bi bi-play"></i> import</button>
|
|
||||||
<button type="submit" class="btn btn-success" onclick="fetchPgpKey()"><i class="bi bi-arrow-clockwise"></i> fetch</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const keyImportModal = document.getElementById("key-import-modal");
|
|
||||||
const keyImportForm = document.getElementById("key-import-form");
|
|
||||||
|
|
||||||
const keyImportBodyInput = document.getElementById("key-import-body-input");
|
|
||||||
const keyImportCopyButton = document.getElementById("key-import-copy-button");
|
|
||||||
|
|
||||||
const keyImportFingerprintInput = document.getElementById("key-import-fingerprint-input");
|
|
||||||
const keyImportServerInput = document.getElementById("key-import-server-input");
|
|
||||||
|
|
||||||
async function copyPgpKey() {
|
|
||||||
const key = keyImportBodyInput.textContent;
|
|
||||||
await copyToClipboard(key, keyImportCopyButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchPgpKey() {
|
|
||||||
const key = keyImportFingerprintInput.value;
|
|
||||||
const server = keyImportServerInput.value;
|
|
||||||
|
|
||||||
if (key && server) {
|
|
||||||
makeRequest(
|
|
||||||
"/api/v1/service/pgp",
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
key: key,
|
|
||||||
server: server,
|
|
||||||
},
|
|
||||||
convert: response => response.json(),
|
|
||||||
},
|
|
||||||
data => { keyImportBodyInput.textContent = data.key; },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function importPgpKey() {
|
|
||||||
const key = keyImportFingerprintInput.value;
|
|
||||||
const server = keyImportServerInput.value;
|
|
||||||
|
|
||||||
if (key && server) {
|
|
||||||
makeRequest(
|
|
||||||
"/api/v1/service/pgp",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
json: {
|
|
||||||
key: key,
|
|
||||||
server: server,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_ => {
|
|
||||||
bootstrap.Modal.getOrCreateInstance(keyImportModal).hide();
|
|
||||||
showSuccess("Success", `Key ${key} has been imported`);
|
|
||||||
},
|
|
||||||
error => {
|
|
||||||
const message = _ => `Could not import key ${key} from ${server}`;
|
|
||||||
showFailure("Action failed", message, error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ready(_ => {
|
|
||||||
keyImportModal.addEventListener("hidden.bs.modal", _ => {
|
|
||||||
keyImportBodyInput.textContent = "";
|
|
||||||
keyImportForm.reset();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<div id="login-modal" tabindex="-1" role="dialog" class="modal fade">
|
|
||||||
<div class="modal-dialog" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<form id="login-form" onsubmit="return false">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h4 class="modal-title">Login</h4>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="form-group row">
|
|
||||||
<label for="login-username" class="col-4 col-form-label">username</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<input id="login-username" type="text" class="form-control" placeholder="enter username" name="username" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row">
|
|
||||||
<label for="login-password" class="col-4 col-form-label">password</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<div class="input-group">
|
|
||||||
<input id="login-password" type="password" class="form-control" placeholder="enter password" name="password" required>
|
|
||||||
<div class="input-group-append">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="showPassword()"><i id="login-show-hide-password-button" class="bi bi-eye"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="submit" class="btn btn-primary" onclick="login()"><i class="bi bi-person"></i> login</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const loginModal = document.getElementById("login-modal");
|
|
||||||
const loginForm = document.getElementById("login-form");
|
|
||||||
|
|
||||||
const loginPasswordInput = document.getElementById("login-password");
|
|
||||||
const loginUsernameInput = document.getElementById("login-username");
|
|
||||||
const showHidePasswordButton = document.getElementById("login-show-hide-password-button");
|
|
||||||
|
|
||||||
function login() {
|
|
||||||
const password = loginPasswordInput.value;
|
|
||||||
const username = loginUsernameInput.value;
|
|
||||||
|
|
||||||
if (username && password) {
|
|
||||||
makeRequest(
|
|
||||||
"/api/v1/login",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
json: {
|
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
_ => {
|
|
||||||
bootstrap.Modal.getOrCreateInstance(loginModal).hide();
|
|
||||||
showSuccess("Logged in", `Successfully logged in as ${username}`, _ => location.href = "/");
|
|
||||||
},
|
|
||||||
error => {
|
|
||||||
const message = _ =>
|
|
||||||
username === "admin" && password === "admin"
|
|
||||||
? "You've entered a password for user \"root\", did you make a typo in username?"
|
|
||||||
: `Could not login as ${username}`;
|
|
||||||
showFailure("Login error", message, error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showPassword() {
|
|
||||||
if (loginPasswordInput.getAttribute("type") === "password") {
|
|
||||||
loginPasswordInput.setAttribute("type", "text");
|
|
||||||
showHidePasswordButton.classList.remove("bi-eye");
|
|
||||||
showHidePasswordButton.classList.add("bi-eye-slash");
|
|
||||||
} else {
|
|
||||||
loginPasswordInput.setAttribute("type", "password");
|
|
||||||
showHidePasswordButton.classList.remove("bi-eye-slash");
|
|
||||||
showHidePasswordButton.classList.add("bi-eye");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ready(_ => {
|
|
||||||
loginModal.addEventListener("hidden.bs.modal", _ => {
|
|
||||||
loginForm.reset();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
<div id="package-add-modal" tabindex="-1" role="dialog" class="modal fade">
|
|
||||||
<div class="modal-dialog modal-lg" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<form id="package-add-form" onsubmit="return false">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h4 class="modal-title">Add new packages</h4>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="form-group row">
|
|
||||||
<label for="package-add-repository-input" class="col-3 col-form-label">repository</label>
|
|
||||||
<div class="col-9">
|
|
||||||
<select id="package-add-repository-input" class="form-control" required>
|
|
||||||
{% for repository in repositories %}
|
|
||||||
<option value="{{ repository.id }}" data-repository="{{ repository.repository }}" data-architecture="{{ repository.architecture }}">{{ repository.repository }} ({{ repository.architecture }})</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row">
|
|
||||||
<label for="package-add-input" class="col-3 col-form-label">package</label>
|
|
||||||
<div class="col-9">
|
|
||||||
<input id="package-add-input" type="text" list="package-add-known-packages-dlist" autocomplete="off" class="form-control" placeholder="AUR package" required>
|
|
||||||
<datalist id="package-add-known-packages-dlist"></datalist>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row">
|
|
||||||
<label class="col-3 col-form-label"></label>
|
|
||||||
<div class="col-9">
|
|
||||||
<input id="package-add-refresh-input" type="checkbox" class="form-check-input" value="" checked>
|
|
||||||
<label for="package-add-refresh-input" class="form-check-label">update pacman databases</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row">
|
|
||||||
<div class="col-12">
|
|
||||||
<button id="package-add-variable-button" type="button" class="form-control btn btn-light rounded" onclick="packageAddVariableInputCreate()"><i class="bi bi-plus"></i> add environment variable </button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="package-add-variables-div" class="form-group row"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="submit" class="btn btn-primary" onclick="packagesAdd()"><i class="bi bi-play"></i> add</button>
|
|
||||||
<button type="submit" class="btn btn-success" onclick="packagesRequest()"><i class="bi bi-plus"></i> request</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const packageAddModal = document.getElementById("package-add-modal");
|
|
||||||
const packageAddForm = document.getElementById("package-add-form");
|
|
||||||
|
|
||||||
const packageAddInput = document.getElementById("package-add-input");
|
|
||||||
const packageAddRepositoryInput = document.getElementById("package-add-repository-input");
|
|
||||||
const packageAddKnownPackagesList = document.getElementById("package-add-known-packages-dlist");
|
|
||||||
|
|
||||||
const packageAddVariablesDiv = document.getElementById("package-add-variables-div");
|
|
||||||
|
|
||||||
const packageAddRefreshInput = document.getElementById("package-add-refresh-input");
|
|
||||||
|
|
||||||
function packageAddVariableInputCreate() {
|
|
||||||
const variableInput = document.createElement("div");
|
|
||||||
variableInput.classList.add("input-group");
|
|
||||||
variableInput.classList.add("package-add-variable");
|
|
||||||
|
|
||||||
const variableNameInput = document.createElement("input");
|
|
||||||
variableNameInput.type = "text";
|
|
||||||
variableNameInput.classList.add("form-control");
|
|
||||||
variableNameInput.classList.add("package-add-variable-name");
|
|
||||||
variableNameInput.placeholder = "name";
|
|
||||||
variableNameInput.ariaLabel = "variable name";
|
|
||||||
|
|
||||||
const variableSeparator = document.createElement("span");
|
|
||||||
variableSeparator.classList.add("input-group-text")
|
|
||||||
variableSeparator.textContent = "=";
|
|
||||||
|
|
||||||
const variableValueInput = document.createElement("input");
|
|
||||||
variableValueInput.type = "text";
|
|
||||||
variableValueInput.classList.add("form-control");
|
|
||||||
variableValueInput.classList.add("package-add-variable-value");
|
|
||||||
variableValueInput.placeholder = "value";
|
|
||||||
variableValueInput.ariaLabel = "variable value";
|
|
||||||
|
|
||||||
const variableButtonRemove = document.createElement("button");
|
|
||||||
variableButtonRemove.type = "button";
|
|
||||||
variableButtonRemove.classList.add("btn");
|
|
||||||
variableButtonRemove.classList.add("btn-outline-danger");
|
|
||||||
variableButtonRemove.innerHTML = "<i class=\"bi bi-trash\"></i>";
|
|
||||||
variableButtonRemove.onclick = _ => { variableInput.remove(); };
|
|
||||||
|
|
||||||
// bring them together
|
|
||||||
variableInput.appendChild(variableNameInput);
|
|
||||||
variableInput.appendChild(variableSeparator);
|
|
||||||
variableInput.appendChild(variableValueInput);
|
|
||||||
variableInput.appendChild(variableButtonRemove);
|
|
||||||
|
|
||||||
packageAddVariablesDiv.appendChild(variableInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchesParse() {
|
|
||||||
const patches = Array.from(packageAddVariablesDiv.getElementsByClassName("package-add-variable")).map(element => {
|
|
||||||
return {
|
|
||||||
key: element.querySelector(".package-add-variable-name").value,
|
|
||||||
value: element.querySelector(".package-add-variable-value").value,
|
|
||||||
};
|
|
||||||
}).filter(patch => patch.key);
|
|
||||||
return {patches: patches};
|
|
||||||
}
|
|
||||||
|
|
||||||
function packagesAdd(packages, patches, repository, data) {
|
|
||||||
packages = packages ?? packageAddInput.value;
|
|
||||||
patches = patches ?? patchesParse();
|
|
||||||
repository = repository ?? getRepositorySelector(packageAddRepositoryInput);
|
|
||||||
data = data ?? {refresh: packageAddRefreshInput.checked};
|
|
||||||
|
|
||||||
if (packages) {
|
|
||||||
bootstrap.Modal.getOrCreateInstance(packageAddModal).hide();
|
|
||||||
const onSuccess = update => `Packages ${update} have been added`;
|
|
||||||
const onFailure = error => `Package addition failed: ${error}`;
|
|
||||||
const parameters = Object.assign({}, data, patches);
|
|
||||||
doPackageAction("/api/v1/service/add", [packages], repository, onSuccess, onFailure, parameters);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function packagesRequest(packages, patches) {
|
|
||||||
packages = packages ?? packageAddInput.value;
|
|
||||||
patches = patches ?? patchesParse();
|
|
||||||
const repository = getRepositorySelector(packageAddRepositoryInput);
|
|
||||||
|
|
||||||
if (packages) {
|
|
||||||
bootstrap.Modal.getOrCreateInstance(packageAddModal).hide();
|
|
||||||
const onSuccess = update => `Packages ${update} have been requested`;
|
|
||||||
const onFailure = error => `Package request failed: ${error}`;
|
|
||||||
doPackageAction("/api/v1/service/request", [packages], repository, onSuccess, onFailure, patches);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ready(_ => {
|
|
||||||
packageAddModal.addEventListener("shown.bs.modal", _ => {
|
|
||||||
const option = packageAddRepositoryInput.querySelector(`option[value="${repository.architecture}-${repository.repository}"]`);
|
|
||||||
option.selected = "selected";
|
|
||||||
});
|
|
||||||
packageAddModal.addEventListener("hidden.bs.modal", _ => {
|
|
||||||
packageAddVariablesDiv.replaceChildren();
|
|
||||||
packageAddForm.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
packageAddInput.addEventListener("keyup", _ => {
|
|
||||||
clearTimeout(packageAddInput.requestTimeout);
|
|
||||||
|
|
||||||
// do not update datalist if search string didn't change yet
|
|
||||||
const value = packageAddInput.value;
|
|
||||||
const previousValue = packageAddInput.dataset.previousValue;
|
|
||||||
if (value === previousValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// store current search string in attributes
|
|
||||||
packageAddInput.dataset.previousValue = value;
|
|
||||||
|
|
||||||
// perform data list update
|
|
||||||
packageAddInput.requestTimeout = setTimeout(_ => {
|
|
||||||
|
|
||||||
if (value.length >= 3) {
|
|
||||||
makeRequest(
|
|
||||||
"/api/v1/service/search",
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
for: value,
|
|
||||||
},
|
|
||||||
convert: response => response.json(),
|
|
||||||
},
|
|
||||||
data => {
|
|
||||||
const options = data.map(pkg => {
|
|
||||||
const option = document.createElement("option");
|
|
||||||
option.value = pkg.package;
|
|
||||||
option.innerText = `${pkg.package} (${pkg.description})`;
|
|
||||||
return option;
|
|
||||||
});
|
|
||||||
packageAddKnownPackagesList.replaceChildren(...options);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,586 +0,0 @@
|
|||||||
<div id="package-info-modal" tabindex="-1" role="dialog" class="modal fade">
|
|
||||||
<div class="modal-dialog modal-xl" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div id="package-info-modal-header" class="modal-header">
|
|
||||||
<h4 id="package-info" class="modal-title"></h4>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="form-group row mt-2">
|
|
||||||
<div class="col-4 col-lg-1" style="text-align: right">packages</div>
|
|
||||||
<div id="package-info-packages" class="col-8 col-lg-5"></div>
|
|
||||||
<div class="col-4 col-lg-1" style="text-align: right">version</div>
|
|
||||||
<div id="package-info-version" class="col-8 col-lg-5"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group row mt-2">
|
|
||||||
<div class="col-4 col-lg-1" style="text-align: right">packager</div>
|
|
||||||
<div id="package-info-packager" class="col-8 col-lg-5"></div>
|
|
||||||
<div class="col-4 col-lg-1" style="text-align: right"></div>
|
|
||||||
<div id="package-info---placeholder" class="col-8 col-lg-5"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group row mt-2">
|
|
||||||
<div class="col-4 col-lg-1" style="text-align: right">groups</div>
|
|
||||||
<div id="package-info-groups" class="col-8 col-lg-5"></div>
|
|
||||||
<div class="col-4 col-lg-1" style="text-align: right">licenses</div>
|
|
||||||
<div id="package-info-licenses" class="col-8 col-lg-5"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group row mt-2">
|
|
||||||
<div class="col-4 col-lg-1" style="text-align: right">upstream</div>
|
|
||||||
<div id="package-info-upstream-url" class="col-8 col-lg-5"></div>
|
|
||||||
<div class="col-4 col-lg-1" style="text-align: right">AUR</div>
|
|
||||||
<div id="package-info-aur-url" class="col-8 col-lg-5"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group row mt-2">
|
|
||||||
<div class="col-4 col-lg-1" style="text-align: right">depends</div>
|
|
||||||
<div id="package-info-depends" class="col-8 col-lg-5"></div>
|
|
||||||
<div class="col-4 col-lg-1" style="text-align: right">implicitly depends</div>
|
|
||||||
<div id="package-info-implicitly-depends" class="col-8 col-lg-5"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="col-12">
|
|
||||||
|
|
||||||
<div id="package-info-variables-block" hidden>
|
|
||||||
<h3>Environment variables</h3>
|
|
||||||
<div id="package-info-variables-div" class="form-group row"></div>
|
|
||||||
|
|
||||||
<hr class="col-12">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav>
|
|
||||||
<div class="nav nav-tabs" role="tablist">
|
|
||||||
<button id="package-info-logs-button" class="nav-link active" data-bs-toggle="tab" data-bs-target="#package-info-logs" type="button" role="tab" aria-controls="package-info-logs" aria-selected="true">Build logs</button>
|
|
||||||
<button id="package-info-changes-button" class="nav-link" data-bs-toggle="tab" data-bs-target="#package-info-changes" type="button" role="tab" aria-controls="package-info-changes" aria-selected="false">Changes</button>
|
|
||||||
<button id="package-info-events-button" class="nav-link" data-bs-toggle="tab" data-bs-target="#package-info-events" type="button" role="tab" aria-controls="package-info-events" aria-selected="false">Events</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<div class="tab-content" id="nav-tabContent">
|
|
||||||
<div id="package-info-logs" class="tab-pane fade show active" role="tabpanel" aria-labelledby="package-info-logs-button" tabindex="0">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-1 dropend">
|
|
||||||
<button id="package-info-logs-dropdown" class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
||||||
<i class="bi bi-list"></i>
|
|
||||||
</button>
|
|
||||||
<nav id="package-info-logs-versions" class="dropdown-menu" aria-labelledby="package-info-logs-dropdown"></nav>
|
|
||||||
</div>
|
|
||||||
<div class="col-11">
|
|
||||||
<pre class="language-console"><code id="package-info-logs-input" class="pre-scrollable language-console"></code><button id="package-info-logs-copy-button" type="button" class="btn language-console" onclick="copyLogs()"><i class="bi bi-clipboard"></i> copy</button></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="package-info-changes" class="tab-pane fade" role="tabpanel" aria-labelledby="package-info-changes-button" tabindex="0">
|
|
||||||
<pre class="language-diff"><code id="package-info-changes-input" class="pre-scrollable language-diff"></code><button id="package-info-changes-copy-button" type="button" class="btn language-diff" onclick="copyChanges()"><i class="bi bi-clipboard"></i> copy</button></pre>
|
|
||||||
</div>
|
|
||||||
<div id="package-info-events" class="tab-pane fade" role="tabpanel" aria-labelledby="package-info-events-button" tabindex="0">
|
|
||||||
<canvas id="package-info-events-update-chart" hidden></canvas>
|
|
||||||
<table id="package-info-events-table"
|
|
||||||
data-classes="table table-hover"
|
|
||||||
data-sortable="true"
|
|
||||||
data-sort-name="timestamp"
|
|
||||||
data-sort-order="desc">
|
|
||||||
<thead class="table-primary">
|
|
||||||
<tr>
|
|
||||||
<th data-align="right" data-field="timestamp">date</th>
|
|
||||||
<th data-field="event">event</th>
|
|
||||||
<th data-field="message">description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
{% if not auth.enabled or auth.username is not none %}
|
|
||||||
<input id="package-info-refresh-input" type="checkbox" class="form-check-input" value="" checked>
|
|
||||||
<label for="package-info-refresh-input" class="form-check-label">update pacman databases</label>
|
|
||||||
|
|
||||||
<button id="package-info-update-button" type="submit" class="btn btn-success" onclick="packageInfoUpdate()"><i class="bi bi-play"></i><span class="d-none d-sm-inline"> update</span></button>
|
|
||||||
<button id="package-info-remove-button" type="submit" class="btn btn-danger" onclick="packageInfoRemove()" data-bs-dismiss="modal"><i class="bi bi-trash"></i><span class="d-none d-sm-inline"> remove</span></button>
|
|
||||||
{% endif %}
|
|
||||||
{% if autorefresh_intervals %}
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="showPackageInfo()"><i class="bi bi-arrow-clockwise"></i><span class="d-none d-sm-inline"> reload</span></button>
|
|
||||||
<div class="btn-group dropup">
|
|
||||||
<input id="package-info-autoreload-button" type="checkbox" class="btn-check" autocomplete="off" onclick="togglePackageInfoAutoReload()" checked>
|
|
||||||
<label for="package-info-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="package-info-autoreload-input" class="dropdown-menu">
|
|
||||||
{% for interval in autorefresh_intervals %}
|
|
||||||
<li><a class="dropdown-item {{ "active" if interval.is_active }}" onclick="togglePackageInfoAutoReload({{ interval.interval }})" data-interval="{{ interval.interval }}">{{ interval.text }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal"><i class="bi bi-x"></i><span class="d-none d-sm-inline"> close</span></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const packageInfoModal = document.getElementById("package-info-modal");
|
|
||||||
const packageInfoModalHeader = document.getElementById("package-info-modal-header");
|
|
||||||
const packageInfo = document.getElementById("package-info");
|
|
||||||
|
|
||||||
const packageInfoLogsVersions = document.getElementById("package-info-logs-versions");
|
|
||||||
const packageInfoLogsInput = document.getElementById("package-info-logs-input");
|
|
||||||
const packageInfoLogsCopyButton = document.getElementById("package-info-logs-copy-button");
|
|
||||||
|
|
||||||
const packageInfoChangesInput = document.getElementById("package-info-changes-input");
|
|
||||||
const packageInfoChangesCopyButton = document.getElementById("package-info-changes-copy-button");
|
|
||||||
|
|
||||||
// so far bootstrap-table only operates with jquery elements
|
|
||||||
const packageInfoEventsTable = $(document.getElementById("package-info-events-table"));
|
|
||||||
const packageInfoEventsUpdateChartCanvas = document.getElementById("package-info-events-update-chart");
|
|
||||||
let packageInfoEventsUpdateChart = null;
|
|
||||||
|
|
||||||
const packageInfoAurUrl = document.getElementById("package-info-aur-url");
|
|
||||||
const packageInfoDepends = document.getElementById("package-info-depends");
|
|
||||||
const packageInfoGroups = document.getElementById("package-info-groups");
|
|
||||||
const packageInfoImplicitlyDepends = document.getElementById("package-info-implicitly-depends");
|
|
||||||
const packageInfoLicenses = document.getElementById("package-info-licenses");
|
|
||||||
const packageInfoPackager = document.getElementById("package-info-packager");
|
|
||||||
const packageInfoPackages = document.getElementById("package-info-packages");
|
|
||||||
const packageInfoUpstreamUrl = document.getElementById("package-info-upstream-url");
|
|
||||||
const packageInfoVersion = document.getElementById("package-info-version");
|
|
||||||
|
|
||||||
const packageInfoVariablesBlock = document.getElementById("package-info-variables-block");
|
|
||||||
const packageInfoVariablesDiv = document.getElementById("package-info-variables-div");
|
|
||||||
|
|
||||||
const packageInfoRefreshInput = document.getElementById("package-info-refresh-input");
|
|
||||||
|
|
||||||
const packageInfoAutoReloadButton = document.getElementById("package-info-autoreload-button");
|
|
||||||
const packageInfoAutoReloadInput = document.getElementById("package-info-autoreload-input");
|
|
||||||
let packageInfoAutoReloadTask = null;
|
|
||||||
|
|
||||||
function clearChart() {
|
|
||||||
packageInfoEventsUpdateChartCanvas.hidden = true;
|
|
||||||
if (packageInfoEventsUpdateChart) {
|
|
||||||
packageInfoEventsUpdateChart.data = {};
|
|
||||||
packageInfoEventsUpdateChart.update();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertLogs(data, filter) {
|
|
||||||
return data
|
|
||||||
.filter((filter || Boolean))
|
|
||||||
.map(log_record => `[${new Date(1000 * log_record.created).toISOString()}] ${log_record.message}`)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyChanges() {
|
|
||||||
const changes = packageInfoChangesInput.textContent;
|
|
||||||
await copyToClipboard(changes, packageInfoChangesCopyButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyLogs() {
|
|
||||||
const logs = packageInfoLogsInput.textContent;
|
|
||||||
await copyToClipboard(logs, packageInfoLogsCopyButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
function highlight(element) {
|
|
||||||
delete element.dataset.highlighted;
|
|
||||||
hljs.highlightElement(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertVariable(packageBase, variable) {
|
|
||||||
const variableInput = document.createElement("div");
|
|
||||||
variableInput.classList.add("input-group");
|
|
||||||
|
|
||||||
const variableNameInput = document.createElement("input");
|
|
||||||
variableNameInput.classList.add("form-control");
|
|
||||||
variableNameInput.readOnly = true;
|
|
||||||
variableNameInput.value = variable.key;
|
|
||||||
|
|
||||||
const variableSeparator = document.createElement("span");
|
|
||||||
variableSeparator.classList.add("input-group-text")
|
|
||||||
variableSeparator.textContent = "=";
|
|
||||||
|
|
||||||
const variableValueInput = document.createElement("input");
|
|
||||||
variableValueInput.classList.add("form-control");
|
|
||||||
variableValueInput.readOnly = true;
|
|
||||||
variableValueInput.value = JSON.stringify(variable.value);
|
|
||||||
|
|
||||||
const variableButtonRemove = document.createElement("button");
|
|
||||||
variableButtonRemove.type = "button";
|
|
||||||
variableButtonRemove.classList.add("btn");
|
|
||||||
variableButtonRemove.classList.add("btn-outline-danger");
|
|
||||||
variableButtonRemove.innerHTML = "<i class=\"bi bi-trash\"></i>";
|
|
||||||
variableButtonRemove.onclick = _ => {
|
|
||||||
makeRequest(
|
|
||||||
`/api/v1/packages/${packageBase}/patches/${variable.key}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
},
|
|
||||||
_ => variableInput.remove(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// bring them together
|
|
||||||
variableInput.appendChild(variableNameInput);
|
|
||||||
variableInput.appendChild(variableSeparator);
|
|
||||||
variableInput.appendChild(variableValueInput);
|
|
||||||
variableInput.appendChild(variableButtonRemove);
|
|
||||||
|
|
||||||
packageInfoVariablesDiv.appendChild(variableInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadChanges(packageBase, onFailure) {
|
|
||||||
makeRequest(
|
|
||||||
`/api/v1/packages/${packageBase}/changes`,
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
architecture: repository.architecture,
|
|
||||||
repository: repository.repository,
|
|
||||||
},
|
|
||||||
convert: response => response.json(),
|
|
||||||
},
|
|
||||||
data => {
|
|
||||||
const changes = data.changes;
|
|
||||||
packageInfoChangesInput.textContent = changes ?? "";
|
|
||||||
highlight(packageInfoChangesInput);
|
|
||||||
},
|
|
||||||
onFailure,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadDependencies(packageBase, onFailure) {
|
|
||||||
makeRequest(
|
|
||||||
`/api/v1/packages/${packageBase}/dependencies`,
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
architecture: repository.architecture,
|
|
||||||
repository: repository.repository,
|
|
||||||
},
|
|
||||||
convert: response => response.json(),
|
|
||||||
},
|
|
||||||
data => {
|
|
||||||
packageInfoImplicitlyDepends.innerHTML = listToTable(
|
|
||||||
Object.values(data.paths)
|
|
||||||
.reduce((accumulator, currentValue) => accumulator.concat(currentValue), [])
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onFailure,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadEvents(packageBase, onFailure) {
|
|
||||||
packageInfoEventsTable.bootstrapTable("showLoading");
|
|
||||||
clearChart();
|
|
||||||
|
|
||||||
makeRequest(
|
|
||||||
"/api/v1/events",
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
architecture: repository.architecture,
|
|
||||||
repository: repository.repository,
|
|
||||||
object_id: packageBase,
|
|
||||||
limit: 30,
|
|
||||||
},
|
|
||||||
convert: response => response.json(),
|
|
||||||
},
|
|
||||||
data => {
|
|
||||||
const events = data.map(event => {
|
|
||||||
return {
|
|
||||||
timestamp: new Date(1000 * event.created).toISOStringShort(),
|
|
||||||
event: event.event,
|
|
||||||
message: event.message || "",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const chart = data.filter(event => event.event === "package-updated");
|
|
||||||
|
|
||||||
packageInfoEventsTable.bootstrapTable("load", events);
|
|
||||||
packageInfoEventsTable.bootstrapTable("hideLoading");
|
|
||||||
|
|
||||||
if (packageInfoEventsUpdateChart) {
|
|
||||||
packageInfoEventsUpdateChart.config.data = {
|
|
||||||
labels: chart.map(event => new Date(1000 * event.created).toISOStringShort()),
|
|
||||||
datasets: [{
|
|
||||||
label: "update duration, s",
|
|
||||||
data: chart.map(event => event.data.took),
|
|
||||||
cubicInterpolationMode: "monotone",
|
|
||||||
tension: 0.4,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
packageInfoEventsUpdateChart.update();
|
|
||||||
}
|
|
||||||
packageInfoEventsUpdateChartCanvas.hidden = !chart.length;
|
|
||||||
},
|
|
||||||
onFailure,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadLogs(packageBase, onFailure) {
|
|
||||||
const sortFn = (left, right) => left.process_id.localeCompare(right.process_id) || left.version.localeCompare(right.version);
|
|
||||||
const compareFn = (left, right) => left.process_id === right.process_id && left.version === right.version;
|
|
||||||
|
|
||||||
makeRequest(
|
|
||||||
`/api/v2/packages/${packageBase}/logs`,
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
architecture: repository.architecture,
|
|
||||||
head: true,
|
|
||||||
repository: repository.repository,
|
|
||||||
},
|
|
||||||
convert: response => response.json(),
|
|
||||||
},
|
|
||||||
data => {
|
|
||||||
const currentVersions = Array.from(packageInfoLogsVersions.children)
|
|
||||||
.map(el => {
|
|
||||||
return {
|
|
||||||
process_id: el.dataset.processId,
|
|
||||||
version: el.dataset.version,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort(sortFn);
|
|
||||||
const newVersions = data
|
|
||||||
.map(el => {
|
|
||||||
return {
|
|
||||||
process_id: el.process_id,
|
|
||||||
version: el.version,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort(sortFn);
|
|
||||||
|
|
||||||
if (currentVersions.equals(newVersions, compareFn))
|
|
||||||
loadLogsActive(packageBase);
|
|
||||||
else
|
|
||||||
loadLogsAll(packageBase, onFailure);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadLogsActive(packageBase) {
|
|
||||||
const activeLogSelector = packageInfoLogsVersions.querySelector(".active");
|
|
||||||
|
|
||||||
if (activeLogSelector) {
|
|
||||||
makeRequest(
|
|
||||||
`/api/v2/packages/${packageBase}/logs`,
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
architecture: repository.architecture,
|
|
||||||
repository: repository.repository,
|
|
||||||
version: activeLogSelector.dataset.version,
|
|
||||||
process_id: activeLogSelector.dataset.processId,
|
|
||||||
},
|
|
||||||
convert: response => response.json(),
|
|
||||||
},
|
|
||||||
data => {
|
|
||||||
activeLogSelector.dataset.logs = convertLogs(data);
|
|
||||||
activeLogSelector.click();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadLogsAll(packageBase, onFailure) {
|
|
||||||
makeRequest(
|
|
||||||
`/api/v2/packages/${packageBase}/logs`,
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
architecture: repository.architecture,
|
|
||||||
repository: repository.repository,
|
|
||||||
},
|
|
||||||
convert: response => response.json(),
|
|
||||||
},
|
|
||||||
data => {
|
|
||||||
const selectors = Object
|
|
||||||
.values(
|
|
||||||
data.reduce((acc, log_record) => {
|
|
||||||
const id = `${log_record.version}-${log_record.process_id}`;
|
|
||||||
if (acc[id])
|
|
||||||
acc[id].created = Math.min(log_record.created, acc[id].created);
|
|
||||||
else
|
|
||||||
acc[id] = log_record;
|
|
||||||
return acc;
|
|
||||||
}, {})
|
|
||||||
)
|
|
||||||
.sort(({created: left}, {created: right}) =>
|
|
||||||
right - left
|
|
||||||
)
|
|
||||||
.map(version => {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.classList.add("dropdown-item");
|
|
||||||
|
|
||||||
link.dataset.version = version.version;
|
|
||||||
link.dataset.processId = version.process_id;
|
|
||||||
link.dataset.logs = convertLogs(data, log_record => log_record.version === version.version && log_record.process_id === version.process_id);
|
|
||||||
|
|
||||||
link.textContent = new Date(1000 * version.created).toISOStringShort();
|
|
||||||
link.href = "#";
|
|
||||||
link.onclick = _ => {
|
|
||||||
// check if we are at the bottom of the code block
|
|
||||||
const isScrolledToBottom = packageInfoLogsInput.scrollTop + packageInfoLogsInput.clientHeight >= packageInfoLogsInput.scrollHeight;
|
|
||||||
packageInfoLogsInput.textContent = link.dataset.logs;
|
|
||||||
highlight(packageInfoLogsInput);
|
|
||||||
if (isScrolledToBottom)
|
|
||||||
packageInfoLogsInput.scrollTop = packageInfoLogsInput.scrollHeight; // scroll to the new end
|
|
||||||
|
|
||||||
Array.from(packageInfoLogsVersions.children).forEach(el => el.classList.remove("active"));
|
|
||||||
link.classList.add("active");
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
return link;
|
|
||||||
});
|
|
||||||
|
|
||||||
packageInfoLogsVersions.replaceChildren(...selectors);
|
|
||||||
selectors.find(Boolean)?.click();
|
|
||||||
},
|
|
||||||
onFailure,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadPackage(packageBase, onFailure) {
|
|
||||||
makeRequest(
|
|
||||||
`/api/v1/packages/${packageBase}`,
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
architecture: repository.architecture,
|
|
||||||
repository: repository.repository,
|
|
||||||
},
|
|
||||||
convert: response => response.json(),
|
|
||||||
},
|
|
||||||
data => {
|
|
||||||
const description = data.find(Boolean);
|
|
||||||
const packages = description.package.packages;
|
|
||||||
const aurUrl = description.package.remote.web_url;
|
|
||||||
const upstreamUrls = Array.from(new Set(Object.values(packages).map(single => single.url))).sort();
|
|
||||||
|
|
||||||
packageInfo.textContent = `${description.package.base} ${description.status.status} at ${new Date(1000 * description.status.timestamp).toISOStringShort()}`;
|
|
||||||
|
|
||||||
packageInfoModalHeader.classList.remove(...packageInfoModalHeader.classList);
|
|
||||||
packageInfoModalHeader.classList.add("modal-header");
|
|
||||||
headerClass(description.status.status).forEach(clz => packageInfoModalHeader.classList.add(clz));
|
|
||||||
|
|
||||||
packageInfoAurUrl.innerHTML = aurUrl ? safeLink(aurUrl, aurUrl, "AUR link").outerHTML : "";
|
|
||||||
packageInfoDepends.innerHTML = listToTable(
|
|
||||||
Object.values(packages)
|
|
||||||
.reduce((accumulator, currentValue) => {
|
|
||||||
return accumulator.concat(currentValue.depends.filter(v => !packages.hasOwnProperty(v)))
|
|
||||||
.concat(currentValue.make_depends.filter(v => !packages.hasOwnProperty(v)).map(v => `${v} (make)`))
|
|
||||||
.concat(currentValue.opt_depends.filter(v => !packages.hasOwnProperty(v)).map(v => `${v} (optional)`));
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
packageInfoGroups.innerHTML = listToTable(extractListProperties(description.package, "groups"));
|
|
||||||
packageInfoLicenses.innerHTML = listToTable(extractListProperties(description.package, "licenses"));
|
|
||||||
packageInfoPackager.textContent = description.package.packager;
|
|
||||||
packageInfoPackages.innerHTML = listToTable(Object.entries(packages).map(([key, value]) => `${key} (${value.description})`));
|
|
||||||
packageInfoUpstreamUrl.innerHTML = upstreamUrls.map(url => safeLink(url, url, "upstream link").outerHTML).join("<br>");
|
|
||||||
packageInfoVersion.textContent = description.package.version;
|
|
||||||
},
|
|
||||||
onFailure,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadPatches(packageBase, onFailure) {
|
|
||||||
makeRequest(
|
|
||||||
`/api/v1/packages/${packageBase}/patches`,
|
|
||||||
{
|
|
||||||
convert: response => response.json(),
|
|
||||||
},
|
|
||||||
data => {
|
|
||||||
packageInfoVariablesDiv.replaceChildren();
|
|
||||||
data.map(patch => insertVariable(packageBase, patch));
|
|
||||||
packageInfoVariablesBlock.hidden = !data.length;
|
|
||||||
},
|
|
||||||
onFailure,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function packageInfoRemove() {
|
|
||||||
const packageBase = packageInfoModal.dataset.package;
|
|
||||||
packagesRemove([packageBase]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function packageInfoUpdate() {
|
|
||||||
const packageBase = packageInfoModal.dataset.package;
|
|
||||||
packagesAdd(packageBase, [], repository, {refresh: packageInfoRefreshInput.checked});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showPackageInfo(packageBase) {
|
|
||||||
const isPackageBaseSet = packageBase !== undefined;
|
|
||||||
if (isPackageBaseSet) {
|
|
||||||
// set package base as currently used
|
|
||||||
packageInfoModal.dataset.package = packageBase;
|
|
||||||
} else {
|
|
||||||
// read package base from the current window attribute
|
|
||||||
packageBase = packageInfoModal.dataset.package;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFailure = error => {
|
|
||||||
if (isPackageBaseSet) {
|
|
||||||
const message = details => `Could not load package ${packageBase} info: ${details}`;
|
|
||||||
showFailure("Load failure", message, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadPackage(packageBase, onFailure);
|
|
||||||
loadDependencies(packageBase, onFailure);
|
|
||||||
loadPatches(packageBase, onFailure);
|
|
||||||
loadLogs(packageBase, onFailure);
|
|
||||||
loadChanges(packageBase, onFailure);
|
|
||||||
loadEvents(packageBase, onFailure);
|
|
||||||
|
|
||||||
if (isPackageBaseSet) {
|
|
||||||
bootstrap.Modal.getOrCreateInstance(packageInfoModal).show();
|
|
||||||
{% if autorefresh_intervals %}
|
|
||||||
togglePackageInfoAutoReload();
|
|
||||||
{% endif %}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePackageInfoAutoReload(interval) {
|
|
||||||
clearInterval(packageInfoAutoReloadTask);
|
|
||||||
packageInfoAutoReloadTask = toggleAutoReload(packageInfoAutoReloadButton, interval, packageInfoAutoReloadInput, _ => {
|
|
||||||
if (!hasActiveSelection()) {
|
|
||||||
const packageBase = packageInfoModal.dataset.package;
|
|
||||||
// we only poll status and logs here
|
|
||||||
loadPackage(packageBase);
|
|
||||||
loadLogs(packageBase);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ready(_ => {
|
|
||||||
packageInfoEventsTable.bootstrapTable({});
|
|
||||||
|
|
||||||
packageInfoEventsUpdateChart = new Chart(packageInfoEventsUpdateChartCanvas, {
|
|
||||||
type: "line",
|
|
||||||
data: {},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
packageInfoModal.addEventListener("hidden.bs.modal", _ => {
|
|
||||||
packageInfoAurUrl.textContent = "";
|
|
||||||
packageInfoDepends.textContent = "";
|
|
||||||
packageInfoGroups.textContent = "";
|
|
||||||
packageInfoImplicitlyDepends.textContent = "";
|
|
||||||
packageInfoLicenses.textContent = "";
|
|
||||||
packageInfoPackager.textContent = "";
|
|
||||||
packageInfoPackages.textContent = "";
|
|
||||||
packageInfoUpstreamUrl.textContent = "";
|
|
||||||
packageInfoVersion.textContent = "";
|
|
||||||
|
|
||||||
packageInfoVariablesBlock.hidden = true;
|
|
||||||
packageInfoVariablesDiv.replaceChildren();
|
|
||||||
|
|
||||||
packageInfoLogsInput.textContent = "";
|
|
||||||
packageInfoChangesInput.textContent = "";
|
|
||||||
packageInfoEventsTable.bootstrapTable("load", []);
|
|
||||||
clearChart();
|
|
||||||
|
|
||||||
clearInterval(packageInfoAutoReloadTask);
|
|
||||||
packageInfoAutoReloadTask = null; // not really required (?) but lets clear everything
|
|
||||||
});
|
|
||||||
|
|
||||||
restoreAutoReloadSettings(packageInfoAutoReloadButton, packageInfoAutoReloadInput);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<div id="package-rebuild-modal" tabindex="-1" role="dialog" class="modal fade">
|
|
||||||
<div class="modal-dialog modal-lg" role="document">
|
|
||||||
<div class="modal-content">
|
|
||||||
<form id="package-rebuild-form" onsubmit="return false">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h4 class="modal-title">Rebuild depending packages</h4>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="form-group row">
|
|
||||||
<label for="package-rebuild-repository-input" class="col-3 col-form-label">repository</label>
|
|
||||||
<div class="col-9">
|
|
||||||
<select id="package-rebuild-repository-input" class="form-control" name="repository" required>
|
|
||||||
{% for repository in repositories %}
|
|
||||||
<option value="{{ repository.id }}" data-repository="{{ repository.repository }}" data-architecture="{{ repository.architecture }}">{{ repository.repository }} ({{ repository.architecture }})</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row">
|
|
||||||
<label for="package-rebuild-dependency-input" class="col-3 col-form-label">dependency</label>
|
|
||||||
<div class="col-9">
|
|
||||||
<input id="package-rebuild-dependency-input" type="text" class="form-control" placeholder="packages dependency" name="package" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="submit" class="btn btn-primary" onclick="packagesRebuild()"><i class="bi bi-play"></i> rebuild</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const packageRebuildModal = document.getElementById("package-rebuild-modal");
|
|
||||||
const packageRebuildForm = document.getElementById("package-rebuild-form");
|
|
||||||
|
|
||||||
const packageRebuildDependencyInput = document.getElementById("package-rebuild-dependency-input");
|
|
||||||
const packageRebuildRepositoryInput = document.getElementById("package-rebuild-repository-input");
|
|
||||||
|
|
||||||
function packagesRebuild() {
|
|
||||||
const packages = packageRebuildDependencyInput.value;
|
|
||||||
const repository = getRepositorySelector(packageRebuildRepositoryInput);
|
|
||||||
if (packages) {
|
|
||||||
bootstrap.Modal.getOrCreateInstance(packageRebuildModal).hide();
|
|
||||||
const onSuccess = update => `Repository rebuild has been run for packages which depend on ${update}`;
|
|
||||||
const onFailure = error => `Repository rebuild failed: ${error}`;
|
|
||||||
doPackageAction("/api/v1/service/rebuild", [packages], repository, onSuccess, onFailure);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ready(_ => {
|
|
||||||
packageRebuildModal.addEventListener("shown.bs.modal", _ => {
|
|
||||||
const option = packageRebuildRepositoryInput.querySelector(`option[value="${repository.architecture}-${repository.repository}"]`);
|
|
||||||
option.selected = "selected";
|
|
||||||
|
|
||||||
});
|
|
||||||
packageRebuildModal.addEventListener("hidden.bs.modal", _ => {
|
|
||||||
packageRebuildForm.reset();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
<script>
|
|
||||||
const packageRemoveButton = document.getElementById("package-remove-button");
|
|
||||||
const packageUpdateButton = document.getElementById("package-update-button");
|
|
||||||
|
|
||||||
let repository = null;
|
|
||||||
|
|
||||||
// so far bootstrap-table only operates with jquery elements
|
|
||||||
const table = $(document.getElementById("packages"));
|
|
||||||
|
|
||||||
const dashboardButton = document.getElementById("dashboard-button");
|
|
||||||
const versionBadge = document.getElementById("badge-version");
|
|
||||||
|
|
||||||
const tableAutoReloadButton = document.getElementById("table-autoreload-button");
|
|
||||||
const tableAutoReloadInput = document.getElementById("table-autoreload-input");
|
|
||||||
let tableAutoReloadTask = null;
|
|
||||||
|
|
||||||
function doPackageAction(uri, packages, repository, successText, failureText, data) {
|
|
||||||
makeRequest(
|
|
||||||
uri,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
query: {
|
|
||||||
architecture: repository.architecture,
|
|
||||||
repository: repository.repository,
|
|
||||||
},
|
|
||||||
json: Object.assign({}, {packages: packages}, data || {}),
|
|
||||||
},
|
|
||||||
_ => {
|
|
||||||
const message = successText(packages.join(", "));
|
|
||||||
showSuccess("Success", message);
|
|
||||||
},
|
|
||||||
error => {
|
|
||||||
showFailure("Action failed", failureText, error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterListGroups() {
|
|
||||||
return extractDataList(table.bootstrapTable("getData"), "groups");
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterListLicenses() {
|
|
||||||
return extractDataList(table.bootstrapTable("getData"), "licenses");
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterListPackagers() {
|
|
||||||
return extractDataList(table.bootstrapTable("getData"), "packager");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRepositorySelector(selector) {
|
|
||||||
const selected = selector.options[selector.selectedIndex];
|
|
||||||
return {
|
|
||||||
architecture: selected.getAttribute("data-architecture"),
|
|
||||||
repository: selected.getAttribute("data-repository"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSelection() {
|
|
||||||
return table.bootstrapTable("getSelections").map(row => row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function packagesLoad(onFailure) {
|
|
||||||
makeRequest(
|
|
||||||
"/api/v1/packages",
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
architecture: repository.architecture,
|
|
||||||
repository: repository.repository,
|
|
||||||
},
|
|
||||||
convert: response => response.json(),
|
|
||||||
},
|
|
||||||
data => {
|
|
||||||
const payload = data
|
|
||||||
.map(description => {
|
|
||||||
const package_base = description.package.base;
|
|
||||||
const web_url = description.package.remote.web_url;
|
|
||||||
return {
|
|
||||||
id: package_base,
|
|
||||||
base: web_url ? safeLink(web_url, package_base, package_base).outerHTML : safe(package_base),
|
|
||||||
version: safe(description.package.version),
|
|
||||||
packager: description.package.packager ? safe(description.package.packager) : "",
|
|
||||||
packages: listToTable(Object.keys(description.package.packages)),
|
|
||||||
groups: listToTable(extractListProperties(description.package, "groups")),
|
|
||||||
licenses: listToTable(extractListProperties(description.package, "licenses")),
|
|
||||||
timestamp: new Date(1000 * description.status.timestamp).toISOStringShort(),
|
|
||||||
status: description.status.status,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
updateTable(table, payload, row => row.timestamp);
|
|
||||||
table.bootstrapTable("hideLoading");
|
|
||||||
},
|
|
||||||
onFailure,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function packagesRemove(packages) {
|
|
||||||
packages = packages ?? getSelection();
|
|
||||||
const onSuccess = update => `Packages ${update} have been removed`;
|
|
||||||
const onFailure = error => `Could not remove packages: ${error}`;
|
|
||||||
|
|
||||||
doPackageAction("/api/v1/service/remove", packages, repository, onSuccess, onFailure);
|
|
||||||
}
|
|
||||||
|
|
||||||
function packagesUpdate() {
|
|
||||||
const currentSelection = getSelection();
|
|
||||||
const [url, onSuccess] = currentSelection.length === 0
|
|
||||||
? ["/api/v1/service/update", _ => `Repository update has been run`]
|
|
||||||
: ["/api/v1/service/add", update => `Run update for packages ${update}`];
|
|
||||||
const onFailure = error => `Packages update failed: ${error}`;
|
|
||||||
|
|
||||||
doPackageAction(url, currentSelection, repository, onSuccess, onFailure);
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshDatabases() {
|
|
||||||
const onSuccess = _ => "Pacman database update has been requested";
|
|
||||||
const onFailure = error => `Could not update pacman databases: ${error}`;
|
|
||||||
const parameters = {
|
|
||||||
refresh: true,
|
|
||||||
aur: false,
|
|
||||||
local: false,
|
|
||||||
manual: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
doPackageAction("/api/v1/service/update", [], repository, onSuccess, onFailure, parameters);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reload() {
|
|
||||||
table.bootstrapTable("showLoading");
|
|
||||||
const onFailure = error => {
|
|
||||||
if ((error.status === 401) || (error.status === 403)) {
|
|
||||||
// authorization error
|
|
||||||
const text = "In order to see statuses you must login first.";
|
|
||||||
table.find("tr.unauthorized").remove();
|
|
||||||
table.find("tbody").append(`<tr class="unauthorized"><td colspan="100%">${safe(text)}</td></tr>`);
|
|
||||||
table.bootstrapTable("hideLoading");
|
|
||||||
} else {
|
|
||||||
// other errors
|
|
||||||
const message = details => `Could not load list of packages: ${details}`;
|
|
||||||
showFailure("Load failure", message, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
packagesLoad(onFailure);
|
|
||||||
statusLoad();
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectRepository() {
|
|
||||||
const fragment = window.location.hash.replace("#", "") || "{{ repositories[0].id }}";
|
|
||||||
document.getElementById(`${fragment}-link`).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusFormat(value) {
|
|
||||||
const cellClass = status => {
|
|
||||||
if (status === "pending") return "table-warning";
|
|
||||||
if (status === "building") return "table-warning";
|
|
||||||
if (status === "failed") return "table-danger";
|
|
||||||
if (status === "success") return "table-success";
|
|
||||||
return "table-secondary";
|
|
||||||
};
|
|
||||||
return {classes: cellClass(value)};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleTableAutoReload(interval) {
|
|
||||||
clearInterval(tableAutoReloadTask);
|
|
||||||
tableAutoReloadTask = toggleAutoReload(tableAutoReloadButton, interval, tableAutoReloadInput, _ => {
|
|
||||||
if (!hasActiveModal() &&
|
|
||||||
!hasActiveDropdown()) {
|
|
||||||
packagesLoad();
|
|
||||||
statusLoad();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ready(_ => {
|
|
||||||
const onCheckFunction = function () {
|
|
||||||
if (packageRemoveButton) {
|
|
||||||
packageRemoveButton.disabled = !getSelection().length;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.querySelectorAll("#repositories a").forEach(element => {
|
|
||||||
element.onclick = _ => {
|
|
||||||
repository = {
|
|
||||||
architecture: element.dataset.architecture,
|
|
||||||
repository: element.dataset.repository,
|
|
||||||
};
|
|
||||||
if (packageUpdateButton) {
|
|
||||||
packageUpdateButton.innerHTML = `<i class="bi bi-play"></i> update<span class="d-none d-sm-inline"> ${safe(repository.repository)} (${safe(repository.architecture)})</span>`;
|
|
||||||
}
|
|
||||||
bootstrap.Tab.getOrCreateInstance(document.getElementById(element.id)).show();
|
|
||||||
reload();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
table.bootstrapTable({
|
|
||||||
onCheck: onCheckFunction,
|
|
||||||
onCheckAll: onCheckFunction,
|
|
||||||
onClickRow: (data, row, cell) => {
|
|
||||||
if (0 === cell || "base" === cell) {
|
|
||||||
const method = data[0] === true ? "uncheckBy" : "checkBy"; // fck javascript
|
|
||||||
table.bootstrapTable(method, {field: "id", values: [data.id]});
|
|
||||||
} else showPackageInfo(data.id);
|
|
||||||
},
|
|
||||||
onCreatedControls: _ => {
|
|
||||||
new easepick.create({
|
|
||||||
element: document.querySelector(".bootstrap-table-filter-control-timestamp"),
|
|
||||||
css: [
|
|
||||||
"https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css",
|
|
||||||
],
|
|
||||||
grid: 2,
|
|
||||||
calendars: 2,
|
|
||||||
autoApply: false,
|
|
||||||
locale: {
|
|
||||||
cancel: "Clear",
|
|
||||||
},
|
|
||||||
RangePlugin: {
|
|
||||||
tooltip: false,
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
"RangePlugin",
|
|
||||||
],
|
|
||||||
setup: picker => {
|
|
||||||
picker.on("select", _ => { table.bootstrapTable("triggerSearch"); });
|
|
||||||
// replace "Cancel" behaviour to "Clear"
|
|
||||||
picker.onClickCancelButton = element => {
|
|
||||||
if (picker.isCancelButton(element)) {
|
|
||||||
picker.clear();
|
|
||||||
picker.hide();
|
|
||||||
table.bootstrapTable("triggerSearch");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onUncheck: onCheckFunction,
|
|
||||||
onUncheckAll: onCheckFunction,
|
|
||||||
});
|
|
||||||
|
|
||||||
restoreAutoReloadSettings(tableAutoReloadButton, tableAutoReloadInput);
|
|
||||||
|
|
||||||
selectRepository();
|
|
||||||
{% if autorefresh_intervals %}
|
|
||||||
toggleTableAutoReload();
|
|
||||||
{% endif %}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
10
package/share/ahriman/templates/static/index.css
Normal file
10
package/share/ahriman/templates/static/index.css
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||||
|
Theme: GitHub
|
||||||
|
Description: Light theme as seen on github.com
|
||||||
|
Author: github.com
|
||||||
|
Maintainer: @Hirse
|
||||||
|
Updated: 2021-05-15
|
||||||
|
|
||||||
|
Outdated base version: https://github.com/primer/github-syntax-light
|
||||||
|
Current colors taken from GitHub's CSS
|
||||||
|
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#005cc5}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-comment,.hljs-code,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
|
||||||
328
package/share/ahriman/templates/static/index.js
Normal file
328
package/share/ahriman/templates/static/index.js
Normal file
File diff suppressed because one or more lines are too long
@@ -22,6 +22,22 @@ from ahriman.web.apispec import Schema, fields
|
|||||||
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
|
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
|
||||||
|
|
||||||
|
|
||||||
|
class AutoRefreshIntervalSchema(Schema):
|
||||||
|
"""
|
||||||
|
auto refresh interval schema
|
||||||
|
"""
|
||||||
|
|
||||||
|
interval = fields.Integer(required=True, metadata={
|
||||||
|
"description": "Auto refresh interval in milliseconds",
|
||||||
|
})
|
||||||
|
is_active = fields.Boolean(required=True, metadata={
|
||||||
|
"description": "Whether this interval is the default active one",
|
||||||
|
})
|
||||||
|
text = fields.String(required=True, metadata={
|
||||||
|
"description": "Human readable interval description",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class InfoSchema(Schema):
|
class InfoSchema(Schema):
|
||||||
"""
|
"""
|
||||||
response service information schema
|
response service information schema
|
||||||
@@ -30,9 +46,21 @@ class InfoSchema(Schema):
|
|||||||
auth = fields.Boolean(dump_default=False, required=True, metadata={
|
auth = fields.Boolean(dump_default=False, required=True, metadata={
|
||||||
"description": "Whether authentication is enabled or not",
|
"description": "Whether authentication is enabled or not",
|
||||||
})
|
})
|
||||||
|
autorefresh_intervals = fields.Nested(AutoRefreshIntervalSchema(many=True), dump_default=[], metadata={
|
||||||
|
"description": "Available auto refresh intervals",
|
||||||
|
})
|
||||||
|
docs_enabled = fields.Boolean(dump_default=False, metadata={
|
||||||
|
"description": "Whether API documentation is enabled",
|
||||||
|
})
|
||||||
|
index_url = fields.String(dump_default=None, load_default=None, metadata={
|
||||||
|
"description": "URL to the repository index page",
|
||||||
|
})
|
||||||
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
|
repositories = fields.Nested(RepositoryIdSchema(many=True), required=True, metadata={
|
||||||
"description": "List of loaded repositories",
|
"description": "List of loaded repositories",
|
||||||
})
|
})
|
||||||
|
username = fields.String(dump_default=None, load_default=None, metadata={
|
||||||
|
"description": "Currently authenticated username if any",
|
||||||
|
})
|
||||||
version = fields.String(required=True, metadata={
|
version = fields.String(required=True, metadata={
|
||||||
"description": "Service version",
|
"description": "Service version",
|
||||||
"example": __version__,
|
"example": __version__,
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ import aiohttp_jinja2
|
|||||||
|
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from ahriman.core.auth.helpers import authorized_userid
|
|
||||||
from ahriman.core.utils import pretty_interval
|
|
||||||
from ahriman.models.user_access import UserAccess
|
from ahriman.models.user_access import UserAccess
|
||||||
from ahriman.web.apispec import aiohttp_apispec
|
|
||||||
from ahriman.web.views.base import BaseView
|
from ahriman.web.views.base import BaseView
|
||||||
|
|
||||||
|
|
||||||
@@ -32,23 +29,6 @@ class IndexView(BaseView):
|
|||||||
"""
|
"""
|
||||||
root view
|
root view
|
||||||
|
|
||||||
It uses jinja2 templates for report generation, the following variables are allowed:
|
|
||||||
|
|
||||||
* auth - authorization descriptor, required
|
|
||||||
* control - HTML to insert for login control, HTML string, required
|
|
||||||
* enabled - whether authorization is enabled by configuration or not, boolean, required
|
|
||||||
* username - authenticated username if any, string, null means not authenticated
|
|
||||||
* autorefresh_intervals - auto refresh intervals, optional
|
|
||||||
* interval - auto refresh interval in milliseconds, integer, required
|
|
||||||
* is_active - is current interval active or not, boolean, required
|
|
||||||
* text - text representation of the interval (e.g. "30 seconds"), string, required
|
|
||||||
* docs_enabled - indicates if api docs is enabled, boolean, required
|
|
||||||
* index_url - url to the repository index, string, optional
|
|
||||||
* repositories - list of repositories unique identifiers, required
|
|
||||||
* id - unique repository identifier, string, required
|
|
||||||
* repository - repository name, string, required
|
|
||||||
* architecture - repository architecture, string, required
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
|
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
|
||||||
"""
|
"""
|
||||||
@@ -59,38 +39,9 @@ class IndexView(BaseView):
|
|||||||
@aiohttp_jinja2.template("build-status.jinja2")
|
@aiohttp_jinja2.template("build-status.jinja2")
|
||||||
async def get(self) -> dict[str, Any]:
|
async def get(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
process get request. No parameters supported here
|
process get request. Serves the React SPA via jinja2 template
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: parameters for jinja template
|
dict[str, Any]: parameters for jinja template
|
||||||
"""
|
"""
|
||||||
auth_username = await authorized_userid(self.request)
|
return {}
|
||||||
auth = {
|
|
||||||
"control": self.validator.auth_control,
|
|
||||||
"enabled": self.validator.enabled,
|
|
||||||
"username": auth_username,
|
|
||||||
}
|
|
||||||
|
|
||||||
autorefresh_intervals = [
|
|
||||||
{
|
|
||||||
"interval": interval * 1000, # milliseconds
|
|
||||||
"is_active": index == 0, # first element is always default
|
|
||||||
"text": pretty_interval(interval),
|
|
||||||
}
|
|
||||||
for index, interval in enumerate(self.configuration.getintlist("web", "autorefresh_intervals", fallback=[]))
|
|
||||||
if interval > 0 # special case if 0 exists and first, refresh will not be turned on by default
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"auth": auth,
|
|
||||||
"autorefresh_intervals": sorted(autorefresh_intervals, key=lambda interval: interval["interval"]),
|
|
||||||
"docs_enabled": aiohttp_apispec is not None,
|
|
||||||
"index_url": self.configuration.get("web", "index_url", fallback=None),
|
|
||||||
"repositories": [
|
|
||||||
{
|
|
||||||
"id": repository.id,
|
|
||||||
**repository.view(),
|
|
||||||
}
|
|
||||||
for repository in sorted(self.services)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,10 +18,15 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
from aiohttp.web import Response, json_response
|
from aiohttp.web import Response, json_response
|
||||||
from typing import ClassVar
|
from collections.abc import Callable
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from ahriman import __version__
|
from ahriman import __version__
|
||||||
|
from ahriman.core.auth.helpers import authorized_userid
|
||||||
|
from ahriman.core.types import Comparable
|
||||||
|
from ahriman.core.utils import pretty_interval
|
||||||
from ahriman.models.user_access import UserAccess
|
from ahriman.models.user_access import UserAccess
|
||||||
|
from ahriman.web.apispec import aiohttp_apispec
|
||||||
from ahriman.web.apispec.decorators import apidocs
|
from ahriman.web.apispec.decorators import apidocs
|
||||||
from ahriman.web.schemas import InfoSchema
|
from ahriman.web.schemas import InfoSchema
|
||||||
from ahriman.web.views.base import BaseView
|
from ahriman.web.views.base import BaseView
|
||||||
@@ -52,12 +57,29 @@ class InfoView(BaseView):
|
|||||||
Returns:
|
Returns:
|
||||||
Response: 200 with service information object
|
Response: 200 with service information object
|
||||||
"""
|
"""
|
||||||
|
autorefresh_intervals = [
|
||||||
|
{
|
||||||
|
"interval": interval * 1000, # milliseconds
|
||||||
|
"is_active": index == 0, # first element is always default
|
||||||
|
"text": pretty_interval(interval),
|
||||||
|
}
|
||||||
|
for index, interval in enumerate(self.configuration.getintlist("web", "autorefresh_intervals", fallback=[]))
|
||||||
|
if interval > 0 # special case if 0 exists and first, refresh will not be turned on by default
|
||||||
|
]
|
||||||
|
comparator: Callable[[dict[str, Any]], Comparable] = lambda interval: interval["interval"]
|
||||||
|
|
||||||
|
username = await authorized_userid(self.request)
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"auth": self.validator.enabled,
|
"auth": self.validator.enabled,
|
||||||
|
"autorefresh_intervals": sorted(autorefresh_intervals, key=comparator),
|
||||||
|
"docs_enabled": aiohttp_apispec is not None,
|
||||||
|
"index_url": self.configuration.get("web", "index_url", fallback=None),
|
||||||
"repositories": [
|
"repositories": [
|
||||||
repository_id.view()
|
repository_id.view()
|
||||||
for repository_id in sorted(self.services)
|
for repository_id in sorted(self.services)
|
||||||
],
|
],
|
||||||
|
"username": username,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ SUBPACKAGES = {
|
|||||||
prefix / "lib" / "systemd" / "system" / "ahriman-web@.service",
|
prefix / "lib" / "systemd" / "system" / "ahriman-web@.service",
|
||||||
prefix / "share" / "ahriman" / "settings" / "ahriman.ini.d" / "00-web.ini",
|
prefix / "share" / "ahriman" / "settings" / "ahriman.ini.d" / "00-web.ini",
|
||||||
prefix / "share" / "ahriman" / "templates" / "api.jinja2",
|
prefix / "share" / "ahriman" / "templates" / "api.jinja2",
|
||||||
prefix / "share" / "ahriman" / "templates" / "build-status",
|
|
||||||
prefix / "share" / "ahriman" / "templates" / "build-status.jinja2",
|
|
||||||
prefix / "share" / "ahriman" / "templates" / "error.jinja2",
|
prefix / "share" / "ahriman" / "templates" / "error.jinja2",
|
||||||
site_packages / "ahriman" / "application" / "handlers" / "web.py",
|
site_packages / "ahriman" / "application" / "handlers" / "web.py",
|
||||||
site_packages / "ahriman" / "core" / "auth",
|
site_packages / "ahriman" / "core" / "auth",
|
||||||
|
|||||||
@@ -569,15 +569,9 @@ def test_walk(resource_path_root: Path) -> None:
|
|||||||
resource_path_root / "models" / "package_yay_pkgbuild",
|
resource_path_root / "models" / "package_yay_pkgbuild",
|
||||||
resource_path_root / "models" / "pkgbuild",
|
resource_path_root / "models" / "pkgbuild",
|
||||||
resource_path_root / "models" / "utf8",
|
resource_path_root / "models" / "utf8",
|
||||||
resource_path_root / "web" / "templates" / "build-status" / "alerts.jinja2",
|
|
||||||
resource_path_root / "web" / "templates" / "build-status" / "dashboard.jinja2",
|
|
||||||
resource_path_root / "web" / "templates" / "build-status" / "key-import-modal.jinja2",
|
|
||||||
resource_path_root / "web" / "templates" / "build-status" / "login-modal.jinja2",
|
|
||||||
resource_path_root / "web" / "templates" / "build-status" / "package-add-modal.jinja2",
|
|
||||||
resource_path_root / "web" / "templates" / "build-status" / "package-info-modal.jinja2",
|
|
||||||
resource_path_root / "web" / "templates" / "build-status" / "package-rebuild-modal.jinja2",
|
|
||||||
resource_path_root / "web" / "templates" / "build-status" / "table.jinja2",
|
|
||||||
resource_path_root / "web" / "templates" / "static" / "favicon.ico",
|
resource_path_root / "web" / "templates" / "static" / "favicon.ico",
|
||||||
|
resource_path_root / "web" / "templates" / "static" / "index.css",
|
||||||
|
resource_path_root / "web" / "templates" / "static" / "index.js",
|
||||||
resource_path_root / "web" / "templates" / "static" / "logo.svg",
|
resource_path_root / "web" / "templates" / "static" / "logo.svg",
|
||||||
resource_path_root / "web" / "templates" / "utils" / "bootstrap-scripts.jinja2",
|
resource_path_root / "web" / "templates" / "utils" / "bootstrap-scripts.jinja2",
|
||||||
resource_path_root / "web" / "templates" / "utils" / "style.jinja2",
|
resource_path_root / "web" / "templates" / "utils" / "style.jinja2",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ async def test_get_index(client_with_auth: TestClient) -> None:
|
|||||||
|
|
||||||
async def test_get_without_auth(client: TestClient) -> None:
|
async def test_get_without_auth(client: TestClient) -> None:
|
||||||
"""
|
"""
|
||||||
must use dummy authorized_userid function in case if no security library installed
|
must generate status page without authorization
|
||||||
"""
|
"""
|
||||||
response = await client.get("/")
|
response = await client.get("/")
|
||||||
assert response.ok
|
assert response.ok
|
||||||
|
|||||||
18
tox.toml
18
tox.toml
@@ -193,6 +193,24 @@ commands = [
|
|||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[env.frontend]
|
||||||
|
description = "Build frontend HTML and JS"
|
||||||
|
allowlist_externals = [
|
||||||
|
"npm",
|
||||||
|
]
|
||||||
|
change_dir = "frontend"
|
||||||
|
commands = [
|
||||||
|
[
|
||||||
|
"npm",
|
||||||
|
"install",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"npm",
|
||||||
|
"run",
|
||||||
|
"build",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
[env.html]
|
[env.html]
|
||||||
description = "Generate html documentation"
|
description = "Generate html documentation"
|
||||||
dependency_groups = [
|
dependency_groups = [
|
||||||
|
|||||||
Reference in New Issue
Block a user