upload ai slop

This commit is contained in:
2026-02-25 22:49:38 +02:00
parent 49ebbc34fa
commit 0d60f5a83d
146 changed files with 4343 additions and 317 deletions

View File

@@ -12,3 +12,6 @@ __pycache__/
*.pyc
*.pyd
*.pyo
node_modules/
package-lock.json

View File

@@ -26,7 +26,7 @@ jobs:
- ${{ github.workspace }}:/build
steps:
- run: pacman --noconfirm -Syu base-devel git python-tox
- run: pacman --noconfirm -Syu base-devel git npm python-tox
- run: git config --global --add safe.directory *

6
.gitignore vendored
View File

@@ -99,3 +99,9 @@ status_cache.json
*.db
docs/html/
# Frontend
node_modules/
package-lock.json
package/share/ahriman/templates/static/index.js
package/share/ahriman/templates/static/index.css

View File

@@ -23,6 +23,7 @@ COPY "docker/install-aur-package.sh" "/usr/local/bin/install-aur-package"
RUN pacman -S --noconfirm --asdeps \
devtools \
git \
npm \
pyalpm \
python-bcrypt \
python-filelock \

View File

@@ -188,6 +188,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
* ``port`` - port to bind, integer, optional.
* ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``.
* ``static_path`` - path to directory with static files, string, required.
* ``template`` - Jinja2 template name for the index page, string, optional, default ``build-status.jinja2``.
* ``templates`` - path to templates directories, space separated list of paths, required.
* ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization.
* ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration.

50
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,50 @@
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"@stylistic": stylistic,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"curly": "error",
"@stylistic/brace-style": ["error", "1tbs"],
// stylistic
"@stylistic/indent": ["error", 4],
"@stylistic/quotes": ["error", "double"],
"@stylistic/semi": ["error", "always"],
"@stylistic/comma-dangle": ["error", "always-multiline"],
"@stylistic/object-curly-spacing": ["error", "always"],
"@stylistic/array-bracket-spacing": ["error", "never"],
"@stylistic/arrow-parens": ["error", "always"],
"@stylistic/eol-last": ["error", "always"],
"@stylistic/no-trailing-spaces": "error",
"@stylistic/no-multiple-empty-lines": ["error", { max: 1 }],
"@stylistic/jsx-quotes": ["error", "prefer-double"],
// typescript
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
"@typescript-eslint/no-deprecated": "error",
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
},
);

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ahriman</title>
<link rel="icon" href="/static/favicon.ico" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

40
frontend/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "ahriman-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint src/",
"lint:fix": "eslint --fix src/",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8",
"@mui/x-data-grid": "^8.27.3",
"@tanstack/react-query": "^5.0.0",
"chart.js": "^4.5.0",
"highlight.js": "^11.11.0",
"react": "^19.2.4",
"react-chartjs-2": "^5.2.0",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin": "^5.9.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.39.3",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"typescript": "^5.3.0",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.1.1"
}
}

38
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,38 @@
import type React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import Theme from "theme/Theme";
import { ClientProvider } from "contexts/ClientProvider";
import { AuthProvider } from "contexts/AuthProvider";
import { RepositoryProvider } from "contexts/RepositoryProvider";
import { NotificationProvider } from "contexts/NotificationProvider";
import AppLayout from "components/layout/AppLayout";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
export default function App(): React.JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={Theme}>
<CssBaseline />
<ClientProvider>
<AuthProvider>
<RepositoryProvider>
<NotificationProvider>
<AppLayout />
</NotificationProvider>
</RepositoryProvider>
</AuthProvider>
</ClientProvider>
</ThemeProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,27 @@
import { repoKey } from "api/types/RepositoryId";
import type { RepositoryId } from "api/types/RepositoryId";
export const QueryKeys = {
info: ["info"] as const,
packages: (repo: RepositoryId) => ["packages", repoKey(repo)] as const,
package: (base: string, repo: RepositoryId) => ["packages", repoKey(repo), base] as const,
status: (repo: RepositoryId) => ["status", repoKey(repo)] as const,
logs: (base: string, repo: RepositoryId) => ["logs", repoKey(repo), base] as const,
logsVersion: (base: string, repo: RepositoryId, version: string, processId: string) =>
["logs", repoKey(repo), base, version, processId] as const,
changes: (base: string, repo: RepositoryId) => ["changes", repoKey(repo), base] as const,
dependencies: (base: string, repo: RepositoryId) => ["dependencies", repoKey(repo), base] as const,
patches: (base: string) => ["patches", base] as const,
events: (repo: RepositoryId, objectId?: string) => ["events", repoKey(repo), objectId] as const,
search: (query: string) => ["search", query] as const,
pgpKey: (key: string, server: string) => ["pgp", key, server] as const,
};

View File

@@ -0,0 +1,218 @@
import type { AURPackage } from "api/types/AURPackage";
import type { Changes } from "api/types/Changes";
import type { Dependencies } from "api/types/Dependencies";
import type { Event } from "api/types/Event";
import type { InfoResponse } from "api/types/InfoResponse";
import type { InternalStatus } from "api/types/InternalStatus";
import type { LogRecord } from "api/types/LogRecord";
import type { LoginRequest } from "api/types/LoginRequest";
import type { PackageActionRequest } from "api/types/PackageActionRequest";
import type { PackageStatus } from "api/types/PackageStatus";
import type { Patch } from "api/types/Patch";
import type { PGPKey } from "api/types/PGPKey";
import type { PGPKeyRequest } from "api/types/PGPKeyRequest";
import type { RepositoryId } from "api/types/RepositoryId";
import { ApiError } from "api/client/ApiError";
import type { RequestOptions } from "api/client/RequestOptions";
export class AhrimanClient {
private static repoQuery(repo: RepositoryId): Record<string, string> {
return { architecture: repo.architecture, repository: repo.repository };
}
private async request<T>(url: string, options: RequestOptions = {}): Promise<T> {
const { method, query, json } = options;
let fullUrl = url;
if (query) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null) {
params.set(key, String(value));
}
}
fullUrl += `?${params.toString()}`;
}
const headers: Record<string, string> = {
Accept: "application/json",
};
if (json !== undefined) {
headers["Content-Type"] = "application/json";
}
const requestInit: RequestInit = {
method: method || (json ? "POST" : "GET"),
headers,
};
if (json !== undefined) {
requestInit.body = JSON.stringify(json);
}
const response = await fetch(fullUrl, requestInit);
if (!response.ok) {
const body = await response.text();
throw new ApiError(response.status, response.statusText, body);
}
const contentType = response.headers.get("Content-Type") ?? "";
if (contentType.includes("application/json")) {
return await response.json() as T;
}
return await response.text() as T;
}
// Info
async fetchInfo(): Promise<InfoResponse> {
return this.request<InfoResponse>("/api/v2/info");
}
// Packages
async fetchPackages(repo: RepositoryId): Promise<PackageStatus[]> {
return this.request<PackageStatus[]>("/api/v1/packages", { query: AhrimanClient.repoQuery(repo) });
}
async fetchPackage(base: string, repo: RepositoryId): Promise<PackageStatus[]> {
return this.request<PackageStatus[]>(`/api/v1/packages/${encodeURIComponent(base)}`, {
query: AhrimanClient.repoQuery(repo),
});
}
// Status
async fetchStatus(repo: RepositoryId): Promise<InternalStatus> {
return this.request<InternalStatus>("/api/v1/status", { query: AhrimanClient.repoQuery(repo) });
}
// Logs
async fetchLogs(
base: string,
repo: RepositoryId,
version?: string,
processId?: string,
head?: boolean,
): Promise<LogRecord[]> {
const query: Record<string, string> = { ...AhrimanClient.repoQuery(repo) };
if (version) {
query.version = version;
}
if (processId) {
query.process_id = processId;
}
if (head) {
query.head = "true";
}
return this.request<LogRecord[]>(`/api/v2/packages/${encodeURIComponent(base)}/logs`, { query });
}
// Changes
async fetchChanges(base: string, repo: RepositoryId): Promise<Changes> {
return this.request<Changes>(`/api/v1/packages/${encodeURIComponent(base)}/changes`, {
query: AhrimanClient.repoQuery(repo),
});
}
// Dependencies
async fetchDependencies(base: string, repo: RepositoryId): Promise<Dependencies> {
return this.request<Dependencies>(`/api/v1/packages/${encodeURIComponent(base)}/dependencies`, {
query: AhrimanClient.repoQuery(repo),
});
}
// Patches
async fetchPatches(base: string): Promise<Patch[]> {
return this.request<Patch[]>(`/api/v1/packages/${encodeURIComponent(base)}/patches`);
}
async deletePatch(base: string, key: string): Promise<void> {
return this.request(`/api/v1/packages/${encodeURIComponent(base)}/patches/${encodeURIComponent(key)}`, {
method: "DELETE",
});
}
// Events
async fetchEvents(repo: RepositoryId, objectId?: string, limit?: number): Promise<Event[]> {
const query: Record<string, string | number> = AhrimanClient.repoQuery(repo);
if (objectId) {
query.object_id = objectId;
}
if (limit) {
query.limit = limit;
}
return this.request<Event[]>("/api/v1/events", { query });
}
// Service actions
async addPackages(repo: RepositoryId, data: PackageActionRequest): Promise<void> {
return this.request("/api/v1/service/add", { method: "POST", query: AhrimanClient.repoQuery(repo), json: data });
}
async removePackages(repo: RepositoryId, packages: string[]): Promise<void> {
return this.request("/api/v1/service/remove", {
method: "POST",
query: AhrimanClient.repoQuery(repo),
json: { packages },
});
}
async updatePackages(repo: RepositoryId, data: PackageActionRequest): Promise<void> {
return this.request("/api/v1/service/update", {
method: "POST",
query: AhrimanClient.repoQuery(repo),
json: data,
});
}
async rebuildPackages(repo: RepositoryId, packages: string[]): Promise<void> {
return this.request("/api/v1/service/rebuild", {
method: "POST",
query: AhrimanClient.repoQuery(repo),
json: { packages },
});
}
async requestPackages(repo: RepositoryId, data: PackageActionRequest): Promise<void> {
return this.request("/api/v1/service/request", {
method: "POST",
query: AhrimanClient.repoQuery(repo),
json: data,
});
}
// Search
async searchPackages(query: string): Promise<AURPackage[]> {
return this.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
}
// PGP
async fetchPGPKey(key: string, server: string): Promise<PGPKey> {
return this.request<PGPKey>("/api/v1/service/pgp", { query: { key, server } });
}
async importPGPKey(data: PGPKeyRequest): Promise<void> {
return this.request("/api/v1/service/pgp", { method: "POST", json: data });
}
// Auth
async login(data: LoginRequest): Promise<void> {
return this.request("/api/v1/login", { method: "POST", json: data });
}
async logout(): Promise<void> {
return this.request("/api/v1/logout", { method: "POST" });
}
}

View File

@@ -0,0 +1,25 @@
export class ApiError extends Error {
status: number;
statusText: string;
body: string;
constructor(status: number, statusText: string, body: string) {
super(`${status} ${statusText}`);
this.status = status;
this.statusText = statusText;
this.body = body;
}
get detail(): string {
try {
const parsed = JSON.parse(this.body) as Record<string, string>;
return parsed.error ?? (this.body || this.message);
} catch {
return this.body || this.message;
}
}
static errorDetail(e: unknown): string {
return e instanceof ApiError ? e.detail : String(e);
}
}

View File

@@ -0,0 +1,5 @@
export interface RequestOptions {
method?: string;
query?: Record<string, string | number | boolean>;
json?: unknown;
}

View File

@@ -0,0 +1,5 @@
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
export function defaultInterval(intervals: AutoRefreshInterval[]): number {
return intervals.find((i) => i.is_active)?.interval ?? 0;
}

View File

@@ -0,0 +1,4 @@
export interface AURPackage {
package: string;
description: string;
}

View File

@@ -0,0 +1,5 @@
export interface AuthInfo {
control: string;
enabled: boolean;
username?: string;
}

View File

@@ -0,0 +1,5 @@
export interface AutoRefreshInterval {
interval: number;
is_active: boolean;
text: string;
}

View File

@@ -0,0 +1 @@
export type BuildStatus = "unknown" | "pending" | "building" | "failed" | "success";

View File

@@ -0,0 +1,4 @@
export interface Changes {
changes?: string;
last_commit_sha?: string;
}

View File

@@ -0,0 +1,8 @@
export interface Counters {
building: number;
failed: number;
pending: number;
success: number;
total: number;
unknown: number;
}

View File

@@ -0,0 +1,3 @@
export interface Dependencies {
paths: Record<string, string[]>;
}

View File

@@ -0,0 +1,7 @@
export interface Event {
created: number;
data?: Record<string, unknown>;
event: string;
message?: string;
object_id: string;
}

View File

@@ -0,0 +1,12 @@
import type { AuthInfo } from "api/types/AuthInfo";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { RepositoryId } from "api/types/RepositoryId";
export interface InfoResponse {
auth: AuthInfo;
repositories: RepositoryId[];
version: string;
autorefresh_intervals: AutoRefreshInterval[];
docs_enabled: boolean;
index_url?: string;
}

View 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;
}

View File

@@ -0,0 +1,6 @@
export interface LogRecord {
created: number;
message: string;
process_id: string;
version: string;
}

View File

@@ -0,0 +1,4 @@
export interface LoginRequest {
username: string;
password: string;
}

View File

@@ -0,0 +1,3 @@
export interface PGPKey {
key: string;
}

View File

