refactor: reorder arguments in web ui

This commit is contained in:
2026-03-22 03:23:09 +02:00
parent d7984c12f0
commit 5e090cebdb
61 changed files with 547 additions and 578 deletions

View File

@@ -1,8 +1,34 @@
{ {
"dependencies": {
"@emotion/react": ">=11.14.0 <11.15.0",
"@emotion/styled": ">=11.14.0 <11.15.0",
"@mui/icons-material": ">=7.3.0 <7.4.0",
"@mui/material": ">=7.3.0 <7.4.0",
"@mui/x-data-grid": ">=8.28.0 <8.29.0",
"@tanstack/react-query": ">=5.94.0 <5.95.0",
"chart.js": ">=4.5.0 <4.6.0",
"react": ">=19.2.0 <19.3.0",
"react-chartjs-2": ">=5.3.0 <5.4.0",
"react-dom": ">=19.2.0 <19.3.0",
"react-syntax-highlighter": ">=16.1.0 <16.2.0"
},
"devDependencies": {
"@eslint/js": ">=9.39.0 <9.40.0",
"@stylistic/eslint-plugin": ">=5.10.0 <5.11.0",
"@types/react": ">=19.2.0 <19.3.0",
"@types/react-dom": ">=19.2.0 <19.3.0",
"@types/react-syntax-highlighter": ">=15.5.0 <15.6.0",
"@vitejs/plugin-react": ">=6.0.0 <6.1.0",
"eslint": ">=9.39.0 <9.40.0",
"eslint-plugin-react-hooks": ">=7.0.0 <7.1.0",
"eslint-plugin-react-refresh": ">=0.5.0 <0.6.0",
"eslint-plugin-simple-import-sort": ">=12.1.0 <12.2.0",
"typescript": ">=5.9.0 <5.10.0",
"typescript-eslint": ">=8.57.0 <8.58.0",
"vite": ">=8.0.0 <8.1.0"
},
"name": "ahriman-frontend", "name": "ahriman-frontend",
"private": true, "private": true,
"type": "module",
"version": "2.20.0",
"scripts": { "scripts": {
"build": "tsc && vite build", "build": "tsc && vite build",
"dev": "vite", "dev": "vite",
@@ -10,32 +36,6 @@
"lint:fix": "eslint --fix src/", "lint:fix": "eslint --fix src/",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "type": "module",
"@emotion/react": "^11.14.0", "version": "2.20.0"
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.9",
"@mui/material": "^7.3.9",
"@mui/x-data-grid": "^8.27.4",
"@tanstack/react-query": "^5.91.3",
"chart.js": "^4.5.1",
"react": "^19.2.4",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.4",
"react-syntax-highlighter": "^16.1.1"
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.3",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-simple-import-sort": "^12.1.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0"
}
} }

View File

@@ -29,8 +29,8 @@ import type React from "react";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 30_000,
retry: 1, retry: 1,
staleTime: 30_000,
}, },
}, },
}); });

View File

