mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-03-07 02:33:38 +00:00
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:
43
frontend/src/components/charts/EventDurationLineChart.tsx
Normal file
43
frontend/src/components/charts/EventDurationLineChart.tsx
Normal 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 }} />;
|
||||
}
|
||||
55
frontend/src/components/charts/PackageCountBarChart.tsx
Normal file
55
frontend/src/components/charts/PackageCountBarChart.tsx
Normal 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 },
|
||||
},
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
44
frontend/src/components/charts/StatusPieChart.tsx
Normal file
44
frontend/src/components/charts/StatusPieChart.tsx
Normal 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 }} />;
|
||||
}
|
||||
90
frontend/src/components/common/AutoRefreshControl.tsx
Normal file
90
frontend/src/components/common/AutoRefreshControl.tsx
Normal 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>
|
||||
</>;
|
||||
}
|
||||
63
frontend/src/components/common/CodeBlock.tsx
Normal file
63
frontend/src/components/common/CodeBlock.tsx
Normal 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>;
|
||||
}
|
||||
47
frontend/src/components/common/CopyButton.tsx
Normal file
47
frontend/src/components/common/CopyButton.tsx
Normal 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>;
|
||||
}
|
||||
37
frontend/src/components/common/DialogHeader.tsx
Normal file
37
frontend/src/components/common/DialogHeader.tsx
Normal 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>;
|
||||
}
|
||||
50
frontend/src/components/common/NotificationItem.tsx
Normal file
50
frontend/src/components/common/NotificationItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/common/RepositorySelect.tsx
Normal file
44
frontend/src/components/common/RepositorySelect.tsx
Normal 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>;
|
||||
}
|
||||
103
frontend/src/components/dialogs/DashboardDialog.tsx
Normal file
103
frontend/src/components/dialogs/DashboardDialog.tsx
Normal 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>;
|
||||
}
|
||||
118
frontend/src/components/dialogs/KeyImportDialog.tsx
Normal file
118
frontend/src/components/dialogs/KeyImportDialog.tsx
Normal 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>;
|
||||
}
|
||||
118
frontend/src/components/dialogs/LoginDialog.tsx
Normal file
118
frontend/src/components/dialogs/LoginDialog.tsx
Normal 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>;
|
||||
}
|
||||
193
frontend/src/components/dialogs/PackageAddDialog.tsx
Normal file
193
frontend/src/components/dialogs/PackageAddDialog.tsx
Normal 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>;
|
||||
}
|
||||
194
frontend/src/components/dialogs/PackageInfoDialog.tsx
Normal file
194
frontend/src/components/dialogs/PackageInfoDialog.tsx
Normal 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>;
|
||||
}
|
||||
88
frontend/src/components/dialogs/PackageRebuildDialog.tsx
Normal file
88
frontend/src/components/dialogs/PackageRebuildDialog.tsx
Normal 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>;
|
||||
}
|
||||
76
frontend/src/components/layout/AppLayout.tsx
Normal file
76
frontend/src/components/layout/AppLayout.tsx
Normal 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>;
|
||||
}
|
||||
92
frontend/src/components/layout/Footer.tsx
Normal file
92
frontend/src/components/layout/Footer.tsx
Normal 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>;
|
||||
}
|
||||
55
frontend/src/components/layout/Navbar.tsx
Normal file
55
frontend/src/components/layout/Navbar.tsx
Normal 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>;
|
||||
}
|
||||
185
frontend/src/components/package/BuildLogsTab.tsx
Normal file
185
frontend/src/components/package/BuildLogsTab.tsx
Normal 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>;
|
||||
}
|
||||
70
frontend/src/components/package/ChangesTab.tsx
Normal file
70
frontend/src/components/package/ChangesTab.tsx
Normal 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>;
|
||||
}
|
||||
79
frontend/src/components/package/EventsTab.tsx
Normal file
79
frontend/src/components/package/EventsTab.tsx
Normal 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>;
|
||||
}
|
||||
114
frontend/src/components/package/PackageDetailsGrid.tsx
Normal file
114
frontend/src/components/package/PackageDetailsGrid.tsx
Normal 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>
|
||||
</>;
|
||||
}
|
||||
69
frontend/src/components/package/PackageInfoActions.tsx
Normal file
69
frontend/src/components/package/PackageInfoActions.tsx
Normal 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>;
|
||||
}
|
||||
65
frontend/src/components/package/PackagePatchesList.tsx
Normal file
65
frontend/src/components/package/PackagePatchesList.tsx
Normal 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>;
|
||||
}
|
||||
196
frontend/src/components/table/PackageTable.tsx
Normal file
196
frontend/src/components/table/PackageTable.tsx
Normal 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>;
|
||||
}
|
||||
185
frontend/src/components/table/PackageTableToolbar.tsx
Normal file
185
frontend/src/components/table/PackageTableToolbar.tsx
Normal 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>;
|
||||
}
|
||||
39
frontend/src/components/table/StatusCell.tsx
Normal file
39
frontend/src/components/table/StatusCell.tsx
Normal 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,
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
Reference in New Issue
Block a user