@@ -0,0 +1,4 @@
export interface PGPKeyRequest {
key: string;
server: string;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1 @@
export type PackageSource = "auto" | "archive" | "aur" | "directory" | "local" | "remote" | "repository";

View 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;
}

View File

@@ -0,0 +1,4 @@
export interface Patch {
key: string;
value: string;
}

View File

@@ -0,0 +1,9 @@
import type { PackageSource } from "api/types/PackageSource";
export interface Remote {
branch?: string;
git_url?: string;
path?: string;
source: PackageSource;
web_url?: string;
}

View File

@@ -0,0 +1,12 @@
export interface RepositoryId {
architecture: string;
repository: string;
}
export function repoKey(repo: RepositoryId): string {
return `${repo.architecture}-${repo.repository}`;
}
export function repoLabel(repo: RepositoryId): string {
return `${repo.repository} (${repo.architecture})`;
}

View File

@@ -0,0 +1,6 @@
export interface RepositoryStats {
archive_size?: number;
bases?: number;
installed_size?: number;
packages?: number;
}

View File

@@ -0,0 +1,6 @@
import type { BuildStatus } from "api/types/BuildStatus";
export interface Status {
status: BuildStatus;
timestamp: number;
}

View File

@@ -0,0 +1,22 @@
import {
Chart as ChartJS,
ArcElement,
BarElement,
CategoryScale,
Legend,
LinearScale,
LineElement,
PointElement,
Tooltip,
} from "chart.js";
ChartJS.register(
ArcElement,
BarElement,
CategoryScale,
Legend,
LinearScale,
LineElement,
PointElement,
Tooltip,
);

View File

@@ -0,0 +1,30 @@
import type React from "react";
import { Line } from "react-chartjs-2";
import type { Event } from "api/types/Event";
import { formatTimestamp } from "components/common/formatTimestamp";
interface EventDurationLineChartProps {
events: Event[];
}
export default function EventDurationLineChart({ events }: EventDurationLineChartProps): React.JSX.Element | null {
const updateEvents = events.filter((e) => e.event === "package-updated" && e.data?.took);
if (updateEvents.length === 0) {
return null;
}
const data = {
labels: updateEvents.map((e) => formatTimestamp(e.created)),
datasets: [
{
label: "update duration, s",
data: updateEvents.map((e) => (e.data?.took as number | undefined) ?? 0),
cubicInterpolationMode: "monotone" as const,
tension: 0.4,
},
],
};
return <Line data={data} options={{ responsive: true }} />;
}

View File

@@ -0,0 +1,40 @@
import type React from "react";
import { Bar } from "react-chartjs-2";
import { blue, indigo } from "@mui/material/colors";
import type { RepositoryStats } from "api/types/RepositoryStats";
interface PackageCountBarChartProps {
stats: RepositoryStats;
}
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
const data = {
labels: ["packages"],
datasets: [
{
label: "archives",
data: [stats.packages ?? 0],
backgroundColor: blue[500],
},
{
label: "bases",
data: [stats.bases ?? 0],
backgroundColor: indigo[300],
},
],
};
return (
<Bar
data={data}
options={{
maintainAspectRatio: false,
responsive: true,
scales: {
x: { stacked: true },
y: { stacked: true },
},
}}
/>
);
}

View File

@@ -0,0 +1,24 @@
import type React from "react";
import { Pie } from "react-chartjs-2";
import type { Counters } from "api/types/Counters";
import { StatusColors } from "theme/status/StatusColors";
interface StatusPieChartProps {
counters: Counters;
}
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
const labels = ["unknown", "pending", "building", "failed", "success"] as const;
const data = {
labels: [...labels],
datasets: [
{
label: "packages in status",
data: labels.map((label) => counters[label]),
backgroundColor: labels.map((label) => StatusColors[label]),
},
],
};
return <Pie data={data} options={{ responsive: true }} />;
}

View File

