upload ai slop

This commit is contained in:
2026-02-25 22:49:38 +02:00
parent 49ebbc34fa
commit a99f00ec0c
124 changed files with 3559 additions and 131 deletions

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

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

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": "^5.15.0",
"@mui/material": "^5.15.0",
"@mui/x-data-grid": "^7.0.0",
"@tanstack/react-query": "^5.0.0",
"chart.js": "^4.5.0",
"highlight.js": "^11.11.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin": "^5.9.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.0",
"eslint": "^9.39.3",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"typescript": "^5.3.0",
"typescript-eslint": "^8.56.1",
"vite": "^5.0.0",
"vite-tsconfig-paths": "^6.1.1"
}
}

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

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

View File

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

View File

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

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,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,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,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,7 @@
export interface Remote {
branch?: string;
git_url?: string;
path?: string;
source: string;
web_url?: string;
}

View File

@@ -0,0 +1,4 @@
export interface RepositoryId {
architecture: string;
repository: string;
}

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,33 @@
import type React from "react";
import { Line } from "react-chartjs-2";
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend } from "chart.js";
import type { Event } from "api/types/Event";
import { formatTimestamp } from "components/common/formatTimestamp";
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend);
interface EventDurationLineChartProps {
events: Event[];
}
export default function EventDurationLineChart({ events }: EventDurationLineChartProps): React.JSX.Element | null {
const updateEvents = events.filter((e) => e.event === "package-updated" && e.data?.took);
if (updateEvents.length === 0) {
return null;
}
const data = {
labels: updateEvents.map((e) => formatTimestamp(e.created)),
datasets: [
{
label: "update duration, s",
data: updateEvents.map((e) => (e.data as Record<string, number>).took),
cubicInterpolationMode: "monotone" as const,
tension: 0.4,
},
],
};
return <Line data={data} options={{ responsive: true }} />;
}

View File

@@ -0,0 +1,39 @@
import type React from "react";
import { Bar } from "react-chartjs-2";
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from "chart.js";
import type { RepositoryStats } from "api/types/RepositoryStats";
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend);
interface PackageCountBarChartProps {
stats: RepositoryStats;
}
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
const data = {
labels: ["packages"],
datasets: [
{
label: "archives",
data: [stats.packages ?? 0],
},
{
label: "bases",
data: [stats.bases ?? 0],
},
],
};
return (
<Bar
data={data}
options={{
maintainAspectRatio: false,
responsive: true,
scales: {
x: { stacked: true },
},
}}
/>
);
}

View File

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

View File

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