@@ -18,9 +18,10 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export class ApiError extends Error { export class ApiError extends Error {
body: string;
status: number; status: number;
statusText: string; statusText: string;
body: string;
constructor(status: number, statusText: string, body: string) { constructor(status: number, statusText: string, body: string) {
super(`${status} ${statusText}`); super(`${status} ${statusText}`);

View File

@@ -37,14 +37,14 @@ export class FetchClient {
this.client = client; this.client = client;
} }
async fetchPackageArtifacts(packageBase: string, repository: RepositoryId): Promise<Package[]> { async fetchPackage(packageBase: string, repository: RepositoryId): Promise<PackageStatus[]> {
return this.client.request<Package[]>(`/api/v1/packages/${encodeURIComponent(packageBase)}/archives`, { return this.client.request<PackageStatus[]>(`/api/v1/packages/${encodeURIComponent(packageBase)}`, {
query: repository.toQuery(), query: repository.toQuery(),
}); });
} }
async fetchPackage(packageBase: string, repository: RepositoryId): Promise<PackageStatus[]> { async fetchPackageArtifacts(packageBase: string, repository: RepositoryId): Promise<Package[]> {
return this.client.request<PackageStatus[]>(`/api/v1/packages/${encodeURIComponent(packageBase)}`, { return this.client.request<Package[]>(`/api/v1/packages/${encodeURIComponent(packageBase)}/archives`, {
query: repository.toQuery(), query: repository.toQuery(),
}); });
} }

View File

@@ -18,8 +18,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export interface RequestOptions { export interface RequestOptions {
json?: unknown;
method?: string; method?: string;
query?: Record<string, string | number | boolean>; query?: Record<string, string | number | boolean>;
json?: unknown;
timeout?: number; timeout?: number;
} }

View File

@@ -37,17 +37,17 @@ export class ServiceClient {
return this.client.request("/api/v1/service/add", { method: "POST", query: repository.toQuery(), json: data }); return this.client.request("/api/v1/service/add", { method: "POST", query: repository.toQuery(), json: data });
} }
async servicePackagePatchRemove(packageBase: string, key: string): Promise<void> { async servicePackageHold(packageBase: string, repository: RepositoryId, isHeld: boolean): Promise<void> {
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, { return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/hold`, {
method: "DELETE", method: "POST",
query: repository.toQuery(),
json: { is_held: isHeld },
}); });
} }
async servicePackageRollback(repository: RepositoryId, data: RollbackRequest): Promise<void> { async servicePackagePatchRemove(packageBase: string, key: string): Promise<void> {
return this.client.request("/api/v1/service/rollback", { return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, {
method: "POST", method: "DELETE",
query: repository.toQuery(),
json: data,
}); });
} }
@@ -67,6 +67,14 @@ export class ServiceClient {
}); });
} }
async servicePackageRollback(repository: RepositoryId, data: RollbackRequest): Promise<void> {
return this.client.request("/api/v1/service/rollback", {
method: "POST",
query: repository.toQuery(),
json: data,
});
}
async servicePackageSearch(query: string): Promise<AURPackage[]> { async servicePackageSearch(query: string): Promise<AURPackage[]> {
return this.client.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } }); return this.client.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
} }
@@ -87,14 +95,6 @@ export class ServiceClient {
return this.client.request("/api/v1/service/pgp", { method: "POST", json: data }); return this.client.request("/api/v1/service/pgp", { method: "POST", json: data });
} }
async servicePackageHoldUpdate(packageBase: string, repository: RepositoryId, isHeld: boolean): Promise<void> {
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/hold`, {
method: "POST",
query: repository.toQuery(),
json: { is_held: isHeld },
});
}
async serviceRebuild(repository: RepositoryId, packages: string[]): Promise<void> { async serviceRebuild(repository: RepositoryId, packages: string[]): Promise<void> {
return this.client.request("/api/v1/service/rebuild", { return this.client.request("/api/v1/service/rebuild", {
method: "POST", method: "POST",

View File

@@ -32,11 +32,11 @@ export default function EventDurationLineChart({ events }: EventDurationLineChar
labels: updateEvents.map(event => new Date(event.created * 1000).toISOStringShort()), labels: updateEvents.map(event => new Date(event.created * 1000).toISOStringShort()),
datasets: [ datasets: [
{ {
label: "update duration, s",
data: updateEvents.map(event => event.data?.took ?? 0),
borderColor: blue[500],
backgroundColor: blue[200], backgroundColor: blue[200],
borderColor: blue[500],
cubicInterpolationMode: "monotone" as const, cubicInterpolationMode: "monotone" as const,
data: updateEvents.map(event => event.data?.took ?? 0),
label: "update duration, s",
tension: 0.4, tension: 0.4,
}, },
], ],

View File

@@ -29,19 +29,19 @@ interface PackageCountBarChartProps {
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element { export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
return <Bar return <Bar
data={{ data={{
labels: ["packages"],
datasets: [ datasets: [
{ {
label: "bases",
data: [stats.bases ?? 0],
backgroundColor: indigo[300], backgroundColor: indigo[300],
data: [stats.bases ?? 0],
label: "bases",
}, },
{ {
label: "archives",
data: [stats.packages ?? 0],
backgroundColor: blue[500], backgroundColor: blue[500],
data: [stats.packages ?? 0],
label: "archives",
}, },
], ],
labels: ["packages"],
}} }}
options={{ options={{
maintainAspectRatio: false, maintainAspectRatio: false,

View File

@@ -30,14 +30,14 @@ interface StatusPieChartProps {
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element { export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
const labels = ["unknown", "pending", "building", "failed", "success"] as BuildStatus[]; const labels = ["unknown", "pending", "building", "failed", "success"] as BuildStatus[];
const data = { const data = {
labels: labels,
datasets: [ datasets: [
{ {
label: "packages in status",
data: labels.map(label => counters[label]),
backgroundColor: labels.map(label => StatusColors[label]), backgroundColor: labels.map(label => StatusColors[label]),
data: labels.map(label => counters[label]),
label: "packages in status",
}, },
], ],
labels: labels,
}; };
return <Pie data={data} options={{ responsive: true }} />; return <Pie data={data} options={{ responsive: true }} />;

View File

@@ -25,14 +25,14 @@ import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import React, { useState } from "react"; import React, { useState } from "react";
interface AutoRefreshControlProps { interface AutoRefreshControlProps {
intervals: AutoRefreshInterval[];
currentInterval: number; currentInterval: number;
intervals: AutoRefreshInterval[];
onIntervalChange: (interval: number) => void; onIntervalChange: (interval: number) => void;
} }
export default function AutoRefreshControl({ export default function AutoRefreshControl({
intervals,
currentInterval, currentInterval,
intervals,
onIntervalChange, onIntervalChange,
}: AutoRefreshControlProps): React.JSX.Element | null { }: AutoRefreshControlProps): React.JSX.Element | null {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
@@ -46,25 +46,25 @@ export default function AutoRefreshControl({
return <> return <>
<Tooltip title="Auto-refresh"> <Tooltip title="Auto-refresh">
<IconButton <IconButton
size="small"
aria-label="Auto-refresh" aria-label="Auto-refresh"
onClick={event => setAnchorEl(event.currentTarget)}
color={enabled ? "primary" : "default"} color={enabled ? "primary" : "default"}
onClick={event => setAnchorEl(event.currentTarget)}
size="small"
> >
{enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />} {enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Menu <Menu
anchorEl={anchorEl} anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)} onClose={() => setAnchorEl(null)}
open={Boolean(anchorEl)}
> >
<MenuItem <MenuItem
selected={!enabled}
onClick={() => { onClick={() => {
onIntervalChange(0); onIntervalChange(0);
setAnchorEl(null); setAnchorEl(null);
}} }}
selected={!enabled}
> >
<ListItemIcon> <ListItemIcon>
{!enabled && <CheckIcon fontSize="small" />} {!enabled && <CheckIcon fontSize="small" />}
@@ -74,11 +74,11 @@ export default function AutoRefreshControl({
{intervals.map(interval => {intervals.map(interval =>
<MenuItem <MenuItem
key={interval.interval} key={interval.interval}
selected={enabled && interval.interval === currentInterval}
onClick={() => { onClick={() => {
onIntervalChange(interval.interval); onIntervalChange(interval.interval);
setAnchorEl(null); setAnchorEl(null);
}} }}
selected={enabled && interval.interval === currentInterval}
> >
<ListItemIcon> <ListItemIcon>
{enabled && interval.interval === currentInterval && <CheckIcon fontSize="small" />} {enabled && interval.interval === currentInterval && <CheckIcon fontSize="small" />}

View File

@@ -46,27 +46,24 @@ export default function CodeBlock({
return <Box sx={{ position: "relative" }}> return <Box sx={{ position: "relative" }}>
<Box <Box
ref={preRef}
onScroll={onScroll} onScroll={onScroll}
ref={preRef}
sx={{ overflow: "auto", height }} sx={{ overflow: "auto", height }}
> >
<SyntaxHighlighter <SyntaxHighlighter
customStyle={{
borderRadius: `${theme.shape.borderRadius}px`,
fontSize: "0.8rem",
padding: theme.spacing(2),
}}
language={language} language={language}
style={mode === "dark" ? vs2015 : githubGist} style={mode === "dark" ? vs2015 : githubGist}
wrapLongLines wrapLongLines
customStyle={{
padding: theme.spacing(2),
borderRadius: `${theme.shape.borderRadius}px`,
fontSize: "0.8rem",
fontFamily: "monospace",
margin: 0,
minHeight: "100%",
}}
> >
{content} {content}
</SyntaxHighlighter> </SyntaxHighlighter>
</Box> </Box>
{content && <Box sx={{ position: "absolute", top: 8, right: 8 }}> {content && <Box sx={{ position: "absolute", right: 8, top: 8 }}>
<CopyButton text={content} /> <CopyButton text={content} />
</Box>} </Box>}
</Box>; </Box>;

View File

@@ -40,7 +40,7 @@ export default function CopyButton({ text }: CopyButtonProps): React.JSX.Element
}; };
return <Tooltip title={copied ? "Copied!" : "Copy"}> return <Tooltip title={copied ? "Copied!" : "Copy"}>
<IconButton size="small" aria-label={copied ? "Copied" : "Copy"} onClick={() => void handleCopy()}> <IconButton aria-label={copied ? "Copied" : "Copy"} onClick={() => void handleCopy()} size="small">
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />} {copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton> </IconButton>
</Tooltip>; </Tooltip>;

View File

@@ -28,7 +28,7 @@ interface DialogHeaderProps {
} }
export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element { export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element {
return <DialogTitle sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", ...sx }}> return <DialogTitle sx={{ alignItems: "center", display: "flex", justifyContent: "space-between", ...sx }}>
{children} {children}
<IconButton aria-label="Close" onClick={onClose} size="small" sx={{ color: "inherit" }}> <IconButton aria-label="Close" onClick={onClose} size="small" sx={{ color: "inherit" }}>
<CloseIcon /> <CloseIcon />

View File

@@ -35,12 +35,12 @@ export default function NotificationItem({ notification, onClose }: Notification
}, []); }, []);
return ( return (
<Slide direction="down" in={show} mountOnEnter unmountOnExit onExited={() => onClose(notification.id)}> <Slide direction="down" in={show} mountOnEnter onExited={() => onClose(notification.id)} unmountOnExit>
<Alert <Alert
onClose={() => setShow(false)} onClose={() => setShow(false)}
severity={notification.severity} severity={notification.severity}
variant="filled"
sx={{ width: "100%", pointerEvents: "auto" }} sx={{ width: "100%", pointerEvents: "auto" }}
variant="filled"
> >
<strong>{notification.title}</strong> <strong>{notification.title}</strong>
{notification.message && ` - ${notification.message}`} {notification.message && ` - ${notification.message}`}

View File

@@ -30,9 +30,9 @@ export default function RepositorySelect({
return <FormControl fullWidth margin="normal"> return <FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel> <InputLabel>repository</InputLabel>
<Select <Select
value={repositorySelect.selectedKey || (currentRepository?.key ?? "")}
label="repository" label="repository"
onChange={event => repositorySelect.setSelectedKey(event.target.value)} onChange={event => repositorySelect.setSelectedKey(event.target.value)}
value={repositorySelect.selectedKey || (currentRepository?.key ?? "")}
> >
{repositories.map(repository => {repositories.map(repository =>
<MenuItem key={repository.key} value={repository.key}> <MenuItem key={repository.key} value={repository.key}>

View File

@@ -30,23 +30,23 @@ import type React from "react";
import { StatusHeaderStyles } from "theme/StatusColors"; import { StatusHeaderStyles } from "theme/StatusColors";
interface DashboardDialogProps { interface DashboardDialogProps {
open: boolean;
onClose: () => void; onClose: () => void;
open: boolean;
} }
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element { export default function DashboardDialog({ onClose, open }: DashboardDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { currentRepository } = useRepository(); const { currentRepository } = useRepository();
const { data: status } = useQuery<InternalStatus>({ const { data: status } = useQuery<InternalStatus>({
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
enabled: open, enabled: open,
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
}); });
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {}; const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
return <Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth> return <Dialog fullWidth maxWidth="lg" onClose={onClose} open={open}>
<DialogHeader onClose={onClose} sx={headerStyle}> <DialogHeader onClose={onClose} sx={headerStyle}>
System health System health
</DialogHeader> </DialogHeader>
@@ -55,43 +55,43 @@ export default function DashboardDialog({ open, onClose }: DashboardDialogProps)
{status && {status &&
<> <>
<Grid container spacing={2} sx={{ mt: 1 }}> <Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 6, md: 3 }}> <Grid size={{ md: 3, xs: 6 }}>
<Typography variant="body2" color="text.secondary" align="right">Repository name</Typography> <Typography align="right" color="text.secondary" variant="body2">Repository name</Typography>
</Grid> </Grid>
<Grid size={{ xs: 6, md: 3 }}> <Grid size={{ md: 3, xs: 6 }}>
<Typography variant="body2">{status.repository}</Typography> <Typography variant="body2">{status.repository}</Typography>
</Grid> </Grid>
<Grid size={{ xs: 6, md: 3 }}> <Grid size={{ md: 3, xs: 6 }}>
<Typography variant="body2" color="text.secondary" align="right">Repository architecture</Typography> <Typography align="right" color="text.secondary" variant="body2">Repository architecture</Typography>
</Grid> </Grid>
<Grid size={{ xs: 6, md: 3 }}> <Grid size={{ md: 3, xs: 6 }}>
<Typography variant="body2">{status.architecture}</Typography> <Typography variant="body2">{status.architecture}</Typography>
</Grid> </Grid>
</Grid> </Grid>
<Grid container spacing={2} sx={{ mt: 1 }}> <Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 6, md: 3 }}> <Grid size={{ md: 3, xs: 6 }}>
<Typography variant="body2" color="text.secondary" align="right">Current status</Typography> <Typography align="right" color="text.secondary" variant="body2">Current status</Typography>
</Grid> </Grid>
<Grid size={{ xs: 6, md: 3 }}> <Grid size={{ md: 3, xs: 6 }}>
<Typography variant="body2">{status.status.status}</Typography> <Typography variant="body2">{status.status.status}</Typography>
</Grid> </Grid>
<Grid size={{ xs: 6, md: 3 }}> <Grid size={{ md: 3, xs: 6 }}>
<Typography variant="body2" color="text.secondary" align="right">Updated at</Typography> <Typography align="right" color="text.secondary" variant="body2">Updated at</Typography>
</Grid> </Grid>
<Grid size={{ xs: 6, md: 3 }}> <Grid size={{ md: 3, xs: 6 }}>
<Typography variant="body2">{new Date(status.status.timestamp * 1000).toISOStringShort()}</Typography> <Typography variant="body2">{new Date(status.status.timestamp * 1000).toISOStringShort()}</Typography>
</Grid> </Grid>
</Grid> </Grid>
<Grid container spacing={2} sx={{ mt: 2 }}> <Grid container spacing={2} sx={{ mt: 2 }}>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ md: 6, xs: 12 }}>
<Box sx={{ height: 300 }}> <Box sx={{ height: 300 }}>
<PackageCountBarChart stats={status.stats} /> <PackageCountBarChart stats={status.stats} />
</Box> </Box>
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ md: 6, xs: 12 }}>
<Box sx={{ height: 300, display: "flex", justifyContent: "center", alignItems: "center" }}> <Box sx={{ alignItems: "center", display: "flex", height: 300, justifyContent: "center" }}>
<StatusPieChart counters={status.packages} /> <StatusPieChart counters={status.packages} />
</Box> </Box>
</Grid> </Grid>

View File

@@ -35,11 +35,11 @@ import { useNotification } from "hooks/useNotification";
import React, { useState } from "react"; import React, { useState } from "react";
interface KeyImportDialogProps { interface KeyImportDialogProps {
open: boolean;
onClose: () => void; onClose: () => void;
open: boolean;
} }
export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps): React.JSX.Element { export default function KeyImportDialog({ onClose, open }: KeyImportDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
@@ -54,7 +54,7 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
onClose(); onClose();
}; };
const handleFetch: () => Promise<void> = async () => { const handleFetch = async (): Promise<void> => {
if (!fingerprint || !server) { if (!fingerprint || !server) {
return; return;
} }
@@ -67,7 +67,7 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
} }
}; };
const handleImport: () => Promise<void> = async () => { const handleImport = async (): Promise<void> => {
if (!fingerprint || !server) { if (!fingerprint || !server) {
return; return;
} }
@@ -81,38 +81,38 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
} }
}; };
return <Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth> return <Dialog fullWidth maxWidth="lg" onClose={handleClose} open={open}>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Import key from PGP server Import key from PGP server
</DialogHeader> </DialogHeader>
<DialogContent> <DialogContent>
<TextField <TextField
label="fingerprint"
placeholder="PGP key fingerprint"
fullWidth fullWidth
label="fingerprint"
margin="normal" margin="normal"
value={fingerprint}
onChange={event => setFingerprint(event.target.value)} onChange={event => setFingerprint(event.target.value)}
placeholder="PGP key fingerprint"
value={fingerprint}
/> />
<TextField <TextField
label="key server"
placeholder="PGP key server"
fullWidth fullWidth
label="key server"
margin="normal" margin="normal"
value={server}
onChange={event => setServer(event.target.value)} onChange={event => setServer(event.target.value)}
placeholder="PGP key server"
value={server}
/> />
{keyBody && {keyBody &&
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<CodeBlock content={keyBody} height={300} /> <CodeBlock height={300} content={keyBody} />
</Box> </Box>
} }
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => void handleImport()} variant="contained" startIcon={<PlayArrowIcon />}>import</Button> <Button onClick={() => void handleImport()} startIcon={<PlayArrowIcon />} variant="contained">import</Button>
<Button onClick={() => void handleFetch()} variant="contained" color="success" startIcon={<RefreshIcon />}>fetch</Button> <Button color="success" onClick={() => void handleFetch()} startIcon={<RefreshIcon />} variant="contained">fetch</Button>
</DialogActions> </DialogActions>
</Dialog>; </Dialog>;
} }

View File

@@ -36,11 +36,11 @@ import { useNotification } from "hooks/useNotification";
import React, { useState } from "react"; import React, { useState } from "react";
interface LoginDialogProps { interface LoginDialogProps {
open: boolean;
onClose: () => void; onClose: () => void;
open: boolean;
} }
export default function LoginDialog({ open, onClose }: LoginDialogProps): React.JSX.Element { export default function LoginDialog({ onClose, open }: LoginDialogProps): React.JSX.Element {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@@ -54,7 +54,7 @@ export default function LoginDialog({ open, onClose }: LoginDialogProps): React.
onClose(); onClose();
}; };
const handleSubmit: () => Promise<void> = async () => { const handleSubmit = async (): Promise<void> => {
if (!username || !password) { if (!username || !password) {
return; return;
} }
@@ -72,26 +72,24 @@ export default function LoginDialog({ open, onClose }: LoginDialogProps): React.
} }
}; };
return <Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth> return <Dialog fullWidth maxWidth="xs" onClose={handleClose} open={open}>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Login Login
</DialogHeader> </DialogHeader>
<DialogContent> <DialogContent>
<TextField <TextField
label="username"
fullWidth
margin="normal"
value={username}
onChange={event => setUsername(event.target.value)}
autoFocus autoFocus
fullWidth
label="username"
margin="normal"
onChange={event => setUsername(event.target.value)}
value={username}
/> />
<TextField <TextField
label="password"
fullWidth fullWidth
label="password"
margin="normal" margin="normal"
type={showPassword ? "text" : "password"}
value={password}
onChange={event => setPassword(event.target.value)} onChange={event => setPassword(event.target.value)}
onKeyDown={event => { onKeyDown={event => {
if (event.key === "Enter") { if (event.key === "Enter") {
@@ -102,17 +100,24 @@ export default function LoginDialog({ open, onClose }: LoginDialogProps): React.
input: { input: {
endAdornment: endAdornment:
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton aria-label={showPassword ? "Hide password" : "Show password"} onClick={() => setShowPassword(!showPassword)} edge="end" size="small"> <IconButton
aria-label={showPassword ? "Hide password" : "Show password"}
edge="end"
onClick={() => setShowPassword(!showPassword)}
size="small"
>
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />} {showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton> </IconButton>
</InputAdornment>, </InputAdornment>,
}, },
}} }}
type={showPassword ? "text" : "password"}
value={password}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => void handleSubmit()} variant="contained" startIcon={<PersonIcon />}>login</Button> <Button onClick={() => void handleSubmit()} startIcon={<PersonIcon />} variant="contained">login</Button>
</DialogActions> </DialogActions>
</Dialog>; </Dialog>;
} }

View File

@@ -52,11 +52,11 @@ interface EnvironmentVariable {
} }
interface PackageAddDialogProps { interface PackageAddDialogProps {
open: boolean;
onClose: () => void; onClose: () => void;
open: boolean;
} }
export default function PackageAddDialog({ open, onClose }: PackageAddDialogProps): React.JSX.Element { export default function PackageAddDialog({ onClose, open }: PackageAddDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const repositorySelect = useSelectedRepository(); const repositorySelect = useSelectedRepository();
@@ -77,9 +77,9 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
const debouncedSearch = useDebounce(packageName, 500); const debouncedSearch = useDebounce(packageName, 500);
const { data: searchResults = [] } = useQuery<AURPackage[]>({ const { data: searchResults = [] } = useQuery<AURPackage[]>({
queryKey: QueryKeys.search(debouncedSearch),
queryFn: () => client.service.servicePackageSearch(debouncedSearch),
enabled: debouncedSearch.length >= 3, enabled: debouncedSearch.length >= 3,
queryFn: () => client.service.servicePackageSearch(debouncedSearch),
queryKey: QueryKeys.search(debouncedSearch),
}); });
const handleSubmit = async (action: "add" | "request"): Promise<void> => { const handleSubmit = async (action: "add" | "request"): Promise<void> => {
@@ -107,7 +107,7 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
} }
}; };
return <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth> return <Dialog fullWidth maxWidth="md" onClose={handleClose} open={open}>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Add new packages Add new packages
</DialogHeader> </DialogHeader>
@@ -117,20 +117,18 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
<Autocomplete <Autocomplete
freeSolo freeSolo
options={searchResults.map(pkg => pkg.package)}
inputValue={packageName} inputValue={packageName}
onInputChange={(_, value) => setPackageName(value)} onInputChange={(_, value) => setPackageName(value)}
options={searchResults.map(pkg => pkg.package)}
renderInput={params =>
<TextField {...params} label="package" margin="normal" placeholder="AUR package" />
}
renderOption={(props, option) => { renderOption={(props, option) => {
const pkg = searchResults.find(pkg => pkg.package === option); const pkg = searchResults.find(pkg => pkg.package === option);
return ( return <li {...props} key={option}>
<li {...props} key={option}> {option}{pkg ? ` (${pkg.description})` : ""}
{option}{pkg ? ` (${pkg.description})` : ""} </li>;
</li>
);
}} }}
renderInput={params =>
<TextField {...params} label="package" placeholder="AUR package" margin="normal" />
}
/> />
<FormControlLabel <FormControlLabel
@@ -140,45 +138,50 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
<Button <Button
fullWidth fullWidth
variant="outlined"
startIcon={<AddIcon />}
onClick={() => { onClick={() => {
const id = variableIdCounter.current++; const id = variableIdCounter.current++;
setEnvironmentVariables(prev => [...prev, { id, key: "", value: "" }]); setEnvironmentVariables(prev => [...prev, { id, key: "", value: "" }]);
}} }}
startIcon={<AddIcon />}
sx={{ mt: 1 }} sx={{ mt: 1 }}
variant="outlined"
> >
add environment variable add environment variable
</Button> </Button>
{environmentVariables.map(variable => {environmentVariables.map(variable =>
<Box key={variable.id} sx={{ display: "flex", gap: 1, mt: 1, alignItems: "center" }}> <Box key={variable.id} sx={{ alignItems: "center", display: "flex", gap: 1, mt: 1 }}>
<TextField <TextField
size="small"
placeholder="name"
value={variable.key}
onChange={event => { onChange={event => {
const newKey = event.target.value; const newKey = event.target.value;
setEnvironmentVariables(prev => setEnvironmentVariables(prev =>
prev.map(entry => entry.id === variable.id ? { ...entry, key: newKey } : entry), prev.map(entry => entry.id === variable.id ? { ...entry, key: newKey } : entry),
); );
}} }}
placeholder="name"
size="small"
sx={{ flex: 1 }} sx={{ flex: 1 }}
value={variable.key}
/> />
<Box>=</Box> <Box>=</Box>
<TextField <TextField
size="small"
placeholder="value" placeholder="value"
value={variable.value}
onChange={event => { onChange={event => {
const newValue = event.target.value; const newValue = event.target.value;
setEnvironmentVariables(prev => setEnvironmentVariables(prev =>
prev.map(entry => entry.id === variable.id ? { ...entry, value: newValue } : entry), prev.map(entry => entry.id === variable.id ? { ...entry, value: newValue } : entry),
); );
}} }}
size="small"
sx={{ flex: 1 }} sx={{ flex: 1 }}
value={variable.value}
/> />
<IconButton size="small" color="error" aria-label="Remove variable" onClick={() => setEnvironmentVariables(prev => prev.filter(entry => entry.id !== variable.id))}> <IconButton
aria-label="Remove variable"
color="error"
onClick={() => setEnvironmentVariables(prev => prev.filter(entry => entry.id !== variable.id))}
size="small"
>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
</Box>, </Box>,
@@ -186,8 +189,8 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => void handleSubmit("add")} variant="contained" startIcon={<PlayArrowIcon />}>add</Button> <Button onClick={() => void handleSubmit("add")} startIcon={<PlayArrowIcon />} variant="contained">add</Button>
<Button onClick={() => void handleSubmit("request")} variant="contained" color="success" startIcon={<AddIcon />}>request</Button> <Button color="success" onClick={() => void handleSubmit("request")} startIcon={<AddIcon />} variant="contained">request</Button>
</DialogActions> </DialogActions>
</Dialog>; </Dialog>;
} }

View File

@@ -45,17 +45,17 @@ import { StatusHeaderStyles } from "theme/StatusColors";
import { defaultInterval } from "utils"; import { defaultInterval } from "utils";
interface PackageInfoDialogProps { interface PackageInfoDialogProps {
packageBase: string | null;
open: boolean;
onClose: () => void;
autoRefreshIntervals: AutoRefreshInterval[]; autoRefreshIntervals: AutoRefreshInterval[];
onClose: () => void;
open: boolean;
packageBase: string | null;
} }
export default function PackageInfoDialog({ export default function PackageInfoDialog({
packageBase,
open,
onClose,
autoRefreshIntervals, autoRefreshIntervals,
onClose,
open,
packageBase,
}: PackageInfoDialogProps): React.JSX.Element { }: PackageInfoDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { currentRepository } = useRepository(); const { currentRepository } = useRepository();
@@ -80,32 +80,32 @@ export default function PackageInfoDialog({
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals)); const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packageData } = useQuery<PackageStatus[]>({ const { data: packageData } = useQuery<PackageStatus[]>({
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"], enabled: open,
queryFn: localPackageBase && currentRepository ? queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken, () => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
enabled: open, queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: dependencies } = useQuery<Dependencies>({ const { data: dependencies } = useQuery<Dependencies>({
queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"], enabled: open,
queryFn: localPackageBase && currentRepository ? queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken, () => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken,
enabled: open, queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"],
}); });
const { data: patches = [] } = useQuery<Patch[]>({ const { data: patches = [] } = useQuery<Patch[]>({
queryKey: localPackageBase ? QueryKeys.patches(localPackageBase) : ["patches"],
queryFn: localPackageBase ? () => client.fetch.fetchPackagePatches(localPackageBase) : skipToken,
enabled: open, enabled: open,
queryFn: localPackageBase ? () => client.fetch.fetchPackagePatches(localPackageBase) : skipToken,
queryKey: localPackageBase ? QueryKeys.patches(localPackageBase) : ["patches"],
}); });
const description: PackageStatus | undefined = packageData?.[0]; const description = packageData?.[0];
const pkg = description?.package; const pkg = description?.package;
const status = description?.status; const status = description?.status;
const headerStyle = status ? StatusHeaderStyles[status.status] : {}; const headerStyle = status ? StatusHeaderStyles[status.status] : {};
const handleUpdate: () => Promise<void> = async () => { const handleUpdate = async (): Promise<void> => {
if (!localPackageBase || !currentRepository) { if (!localPackageBase || !currentRepository) {
return; return;
} }
@@ -118,7 +118,7 @@ export default function PackageInfoDialog({
} }
}; };
const handleRemove: () => Promise<void> = async () => { const handleRemove = async (): Promise<void> => {
if (!localPackageBase || !currentRepository) { if (!localPackageBase || !currentRepository) {
return; return;
} }
@@ -131,20 +131,20 @@ export default function PackageInfoDialog({
} }
}; };
const handleHoldToggle: () => Promise<void> = async () => { const handleHoldToggle = async (): Promise<void> => {
if (!localPackageBase || !currentRepository) { if (!localPackageBase || !currentRepository) {
return; return;
} }
try { try {
const newHeldStatus = !(status?.is_held ?? false); const newHeldStatus = !(status?.is_held ?? false);
await client.service.servicePackageHoldUpdate(localPackageBase, currentRepository, newHeldStatus); await client.service.servicePackageHold(localPackageBase, currentRepository, newHeldStatus);
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(localPackageBase, currentRepository) }); void queryClient.invalidateQueries({ queryKey: QueryKeys.package(localPackageBase, currentRepository) });
} catch (exception) { } catch (exception) {
showError("Action failed", `Could not update hold status: ${ApiError.errorDetail(exception)}`); showError("Action failed", `Could not update hold status: ${ApiError.errorDetail(exception)}`);
} }
}; };
const handleDeletePatch: (key: string) => Promise<void> = async key => { const handleDeletePatch = async (key: string): Promise<void> => {
if (!localPackageBase) { if (!localPackageBase) {
return; return;
} }
@@ -156,7 +156,7 @@ export default function PackageInfoDialog({
} }
}; };
return <Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth> return <Dialog fullWidth maxWidth="lg" onClose={handleClose} open={open}>
<DialogHeader onClose={handleClose} sx={headerStyle}> <DialogHeader onClose={handleClose} sx={headerStyle}>
{pkg && status {pkg && status
? `${pkg.base} ${status.status} at ${new Date(status.timestamp * 1000).toISOStringShort()}` ? `${pkg.base} ${status.status} at ${new Date(status.timestamp * 1000).toISOStringShort()}`
@@ -166,24 +166,24 @@ export default function PackageInfoDialog({
<DialogContent> <DialogContent>
{pkg && {pkg &&
<> <>
<PackageDetailsGrid pkg={pkg} dependencies={dependencies} /> <PackageDetailsGrid dependencies={dependencies} pkg={pkg} />
<PackagePatchesList <PackagePatchesList
patches={patches}
editable={isAuthorized} editable={isAuthorized}
onDelete={key => void handleDeletePatch(key)} onDelete={key => void handleDeletePatch(key)}
patches={patches}
/> />
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}> <Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
<Tabs value={activeTab} onChange={(_, tab: TabKey) => setActiveTab(tab)}> <Tabs onChange={(_, tab: TabKey) => setActiveTab(tab)} value={activeTab}>
{tabs.map(({ key, label }) => <Tab key={key} value={key} label={label} />)} {tabs.map(({ key, label }) => <Tab key={key} label={label} value={key} />)}
</Tabs> </Tabs>
</Box> </Box>
{activeTab === "logs" && localPackageBase && currentRepository && {activeTab === "logs" && localPackageBase && currentRepository &&
<BuildLogsTab <BuildLogsTab
packageBase={localPackageBase} packageBase={localPackageBase}
repository={currentRepository}
refreshInterval={autoRefresh.interval} refreshInterval={autoRefresh.interval}
repository={currentRepository}
/> />
} }
{activeTab === "changes" && localPackageBase && currentRepository && {activeTab === "changes" && localPackageBase && currentRepository &&
@@ -197,25 +197,26 @@ export default function PackageInfoDialog({
} }
{activeTab === "artifacts" && localPackageBase && currentRepository && {activeTab === "artifacts" && localPackageBase && currentRepository &&
<ArtifactsTab <ArtifactsTab
currentVersion={pkg.version}
packageBase={localPackageBase} packageBase={localPackageBase}
repository={currentRepository} repository={currentRepository}
currentVersion={pkg.version} /> />
} }
</> </>
} }
</DialogContent> </DialogContent>
<PackageInfoActions <PackageInfoActions
isAuthorized={isAuthorized}
refreshDatabase={refreshDatabase}
onRefreshDatabaseChange={setRefreshDatabase}
isHeld={status?.is_held ?? false}
onHoldToggle={() => void handleHoldToggle()}
onUpdate={() => void handleUpdate()}
onRemove={() => void handleRemove()}
autoRefreshIntervals={autoRefreshIntervals}
autoRefreshInterval={autoRefresh.interval} autoRefreshInterval={autoRefresh.interval}
autoRefreshIntervals={autoRefreshIntervals}
isAuthorized={isAuthorized}
isHeld={status?.is_held ?? false}
onAutoRefreshIntervalChange={autoRefresh.setInterval} onAutoRefreshIntervalChange={autoRefresh.setInterval}
onHoldToggle={() => void handleHoldToggle()}
onRefreshDatabaseChange={setRefreshDatabase}
onRemove={() => void handleRemove()}
onUpdate={() => void handleUpdate()}
refreshDatabase={refreshDatabase}
/> />
</Dialog>; </Dialog>;
} }

View File

@@ -28,11 +28,11 @@ import { useSelectedRepository } from "hooks/useSelectedRepository";
import React, { useState } from "react"; import React, { useState } from "react";
interface PackageRebuildDialogProps { interface PackageRebuildDialogProps {
open: boolean;
onClose: () => void; onClose: () => void;
open: boolean;
} }
export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDialogProps): React.JSX.Element { export default function PackageRebuildDialog({ onClose, open }: PackageRebuildDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const repositorySelect = useSelectedRepository(); const repositorySelect = useSelectedRepository();
@@ -45,7 +45,7 @@ export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDi
onClose(); onClose();
}; };
const handleRebuild: () => Promise<void> = async () => { const handleRebuild = async (): Promise<void> => {
if (!dependency) { if (!dependency) {
return; return;
} }
@@ -63,7 +63,7 @@ export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDi
} }
}; };
return <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth> return <Dialog fullWidth maxWidth="md" onClose={handleClose} open={open}>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Rebuild depending packages Rebuild depending packages
</DialogHeader> </DialogHeader>
@@ -72,17 +72,17 @@ export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDi
<RepositorySelect repositorySelect={repositorySelect} /> <RepositorySelect repositorySelect={repositorySelect} />
<TextField <TextField
label="dependency"
placeholder="packages dependency"
fullWidth fullWidth
label="dependency"
margin="normal" margin="normal"
value={dependency} placeholder="packages dependency"
onChange={event => setDependency(event.target.value)} onChange={event => setDependency(event.target.value)}
value={dependency}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => void handleRebuild()} variant="contained" startIcon={<PlayArrowIcon />}>rebuild</Button> <Button onClick={() => void handleRebuild()} startIcon={<PlayArrowIcon />} variant="contained">rebuild</Button>
</DialogActions> </DialogActions>
</Dialog>; </Dialog>;
} }

View File

@@ -41,8 +41,8 @@ export default function AppLayout(): React.JSX.Element {
const [loginOpen, setLoginOpen] = useState(false); const [loginOpen, setLoginOpen] = useState(false);
const { data: info } = useQuery<InfoResponse>({ const { data: info } = useQuery<InfoResponse>({
queryKey: QueryKeys.info,
queryFn: () => client.fetch.fetchServerInfo(), queryFn: () => client.fetch.fetchServerInfo(),
queryKey: QueryKeys.info,
staleTime: Infinity, staleTime: Infinity,
}); });
@@ -55,9 +55,9 @@ export default function AppLayout(): React.JSX.Element {
}, [info, setAuthState, setRepositories]); }, [info, setAuthState, setRepositories]);
return <Container maxWidth="xl"> return <Container maxWidth="xl">
<Box sx={{ display: "flex", alignItems: "center", py: 1, gap: 1 }}> <Box sx={{ alignItems: "center", display: "flex", gap: 1, py: 1 }}>
<a href="https://ahriman.readthedocs.io/" title="logo"> <a href="https://ahriman.readthedocs.io/" title="logo">
<img src="/static/logo.svg" width={30} height={30} alt="" /> <img alt="" height={30} src="/static/logo.svg" width={30} />
</a> </a>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Navbar /> <Navbar />
@@ -69,17 +69,15 @@ export default function AppLayout(): React.JSX.Element {
</Tooltip> </Tooltip>
</Box> </Box>
<PackageTable <PackageTable autoRefreshIntervals={info?.autorefresh_intervals ?? []} />
autoRefreshIntervals={info?.autorefresh_intervals ?? []}
/>
<Footer <Footer
version={info?.version ?? ""}
docsEnabled={info?.docs_enabled ?? false} docsEnabled={info?.docs_enabled ?? false}
indexUrl={info?.index_url} indexUrl={info?.index_url}
onLoginClick={() => info?.auth.external ? window.location.assign("/api/v1/login") : setLoginOpen(true)} onLoginClick={() => info?.auth.external ? window.location.assign("/api/v1/login") : setLoginOpen(true)}
version={info?.version ?? ""}
/> />
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} /> <LoginDialog onClose={() => setLoginOpen(false)} open={loginOpen} />
</Container>; </Container>;
} }

View File

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

View File

@@ -35,15 +35,15 @@ export default function Navbar(): React.JSX.Element | null {
return <Box sx={{ borderBottom: 1, borderColor: "divider" }}> return <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs <Tabs
value={currentIndex >= 0 ? currentIndex : 0}
onChange={(_, newValue: number) => { onChange={(_, newValue: number) => {
const repository = repositories[newValue]; const repository = repositories[newValue];
if (repository) { if (repository) {
setCurrentRepository(repository); setCurrentRepository(repository);
} }
}} }}
variant="scrollable"
scrollButtons="auto" scrollButtons="auto"
value={currentIndex >= 0 ? currentIndex : 0}
variant="scrollable"
> >
{repositories.map(repository => {repositories.map(repository =>
<Tab <Tab

View File

@@ -19,7 +19,7 @@
*/ */
import RestoreIcon from "@mui/icons-material/Restore"; import RestoreIcon from "@mui/icons-material/Restore";
import { Box, IconButton, Tooltip } from "@mui/material"; import { Box, IconButton, Tooltip } from "@mui/material";
import { DataGrid, type GridColDef, type GridRenderCellParams } from "@mui/x-data-grid"; import { DataGrid, type GridColDef } from "@mui/x-data-grid";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "api/client/ApiError"; import { ApiError } from "api/client/ApiError";
import { QueryKeys } from "hooks/QueryKeys"; import { QueryKeys } from "hooks/QueryKeys";
@@ -29,36 +29,37 @@ import { useNotification } from "hooks/useNotification";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
import type React from "react"; import type React from "react";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { DETAIL_TABLE_PROPS } from "utils";
interface ArtifactsTabProps { interface ArtifactsTabProps {
currentVersion: string;
packageBase: string; packageBase: string;
repository: RepositoryId; repository: RepositoryId;
currentVersion: string;
} }
interface ArtifactRow { interface ArtifactRow {
id: string; id: string;
version: string;
packager: string; packager: string;
packages: string[]; packages: string[];
version: string;
} }
const staticColumns: GridColDef<ArtifactRow>[] = [ const staticColumns: GridColDef<ArtifactRow>[] = [
{ field: "version", headerName: "version", flex: 1, align: "right", headerAlign: "right" }, { align: "right", field: "version", flex: 1, headerAlign: "right", headerName: "version" },
{ {
field: "packages", field: "packages",
headerName: "packages",
flex: 2, flex: 2,
renderCell: (params: GridRenderCellParams<ArtifactRow>) => headerName: "packages",
renderCell: params =>
<Box sx={{ whiteSpace: "pre-line" }}>{params.row.packages.join("\n")}</Box>, <Box sx={{ whiteSpace: "pre-line" }}>{params.row.packages.join("\n")}</Box>,
}, },
{ field: "packager", headerName: "packager", flex: 1 }, { field: "packager", flex: 1, headerName: "packager" },
]; ];
export default function ArtifactsTab({ export default function ArtifactsTab({
currentVersion,
packageBase, packageBase,
repository, repository,
currentVersion,
}: ArtifactsTabProps): React.JSX.Element { }: ArtifactsTabProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -66,17 +67,17 @@ export default function ArtifactsTab({
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const { data: rows = [] } = useQuery<ArtifactRow[]>({ const { data: rows = [] } = useQuery<ArtifactRow[]>({
queryKey: QueryKeys.artifacts(packageBase, repository), enabled: !!packageBase,
queryFn: async () => { queryFn: async () => {
const packages = await client.fetch.fetchPackageArtifacts(packageBase, repository); const packages = await client.fetch.fetchPackageArtifacts(packageBase, repository);
return packages.map(artifact => ({ return packages.map(artifact => ({
id: artifact.version, id: artifact.version,
version: artifact.version,
packager: artifact.packager ?? "", packager: artifact.packager ?? "",
packages: Object.keys(artifact.packages).sort(), packages: Object.keys(artifact.packages).sort(),
version: artifact.version,
})).reverse(); })).reverse();
}, },
enabled: !!packageBase, queryKey: QueryKeys.artifacts(packageBase, repository),
}); });
const handleRollback = useCallback(async (version: string): Promise<void> => { const handleRollback = useCallback(async (version: string): Promise<void> => {
@@ -96,32 +97,23 @@ export default function ArtifactsTab({
field: "actions", field: "actions",
filterable: false, filterable: false,
headerName: "", headerName: "",
width: 60, renderCell: params =>
renderCell: (params: GridRenderCellParams<ArtifactRow>) =>
<Tooltip title={params.row.version === currentVersion ? "Current version" : "Rollback to this version"}> <Tooltip title={params.row.version === currentVersion ? "Current version" : "Rollback to this version"}>
<span> <span>
<IconButton <IconButton
size="small"
disabled={params.row.version === currentVersion} disabled={params.row.version === currentVersion}
onClick={() => void handleRollback(params.row.version)} onClick={() => void handleRollback(params.row.version)}
size="small"
> >
<RestoreIcon fontSize="small" /> <RestoreIcon fontSize="small" />
</IconButton> </IconButton>
</span> </span>
</Tooltip>, </Tooltip>,
width: 60,
} satisfies GridColDef<ArtifactRow>] : [], } satisfies GridColDef<ArtifactRow>] : [],
], [isAuthorized, currentVersion, handleRollback]); ], [isAuthorized, currentVersion, handleRollback]);
return <Box sx={{ mt: 1 }}> return <Box sx={{ mt: 1 }}>
<DataGrid <DataGrid columns={columns} getRowHeight={() => "auto"} rows={rows} {...DETAIL_TABLE_PROPS} />
rows={rows}
columns={columns}
density="compact"
disableColumnSorting
disableRowSelectionOnClick
getRowHeight={() => "auto"}
pageSizeOptions={[10, 25]}
sx={{ height: 400, mt: 1 }}
/>
</Box>; </Box>;
} }

View File

@@ -29,16 +29,16 @@ import type { RepositoryId } from "models/RepositoryId";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
interface Logs { interface Logs {
version: string;
processId: string;
created: number; created: number;
logs: string; logs: string;
processId: string;
version: string;
} }
interface BuildLogsTabProps { interface BuildLogsTabProps {
packageBase: string; packageBase: string;
repository: RepositoryId;
refreshInterval: number; refreshInterval: number;
repository: RepositoryId;
} }
function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string { function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string {
@@ -50,17 +50,17 @@ function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boole
export default function BuildLogsTab({ export default function BuildLogsTab({
packageBase, packageBase,
repository,
refreshInterval, refreshInterval,
repository,
}: BuildLogsTabProps): React.JSX.Element { }: BuildLogsTabProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null); const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const { data: allLogs } = useQuery<LogRecord[]>({ const { data: allLogs } = useQuery<LogRecord[]>({
queryKey: QueryKeys.logs(packageBase, repository),
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
enabled: !!packageBase, enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
queryKey: QueryKeys.logs(packageBase, repository),
refetchInterval: refreshInterval > 0 ? refreshInterval : false, refetchInterval: refreshInterval > 0 ? refreshInterval : false,
}); });
@@ -84,13 +84,13 @@ export default function BuildLogsTab({
return Object.values(grouped) return Object.values(grouped)
.sort((left, right) => right.minCreated - left.minCreated) .sort((left, right) => right.minCreated - left.minCreated)
.map(record => ({ .map(record => ({
version: record.version,
processId: record.process_id,
created: record.minCreated, created: record.minCreated,
logs: convertLogs( logs: convertLogs(
allLogs, allLogs,
right => record.version === right.version && record.process_id === right.process_id, right => record.version === right.version && record.process_id === right.process_id,
), ),
processId: record.process_id,
version: record.version,
})); }));
}, [allLogs]); }, [allLogs]);
@@ -110,13 +110,13 @@ export default function BuildLogsTab({
// Refresh active version logs // Refresh active version logs
const { data: versionLogs } = useQuery<LogRecord[]>({ const { data: versionLogs } = useQuery<LogRecord[]>({
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""), placeholderData: keepPreviousData,
queryFn: activeVersion queryFn: activeVersion
? () => client.fetch.fetchPackageLogs( ? () => client.fetch.fetchPackageLogs(
packageBase, repository, activeVersion.version, activeVersion.processId, packageBase, repository, activeVersion.version, activeVersion.processId,
) )
: skipToken, : skipToken,
placeholderData: keepPreviousData, queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
refetchInterval: refreshInterval > 0 ? refreshInterval : false, refetchInterval: refreshInterval > 0 ? refreshInterval : false,
}); });
@@ -143,25 +143,25 @@ export default function BuildLogsTab({
return <Box sx={{ display: "flex", gap: 1, mt: 1 }}> return <Box sx={{ display: "flex", gap: 1, mt: 1 }}>
<Box> <Box>
<Button <Button
size="small"
aria-label="Select version" aria-label="Select version"
startIcon={<ListIcon />}
onClick={event => setAnchorEl(event.currentTarget)} onClick={event => setAnchorEl(event.currentTarget)}
size="small"
startIcon={<ListIcon />}
/> />
<Menu <Menu
anchorEl={anchorEl} anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)} onClose={() => setAnchorEl(null)}
open={Boolean(anchorEl)}
> >
{versions.map((logs, index) => {versions.map((logs, index) =>
<MenuItem <MenuItem
key={`${logs.version}-${logs.processId}`} key={`${logs.version}-${logs.processId}`}
selected={index === activeIndex}
onClick={() => { onClick={() => {
setSelectedVersionKey(`${logs.version}-${logs.processId}`); setSelectedVersionKey(`${logs.version}-${logs.processId}`);
setAnchorEl(null); setAnchorEl(null);
resetScroll(); resetScroll();
}} }}
selected={index === activeIndex}
> >
<Typography variant="body2">{new Date(logs.created * 1000).toISOStringShort()}</Typography> <Typography variant="body2">{new Date(logs.created * 1000).toISOStringShort()}</Typography>
</MenuItem>, </MenuItem>,
@@ -174,10 +174,10 @@ export default function BuildLogsTab({
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<CodeBlock <CodeBlock
preRef={preRef}
content={displayedLogs} content={displayedLogs}
height={400} height={400}
onScroll={handleScroll} onScroll={handleScroll}
preRef={preRef}
/> />
</Box> </Box>
</Box>; </Box>;

View File

@@ -30,5 +30,5 @@ interface ChangesTabProps {
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element { export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
const data = usePackageChanges(packageBase, repository); const data = usePackageChanges(packageBase, repository);
return <CodeBlock language="diff" content={data?.changes ?? ""} height={400} />; return <CodeBlock content={data?.changes ?? ""} height={400} language="diff" />;
} }

View File

@@ -27,6 +27,7 @@ import type { Event } from "models/Event";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
import type React from "react"; import type React from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { DETAIL_TABLE_PROPS } from "utils";
interface EventsTabProps { interface EventsTabProps {
packageBase: string; packageBase: string;
@@ -34,44 +35,36 @@ interface EventsTabProps {
} }
interface EventRow { interface EventRow {
id: number;
timestamp: string;
event: string; event: string;
id: number;
message: string; message: string;
timestamp: string;
} }
const columns: GridColDef<EventRow>[] = [ const columns: GridColDef<EventRow>[] = [
{ field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" }, { align: "right", field: "timestamp", headerAlign: "right", headerName: "date", width: 180 },
{ field: "event", headerName: "event", flex: 1 }, { field: "event", flex: 1, headerName: "event" },
{ field: "message", headerName: "description", flex: 2 }, { field: "message", flex: 2, headerName: "description" },
]; ];
export default function EventsTab({ packageBase, repository }: EventsTabProps): React.JSX.Element { export default function EventsTab({ packageBase, repository }: EventsTabProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { data: events = [] } = useQuery<Event[]>({ const { data: events = [] } = useQuery<Event[]>({
queryKey: QueryKeys.events(repository, packageBase),
queryFn: () => client.fetch.fetchPackageEvents(repository, packageBase, 30),
enabled: !!packageBase, enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageEvents(repository, packageBase, 30),
queryKey: QueryKeys.events(repository, packageBase),
}); });
const rows = useMemo<EventRow[]>(() => events.map((event, index) => ({ const rows = useMemo<EventRow[]>(() => events.map((event, index) => ({
id: index,
timestamp: new Date(event.created * 1000).toISOStringShort(),
event: event.event, event: event.event,
id: index,
message: event.message ?? "", message: event.message ?? "",
timestamp: new Date(event.created * 1000).toISOStringShort(),
})), [events]); })), [events]);
return <Box sx={{ mt: 1 }}> return <Box sx={{ mt: 1 }}>
<EventDurationLineChart events={events} /> <EventDurationLineChart events={events} />
<DataGrid <DataGrid columns={columns} rows={rows} {...DETAIL_TABLE_PROPS} />
rows={rows}
columns={columns}
density="compact"
disableColumnSorting
disableRowSelectionOnClick
pageSizeOptions={[10, 25]}
sx={{ height: 400, mt: 1 }}
/>
</Box>; </Box>;
} }

View File

@@ -23,11 +23,11 @@ import type { Package } from "models/Package";
import React from "react"; import React from "react";
interface PackageDetailsGridProps { interface PackageDetailsGridProps {
pkg: Package;
dependencies?: Dependencies; dependencies?: Dependencies;
pkg: Package;
} }
export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetailsGridProps): React.JSX.Element { export default function PackageDetailsGrid({ dependencies, pkg }: PackageDetailsGridProps): React.JSX.Element {
const packagesList = Object.entries(pkg.packages) const packagesList = Object.entries(pkg.packages)
.map(([name, properties]) => `${name}${properties.description ? ` (${properties.description})` : ""}`); .map(([name, properties]) => `${name}${properties.description ? ` (${properties.description})` : ""}`);
@@ -65,50 +65,50 @@ export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetails
return <> return <>
<Grid container spacing={1} sx={{ mt: 1 }}> <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={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">packages</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{packagesList.unique().join("\n")}</Typography></Grid> <Grid size={{ md: 5, xs: 8 }}><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={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">version</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.version}</Typography></Grid> <Grid size={{ md: 5, xs: 8 }}><Typography variant="body2">{pkg.version}</Typography></Grid>
</Grid> </Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}> <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={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">packager</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid> <Grid size={{ md: 5, xs: 8 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
<Grid size={{ xs: 4, md: 1 }} /> <Grid size={{ md: 1, xs: 4 }} />
<Grid size={{ xs: 8, md: 5 }} /> <Grid size={{ md: 5, xs: 8 }} />
</Grid> </Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}> <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={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">groups</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{groups.unique().join("\n")}</Typography></Grid> <Grid size={{ md: 5, xs: 8 }}><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={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">licenses</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{licenses.unique().join("\n")}</Typography></Grid> <Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{licenses.unique().join("\n")}</Typography></Grid>
</Grid> </Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}> <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={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">upstream</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}> <Grid size={{ md: 5, xs: 8 }}>
{upstreamUrls.map(url => {upstreamUrls.map(url =>
<Link key={url} href={url} target="_blank" rel="noopener noreferrer" underline="hover" display="block" variant="body2"> <Link display="block" href={url} key={url} rel="noopener noreferrer" target="_blank" underline="hover" variant="body2">
{url} {url}
</Link>, </Link>,
)} )}
</Grid> </Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid> <Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">AUR</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}> <Grid size={{ md: 5, xs: 8 }}>
<Typography variant="body2"> <Typography variant="body2">
{aurUrl && {aurUrl &&
<Link href={aurUrl} target="_blank" rel="noopener noreferrer" underline="hover">AUR link</Link> <Link href={aurUrl} rel="noopener noreferrer" target="_blank" underline="hover">AUR link</Link>
} }
</Typography> </Typography>
</Grid> </Grid>
</Grid> </Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}> <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={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">depends</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{allDepends.join("\n")}</Typography></Grid> <Grid size={{ md: 5, xs: 8 }}><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={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">implicitly depends</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{implicitDepends.unique().join("\n")}</Typography></Grid> <Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{implicitDepends.unique().join("\n")}</Typography></Grid>
</Grid> </Grid>
</>; </>;
} }

View File

@@ -27,29 +27,29 @@ import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type React from "react"; import type React from "react";
interface PackageInfoActionsProps { interface PackageInfoActionsProps {
autoRefreshInterval: number;
autoRefreshIntervals: AutoRefreshInterval[];
isAuthorized: boolean; isAuthorized: boolean;
isHeld: boolean; isHeld: boolean;
onHoldToggle: () => void;
refreshDatabase: boolean;
onRefreshDatabaseChange: (checked: boolean) => void;
onUpdate: () => void;
onRemove: () => void;
autoRefreshIntervals: AutoRefreshInterval[];
autoRefreshInterval: number;
onAutoRefreshIntervalChange: (interval: number) => void; onAutoRefreshIntervalChange: (interval: number) => void;
onHoldToggle: () => void;
onRefreshDatabaseChange: (checked: boolean) => void;
onRemove: () => void;
onUpdate: () => void;
refreshDatabase: boolean;
} }
export default function PackageInfoActions({ export default function PackageInfoActions({
isAuthorized,
refreshDatabase,
onRefreshDatabaseChange,
isHeld,
onHoldToggle,
onUpdate,
onRemove,
autoRefreshIntervals,
autoRefreshInterval, autoRefreshInterval,
autoRefreshIntervals,
isAuthorized,
isHeld,
onAutoRefreshIntervalChange, onAutoRefreshIntervalChange,
onHoldToggle,
onRefreshDatabaseChange,
onRemove,
onUpdate,
refreshDatabase,
}: PackageInfoActionsProps): React.JSX.Element { }: PackageInfoActionsProps): React.JSX.Element {
return <DialogActions sx={{ flexWrap: "wrap", gap: 1 }}> return <DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
{isAuthorized && {isAuthorized &&
@@ -58,20 +58,20 @@ export default function PackageInfoActions({
control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />} control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />}
label="update pacman databases" label="update pacman databases"
/> />
<Button onClick={onHoldToggle} variant="outlined" color="warning" startIcon={isHeld ? <PlayCircleIcon /> : <PauseCircleIcon />} size="small"> <Button color="warning" onClick={onHoldToggle} size="small" startIcon={isHeld ? <PlayCircleIcon /> : <PauseCircleIcon />} variant="outlined">
{isHeld ? "unhold" : "hold"} {isHeld ? "unhold" : "hold"}
</Button> </Button>
<Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small"> <Button color="success" onClick={onUpdate} size="small" startIcon={<PlayArrowIcon />} variant="contained">
update update
</Button> </Button>
<Button onClick={onRemove} variant="contained" color="error" startIcon={<DeleteIcon />} size="small"> <Button color="error" onClick={onRemove} size="small" startIcon={<DeleteIcon />} variant="contained">
remove remove
</Button> </Button>
</> </>
} }
<AutoRefreshControl <AutoRefreshControl
intervals={autoRefreshIntervals}
currentInterval={autoRefreshInterval} currentInterval={autoRefreshInterval}
intervals={autoRefreshIntervals}
onIntervalChange={onAutoRefreshIntervalChange} onIntervalChange={onAutoRefreshIntervalChange}
/> />
</DialogActions>; </DialogActions>;

View File

@@ -23,39 +23,39 @@ import type { Patch } from "models/Patch";
import type React from "react"; import type React from "react";
interface PackagePatchesListProps { interface PackagePatchesListProps {
patches: Patch[];
editable: boolean; editable: boolean;
onDelete: (key: string) => void; onDelete: (key: string) => void;
patches: Patch[];
} }
export default function PackagePatchesList({ export default function PackagePatchesList({
patches,
editable, editable,
onDelete, onDelete,
patches,
}: PackagePatchesListProps): React.JSX.Element | null { }: PackagePatchesListProps): React.JSX.Element | null {
if (patches.length === 0) { if (patches.length === 0) {
return null; return null;
} }
return <Box sx={{ mt: 2 }}> return <Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom>Environment variables</Typography> <Typography gutterBottom variant="h6">Environment variables</Typography>
{patches.map(patch => {patches.map(patch =>
<Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}> <Box key={patch.key} sx={{ alignItems: "center", display: "flex", gap: 1, mb: 0.5 }}>
<TextField <TextField
size="small"
value={patch.key}
disabled disabled
size="small"
sx={{ flex: 1 }} sx={{ flex: 1 }}
value={patch.key}
/> />
<Box>=</Box> <Box>=</Box>
<TextField <TextField
size="small"
value={JSON.stringify(patch.value)}
disabled disabled
value={JSON.stringify(patch.value)}
size="small"
sx={{ flex: 1 }} sx={{ flex: 1 }}
/> />
{editable && {editable &&
<IconButton size="small" color="error" aria-label="Remove patch" onClick={() => onDelete(patch.key)}> <IconButton aria-label="Remove patch" color="error" onClick={() => onDelete(patch.key)} size="small">
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
} }

View File

@@ -30,5 +30,5 @@ interface PkgbuildTabProps {
export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element { export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element {
const data = usePackageChanges(packageBase, repository); const data = usePackageChanges(packageBase, repository);
return <CodeBlock language="bash" content={data?.pkgbuild ?? ""} height={400} />; return <CodeBlock content={data?.pkgbuild ?? ""} height={400} language="bash" />;
} }

View File

@@ -23,7 +23,6 @@ import {
GRID_CHECKBOX_SELECTION_COL_DEF, GRID_CHECKBOX_SELECTION_COL_DEF,
type GridColDef, type GridColDef,
type GridFilterModel, type GridFilterModel,
type GridRenderCellParams,
type GridRowId, type GridRowId,
useGridApiRef, useGridApiRef,
} from "@mui/x-data-grid"; } from "@mui/x-data-grid";
@@ -44,8 +43,6 @@ interface PackageTableProps {
autoRefreshIntervals: AutoRefreshInterval[]; autoRefreshIntervals: AutoRefreshInterval[];
} }
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
function createListColumn( function createListColumn(
field: keyof PackageRow, field: keyof PackageRow,
headerName: string, headerName: string,
@@ -55,10 +52,10 @@ function createListColumn(
field, field,
headerName, headerName,
...options, ...options,
valueGetter: (value: string[]) => (value ?? []).join(" "), renderCell: params =>
renderCell: (params: GridRenderCellParams<PackageRow>) =>
<Box sx={{ whiteSpace: "pre-line" }}>{((params.row[field] as string[]) ?? []).join("\n")}</Box>, <Box sx={{ whiteSpace: "pre-line" }}>{((params.row[field] as string[]) ?? []).join("\n")}</Box>,
sortComparator: (left: string, right: string) => left.localeCompare(right), sortComparator: (left: string, right: string) => left.localeCompare(right),
valueGetter: (value: string[]) => (value ?? []).join(" "),
}; };
} }
@@ -79,36 +76,30 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
() => [ () => [
{ {
field: "base", field: "base",
headerName: "package base",
flex: 1, flex: 1,
headerName: "package base",
minWidth: 150, minWidth: 150,
renderCell: (params: GridRenderCellParams<PackageRow>) => renderCell: params =>
params.row.webUrl ? params.row.webUrl ?
<Link href={params.row.webUrl} target="_blank" rel="noopener noreferrer" underline="hover"> <Link href={params.row.webUrl} rel="noopener noreferrer" target="_blank" underline="hover">
{params.value as string} {params.value as string}
</Link> </Link>
: params.value as string, : params.value as string,
}, },
{ field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" }, { align: "right", field: "version", headerAlign: "right", headerName: "version", width: 180 },
createListColumn("packages", "packages", { flex: 1, minWidth: 120 }), createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
createListColumn("groups", "groups", { width: 150 }), createListColumn("groups", "groups", { width: 150 }),
createListColumn("licenses", "licenses", { width: 150 }), createListColumn("licenses", "licenses", { width: 150 }),
{ field: "packager", headerName: "packager", width: 150 }, { field: "packager", headerName: "packager", width: 150 },
{ align: "right", field: "timestamp", headerName: "last update", headerAlign: "right", width: 180 },
{ {
field: "timestamp",
headerName: "last update",
width: 180,
align: "right",
headerAlign: "right",
},
{
field: "status",
headerName: "status",
width: 120,
align: "center", align: "center",
field: "status",
headerAlign: "center", headerAlign: "center",
renderCell: (params: GridRenderCellParams<PackageRow>) => headerName: "status",
<StatusCell status={params.row.status} isHeld={params.row.isHeld} />, renderCell: params =>
<StatusCell isHeld={params.row.isHeld} status={params.row.status} />,
width: 120,
}, },
], ],
[], [],
@@ -116,56 +107,42 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}> return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
<PackageTableToolbar <PackageTableToolbar
hasSelection={table.selectionModel.length > 0} actions={{
isAuthorized={table.isAuthorized} onAddClick: () => table.setDialogOpen("add"),
status={table.status} onDashboardClick: () => table.setDialogOpen("dashboard"),
searchText={table.searchText} onExportClick: () => apiRef.current?.exportDataAsCsv(),
onSearchChange={table.setSearchText} onKeyImportClick: () => table.setDialogOpen("keyImport"),
onRebuildClick: () => table.setDialogOpen("rebuild"),
onRefreshDatabaseClick: () => void table.handleRefreshDatabase(),
onReloadClick: table.handleReload,
onRemoveClick: () => void table.handleRemove(),
onUpdateClick: () => void table.handleUpdate(),
}}
autoRefresh={{ autoRefresh={{
autoRefreshIntervals, autoRefreshIntervals,
currentInterval: table.autoRefreshInterval, currentInterval: table.autoRefreshInterval,
onIntervalChange: table.onAutoRefreshIntervalChange, onIntervalChange: table.onAutoRefreshIntervalChange,
}} }}
actions={{ isAuthorized={table.isAuthorized}
onDashboardClick: () => table.setDialogOpen("dashboard"), hasSelection={table.selectionModel.length > 0}
onAddClick: () => table.setDialogOpen("add"), onSearchChange={table.setSearchText}
onUpdateClick: () => void table.handleUpdate(), searchText={table.searchText}
onRefreshDatabaseClick: () => void table.handleRefreshDatabase(), status={table.status}
onRebuildClick: () => table.setDialogOpen("rebuild"),
onRemoveClick: () => void table.handleRemove(),
onKeyImportClick: () => table.setDialogOpen("keyImport"),
onReloadClick: table.handleReload,
onExportClick: () => apiRef.current?.exportDataAsCsv(),
}}
/> />
<DataGrid <DataGrid
apiRef={apiRef} apiRef={apiRef}
rows={table.rows}
columns={columns}
loading={table.isLoading}
getRowHeight={() => "auto"}
checkboxSelection 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} columnVisibilityModel={table.columnVisibility}
onColumnVisibilityModelChange={table.setColumnVisibility} columns={columns}
density="compact"
disableRowSelectionOnClick
filterModel={effectiveFilterModel} filterModel={effectiveFilterModel}
onFilterModelChange={table.setFilterModel} getRowHeight={() => "auto"}
initialState={{ initialState={{
sorting: { sortModel: [{ field: "base", sort: "asc" }] }, sorting: { sortModel: [{ field: "base", sort: "asc" }] },
}} }}
loading={table.isLoading}
onCellClick={(params, event) => { onCellClick={(params, event) => {
// Don't open info dialog when clicking checkbox or link // Don't open info dialog when clicking checkbox or link
if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) { if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
@@ -176,22 +153,32 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
} }
table.setSelectedPackage(String(params.id)); table.setSelectedPackage(String(params.id));
}} }}
sx={{ onColumnVisibilityModelChange={table.setColumnVisibility}
flex: 1, onFilterModelChange={table.setFilterModel}
"& .MuiDataGrid-row": { cursor: "pointer" }, onPaginationModelChange={table.setPaginationModel}
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));
}
}} }}
density="compact" paginationModel={table.paginationModel}
rowSelectionModel={{ type: "include", ids: new Set<GridRowId>(table.selectionModel) }}
rows={table.rows}
sx={{ flex: 1 }}
/> />
<DashboardDialog open={table.dialogOpen === "dashboard"} onClose={() => table.setDialogOpen(null)} /> <DashboardDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "dashboard"} />
<PackageAddDialog open={table.dialogOpen === "add"} onClose={() => table.setDialogOpen(null)} /> <PackageAddDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "add"} />
<PackageRebuildDialog open={table.dialogOpen === "rebuild"} onClose={() => table.setDialogOpen(null)} /> <PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} />
<KeyImportDialog open={table.dialogOpen === "keyImport"} onClose={() => table.setDialogOpen(null)} /> <KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} />
<PackageInfoDialog <PackageInfoDialog
packageBase={table.selectedPackage}
open={table.selectedPackage !== null}
onClose={() => table.setSelectedPackage(null)}
autoRefreshIntervals={autoRefreshIntervals} autoRefreshIntervals={autoRefreshIntervals}
onClose={() => table.setSelectedPackage(null)}
open={table.selectedPackage !== null}
packageBase={table.selectedPackage}
/> />
</Box>; </Box>;
} }

View File

@@ -43,47 +43,47 @@ export interface AutoRefreshProps {
} }
export interface ToolbarActions { export interface ToolbarActions {
onDashboardClick: () => void;
onAddClick: () => void; onAddClick: () => void;
onUpdateClick: () => void; onDashboardClick: () => void;
onRefreshDatabaseClick: () => void;
onRebuildClick: () => void;
onRemoveClick: () => void;
onKeyImportClick: () => void;
onReloadClick: () => void;
onExportClick: () => void; onExportClick: () => void;
onKeyImportClick: () => void;
onRebuildClick: () => void;
onRefreshDatabaseClick: () => void;
onReloadClick: () => void;
onRemoveClick: () => void;
onUpdateClick: () => void;
} }
interface PackageTableToolbarProps { interface PackageTableToolbarProps {
actions: ToolbarActions;
autoRefresh: AutoRefreshProps;
hasSelection: boolean; hasSelection: boolean;
isAuthorized: boolean; isAuthorized: boolean;
status?: BuildStatus;
searchText: string;
onSearchChange: (text: string) => void; onSearchChange: (text: string) => void;
autoRefresh: AutoRefreshProps; searchText: string;
actions: ToolbarActions; status?: BuildStatus;
} }
export default function PackageTableToolbar({ export default function PackageTableToolbar({
actions,
autoRefresh,
hasSelection, hasSelection,
isAuthorized, isAuthorized,
status,
searchText,
onSearchChange, onSearchChange,
autoRefresh, searchText,
actions, status,
}: PackageTableToolbarProps): React.JSX.Element { }: PackageTableToolbarProps): React.JSX.Element {
const [packagesAnchorEl, setPackagesAnchorEl] = useState<HTMLElement | null>(null); const [packagesAnchorEl, setPackagesAnchorEl] = useState<HTMLElement | null>(null);
return <Box sx={{ display: "flex", gap: 1, mb: 1, flexWrap: "wrap", alignItems: "center" }}> return <Box sx={{ alignItems: "center", display: "flex", flexWrap: "wrap", gap: 1, mb: 1 }}>
<Tooltip title="System health"> <Tooltip title="System health">
<IconButton <IconButton
aria-label="System health" aria-label="System health"
onClick={actions.onDashboardClick} onClick={actions.onDashboardClick}
sx={{ sx={{
borderColor: status ? StatusColors[status] : undefined, borderColor: status ? StatusColors[status] : undefined,
borderWidth: 1,
borderStyle: "solid", borderStyle: "solid",
borderWidth: 1,
color: status ? StatusColors[status] : undefined, color: status ? StatusColors[status] : undefined,
}} }}
> >
@@ -94,16 +94,16 @@ export default function PackageTableToolbar({
{isAuthorized && {isAuthorized &&
<> <>
<Button <Button
variant="contained"
startIcon={<InventoryIcon />}
onClick={event => setPackagesAnchorEl(event.currentTarget)} onClick={event => setPackagesAnchorEl(event.currentTarget)}
startIcon={<InventoryIcon />}
variant="contained"
> >
packages packages
</Button> </Button>
<Menu <Menu
anchorEl={packagesAnchorEl} anchorEl={packagesAnchorEl}
open={Boolean(packagesAnchorEl)}
onClose={() => setPackagesAnchorEl(null)} onClose={() => setPackagesAnchorEl(null)}
open={Boolean(packagesAnchorEl)}
> >
<MenuItem onClick={() => { <MenuItem onClick={() => {
setPackagesAnchorEl(null); actions.onAddClick(); setPackagesAnchorEl(null); actions.onAddClick();
@@ -126,58 +126,58 @@ export default function PackageTableToolbar({
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild <ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
</MenuItem> </MenuItem>
<Divider /> <Divider />
<MenuItem onClick={() => { <MenuItem disabled={!hasSelection} onClick={() => {
setPackagesAnchorEl(null); actions.onRemoveClick(); setPackagesAnchorEl(null); actions.onRemoveClick();
}} disabled={!hasSelection}> }}>
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove <DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
</MenuItem> </MenuItem>
</Menu> </Menu>
<Button variant="contained" color="info" startIcon={<VpnKeyIcon />} onClick={actions.onKeyImportClick}> <Button color="info" onClick={actions.onKeyImportClick} startIcon={<VpnKeyIcon />} variant="contained">
import key import key
</Button> </Button>
</> </>
} }
<Button variant="outlined" color="secondary" startIcon={<RefreshIcon />} onClick={actions.onReloadClick}> <Button color="secondary" onClick={actions.onReloadClick} startIcon={<RefreshIcon />} variant="outlined">
reload reload
</Button> </Button>
<AutoRefreshControl <AutoRefreshControl
intervals={autoRefresh.autoRefreshIntervals}
currentInterval={autoRefresh.currentInterval} currentInterval={autoRefresh.currentInterval}
intervals={autoRefresh.autoRefreshIntervals}
onIntervalChange={autoRefresh.onIntervalChange} onIntervalChange={autoRefresh.onIntervalChange}
/> />
<Box sx={{ flexGrow: 1 }} /> <Box sx={{ flexGrow: 1 }} />
<TextField <TextField
size="small"
aria-label="Search packages" aria-label="Search packages"
placeholder="search packages..."
value={searchText}
onChange={event => onSearchChange(event.target.value)} onChange={event => onSearchChange(event.target.value)}
placeholder="search packages..."
size="small"
slotProps={{ slotProps={{
input: { input: {
endAdornment: searchText ?
<InputAdornment position="end">
<IconButton aria-label="Clear search" onClick={() => onSearchChange("")} size="small">
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
: undefined,
startAdornment: startAdornment:
<InputAdornment position="start"> <InputAdornment position="start">
<SearchIcon fontSize="small" /> <SearchIcon fontSize="small" />
</InputAdornment> </InputAdornment>
, ,
endAdornment: searchText ?
<InputAdornment position="end">
<IconButton size="small" aria-label="Clear search" onClick={() => onSearchChange("")}>
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
: undefined,
}, },
}} }}
sx={{ minWidth: 200 }} sx={{ minWidth: 200 }}
value={searchText}
/> />
<Tooltip title="Export CSV"> <Tooltip title="Export CSV">
<IconButton size="small" aria-label="Export CSV" onClick={actions.onExportClick}> <IconButton aria-label="Export CSV" onClick={actions.onExportClick} size="small">
<FileDownloadIcon fontSize="small" /> <FileDownloadIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>

View File

@@ -24,11 +24,11 @@ import type React from "react";
import { StatusColors } from "theme/StatusColors"; import { StatusColors } from "theme/StatusColors";
interface StatusCellProps { interface StatusCellProps {
status: BuildStatus;
isHeld?: boolean; isHeld?: boolean;
status: BuildStatus;
} }
export default function StatusCell({ status, isHeld }: StatusCellProps): React.JSX.Element { export default function StatusCell({ isHeld, status }: StatusCellProps): React.JSX.Element {
return <Chip return <Chip
icon={isHeld ? <PauseCircleIcon /> : undefined} icon={isHeld ? <PauseCircleIcon /> : undefined}
label={status} label={status}

View File

@@ -26,9 +26,9 @@ interface AuthState {
export interface AuthContextValue extends AuthState { export interface AuthContextValue extends AuthState {
isAuthorized: boolean; isAuthorized: boolean;
setAuthState: (state: AuthState) => void;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
setAuthState: (state: AuthState) => void;
} }
export const AuthContext = createContext<AuthContextValue | null>(null); export const AuthContext = createContext<AuthContextValue | null>(null);

View File

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

View File

@@ -20,8 +20,8 @@
import { createContext } from "react"; import { createContext } from "react";
export interface NotificationContextValue { export interface NotificationContextValue {
showSuccess: (title: string, message: string) => void;
showError: (title: string, message: string) => void; showError: (title: string, message: string) => void;
showSuccess: (title: string, message: string) => void;
} }
export const NotificationContext = createContext<NotificationContextValue | null>(null); export const NotificationContext = createContext<NotificationContextValue | null>(null);

View File

@@ -55,17 +55,17 @@ export function NotificationProvider({ children }: { children: ReactNode }): Rea
{children} {children}
<Box <Box
sx={{ sx={{
position: "fixed",
top: 16,
left: "50%",
transform: "translateX(-50%)",
zIndex: theme => theme.zIndex.snackbar,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 1, gap: 1,
left: "50%",
maxWidth: 500, maxWidth: 500,
width: "100%",
pointerEvents: "none", pointerEvents: "none",
position: "fixed",
top: 16,
transform: "translateX(-50%)",
width: "100%",
zIndex: theme => theme.zIndex.snackbar,
}} }}
> >
{notifications.map(notification => {notifications.map(notification =>

View File

@@ -21,10 +21,10 @@ import type { RepositoryId } from "models/RepositoryId";
import { createContext } from "react"; import { createContext } from "react";
export interface RepositoryContextValue { export interface RepositoryContextValue {
repositories: RepositoryId[];
currentRepository: RepositoryId | null; currentRepository: RepositoryId | null;
setRepositories: (repositories: RepositoryId[]) => void; repositories: RepositoryId[];
setCurrentRepository: (repository: RepositoryId) => void; setCurrentRepository: (repository: RepositoryId) => void;
setRepositories: (repositories: RepositoryId[]) => void;
} }
export const RepositoryContext = createContext<RepositoryContextValue | null>(null); export const RepositoryContext = createContext<RepositoryContextValue | null>(null);

View File

@@ -39,10 +39,8 @@ export function ThemeProvider({ children }: { children: React.ReactNode }): Reac
const theme = useMemo(() => createAppTheme(mode), [mode]); const theme = useMemo(() => createAppTheme(mode), [mode]);
useEffect(() => { useEffect(() => {
const textColor = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)"; chartDefaults.color = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)";
const gridColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)"; chartDefaults.borderColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
chartDefaults.color = textColor;
chartDefaults.borderColor = gridColor;
}, [mode]); }, [mode]);
const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]); const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]);

View File

@@ -20,10 +20,10 @@
import { type RefObject, useCallback, useRef } from "react"; import { type RefObject, useCallback, useRef } from "react";
interface UseAutoScrollResult { interface UseAutoScrollResult {
preRef: RefObject<HTMLElement | null>;
handleScroll: () => void; handleScroll: () => void;
scrollToBottom: () => void; preRef: RefObject<HTMLElement | null>;
resetScroll: () => void; resetScroll: () => void;
scrollToBottom: () => void;
} }
export function useAutoScroll(): UseAutoScrollResult { export function useAutoScroll(): UseAutoScrollResult {
@@ -59,5 +59,5 @@ export function useAutoScroll(): UseAutoScrollResult {
} }
}, []); }, []);
return { preRef, handleScroll, scrollToBottom, resetScroll }; return { handleScroll, preRef, resetScroll, scrollToBottom };
} }

View File

@@ -26,10 +26,10 @@ import { useRepository } from "hooks/useRepository";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
export interface UsePackageActionsResult { export interface UsePackageActionsResult {
handleReload: () => void;
handleUpdate: () => Promise<void>;
handleRefreshDatabase: () => Promise<void>; handleRefreshDatabase: () => Promise<void>;
handleReload: () => void;
handleRemove: () => Promise<void>; handleRemove: () => Promise<void>;
handleUpdate: () => Promise<void>;
} }
export function usePackageActions( export function usePackageActions(
@@ -63,7 +63,7 @@ export function usePackageActions(
} }
}; };
const handleReload: () => void = () => { const handleReload = (): void => {
if (currentRepository !== null) { if (currentRepository !== null) {
invalidate(currentRepository); invalidate(currentRepository);
} }
@@ -80,11 +80,11 @@ export function usePackageActions(
const handleRefreshDatabase = (): Promise<void> => performAction(async (repository): Promise<string> => { const handleRefreshDatabase = (): Promise<void> => performAction(async (repository): Promise<string> => {
await client.service.servicePackageUpdate(repository, { await client.service.servicePackageUpdate(repository, {
packages: [],
refresh: true,
aur: false, aur: false,
local: false, local: false,
manual: false, manual: false,
packages: [],
refresh: true,
}); });
return "Pacman database update has been requested"; return "Pacman database update has been requested";
}, "Could not update pacman databases"); }, "Could not update pacman databases");
@@ -100,9 +100,9 @@ export function usePackageActions(
}; };
return { return {
handleReload,
handleUpdate,
handleRefreshDatabase, handleRefreshDatabase,
handleReload,
handleRemove, handleRemove,
handleUpdate,
}; };
} }

View File

@@ -27,9 +27,9 @@ export function usePackageChanges(packageBase: string, repository: RepositoryId)
const client = useClient(); const client = useClient();
const { data } = useQuery<Changes>({ const { data } = useQuery<Changes>({
queryKey: QueryKeys.changes(packageBase, repository),
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
enabled: !!packageBase, enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
queryKey: QueryKeys.changes(packageBase, repository),
}); });
return data; return data;

View File

@@ -30,11 +30,11 @@ import { useMemo } from "react";
import { defaultInterval } from "utils"; import { defaultInterval } from "utils";
export interface UsePackageDataResult { export interface UsePackageDataResult {
rows: PackageRow[];
isLoading: boolean;
isAuthorized: boolean;
status: BuildStatus | undefined;
autoRefresh: ReturnType<typeof useAutoRefresh>; autoRefresh: ReturnType<typeof useAutoRefresh>;
isAuthorized: boolean;
isLoading: boolean;
rows: PackageRow[];
status: BuildStatus | undefined;
} }
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult { export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
@@ -45,24 +45,24 @@ export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): Use
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals)); const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packages = [], isLoading } = useQuery({ const { data: packages = [], isLoading } = useQuery({
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken, queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: status } = useQuery({ const { data: status } = useQuery({
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken, queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]); const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);
return { return {
rows, autoRefresh,
isLoading, isLoading,
isAuthorized, isAuthorized,
rows,
status: status?.status.status, status: status?.status.status,
autoRefresh,
}; };
} }

View File

@@ -27,35 +27,30 @@ import type { PackageRow } from "models/PackageRow";
import { useEffect } from "react"; import { useEffect } from "react";
export interface UsePackageTableResult { export interface UsePackageTableResult {
rows: PackageRow[];
isLoading: boolean;
isAuthorized: boolean;
status: BuildStatus | undefined;
selectionModel: string[];
setSelectionModel: (model: string[]) => void;
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
setDialogOpen: (dialog: "dashboard" | "add" | "rebuild" | "keyImport" | null) => void;
selectedPackage: string | null;
setSelectedPackage: (base: string | null) => void;
paginationModel: { pageSize: number; page: number };
setPaginationModel: (model: { pageSize: number; page: number }) => void;
columnVisibility: Record<string, boolean>;
setColumnVisibility: (model: Record<string, boolean>) => void;
filterModel: GridFilterModel;
setFilterModel: (model: GridFilterModel) => void;
searchText: string;
setSearchText: (text: string) => void;
autoRefreshInterval: number; autoRefreshInterval: number;
onAutoRefreshIntervalChange: (interval: number) => void; columnVisibility: Record<string, boolean>;
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
handleReload: () => void; filterModel: GridFilterModel;
handleUpdate: () => Promise<void>;
handleRefreshDatabase: () => Promise<void>; handleRefreshDatabase: () => Promise<void>;
handleReload: () => void;
handleRemove: () => Promise<void>; handleRemove: () => Promise<void>;
handleUpdate: () => Promise<void>;
isAuthorized: boolean;
isLoading: boolean;
onAutoRefreshIntervalChange: (interval: number) => void;
paginationModel: { page: number; pageSize: number };
rows: PackageRow[];
searchText: string;
selectedPackage: string | null;
selectionModel: string[];
setColumnVisibility: (model: Record<string, boolean>) => void;
setDialogOpen: (dialog: "dashboard" | "add" | "rebuild" | "keyImport" | null) => void;
setFilterModel: (model: GridFilterModel) => void;
setPaginationModel: (model: { page: number; pageSize: number }) => void;
setSearchText: (text: string) => void;
setSelectedPackage: (base: string | null) => void;
setSelectionModel: (model: string[]) => void;
status: BuildStatus | undefined;
} }
export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult { export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
@@ -71,16 +66,13 @@ export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): Us
}, [isDialogOpen, setPaused]); }, [isDialogOpen, setPaused]);
return { return {
rows, autoRefreshInterval: autoRefresh.interval,
isLoading, isLoading,
isAuthorized, isAuthorized,
status,
...tableState,
autoRefreshInterval: autoRefresh.interval,
onAutoRefreshIntervalChange: autoRefresh.setInterval, onAutoRefreshIntervalChange: autoRefresh.setInterval,
rows,
status,
...actions, ...actions,
...tableState,
}; };
} }

View File

@@ -22,10 +22,10 @@ import type { RepositoryId } from "models/RepositoryId";
import { useState } from "react"; import { useState } from "react";
export interface SelectedRepositoryResult { export interface SelectedRepositoryResult {
selectedKey: string;
setSelectedKey: (key: string) => void;
selectedRepository: RepositoryId | null;
reset: () => void; reset: () => void;
selectedKey: string;
selectedRepository: RepositoryId | null;
setSelectedKey: (key: string) => void;
} }
export function useSelectedRepository(): SelectedRepositoryResult { export function useSelectedRepository(): SelectedRepositoryResult {
@@ -40,9 +40,9 @@ export function useSelectedRepository(): SelectedRepositoryResult {
} }
} }
const reset: () => void = () => { const reset = (): void => {
setSelectedKey(""); setSelectedKey("");
}; };
return { selectedKey, setSelectedKey, selectedRepository, reset }; return { reset, selectedKey, selectedRepository, setSelectedKey };
} }

View File

@@ -24,22 +24,20 @@ import { useState } from "react";
export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport"; export type DialogType = "dashboard" | "add" | "rebuild" | "keyImport";
export interface UseTableStateResult { export interface UseTableStateResult {
selectionModel: string[];
setSelectionModel: (model: string[]) => void;
dialogOpen: DialogType | null;
setDialogOpen: (dialog: DialogType | null) => void;
selectedPackage: string | null;
setSelectedPackage: (base: string | null) => void;
paginationModel: { pageSize: number; page: number };
setPaginationModel: (model: { pageSize: number; page: number }) => void;
columnVisibility: Record<string, boolean>; columnVisibility: Record<string, boolean>;
setColumnVisibility: (model: Record<string, boolean>) => void; dialogOpen: DialogType | null;
filterModel: GridFilterModel; filterModel: GridFilterModel;
setFilterModel: (model: GridFilterModel) => void; paginationModel: { pageSize: number; page: number };
searchText: string; searchText: string;
selectedPackage: string | null;
selectionModel: string[];
setColumnVisibility: (model: Record<string, boolean>) => void;
setDialogOpen: (dialog: DialogType | null) => void;
setFilterModel: (model: GridFilterModel) => void;
setPaginationModel: (model: { pageSize: number; page: number }) => void;
setSearchText: (text: string) => void; setSearchText: (text: string) => void;
setSelectedPackage: (base: string | null) => void;
setSelectionModel: (model: string[]) => void;
} }
export function useTableState(): UseTableStateResult { export function useTableState(): UseTableStateResult {
@@ -49,8 +47,8 @@ export function useTableState(): UseTableStateResult {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", { const [paginationModel, setPaginationModel] = useLocalStorage("ahriman-packages-pagination", {
pageSize: 10,
page: 0, page: 0,
pageSize: 25,
}); });
const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>( const [columnVisibility, setColumnVisibility] = useLocalStorage<Record<string, boolean>>(
"ahriman-packages-columns", "ahriman-packages-columns",
@@ -62,21 +60,19 @@ export function useTableState(): UseTableStateResult {
); );
return { return {
selectionModel,
setSelectionModel,
dialogOpen,
setDialogOpen,
selectedPackage,
setSelectedPackage,
paginationModel,
setPaginationModel,
columnVisibility, columnVisibility,
setColumnVisibility, dialogOpen,
filterModel, filterModel,
setFilterModel, paginationModel,
searchText, searchText,
selectedPackage,
selectionModel,
setColumnVisibility,
setDialogOpen,
setFilterModel,
setPaginationModel,
setSearchText, setSearchText,
setSelectedPackage,
setSelectionModel,
}; };
} }

View File

@@ -18,6 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export interface AURPackage { export interface AURPackage {
package: string;
description: string; description: string;
package: string;
} }

View File

@@ -17,4 +17,4 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export type BuildStatus = "unknown" | "pending" | "building" | "failed" | "success"; export type BuildStatus = "building" | "failed" | "pending" | "success" | "unknown";

View File

@@ -23,9 +23,9 @@ import type { RepositoryId } from "models/RepositoryId";
export interface InfoResponse { export interface InfoResponse {
auth: AuthInfo; auth: AuthInfo;
repositories: RepositoryId[];
version: string;
autorefresh_intervals: AutoRefreshInterval[]; autorefresh_intervals: AutoRefreshInterval[];
docs_enabled: boolean; docs_enabled: boolean;
index_url?: string; index_url?: string;
repositories: RepositoryId[];
version: string;
} }

View File

@@ -23,8 +23,8 @@ import type { Status } from "models/Status";
export interface InternalStatus { export interface InternalStatus {
architecture: string; architecture: string;
repository: string;
packages: Counters; packages: Counters;
repository: string;
stats: RepositoryStats; stats: RepositoryStats;
status: Status; status: Status;
version: string; version: string;

View File

@@ -18,6 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export interface LoginRequest { export interface LoginRequest {
username: string;
password: string; password: string;
username: string;
} }

View File

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

View File

@@ -20,10 +20,10 @@
import type { Patch } from "models/Patch"; import type { Patch } from "models/Patch";
export interface PackageActionRequest { export interface PackageActionRequest {
packages: string[];
patches?: Patch[];
refresh?: boolean;
aur?: boolean; aur?: boolean;
local?: boolean; local?: boolean;
manual?: boolean; manual?: boolean;
packages: string[];
patches?: Patch[];
refresh?: boolean;
} }

View File

@@ -21,32 +21,31 @@ import type { BuildStatus } from "models/BuildStatus";
import type { PackageStatus } from "models/PackageStatus"; import type { PackageStatus } from "models/PackageStatus";
export class PackageRow { export class PackageRow {
id: string;
base: string; base: string;
webUrl?: string;
version: string;
packages: string[];
groups: string[]; groups: string[];
id: string;
isHeld: boolean;
licenses: string[]; licenses: string[];
packager: string; packager: string;
timestamp: string; packages: string[];
timestampValue: number;
status: BuildStatus; status: BuildStatus;
isHeld: boolean; timestamp: string;
version: string;
webUrl?: string;
constructor(descriptor: PackageStatus) { constructor(descriptor: PackageStatus) {
this.id = descriptor.package.base;
this.base = descriptor.package.base; this.base = descriptor.package.base;
this.webUrl = descriptor.package.remote.web_url ?? undefined;
this.version = descriptor.package.version;
this.packages = Object.keys(descriptor.package.packages).sort();
this.groups = PackageRow.extractListProperties(descriptor.package, "groups"); this.groups = PackageRow.extractListProperties(descriptor.package, "groups");
this.id = descriptor.package.base;
this.isHeld = descriptor.status.is_held ?? false;
this.licenses = PackageRow.extractListProperties(descriptor.package, "licenses"); this.licenses = PackageRow.extractListProperties(descriptor.package, "licenses");
this.packager = descriptor.package.packager ?? ""; this.packager = descriptor.package.packager ?? "";
this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort(); this.packages = Object.keys(descriptor.package.packages).sort();
this.timestampValue = descriptor.status.timestamp;
this.status = descriptor.status.status; this.status = descriptor.status.status;
this.isHeld = descriptor.status.is_held ?? false; this.timestamp = new Date(descriptor.status.timestamp * 1000).toISOStringShort();
this.version = descriptor.package.version;
this.webUrl = descriptor.package.remote.web_url ?? undefined;
} }
private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] { private static extractListProperties(pkg: PackageStatus["package"], property: "groups" | "licenses"): string[] {

View File

@@ -18,6 +18,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export class RepositoryId { export class RepositoryId {
readonly architecture: string; readonly architecture: string;
readonly repository: string; readonly repository: string;

View File

@@ -20,7 +20,7 @@
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
export interface Status { export interface Status {
is_held?: boolean;
status: BuildStatus; status: BuildStatus;
timestamp: number; timestamp: number;
is_held?: boolean;
} }

View File

@@ -21,19 +21,19 @@ import { amber, green, grey, orange, red } from "@mui/material/colors";
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
const base: Record<BuildStatus, string> = { const base: Record<BuildStatus, string> = {
unknown: grey[600],
pending: amber[700],
building: orange[800], building: orange[800],
failed: red[700], failed: red[700],
pending: amber[700],
success: green[700], success: green[700],
unknown: grey[600],
}; };
const headerBase: Record<BuildStatus, string> = { const headerBase: Record<BuildStatus, string> = {
unknown: grey[600],
pending: amber[700],
building: orange[600], building: orange[600],
failed: red[500], failed: red[500],
pending: amber[700],
success: green[600], success: green[600],
unknown: grey[600],
}; };
export const StatusColors = base; export const StatusColors = base;

View File

@@ -19,6 +19,14 @@
*/ */
import type { AutoRefreshInterval } from "models/AutoRefreshInterval"; import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
export const DETAIL_TABLE_PROPS = {
density: "compact" as const,
disableColumnSorting: true,
disableRowSelectionOnClick: true,
paginationModel: { page: 0, pageSize: 25 },
sx: { height: 400, mt: 1 },
};
export function defaultInterval(intervals: AutoRefreshInterval[]): number { export function defaultInterval(intervals: AutoRefreshInterval[]): number {
return intervals.find(interval => interval.is_active)?.interval ?? 0; return intervals.find(interval => interval.is_active)?.interval ?? 0;
} }

View File

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