@@ -0,0 +1,76 @@
import React, { useState } from "react";
import { IconButton, Menu, MenuItem, Tooltip, ListItemIcon, ListItemText } from "@mui/material";
import TimerIcon from "@mui/icons-material/Timer";
import TimerOffIcon from "@mui/icons-material/TimerOff";
import CheckIcon from "@mui/icons-material/Check";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
interface AutoRefreshControlProps {
intervals: AutoRefreshInterval[];
enabled: boolean;
currentInterval: number;
onToggle: (enabled: boolean) => void;
onIntervalChange: (interval: number) => void;
}
export default function AutoRefreshControl({
intervals,
enabled,
currentInterval,
onToggle,
onIntervalChange,
}: AutoRefreshControlProps): React.JSX.Element | null {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
if (intervals.length === 0) {
return null;
}
return (
<>
<Tooltip title="Auto-refresh">
<IconButton
size="small"
onClick={(e) => setAnchorEl(e.currentTarget)}
color={enabled ? "primary" : "default"}
>
{enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
<MenuItem
selected={!enabled}
onClick={() => {
onToggle(false);
setAnchorEl(null);
}}
>
<ListItemIcon>
{!enabled && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>Off</ListItemText>
</MenuItem>
{intervals.map((iv) => (
<MenuItem
key={iv.interval}
selected={enabled && iv.interval === currentInterval}
onClick={() => {
onIntervalChange(iv.interval);
onToggle(true);
setAnchorEl(null);
}}
>
<ListItemIcon>
{enabled && iv.interval === currentInterval && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>{iv.text}</ListItemText>
</MenuItem>
))}
</Menu>
</>
);
}

View File

@@ -0,0 +1,56 @@
import React, { type RefObject } from "react";
import { Box } from "@mui/material";
import CopyButton from "components/common/CopyButton";
interface CodeBlockProps {
codeRef?: RefObject<HTMLElement | null>;
preRef?: RefObject<HTMLElement | null>;
className?: string;
children?: React.ReactNode;
getText: () => string;
maxHeight?: number | string;
height?: number | string;
onScroll?: () => void;
wordBreak?: boolean;
}
export default function CodeBlock({
codeRef,
preRef,
className,
children,
getText,
maxHeight,
height,
onScroll,
wordBreak,
}: CodeBlockProps): React.JSX.Element {
return (
<Box sx={{ position: "relative" }}>
<Box
ref={preRef}
component="pre"
onScroll={onScroll}
sx={{
backgroundColor: "grey.100",
p: 2,
borderRadius: 1,
overflow: "auto",
maxHeight,
height,
fontSize: "0.8rem",
fontFamily: "monospace",
...(wordBreak ? { whiteSpace: "pre-wrap", wordBreak: "break-all" } : {}),
}}
>
{codeRef
? <code ref={codeRef} className={className} />
: <code className={className}>{children}</code>
}
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={getText} />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,30 @@
import React, { useEffect, useRef, useState } from "react";
import { IconButton, Tooltip } from "@mui/material";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import CheckIcon from "@mui/icons-material/Check";
interface CopyButtonProps {
getText: () => string;
}
export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timerRef.current), []);
const handleCopy = async (): Promise<void> => {
await navigator.clipboard.writeText(getText());
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 2000);
};
return (
<Tooltip title={copied ? "Copied!" : "Copy"}>
<IconButton size="small" aria-label={copied ? "Copied" : "Copy"} onClick={() => void handleCopy()}>
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</Tooltip>
);
}

View File

@@ -0,0 +1,20 @@
import type React from "react";
import { DialogTitle, IconButton, type SxProps, type Theme } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
interface DialogHeaderProps {
children: React.ReactNode;
onClose: () => void;
sx?: SxProps<Theme>;
}
export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element {
return (
<DialogTitle sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", ...sx }}>
{children}
<IconButton aria-label="Close" onClick={onClose} size="small" sx={{ color: "inherit" }}>
<CloseIcon />
</IconButton>
</DialogTitle>
);
}

View File

@@ -0,0 +1,30 @@
import type React from "react";
import { Select, MenuItem, InputLabel, FormControl } from "@mui/material";
import { useRepository } from "hooks/useRepository";
import { repoKey, repoLabel } from "api/types/RepositoryId";
import type { SelectedRepositoryResult } from "hooks/useSelectedRepository";
interface RepositorySelectProps {
repoSelect: SelectedRepositoryResult;
}
export default function RepositorySelect({ repoSelect }: RepositorySelectProps): React.JSX.Element {
const { repositories, current } = useRepository();
return (
<FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel>
<Select
value={repoSelect.selectedKey || (current ? repoKey(current) : "")}
label="repository"
onChange={(e) => repoSelect.setSelectedKey(e.target.value)}
>
{repositories.map((r) => (
<MenuItem key={repoKey(r)} value={repoKey(r)}>
{repoLabel(r)}
</MenuItem>
))}
</Select>
</FormControl>
);
}

View 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())}`;
}

View File

@@ -0,0 +1,90 @@
import type React from "react";
import { Dialog, DialogContent, Grid, Typography, Box } from "@mui/material";
import DialogHeader from "components/common/DialogHeader";
import { skipToken, useQuery } from "@tanstack/react-query";
import StatusPieChart from "components/charts/StatusPieChart";
import PackageCountBarChart from "components/charts/PackageCountBarChart";
import { QueryKeys } from "api/QueryKeys";
import { useClient } from "hooks/useClient";
import { useRepository } from "hooks/useRepository";
import { useDialogClose } from "hooks/useDialogClose";
import { StatusHeaderStyles } from "theme/status/StatusColors";
import { formatTimestamp } from "components/common/formatTimestamp";
import type { InternalStatus } from "api/types/InternalStatus";
interface DashboardDialogProps {
open: boolean;
onClose: () => void;
}
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
const client = useClient();
const { current } = useRepository();
const { isOpen, requestClose, transitionProps } = useDialogClose(open, onClose);
const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetchStatus(current) : skipToken,
enabled: !!current && open,
});
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
return (
<Dialog open={isOpen} onClose={requestClose} maxWidth="lg" fullWidth slotProps={{ transition: transitionProps }}>
<DialogHeader onClose={requestClose} sx={headerStyle}>
System health
</DialogHeader>
<DialogContent>
{status && (
<>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2" color="text.secondary" align="right">Repository name</Typography>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2">{status.repository}</Typography>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2" color="text.secondary" align="right">Repository architecture</Typography>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2">{status.architecture}</Typography>
</Grid>
</Grid>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2" color="text.secondary" align="right">Current status</Typography>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2">{status.status.status}</Typography>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2" color="text.secondary" align="right">Updated at</Typography>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Typography variant="body2">{formatTimestamp(status.status.timestamp)}</Typography>
</Grid>
</Grid>
{status.packages.total > 0 && (
<Grid container spacing={2} sx={{ mt: 2 }}>
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ maxHeight: 300 }}>
<PackageCountBarChart stats={status.stats} />
</Box>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ maxHeight: 300, display: "flex", justifyContent: "center", alignItems: "center" }}>
<StatusPieChart counters={status.packages} />
</Box>
</Grid>
</Grid>
)}
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,99 @@
import React, { useCallback, useState } from "react";
import {
Dialog, DialogContent, DialogActions, Button,
TextField, Box,
} from "@mui/material";
import DialogHeader from "components/common/DialogHeader";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RefreshIcon from "@mui/icons-material/Refresh";
import { useNotification } from "hooks/useNotification";
import { useClient } from "hooks/useClient";
import { useDialogClose } from "hooks/useDialogClose";
import { ApiError } from "api/client/ApiError";
import CodeBlock from "components/common/CodeBlock";
interface KeyImportDialogProps {
open: boolean;
onClose: () => void;
}
export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element {
const client = useClient();
const { showSuccess, showError } = useNotification();
const [fingerprint, setFingerprint] = useState("");
const [server, setServer] = useState("keyserver.ubuntu.com");
const [keyBody, setKeyBody] = useState("");
const onOpen = useCallback(() => {
setFingerprint("");
setServer("keyserver.ubuntu.com");
setKeyBody("");
}, []);
const { isOpen, requestClose, transitionProps } = useDialogClose(open, onClose, onOpen);
const handleFetch = async (): Promise<void> => {
if (!fingerprint || !server) {
return;
}
try {
const result = await client.fetchPGPKey(fingerprint, server);
setKeyBody(result.key);
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Could not fetch key: ${detail}`);
}
};
const handleImport = async (): Promise<void> => {
if (!fingerprint || !server) {
return;
}
try {
await client.importPGPKey({ key: fingerprint, server });
requestClose();
showSuccess("Success", `Key ${fingerprint} has been imported`);
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`);
}
};
return (
<Dialog open={isOpen} onClose={requestClose} maxWidth="lg" fullWidth slotProps={{ transition: transitionProps }}>
<DialogHeader onClose={requestClose}>
Import key from PGP server
</DialogHeader>
<DialogContent>
<TextField
label="fingerprint"
placeholder="PGP key fingerprint"
fullWidth
margin="normal"
value={fingerprint}
onChange={(e) => setFingerprint(e.target.value)}
/>
<TextField
label="key server"
placeholder="PGP key server"
fullWidth
margin="normal"
value={server}
onChange={(e) => setServer(e.target.value)}
/>
{keyBody && (
<Box sx={{ mt: 2 }}>
<CodeBlock getText={() => keyBody} maxHeight={300}>
{keyBody}
</CodeBlock>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => void handleImport()} variant="contained" startIcon={<PlayArrowIcon />}>import</Button>
<Button onClick={() => void handleFetch()} variant="contained" color="success" startIcon={<RefreshIcon />}>fetch</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,98 @@
import React, { useCallback, useState } from "react";
import {
Dialog, DialogContent, DialogActions, Button, TextField,
InputAdornment, IconButton,
} from "@mui/material";
import DialogHeader from "components/common/DialogHeader";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import PersonIcon from "@mui/icons-material/Person";
import { useAuth } from "hooks/useAuth";
import { useNotification } from "hooks/useNotification";
import { useDialogClose } from "hooks/useDialogClose";
import { ApiError } from "api/client/ApiError";
interface LoginDialogProps {
open: boolean;
onClose: () => void;
}
export default function LoginDialog({ open, onClose }: LoginDialogProps): React.JSX.Element {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const { login } = useAuth();
const { showSuccess, showError } = useNotification();
const onOpen = useCallback(() => {
setUsername("");
setPassword("");
setShowPassword(false);
}, []);
const { isOpen, requestClose, transitionProps } = useDialogClose(open, onClose, onOpen);
const handleSubmit = async (): Promise<void> => {
if (!username || !password) {
return;
}
try {
await login(username, password);
requestClose();
showSuccess("Logged in", `Successfully logged in as ${username}`);
window.location.reload();
} catch (e) {
const detail = ApiError.errorDetail(e);
if (username === "admin" && password === "admin") {
showError("Login error", "You've entered a password for user \"root\", did you make a typo in username?");
} else {
showError("Login error", `Could not login as ${username}: ${detail}`);
}
}
};
return (
<Dialog open={isOpen} onClose={requestClose} maxWidth="xs" fullWidth slotProps={{ transition: transitionProps }}>
<DialogHeader onClose={requestClose}>
Login
</DialogHeader>
<DialogContent>
<TextField
label="username"
fullWidth
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
/>
<TextField
label="password"
fullWidth
margin="normal"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleSubmit();
}
}}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton aria-label={showPassword ? "Hide password" : "Show password"} onClick={() => setShowPassword(!showPassword)} edge="end" size="small">
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</InputAdornment>
),
},
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => void handleSubmit()} variant="contained" startIcon={<PersonIcon />}>login</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,184 @@
import React, { useCallback, useRef, useState } from "react";
import {
Dialog, DialogContent, DialogActions, Button,
TextField, Autocomplete, Box, IconButton, FormControlLabel, Checkbox,
} from "@mui/material";
import DialogHeader from "components/common/DialogHeader";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import AddIcon from "@mui/icons-material/Add";
import DeleteIcon from "@mui/icons-material/Delete";
import { useQuery } from "@tanstack/react-query";
import RepositorySelect from "components/common/RepositorySelect";
import { useNotification } from "hooks/useNotification";
import { useSelectedRepository } from "hooks/useSelectedRepository";
import { useDebounce } from "hooks/useDebounce";
import { useClient } from "hooks/useClient";
import { useDialogClose } from "hooks/useDialogClose";
import { ApiError } from "api/client/ApiError";
import { QueryKeys } from "api/QueryKeys";
import type { AURPackage } from "api/types/AURPackage";
interface EnvVar {
id: number;
key: string;
value: string;
}
interface PackageAddDialogProps {
open: boolean;
onClose: () => void;
}
export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
const client = useClient();
const { showSuccess, showError } = useNotification();
const repoSelect = useSelectedRepository();
const [packageName, setPackageName] = useState("");
const [refresh, setRefresh] = useState(true);
const [envVars, setEnvVars] = useState<EnvVar[]>([]);
const envIdCounter = useRef(0);
const { reset: resetRepoSelect } = repoSelect;
const onOpen = useCallback(() => {
setPackageName("");
resetRepoSelect();
setRefresh(true);
setEnvVars([]);
}, [resetRepoSelect]);
const { isOpen, requestClose, transitionProps } = useDialogClose(open, onClose, onOpen);
const debouncedSearch = useDebounce(packageName, 500);
const { data: searchResults = [] } = useQuery<AURPackage[]>({
queryKey: QueryKeys.search(debouncedSearch),
queryFn: () => client.searchPackages(debouncedSearch),
enabled: debouncedSearch.length >= 3,
});
const handleAdd = async (): Promise<void> => {
if (!packageName) {
return;
}
const repo = repoSelect.selectedRepo;
if (!repo) {
return;
}
try {
const patches = envVars.filter((v) => v.key);
await client.addPackages(repo, {
packages: [packageName],
patches: patches.length > 0 ? patches : undefined,
refresh,
});
requestClose();
showSuccess("Success", `Packages ${packageName} have been added`);
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Package addition failed: ${detail}`);
}
};
const handleRequest = async (): Promise<void> => {
if (!packageName) {
return;
}
const repo = repoSelect.selectedRepo;
if (!repo) {
return;
}
try {
const patches = envVars.filter((v) => v.key);
await client.requestPackages(repo, {
packages: [packageName],
patches: patches.length > 0 ? patches : undefined,
});
requestClose();
showSuccess("Success", `Packages ${packageName} have been requested`);
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Package request failed: ${detail}`);
}
};
return (
<Dialog open={isOpen} onClose={requestClose} maxWidth="md" fullWidth slotProps={{ transition: transitionProps }}>
<DialogHeader onClose={requestClose}>
Add new packages
</DialogHeader>
<DialogContent>
<RepositorySelect repoSelect={repoSelect} />
<Autocomplete
freeSolo
options={searchResults.map((p) => p.package)}
inputValue={packageName}
onInputChange={(_, value) => setPackageName(value)}
renderOption={(props, option) => {
const pkg = searchResults.find((p) => p.package === option);
return (
<li {...props} key={option}>
{option}{pkg ? ` (${pkg.description})` : ""}
</li>
);
}}
renderInput={(params) => (
<TextField {...params} label="package" placeholder="AUR package" margin="normal" />
)}
/>
<FormControlLabel
control={<Checkbox checked={refresh} onChange={(_, checked) => setRefresh(checked)} />}
label="update pacman databases"
/>
<Button
fullWidth
variant="outlined"
startIcon={<AddIcon />}
onClick={() => {
const id = envIdCounter.current++;
setEnvVars((prev) => [...prev, { id, key: "", value: "" }]);
}}
sx={{ mt: 1 }}
>
add environment variable
</Button>
{envVars.map((env) => (
<Box key={env.id} sx={{ display: "flex", gap: 1, mt: 1, alignItems: "center" }}>
<TextField
size="small"
placeholder="name"
value={env.key}
onChange={(e) => {
const newKey = e.target.value;
setEnvVars((prev) => prev.map((v) => v.id === env.id ? { ...v, key: newKey } : v));
}}
sx={{ flex: 1 }}
/>
<Box>=</Box>
<TextField
size="small"
placeholder="value"
value={env.value}
onChange={(e) => {
const newValue = e.target.value;
setEnvVars((prev) => prev.map((v) => v.id === env.id ? { ...v, value: newValue } : v));
}}
sx={{ flex: 1 }}
/>
<IconButton size="small" color="error" aria-label="Remove variable" onClick={() => setEnvVars((prev) => prev.filter((v) => v.id !== env.id))}>
<DeleteIcon />
</IconButton>
</Box>
))}
</DialogContent>
<DialogActions>
<Button onClick={() => void handleAdd()} variant="contained" startIcon={<PlayArrowIcon />}>add</Button>
<Button onClick={() => void handleRequest()} variant="contained" color="success" startIcon={<AddIcon />}>request</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,178 @@
import React, { useCallback, useState } from "react";
import { Dialog, DialogContent, Box, Tab, Tabs } from "@mui/material";
import DialogHeader from "components/common/DialogHeader";
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import PackageDetailsGrid from "components/package/PackageDetailsGrid";
import PackagePatchesList from "components/package/PackagePatchesList";
import PackageInfoActions from "components/package/PackageInfoActions";
import BuildLogsTab from "components/package/BuildLogsTab";
import ChangesTab from "components/package/ChangesTab";
import EventsTab from "components/package/EventsTab";
import { useRepository } from "hooks/useRepository";
import { useAuth } from "hooks/useAuth";
import { useNotification } from "hooks/useNotification";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { useClient } from "hooks/useClient";
import { useDialogClose } from "hooks/useDialogClose";
import { ApiError } from "api/client/ApiError";
import { defaultInterval } from "api/defaultInterval";
import { QueryKeys } from "api/QueryKeys";
import { StatusHeaderStyles } from "theme/status/StatusColors";
import { formatTimestamp } from "components/common/formatTimestamp";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { Dependencies } from "api/types/Dependencies";
import type { PackageStatus } from "api/types/PackageStatus";
import type { Patch } from "api/types/Patch";
interface PackageInfoDialogProps {
packageBase: string | null;
open: boolean;
onClose: () => void;
autorefreshIntervals: AutoRefreshInterval[];
}
export default function PackageInfoDialog({ packageBase, open, onClose, autorefreshIntervals }: PackageInfoDialogProps): React.JSX.Element {
const client = useClient();
const { current } = useRepository();
const { isAuthorized } = useAuth();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const [tabIndex, setTabIndex] = useState(0);
const [refreshDb, setRefreshDb] = useState(true);
const onOpen = useCallback(() => {
setTabIndex(0);
setRefreshDb(true);
}, []);
const { isOpen, requestClose, transitionProps } = useDialogClose(open, onClose, onOpen);
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autorefreshIntervals));
const { data: packageData } = useQuery<PackageStatus[]>({
queryKey: packageBase && current ? QueryKeys.package(packageBase, current) : ["package-none"],
queryFn: packageBase && current ? () => client.fetchPackage(packageBase, current) : skipToken,
enabled: !!packageBase && !!current && open,
refetchInterval: autoRefresh.refetchInterval,
});
const { data: dependencies } = useQuery<Dependencies>({
queryKey: packageBase && current ? QueryKeys.dependencies(packageBase, current) : ["deps-none"],
queryFn: packageBase && current ? () => client.fetchDependencies(packageBase, current) : skipToken,
enabled: !!packageBase && !!current && open,
});
const { data: patches = [] } = useQuery<Patch[]>({
queryKey: packageBase ? QueryKeys.patches(packageBase) : ["patches-none"],
queryFn: packageBase ? () => client.fetchPatches(packageBase) : skipToken,
enabled: !!packageBase && open,
});
const description: PackageStatus | undefined = packageData?.[0];
const pkg = description?.package;
const status = description?.status;
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
const handleUpdate = async (): Promise<void> => {
if (!packageBase || !current) {
return;
}
try {
await client.addPackages(current, { packages: [packageBase], refresh: refreshDb });
showSuccess("Success", `Run update for packages ${packageBase}`);
} catch (e) {
showError("Action failed", `Package update failed: ${ApiError.errorDetail(e)}`);
}
};
const handleRemove = async (): Promise<void> => {
if (!packageBase || !current) {
return;
}
try {
await client.removePackages(current, [packageBase]);
showSuccess("Success", `Packages ${packageBase} have been removed`);
requestClose();
} catch (e) {
showError("Action failed", `Could not remove package: ${ApiError.errorDetail(e)}`);
}
};
const handleDeletePatch = async (key: string): Promise<void> => {
if (!packageBase) {
return;
}
try {
await client.deletePatch(packageBase, key);
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
} catch (e) {
showError("Action failed", `Could not delete variable: ${ApiError.errorDetail(e)}`);
}
};
const handleReload = (): void => {
if (!packageBase || !current) {
return;
}
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(packageBase, current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.logs(packageBase, current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.changes(packageBase, current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.events(current, packageBase) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.dependencies(packageBase, current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
};
return (
<Dialog open={isOpen} onClose={requestClose} maxWidth="lg" fullWidth slotProps={{ transition: transitionProps }}>
<DialogHeader onClose={requestClose} sx={headerStyle}>
{pkg && status
? `${pkg.base} ${status.status} at ${formatTimestamp(status.timestamp)}`
: packageBase ?? ""}
</DialogHeader>
<DialogContent>
{pkg && (
<>
<PackageDetailsGrid pkg={pkg} dependencies={dependencies} />
<PackagePatchesList
patches={patches}
editable={isAuthorized}
onDelete={(key) => void handleDeletePatch(key)}
/>
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
<Tabs value={tabIndex} onChange={(_, v: number) => setTabIndex(v)}>
<Tab label="Build logs" />
<Tab label="Changes" />
<Tab label="Events" />
</Tabs>
</Box>
{tabIndex === 0 && packageBase && current && (
<BuildLogsTab packageBase={packageBase} repo={current} refetchInterval={autoRefresh.refetchInterval} />
)}
{tabIndex === 1 && packageBase && current && (
<ChangesTab packageBase={packageBase} repo={current} />
)}
{tabIndex === 2 && packageBase && current && (
<EventsTab packageBase={packageBase} repo={current} />
)}
</>
)}
</DialogContent>
<PackageInfoActions
isAuthorized={isAuthorized}
refreshDb={refreshDb}
onRefreshDbChange={setRefreshDb}
onUpdate={() => void handleUpdate()}
onRemove={() => void handleRemove()}
onReload={handleReload}
autorefreshIntervals={autorefreshIntervals}
autoRefreshEnabled={autoRefresh.enabled}
autoRefreshInterval={autoRefresh.interval}
onAutoRefreshToggle={autoRefresh.setEnabled}
onAutoRefreshIntervalChange={autoRefresh.changeInterval}
/>
</Dialog>
);
}

View File

@@ -0,0 +1,75 @@
import React, { useCallback, useState } from "react";
import {
Dialog, DialogContent, DialogActions, Button,
TextField,
} from "@mui/material";
import DialogHeader from "components/common/DialogHeader";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RepositorySelect from "components/common/RepositorySelect";
import { useNotification } from "hooks/useNotification";
import { useSelectedRepository } from "hooks/useSelectedRepository";
import { useClient } from "hooks/useClient";
import { useDialogClose } from "hooks/useDialogClose";
import { ApiError } from "api/client/ApiError";
interface PackageRebuildDialogProps {
open: boolean;
onClose: () => void;
}
export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
const client = useClient();
const { showSuccess, showError } = useNotification();
const repoSelect = useSelectedRepository();
const [dependency, setDependency] = useState("");
const { reset: resetRepoSelect } = repoSelect;
const onOpen = useCallback(() => {
setDependency("");
resetRepoSelect();
}, [resetRepoSelect]);
const { isOpen, requestClose, transitionProps } = useDialogClose(open, onClose, onOpen);
const handleRebuild = async (): Promise<void> => {
if (!dependency) {
return;
}
const repo = repoSelect.selectedRepo;
if (!repo) {
return;
}
try {
await client.rebuildPackages(repo, [dependency]);
requestClose();
showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`);
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Repository rebuild failed: ${detail}`);
}
};
return (
<Dialog open={isOpen} onClose={requestClose} maxWidth="md" fullWidth slotProps={{ transition: transitionProps }}>
<DialogHeader onClose={requestClose}>
Rebuild depending packages
</DialogHeader>
<DialogContent>
<RepositorySelect repoSelect={repoSelect} />
<TextField
label="dependency"
placeholder="packages dependency"
fullWidth
margin="normal"
value={dependency}
onChange={(e) => setDependency(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => void handleRebuild()} variant="contained" startIcon={<PlayArrowIcon />}>rebuild</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,59 @@
import React, { useState, useEffect } from "react";
import { Container, Box } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import Navbar from "components/layout/Navbar";
import Footer from "components/layout/Footer";
import PackageTable from "components/table/PackageTable";
import LoginDialog from "components/dialogs/LoginDialog";
import { useAuth } from "hooks/useAuth";
import { useClient } from "hooks/useClient";
import { useRepository } from "hooks/useRepository";
import { QueryKeys } from "api/QueryKeys";
import type { InfoResponse } from "api/types/InfoResponse";
export default function AppLayout(): React.JSX.Element {
const client = useClient();
const { setAuthState } = useAuth();
const { setRepositories } = useRepository();
const [loginOpen, setLoginOpen] = useState(false);
const { data: info } = useQuery<InfoResponse>({
queryKey: QueryKeys.info,
queryFn: () => client.fetchInfo(),
staleTime: Infinity,
});
// Sync info to contexts when loaded
useEffect(() => {
if (info) {
setAuthState({ enabled: info.auth.enabled, username: info.auth.username ?? null });
setRepositories(info.repositories);
}
}, [info, setAuthState, setRepositories]);
return (
<Container maxWidth="xl">
<Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}>
<a href="https://github.com/arcan1s/ahriman" title="logo">
<img src="/static/logo.svg" width={30} height={30} alt="" />
</a>
<Box sx={{ flex: 1 }}>
<Navbar />
</Box>
</Box>
<PackageTable
autorefreshIntervals={info?.autorefresh_intervals ?? []}
/>
<Footer
version={info?.version ?? ""}
docsEnabled={info?.docs_enabled ?? false}
indexUrl={info?.index_url}
onLoginClick={() => setLoginOpen(true)}
/>
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
</Container>
);
}

View File

@@ -0,0 +1,79 @@
import type React from "react";
import { Box, Link, Button, Typography } from "@mui/material";
import GitHubIcon from "@mui/icons-material/GitHub";
import LogoutIcon from "@mui/icons-material/Logout";
import HomeIcon from "@mui/icons-material/Home";
import { useAuth } from "hooks/useAuth";
interface FooterProps {
version: string;
docsEnabled: boolean;
indexUrl?: string;
onLoginClick: () => void;
}
export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }: FooterProps): React.JSX.Element {
const { enabled: authEnabled, username, logout } = useAuth();
const handleLogout = async (): Promise<void> => {
await logout();
window.location.reload();
};
return (
<Box
component="footer"
sx={{
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
alignItems: "center",
borderTop: 1,
borderColor: "divider",
mt: 2,
py: 1,
}}
>
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
<Link href="https://github.com/arcan1s/ahriman" underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<GitHubIcon fontSize="small" />
<Typography variant="body2">ahriman {version}</Typography>
</Link>
<Link href="https://github.com/arcan1s/ahriman/releases" underline="hover" color="text.secondary" variant="body2">
releases
</Link>
<Link href="https://github.com/arcan1s/ahriman/issues" underline="hover" color="text.secondary" variant="body2">
report a bug
</Link>
{docsEnabled && (
<Link href="/api-docs" underline="hover" color="text.secondary" variant="body2">
api
</Link>
)}
</Box>
{indexUrl && (
<Box>
<Link href={indexUrl} underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<HomeIcon fontSize="small" />
<Typography variant="body2">repo index</Typography>
</Link>
</Box>
)}
{authEnabled && (
<Box>
{username ? (
<Button size="small" startIcon={<LogoutIcon />} onClick={() => void handleLogout()} sx={{ textTransform: "none" }}>
logout ({username})
</Button>
) : (
<Button size="small" onClick={onLoginClick} sx={{ textTransform: "none" }}>
login
</Button>
)}
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,39 @@
import type React from "react";
import { Tabs, Tab, Box } from "@mui/material";
import { useRepository } from "hooks/useRepository";
import { repoKey, repoLabel } from "api/types/RepositoryId";
export default function Navbar(): React.JSX.Element | null {
const { repositories, current, setCurrent } = useRepository();
if (repositories.length === 0 || !current) {
return null;
}
const currentIndex = repositories.findIndex(
(r) => r.architecture === current.architecture && r.repository === current.repository,
);
return (
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs
value={currentIndex >= 0 ? currentIndex : 0}
onChange={(_, newValue: number) => {
const repo = repositories[newValue];
if (repo) {
setCurrent(repo);
}
}}
variant="scrollable"
scrollButtons="auto"
>
{repositories.map((repo) => (
<Tab
key={repoKey(repo)}
label={repoLabel(repo)}
/>
))}
</Tabs>
</Box>
);
}

View File

@@ -0,0 +1,176 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
import ListIcon from "@mui/icons-material/List";
import { keepPreviousData, skipToken, useQuery } from "@tanstack/react-query";
import hljs from "highlight.js/lib/core";
import plaintext from "highlight.js/lib/languages/plaintext";
import "highlight.js/styles/github.css";
import { useClient } from "hooks/useClient";
import { useAutoScroll } from "hooks/useAutoScroll";
import { QueryKeys } from "api/QueryKeys";
import { formatTimestamp } from "components/common/formatTimestamp";
import CodeBlock from "components/common/CodeBlock";
import type { LogRecord } from "api/types/LogRecord";
import type { RepositoryId } from "api/types/RepositoryId";
hljs.registerLanguage("plaintext", plaintext);
interface LogVersion {
version: string;
processId: string;
created: number;
logs: string;
}
interface BuildLogsTabProps {
packageBase: string;
repo: RepositoryId;
refetchInterval: number | false;
}
function convertLogs(records: LogRecord[], filter?: (r: LogRecord) => boolean): string {
const filtered = filter ? records.filter(filter) : records;
return filtered
.map((r) => `[${new Date(r.created * 1000).toISOString()}] ${r.message}`)
.join("\n");
}
export default function BuildLogsTab({ packageBase, repo, refetchInterval }: BuildLogsTabProps): React.JSX.Element {
const client = useClient();
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const codeRef = useRef<HTMLElement>(null);
const { data: allLogs } = useQuery<LogRecord[]>({
queryKey: QueryKeys.logs(packageBase, repo),
queryFn: () => client.fetchLogs(packageBase, repo),
enabled: !!packageBase,
refetchInterval,
});
// Build version selectors from all logs
const versions = useMemo<LogVersion[]>(() => {
if (!allLogs || allLogs.length === 0) {
return [];
}
const grouped: Record<string, LogRecord & { minCreated: number }> = {};
for (const record of allLogs) {
const key = `${record.version}-${record.process_id}`;
const existing = grouped[key];
if (!existing) {
grouped[key] = { ...record, minCreated: record.created };
} else {
existing.minCreated = Math.min(existing.minCreated, record.created);
}
}
return Object.values(grouped)
.sort((a, b) => b.minCreated - a.minCreated)
.map((v) => ({
version: v.version,
processId: v.process_id,
created: v.minCreated,
logs: convertLogs(
allLogs,
(r) => r.version === v.version && r.process_id === v.process_id,
),
}));
}, [allLogs]);
// Compute active index from selected version key, defaulting to newest (index 0)
const activeIndex = useMemo(() => {
if (selectedVersionKey) {
const idx = versions.findIndex((v) => `${v.version}-${v.processId}` === selectedVersionKey);
if (idx >= 0) {
return idx;
}
}
return 0;
}, [versions, selectedVersionKey]);
const activeVersion = versions[activeIndex];
const activeVersionKey = activeVersion ? `${activeVersion.version}-${activeVersion.processId}` : null;
// Refresh active version logs
const { data: versionLogs } = useQuery<LogRecord[]>({
queryKey: activeVersion
? QueryKeys.logsVersion(packageBase, repo, activeVersion.version, activeVersion.processId)
: ["logs-none"],
queryFn: activeVersion
? () => client.fetchLogs(packageBase, repo, activeVersion.version, activeVersion.processId)
: skipToken,
placeholderData: keepPreviousData,
refetchInterval,
});
// Derive displayed logs: prefer fresh polled data when available
const displayedLogs = useMemo(() => {
if (versionLogs && versionLogs.length > 0) {
return convertLogs(versionLogs);
}
return activeVersion?.logs ?? "";
}, [versionLogs, activeVersion]);
const { preRef, handleScroll, scrollToBottom, resetScroll } = useAutoScroll();
// Reset scroll tracking when active version changes
useEffect(() => {
resetScroll();
}, [activeVersionKey, resetScroll]);
// Highlight code, then scroll to bottom
useEffect(() => {
if (codeRef.current && displayedLogs) {
codeRef.current.innerHTML = hljs.highlight(displayedLogs, { language: "plaintext" }).value;
}
scrollToBottom();
}, [displayedLogs, scrollToBottom]);
return (
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
<Box>
<Button
size="small"
aria-label="Select version"
startIcon={<ListIcon />}
onClick={(e) => setAnchorEl(e.currentTarget)}
/>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
{versions.map((v, idx) => (
<MenuItem
key={`${v.version}-${v.processId}`}
selected={idx === activeIndex}
onClick={() => {
setSelectedVersionKey(`${v.version}-${v.processId}`);
setAnchorEl(null);
resetScroll();
}}
>
<Typography variant="body2">{formatTimestamp(v.created)}</Typography>
</MenuItem>
))}
{versions.length === 0 && (
<MenuItem disabled>No logs available</MenuItem>
)}
</Menu>
</Box>
<Box sx={{ flex: 1 }}>
<CodeBlock
codeRef={codeRef}
preRef={preRef}
className="language-plaintext"
getText={() => displayedLogs}
height={400}
onScroll={handleScroll}
wordBreak
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,43 @@
import React, { useEffect, useRef } from "react";
import { Box } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import hljs from "highlight.js/lib/core";
import diff from "highlight.js/lib/languages/diff";
import "highlight.js/styles/github.css";
import { useClient } from "hooks/useClient";
import { QueryKeys } from "api/QueryKeys";
import CodeBlock from "components/common/CodeBlock";
import type { Changes } from "api/types/Changes";
import type { RepositoryId } from "api/types/RepositoryId";
hljs.registerLanguage("diff", diff);
interface ChangesTabProps {
packageBase: string;
repo: RepositoryId;
}
export default function ChangesTab({ packageBase, repo }: ChangesTabProps): React.JSX.Element {
const client = useClient();
const codeRef = useRef<HTMLElement>(null);
const { data } = useQuery<Changes>({
queryKey: QueryKeys.changes(packageBase, repo),
queryFn: () => client.fetchChanges(packageBase, repo),
enabled: !!packageBase,
});
const changesText = data?.changes ?? "";
useEffect(() => {
if (codeRef.current) {
codeRef.current.innerHTML = hljs.highlight(changesText, { language: "diff" }).value;
}
}, [changesText]);
return (
<Box sx={{ mt: 1 }}>
<CodeBlock codeRef={codeRef} className="language-diff" getText={() => changesText} maxHeight={400} />
</Box>
);
}

View File

@@ -0,0 +1,63 @@
import type React from "react";
import { Box } from "@mui/material";
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
import { useQuery } from "@tanstack/react-query";
import EventDurationLineChart from "components/charts/EventDurationLineChart";
import { useClient } from "hooks/useClient";
import { QueryKeys } from "api/QueryKeys";
import { formatTimestamp } from "components/common/formatTimestamp";
import type { Event } from "api/types/Event";
import type { RepositoryId } from "api/types/RepositoryId";
interface EventsTabProps {
packageBase: string;
repo: RepositoryId;
}
interface EventRow {
id: number;
timestamp: string;
event: string;
message: string;
}
const columns: GridColDef<EventRow>[] = [
{ field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
{ field: "event", headerName: "event", flex: 1 },
{ field: "message", headerName: "description", flex: 2 },
];
export default function EventsTab({ packageBase, repo }: EventsTabProps): React.JSX.Element {
const client = useClient();
const { data: events = [] } = useQuery<Event[]>({
queryKey: QueryKeys.events(repo, packageBase),
queryFn: () => client.fetchEvents(repo, packageBase, 30),
enabled: !!packageBase,
});
const rows: EventRow[] = events.map((e, idx) => ({
id: idx,
timestamp: formatTimestamp(e.created),
event: e.event,
message: e.message ?? "",
}));
return (
<Box sx={{ mt: 1 }}>
<EventDurationLineChart events={events} />
<DataGrid
rows={rows}
columns={columns}
density="compact"
initialState={{
sorting: { sortModel: [{ field: "timestamp", sort: "desc" }] },
}}
pageSizeOptions={[10, 25]}
autoHeight
sx={{ mt: 1 }}
disableRowSelectionOnClick
/>
</Box>
);
}

View File

@@ -0,0 +1,98 @@
import React from "react";
import { Grid, Typography, Link } from "@mui/material";
import type { Package } from "api/types/Package";
import type { Dependencies } from "api/types/Dependencies";
import type { PackageProperties } from "api/types/PackageProperties";
interface PackageDetailsGridProps {
pkg: Package;
dependencies?: Dependencies;
}
function listToString(items: string[]): React.ReactNode {
const unique = [...new Set(items)].sort();
return unique.map((item, i) => (
<React.Fragment key={item}>
{item}
{i < unique.length - 1 && <br />}
</React.Fragment>
));
}
export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetailsGridProps): React.JSX.Element {
const packagesList = Object.entries(pkg.packages)
.map(([name, p]) => `${name}${p.description ? ` (${p.description})` : ""}`);
const groups = Object.values(pkg.packages)
.flatMap((p: PackageProperties) => p.groups ?? []);
const licenses = Object.values(pkg.packages)
.flatMap((p: PackageProperties) => p.licenses ?? []);
const upstreamUrls = [...new Set(
Object.values(pkg.packages)
.map((p: PackageProperties) => p.url)
.filter((u): u is string => !!u),
)].sort();
const aurUrl = pkg.remote.web_url;
const pkgNames = Object.keys(pkg.packages);
const allDepends = Object.values(pkg.packages).flatMap((p: PackageProperties) => {
const deps = (p.depends ?? []).filter((d) => !pkgNames.includes(d));
const makeDeps = (p.make_depends ?? []).filter((d) => !pkgNames.includes(d)).map((d) => `${d} (make)`);
const optDeps = (p.opt_depends ?? []).filter((d) => !pkgNames.includes(d)).map((d) => `${d} (optional)`);
return [...deps, ...makeDeps, ...optDeps];
});
const implicitDepends = dependencies
? Object.values(dependencies.paths).flat()
: [];
return (
<>
<Grid container spacing={1} sx={{ mt: 1 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(packagesList)}</Typography></Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.version}</Typography></Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packager</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
<Grid size={{ xs: 4, md: 1 }} />
<Grid size={{ xs: 8, md: 5 }} />
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(groups)}</Typography></Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(licenses)}</Typography></Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">upstream</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}>
{upstreamUrls.map((url) => (
<Link key={url} href={url} target="_blank" rel="noopener" underline="hover" display="block" variant="body2">
{url}
</Link>
))}
</Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}>
<Typography variant="body2">
{aurUrl && (
<Link href={aurUrl} target="_blank" rel="noopener" underline="hover">AUR link</Link>
)}
</Typography>
</Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(allDepends)}</Typography></Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(implicitDepends)}</Typography></Grid>
</Grid>
</>
);
}

View File

@@ -0,0 +1,64 @@
import type React from "react";
import { DialogActions, Button, FormControlLabel, Checkbox } from "@mui/material";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import DeleteIcon from "@mui/icons-material/Delete";
import RefreshIcon from "@mui/icons-material/Refresh";
import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
interface PackageInfoActionsProps {
isAuthorized: boolean;
refreshDb: boolean;
onRefreshDbChange: (checked: boolean) => void;
onUpdate: () => void;
onRemove: () => void;
onReload: () => void;
autorefreshIntervals: AutoRefreshInterval[];
autoRefreshEnabled: boolean;
autoRefreshInterval: number;
onAutoRefreshToggle: (enabled: boolean) => void;
onAutoRefreshIntervalChange: (interval: number) => void;
}
export default function PackageInfoActions({
isAuthorized,
refreshDb,
onRefreshDbChange,
onUpdate,
onRemove,
onReload,
autorefreshIntervals,
autoRefreshEnabled,
autoRefreshInterval,
onAutoRefreshToggle,
onAutoRefreshIntervalChange,
}: PackageInfoActionsProps): React.JSX.Element {
return (
<DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
{isAuthorized && (
<>
<FormControlLabel
control={<Checkbox checked={refreshDb} onChange={(_, checked) => onRefreshDbChange(checked)} size="small" />}
label="update pacman databases"
/>
<Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
update
</Button>
<Button onClick={onRemove} variant="contained" color="error" startIcon={<DeleteIcon />} size="small">
remove
</Button>
</>
)}
<Button onClick={onReload} variant="outlined" color="secondary" startIcon={<RefreshIcon />} size="small">
reload
</Button>
<AutoRefreshControl
intervals={autorefreshIntervals}
enabled={autoRefreshEnabled}
currentInterval={autoRefreshInterval}
onToggle={onAutoRefreshToggle}
onIntervalChange={onAutoRefreshIntervalChange}
/>
</DialogActions>
);
}

View File

@@ -0,0 +1,34 @@
import type React from "react";
import { Box, Typography, Chip, IconButton } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import type { Patch } from "api/types/Patch";
interface PackagePatchesListProps {
patches: Patch[];
editable: boolean;
onDelete: (key: string) => void;
}
export default function PackagePatchesList({ patches, editable, onDelete }: PackagePatchesListProps): React.JSX.Element | null {
if (patches.length === 0) {
return null;
}
return (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Environment variables</Typography>
{patches.map((patch) => (
<Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<Chip label={patch.key} size="small" />
<Typography variant="body2">=</Typography>
<Typography variant="body2" sx={{ fontFamily: "monospace" }}>{JSON.stringify(patch.value)}</Typography>
{editable && (
<IconButton size="small" color="error" onClick={() => onDelete(patch.key)}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Box>
))}
</Box>
);
}

View File

@@ -0,0 +1,185 @@
import React, { useMemo } from "react";
import {
DataGrid,
GRID_CHECKBOX_SELECTION_COL_DEF,
useGridApiRef,
type GridColDef,
type GridFilterModel,
type GridRenderCellParams,
type GridRowId,
} from "@mui/x-data-grid";
import { Box, Link } from "@mui/material";
import PackageTableToolbar from "components/table/PackageTableToolbar";
import StatusCell from "components/table/StatusCell";
import DashboardDialog from "components/dialogs/DashboardDialog";
import PackageAddDialog from "components/dialogs/PackageAddDialog";
import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog";
import KeyImportDialog from "components/dialogs/KeyImportDialog";
import PackageInfoDialog from "components/dialogs/PackageInfoDialog";
import { usePackageTable } from "hooks/usePackageTable";
import { useDebounce } from "hooks/useDebounce";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { PackageRow } from "models/PackageRow";
interface PackageTableProps {
autorefreshIntervals: AutoRefreshInterval[];
}
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
function createListColumn(
field: keyof PackageRow,
headerName: string,
opts?: { flex?: number; minWidth?: number; width?: number },
): GridColDef<PackageRow> {
return {
field,
headerName,
...opts,
valueGetter: (value: string[]) => (value ?? []).join(" "),
renderCell: (params: GridRenderCellParams<PackageRow>) =>
((params.row[field] as string[]) ?? []).map((item, i, arr) => (
<React.Fragment key={`${item}-${String(i)}`}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
)),
sortComparator: (v1: string, v2: string) => v1.localeCompare(v2),
};
}
export default function PackageTable({ autorefreshIntervals }: PackageTableProps): React.JSX.Element {
const table = usePackageTable(autorefreshIntervals);
const apiRef = useGridApiRef();
const debouncedSearch = useDebounce(table.searchText, 300);
const effectiveFilterModel: GridFilterModel = useMemo(
() => ({
...table.filterModel,
quickFilterValues: debouncedSearch ? debouncedSearch.split(/\s+/) : undefined,
}),
[table.filterModel, debouncedSearch],
);
const columns: GridColDef<PackageRow>[] = useMemo(
() => [
{
field: "base",
headerName: "package base",
flex: 1,
minWidth: 150,
renderCell: (params: GridRenderCellParams<PackageRow>) =>
params.row.webUrl ? (
<Link href={params.row.webUrl} target="_blank" rel="noopener" underline="hover">
{params.value as string}
</Link>
) : (
params.value as string
),
},
{ field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
createListColumn("groups", "groups", { width: 150 }),
createListColumn("licenses", "licenses", { width: 150 }),
{ field: "packager", headerName: "packager", width: 150 },
{
field: "timestamp",
headerName: "last update",
width: 180,
align: "right",
headerAlign: "right",
},
{
field: "status",
headerName: "status",
width: 120,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams<PackageRow>) => <StatusCell status={params.row.status} />,
},
],
[],
);
return (
<Box>
<PackageTableToolbar
hasSelection={table.selectionModel.length > 0}
isAuthorized={table.isAuthorized}
repoStatus={table.repoStatus}
searchText={table.searchText}
onSearchChange={table.setSearchText}
autoRefresh={{
intervals: autorefreshIntervals,
enabled: table.autoRefreshEnabled,
currentInterval: table.autoRefreshInterval,
onToggle: table.onAutoRefreshToggle,
onIntervalChange: table.onAutoRefreshIntervalChange,
}}
actions={{
onDashboardClick: () => table.setDialogOpen("dashboard"),
onAddClick: () => table.setDialogOpen("add"),
onUpdateClick: () => void table.handleUpdate(),
onRefreshDbClick: () => void table.handleRefreshDb(),
onRebuildClick: () => table.setDialogOpen("rebuild"),
onRemoveClick: () => void table.handleRemove(),
onKeyImportClick: () => table.setDialogOpen("keyImport"),
onReloadClick: table.handleReload,
onExportClick: () => apiRef.current?.exportDataAsCsv(),
}}
/>
<DataGrid
apiRef={apiRef}
rows={table.rows}
columns={columns}
loading={table.isLoading}
getRowHeight={() => "auto"}
checkboxSelection
disableRowSelectionOnClick
rowSelectionModel={{ type: "include", ids: new Set<GridRowId>(table.selectionModel) }}
onRowSelectionModelChange={(model) => {
if (model.type === "exclude") {
const excludeIds = new Set([...model.ids].map(String));
table.setSelectionModel(table.rows.map((r) => r.id).filter((id) => !excludeIds.has(id)));
} else {
table.setSelectionModel([...model.ids].map(String));
}
}}
paginationModel={table.paginationModel}
onPaginationModelChange={table.setPaginationModel}
pageSizeOptions={PAGE_SIZE_OPTIONS}
columnVisibilityModel={table.columnVisibility}
onColumnVisibilityModelChange={table.setColumnVisibility}
filterModel={effectiveFilterModel}
onFilterModelChange={table.setFilterModel}
initialState={{
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
}}
onCellClick={(params, event) => {
// Don't open info dialog when clicking checkbox or link
if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
return;
}
if ((event.target as HTMLElement).closest("a")) {
return;
}
table.setSelectedPackage(String(params.id));
}}
autoHeight
sx={{
"& .MuiDataGrid-row": { cursor: "pointer" },
}}
density="compact"
/>
<DashboardDialog open={table.dialogOpen === "dashboard"} onClose={() => table.setDialogOpen(null)} />
<PackageAddDialog open={table.dialogOpen === "add"} onClose={() => table.setDialogOpen(null)} />
<PackageRebuildDialog open={table.dialogOpen === "rebuild"} onClose={() => table.setDialogOpen(null)} />
<KeyImportDialog open={table.dialogOpen === "keyImport"} onClose={() => table.setDialogOpen(null)} />
<PackageInfoDialog
packageBase={table.selectedPackage}
open={table.selectedPackage !== null}
onClose={() => table.setSelectedPackage(null)}
autorefreshIntervals={autorefreshIntervals}
/>
</Box>
);
}

View File

@@ -0,0 +1,172 @@
import React, { useState } from "react";
import { Button, Menu, MenuItem, Box, Tooltip, IconButton, Divider, TextField, InputAdornment } from "@mui/material";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import InventoryIcon from "@mui/icons-material/Inventory";
import AddIcon from "@mui/icons-material/Add";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import DownloadIcon from "@mui/icons-material/Download";
import ReplayIcon from "@mui/icons-material/Replay";
import DeleteIcon from "@mui/icons-material/Delete";
import VpnKeyIcon from "@mui/icons-material/VpnKey";
import RefreshIcon from "@mui/icons-material/Refresh";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import SearchIcon from "@mui/icons-material/Search";
import ClearIcon from "@mui/icons-material/Clear";
import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { BuildStatus } from "api/types/BuildStatus";
import { StatusColors } from "theme/status/StatusColors";
export interface AutoRefreshProps {
intervals: AutoRefreshInterval[];
enabled: boolean;
currentInterval: number;
onToggle: (enabled: boolean) => void;
onIntervalChange: (interval: number) => void;
}
export interface ToolbarActions {
onDashboardClick: () => void;
onAddClick: () => void;
onUpdateClick: () => void;
onRefreshDbClick: () => void;
onRebuildClick: () => void;
onRemoveClick: () => void;
onKeyImportClick: () => void;
onReloadClick: () => void;
onExportClick: () => void;
}
interface PackageTableToolbarProps {
hasSelection: boolean;
isAuthorized: boolean;
repoStatus?: BuildStatus;
searchText: string;
onSearchChange: (text: string) => void;
autoRefresh: AutoRefreshProps;
actions: ToolbarActions;
}
export default function PackageTableToolbar({
hasSelection,
isAuthorized,
repoStatus,
searchText,
onSearchChange,
autoRefresh,
actions,
}: PackageTableToolbarProps): React.JSX.Element {
const [packagesAnchorEl, setPackagesAnchorEl] = useState<null | HTMLElement>(null);
return (
<Box sx={{ display: "flex", gap: 1, mb: 1, flexWrap: "wrap", alignItems: "center" }}>
<Tooltip title="System health">
<IconButton
aria-label="System health"
onClick={actions.onDashboardClick}
sx={{
borderColor: repoStatus ? StatusColors[repoStatus] : undefined,
borderWidth: 1,
borderStyle: "solid",
color: repoStatus ? StatusColors[repoStatus] : undefined,
}}
>
<InfoOutlinedIcon />
</IconButton>
</Tooltip>
{isAuthorized && (
<>
<Button
variant="contained"
startIcon={<InventoryIcon />}
onClick={(e) => setPackagesAnchorEl(e.currentTarget)}
>
packages
</Button>
<Menu
anchorEl={packagesAnchorEl}
open={Boolean(packagesAnchorEl)}
onClose={() => setPackagesAnchorEl(null)}
>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); actions.onAddClick();
}}>
<AddIcon fontSize="small" sx={{ mr: 1 }} /> add
</MenuItem>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); actions.onUpdateClick();
}}>
<PlayArrowIcon fontSize="small" sx={{ mr: 1 }} /> update
</MenuItem>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); actions.onRefreshDbClick();
}}>
<DownloadIcon fontSize="small" sx={{ mr: 1 }} /> update pacman databases
</MenuItem>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); actions.onRebuildClick();
}}>
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
</MenuItem>
<Divider />
<MenuItem onClick={() => {
setPackagesAnchorEl(null); actions.onRemoveClick();
}} disabled={!hasSelection}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
</MenuItem>
</Menu>
<Button variant="contained" color="info" startIcon={<VpnKeyIcon />} onClick={actions.onKeyImportClick}>
import key
</Button>
</>
)}
<Button variant="outlined" color="secondary" startIcon={<RefreshIcon />} onClick={actions.onReloadClick}>
reload
</Button>
<AutoRefreshControl
intervals={autoRefresh.intervals}
enabled={autoRefresh.enabled}
currentInterval={autoRefresh.currentInterval}
onToggle={autoRefresh.onToggle}
onIntervalChange={autoRefresh.onIntervalChange}
/>
<Box sx={{ flexGrow: 1 }} />
<TextField
size="small"
aria-label="Search packages"
placeholder="search packages..."
value={searchText}
onChange={(e) => onSearchChange(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
endAdornment: searchText ? (
<InputAdornment position="end">
<IconButton size="small" aria-label="Clear search" onClick={() => onSearchChange("")}>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
) : undefined,
},
}}
sx={{ minWidth: 200 }}
/>
<Tooltip title="Export CSV">
<IconButton size="small" aria-label="Export CSV" onClick={actions.onExportClick}>
<FileDownloadIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
);
}

View File

@@ -0,0 +1,22 @@
import type React from "react";
import { Chip } from "@mui/material";
import type { BuildStatus } from "api/types/BuildStatus";
import { StatusColors } from "theme/status/StatusColors";
interface StatusCellProps {
status: BuildStatus;
}
export default function StatusCell({ status }: StatusCellProps): React.JSX.Element {
return (
<Chip
label={status}
size="small"
sx={{
backgroundColor: StatusColors[status],
color: "common.white",
fontWeight: 500,
}}
/>
);
}

View File

@@ -0,0 +1,15 @@
import { createContext } from "react";
interface AuthState {
enabled: boolean;
username: string | null;
}
export interface AuthContextValue extends AuthState {
isAuthorized: boolean;
setAuthState: (state: AuthState) => void;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);

View File

@@ -0,0 +1,30 @@
import React, { useState, useCallback, useMemo, type ReactNode } from "react";
import { useClient } from "hooks/useClient";
import { AuthContext } from "contexts/AuthContext";
export function AuthProvider({ children }: { children: ReactNode }): React.JSX.Element {
const client = useClient();
const [state, setState] = useState({ enabled: false, username: null as string | null });
const login = useCallback(async (username: string, password: string) => {
await client.login({ username, password });
setState((prev) => ({ ...prev, username }));
}, [client]);
const doLogout = useCallback(async () => {
await client.logout();
setState((prev) => ({ ...prev, username: null }));
}, [client]);
const isAuthorized = useMemo(() => !state.enabled || state.username !== null, [state.enabled, state.username]);
const value = useMemo(() => ({
...state, isAuthorized, setAuthState: setState, login, logout: doLogout,
}), [state, isAuthorized, login, doLogout]);
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,4 @@
import { createContext } from "react";
import type { AhrimanClient } from "api/client/AhrimanClient";
export const ClientContext = createContext<AhrimanClient | null>(null);

View File

@@ -0,0 +1,13 @@
import React, { useMemo, type ReactNode } from "react";
import { AhrimanClient } from "api/client/AhrimanClient";
import { ClientContext } from "contexts/ClientContext";
export function ClientProvider({ children }: { children: ReactNode }): React.JSX.Element {
const client = useMemo(() => new AhrimanClient(), []);
return (
<ClientContext.Provider value={client}>
{children}
</ClientContext.Provider>
);
}

View File

@@ -0,0 +1,8 @@
import type { AlertColor } from "@mui/material";
export interface Notification {
id: number;
title: string;
message: string;
severity: AlertColor;
}

View 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);

View File

@@ -0,0 +1,31 @@
import React, { useState, useEffect } from "react";
import { Alert, Slide } from "@mui/material";
import type { Notification } from "contexts/Notification";
interface NotificationItemProps {
notification: Notification;
onClose: (id: number) => void;
}
export default function NotificationItem({ notification, onClose }: NotificationItemProps): React.JSX.Element {
const [show, setShow] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setShow(false), 5000);
return () => clearTimeout(timer);
}, []);
return (
<Slide direction="down" in={show} mountOnEnter unmountOnExit onExited={() => onClose(notification.id)}>
<Alert
onClose={() => setShow(false)}
severity={notification.severity}
variant="filled"
sx={{ width: "100%", pointerEvents: "auto" }}
>
<strong>{notification.title}</strong>
{notification.message && ` - ${notification.message}`}
</Alert>
</Slide>
);
}

View File

@@ -0,0 +1,55 @@
import React, { useState, useCallback, useMemo, useRef, type ReactNode } from "react";
import { Box, type AlertColor } from "@mui/material";
import { NotificationContext } from "contexts/NotificationContext";
import NotificationItem from "contexts/NotificationItem";
import type { Notification } from "contexts/Notification";
export function NotificationProvider({ children }: { children: ReactNode }): React.JSX.Element {
const nextId = useRef(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const addNotification = useCallback((title: string, message: string, severity: AlertColor) => {
const id = nextId.current++;
setNotifications((prev) => [...prev, { id, title, message, severity }]);
}, []);
const removeNotification = useCallback((id: number) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, []);
const showSuccess = useCallback(
(title: string, message: string) => addNotification(title, message, "success"),
[addNotification],
);
const showError = useCallback(
(title: string, message: string) => addNotification(title, message, "error"),
[addNotification],
);
const value = useMemo(() => ({ showSuccess, showError }), [showSuccess, showError]);
return (
<NotificationContext.Provider value={value}>
{children}
<Box
sx={{
position: "fixed",
top: 16,
left: "50%",
transform: "translateX(-50%)",
zIndex: (theme) => theme.zIndex.snackbar,
display: "flex",
flexDirection: "column",
gap: 1,
maxWidth: 500,
width: "100%",
pointerEvents: "none",
}}
>
{notifications.map((n) => (
<NotificationItem key={n.id} notification={n} onClose={removeNotification} />
))}
</Box>
</NotificationContext.Provider>
);
}

View 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);

View File

@@ -0,0 +1,39 @@
import React, { useState, useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
import { repoKey } from "api/types/RepositoryId";
import type { RepositoryId } from "api/types/RepositoryId";
import { RepositoryContext } from "contexts/RepositoryContext";
function subscribeToHash(callback: () => void): () => void {
window.addEventListener("hashchange", callback);
return () => window.removeEventListener("hashchange", callback);
}
function getHashSnapshot(): string {
return window.location.hash.replace("#", "");
}
export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [repositories, setRepositories] = useState<RepositoryId[]>([]);
const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
const current = useMemo(() => {
if (repositories.length === 0) {
return null;
}
return repositories.find((r) => repoKey(r) === hash) ?? repositories[0] ?? null;
}, [repositories, hash]);
const setCurrent = useCallback((repo: RepositoryId) => {
window.location.hash = repoKey(repo);
}, []);
const value = useMemo(() => ({
repositories, current, setRepositories, setCurrent,
}), [repositories, current, setCurrent]);
return (
<RepositoryContext.Provider value={value}>
{children}
</RepositoryContext.Provider>
);
}

View 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;
}

View File

@@ -0,0 +1,52 @@
import { useState, useCallback, type Dispatch, type SetStateAction } from "react";
import { useLocalStorage } from "hooks/useLocalStorage";
interface AutoRefreshState {
enabled: boolean;
interval: number;
}
interface AutoRefreshResult {
enabled: boolean;
interval: number;
paused: boolean;
refetchInterval: number | false;
setEnabled: (enabled: boolean) => void;
changeInterval: (interval: number) => void;
setPaused: Dispatch<SetStateAction<boolean>>;
}
export function useAutoRefresh(key: string, defaultInterval: number = 0): AutoRefreshResult {
const [stored, setStored] = useLocalStorage<AutoRefreshState>(`ahriman-${key}`, {
enabled: defaultInterval > 0,
interval: defaultInterval,
});
const [paused, setPaused] = useState(false);
const refetchInterval: number | false = stored.enabled && !paused && stored.interval > 0 ? stored.interval : false;
const setEnabled = useCallback(
(enabled: boolean) => {
setStored((prev) => ({ ...prev, enabled }));
},
[setStored],
);
const changeInterval = useCallback(
(interval: number) => {
setStored((prev) => ({ ...prev, interval }));
},
[setStored],
);
return {
enabled: stored.enabled,
interval: stored.interval,
paused,
refetchInterval,
setEnabled,
changeInterval,
setPaused,
};
}

View File

@@ -0,0 +1,46 @@
import { useCallback, useRef, type RefObject } from "react";
interface UseAutoScrollResult {
preRef: RefObject<HTMLElement | null>;
handleScroll: () => void;
scrollToBottom: () => void;
resetScroll: () => void;
}
export function useAutoScroll(): UseAutoScrollResult {
const preRef = useRef<HTMLElement>(null);
const initialScrollDone = useRef(false);
const wasAtBottom = useRef(true);
const handleScroll = useCallback(() => {
if (preRef.current) {
const el = preRef.current;
wasAtBottom.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 50;
}
}, []);
const resetScroll = useCallback(() => {
initialScrollDone.current = false;
}, []);
// Scroll to bottom on initial load, then only if already near bottom
// and the user has no active text selection (to avoid disrupting copy workflows).
// Call this after DOM content has been updated (e.g. after highlighting).
const scrollToBottom = useCallback(() => {
if (!preRef.current) {
return;
}
const el = preRef.current;
if (!initialScrollDone.current) {
el.scrollTop = el.scrollHeight;
initialScrollDone.current = true;
} else {
const hasSelection = !document.getSelection()?.isCollapsed;
if (wasAtBottom.current && !hasSelection) {
el.scrollTop = el.scrollHeight;
}
}
}, []);
return { preRef, handleScroll, scrollToBottom, resetScroll };
}

View File

@@ -0,0 +1,11 @@
import { useContext } from "react";
import type { AhrimanClient } from "api/client/AhrimanClient";
import { ClientContext } from "contexts/ClientContext";
export function useClient(): AhrimanClient {
const ctx = useContext(ClientContext);
if (!ctx) {
throw new Error("useClient must be used within ClientProvider");
}
return ctx;
}

View 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;
}

View File

@@ -0,0 +1,53 @@
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
interface UseDialogCloseResult {
isOpen: boolean;
requestClose: () => void;
transitionProps: { onExited: () => void; onEnter: () => void };
}
export function useDialogClose(open: boolean, onClose: () => void, onOpen?: () => void): UseDialogCloseResult {
const [closing, setClosing] = useState(false);
const onCloseRef = useRef(onClose);
const onOpenRef = useRef(onOpen);
// Keep refs in sync with the latest callbacks on every render so that
// the stable transitionProps object always invokes the current handlers.
useEffect(() => {
onCloseRef.current = onClose;
onOpenRef.current = onOpen;
});
// Reset closing state when the parent signals the dialog should open.
// Without this, a stale closing=true from a previous close prevents
// the dialog from ever re-opening (onEnter never fires).
// Uses the React-recommended "adjust state during render" pattern
// instead of useEffect to avoid cascading renders.
const [prevOpen, setPrevOpen] = useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (open) {
setClosing(false);
}
}
const requestClose = useCallback(() => {
setClosing(true);
}, []);
const transitionProps = useMemo(() => ({
onExited: () => {
onCloseRef.current();
},
onEnter: () => {
setClosing(false);
onOpenRef.current?.();
},
}), []);
return {
isOpen: open && !closing,
requestClose,
transitionProps,
};
}

View 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];
}

View 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;
}

View File

@@ -0,0 +1,95 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useRepository } from "hooks/useRepository";
import { useClient } from "hooks/useClient";
import { useNotification } from "hooks/useNotification";
import { ApiError } from "api/client/ApiError";
import { QueryKeys } from "api/QueryKeys";
export interface UsePackageActionsResult {
handleReload: () => void;
handleUpdate: () => Promise<void>;
handleRefreshDb: () => Promise<void>;
handleRemove: () => Promise<void>;
}
export function usePackageActions(
selectionModel: string[],
setSelectionModel: (model: string[]) => void,
): UsePackageActionsResult {
const client = useClient();
const { current } = useRepository();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const handleReload = useCallback(() => {
if (!current) {
return;
}
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
}, [current, queryClient]);
const handleUpdate = useCallback(async () => {
if (!current) {
return;
}
try {
if (selectionModel.length === 0) {
await client.updatePackages(current, { packages: [] });
showSuccess("Success", "Repository update has been run");
} else {
await client.addPackages(current, { packages: selectionModel });
showSuccess("Success", `Run update for packages ${selectionModel.join(", ")}`);
}
setSelectionModel([]);
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Packages update failed: ${detail}`);
}
}, [client, current, selectionModel, setSelectionModel, showSuccess, showError, queryClient]);
const handleRefreshDb = useCallback(async () => {
if (!current) {
return;
}
try {
await client.updatePackages(current, { packages: [], refresh: true, aur: false, local: false, manual: false });
showSuccess("Success", "Pacman database update has been requested");
setSelectionModel([]);
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Could not update pacman databases: ${detail}`);
}
}, [client, current, setSelectionModel, showSuccess, showError, queryClient]);
const handleRemove = useCallback(async () => {
if (!current) {
return;
}
if (selectionModel.length === 0) {
return;
}
try {
await client.removePackages(current, selectionModel);
showSuccess("Success", `Packages ${selectionModel.join(", ")} have been removed`);
setSelectionModel([]);
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Could not remove packages: ${detail}`);
}
}, [client, current, selectionModel, setSelectionModel, showSuccess, showError, queryClient]);
return {
handleReload,
handleUpdate,
handleRefreshDb,
handleRemove,
};
}