View File

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

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,88 @@
import type React from "react";
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, Typography, Box } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import { skipToken, useQuery } from "@tanstack/react-query";
import StatusPieChart from "components/charts/StatusPieChart";
import PackageCountBarChart from "components/charts/PackageCountBarChart";
import { Client } from "api/client/AhrimanClient";
import { QueryKeys } from "api/QueryKeys";
import { useRepository } from "hooks/useRepository";
import { StatusHeaderStyles } from "theme/status/StatusColors";
import { formatTimestamp } from "components/common/formatTimestamp";
import type { InternalStatus } from "api/types/InternalStatus";
interface DashboardDialogProps {
open: boolean;
onClose: () => void;
}
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
const { current } = useRepository();
const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => Client.fetchStatus(current) : skipToken,
enabled: !!current && open,
});
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogTitle sx={headerStyle}>System health</DialogTitle>
<DialogContent>
{status && (
<>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={6} md={3}>
<Typography variant="body2" color="text.secondary" align="right">Repository name</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body2">{status.repository}</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body2" color="text.secondary" align="right">Repository architecture</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body2">{status.architecture}</Typography>
</Grid>
</Grid>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid item xs={6} md={3}>
<Typography variant="body2" color="text.secondary" align="right">Current status</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body2">{status.status.status}</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body2" color="text.secondary" align="right">Updated at</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="body2">{formatTimestamp(status.status.timestamp)}</Typography>
</Grid>
</Grid>
{status.packages.total > 0 && (
<Grid container spacing={2} sx={{ mt: 2 }}>
<Grid item xs={12} md={6}>
<Box sx={{ maxHeight: 300 }}>
<PackageCountBarChart stats={status.stats} />
</Box>
</Grid>
<Grid item xs={12} md={6}>
<Box sx={{ maxHeight: 300 }}>
<StatusPieChart counters={status.packages} />
</Box>
</Grid>
</Grid>
)}
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="contained" startIcon={<CloseIcon />}>close</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,106 @@
import React, { useState } from "react";
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
TextField, Box,
} from "@mui/material";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RefreshIcon from "@mui/icons-material/Refresh";
import { useNotification } from "hooks/useNotification";
import { Client } from "api/client/AhrimanClient";
import { ApiError } from "api/client/ApiError";
import CopyButton from "components/common/CopyButton";
interface KeyImportDialogProps {
open: boolean;
onClose: () => void;
}
export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element {
const { showSuccess, showError } = useNotification();
const [fingerprint, setFingerprint] = useState("");
const [server, setServer] = useState("keyserver.ubuntu.com");
const [keyBody, setKeyBody] = useState("");
const handleFetch = async (): Promise<void> => {
if (!fingerprint || !server) {
return;
}
try {
const result = await Client.fetchPGPKey(fingerprint, server);
setKeyBody(result.key);
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Could not fetch key: ${detail}`);
}
};
const handleImport = async (): Promise<void> => {
if (!fingerprint || !server) {
return;
}
try {
await Client.importPGPKey({ key: fingerprint, server });
onClose();
showSuccess("Success", `Key ${fingerprint} has been imported`);
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`);
}
};
const handleClose = (): void => {
setFingerprint("");
setServer("keyserver.ubuntu.com");
setKeyBody("");
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
<DialogTitle>Import key from PGP server</DialogTitle>
<DialogContent>
<TextField
label="fingerprint"
placeholder="PGP key fingerprint"
fullWidth
margin="normal"
value={fingerprint}
onChange={(e) => setFingerprint(e.target.value)}
/>
<TextField
label="key server"
placeholder="PGP key server"
fullWidth
margin="normal"
value={server}
onChange={(e) => setServer(e.target.value)}
/>
{keyBody && (
<Box sx={{ position: "relative", mt: 2 }}>
<Box
component="pre"
sx={{
backgroundColor: "grey.100",
p: 2,
borderRadius: 1,
overflow: "auto",
maxHeight: 300,
fontSize: "0.85rem",
}}
>
<code>{keyBody}</code>
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={() => keyBody} />
</Box>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => void handleImport()} variant="contained" startIcon={<PlayArrowIcon />}>import</Button>
<Button onClick={() => void handleFetch()} variant="contained" color="success" startIcon={<RefreshIcon />}>fetch</Button>
</DialogActions>
</Dialog>
);
}

View File

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

View File

@@ -0,0 +1,200 @@
import React, { useState, useCallback } from "react";
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
TextField, Autocomplete, Box, IconButton, FormControlLabel, Checkbox, Select, MenuItem, InputLabel, FormControl,
} from "@mui/material";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import AddIcon from "@mui/icons-material/Add";
import DeleteIcon from "@mui/icons-material/Delete";
import { useQuery } from "@tanstack/react-query";
import { useRepository } from "hooks/useRepository";
import { useNotification } from "hooks/useNotification";
import { useDebounce } from "hooks/useDebounce";
import { Client } from "api/client/AhrimanClient";
import { ApiError } from "api/client/ApiError";
import { QueryKeys } from "api/QueryKeys";
import type { AURPackage } from "api/types/AURPackage";
import type { RepositoryId } from "api/types/RepositoryId";
interface EnvVar {
key: string;
value: string;
}
interface PackageAddDialogProps {
open: boolean;
onClose: () => void;
}
export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
const { repositories, current } = useRepository();
const { showSuccess, showError } = useNotification();
const [packageName, setPackageName] = useState("");
const [selectedRepo, setSelectedRepo] = useState<string>("");
const [refresh, setRefresh] = useState(true);
const [envVars, setEnvVars] = useState<EnvVar[]>([]);
const debouncedSearch = useDebounce(packageName, 500);
const { data: searchResults = [] } = useQuery<AURPackage[]>({
queryKey: QueryKeys.search(debouncedSearch),
queryFn: () => Client.searchPackages(debouncedSearch),
enabled: debouncedSearch.length >= 3,
});
const getSelectedRepo = useCallback((): RepositoryId | null => {
if (selectedRepo) {
const repo = repositories.find(
(r) => `${r.architecture}-${r.repository}` === selectedRepo,
);
if (repo) {
return repo;
}
}
return current;
}, [selectedRepo, repositories, current]);
const handleAdd = async (): Promise<void> => {
if (!packageName) {
return;
}
const repo = getSelectedRepo();
if (!repo) {
return;
}
try {
const patches = envVars.filter((v) => v.key);
await Client.addPackages(repo, {
packages: [packageName],
patches: patches.length > 0 ? patches : undefined,
refresh,
});
onClose();
showSuccess("Success", `Packages ${packageName} have been added`);
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Package addition failed: ${detail}`);
}
};
const handleRequest = async (): Promise<void> => {
if (!packageName) {
return;
}
const repo = getSelectedRepo();
if (!repo) {
return;
}
try {
const patches = envVars.filter((v) => v.key);
await Client.requestPackages(repo, {
packages: [packageName],
patches: patches.length > 0 ? patches : undefined,
});
onClose();
showSuccess("Success", `Packages ${packageName} have been requested`);
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Package request failed: ${detail}`);
}
};
const handleClose = (): void => {
setPackageName("");
setSelectedRepo("");
setRefresh(true);
setEnvVars([]);
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>Add new packages</DialogTitle>
<DialogContent>
<FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel>
<Select
value={selectedRepo || (current ? `${current.architecture}-${current.repository}` : "")}
label="repository"
onChange={(e) => setSelectedRepo(e.target.value)}
>
{repositories.map((r) => (
<MenuItem key={`${r.architecture}-${r.repository}`} value={`${r.architecture}-${r.repository}`}>
{r.repository} ({r.architecture})
</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
freeSolo
options={searchResults.map((p) => p.package)}
inputValue={packageName}
onInputChange={(_, value) => setPackageName(value)}
renderOption={(props, option) => {
const pkg = searchResults.find((p) => p.package === option);
return (
<li {...props} key={option}>
{option}{pkg ? ` (${pkg.description})` : ""}
</li>
);
}}
renderInput={(params) => (
<TextField {...params} label="package" placeholder="AUR package" margin="normal" />
)}
/>
<FormControlLabel
control={<Checkbox checked={refresh} onChange={(_, checked) => setRefresh(checked)} />}
label="update pacman databases"
/>
<Button
fullWidth
variant="outlined"
startIcon={<AddIcon />}
onClick={() => setEnvVars([...envVars, { key: "", value: "" }])}
sx={{ mt: 1 }}
>
add environment variable
</Button>
{envVars.map((env, index) => (
<Box key={index} sx={{ display: "flex", gap: 1, mt: 1, alignItems: "center" }}>
<TextField
size="small"
placeholder="name"
value={env.key}
onChange={(e) => {
const updated = [...envVars];
updated[index] = { ...updated[index], key: e.target.value };
setEnvVars(updated);
}}
sx={{ flex: 1 }}
/>
<Box>=</Box>
<TextField
size="small"
placeholder="value"
value={env.value}
onChange={(e) => {
const updated = [...envVars];
updated[index] = { ...updated[index], value: e.target.value };
setEnvVars(updated);
}}
sx={{ flex: 1 }}
/>
<IconButton size="small" color="error" onClick={() => setEnvVars(envVars.filter((_, i) => i !== index))}>
<DeleteIcon />
</IconButton>
</Box>
))}
</DialogContent>
<DialogActions>
<Button onClick={() => void handleAdd()} variant="contained" startIcon={<PlayArrowIcon />}>add</Button>
<Button onClick={() => void handleRequest()} variant="contained" color="success" startIcon={<AddIcon />}>request</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,296 @@
import React, { useState } from "react";
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
Grid, Typography, Link, Box, Tab, Tabs, IconButton, Chip, FormControlLabel, Checkbox,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import DeleteIcon from "@mui/icons-material/Delete";
import RefreshIcon from "@mui/icons-material/Refresh";
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import BuildLogsTab from "components/package/BuildLogsTab";
import ChangesTab from "components/package/ChangesTab";
import EventsTab from "components/package/EventsTab";
import AutoRefreshControl from "components/common/AutoRefreshControl";
import { useRepository } from "hooks/useRepository";
import { useAuth } from "hooks/useAuth";
import { useNotification } from "hooks/useNotification";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { Client } from "api/client/AhrimanClient";
import { ApiError } from "api/client/ApiError";
import { QueryKeys } from "api/QueryKeys";
import { StatusHeaderStyles } from "theme/status/StatusColors";
import { formatTimestamp } from "components/common/formatTimestamp";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { Dependencies } from "api/types/Dependencies";
import type { PackageProperties } from "api/types/PackageProperties";
import type { PackageStatus } from "api/types/PackageStatus";
import type { Patch } from "api/types/Patch";
interface PackageInfoDialogProps {
packageBase: string | null;
open: boolean;
onClose: () => void;
autorefreshIntervals: AutoRefreshInterval[];
}
function listToString(items: string[]): React.ReactNode {
const unique = [...new Set(items)].sort();
return unique.map((item, i) => (
<React.Fragment key={item}>
{item}
{i < unique.length - 1 && <br />}
</React.Fragment>
));
}
export default function PackageInfoDialog({ packageBase, open, onClose, autorefreshIntervals }: PackageInfoDialogProps): React.JSX.Element {
const { current } = useRepository();
const { enabled: authEnabled, username } = useAuth();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const hasAuth = !authEnabled || username !== null;
const [tabIndex, setTabIndex] = useState(0);
const [refreshDb, setRefreshDb] = useState(true);
const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval);
const { 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] : {};
// Flatten depends from all sub-packages
const allDepends: string[] = pkg
? Object.values(pkg.packages).flatMap((p: PackageProperties) => {
const pkgNames = Object.keys(pkg.packages);
const deps = (p.depends ?? []).filter((d: string) => !pkgNames.includes(d));
const makeDeps = (p.make_depends ?? []).filter((d: string) => !pkgNames.includes(d)).map((d: string) => `${d} (make)`);
const optDeps = (p.opt_depends ?? []).filter((d: string) => !pkgNames.includes(d)).map((d: string) => `${d} (optional)`);
return [...deps, ...makeDeps, ...optDeps];
})
: [];
const implicitDepends: string[] = dependencies
? Object.values(dependencies.paths).flat()
: [];
const groups: string[] = pkg
? Object.values(pkg.packages).flatMap((p: PackageProperties) => p.groups ?? [])
: [];
const licenses: string[] = pkg
? Object.values(pkg.packages).flatMap((p: PackageProperties) => p.licenses ?? [])
: [];
const upstreamUrls: string[] = pkg
? [...new Set(Object.values(pkg.packages).map((p: PackageProperties) => p.url).filter((u): u is string => !!u))].sort()
: [];
const aurUrl = pkg?.remote.web_url;
const packagesList: string[] = pkg
? Object.entries(pkg.packages).map(([name, p]) => `${name}${p.description ? ` (${p.description})` : ""}`)
: [];
const handleUpdate = async (): Promise<void> => {
if (!packageBase || !current) {
return;
}
try {
await Client.addPackages(current, { packages: [packageBase], refresh: refreshDb });
showSuccess("Success", `Run update for packages ${packageBase}`);
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Package update failed: ${detail}`);
}
};
const handleRemove = async (): Promise<void> => {
if (!packageBase || !current) {
return;
}
try {
await Client.removePackages(current, [packageBase]);
showSuccess("Success", `Packages ${packageBase} have been removed`);
onClose();
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Could not remove package: ${detail}`);
}
};
const handleDeletePatch = async (key: string): Promise<void> => {
if (!packageBase) {
return;
}
try {
await Client.deletePatch(packageBase, key);
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Could not delete variable: ${detail}`);
}
};
const handleReload = (): void => {
if (!packageBase || !current) {
return;
}
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(packageBase, current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.logs(packageBase, current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.changes(packageBase, current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.events(current, packageBase) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.dependencies(packageBase, current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
};
const handleClose = (): void => {
setTabIndex(0);
setRefreshDb(true);
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
<DialogTitle sx={headerStyle}>
{pkg && status
? `${pkg.base} ${status.status} at ${formatTimestamp(status.timestamp)}`
: packageBase ?? ""}
</DialogTitle>
<DialogContent>
{pkg && (
<>
<Grid container spacing={1} sx={{ mt: 1 }}>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(packagesList)}</Typography></Grid>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{pkg.version}</Typography></Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">packager</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
<Grid item xs={4} md={1} />
<Grid item xs={8} md={5} />
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(groups)}</Typography></Grid>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(licenses)}</Typography></Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">upstream</Typography></Grid>
<Grid item xs={8} md={5}>
{upstreamUrls.map((url) => (
<Link key={url} href={url} target="_blank" rel="noopener" underline="hover" display="block" variant="body2">
{url}
</Link>
))}
</Grid>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid>
<Grid item xs={8} md={5}>
{aurUrl && (
<Link href={aurUrl} target="_blank" rel="noopener" underline="hover" variant="body2">AUR link</Link>
)}
</Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(allDepends)}</Typography></Grid>
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid>
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(implicitDepends)}</Typography></Grid>
</Grid>
{patches.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Environment variables</Typography>
{patches.map((patch) => (
<Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<Chip label={patch.key} size="small" />
<Typography variant="body2">=</Typography>
<Typography variant="body2" sx={{ fontFamily: "monospace" }}>{JSON.stringify(patch.value)}</Typography>
{hasAuth && (
<IconButton size="small" color="error" onClick={() => void handleDeletePatch(patch.key)}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Box>
))}
</Box>
)}
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
<Tabs value={tabIndex} onChange={(_, v: number) => setTabIndex(v)}>
<Tab label="Build logs" />
<Tab label="Changes" />
<Tab label="Events" />
</Tabs>
</Box>
{tabIndex === 0 && packageBase && 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>
<DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
{hasAuth && (
<>
<FormControlLabel
control={<Checkbox checked={refreshDb} onChange={(_, checked) => setRefreshDb(checked)} size="small" />}
label="update pacman databases"
/>
<Button onClick={() => void handleUpdate()} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
update
</Button>
<Button onClick={() => void handleRemove()} variant="contained" color="error" startIcon={<DeleteIcon />} size="small">
remove
</Button>
</>
)}
<Button onClick={handleReload} variant="outlined" color="secondary" startIcon={<RefreshIcon />} size="small">
reload
</Button>
<AutoRefreshControl
intervals={autorefreshIntervals}
enabled={autoRefresh.enabled}
currentInterval={autoRefresh.interval}
onToggle={autoRefresh.setEnabled}
onIntervalChange={autoRefresh.setInterval}
/>
<Button onClick={handleClose} variant="contained" startIcon={<CloseIcon />} size="small">
close
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,92 @@
import React, { useState } from "react";
import {
Dialog, DialogTitle, DialogContent, DialogActions, Button,
TextField, Select, MenuItem, InputLabel, FormControl,
} from "@mui/material";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import { useRepository } from "hooks/useRepository";
import { useNotification } from "hooks/useNotification";
import { Client } from "api/client/AhrimanClient";
import { ApiError } from "api/client/ApiError";
import type { RepositoryId } from "api/types/RepositoryId";
interface PackageRebuildDialogProps {
open: boolean;
onClose: () => void;
}
export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
const { repositories, current } = useRepository();
const { showSuccess, showError } = useNotification();
const [dependency, setDependency] = useState("");
const [selectedRepo, setSelectedRepo] = useState<string>("");
const getSelectedRepo = (): RepositoryId | null => {
if (selectedRepo) {
const repo = repositories.find((r) => `${r.architecture}-${r.repository}` === selectedRepo);
if (repo) {
return repo;
}
}
return current;
};
const handleRebuild = async (): Promise<void> => {
if (!dependency) {
return;
}
const repo = getSelectedRepo();
if (!repo) {
return;
}
try {
await Client.rebuildPackages(repo, [dependency]);
onClose();
showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`);
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Repository rebuild failed: ${detail}`);
}
};
const handleClose = (): void => {
setDependency("");
setSelectedRepo("");
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>Rebuild depending packages</DialogTitle>
<DialogContent>
<FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel>
<Select
value={selectedRepo || (current ? `${current.architecture}-${current.repository}` : "")}
label="repository"
onChange={(e) => setSelectedRepo(e.target.value)}
>
{repositories.map((r) => (
<MenuItem key={`${r.architecture}-${r.repository}`} value={`${r.architecture}-${r.repository}`}>
{r.repository} ({r.architecture})
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="dependency"
placeholder="packages dependency"
fullWidth
margin="normal"
value={dependency}
onChange={(e) => setDependency(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => void handleRebuild()} variant="contained" startIcon={<PlayArrowIcon />}>rebuild</Button>
</DialogActions>
</Dialog>
);
}

View File

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

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.href = "/";
};
return (
<Box
component="footer"
sx={{
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
alignItems: "center",
borderTop: 1,
borderColor: "divider",
mt: 2,
py: 1,
}}
>
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
<Link href="https://github.com/arcan1s/ahriman" underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<GitHubIcon fontSize="small" />
<Typography variant="body2">ahriman {version}</Typography>
</Link>
<Link href="https://github.com/arcan1s/ahriman/releases" underline="hover" color="text.secondary" variant="body2">
releases
</Link>
<Link href="https://github.com/arcan1s/ahriman/issues" underline="hover" color="text.secondary" variant="body2">
report a bug
</Link>
{docsEnabled && (
<Link href="/api-docs" underline="hover" color="text.secondary" variant="body2">
api
</Link>
)}
</Box>
{indexUrl && (
<Box>
<Link href={indexUrl} underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<HomeIcon fontSize="small" />
<Typography variant="body2">repo index</Typography>
</Link>
</Box>
)}
{authEnabled && (
<Box>
{username ? (
<Button size="small" startIcon={<LogoutIcon />} onClick={() => void handleLogout()} sx={{ textTransform: "none" }}>
logout ({username})
</Button>
) : (
<Button size="small" onClick={onLoginClick} sx={{ textTransform: "none" }}>
login
</Button>
)}
</Box>
)}
</Box>
);
}

View File

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

View File

@@ -0,0 +1,192 @@
import React, { useState, useEffect, useMemo, useRef } from "react";
import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
import ListIcon from "@mui/icons-material/List";
import { useQuery } from "@tanstack/react-query";
import hljs from "highlight.js/lib/core";
import plaintext from "highlight.js/lib/languages/plaintext";
import "highlight.js/styles/github.css";
import { Client } from "api/client/AhrimanClient";
import { QueryKeys } from "api/QueryKeys";
import { formatTimestamp } from "components/common/formatTimestamp";
import CopyButton from "components/common/CopyButton";
import type { LogRecord } from "api/types/LogRecord";
import type { RepositoryId } from "api/types/RepositoryId";
hljs.registerLanguage("plaintext", plaintext);
interface LogVersion {
version: string;
processId: string;
created: number;
logs: string;
}
interface BuildLogsTabProps {
packageBase: string;
repo: RepositoryId;
refetchInterval: number | false;
}
function convertLogs(records: LogRecord[], filter?: (r: LogRecord) => boolean): string {
return records
.filter(filter || Boolean)
.map((r) => `[${new Date(r.created * 1000).toISOString()}] ${r.message}`)
.join("\n");
}
export default function BuildLogsTab({ packageBase, repo, refetchInterval }: BuildLogsTabProps): React.JSX.Element {
const [activeIndex, setActiveIndex] = useState(0);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const codeRef = useRef<HTMLElement>(null);
const preRef = useRef<HTMLElement>(null);
const initialScrollDone = useRef(false);
const { data: allLogs } = useQuery<LogRecord[]>({
queryKey: QueryKeys.logs(packageBase, repo),
queryFn: () => Client.fetchLogs(packageBase, repo),
enabled: !!packageBase,
});
// Build version selectors from all logs
const versions = useMemo<LogVersion[]>(() => {
if (!allLogs || allLogs.length === 0) {
return [];
}
const grouped: Record<string, LogRecord & { minCreated: number }> = {};
for (const record of allLogs) {
const key = `${record.version}-${record.process_id}`;
if (!grouped[key]) {
grouped[key] = { ...record, minCreated: record.created };
} else {
grouped[key].minCreated = Math.min(grouped[key].minCreated, record.created);
}
}
return Object.values(grouped)
.sort((a, b) => b.minCreated - a.minCreated)
.map((v) => ({
version: v.version,
processId: v.process_id,
created: v.minCreated,
logs: convertLogs(
allLogs,
(r) => r.version === v.version && r.process_id === v.process_id,
),
}));
}, [allLogs]);
// Reset active index when data changes
const [prevAllLogs, setPrevAllLogs] = useState(allLogs);
if (allLogs !== prevAllLogs) {
setPrevAllLogs(allLogs);
setActiveIndex(0);
}
// Reset scroll tracking when logs data changes
useEffect(() => {
initialScrollDone.current = false;
}, [allLogs]);
// Refresh active version logs when using auto-refresh
const activeVersion = versions[activeIndex];
const { data: versionLogs } = useQuery<LogRecord[]>({
queryKey: activeVersion
? QueryKeys.logsVersion(packageBase, repo, activeVersion.version, activeVersion.processId)
: ["logs-none"],
queryFn: () =>
activeVersion
? Client.fetchLogs(packageBase, repo, activeVersion.version, activeVersion.processId)
: Promise.resolve([]),
enabled: !!activeVersion && !!refetchInterval,
refetchInterval,
});
// Derive displayed logs: prefer fresh polled data when available
const displayedLogs = useMemo(() => {
if (versionLogs && versionLogs.length > 0) {
return convertLogs(versionLogs);
}
return activeVersion?.logs ?? "";
}, [versionLogs, activeVersion]);
// Highlight code
useEffect(() => {
if (codeRef.current && displayedLogs) {
codeRef.current.textContent = displayedLogs;
delete codeRef.current.dataset.highlighted;
hljs.highlightElement(codeRef.current);
}
}, [displayedLogs]);
// Auto-scroll: always scroll to bottom on initial load, then only if already near bottom
useEffect(() => {
if (preRef.current && displayedLogs) {
const el = preRef.current;
if (!initialScrollDone.current) {
el.scrollTop = el.scrollHeight;
initialScrollDone.current = true;
} else {
const isAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 50;
if (isAtBottom) {
el.scrollTop = el.scrollHeight;
}
}
}
}, [displayedLogs]);
return (
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
<Box>
<Button
size="small"
startIcon={<ListIcon />}
onClick={(e) => setAnchorEl(e.currentTarget)}
/>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
{versions.map((v, idx) => (
<MenuItem
key={`${v.version}-${v.processId}`}
selected={idx === activeIndex}
onClick={() => {
setActiveIndex(idx);
setAnchorEl(null);
initialScrollDone.current = false;
}}
>
<Typography variant="body2">{formatTimestamp(v.created)}</Typography>
</MenuItem>
))}
{versions.length === 0 && (
<MenuItem disabled>No logs available</MenuItem>
)}
</Menu>
</Box>
<Box sx={{ flex: 1, position: "relative" }}>
<Box
ref={preRef}
component="pre"
sx={{
backgroundColor: "grey.100",
p: 2,
borderRadius: 1,
overflow: "auto",
maxHeight: 400,
fontSize: "0.8rem",
fontFamily: "monospace",
}}
>
<code ref={codeRef} className="language-plaintext" />
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={() => displayedLogs} />
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,60 @@
import React, { useEffect, useRef } from "react";
import { Box } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import hljs from "highlight.js/lib/core";
import diff from "highlight.js/lib/languages/diff";
import "highlight.js/styles/github.css";
import { Client } from "api/client/AhrimanClient";
import { QueryKeys } from "api/QueryKeys";
import CopyButton from "components/common/CopyButton";
import type { Changes } from "api/types/Changes";
import type { RepositoryId } from "api/types/RepositoryId";
hljs.registerLanguage("diff", diff);
interface ChangesTabProps {
packageBase: string;
repo: RepositoryId;
}
export default function ChangesTab({ packageBase, repo }: ChangesTabProps): React.JSX.Element {
const codeRef = useRef<HTMLElement>(null);
const { data } = useQuery<Changes>({
queryKey: QueryKeys.changes(packageBase, repo),
queryFn: () => Client.fetchChanges(packageBase, repo),
enabled: !!packageBase,
});
const changesText = data?.changes ?? "";
useEffect(() => {
if (codeRef.current) {
codeRef.current.textContent = changesText;
delete codeRef.current.dataset.highlighted;
hljs.highlightElement(codeRef.current);
}
}, [changesText]);
return (
<Box sx={{ position: "relative", mt: 1 }}>
<Box
component="pre"
sx={{
backgroundColor: "grey.100",
p: 2,
borderRadius: 1,
overflow: "auto",
maxHeight: 400,
fontSize: "0.8rem",
fontFamily: "monospace",
}}
>
<code ref={codeRef} className="language-diff" />
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={() => changesText} />
</Box>
</Box>
);
}

View File

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

View File

@@ -0,0 +1,314 @@
import React, { useState, useMemo, useCallback, useEffect } from "react";
import {
DataGrid,
GridToolbarQuickFilter,
GridToolbarFilterButton,
type GridColDef,
type GridFilterModel,
type GridRenderCellParams,
} from "@mui/x-data-grid";
import { Box, Link, Stack } from "@mui/material";
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import PackageTableToolbar from "components/table/PackageTableToolbar";
import StatusCell from "components/table/StatusCell";
import DashboardDialog from "components/dialogs/DashboardDialog";
import PackageAddDialog from "components/dialogs/PackageAddDialog";
import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog";
import KeyImportDialog from "components/dialogs/KeyImportDialog";
import PackageInfoDialog from "components/dialogs/PackageInfoDialog";
import { useRepository } from "hooks/useRepository";
import { useAuth } from "hooks/useAuth";
import { useNotification } from "hooks/useNotification";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { useLocalStorage } from "hooks/useLocalStorage";
import { Client } from "api/client/AhrimanClient";
import { ApiError } from "api/client/ApiError";
import { QueryKeys } from "api/QueryKeys";
import { formatTimestamp } from "components/common/formatTimestamp";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { InternalStatus } from "api/types/InternalStatus";
import type { PackageRow } from "api/types/PackageRow";
import type { PackageStatus } from "api/types/PackageStatus";
interface PackageTableProps {
autorefreshIntervals: AutoRefreshInterval[];
}
function extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
return [
...new Set(
Object.values(pkg.packages)
.flatMap((p) => p[property] ?? []),
),
].sort();
}
function toRow(ps: PackageStatus): PackageRow {
return {
id: ps.package.base,
base: ps.package.base,
webUrl: ps.package.remote.web_url ?? undefined,
version: ps.package.version,
packages: Object.keys(ps.package.packages).sort(),
groups: extractListProperties(ps.package, "groups"),
licenses: extractListProperties(ps.package, "licenses"),
packager: ps.package.packager ?? "",
timestamp: formatTimestamp(ps.status.timestamp),
timestampValue: ps.status.timestamp,
status: ps.status.status,
};
}
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
function SearchToolbar(): React.JSX.Element {
return (
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 1, py: 0.5 }}>
<GridToolbarFilterButton />
<GridToolbarQuickFilter debounceMs={300} sx={{ flex: 1 }} />
</Stack>
);
}
export default function PackageTable({ autorefreshIntervals }: PackageTableProps): React.JSX.Element {
const { current } = useRepository();
const { enabled: authEnabled, username } = useAuth();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
const hasAuth = !authEnabled || username !== null;
const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval);
const [selectionModel, setSelectionModel] = useState<string[]>([]);
const [dialogOpen, setDialogOpen] = useState<string | null>(null);
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
pageSize: 10,
page: 0,
});
const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>(
"ahriman-packages-columns",
{ groups: false, licenses: false, packager: false },
);
const [filterModel, setFilterModel] = useLocalStorage<GridFilterModel>(
"ahriman-packages-filters",
{ items: [] },
);
// Pause auto-refresh when dialog is open
const isDialogOpen = dialogOpen !== null || selectedPackage !== null;
const setPaused = autoRefresh.setPaused;
useEffect(() => {
setPaused(isDialogOpen);
}, [isDialogOpen, setPaused]);
const { data: packages = [], isLoading } = useQuery<PackageStatus[]>({
queryKey: current ? QueryKeys.packages(current) : ["packages"],
queryFn: () => (current ? Client.fetchPackages(current) : Promise.resolve([])),
enabled: !!current,
refetchInterval: autoRefresh.refetchInterval,
});
const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => Client.fetchStatus(current) : skipToken,
enabled: !!current,
refetchInterval: autoRefresh.refetchInterval,
});
const rows = useMemo(() => packages.map(toRow), [packages]);
const handleReload = useCallback(() => {
if (!current) {
return;
}
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
}, [current, queryClient]);
const handleUpdate = useCallback(async () => {
if (!current) {
return;
}
try {
if (selectionModel.length === 0) {
await Client.updatePackages(current, { packages: [] });
showSuccess("Success", "Repository update has been run");
} else {
await Client.addPackages(current, { packages: selectionModel });
showSuccess("Success", `Run update for packages ${selectionModel.join(", ")}`);
}
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Packages update failed: ${detail}`);
}
}, [current, selectionModel, showSuccess, showError]);
const handleRefreshDb = useCallback(async () => {
if (!current) {
return;
}
try {
await Client.updatePackages(current, { packages: [], refresh: true, aur: false, local: false, manual: false });
showSuccess("Success", "Pacman database update has been requested");
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Could not update pacman databases: ${detail}`);
}
}, [current, showSuccess, showError]);
const handleRemove = useCallback(async () => {
if (!current) {
return;
}
if (selectionModel.length === 0) {
return;
}
try {
await Client.removePackages(current, selectionModel);
showSuccess("Success", `Packages ${selectionModel.join(", ")} have been removed`);
setSelectionModel([]);
} catch (e) {
const detail = ApiError.errorDetail(e);
showError("Action failed", `Could not remove packages: ${detail}`);
}
}, [current, selectionModel, showSuccess, showError]);
const columns: GridColDef<PackageRow>[] = useMemo(
() => [
{
field: "base",
headerName: "package base",
flex: 1,
minWidth: 150,
renderCell: (params: GridRenderCellParams<PackageRow>) =>
params.row.webUrl ? (
<Link href={params.row.webUrl} target="_blank" rel="noopener" underline="hover">
{params.value as string}
</Link>
) : (
params.value as string
),
},
{ field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
{
field: "packages",
headerName: "packages",
flex: 1,
minWidth: 120,
valueGetter: (value: string[]) => (value ?? []).join(" "),
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.packages ?? []).map((item, i, arr) => (
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
)),
sortComparator: (v1: string, v2: string) => v1.localeCompare(v2),
},
{
field: "groups",
headerName: "groups",
width: 150,
valueGetter: (value: string[]) => (value ?? []).join(" "),
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.groups ?? []).map((item, i, arr) => (
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
)),
},
{
field: "licenses",
headerName: "licenses",
width: 150,
valueGetter: (value: string[]) => (value ?? []).join(" "),
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.licenses ?? []).map((item, i, arr) => (
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
)),
},
{ field: "packager", headerName: "packager", width: 150 },
{
field: "timestamp",
headerName: "last update",
width: 180,
align: "right",
headerAlign: "right",
},
{
field: "status",
headerName: "status",
width: 120,
align: "center",
headerAlign: "center",
renderCell: (params: GridRenderCellParams<PackageRow>) => <StatusCell status={params.row.status} />,
},
],
[],
);
return (
<Box>
<PackageTableToolbar
hasSelection={(selectionModel).length > 0}
hasAuth={hasAuth}
repoStatus={status?.status.status}
autorefreshIntervals={autorefreshIntervals}
autoRefreshEnabled={autoRefresh.enabled}
autoRefreshInterval={autoRefresh.interval}
onAutoRefreshToggle={autoRefresh.setEnabled}
onAutoRefreshIntervalChange={autoRefresh.setInterval}
onDashboardClick={() => setDialogOpen("dashboard")}
onAddClick={() => setDialogOpen("add")}
onUpdateClick={() => void handleUpdate()}
onRefreshDbClick={() => void handleRefreshDb()}
onRebuildClick={() => setDialogOpen("rebuild")}
onRemoveClick={() => void handleRemove()}
onKeyImportClick={() => setDialogOpen("keyImport")}
onReloadClick={handleReload}
/>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
getRowHeight={() => "auto"}
checkboxSelection
disableRowSelectionOnClick
rowSelectionModel={selectionModel}
onRowSelectionModelChange={(model) => setSelectionModel(model as string[])}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={PAGE_SIZE_OPTIONS}
columnVisibilityModel={columnVisibility}
onColumnVisibilityModelChange={setColumnVisibility}
filterModel={filterModel}
onFilterModelChange={setFilterModel}
slots={{ toolbar: SearchToolbar }}
initialState={{
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
}}
onRowClick={(params: { row: PackageRow }, event) => {
const target = event.target as HTMLElement;
// Don't open info dialog when clicking checkbox or link
if (target.closest("input[type=\"checkbox\"]") || target.closest("a")) {
return;
}
setSelectedPackage(params.row.id);
}}
sx={{
"& .MuiDataGrid-row": { cursor: "pointer" },
height: 600,
}}
density="compact"
/>
<DashboardDialog open={dialogOpen === "dashboard"} onClose={() => setDialogOpen(null)} />
<PackageAddDialog open={dialogOpen === "add"} onClose={() => setDialogOpen(null)} />
<PackageRebuildDialog open={dialogOpen === "rebuild"} onClose={() => setDialogOpen(null)} />
<KeyImportDialog open={dialogOpen === "keyImport"} onClose={() => setDialogOpen(null)} />
<PackageInfoDialog
packageBase={selectedPackage}
open={selectedPackage !== null}
onClose={() => setSelectedPackage(null)}
autorefreshIntervals={autorefreshIntervals}
/>
</Box>
);
}

View File

@@ -0,0 +1,133 @@
import React, { useState } from "react";
import { Button, Menu, MenuItem, Box, Tooltip, IconButton, Divider } from "@mui/material";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import InventoryIcon from "@mui/icons-material/Inventory";
import AddIcon from "@mui/icons-material/Add";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import DownloadIcon from "@mui/icons-material/Download";
import ReplayIcon from "@mui/icons-material/Replay";
import DeleteIcon from "@mui/icons-material/Delete";
import VpnKeyIcon from "@mui/icons-material/VpnKey";
import RefreshIcon from "@mui/icons-material/Refresh";
import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
import type { BuildStatus } from "api/types/BuildStatus";
import { StatusColors } from "theme/status/StatusColors";
interface PackageTableToolbarProps {
hasSelection: boolean;
hasAuth: boolean;
repoStatus?: BuildStatus;
autorefreshIntervals: AutoRefreshInterval[];
autoRefreshEnabled: boolean;
autoRefreshInterval: number;
onAutoRefreshToggle: (enabled: boolean) => void;
onAutoRefreshIntervalChange: (interval: number) => void;
onDashboardClick: () => void;
onAddClick: () => void;
onUpdateClick: () => void;
onRefreshDbClick: () => void;
onRebuildClick: () => void;
onRemoveClick: () => void;
onKeyImportClick: () => void;
onReloadClick: () => void;
}
export default function PackageTableToolbar({
hasSelection,
hasAuth,
repoStatus,
autorefreshIntervals,
autoRefreshEnabled,
autoRefreshInterval,
onAutoRefreshToggle,
onAutoRefreshIntervalChange,
onDashboardClick,
onAddClick,
onUpdateClick,
onRefreshDbClick,
onRebuildClick,
onRemoveClick,
onKeyImportClick,
onReloadClick,
}: PackageTableToolbarProps): React.JSX.Element {
const [packagesAnchorEl, setPackagesAnchorEl] = useState<null | HTMLElement>(null);
return (
<Box sx={{ display: "flex", gap: 1, mb: 1, flexWrap: "wrap", alignItems: "center" }}>
<Tooltip title="System health">
<IconButton
onClick={onDashboardClick}
sx={{
borderColor: repoStatus ? StatusColors[repoStatus] : undefined,
borderWidth: 1,
borderStyle: "solid",
color: repoStatus ? StatusColors[repoStatus] : undefined,
}}
>
<InfoOutlinedIcon />
</IconButton>
</Tooltip>
{hasAuth && (
<>
<Button
variant="contained"
startIcon={<InventoryIcon />}
onClick={(e) => setPackagesAnchorEl(e.currentTarget)}
>
packages
</Button>
<Menu
anchorEl={packagesAnchorEl}
open={Boolean(packagesAnchorEl)}
onClose={() => setPackagesAnchorEl(null)}
>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); onAddClick();
}}>
<AddIcon fontSize="small" sx={{ mr: 1 }} /> add
</MenuItem>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); onUpdateClick();
}}>
<PlayArrowIcon fontSize="small" sx={{ mr: 1 }} /> update
</MenuItem>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); onRefreshDbClick();
}}>
<DownloadIcon fontSize="small" sx={{ mr: 1 }} /> update pacman databases
</MenuItem>
<MenuItem onClick={() => {
setPackagesAnchorEl(null); onRebuildClick();
}}>
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
</MenuItem>
<Divider />
<MenuItem onClick={() => {
setPackagesAnchorEl(null); onRemoveClick();
}} disabled={!hasSelection}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
</MenuItem>
</Menu>
<Button variant="contained" color="info" startIcon={<VpnKeyIcon />} onClick={onKeyImportClick}>
import key
</Button>
</>
)}
<Button variant="outlined" color="secondary" startIcon={<RefreshIcon />} onClick={onReloadClick}>
reload
</Button>
<AutoRefreshControl
intervals={autorefreshIntervals}
enabled={autoRefreshEnabled}
currentInterval={autoRefreshInterval}
onToggle={onAutoRefreshToggle}
onIntervalChange={onAutoRefreshIntervalChange}
/>
</Box>
);
}

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,14 @@
import { createContext } from "react";
interface AuthState {
enabled: boolean;
username: string | null;
}
export interface AuthContextValue extends AuthState {
setAuthState: (state: AuthState) => void;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);

View File

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

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,53 @@
import React, { useState, useCallback, useRef, type ReactNode } from "react";
import { Snackbar, Alert, type AlertColor } from "@mui/material";
import { NotificationContext } from "contexts/NotificationContext";
interface Notification {
id: number;
title: string;
message: string;
severity: AlertColor;
}
export function NotificationProvider({ children }: { children: ReactNode }): React.JSX.Element {
const nextId = useRef(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const addNotification = useCallback((title: string, message: string, severity: AlertColor) => {
const id = nextId.current++;
setNotifications((prev) => [...prev, { id, title, message, severity }]);
}, []);
const removeNotification = useCallback((id: number) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, []);
const showSuccess = useCallback(
(title: string, message: string) => addNotification(title, message, "success"),
[addNotification],
);
const showError = useCallback(
(title: string, message: string) => addNotification(title, message, "error"),
[addNotification],
);
return (
<NotificationContext.Provider value={{ showSuccess, showError }}>
{children}
{notifications.map((n) => (
<Snackbar
key={n.id}
open
autoHideDuration={5000}
onClose={() => removeNotification(n.id)}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
>
<Alert onClose={() => removeNotification(n.id)} severity={n.severity} variant="filled" sx={{ width: "100%", maxWidth: 500 }}>
<strong>{n.title}</strong>
{n.message && ` - ${n.message}`}
</Alert>
</Snackbar>
))}
</NotificationContext.Provider>
);
}

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,47 @@
import React, { useState, useCallback, useEffect, type ReactNode } from "react";
import type { RepositoryId } from "api/types/RepositoryId";
import { RepositoryContext } from "contexts/RepositoryContext";
function repoId(repo: RepositoryId): string {
return `${repo.architecture}-${repo.repository}`;
}
export function RepositoryProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [repositories, setRepositories] = useState<RepositoryId[]>([]);
const [current, setCurrentState] = useState<RepositoryId | null>(null);
const setCurrent = useCallback((repo: RepositoryId) => {
setCurrentState(repo);
window.location.hash = repoId(repo);
}, []);
// Sync current repository when repositories list changes
const [prevRepositories, setPrevRepositories] = useState(repositories);
if (repositories !== prevRepositories) {
setPrevRepositories(repositories);
if (repositories.length > 0) {
const hash = window.location.hash.replace("#", "");
const match = repositories.find((r) => repoId(r) === hash);
setCurrentState(match || repositories[0]);
}
}
// Listen for hash changes
useEffect(() => {
const handler = (): void => {
const hash = window.location.hash.replace("#", "");
const match = repositories.find((r) => repoId(r) === hash);
if (match) {
setCurrentState(match);
}
};
window.addEventListener("hashchange", handler);
return () => window.removeEventListener("hashchange", handler);
}, [repositories]);
return (
<RepositoryContext.Provider value={{ repositories, current, setRepositories, setCurrent }}>
{children}
</RepositoryContext.Provider>
);
}

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;
setInterval: (interval: number) => void;
setPaused: Dispatch<SetStateAction<boolean>>;
}
export function useAutoRefresh(key: string, defaultInterval: number = 0): AutoRefreshResult {
const [stored, setStored] = useLocalStorage<AutoRefreshState>(`ahriman-${key}`, {
enabled: defaultInterval > 0,
interval: defaultInterval,
});
const [paused, setPaused] = useState(false);
const refetchInterval: number | false = stored.enabled && !paused && stored.interval > 0 ? stored.interval : false;
const setEnabled = useCallback(
(enabled: boolean) => {
setStored((prev) => ({ ...prev, enabled }));
},
[setStored],
);
const setInterval = useCallback(
(interval: number) => {
setStored({ enabled: true, interval });
},
[setStored],
);
return {
enabled: stored.enabled,
interval: stored.interval,
paused,
refetchInterval,
setEnabled,
setInterval,
setPaused,
};
}

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,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,10 @@
import { useContext } from "react";
import { RepositoryContext, type RepositoryContextValue } from "contexts/RepositoryContext";
export function useRepository(): RepositoryContextValue {
const ctx = useContext(RepositoryContext);
if (!ctx) {
throw new Error("useRepository must be used within RepositoryProvider");
}
return ctx;
}

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

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

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,20 @@
import { alpha } from "@mui/material/styles";
import type { BuildStatus } from "api/types/BuildStatus";
const base: Record<BuildStatus, string> = {
unknown: "#373a3c",
pending: "#ff7518",
building: "#ff7518",
failed: "#ff0039",
success: "#3fb618",
};
export const StatusColors = base;
export const StatusBackgrounds: Record<BuildStatus, string> = Object.fromEntries(
Object.entries(base).map(([k, v]) => [k, alpha(v, 0.1)]),
) as Record<BuildStatus, string>;
export const StatusHeaderStyles: Record<BuildStatus, { backgroundColor: string; color: string }> = Object.fromEntries(
Object.entries(base).map(([k, v]) => [k, { backgroundColor: v, color: "#fff" }]),
) as Record<BuildStatus, { backgroundColor: string; color: string }>;

21
frontend/tsconfig.json Normal file
View File

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

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

@@ -0,0 +1,41 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "path";
import fs from "fs";
import type { Plugin } from "vite";
function renameIndexHtml(): Plugin {
return {
name: "rename-index-html",
closeBundle() {
const outDir = path.resolve(__dirname, "../package/share/ahriman/templates");
const src = path.join(outDir, "index.html");
const dest = path.join(outDir, "build-status-v2.jinja2");
if (fs.existsSync(src)) {
fs.renameSync(src, dest);
}
},
};
}
export default defineConfig({
plugins: [react(), tsconfigPaths(), renameIndexHtml()],
base: "/",
build: {
outDir: path.resolve(__dirname, "../package/share/ahriman/templates"),
emptyOutDir: false,
rollupOptions: {
output: {
entryFileNames: "static/[name].js",
chunkFileNames: "static/[name].js",
assetFileNames: "static/[name].[ext]",
},
},
},
server: {
proxy: {
"/api": "http://localhost:8080",
},
},
});