mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-04-07 19:03:38 +00:00
upload ai slop
This commit is contained in:
33
frontend/src/components/charts/EventDurationLineChart.tsx
Normal file
33
frontend/src/components/charts/EventDurationLineChart.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type React from "react";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend } from "chart.js";
|
||||
import type { Event } from "api/types/Event";
|
||||
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend);
|
||||
|
||||
interface EventDurationLineChartProps {
|
||||
events: Event[];
|
||||
}
|
||||
|
||||
export default function EventDurationLineChart({ events }: EventDurationLineChartProps): React.JSX.Element | null {
|
||||
const updateEvents = events.filter((e) => e.event === "package-updated" && e.data?.took);
|
||||
|
||||
if (updateEvents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = {
|
||||
labels: updateEvents.map((e) => formatTimestamp(e.created)),
|
||||
datasets: [
|
||||
{
|
||||
label: "update duration, s",
|
||||
data: updateEvents.map((e) => (e.data as Record<string, number>).took),
|
||||
cubicInterpolationMode: "monotone" as const,
|
||||
tension: 0.4,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <Line data={data} options={{ responsive: true }} />;
|
||||
}
|
||||
39
frontend/src/components/charts/PackageCountBarChart.tsx
Normal file
39
frontend/src/components/charts/PackageCountBarChart.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type React from "react";
|
||||
import { Bar } from "react-chartjs-2";
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from "chart.js";
|
||||
import type { RepositoryStats } from "api/types/RepositoryStats";
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip, Legend);
|
||||
|
||||
interface PackageCountBarChartProps {
|
||||
stats: RepositoryStats;
|
||||
}
|
||||
|
||||
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
|
||||
const data = {
|
||||
labels: ["packages"],
|
||||
datasets: [
|
||||
{
|
||||
label: "archives",
|
||||
data: [stats.packages ?? 0],
|
||||
},
|
||||
{
|
||||
label: "bases",
|
||||
data: [stats.bases ?? 0],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<Bar
|
||||
data={data}
|
||||
options={{
|
||||
maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: { stacked: true },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/charts/StatusPieChart.tsx
Normal file
27
frontend/src/components/charts/StatusPieChart.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type React from "react";
|
||||
import { Pie } from "react-chartjs-2";
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
|
||||
import type { Counters } from "api/types/Counters";
|
||||
import { StatusColors } from "theme/status/StatusColors";
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend);
|
||||
|
||||
interface StatusPieChartProps {
|
||||
counters: Counters;
|
||||
}
|
||||
|
||||
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
|
||||
const labels = ["unknown", "pending", "building", "failed", "success"] as const;
|
||||
const data = {
|
||||
labels: labels.map((l) => l),
|
||||
datasets: [
|
||||
{
|
||||
label: "packages in status",
|
||||
data: labels.map((label) => counters[label]),
|
||||
backgroundColor: labels.map((label) => StatusColors[label]),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return <Pie data={data} options={{ responsive: true }} />;
|
||||
}
|
||||
75
frontend/src/components/common/AutoRefreshControl.tsx
Normal file
75
frontend/src/components/common/AutoRefreshControl.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useState } from "react";
|
||||
import { IconButton, Menu, MenuItem, Tooltip, ListItemIcon, ListItemText } from "@mui/material";
|
||||
import TimerIcon from "@mui/icons-material/Timer";
|
||||
import TimerOffIcon from "@mui/icons-material/TimerOff";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||
|
||||
interface AutoRefreshControlProps {
|
||||
intervals: AutoRefreshInterval[];
|
||||
enabled: boolean;
|
||||
currentInterval: number;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onIntervalChange: (interval: number) => void;
|
||||
}
|
||||
|
||||
export default function AutoRefreshControl({
|
||||
intervals,
|
||||
enabled,
|
||||
currentInterval,
|
||||
onToggle,
|
||||
onIntervalChange,
|
||||
}: AutoRefreshControlProps): React.JSX.Element | null {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
if (intervals.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Auto-refresh">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
color={enabled ? "primary" : "default"}
|
||||
>
|
||||
{enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
>
|
||||
<MenuItem
|
||||
selected={!enabled}
|
||||
onClick={() => {
|
||||
onToggle(false);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{!enabled && <CheckIcon fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText>Off</ListItemText>
|
||||
</MenuItem>
|
||||
{intervals.map((iv) => (
|
||||
<MenuItem
|
||||
key={iv.interval}
|
||||
selected={enabled && iv.interval === currentInterval}
|
||||
onClick={() => {
|
||||
onIntervalChange(iv.interval);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{enabled && iv.interval === currentInterval && <CheckIcon fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText>{iv.text}</ListItemText>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/common/CopyButton.tsx
Normal file
26
frontend/src/components/common/CopyButton.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React, { useState } from "react";
|
||||
import { IconButton, Tooltip } from "@mui/material";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
|
||||
interface CopyButtonProps {
|
||||
getText: () => string;
|
||||
}
|
||||
|
||||
export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
await navigator.clipboard.writeText(getText());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={copied ? "Copied!" : "Copy"}>
|
||||
<IconButton size="small" onClick={() => void handleCopy()}>
|
||||
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
5
frontend/src/components/common/formatTimestamp.ts
Normal file
5
frontend/src/components/common/formatTimestamp.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function formatTimestamp(unixSeconds: number): string {
|
||||
const d = new Date(unixSeconds * 1000);
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
88
frontend/src/components/dialogs/DashboardDialog.tsx
Normal file
88
frontend/src/components/dialogs/DashboardDialog.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type React from "react";
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, Typography, Box } from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import StatusPieChart from "components/charts/StatusPieChart";
|
||||
import PackageCountBarChart from "components/charts/PackageCountBarChart";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { StatusHeaderStyles } from "theme/status/StatusColors";
|
||||
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||
import type { InternalStatus } from "api/types/InternalStatus";
|
||||
|
||||
interface DashboardDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
|
||||
const { current } = useRepository();
|
||||
|
||||
const { data: status } = useQuery<InternalStatus>({
|
||||
queryKey: current ? QueryKeys.status(current) : ["status"],
|
||||
queryFn: () => Client.fetchStatus(current!),
|
||||
enabled: !!current && open,
|
||||
});
|
||||
|
||||
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle sx={headerStyle}>System health</DialogTitle>
|
||||
<DialogContent>
|
||||
{status && (
|
||||
<>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="body2" color="text.secondary" align="right">Repository name</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="body2">{status.repository}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="body2" color="text.secondary" align="right">Repository architecture</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="body2">{status.architecture}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="body2" color="text.secondary" align="right">Current status</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="body2">{status.status.status}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="body2" color="text.secondary" align="right">Updated at</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="body2">{formatTimestamp(status.status.timestamp)}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{status.packages.total > 0 && (
|
||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ maxHeight: 300 }}>
|
||||
<PackageCountBarChart stats={status.stats} />
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ maxHeight: 300 }}>
|
||||
<StatusPieChart counters={status.packages} />
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} variant="contained" startIcon={<CloseIcon />}>close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/dialogs/KeyImportDialog.tsx
Normal file
106
frontend/src/components/dialogs/KeyImportDialog.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||
TextField, Box,
|
||||
} from "@mui/material";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import { useNotification } from "hooks/useNotification";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { ApiError } from "api/client/ApiError";
|
||||
import CopyButton from "components/common/CopyButton";
|
||||
|
||||
interface KeyImportDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element {
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const [fingerprint, setFingerprint] = useState("");
|
||||
const [server, setServer] = useState("keyserver.ubuntu.com");
|
||||
const [keyBody, setKeyBody] = useState("");
|
||||
|
||||
const handleFetch = async (): Promise<void> => {
|
||||
if (!fingerprint || !server) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await Client.fetchPGPKey(fingerprint, server);
|
||||
setKeyBody(result.key);
|
||||
} catch (e) {
|
||||
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||
showError("Action failed", `Could not fetch key: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
if (!fingerprint || !server) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Client.importPGPKey({ key: fingerprint, server });
|
||||
onClose();
|
||||
showSuccess("Success", `Key ${fingerprint} has been imported`);
|
||||
} catch (e) {
|
||||
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||
showError("Action failed", `Could not import key ${fingerprint} from ${server}: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setFingerprint("");
|
||||
setServer("keyserver.ubuntu.com");
|
||||
setKeyBody("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>Import key from PGP server</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
label="fingerprint"
|
||||
placeholder="PGP key fingerprint"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
value={fingerprint}
|
||||
onChange={(e) => setFingerprint(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="key server"
|
||||
placeholder="PGP key server"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
value={server}
|
||||
onChange={(e) => setServer(e.target.value)}
|
||||
/>
|
||||
{keyBody && (
|
||||
<Box sx={{ position: "relative", mt: 2 }}>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
backgroundColor: "grey.100",
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
overflow: "auto",
|
||||
maxHeight: 300,
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
<code>{keyBody}</code>
|
||||
</Box>
|
||||
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||
<CopyButton getText={() => keyBody} />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => void handleImport()} variant="contained" startIcon={<PlayArrowIcon />}>import</Button>
|
||||
<Button onClick={() => void handleFetch()} variant="contained" color="success" startIcon={<RefreshIcon />}>fetch</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
91
frontend/src/components/dialogs/LoginDialog.tsx
Normal file
91
frontend/src/components/dialogs/LoginDialog.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField,
|
||||
InputAdornment, IconButton,
|
||||
} from "@mui/material";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||
import PersonIcon from "@mui/icons-material/Person";
|
||||
import { useAuth } from "hooks/useAuth";
|
||||
import { useNotification } from "hooks/useNotification";
|
||||
import { ApiError } from "api/client/ApiError";
|
||||
|
||||
interface LoginDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function LoginDialog({ open, onClose }: LoginDialogProps): React.JSX.Element {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (!username || !password) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await login(username, password);
|
||||
onClose();
|
||||
showSuccess("Logged in", `Successfully logged in as ${username}`);
|
||||
window.location.href = "/";
|
||||
} catch (e) {
|
||||
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||
if (username === "admin" && password === "admin") {
|
||||
showError("Login error", "You've entered a password for user \"root\", did you make a typo in username?");
|
||||
} else {
|
||||
showError("Login error", `Could not login as ${username}: ${detail}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setShowPassword(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Login</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
label="username"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
label="password"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void handleSubmit();
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end" size="small">
|
||||
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => void handleSubmit()} variant="contained" startIcon={<PersonIcon />}>login</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
194
frontend/src/components/dialogs/PackageAddDialog.tsx
Normal file
194
frontend/src/components/dialogs/PackageAddDialog.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||
TextField, Autocomplete, Box, IconButton, FormControlLabel, Checkbox, Select, MenuItem, InputLabel, FormControl,
|
||||
} from "@mui/material";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { useNotification } from "hooks/useNotification";
|
||||
import { useDebounce } from "hooks/useDebounce";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { ApiError } from "api/client/ApiError";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import type { AURPackage } from "api/types/AURPackage";
|
||||
import type { RepositoryId } from "api/types/RepositoryId";
|
||||
|
||||
interface EnvVar {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface PackageAddDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element {
|
||||
const { repositories, current } = useRepository();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const [packageName, setPackageName] = useState("");
|
||||
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
||||
const [refresh, setRefresh] = useState(true);
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>([]);
|
||||
|
||||
const debouncedSearch = useDebounce(packageName, 500);
|
||||
|
||||
const { data: searchResults = [] } = useQuery<AURPackage[]>({
|
||||
queryKey: QueryKeys.search(debouncedSearch),
|
||||
queryFn: () => Client.searchPackages(debouncedSearch),
|
||||
enabled: debouncedSearch.length >= 3,
|
||||
});
|
||||
|
||||
const getSelectedRepo = useCallback((): RepositoryId => {
|
||||
if (selectedRepo) {
|
||||
const repo = repositories.find(
|
||||
(r) => `${r.architecture}-${r.repository}` === selectedRepo,
|
||||
);
|
||||
if (repo) {
|
||||
return repo;
|
||||
}
|
||||
}
|
||||
return current!;
|
||||
}, [selectedRepo, repositories, current]);
|
||||
|
||||
const handleAdd = async (): Promise<void> => {
|
||||
if (!packageName) {
|
||||
return;
|
||||
}
|
||||
const repo = getSelectedRepo();
|
||||
try {
|
||||
const patches = envVars.filter((v) => v.key);
|
||||
await Client.addPackages(repo, {
|
||||
packages: [packageName],
|
||||
patches: patches.length > 0 ? patches : undefined,
|
||||
refresh,
|
||||
});
|
||||
onClose();
|
||||
showSuccess("Success", `Packages ${packageName} have been added`);
|
||||
} catch (e) {
|
||||
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||
showError("Action failed", `Package addition failed: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequest = async (): Promise<void> => {
|
||||
if (!packageName) {
|
||||
return;
|
||||
}
|
||||
const repo = getSelectedRepo();
|
||||
try {
|
||||
const patches = envVars.filter((v) => v.key);
|
||||
await Client.requestPackages(repo, {
|
||||
packages: [packageName],
|
||||
patches: patches.length > 0 ? patches : undefined,
|
||||
});
|
||||
onClose();
|
||||
showSuccess("Success", `Packages ${packageName} have been requested`);
|
||||
} catch (e) {
|
||||
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||
showError("Action failed", `Package request failed: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setPackageName("");
|
||||
setSelectedRepo("");
|
||||
setRefresh(true);
|
||||
setEnvVars([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Add new packages</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>repository</InputLabel>
|
||||
<Select
|
||||
value={selectedRepo || (current ? `${current.architecture}-${current.repository}` : "")}
|
||||
label="repository"
|
||||
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||
>
|
||||
{repositories.map((r) => (
|
||||
<MenuItem key={`${r.architecture}-${r.repository}`} value={`${r.architecture}-${r.repository}`}>
|
||||
{r.repository} ({r.architecture})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={searchResults.map((p) => p.package)}
|
||||
inputValue={packageName}
|
||||
onInputChange={(_, value) => setPackageName(value)}
|
||||
renderOption={(props, option) => {
|
||||
const pkg = searchResults.find((p) => p.package === option);
|
||||
return (
|
||||
<li {...props} key={option}>
|
||||
{option}{pkg ? ` (${pkg.description})` : ""}
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="package" placeholder="AUR package" margin="normal" />
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={refresh} onChange={(_, checked) => setRefresh(checked)} />}
|
||||
label="update pacman databases"
|
||||
/>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setEnvVars([...envVars, { key: "", value: "" }])}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
add environment variable
|
||||
</Button>
|
||||
|
||||
{envVars.map((env, index) => (
|
||||
<Box key={index} sx={{ display: "flex", gap: 1, mt: 1, alignItems: "center" }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="name"
|
||||
value={env.key}
|
||||
onChange={(e) => {
|
||||
const updated = [...envVars];
|
||||
updated[index] = { ...updated[index], key: e.target.value };
|
||||
setEnvVars(updated);
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Box>=</Box>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="value"
|
||||
value={env.value}
|
||||
onChange={(e) => {
|
||||
const updated = [...envVars];
|
||||
updated[index] = { ...updated[index], value: e.target.value };
|
||||
setEnvVars(updated);
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<IconButton size="small" color="error" onClick={() => setEnvVars(envVars.filter((_, i) => i !== index))}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => void handleAdd()} variant="contained" startIcon={<PlayArrowIcon />}>add</Button>
|
||||
<Button onClick={() => void handleRequest()} variant="contained" color="success" startIcon={<AddIcon />}>request</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
299
frontend/src/components/dialogs/PackageInfoDialog.tsx
Normal file
299
frontend/src/components/dialogs/PackageInfoDialog.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||
Grid, Typography, Link, Box, Tab, Tabs, IconButton, Chip, FormControlLabel, Checkbox,
|
||||
} from "@mui/material";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import BuildLogsTab from "components/package/BuildLogsTab";
|
||||
import ChangesTab from "components/package/ChangesTab";
|
||||
import EventsTab from "components/package/EventsTab";
|
||||
import AutoRefreshControl from "components/common/AutoRefreshControl";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { useAuth } from "hooks/useAuth";
|
||||
import { useNotification } from "hooks/useNotification";
|
||||
import { useAutoRefresh } from "hooks/useAutoRefresh";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { ApiError } from "api/client/ApiError";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import { StatusHeaderStyles } from "theme/status/StatusColors";
|
||||
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||
import type { Dependencies } from "api/types/Dependencies";
|
||||
import type { PackageProperties } from "api/types/PackageProperties";
|
||||
import type { PackageStatus } from "api/types/PackageStatus";
|
||||
import type { Patch } from "api/types/Patch";
|
||||
import type { RepositoryId } from "api/types/RepositoryId";
|
||||
|
||||
interface PackageInfoDialogProps {
|
||||
packageBase: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
autorefreshIntervals: AutoRefreshInterval[];
|
||||
}
|
||||
|
||||
function listToString(items: string[]): React.ReactNode {
|
||||
const unique = [...new Set(items)].sort();
|
||||
return unique.map((item, i) => (
|
||||
<React.Fragment key={item}>
|
||||
{item}
|
||||
{i < unique.length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
export default function PackageInfoDialog({ packageBase, open, onClose, autorefreshIntervals }: PackageInfoDialogProps): React.JSX.Element {
|
||||
const { current } = useRepository();
|
||||
const { enabled: authEnabled, username } = useAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
const hasAuth = !authEnabled || username !== null;
|
||||
|
||||
const [tabIndex, setTabIndex] = useState(0);
|
||||
const [refreshDb, setRefreshDb] = useState(true);
|
||||
|
||||
const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
|
||||
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval);
|
||||
|
||||
const repo = current as RepositoryId;
|
||||
|
||||
const { data: packageData } = useQuery<PackageStatus[]>({
|
||||
queryKey: packageBase && repo ? QueryKeys.package(packageBase, repo) : ["package-none"],
|
||||
queryFn: () => Client.fetchPackage(packageBase!, repo),
|
||||
enabled: !!packageBase && !!repo && open,
|
||||
refetchInterval: autoRefresh.refetchInterval,
|
||||
});
|
||||
|
||||
const { data: dependencies } = useQuery<Dependencies>({
|
||||
queryKey: packageBase && repo ? QueryKeys.dependencies(packageBase, repo) : ["deps-none"],
|
||||
queryFn: () => Client.fetchDependencies(packageBase!, repo),
|
||||
enabled: !!packageBase && !!repo && open,
|
||||
});
|
||||
|
||||
const { data: patches = [] } = useQuery<Patch[]>({
|
||||
queryKey: packageBase ? QueryKeys.patches(packageBase) : ["patches-none"],
|
||||
queryFn: () => Client.fetchPatches(packageBase!),
|
||||
enabled: !!packageBase && open,
|
||||
});
|
||||
|
||||
const description: PackageStatus | undefined = packageData?.[0];
|
||||
const pkg = description?.package;
|
||||
const status = description?.status;
|
||||
|
||||
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
|
||||
|
||||
// Flatten depends from all sub-packages
|
||||
const allDepends: string[] = pkg
|
||||
? Object.values(pkg.packages).flatMap((p: PackageProperties) => {
|
||||
const pkgNames = Object.keys(pkg.packages);
|
||||
const deps = (p.depends ?? []).filter((d: string) => !pkgNames.includes(d));
|
||||
const makeDeps = (p.make_depends ?? []).filter((d: string) => !pkgNames.includes(d)).map((d: string) => `${d} (make)`);
|
||||
const optDeps = (p.opt_depends ?? []).filter((d: string) => !pkgNames.includes(d)).map((d: string) => `${d} (optional)`);
|
||||
return [...deps, ...makeDeps, ...optDeps];
|
||||
})
|
||||
: [];
|
||||
|
||||
const implicitDepends: string[] = dependencies
|
||||
? Object.values(dependencies.paths).flat()
|
||||
: [];
|
||||
|
||||
const groups: string[] = pkg
|
||||
? Object.values(pkg.packages).flatMap((p: PackageProperties) => p.groups ?? [])
|
||||
: [];
|
||||
|
||||
const licenses: string[] = pkg
|
||||
? Object.values(pkg.packages).flatMap((p: PackageProperties) => p.licenses ?? [])
|
||||
: [];
|
||||
|
||||
const upstreamUrls: string[] = pkg
|
||||
? [...new Set(Object.values(pkg.packages).map((p: PackageProperties) => p.url).filter((u): u is string => !!u))].sort()
|
||||
: [];
|
||||
|
||||
const aurUrl = pkg?.remote.web_url;
|
||||
|
||||
const packagesList: string[] = pkg
|
||||
? Object.entries(pkg.packages).map(([name, p]) => `${name}${p.description ? ` (${p.description})` : ""}`)
|
||||
: [];
|
||||
|
||||
const handleUpdate = async (): Promise<void> => {
|
||||
if (!packageBase || !repo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Client.addPackages(repo, { packages: [packageBase], refresh: refreshDb });
|
||||
showSuccess("Success", `Run update for packages ${packageBase}`);
|
||||
} catch (e) {
|
||||
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||
showError("Action failed", `Package update failed: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (): Promise<void> => {
|
||||
if (!packageBase || !repo) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Client.removePackages(repo, [packageBase]);
|
||||
showSuccess("Success", `Packages ${packageBase} have been removed`);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||
showError("Action failed", `Could not remove package: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePatch = async (key: string): Promise<void> => {
|
||||
if (!packageBase) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Client.deletePatch(packageBase, key);
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
|
||||
} catch (e) {
|
||||
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||
showError("Action failed", `Could not delete variable: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReload = (): void => {
|
||||
if (!packageBase || !repo) {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(packageBase, repo) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.logs(packageBase, repo) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.changes(packageBase, repo) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.events(repo, packageBase) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.dependencies(packageBase, repo) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.patches(packageBase) });
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setTabIndex(0);
|
||||
setRefreshDb(true);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle sx={headerStyle}>
|
||||
{pkg && status
|
||||
? `${pkg.base} ${status.status} at ${formatTimestamp(status.timestamp)}`
|
||||
: packageBase ?? ""}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{pkg && (
|
||||
<>
|
||||
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
|
||||
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(packagesList)}</Typography></Grid>
|
||||
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid>
|
||||
<Grid item xs={8} md={5}><Typography variant="body2">{pkg.version}</Typography></Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">packager</Typography></Grid>
|
||||
<Grid item xs={8} md={5}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
|
||||
<Grid item xs={4} md={1} />
|
||||
<Grid item xs={8} md={5} />
|
||||
</Grid>
|
||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
|
||||
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(groups)}</Typography></Grid>
|
||||
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid>
|
||||
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(licenses)}</Typography></Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">upstream</Typography></Grid>
|
||||
<Grid item xs={8} md={5}>
|
||||
{upstreamUrls.map((url) => (
|
||||
<Link key={url} href={url} target="_blank" rel="noopener" underline="hover" display="block" variant="body2">
|
||||
{url}
|
||||
</Link>
|
||||
))}
|
||||
</Grid>
|
||||
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid>
|
||||
<Grid item xs={8} md={5}>
|
||||
{aurUrl && (
|
||||
<Link href={aurUrl} target="_blank" rel="noopener" underline="hover" variant="body2">AUR link</Link>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
|
||||
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(allDepends)}</Typography></Grid>
|
||||
<Grid item xs={4} md={1}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid>
|
||||
<Grid item xs={8} md={5}><Typography variant="body2">{listToString(implicitDepends)}</Typography></Grid>
|
||||
</Grid>
|
||||
|
||||
{patches.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Environment variables</Typography>
|
||||
{patches.map((patch) => (
|
||||
<Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
|
||||
<Chip label={patch.key} size="small" />
|
||||
<Typography variant="body2">=</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: "monospace" }}>{JSON.stringify(patch.value)}</Typography>
|
||||
{hasAuth && (
|
||||
<IconButton size="small" color="error" onClick={() => void handleDeletePatch(patch.key)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
|
||||
<Tabs value={tabIndex} onChange={(_, v: number) => setTabIndex(v)}>
|
||||
<Tab label="Build logs" />
|
||||
<Tab label="Changes" />
|
||||
<Tab label="Events" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{tabIndex === 0 && packageBase && repo && (
|
||||
<BuildLogsTab packageBase={packageBase} repo={repo} refetchInterval={autoRefresh.refetchInterval} />
|
||||
)}
|
||||
{tabIndex === 1 && packageBase && repo && (
|
||||
<ChangesTab packageBase={packageBase} repo={repo} />
|
||||
)}
|
||||
{tabIndex === 2 && packageBase && repo && (
|
||||
<EventsTab packageBase={packageBase} repo={repo} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
|
||||
{hasAuth && (
|
||||
<>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={refreshDb} onChange={(_, checked) => setRefreshDb(checked)} size="small" />}
|
||||
label="update pacman databases"
|
||||
/>
|
||||
<Button onClick={() => void handleUpdate()} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
|
||||
update
|
||||
</Button>
|
||||
<Button onClick={() => void handleRemove()} variant="contained" color="error" startIcon={<DeleteIcon />} size="small">
|
||||
remove
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={handleReload} variant="outlined" color="secondary" startIcon={<RefreshIcon />} size="small">
|
||||
reload
|
||||
</Button>
|
||||
<AutoRefreshControl
|
||||
intervals={autorefreshIntervals}
|
||||
enabled={autoRefresh.enabled}
|
||||
currentInterval={autoRefresh.interval}
|
||||
onToggle={autoRefresh.setEnabled}
|
||||
onIntervalChange={autoRefresh.setInterval}
|
||||
/>
|
||||
<Button onClick={handleClose} variant="contained" startIcon={<CloseIcon />} size="small">
|
||||
close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
89
frontend/src/components/dialogs/PackageRebuildDialog.tsx
Normal file
89
frontend/src/components/dialogs/PackageRebuildDialog.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button,
|
||||
TextField, Select, MenuItem, InputLabel, FormControl,
|
||||
} from "@mui/material";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { useNotification } from "hooks/useNotification";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { ApiError } from "api/client/ApiError";
|
||||
import type { RepositoryId } from "api/types/RepositoryId";
|
||||
|
||||
interface PackageRebuildDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element {
|
||||
const { repositories, current } = useRepository();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const [dependency, setDependency] = useState("");
|
||||
const [selectedRepo, setSelectedRepo] = useState<string>("");
|
||||
|
||||
const getSelectedRepo = (): RepositoryId => {
|
||||
if (selectedRepo) {
|
||||
const repo = repositories.find((r) => `${r.architecture}-${r.repository}` === selectedRepo);
|
||||
if (repo) {
|
||||
return repo;
|
||||
}
|
||||
}
|
||||
return current!;
|
||||
};
|
||||
|
||||
const handleRebuild = async (): Promise<void> => {
|
||||
if (!dependency) {
|
||||
return;
|
||||
}
|
||||
const repo = getSelectedRepo();
|
||||
try {
|
||||
await Client.rebuildPackages(repo, [dependency]);
|
||||
onClose();
|
||||
showSuccess("Success", `Repository rebuild has been run for packages which depend on ${dependency}`);
|
||||
} catch (e) {
|
||||
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||
showError("Action failed", `Repository rebuild failed: ${detail}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setDependency("");
|
||||
setSelectedRepo("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Rebuild depending packages</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>repository</InputLabel>
|
||||
<Select
|
||||
value={selectedRepo || (current ? `${current.architecture}-${current.repository}` : "")}
|
||||
label="repository"
|
||||
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||
>
|
||||
{repositories.map((r) => (
|
||||
<MenuItem key={`${r.architecture}-${r.repository}`} value={`${r.architecture}-${r.repository}`}>
|
||||
{r.repository} ({r.architecture})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="dependency"
|
||||
placeholder="packages dependency"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
value={dependency}
|
||||
onChange={(e) => setDependency(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => void handleRebuild()} variant="contained" startIcon={<PlayArrowIcon />}>rebuild</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/layout/AppLayout.tsx
Normal file
58
frontend/src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Container, Box } from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Navbar from "components/layout/Navbar";
|
||||
import Footer from "components/layout/Footer";
|
||||
import PackageTable from "components/table/PackageTable";
|
||||
import LoginDialog from "components/dialogs/LoginDialog";
|
||||
import { useAuth } from "hooks/useAuth";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import type { InfoResponse } from "api/types/InfoResponse";
|
||||
|
||||
export default function AppLayout(): React.JSX.Element {
|
||||
const { setAuthState } = useAuth();
|
||||
const { setRepositories } = useRepository();
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
const { data: info } = useQuery<InfoResponse>({
|
||||
queryKey: QueryKeys.info,
|
||||
queryFn: () => Client.fetchInfo(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// Sync info to contexts when loaded
|
||||
useEffect(() => {
|
||||
if (info) {
|
||||
setAuthState({ enabled: info.auth.enabled, username: info.auth.username ?? null });
|
||||
setRepositories(info.repositories);
|
||||
}
|
||||
}, [info, setAuthState, setRepositories]);
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl">
|
||||
<Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}>
|
||||
<a href="https://github.com/arcan1s/ahriman" title="logo">
|
||||
<img src="/static/logo.svg" width={30} height={30} alt="" />
|
||||
</a>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Navbar />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<PackageTable
|
||||
autorefreshIntervals={info?.autorefresh_intervals ?? []}
|
||||
/>
|
||||
|
||||
<Footer
|
||||
version={info?.version ?? ""}
|
||||
docsEnabled={info?.docs_enabled ?? false}
|
||||
indexUrl={info?.index_url}
|
||||
onLoginClick={() => setLoginOpen(true)}
|
||||
/>
|
||||
|
||||
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/layout/Footer.tsx
Normal file
79
frontend/src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type React from "react";
|
||||
import { Box, Link, Button, Typography } from "@mui/material";
|
||||
import GitHubIcon from "@mui/icons-material/GitHub";
|
||||
import LogoutIcon from "@mui/icons-material/Logout";
|
||||
import HomeIcon from "@mui/icons-material/Home";
|
||||
import { useAuth } from "hooks/useAuth";
|
||||
|
||||
interface FooterProps {
|
||||
version: string;
|
||||
docsEnabled: boolean;
|
||||
indexUrl?: string;
|
||||
onLoginClick: () => void;
|
||||
}
|
||||
|
||||
export default function Footer({ version, docsEnabled, indexUrl, onLoginClick }: FooterProps): React.JSX.Element {
|
||||
const { enabled: authEnabled, username, logout } = useAuth();
|
||||
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
await logout();
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
mt: 2,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
|
||||
<Link href="https://github.com/arcan1s/ahriman" underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<GitHubIcon fontSize="small" />
|
||||
<Typography variant="body2">ahriman {version}</Typography>
|
||||
</Link>
|
||||
<Link href="https://github.com/arcan1s/ahriman/releases" underline="hover" color="text.secondary" variant="body2">
|
||||
releases
|
||||
</Link>
|
||||
<Link href="https://github.com/arcan1s/ahriman/issues" underline="hover" color="text.secondary" variant="body2">
|
||||
report a bug
|
||||
</Link>
|
||||
{docsEnabled && (
|
||||
<Link href="/api-docs" underline="hover" color="text.secondary" variant="body2">
|
||||
api
|
||||
</Link>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{indexUrl && (
|
||||
<Box>
|
||||
<Link href={indexUrl} underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<HomeIcon fontSize="small" />
|
||||
<Typography variant="body2">repo index</Typography>
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{authEnabled && (
|
||||
<Box>
|
||||
{username ? (
|
||||
<Button size="small" startIcon={<LogoutIcon />} onClick={() => void handleLogout()} sx={{ textTransform: "none" }}>
|
||||
logout ({username})
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" onClick={onLoginClick} sx={{ textTransform: "none" }}>
|
||||
login
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/layout/Navbar.tsx
Normal file
33
frontend/src/components/layout/Navbar.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type React from "react";
|
||||
import { Tabs, Tab, Box } from "@mui/material";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
|
||||
export default function Navbar(): React.JSX.Element | null {
|
||||
const { repositories, current, setCurrent } = useRepository();
|
||||
|
||||
if (repositories.length === 0 || !current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentIndex = repositories.findIndex(
|
||||
(r) => r.architecture === current.architecture && r.repository === current.repository,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
<Tabs
|
||||
value={currentIndex >= 0 ? currentIndex : 0}
|
||||
onChange={(_, newValue: number) => setCurrent(repositories[newValue])}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<Tab
|
||||
key={`${repo.architecture}-${repo.repository}`}
|
||||
label={`${repo.repository} (${repo.architecture})`}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
192
frontend/src/components/package/BuildLogsTab.tsx
Normal file
192
frontend/src/components/package/BuildLogsTab.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
|
||||
import ListIcon from "@mui/icons-material/List";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import "highlight.js/styles/github.css";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||
import CopyButton from "components/common/CopyButton";
|
||||
import type { LogRecord } from "api/types/LogRecord";
|
||||
import type { RepositoryId } from "api/types/RepositoryId";
|
||||
|
||||
hljs.registerLanguage("plaintext", plaintext);
|
||||
|
||||
interface LogVersion {
|
||||
version: string;
|
||||
processId: string;
|
||||
created: number;
|
||||
logs: string;
|
||||
}
|
||||
|
||||
interface BuildLogsTabProps {
|
||||
packageBase: string;
|
||||
repo: RepositoryId;
|
||||
refetchInterval: number | false;
|
||||
}
|
||||
|
||||
function convertLogs(records: LogRecord[], filter?: (r: LogRecord) => boolean): string {
|
||||
return records
|
||||
.filter(filter || Boolean)
|
||||
.map((r) => `[${new Date(r.created * 1000).toISOString()}] ${r.message}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export default function BuildLogsTab({ packageBase, repo, refetchInterval }: BuildLogsTabProps): React.JSX.Element {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const codeRef = useRef<HTMLElement>(null);
|
||||
const preRef = useRef<HTMLElement>(null);
|
||||
const initialScrollDone = useRef(false);
|
||||
|
||||
const { data: allLogs } = useQuery<LogRecord[]>({
|
||||
queryKey: QueryKeys.logs(packageBase, repo),
|
||||
queryFn: () => Client.fetchLogs(packageBase, repo),
|
||||
enabled: !!packageBase,
|
||||
});
|
||||
|
||||
// Build version selectors from all logs
|
||||
const versions = useMemo<LogVersion[]>(() => {
|
||||
if (!allLogs || allLogs.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const grouped: Record<string, LogRecord & { minCreated: number }> = {};
|
||||
for (const record of allLogs) {
|
||||
const key = `${record.version}-${record.process_id}`;
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = { ...record, minCreated: record.created };
|
||||
} else {
|
||||
grouped[key].minCreated = Math.min(grouped[key].minCreated, record.created);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(grouped)
|
||||
.sort((a, b) => b.minCreated - a.minCreated)
|
||||
.map((v) => ({
|
||||
version: v.version,
|
||||
processId: v.process_id,
|
||||
created: v.minCreated,
|
||||
logs: convertLogs(
|
||||
allLogs,
|
||||
(r) => r.version === v.version && r.process_id === v.process_id,
|
||||
),
|
||||
}));
|
||||
}, [allLogs]);
|
||||
|
||||
// Reset active index when data changes
|
||||
const [prevAllLogs, setPrevAllLogs] = useState(allLogs);
|
||||
if (allLogs !== prevAllLogs) {
|
||||
setPrevAllLogs(allLogs);
|
||||
setActiveIndex(0);
|
||||
}
|
||||
|
||||
// Reset scroll tracking when logs data changes
|
||||
useEffect(() => {
|
||||
initialScrollDone.current = false;
|
||||
}, [allLogs]);
|
||||
|
||||
// Refresh active version logs when using auto-refresh
|
||||
const activeVersion = versions[activeIndex];
|
||||
const { data: versionLogs } = useQuery<LogRecord[]>({
|
||||
queryKey: activeVersion
|
||||
? QueryKeys.logsVersion(packageBase, repo, activeVersion.version, activeVersion.processId)
|
||||
: ["logs-none"],
|
||||
queryFn: () =>
|
||||
activeVersion
|
||||
? Client.fetchLogs(packageBase, repo, activeVersion.version, activeVersion.processId)
|
||||
: Promise.resolve([]),
|
||||
enabled: !!activeVersion && !!refetchInterval,
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
// Derive displayed logs: prefer fresh polled data when available
|
||||
const displayedLogs = useMemo(() => {
|
||||
if (versionLogs && versionLogs.length > 0) {
|
||||
return convertLogs(versionLogs);
|
||||
}
|
||||
return activeVersion?.logs ?? "";
|
||||
}, [versionLogs, activeVersion]);
|
||||
|
||||
// Highlight code
|
||||
useEffect(() => {
|
||||
if (codeRef.current && displayedLogs) {
|
||||
codeRef.current.textContent = displayedLogs;
|
||||
delete codeRef.current.dataset.highlighted;
|
||||
hljs.highlightElement(codeRef.current);
|
||||
}
|
||||
}, [displayedLogs]);
|
||||
|
||||
// Auto-scroll: always scroll to bottom on initial load, then only if already near bottom
|
||||
useEffect(() => {
|
||||
if (preRef.current && displayedLogs) {
|
||||
const el = preRef.current;
|
||||
if (!initialScrollDone.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
initialScrollDone.current = true;
|
||||
} else {
|
||||
const isAtBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 50;
|
||||
if (isAtBottom) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [displayedLogs]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
||||
<Box>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<ListIcon />}
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
/>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
>
|
||||
{versions.map((v, idx) => (
|
||||
<MenuItem
|
||||
key={`${v.version}-${v.processId}`}
|
||||
selected={idx === activeIndex}
|
||||
onClick={() => {
|
||||
setActiveIndex(idx);
|
||||
setAnchorEl(null);
|
||||
initialScrollDone.current = false;
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">{formatTimestamp(v.created)}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
{versions.length === 0 && (
|
||||
<MenuItem disabled>No logs available</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, position: "relative" }}>
|
||||
<Box
|
||||
ref={preRef}
|
||||
component="pre"
|
||||
sx={{
|
||||
backgroundColor: "grey.100",
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
overflow: "auto",
|
||||
maxHeight: 400,
|
||||
fontSize: "0.8rem",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
<code ref={codeRef} className="language-plaintext" />
|
||||
</Box>
|
||||
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||
<CopyButton getText={() => displayedLogs} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
60
frontend/src/components/package/ChangesTab.tsx
Normal file
60
frontend/src/components/package/ChangesTab.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import diff from "highlight.js/lib/languages/diff";
|
||||
import "highlight.js/styles/github.css";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import CopyButton from "components/common/CopyButton";
|
||||
import type { Changes } from "api/types/Changes";
|
||||
import type { RepositoryId } from "api/types/RepositoryId";
|
||||
|
||||
hljs.registerLanguage("diff", diff);
|
||||
|
||||
interface ChangesTabProps {
|
||||
packageBase: string;
|
||||
repo: RepositoryId;
|
||||
}
|
||||
|
||||
export default function ChangesTab({ packageBase, repo }: ChangesTabProps): React.JSX.Element {
|
||||
const codeRef = useRef<HTMLElement>(null);
|
||||
|
||||
const { data } = useQuery<Changes>({
|
||||
queryKey: QueryKeys.changes(packageBase, repo),
|
||||
queryFn: () => Client.fetchChanges(packageBase, repo),
|
||||
enabled: !!packageBase,
|
||||
});
|
||||
|
||||
const changesText = data?.changes ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (codeRef.current) {
|
||||
codeRef.current.textContent = changesText;
|
||||
delete codeRef.current.dataset.highlighted;
|
||||
hljs.highlightElement(codeRef.current);
|
||||
}
|
||||
}, [changesText]);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: "relative", mt: 1 }}>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
backgroundColor: "grey.100",
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
overflow: "auto",
|
||||
maxHeight: 400,
|
||||
fontSize: "0.8rem",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
<code ref={codeRef} className="language-diff" />
|
||||
</Box>
|
||||
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||
<CopyButton getText={() => changesText} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
60
frontend/src/components/package/EventsTab.tsx
Normal file
60
frontend/src/components/package/EventsTab.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import type React from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import EventDurationLineChart from "components/charts/EventDurationLineChart";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||
import type { Event } from "api/types/Event";
|
||||
import type { RepositoryId } from "api/types/RepositoryId";
|
||||
|
||||
interface EventsTabProps {
|
||||
packageBase: string;
|
||||
repo: RepositoryId;
|
||||
}
|
||||
|
||||
interface EventRow {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
event: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const columns: GridColDef<EventRow>[] = [
|
||||
{ field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
|
||||
{ field: "event", headerName: "event", flex: 1 },
|
||||
{ field: "message", headerName: "description", flex: 2 },
|
||||
];
|
||||
|
||||
export default function EventsTab({ packageBase, repo }: EventsTabProps): React.JSX.Element {
|
||||
const { data: events = [] } = useQuery<Event[]>({
|
||||
queryKey: QueryKeys.events(repo, packageBase),
|
||||
queryFn: () => Client.fetchEvents(repo, packageBase, 30),
|
||||
enabled: !!packageBase,
|
||||
});
|
||||
|
||||
const rows: EventRow[] = events.map((e, idx) => ({
|
||||
id: idx,
|
||||
timestamp: formatTimestamp(e.created),
|
||||
event: e.event,
|
||||
message: e.message ?? "",
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<EventDurationLineChart events={events} />
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
density="compact"
|
||||
initialState={{
|
||||
sorting: { sortModel: [{ field: "timestamp", sort: "desc" }] },
|
||||
}}
|
||||
pageSizeOptions={[10, 25]}
|
||||
sx={{ height: 300, mt: 1 }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
317
frontend/src/components/table/PackageTable.tsx
Normal file
317
frontend/src/components/table/PackageTable.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import {
|
||||
DataGrid,
|
||||
GridToolbarQuickFilter,
|
||||
GridToolbarFilterButton,
|
||||
type GridColDef,
|
||||
type GridFilterModel,
|
||||
type GridRowSelectionModel,
|
||||
type GridRenderCellParams,
|
||||
} from "@mui/x-data-grid";
|
||||
import { Box, Link, Stack } from "@mui/material";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import PackageTableToolbar from "components/table/PackageTableToolbar";
|
||||
import StatusCell from "components/table/StatusCell";
|
||||
import DashboardDialog from "components/dialogs/DashboardDialog";
|
||||
import PackageAddDialog from "components/dialogs/PackageAddDialog";
|
||||
import PackageRebuildDialog from "components/dialogs/PackageRebuildDialog";
|
||||
import KeyImportDialog from "components/dialogs/KeyImportDialog";
|
||||
import PackageInfoDialog from "components/dialogs/PackageInfoDialog";
|
||||
import { useRepository } from "hooks/useRepository";
|
||||
import { useAuth } from "hooks/useAuth";
|
||||
import { useNotification } from "hooks/useNotification";
|
||||
import { useAutoRefresh } from "hooks/useAutoRefresh";
|
||||
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||
import { Client } from "api/client/AhrimanClient";
|
||||
import { ApiError } from "api/client/ApiError";
|
||||
import { QueryKeys } from "api/QueryKeys";
|
||||
import { formatTimestamp } from "components/common/formatTimestamp";
|
||||
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||
import type { InternalStatus } from "api/types/InternalStatus";
|
||||
import type { PackageRow } from "api/types/PackageRow";
|
||||
import type { PackageStatus } from "api/types/PackageStatus";
|
||||
|
||||
interface PackageTableProps {
|
||||
autorefreshIntervals: AutoRefreshInterval[];
|
||||
}
|
||||
|
||||
function extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
Object.values(pkg.packages)
|
||||
.flatMap((p) => p[property] ?? []),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
function toRow(ps: PackageStatus): PackageRow {
|
||||
return {
|
||||
id: ps.package.base,
|
||||
base: ps.package.base,
|
||||
webUrl: ps.package.remote.web_url ?? undefined,
|
||||
version: ps.package.version,
|
||||
packages: Object.keys(ps.package.packages).sort(),
|
||||
groups: extractListProperties(ps.package, "groups"),
|
||||
licenses: extractListProperties(ps.package, "licenses"),
|
||||
packager: ps.package.packager ?? "",
|
||||
timestamp: formatTimestamp(ps.status.timestamp),
|
||||
timestampValue: ps.status.timestamp,
|
||||
status: ps.status.status,
|
||||
};
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||
|
||||
function SearchToolbar(): React.JSX.Element {
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 1, py: 0.5 }}>
|
||||
<GridToolbarFilterButton />
|
||||
<GridToolbarQuickFilter debounceMs={300} sx={{ flex: 1 }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PackageTable({ autorefreshIntervals }: PackageTableProps): React.JSX.Element {
|
||||
const { current } = useRepository();
|
||||
const { enabled: authEnabled, username } = useAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const hasAuth = !authEnabled || username !== null;
|
||||
|
||||
const defaultInterval = autorefreshIntervals.find((i) => i.is_active)?.interval ?? 0;
|
||||
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval);
|
||||
|
||||
const [selectionModel, setSelectionModel] = useState<GridRowSelectionModel>([]);
|
||||
const [dialogOpen, setDialogOpen] = useState<string | null>(null);
|
||||
const [selectedPackage, setSelectedPackage] = useState<string | null>(null);
|
||||
|
||||
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
|
||||
pageSize: 10,
|
||||
page: 0,
|
||||
});
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>(
|
||||
"ahriman-packages-columns",
|
||||
{ groups: false, licenses: false, packager: false },
|
||||
);
|
||||
const [filterModel, setFilterModel] = useLocalStorage<GridFilterModel>(
|
||||
"ahriman-packages-filters",
|
||||
{ items: [] },
|
||||
);
|
||||
|
||||
// Pause auto-refresh when dialog is open
|
||||
const isDialogOpen = dialogOpen !== null || selectedPackage !== null;
|
||||
const setPaused = autoRefresh.setPaused;
|
||||
useEffect(() => {
|
||||
setPaused(isDialogOpen);
|
||||
}, [isDialogOpen, setPaused]);
|
||||
|
||||
const { data: packages = [], isLoading } = useQuery<PackageStatus[]>({
|
||||
queryKey: current ? QueryKeys.packages(current) : ["packages"],
|
||||
queryFn: () => (current ? Client.fetchPackages(current) : Promise.resolve([])),
|
||||
enabled: !!current,
|
||||
refetchInterval: autoRefresh.refetchInterval,
|
||||
});
|
||||
|
||||
const { data: status } = useQuery<InternalStatus>({
|
||||
queryKey: current ? QueryKeys.status(current) : ["status"],
|
||||
queryFn: () => Client.fetchStatus(current!),
|
||||
enabled: !!current,
|
||||
refetchInterval: autoRefresh.refetchInterval,
|
||||
});
|
||||
|
||||
const rows = useMemo(() => packages.map(toRow), [packages]);
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.packages(current) });
|
||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.status(current) });
|
||||
}, [current, queryClient]);
|
||||
|
||||
const handleUpdate = useCallback(async () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const selected = selectionModel as string[];
|
||||
try {
|
||||
if (selected.length === 0) {
|
||||
await Client.updatePackages(current, { packages: [] });
|
||||
showSuccess("Success", "Repository update has been run");
|
||||
} else {
|
||||
await Client.addPackages(current, { packages: selected });
|
||||
showSuccess("Success", `Run update for packages ${selected.join(", ")}`);
|
||||
}
|
||||
} catch (e) {
|
||||
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||
showError("Action failed", `Packages update failed: ${detail}`);
|
||||
}
|
||||
}, [current, selectionModel, showSuccess, showError]);
|
||||
|
||||
const handleRefreshDb = useCallback(async () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Client.updatePackages(current, { packages: [], refresh: true, aur: false, local: false, manual: false });
|
||||
showSuccess("Success", "Pacman database update has been requested");
|
||||
} catch (e) {
|
||||
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||
showError("Action failed", `Could not update pacman databases: ${detail}`);
|
||||
}
|
||||
}, [current, showSuccess, showError]);
|
||||
|
||||
const handleRemove = useCallback(async () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const selected = selectionModel as string[];
|
||||
if (selected.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await Client.removePackages(current, selected);
|
||||
showSuccess("Success", `Packages ${selected.join(", ")} have been removed`);
|
||||
setSelectionModel([]);
|
||||
} catch (e) {
|
||||
const detail = e instanceof ApiError ? e.detail : String(e);
|
||||
showError("Action failed", `Could not remove packages: ${detail}`);
|
||||
}
|
||||
}, [current, selectionModel, showSuccess, showError]);
|
||||
|
||||
const columns: GridColDef<PackageRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
field: "base",
|
||||
headerName: "package base",
|
||||
flex: 1,
|
||||
minWidth: 150,
|
||||
renderCell: (params: GridRenderCellParams<PackageRow>) =>
|
||||
params.row.webUrl ? (
|
||||
<Link href={params.row.webUrl} target="_blank" rel="noopener" underline="hover">
|
||||
{params.value as string}
|
||||
</Link>
|
||||
) : (
|
||||
params.value as string
|
||||
),
|
||||
},
|
||||
{ field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
|
||||
{
|
||||
field: "packages",
|
||||
headerName: "packages",
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
||||
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.packages ?? []).map((item, i, arr) => (
|
||||
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
|
||||
)),
|
||||
sortComparator: (v1: string, v2: string) => v1.localeCompare(v2),
|
||||
},
|
||||
{
|
||||
field: "groups",
|
||||
headerName: "groups",
|
||||
width: 150,
|
||||
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
||||
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.groups ?? []).map((item, i, arr) => (
|
||||
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
|
||||
)),
|
||||
},
|
||||
{
|
||||
field: "licenses",
|
||||
headerName: "licenses",
|
||||
width: 150,
|
||||
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
||||
renderCell: (params: GridRenderCellParams<PackageRow>) => (params.row.licenses ?? []).map((item, i, arr) => (
|
||||
<React.Fragment key={item}>{item}{i < arr.length - 1 && <br />}</React.Fragment>
|
||||
)),
|
||||
},
|
||||
{ field: "packager", headerName: "packager", width: 150 },
|
||||
{
|
||||
field: "timestamp",
|
||||
headerName: "last update",
|
||||
width: 180,
|
||||
align: "right",
|
||||
headerAlign: "right",
|
||||
},
|
||||
{
|
||||
field: "status",
|
||||
headerName: "status",
|
||||
width: 120,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
renderCell: (params: GridRenderCellParams<PackageRow>) => <StatusCell status={params.row.status} />,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PackageTableToolbar
|
||||
hasSelection={(selectionModel as string[]).length > 0}
|
||||
hasAuth={hasAuth}
|
||||
repoStatus={status?.status.status}
|
||||
autorefreshIntervals={autorefreshIntervals}
|
||||
autoRefreshEnabled={autoRefresh.enabled}
|
||||
autoRefreshInterval={autoRefresh.interval}
|
||||
onAutoRefreshToggle={autoRefresh.setEnabled}
|
||||
onAutoRefreshIntervalChange={autoRefresh.setInterval}
|
||||
onDashboardClick={() => setDialogOpen("dashboard")}
|
||||
onAddClick={() => setDialogOpen("add")}
|
||||
onUpdateClick={() => void handleUpdate()}
|
||||
onRefreshDbClick={() => void handleRefreshDb()}
|
||||
onRebuildClick={() => setDialogOpen("rebuild")}
|
||||
onRemoveClick={() => void handleRemove()}
|
||||
onKeyImportClick={() => setDialogOpen("keyImport")}
|
||||
onReloadClick={handleReload}
|
||||
/>
|
||||
|
||||
<DataGrid
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
getRowHeight={() => "auto"}
|
||||
checkboxSelection
|
||||
disableRowSelectionOnClick
|
||||
rowSelectionModel={selectionModel}
|
||||
onRowSelectionModelChange={setSelectionModel}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
columnVisibilityModel={columnVisibility}
|
||||
onColumnVisibilityModelChange={setColumnVisibility}
|
||||
filterModel={filterModel}
|
||||
onFilterModelChange={setFilterModel}
|
||||
slots={{ toolbar: SearchToolbar }}
|
||||
initialState={{
|
||||
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
|
||||
}}
|
||||
onRowClick={(params: { row: PackageRow }, event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
// Don't open info dialog when clicking checkbox or link
|
||||
if (target.closest("input[type=\"checkbox\"]") || target.closest("a")) {
|
||||
return;
|
||||
}
|
||||
setSelectedPackage(params.row.id);
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiDataGrid-row": { cursor: "pointer" },
|
||||
height: 600,
|
||||
}}
|
||||
density="compact"
|
||||
/>
|
||||
|
||||
<DashboardDialog open={dialogOpen === "dashboard"} onClose={() => setDialogOpen(null)} />
|
||||
<PackageAddDialog open={dialogOpen === "add"} onClose={() => setDialogOpen(null)} />
|
||||
<PackageRebuildDialog open={dialogOpen === "rebuild"} onClose={() => setDialogOpen(null)} />
|
||||
<KeyImportDialog open={dialogOpen === "keyImport"} onClose={() => setDialogOpen(null)} />
|
||||
<PackageInfoDialog
|
||||
packageBase={selectedPackage}
|
||||
open={selectedPackage !== null}
|
||||
onClose={() => setSelectedPackage(null)}
|
||||
autorefreshIntervals={autorefreshIntervals}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
133
frontend/src/components/table/PackageTableToolbar.tsx
Normal file
133
frontend/src/components/table/PackageTableToolbar.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Menu, MenuItem, Box, Tooltip, IconButton, Divider } from "@mui/material";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import InventoryIcon from "@mui/icons-material/Inventory";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import ReplayIcon from "@mui/icons-material/Replay";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import VpnKeyIcon from "@mui/icons-material/VpnKey";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import AutoRefreshControl from "components/common/AutoRefreshControl";
|
||||
import type { AutoRefreshInterval } from "api/types/AutoRefreshInterval";
|
||||
import type { BuildStatus } from "api/types/BuildStatus";
|
||||
import { StatusColors } from "theme/status/StatusColors";
|
||||
|
||||
interface PackageTableToolbarProps {
|
||||
hasSelection: boolean;
|
||||
hasAuth: boolean;
|
||||
repoStatus?: BuildStatus;
|
||||
autorefreshIntervals: AutoRefreshInterval[];
|
||||
autoRefreshEnabled: boolean;
|
||||
autoRefreshInterval: number;
|
||||
onAutoRefreshToggle: (enabled: boolean) => void;
|
||||
onAutoRefreshIntervalChange: (interval: number) => void;
|
||||
onDashboardClick: () => void;
|
||||
onAddClick: () => void;
|
||||
onUpdateClick: () => void;
|
||||
onRefreshDbClick: () => void;
|
||||
onRebuildClick: () => void;
|
||||
onRemoveClick: () => void;
|
||||
onKeyImportClick: () => void;
|
||||
onReloadClick: () => void;
|
||||
}
|
||||
|
||||
export default function PackageTableToolbar({
|
||||
hasSelection,
|
||||
hasAuth,
|
||||
repoStatus,
|
||||
autorefreshIntervals,
|
||||
autoRefreshEnabled,
|
||||
autoRefreshInterval,
|
||||
onAutoRefreshToggle,
|
||||
onAutoRefreshIntervalChange,
|
||||
onDashboardClick,
|
||||
onAddClick,
|
||||
onUpdateClick,
|
||||
onRefreshDbClick,
|
||||
onRebuildClick,
|
||||
onRemoveClick,
|
||||
onKeyImportClick,
|
||||
onReloadClick,
|
||||
}: PackageTableToolbarProps): React.JSX.Element {
|
||||
const [packagesAnchorEl, setPackagesAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1, mb: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Tooltip title="System health">
|
||||
<IconButton
|
||||
onClick={onDashboardClick}
|
||||
sx={{
|
||||
borderColor: repoStatus ? StatusColors[repoStatus] : undefined,
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
color: repoStatus ? StatusColors[repoStatus] : undefined,
|
||||
}}
|
||||
>
|
||||
<InfoOutlinedIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{hasAuth && (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<InventoryIcon />}
|
||||
onClick={(e) => setPackagesAnchorEl(e.currentTarget)}
|
||||
>
|
||||
packages
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={packagesAnchorEl}
|
||||
open={Boolean(packagesAnchorEl)}
|
||||
onClose={() => setPackagesAnchorEl(null)}
|
||||
>
|
||||
<MenuItem onClick={() => {
|
||||
setPackagesAnchorEl(null); onAddClick();
|
||||
}}>
|
||||
<AddIcon fontSize="small" sx={{ mr: 1 }} /> add
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
setPackagesAnchorEl(null); onUpdateClick();
|
||||
}}>
|
||||
<PlayArrowIcon fontSize="small" sx={{ mr: 1 }} /> update
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
setPackagesAnchorEl(null); onRefreshDbClick();
|
||||
}}>
|
||||
<DownloadIcon fontSize="small" sx={{ mr: 1 }} /> update pacman databases
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => {
|
||||
setPackagesAnchorEl(null); onRebuildClick();
|
||||
}}>
|
||||
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => {
|
||||
setPackagesAnchorEl(null); onRemoveClick();
|
||||
}} disabled={!hasSelection}>
|
||||
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Button variant="contained" color="info" startIcon={<VpnKeyIcon />} onClick={onKeyImportClick}>
|
||||
import key
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="outlined" color="secondary" startIcon={<RefreshIcon />} onClick={onReloadClick}>
|
||||
reload
|
||||
</Button>
|
||||
|
||||
<AutoRefreshControl
|
||||
intervals={autorefreshIntervals}
|
||||
enabled={autoRefreshEnabled}
|
||||
currentInterval={autoRefreshInterval}
|
||||
onToggle={onAutoRefreshToggle}
|
||||
onIntervalChange={onAutoRefreshIntervalChange}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/table/StatusCell.tsx
Normal file
22
frontend/src/components/table/StatusCell.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type React from "react";
|
||||
import { Chip } from "@mui/material";
|
||||
import type { BuildStatus } from "api/types/BuildStatus";
|
||||
import { StatusColors } from "theme/status/StatusColors";
|
||||
|
||||
interface StatusCellProps {
|
||||
status: BuildStatus;
|
||||
}
|
||||
|
||||
export default function StatusCell({ status }: StatusCellProps): React.JSX.Element {
|
||||
return (
|
||||
<Chip
|
||||
label={status}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: StatusColors[status],
|
||||
color: "common.white",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user