View File

@@ -0,0 +1,76 @@
import { useMemo } from "react";
import { skipToken, useQuery } from "@tanstack/react-query";
import { useRepository } from "hooks/useRepository";
import { useAuth } from "hooks/useAuth";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { useClient } from "hooks/useClient";
import { defaultInterval } from "api/defaultInterval";
import { QueryKeys } from "api/QueryKeys";
import { formatTimestamp } from "components/common/formatTimestamp";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { BuildStatus } from "api/types/BuildStatus";
import type { PackageRow } from "models/PackageRow";
import type { PackageStatus } from "api/types/PackageStatus";
function extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
return [
...new Set(
Object.values(pkg.packages)
.flatMap((p) => p[property] ?? []),
),
].sort();
}
function toRow(ps: PackageStatus): PackageRow {
return {
id: ps.package.base,
base: ps.package.base,
webUrl: ps.package.remote.web_url ?? undefined,
version: ps.package.version,
packages: Object.keys(ps.package.packages).sort(),
groups: extractListProperties(ps.package, "groups"),
licenses: extractListProperties(ps.package, "licenses"),
packager: ps.package.packager ?? "",
timestamp: formatTimestamp(ps.status.timestamp),
timestampValue: ps.status.timestamp,
status: ps.status.status,
};
}
export interface UsePackageDataResult {
rows: PackageRow[];
isLoading: boolean;
isAuthorized: boolean;
repoStatus: BuildStatus | undefined;
autoRefresh: ReturnType<typeof useAutoRefresh>;
}
export function usePackageData(autorefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
const client = useClient();
const { current } = useRepository();
const { isAuthorized } = useAuth();
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autorefreshIntervals));
const { data: packages = [], isLoading } = useQuery<PackageStatus[]>({
queryKey: current ? QueryKeys.packages(current) : ["packages"],
queryFn: current ? () => client.fetchPackages(current) : skipToken,
refetchInterval: autoRefresh.refetchInterval,
});
const { data: status } = useQuery({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetchStatus(current) : skipToken,
refetchInterval: autoRefresh.refetchInterval,
});
const rows = useMemo(() => packages.map(toRow), [packages]);
return {
rows,
isLoading,
isAuthorized,
repoStatus: status?.status.status,
autoRefresh,
};
}

