feat: brand-new interface (#158)

This was initally generated by ai, but later has been heavily edited.
The reason why it has been implemented is that there are plans to
implement more features to ui, but it becomes hard to add new features
to plain js, so I decided to rewrite it in typescript.

Yet because it is still ai slop, it is still possible to enable old
interface via configuration, even though new interface is turned on by
default to get feedback
This commit is contained in:
2026-03-06 00:59:10 +02:00
committed by GitHub
parent db46147f0d
commit a05eab9042
158 changed files with 5965 additions and 306 deletions

View File

@@ -0,0 +1,43 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type { Event } from "models/Event";
import type React from "react";
import { Line } from "react-chartjs-2";
interface EventDurationLineChartProps {
events: Event[];
}
export default function EventDurationLineChart({ events }: EventDurationLineChartProps): React.JSX.Element {
const updateEvents = events.filter(event => event.event === "package-updated");
const data = {
labels: updateEvents.map(event => new Date(event.created * 1000).toISOStringShort()),
datasets: [
{
label: "update duration, s",
data: updateEvents.map(event => event.data?.took ?? 0),
cubicInterpolationMode: "monotone" as const,
tension: 0.4,
},
],
};
return <Line data={data} options={{ responsive: true }} />;
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { blue, indigo } from "@mui/material/colors";
import type { RepositoryStats } from "models/RepositoryStats";
import type React from "react";
import { Bar } from "react-chartjs-2";
interface PackageCountBarChartProps {
stats: RepositoryStats;
}
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
return <Bar
data={{
labels: ["packages"],
datasets: [
{
label: "archives",
data: [stats.packages ?? 0],
backgroundColor: blue[500],
},
{
label: "bases",
data: [stats.bases ?? 0],
backgroundColor: indigo[300],
},
],
}}
options={{
maintainAspectRatio: false,
responsive: true,
scales: {
x: { stacked: true },
y: { stacked: true },
},
}}
/>;
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type { BuildStatus } from "models/BuildStatus";
import type { Counters } from "models/Counters";
import type React from "react";
import { Pie } from "react-chartjs-2";
import { StatusColors } from "theme/StatusColors";
interface StatusPieChartProps {
counters: Counters;
}
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
const labels = ["unknown", "pending", "building", "failed", "success"] as BuildStatus[];
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,90 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import CheckIcon from "@mui/icons-material/Check";
import TimerIcon from "@mui/icons-material/Timer";
import TimerOffIcon from "@mui/icons-material/TimerOff";
import { IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Tooltip } from "@mui/material";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import React, { useState } from "react";
interface AutoRefreshControlProps {
intervals: AutoRefreshInterval[];
currentInterval: number;
onIntervalChange: (interval: number) => void;
}
export default function AutoRefreshControl({
intervals,
currentInterval,
onIntervalChange,
}: AutoRefreshControlProps): React.JSX.Element | null {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
if (intervals.length === 0) {
return null;
}
const enabled = currentInterval > 0;
return <>
<Tooltip title="Auto-refresh">
<IconButton
size="small"
onClick={event => setAnchorEl(event.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={() => {
onIntervalChange(0);
setAnchorEl(null);
}}
>
<ListItemIcon>
{!enabled && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>Off</ListItemText>
</MenuItem>
{intervals.map(interval =>
<MenuItem
key={interval.interval}
selected={enabled && interval.interval === currentInterval}
onClick={() => {
onIntervalChange(interval.interval);
setAnchorEl(null);
}}
>
<ListItemIcon>
{enabled && interval.interval === currentInterval && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>{interval.text}</ListItemText>
</MenuItem>,
)}
</Menu>
</>;
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box } from "@mui/material";
import CopyButton from "components/common/CopyButton";
import React, { type RefObject } from "react";
interface CodeBlockProps {
preRef?: RefObject<HTMLElement | null>;
getText: () => string;
height?: number | string;
onScroll?: () => void;
wordBreak?: boolean;
}
export default function CodeBlock({
preRef,
getText,
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",
height,
fontSize: "0.8rem",
fontFamily: "monospace",
...wordBreak ? { whiteSpace: "pre-wrap", wordBreak: "break-all" } : {},
}}
>
<code>
{getText()}
</code>
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={getText} />
</Box>
</Box>;
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import CheckIcon from "@mui/icons-material/Check";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { IconButton, Tooltip } from "@mui/material";
import React, { useEffect, useRef, useState } from "react";
interface CopyButtonProps {
getText: () => string;
}
export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const handleCopy: () => Promise<void> = async () => {
await navigator.clipboard.writeText(getText());
setCopied(true);
clearTimeout(timer.current);
timer.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,37 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import CloseIcon from "@mui/icons-material/Close";
import { DialogTitle, IconButton, type SxProps, type Theme } from "@mui/material";
import type React from "react";
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,50 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Alert, Slide } from "@mui/material";
import type { Notification } from "models/Notification";
import React, { useEffect, useState } from "react";
interface NotificationItemProps {
notification: Notification;
onClose: (id: string) => 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,44 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
import { useRepository } from "hooks/useRepository";
import type { SelectedRepositoryResult } from "hooks/useSelectedRepository";
import type React from "react";
export default function RepositorySelect({
repositorySelect,
}: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element {
const { repositories, current } = useRepository();
return <FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel>
<Select
value={repositorySelect.selectedKey || (current?.key ?? "")}
label="repository"
onChange={event => repositorySelect.setSelectedKey(event.target.value)}
>
{repositories.map(repository =>
<MenuItem key={repository.key} value={repository.key}>
{repository.label}
</MenuItem>,
)}
</Select>
</FormControl>;
}

View File

@@ -0,0 +1,103 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box, Dialog, DialogContent, Grid, Typography } from "@mui/material";
import { skipToken, useQuery } from "@tanstack/react-query";
import PackageCountBarChart from "components/charts/PackageCountBarChart";
import StatusPieChart from "components/charts/StatusPieChart";
import DialogHeader from "components/common/DialogHeader";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import { useRepository } from "hooks/useRepository";
import type { InternalStatus } from "models/InternalStatus";
import type React from "react";
import { StatusHeaderStyles } from "theme/StatusColors";
interface DashboardDialogProps {
open: boolean;
onClose: () => void;
}
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
const client = useClient();
const { current } = useRepository();
const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
enabled: open,
});
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
return <Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogHeader onClose={onClose} 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">{new Date(status.status.timestamp * 1000).toISOStringShort()}</Typography>
</Grid>
</Grid>
<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,118 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RefreshIcon from "@mui/icons-material/Refresh";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
TextField,
} from "@mui/material";
import { ApiError } from "api/client/ApiError";
import CodeBlock from "components/common/CodeBlock";
import DialogHeader from "components/common/DialogHeader";
import { useClient } from "hooks/useClient";
import { useNotification } from "hooks/useNotification";
import React, { useState } from "react";
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 handleClose = (): void => {
setFingerprint("");
setServer("keyserver.ubuntu.com");
setKeyBody("");
onClose();
};
const handleFetch: () => Promise<void> = async () => {
if (!fingerprint || !server) {
return;
}
try {
const result = await client.service.servicePGPFetch(fingerprint, server);
setKeyBody(result.key);
} catch (exception) {
const detail = ApiError.errorDetail(exception);
showError("Action failed", `Could not fetch key: ${detail}`);
}
};
const handleImport: () => Promise<void> = async () => {
if (!fingerprint || !server) {
return;
}
try {
await client.service.servicePGPImport({ key: fingerprint, server });
handleClose();
showSuccess("Success", `Key ${fingerprint} has been imported`);
} catch (exception) {
const detail = ApiError.errorDetail(exception);
showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`);
}
};
return <Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
<DialogHeader onClose={handleClose}>
Import key from PGP server
</DialogHeader>
<DialogContent>
<TextField
label="fingerprint"
placeholder="PGP key fingerprint"
fullWidth
margin="normal"
value={fingerprint}
onChange={event => setFingerprint(event.target.value)}
/>
<TextField
label="key server"
placeholder="PGP key server"
fullWidth
margin="normal"
value={server}
onChange={event => setServer(event.target.value)}
/>
{keyBody &&
<Box sx={{ mt: 2 }}>
<CodeBlock getText={() => keyBody} height={300} />
</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,118 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import PersonIcon from "@mui/icons-material/Person";
import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import {
Button,
Dialog,
DialogActions,
DialogContent,
IconButton,
InputAdornment,
TextField,
} from "@mui/material";
import { ApiError } from "api/client/ApiError";
import DialogHeader from "components/common/DialogHeader";
import { useAuth } from "hooks/useAuth";
import { useNotification } from "hooks/useNotification";
import React, { useState } from "react";
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 handleClose = (): void => {
setUsername("");
setPassword("");
setShowPassword(false);
onClose();
};
const handleSubmit: () => Promise<void> = async () => {
if (!username || !password) {
return;
}
try {
await login(username, password);
handleClose();
showSuccess("Logged in", `Successfully logged in as ${username}`);
} catch (exception) {
const detail = ApiError.errorDetail(exception);
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={open} onClose={handleClose} maxWidth="xs" fullWidth>
<DialogHeader onClose={handleClose}>
Login
</DialogHeader>
<DialogContent>
<TextField
label="username"
fullWidth
margin="normal"
value={username}
onChange={event => setUsername(event.target.value)}
autoFocus
/>
<TextField
label="password"
fullWidth
margin="normal"
type={showPassword ? "text" : "password"}
value={password}
onChange={event => setPassword(event.target.value)}
onKeyDown={event => {
if (event.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,193 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import AddIcon from "@mui/icons-material/Add";
import DeleteIcon from "@mui/icons-material/Delete";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import {
Autocomplete,
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
FormControlLabel,
IconButton,
TextField,
} from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import { ApiError } from "api/client/ApiError";
import DialogHeader from "components/common/DialogHeader";
import RepositorySelect from "components/common/RepositorySelect";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import { useDebounce } from "hooks/useDebounce";
import { useNotification } from "hooks/useNotification";
import { useSelectedRepository } from "hooks/useSelectedRepository";
import type { AURPackage } from "models/AURPackage";
import type { PackageActionRequest } from "models/PackageActionRequest";
import React, { useRef, useState } from "react";
interface EnvironmentVariable {
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 repositorySelect = useSelectedRepository();
const [packageName, setPackageName] = useState("");
const [refreshDatabase, setRefreshDatabase] = useState(true);
const [environmentVariables, setEnvironmentVariables] = useState<EnvironmentVariable[]>([]);
const variableIdCounter = useRef(0);
const handleClose = (): void => {
setPackageName("");
repositorySelect.reset();
setRefreshDatabase(true);
setEnvironmentVariables([]);
onClose();
};
const debouncedSearch = useDebounce(packageName, 500);
const { data: searchResults = [] } = useQuery<AURPackage[]>({
queryKey: QueryKeys.search(debouncedSearch),
queryFn: () => client.service.servicePackageSearch(debouncedSearch),
enabled: debouncedSearch.length >= 3,
});
const handleSubmit = async (action: "add" | "request"): Promise<void> => {
if (!packageName) {
return;
}
const repository = repositorySelect.selectedRepository;
if (!repository) {
return;
}
try {
const patches = environmentVariables.filter(variable => variable.key);
const request: PackageActionRequest = { packages: [packageName], patches };
if (action === "add") {
request.refresh = refreshDatabase;
await client.service.servicePackageAdd(repository, request);
} else {
await client.service.servicePackageRequest(repository, request);
}
handleClose();
showSuccess("Success", `Packages ${packageName} have been ${action === "add" ? "added" : "requested"}`);
} catch (exception) {
const detail = ApiError.errorDetail(exception);
showError("Action failed", `Package ${action} failed: ${detail}`);
}
};
return <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogHeader onClose={handleClose}>
Add new packages
</DialogHeader>
<DialogContent>
<RepositorySelect repositorySelect={repositorySelect} />
<Autocomplete
freeSolo
options={searchResults.map(pkg => pkg.package)}
inputValue={packageName}
onInputChange={(_, value) => setPackageName(value)}
renderOption={(props, option) => {
const pkg = searchResults.find(pkg => pkg.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={refreshDatabase} onChange={(_, checked) => setRefreshDatabase(checked)} />}
label="update pacman databases"
/>
<Button
fullWidth
variant="outlined"
startIcon={<AddIcon />}
onClick={() => {
const id = variableIdCounter.current++;
setEnvironmentVariables(prev => [...prev, { id, key: "", value: "" }]);
}}
sx={{ mt: 1 }}
>
add environment variable
</Button>
{environmentVariables.map(variable =>
<Box key={variable.id} sx={{ display: "flex", gap: 1, mt: 1, alignItems: "center" }}>
<TextField
size="small"
placeholder="name"
value={variable.key}
onChange={event => {
const newKey = event.target.value;
setEnvironmentVariables(prev =>
prev.map(entry => entry.id === variable.id ? { ...entry, key: newKey } : entry),
);
}}
sx={{ flex: 1 }}
/>
<Box>=</Box>
<TextField
size="small"
placeholder="value"
value={variable.value}
onChange={event => {
const newValue = event.target.value;
setEnvironmentVariables(prev =>
prev.map(entry => entry.id === variable.id ? { ...entry, value: newValue } : entry),
);
}}
sx={{ flex: 1 }}
/>
<IconButton size="small" color="error" aria-label="Remove variable" onClick={() => setEnvironmentVariables(prev => prev.filter(entry => entry.id !== variable.id))}>
<DeleteIcon />
</IconButton>
</Box>,
)}
</DialogContent>
<DialogActions>
<Button onClick={() => void handleSubmit("add")} variant="contained" startIcon={<PlayArrowIcon />}>add</Button>
<Button onClick={() => void handleSubmit("request")} variant="contained" color="success" startIcon={<AddIcon />}>request</Button>
</DialogActions>
</Dialog>;
}

View File

@@ -0,0 +1,194 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box, Dialog, DialogContent, Tab, Tabs } from "@mui/material";
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "api/client/ApiError";
import DialogHeader from "components/common/DialogHeader";
import BuildLogsTab from "components/package/BuildLogsTab";
import ChangesTab from "components/package/ChangesTab";
import EventsTab from "components/package/EventsTab";
import PackageDetailsGrid from "components/package/PackageDetailsGrid";
import PackageInfoActions from "components/package/PackageInfoActions";
import PackagePatchesList from "components/package/PackagePatchesList";
import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { useClient } from "hooks/useClient";
import { useNotification } from "hooks/useNotification";
import { useRepository } from "hooks/useRepository";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { Dependencies } from "models/Dependencies";
import type { PackageStatus } from "models/PackageStatus";
import type { Patch } from "models/Patch";
import React, { useState } from "react";
import { StatusHeaderStyles } from "theme/StatusColors";
import { defaultInterval } from "utils";
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 [localPackageBase, setLocalPackageBase] = useState(packageBase);
if (packageBase !== null && packageBase !== localPackageBase) {
setLocalPackageBase(packageBase);
}
const [tabIndex, setTabIndex] = useState(0);
const [refreshDatabase, setRefreshDatabase] = useState(true);
const handleClose = (): void => {
setTabIndex(0);
setRefreshDatabase(true);
onClose();
};
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packageData } = useQuery<PackageStatus[]>({
queryKey: localPackageBase && current ? QueryKeys.package(localPackageBase, current) : ["packages"],
queryFn: localPackageBase && current ? () => client.fetch.fetchPackage(localPackageBase, current) : skipToken,
enabled: open,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
});
const { data: dependencies } = useQuery<Dependencies>({
queryKey: localPackageBase && current ? QueryKeys.dependencies(localPackageBase, current) : ["dependencies"],
queryFn: localPackageBase && current
? () => client.fetch.fetchPackageDependencies(localPackageBase, current) : skipToken,
enabled: open,
});
const { data: patches = [] } = useQuery<Patch[]>({
queryKey: localPackageBase ? QueryKeys.patches(localPackageBase) : ["patches"],
queryFn: localPackageBase ? () => client.fetch.fetchPackagePatches(localPackageBase) : skipToken,
enabled: open,
});
const description: PackageStatus | undefined = packageData?.[0];
const pkg = description?.package;
const status = description?.status;
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
const handleUpdate: () => Promise<void> = async () => {
if (!localPackageBase || !current) {
return;
}
try {
await client.service.servicePackageAdd(current, { packages: [localPackageBase], refresh: refreshDatabase });
showSuccess("Success", `Run update for packages ${localPackageBase}`);
} catch (exception) {
showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`);
}
};
const handleRemove: () => Promise<void> = async () => {
if (!localPackageBase || !current) {
return;
}
try {
await client.service.servicePackageRemove(current, [localPackageBase]);
showSuccess("Success", `Packages ${localPackageBase} have been removed`);
onClose();
} catch (exception) {
showError("Action failed", `Could not remove package: ${ApiError.errorDetail(exception)}`);
}
};
const handleDeletePatch: (key: string) => Promise<void> = async key => {
if (!localPackageBase) {
return;
}
try {
await client.service.servicePackagePatchRemove(localPackageBase, key);
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(localPackageBase) });
} catch (exception) {
showError("Action failed", `Could not delete variable: ${ApiError.errorDetail(exception)}`);
}
};
return <Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
<DialogHeader onClose={handleClose} sx={headerStyle}>
{pkg && status
? `${pkg.base} ${status.status} at ${new Date(status.timestamp * 1000).toISOStringShort()}`
: localPackageBase ?? ""}
</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={(_, index: number) => setTabIndex(index)}>
<Tab label="Build logs" />
<Tab label="Changes" />
<Tab label="Events" />
</Tabs>
</Box>
{tabIndex === 0 && localPackageBase && current &&
<BuildLogsTab
packageBase={localPackageBase}
repository={current}
refreshInterval={autoRefresh.interval}
/>
}
{tabIndex === 1 && localPackageBase && current &&
<ChangesTab packageBase={localPackageBase} repository={current} />
}
{tabIndex === 2 && localPackageBase && current &&
<EventsTab packageBase={localPackageBase} repository={current} />
}
</>
}
</DialogContent>
<PackageInfoActions
isAuthorized={isAuthorized}
refreshDatabase={refreshDatabase}
onRefreshDatabaseChange={setRefreshDatabase}
onUpdate={() => void handleUpdate()}
onRemove={() => void handleRemove()}
autoRefreshIntervals={autoRefreshIntervals}
autoRefreshInterval={autoRefresh.interval}
onAutoRefreshIntervalChange={autoRefresh.setInterval}
/>
</Dialog>;
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import { Button, Dialog, DialogActions, DialogContent, TextField } from "@mui/material";
import { ApiError } from "api/client/ApiError";
import DialogHeader from "components/common/DialogHeader";
import RepositorySelect from "components/common/RepositorySelect";
import { useClient } from "hooks/useClient";
import { useNotification } from "hooks/useNotification";
import { useSelectedRepository } from "hooks/useSelectedRepository";
import React, { useState } from "react";
interface PackageRebuildDialogProps {
open: boolean;
onClose: () => void;
}
export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
const client = useClient();
const { showSuccess, showError } = useNotification();
const repositorySelect = useSelectedRepository();
const [dependency, setDependency] = useState("");
const handleClose = (): void => {
setDependency("");
repositorySelect.reset();
onClose();
};
const handleRebuild: () => Promise<void> = async () => {
if (!dependency) {
return;
}
const repository = repositorySelect.selectedRepository;
if (!repository) {
return;
}
try {
await client.service.serviceRebuild(repository, [dependency]);
handleClose();
showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`);
} catch (exception) {
const detail = ApiError.errorDetail(exception);
showError("Action failed", `Repository rebuild failed: ${detail}`);
}
};
return <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogHeader onClose={handleClose}>
Rebuild depending packages
</DialogHeader>
<DialogContent>
<RepositorySelect repositorySelect={repositorySelect} />
<TextField
label="dependency"
placeholder="packages dependency"
fullWidth
margin="normal"
value={dependency}
onChange={event => setDependency(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => void handleRebuild()} variant="contained" startIcon={<PlayArrowIcon />}>rebuild</Button>
</DialogActions>
</Dialog>;
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box, Container } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import LoginDialog from "components/dialogs/LoginDialog";
import Footer from "components/layout/Footer";
import Navbar from "components/layout/Navbar";
import PackageTable from "components/table/PackageTable";
import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth";
import { useClient } from "hooks/useClient";
import { useRepository } from "hooks/useRepository";
import type { InfoResponse } from "models/InfoResponse";
import React, { useEffect, useState } from "react";
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.fetch.fetchServerInfo(),
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={() => info?.auth.external ? window.location.assign("/api/v1/login") : setLoginOpen(true)}
/>
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
</Container>;
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import GitHubIcon from "@mui/icons-material/GitHub";
import HomeIcon from "@mui/icons-material/Home";
import LoginIcon from "@mui/icons-material/Login";
import LogoutIcon from "@mui/icons-material/Logout";
import { Box, Button, Link, Typography } from "@mui/material";
import { useAuth } from "hooks/useAuth";
import type React from "react";
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();
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 logout()} sx={{ textTransform: "none" }}>
logout ({username})
</Button>
:
<Button size="small" startIcon={<LoginIcon />} onClick={onLoginClick} sx={{ textTransform: "none" }}>
login
</Button>
}
</Box>
}
</Box>;
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box, Tab, Tabs } from "@mui/material";
import { useRepository } from "hooks/useRepository";
import type React from "react";
export default function Navbar(): React.JSX.Element | null {
const { repositories, current, setCurrent } = useRepository();
if (repositories.length === 0 || !current) {
return null;
}
const currentIndex = repositories.findIndex(repository =>
repository.architecture === current.architecture && repository.repository === current.repository,
);
return <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs
value={currentIndex >= 0 ? currentIndex : 0}
onChange={(_, newValue: number) => {
const repository = repositories[newValue];
if (repository) {
setCurrent(repository);
}
}}
variant="scrollable"
scrollButtons="auto"
>
{repositories.map(repository =>
<Tab
key={repository.key}
label={repository.label}
/>,
)}
</Tabs>
</Box>;
}

View File

@@ -0,0 +1,185 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import ListIcon from "@mui/icons-material/List";
import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
import { keepPreviousData, skipToken, useQuery } from "@tanstack/react-query";
import CodeBlock from "components/common/CodeBlock";
import { QueryKeys } from "hooks/QueryKeys";
import { useAutoScroll } from "hooks/useAutoScroll";
import { useClient } from "hooks/useClient";
import type { LogRecord } from "models/LogRecord";
import type { RepositoryId } from "models/RepositoryId";
import React, { useEffect, useMemo, useState } from "react";
interface Logs {
version: string;
processId: string;
created: number;
logs: string;
}
interface BuildLogsTabProps {
packageBase: string;
repository: RepositoryId;
refreshInterval: number;
}
function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string {
const filtered = filter ? records.filter(filter) : records;
return filtered
.map(record => `[${new Date(record.created * 1000).toISOString()}] ${record.message}`)
.join("\n");
}
export default function BuildLogsTab({
packageBase,
repository,
refreshInterval,
}: BuildLogsTabProps): React.JSX.Element {
const client = useClient();
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const { data: allLogs } = useQuery<LogRecord[]>({
queryKey: QueryKeys.logs(packageBase, repository),
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
enabled: !!packageBase,
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
});
// Build version selectors from all logs
const versions = useMemo<Logs[]>(() => {
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((left, right) => right.minCreated - left.minCreated)
.map(record => ({
version: record.version,
processId: record.process_id,
created: record.minCreated,
logs: convertLogs(
allLogs,
right => record.version === right.version && record.process_id === right.process_id,
),
}));
}, [allLogs]);
// Compute active index from selected version key, defaulting to newest (index 0)
const activeIndex = useMemo(() => {
if (selectedVersionKey) {
const index = versions.findIndex(record => `${record.version}-${record.processId}` === selectedVersionKey);
if (index >= 0) {
return index;
}
}
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: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
queryFn: activeVersion
? () => client.fetch.fetchPackageLogs(
packageBase, repository, activeVersion.version, activeVersion.processId,
)
: skipToken,
placeholderData: keepPreviousData,
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
});
// 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]);
// Scroll to bottom on new logs
useEffect(() => {
scrollToBottom();
}, [displayedLogs, scrollToBottom]);
return <Box sx={{ display: "flex", gap: 1, mt: 1 }}>
<Box>
<Button
size="small"
aria-label="Select version"
startIcon={<ListIcon />}
onClick={event => setAnchorEl(event.currentTarget)}
/>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
{versions.map((logs, index) =>
<MenuItem
key={`${logs.version}-${logs.processId}`}
selected={index === activeIndex}
onClick={() => {
setSelectedVersionKey(`${logs.version}-${logs.processId}`);
setAnchorEl(null);
resetScroll();
}}
>
<Typography variant="body2">{new Date(logs.created * 1000).toISOStringShort()}</Typography>
</MenuItem>,
)}
{versions.length === 0 &&
<MenuItem disabled>No logs available</MenuItem>
}
</Menu>
</Box>
<Box sx={{ flex: 1 }}>
<CodeBlock
preRef={preRef}
getText={() => displayedLogs}
height={400}
onScroll={handleScroll}
wordBreak
/>
</Box>
</Box>;
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import CopyButton from "components/common/CopyButton";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Changes } from "models/Changes";
import type { RepositoryId } from "models/RepositoryId";
import React from "react";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff";
import { githubGist } from "react-syntax-highlighter/dist/esm/styles/hljs";
SyntaxHighlighter.registerLanguage("diff", diff);
interface ChangesTabProps {
packageBase: string;
repository: RepositoryId;
}
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
const client = useClient();
const { data } = useQuery<Changes>({
queryKey: QueryKeys.changes(packageBase, repository),
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
enabled: !!packageBase,
});
const changesText = data?.changes ?? "";
return <Box sx={{ position: "relative", mt: 1 }}>
<SyntaxHighlighter
language="diff"
style={githubGist}
customStyle={{
padding: "16px",
borderRadius: "4px",
overflow: "auto",
height: 400,
fontSize: "0.8rem",
fontFamily: "monospace",
margin: 0,
}}
>
{changesText}
</SyntaxHighlighter>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={() => changesText} />
</Box>
</Box>;
}

View File

@@ -0,0 +1,79 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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 { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Event } from "models/Event";
import type { RepositoryId } from "models/RepositoryId";
import type React from "react";
import { useMemo } from "react";
interface EventsTabProps {
packageBase: string;
repository: 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, repository }: EventsTabProps): React.JSX.Element {
const client = useClient();
const { data: events = [] } = useQuery<Event[]>({
queryKey: QueryKeys.events(repository, packageBase),
queryFn: () => client.fetch.fetchPackageEvents(repository, packageBase, 30),
enabled: !!packageBase,
});
const rows = useMemo<EventRow[]>(() => events.map((event, index) => ({
id: index,
timestamp: new Date(event.created * 1000).toISOStringShort(),
event: event.event,
message: event.message ?? "",
})), [events]);
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: 400, mt: 1 }}
disableRowSelectionOnClick
/>
</Box>;
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Grid, Link, Typography } from "@mui/material";
import type { Dependencies } from "models/Dependencies";
import type { Package } from "models/Package";
import React from "react";
interface PackageDetailsGridProps {
pkg: Package;
dependencies?: Dependencies;
}
export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetailsGridProps): React.JSX.Element {
const packagesList = Object.entries(pkg.packages)
.map(([name, properties]) => `${name}${properties.description ? ` (${properties.description})` : ""}`);
const groups = Object.values(pkg.packages)
.flatMap(properties => properties.groups ?? []);
const licenses = Object.values(pkg.packages)
.flatMap(properties => properties.licenses ?? []);
const upstreamUrls = Object.values(pkg.packages)
.map(properties => properties.url)
.filter((url): url is string => !!url)
.unique();
const aurUrl = pkg.remote.web_url;
const pkgNames = Object.keys(pkg.packages);
const pkgValues = Object.values(pkg.packages);
const deps = pkgValues
.flatMap(properties => (properties.depends ?? []).filter(dep => !pkgNames.includes(dep)))
.unique();
const makeDeps = pkgValues
.flatMap(properties => (properties.make_depends ?? []).filter(dep => !pkgNames.includes(dep)))
.map(dep => `${dep} (make)`)
.unique();
const optDeps = pkgValues
.flatMap(properties => (properties.opt_depends ?? []).filter(dep => !pkgNames.includes(dep)))
.map(dep => `${dep} (optional)`)
.unique();
const allDepends = [...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" sx={{ whiteSpace: "pre-line" }}>{packagesList.unique().join("\n")}</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" sx={{ whiteSpace: "pre-line" }}>{groups.unique().join("\n")}</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" sx={{ whiteSpace: "pre-line" }}>{licenses.unique().join("\n")}</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 noreferrer" 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 noreferrer" 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" sx={{ whiteSpace: "pre-line" }}>{allDepends.join("\n")}</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" sx={{ whiteSpace: "pre-line" }}>{implicitDepends.unique().join("\n")}</Typography></Grid>
</Grid>
</>;
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import DeleteIcon from "@mui/icons-material/Delete";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material";
import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type React from "react";
interface PackageInfoActionsProps {
isAuthorized: boolean;
refreshDatabase: boolean;
onRefreshDatabaseChange: (checked: boolean) => void;
onUpdate: () => void;
onRemove: () => void;
autoRefreshIntervals: AutoRefreshInterval[];
autoRefreshInterval: number;
onAutoRefreshIntervalChange: (interval: number) => void;
}
export default function PackageInfoActions({
isAuthorized,
refreshDatabase,
onRefreshDatabaseChange,
onUpdate,
onRemove,
autoRefreshIntervals,
autoRefreshInterval,
onAutoRefreshIntervalChange,
}: PackageInfoActionsProps): React.JSX.Element {
return <DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
{isAuthorized &&
<>
<FormControlLabel
control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(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>
</>
}
<AutoRefreshControl
intervals={autoRefreshIntervals}
currentInterval={autoRefreshInterval}
onIntervalChange={onAutoRefreshIntervalChange}
/>
</DialogActions>;
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import DeleteIcon from "@mui/icons-material/Delete";
import { Box, IconButton, TextField, Typography } from "@mui/material";
import type { Patch } from "models/Patch";
import type React from "react";
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="h6" gutterBottom>Environment variables</Typography>
{patches.map(patch =>
<Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<TextField
size="small"
value={patch.key}
disabled
sx={{ flex: 1 }}
/>
<Box>=</Box>
<TextField
size="small"
value={JSON.stringify(patch.value)}
disabled
sx={{ flex: 1 }}
/>
{editable &&
<IconButton size="small" color="error" onClick={() => onDelete(patch.key)}>
<DeleteIcon fontSize="small" />
</IconButton>
}
</Box>,
)}
</Box>;
}

View File

@@ -0,0 +1,196 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box, Link } from "@mui/material";
import {
DataGrid,
GRID_CHECKBOX_SELECTION_COL_DEF,
type GridColDef,
type GridFilterModel,
type GridRenderCellParams,
type GridRowId,
useGridApiRef,
} from "@mui/x-data-grid";
import DashboardDialog from "components/dialogs/DashboardDialog";
import KeyImportDialog from "components/dialogs/KeyImportDialog";
import PackageAddDialog from "components/dialogs/PackageAddDialog";
import PackageInfoDialog from "components/dialogs/PackageInfoDialog";
import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog";
import PackageTableToolbar from "components/table/PackageTableToolbar";
import StatusCell from "components/table/StatusCell";
import { useDebounce } from "hooks/useDebounce";
import { usePackageTable } from "hooks/usePackageTable";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { PackageRow } from "models/PackageRow";
import React, { useMemo } from "react";
interface PackageTableProps {
autoRefreshIntervals: AutoRefreshInterval[];
}
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
function createListColumn(
field: keyof PackageRow,
headerName: string,
options: { flex?: number; minWidth?: number; width?: number },
): GridColDef<PackageRow> {
return {
field,
headerName,
...options,
valueGetter: (value: string[]) => (value ?? []).join(" "),
renderCell: (params: GridRenderCellParams<PackageRow>) =>
<Box sx={{ whiteSpace: "pre-line" }}>{((params.row[field] as string[]) ?? []).join("\n")}</Box>,
sortComparator: (left: string, right: string) => left.localeCompare(right),
};
}
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 noreferrer" 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 sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
<PackageTableToolbar
hasSelection={table.selectionModel.length > 0}
isAuthorized={table.isAuthorized}
status={table.status}
searchText={table.searchText}
onSearchChange={table.setSearchText}
autoRefresh={{
autoRefreshIntervals,
currentInterval: table.autoRefreshInterval,
onIntervalChange: table.onAutoRefreshIntervalChange,
}}
actions={{
onDashboardClick: () => table.setDialogOpen("dashboard"),
onAddClick: () => table.setDialogOpen("add"),
onUpdateClick: () => void table.handleUpdate(),
onRefreshDatabaseClick: () => void table.handleRefreshDatabase(),
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(row => row.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));
}}
sx={{
flex: 1,
"& .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,185 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import AddIcon from "@mui/icons-material/Add";
import ClearIcon from "@mui/icons-material/Clear";
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import InventoryIcon from "@mui/icons-material/Inventory";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RefreshIcon from "@mui/icons-material/Refresh";
import ReplayIcon from "@mui/icons-material/Replay";
import SearchIcon from "@mui/icons-material/Search";
import VpnKeyIcon from "@mui/icons-material/VpnKey";
import { Box, Button, Divider, IconButton, InputAdornment, Menu, MenuItem, TextField, Tooltip } from "@mui/material";
import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { BuildStatus } from "models/BuildStatus";
import React, { useState } from "react";
import { StatusColors } from "theme/StatusColors";
export interface AutoRefreshProps {
autoRefreshIntervals: AutoRefreshInterval[];
currentInterval: number;
onIntervalChange: (interval: number) => void;
}
export interface ToolbarActions {
onDashboardClick: () => void;
onAddClick: () => void;
onUpdateClick: () => void;
onRefreshDatabaseClick: () => void;
onRebuildClick: () => void;
onRemoveClick: () => void;
onKeyImportClick: () => void;
onReloadClick: () => void;
onExportClick: () => void;
}
interface PackageTableToolbarProps {
hasSelection: boolean;
isAuthorized: boolean;
status?: BuildStatus;
searchText: string;
onSearchChange: (text: string) => void;
autoRefresh: AutoRefreshProps;
actions: ToolbarActions;
}
export default function PackageTableToolbar({
hasSelection,
isAuthorized,
status,
searchText,
onSearchChange,
autoRefresh,
actions,
}: PackageTableToolbarProps): React.JSX.Element {
const [packagesAnchorEl, setPackagesAnchorEl] = useState<HTMLElement | null>(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: status ? StatusColors[status] : undefined,
borderWidth: 1,
borderStyle: "solid",
color: status ? StatusColors[status] : undefined,
}}
>
<InfoOutlinedIcon />
</IconButton>
</Tooltip>
{isAuthorized &&
<>
<Button
variant="contained"
startIcon={<InventoryIcon />}
onClick={event => setPackagesAnchorEl(event.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.onRefreshDatabaseClick();
}}>
<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.autoRefreshIntervals}
currentInterval={autoRefresh.currentInterval}
onIntervalChange={autoRefresh.onIntervalChange}
/>
<Box sx={{ flexGrow: 1 }} />
<TextField
size="small"
aria-label="Search packages"
placeholder="search packages..."
value={searchText}
onChange={event => onSearchChange(event.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,39 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Chip } from "@mui/material";
import type { BuildStatus } from "models/BuildStatus";
import type React from "react";
import { StatusColors } from "theme/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,
}}
/>;
}