View File

@@ -0,0 +1,73 @@
import { useEffect } from "react";
import type { GridFilterModel } from "@mui/x-data-grid";
import { usePackageData } from "hooks/usePackageData";
import { useTableState } from "hooks/useTableState";
import { usePackageActions } from "hooks/usePackageActions";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { BuildStatus } from "api/types/BuildStatus";
import type { PackageRow } from "models/PackageRow";
export type { DialogType } from "hooks/useTableState";
export interface UsePackageTableResult {
rows: PackageRow[];
isLoading: boolean;
isAuthorized: boolean;
repoStatus: BuildStatus | undefined;
selectionModel: string[];
setSelectionModel: (model: string[]) => void;
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
setDialogOpen: (dialog: "dashboard" | "add" | "rebuild" | "keyImport" | null) => void;
selectedPackage: string | null;
setSelectedPackage: (base: string | null) => void;
paginationModel: { pageSize: number; page: number };
setPaginationModel: (model: { pageSize: number; page: number }) => void;
columnVisibility: Record<string, boolean>;
setColumnVisibility: (model: Record<string, boolean>) => void;
filterModel: GridFilterModel;
setFilterModel: (model: GridFilterModel) => void;
searchText: string;
setSearchText: (text: string) => void;
autoRefreshEnabled: boolean;
autoRefreshInterval: number;
onAutoRefreshToggle: (enabled: boolean) => void;
onAutoRefreshIntervalChange: (interval: number) => void;
handleReload: () => void;
handleUpdate: () => Promise<void>;
handleRefreshDb: () => Promise<void>;
handleRemove: () => Promise<void>;
}
export function usePackageTable(autorefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
const { rows, isLoading, isAuthorized, repoStatus, autoRefresh } = usePackageData(autorefreshIntervals);
const tableState = useTableState();
const actions = usePackageActions(tableState.selectionModel, tableState.setSelectionModel);
// Pause auto-refresh when dialog is open
const isDialogOpen = tableState.dialogOpen !== null || tableState.selectedPackage !== null;
const setPaused = autoRefresh.setPaused;
useEffect(() => {
setPaused(isDialogOpen);
}, [isDialogOpen, setPaused]);
return {
rows,
isLoading,
isAuthorized,
repoStatus,
...tableState,
autoRefreshEnabled: autoRefresh.enabled,
autoRefreshInterval: autoRefresh.interval,
onAutoRefreshToggle: autoRefresh.setEnabled,
onAutoRefreshIntervalChange: autoRefresh.changeInterval,
...actions,
};
}

View 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;
}

View File

@@ -0,0 +1,32 @@
import { useState, useCallback } from "react";
import { useRepository } from "hooks/useRepository";
import { repoKey } from "api/types/RepositoryId";
import type { RepositoryId } from "api/types/RepositoryId";
export interface SelectedRepositoryResult {
selectedKey: string;
setSelectedKey: (key: string) => void;
selectedRepo: RepositoryId | null;
reset: () => void;
}
export function useSelectedRepository(): SelectedRepositoryResult {
const { repositories, current } = useRepository();
const [selectedKey, setSelectedKey] = useState("");
const selectedRepo: RepositoryId | null = (() => {
if (selectedKey) {
const repo = repositories.find((r) => repoKey(r) === selectedKey);
if (repo) {
return repo;
}
}
return current;
})();
const reset = useCallback(() => {
setSelectedKey("");
}, []);
return { selectedKey, setSelectedKey, selectedRepo, reset };
}

View File

@@ -0,0 +1,63 @@
import { useState } from "react";
import type { GridFilterModel } from "@mui/x-data-grid";
import { useLocalStorage } from "hooks/useLocalStorage";
export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport";
export interface UseTableStateResult {
selectionModel: string[];
setSelectionModel: (model: string[]) => void;
dialogOpen: DialogType | null;
setDialogOpen: (dialog: DialogType | null) => void;
selectedPackage: string | null;
setSelectedPackage: (base: string | null) => void;
paginationModel: { pageSize: number; page: number };
setPaginationModel: (model: { pageSize: number; page: number }) => void;
columnVisibility: Record<string, boolean>;
setColumnVisibility: (model: Record<string, boolean>) => void;
filterModel: GridFilterModel;
setFilterModel: (model: GridFilterModel) => void;
searchText: string;
setSearchText: (text: string) => void;
}
export function useTableState(): UseTableStateResult {
const [selectionModel, setSelectionModel] = useState<string[]>([]);
const [dialogOpen, setDialogOpen] = useState<DialogType | null>(null);
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
const [searchText, setSearchText] = useState("");
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
pageSize: 10,
page: 0,
});
const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>(
"ahriman-packages-columns",
{ groups: false, licenses: false, packager: false },
);
const [filterModel, setFilterModel] = useLocalStorage<GridFilterModel>(
"ahriman-packages-filters",
{ items: [] },
);
return {
selectionModel,
setSelectionModel,
dialogOpen,
setDialogOpen,
selectedPackage,
setSelectedPackage,
paginationModel,
setPaginationModel,
columnVisibility,
setColumnVisibility,
filterModel,
setFilterModel,
searchText,
setSearchText,
};
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "chartSetup";
import App from "App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

View 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;
}

View 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;

View File

@@ -0,0 +1,29 @@
import { alpha } from "@mui/material/styles";
import { amber, green, grey, orange, red } from "@mui/material/colors";
import type { BuildStatus } from "api/types/BuildStatus";
const base: Record<BuildStatus, string> = {
unknown: grey[800],
pending: amber[900],
building: orange[900],
failed: red[900],
success: green[800],
};
const headerBase: Record<BuildStatus, string> = {
unknown: grey[800],
pending: amber[700],
building: orange[600],
failed: red[500],
success: green[600],
};
export const StatusColors = base;
export const StatusBackgrounds: Record<BuildStatus, string> = Object.fromEntries(
Object.entries(base).map(([k, v]) => [k, alpha(v, 0.1)]),
) as Record<BuildStatus, string>;
export const StatusHeaderStyles: Record<BuildStatus, { backgroundColor: string; color: string }> = Object.fromEntries(
Object.entries(headerBase).map(([k, v]) => [k, { backgroundColor: v, color: "#fff" }]),
) as Record<BuildStatus, { backgroundColor: string; color: string }>;

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"baseUrl": "src"
},
"include": ["src"]
}

37
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,37 @@
import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "path";
function renameHtml(newName: string): Plugin {
return {
name: "rename-html",
enforce: "post",
generateBundle(_, bundle) {
if (bundle["index.html"]) {
bundle["index.html"].fileName = newName;
}
},
};
}
export default defineConfig({
plugins: [react(), tsconfigPaths(), renameHtml("build-status.jinja2")],
base: "/",
build: {
outDir: path.resolve(__dirname, "../package/share/ahriman/templates"),
emptyOutDir: false,
rollupOptions: {
output: {
entryFileNames: "static/[name].js",
chunkFileNames: "static/[name].js",
assetFileNames: "static/[name].[ext]",
},
},
},
server: {
proxy: {
"/api": "http://localhost:8080",
},
},
});

View File

@@ -9,7 +9,7 @@ arch=('any')
url="https://ahriman.readthedocs.io/"
license=('GPL-3.0-or-later')
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-bcrypt' 'python-filelock' 'python-inflection' 'python-pyelftools' 'python-requests')
makedepends=('python-build' 'python-flit' 'python-installer' 'python-wheel')
makedepends=('npm' 'python-build' 'python-flit' 'python-installer' 'python-wheel')
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgbase-$pkgver.tar.gz"
"$pkgbase.sysusers"
"$pkgbase.tmpfiles")
@@ -18,6 +18,10 @@ build() {
cd "$pkgbase-$pkgver"
python -m build --wheel --no-isolation
cd "frontend"
npm install
npm run build
}
package_ahriman() {

View File

@@ -46,6 +46,8 @@ host = 127.0.0.1
;service_only = no
; Path to directory with static files.
static_path = ${templates}/static
; Jinja2 template name for the index page.
;template = build-status.jinja2
; List of directories with templates.
templates[] = ${prefix}/share/ahriman/templates
; Path to unix socket. If none set, unix socket will be disabled.

View File

@@ -0,0 +1,191 @@
<!doctype html>
<html lang="en">
<head>
<title>ahriman</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
{% include "utils/style.jinja2" %}
{% include "user-style.jinja2" ignore missing %}
</head>
<body>
{% include "utils/bootstrap-scripts.jinja2" %}
<div class="container">
<nav class="navbar navbar-expand-lg">
<div class="navbar-brand"><a href="https://github.com/arcan1s/ahriman" title="logo"><img src="/static/logo.svg" width="30" height="30" alt=""></a></div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#repositories-navbar" aria-controls="repositories-navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div id="repositories-navbar" class="collapse navbar-collapse">
<ul id="repositories" class="nav nav-tabs">
{% for repository in repositories %}
<li class="nav-item">
<a id="{{ repository.id }}-link" class="nav-link" href="#{{ repository.id }}" data-repository="{{ repository.repository }}" data-architecture="{{ repository.architecture }}">{{ repository.repository }} ({{ repository.architecture }})</a>
</li>
{% endfor %}
</ul>
</div>
</nav>
</div>
<div id="alert-placeholder" class="toast-container p3 top-0 start-50 translate-middle-x"></div>
<div class="container">
<div id="toolbar" class="dropdown">
<button id="dashboard-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#dashboard-modal">
<i class="bi bi-info-circle"></i>
</button>
{% if not auth.enabled or auth.username is not none %}
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-box"></i><span class="d-none d-sm-inline"> packages</span>
</button>
<ul class="dropdown-menu">
<li>
<button id="package-add-button" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-add-modal">
<i class="bi bi-plus"></i> add
</button>
</li>
<li>
<button id="package-update-button" class="btn dropdown-item" onclick="packagesUpdate()">
<i class="bi bi-play"></i> update
</button>
</li>
<li>
<button id="update-repositories-button" class="btn dropdown-item" onclick="refreshDatabases()">
<i class="bi bi-arrow-down-circle"></i> update pacman databases
</button>
</li>
<li>
<button id="package-rebuild-button" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-rebuild-modal">
<i class="bi bi-arrow-clockwise"></i> rebuild
</button>
</li>
<li>
<button id="package-remove-button" class="btn dropdown-item" onclick="packagesRemove()" disabled>
<i class="bi bi-trash"></i> remove
</button>
</li>
</ul>
<button id="key-import-button" type="button" class="btn btn-info" data-bs-toggle="modal" data-bs-target="#key-import-modal">
<i class="bi bi-key"></i><span class="d-none d-sm-inline"> import key</span>
</button>
{% endif %}
<button type="button" class="btn btn-secondary" onclick="reload()">
<i class="bi bi-arrow-clockwise"></i><span class="d-none d-sm-inline"> reload</span>
</button>
{% if autorefresh_intervals %}
<div class="btn-group">
<input id="table-autoreload-button" type="checkbox" class="btn-check" autocomplete="off" onclick="toggleTableAutoReload()" checked>
<label for="table-autoreload-button" class="btn btn-outline-secondary" title="toggle auto reload"><i class="bi bi-clock"></i></label>
<button type="button" class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">select interval</span>
</button>
<ul id="table-autoreload-input" class="dropdown-menu">
{% for interval in autorefresh_intervals %}
<li><a class="dropdown-item {{ "active" if interval.is_active }}" onclick="toggleTableAutoReload({{ interval.interval }})" data-interval="{{ interval.interval }}">{{ interval.text }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<table id="packages"
data-classes="table table-hover"
data-cookie="true"
data-cookie-id-table="ahriman-packages"
data-cookie-storage="localStorage"
data-export-options='{"fileName": "packages"}'
data-filter-control="true"
data-filter-control-visible="false"
data-page-list="[10, 25, 50, 100, all]"
data-page-size="10"
data-pagination="true"
data-resizable="true"
data-search="true"
data-show-columns="true"
data-show-columns-search="true"
data-show-columns-toggle-all="true"
data-show-export="true"
data-show-filter-control-switch="true"
data-show-fullscreen="true"
data-show-search-clear-button="true"
data-sortable="true"
data-sort-name="base"
data-sort-order="asc"
data-toolbar="#toolbar"
data-unique-id="id">
<thead class="table-primary">
<tr>
<th data-checkbox="true"></th>
<th data-sortable="true" data-switchable="false" data-field="base" data-filter-control="input" data-filter-control-placeholder="(any base)">package base</th>
<th data-sortable="true" data-align="right" data-field="version" data-filter-control="input" data-filter-control-placeholder="(any version)">version</th>
<th data-sortable="true" data-field="packages" data-filter-control="input" data-filter-control-placeholder="(any package)">packages</th>
<th data-sortable="true" data-visible="false" data-field="groups" data-filter-control="select" data-filter-data="func:filterListGroups" data-filter-custom-search="filterList" data-filter-control-placeholder="(any group)">groups</th>
<th data-sortable="true" data-visible="false" data-field="licenses" data-filter-control="select" data-filter-data="func:filterListLicenses" data-filter-custom-search="filterList" data-filter-control-placeholder="(any license)">licenses</th>
<th data-sortable="true" data-visible="false" data-field="packager" data-filter-control="select" data-filter-custom-search="filterContains" data-filter-control-placeholder="(any packager)">packager</th>
<th data-sortable="true" data-align="right" data-field="timestamp" data-filter-control="input" data-filter-custom-search="filterDateRange" data-filter-control-placeholder="(any date)">last update</th>
<th data-sortable="true" data-align="center" data-cell-style="statusFormat" data-field="status" data-filter-control="select" data-filter-control-placeholder="(any status)">status</th>
</tr>
</thead>
</table>
</div>
<div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav">
<li><a id="badge-version" class="nav-link" href="https://github.com/arcan1s/ahriman" title="sources"><i class="bi bi-github"></i> ahriman</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ahriman/releases" title="releases list">releases</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li>
{% if docs_enabled %}
<li><a class="nav-link" href="/api-docs" title="API documentation">api</a></li>
{% endif %}
</ul>
{% if index_url is not none %}
<ul class="nav">
<li><a class="nav-link" href="{{ index_url }}" title="repo index"><i class="bi bi-house"></i> repo index</a></li>
</ul>
{% endif %}
{% if auth.enabled %}
<ul class="nav">
{% if auth.username is none %}
<li>{{ auth.control | safe }}</li>
{% else %}
<li>
<form action="/api/v1/logout" method="post">
<button class="btn btn-link" style="text-decoration: none"><i class="bi bi-box-arrow-right"></i> logout ({{ auth.username }})</button>
</form>
</li>
{% endif %}
</ul>
{% endif %}
</footer>
</div>
{% if auth.enabled %}
{% include "build-status/login-modal.jinja2" %}
{% endif %}
{% include "build-status/alerts.jinja2" %}
{% include "build-status/dashboard.jinja2" %}
{% include "build-status/package-add-modal.jinja2" %}
{% include "build-status/package-rebuild-modal.jinja2" %}
{% include "build-status/key-import-modal.jinja2" %}
{% include "build-status/package-info-modal.jinja2" %}
{% include "build-status/table.jinja2" %}
</body>
</html>

View File

@@ -1,191 +1,20 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<title>ahriman</title>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ahriman</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="/static/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@300;400;600;700&display=swap" />
<script type="module" crossorigin src="/static/index.js"></script>
<link rel="stylesheet" crossorigin href="/static/index.css">
</head>
<body>
{% include "utils/style.jinja2" %}
{% include "user-style.jinja2" ignore missing %}
</head>
<body>
{% include "utils/bootstrap-scripts.jinja2" %}
<div class="container">
<nav class="navbar navbar-expand-lg">
<div class="navbar-brand"><a href="https://github.com/arcan1s/ahriman" title="logo"><img src="/static/logo.svg" width="30" height="30" alt=""></a></div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#repositories-navbar" aria-controls="repositories-navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div id="repositories-navbar" class="collapse navbar-collapse">
<ul id="repositories" class="nav nav-tabs">
{% for repository in repositories %}
<li class="nav-item">
<a id="{{ repository.id }}-link" class="nav-link" href="#{{ repository.id }}" data-repository="{{ repository.repository }}" data-architecture="{{ repository.architecture }}">{{ repository.repository }} ({{ repository.architecture }})</a>
</li>
{% endfor %}
</ul>
</div>
</nav>
</div>
<div id="alert-placeholder" class="toast-container p3 top-0 start-50 translate-middle-x"></div>
<div class="container">
<div id="toolbar" class="dropdown">
<button id="dashboard-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#dashboard-modal">
<i class="bi bi-info-circle"></i>
</button>
{% if not auth.enabled or auth.username is not none %}
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-box"></i><span class="d-none d-sm-inline"> packages</span>
</button>
<ul class="dropdown-menu">
<li>
<button id="package-add-button" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-add-modal">
<i class="bi bi-plus"></i> add
</button>
</li>
<li>
<button id="package-update-button" class="btn dropdown-item" onclick="packagesUpdate()">
<i class="bi bi-play"></i> update
</button>
</li>
<li>
<button id="update-repositories-button" class="btn dropdown-item" onclick="refreshDatabases()">
<i class="bi bi-arrow-down-circle"></i> update pacman databases
</button>
</li>
<li>
<button id="package-rebuild-button" class="btn dropdown-item" data-bs-toggle="modal" data-bs-target="#package-rebuild-modal">
<i class="bi bi-arrow-clockwise"></i> rebuild
</button>
</li>
<li>
<button id="package-remove-button" class="btn dropdown-item" onclick="packagesRemove()" disabled>
<i class="bi bi-trash"></i> remove
</button>
</li>
</ul>
<button id="key-import-button" type="button" class="btn btn-info" data-bs-toggle="modal" data-bs-target="#key-import-modal">
<i class="bi bi-key"></i><span class="d-none d-sm-inline"> import key</span>
</button>
{% endif %}
<button type="button" class="btn btn-secondary" onclick="reload()">
<i class="bi bi-arrow-clockwise"></i><span class="d-none d-sm-inline"> reload</span>
</button>
{% if autorefresh_intervals %}
<div class="btn-group">
<input id="table-autoreload-button" type="checkbox" class="btn-check" autocomplete="off" onclick="toggleTableAutoReload()" checked>
<label for="table-autoreload-button" class="btn btn-outline-secondary" title="toggle auto reload"><i class="bi bi-clock"></i></label>
<button type="button" class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">select interval</span>
</button>
<ul id="table-autoreload-input" class="dropdown-menu">
{% for interval in autorefresh_intervals %}
<li><a class="dropdown-item {{ "active" if interval.is_active }}" onclick="toggleTableAutoReload({{ interval.interval }})" data-interval="{{ interval.interval }}">{{ interval.text }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<table id="packages"
data-classes="table table-hover"
data-cookie="true"
data-cookie-id-table="ahriman-packages"
data-cookie-storage="localStorage"
data-export-options='{"fileName": "packages"}'
data-filter-control="true"
data-filter-control-visible="false"
data-page-list="[10, 25, 50, 100, all]"
data-page-size="10"
data-pagination="true"
data-resizable="true"
data-search="true"
data-show-columns="true"
data-show-columns-search="true"
data-show-columns-toggle-all="true"
data-show-export="true"
data-show-filter-control-switch="true"
data-show-fullscreen="true"
data-show-search-clear-button="true"
data-sortable="true"
data-sort-name="base"
data-sort-order="asc"
data-toolbar="#toolbar"
data-unique-id="id">
<thead class="table-primary">
<tr>
<th data-checkbox="true"></th>
<th data-sortable="true" data-switchable="false" data-field="base" data-filter-control="input" data-filter-control-placeholder="(any base)">package base</th>
<th data-sortable="true" data-align="right" data-field="version" data-filter-control="input" data-filter-control-placeholder="(any version)">version</th>
<th data-sortable="true" data-field="packages" data-filter-control="input" data-filter-control-placeholder="(any package)">packages</th>
<th data-sortable="true" data-visible="false" data-field="groups" data-filter-control="select" data-filter-data="func:filterListGroups" data-filter-custom-search="filterList" data-filter-control-placeholder="(any group)">groups</th>
<th data-sortable="true" data-visible="false" data-field="licenses" data-filter-control="select" data-filter-data="func:filterListLicenses" data-filter-custom-search="filterList" data-filter-control-placeholder="(any license)">licenses</th>
<th data-sortable="true" data-visible="false" data-field="packager" data-filter-control="select" data-filter-custom-search="filterContains" data-filter-control-placeholder="(any packager)">packager</th>
<th data-sortable="true" data-align="right" data-field="timestamp" data-filter-control="input" data-filter-custom-search="filterDateRange" data-filter-control-placeholder="(any date)">last update</th>
<th data-sortable="true" data-align="center" data-cell-style="statusFormat" data-field="status" data-filter-control="select" data-filter-control-placeholder="(any status)">status</th>
</tr>
</thead>
</table>
</div>
<div class="container">
<footer class="d-flex flex-wrap justify-content-between align-items-center border-top">
<ul class="nav">
<li><a id="badge-version" class="nav-link" href="https://github.com/arcan1s/ahriman" title="sources"><i class="bi bi-github"></i> ahriman</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ahriman/releases" title="releases list">releases</a></li>
<li><a class="nav-link" href="https://github.com/arcan1s/ahriman/issues" title="issues tracker">report a bug</a></li>
{% if docs_enabled %}
<li><a class="nav-link" href="/api-docs" title="API documentation">api</a></li>
{% endif %}
</ul>
{% if index_url is not none %}
<ul class="nav">
<li><a class="nav-link" href="{{ index_url }}" title="repo index"><i class="bi bi-house"></i> repo index</a></li>
</ul>
{% endif %}
{% if auth.enabled %}
<ul class="nav">
{% if auth.username is none %}
<li>{{ auth.control | safe }}</li>
{% else %}
<li>
<form action="/api/v1/logout" method="post">
<button class="btn btn-link" style="text-decoration: none"><i class="bi bi-box-arrow-right"></i> logout ({{ auth.username }})</button>
</form>
</li>
{% endif %}
</ul>
{% endif %}
</footer>
</div>
{% if auth.enabled %}
{% include "build-status/login-modal.jinja2" %}
{% endif %}
{% include "build-status/alerts.jinja2" %}
{% include "build-status/dashboard.jinja2" %}
{% include "build-status/package-add-modal.jinja2" %}
{% include "build-status/package-rebuild-modal.jinja2" %}
{% include "build-status/key-import-modal.jinja2" %}
{% include "build-status/package-info-modal.jinja2" %}
{% include "build-status/table.jinja2" %}
</body>
<div id="root"></div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More