Compare commits

..

2 Commits

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

Yet because it is still ai slop, it is still possible to enable old
interface via configuration, even though new interface is turned on by
default to get feedback
2026-03-03 16:52:58 +02:00
db46147f0d fix: mount archive to chroot
while file:// repository is automatically mounted by devtools, it
doesn't mount any directory which contains source of symlinks

This commit adds implicit mount of the archive directory (ro) into
chroot
2026-03-03 15:50:21 +02:00
16 changed files with 101 additions and 51 deletions

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
interface StringListProps {
items: string[];
}
export default function StringList({ items }: StringListProps): React.JSX.Element {
return <>{items.join("\n")}</>;
}

View File

@@ -46,7 +46,7 @@ export default function DashboardDialog({ open, onClose }: DashboardDialogProps)
const headerStyle = status ? StatusHeaderStyles[status.status.status] : {}; const headerStyle = status ? StatusHeaderStyles[status.status.status] : {};
return <Dialog open={open} onClose={onClose} keepMounted maxWidth="lg" fullWidth> return <Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
<DialogHeader onClose={onClose} sx={headerStyle}> <DialogHeader onClose={onClose} sx={headerStyle}>
System health System health
</DialogHeader> </DialogHeader>

View File

@@ -85,7 +85,7 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
} }
}; };
return <Dialog open={open} onClose={handleClose} keepMounted maxWidth="lg" fullWidth> return <Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Import key from PGP server Import key from PGP server
</DialogHeader> </DialogHeader>

View File

@@ -72,7 +72,7 @@ export default function LoginDialog({ open, onClose }: LoginDialogProps): React.
} }
}; };
return <Dialog open={open} onClose={handleClose} keepMounted maxWidth="xs" fullWidth> return <Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Login Login
</DialogHeader> </DialogHeader>

View File

@@ -126,7 +126,7 @@ export default function PackageAddDialog({ open, onClose }: PackageAddDialogProp
} }
}; };
return <Dialog open={open} onClose={handleClose} keepMounted maxWidth="md" fullWidth> return <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Add new packages Add new packages
</DialogHeader> </DialogHeader>

View File

@@ -132,7 +132,7 @@ export default function PackageInfoDialog({
} }
}; };
return <Dialog open={open} onClose={handleClose} keepMounted maxWidth="lg" fullWidth> return <Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
<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()}`

View File

@@ -63,7 +63,7 @@ export default function PackageRebuildDialog({ open, onClose }: PackageRebuildDi
} }
}; };
return <Dialog open={open} onClose={handleClose} keepMounted maxWidth="md" fullWidth> return <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogHeader onClose={handleClose}> <DialogHeader onClose={handleClose}>
Rebuild depending packages Rebuild depending packages
</DialogHeader> </DialogHeader>

View File

@@ -71,8 +71,7 @@ export default function EventsTab({ packageBase, repository }: EventsTabProps):
sorting: { sortModel: [{ field: "timestamp", sort: "desc" }] }, sorting: { sortModel: [{ field: "timestamp", sort: "desc" }] },
}} }}
pageSizeOptions={[10, 25]} pageSizeOptions={[10, 25]}
autoHeight sx={{ height: 400, mt: 1 }}
sx={{ mt: 1 }}
disableRowSelectionOnClick disableRowSelectionOnClick
/> />
</Box>; </Box>;

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/>.
*/ */
import { Grid, Link, Typography } from "@mui/material"; import { Grid, Link, Typography } from "@mui/material";
import StringList from "components/common/StringList";
import type { Dependencies } from "models/Dependencies"; import type { Dependencies } from "models/Dependencies";
import type { Package } from "models/Package"; import type { Package } from "models/Package";
import React from "react"; import React from "react";
@@ -27,16 +28,6 @@ interface PackageDetailsGridProps {
dependencies?: Dependencies; dependencies?: Dependencies;
} }
function listToString(items: string[]): React.ReactNode {
const unique = [...new Set(items)].sort();
return unique.map((item, index) =>
<React.Fragment key={item}>
{item}
{index < unique.length - 1 && <br />}
</React.Fragment>,
);
}
export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetailsGridProps): React.JSX.Element { export default function PackageDetailsGrid({ pkg, dependencies }: 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})` : ""}`);
@@ -47,21 +38,27 @@ export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetails
const licenses = Object.values(pkg.packages) const licenses = Object.values(pkg.packages)
.flatMap(properties => properties.licenses ?? []); .flatMap(properties => properties.licenses ?? []);
const upstreamUrls = [...new Set( const upstreamUrls = Object.values(pkg.packages)
Object.values(pkg.packages) .map(properties => properties.url)
.map(properties => properties.url) .filter((url): url is string => !!url)
.filter((url): url is string => !!url), .unique();
)].sort();
const aurUrl = pkg.remote.web_url; const aurUrl = pkg.remote.web_url;
const pkgNames = Object.keys(pkg.packages); const pkgNames = Object.keys(pkg.packages);
const allDepends = Object.values(pkg.packages).flatMap(properties => { const pkgValues = Object.values(pkg.packages);
const deps = (properties.depends ?? []).filter(dep => !pkgNames.includes(dep)); const deps = pkgValues
const makeDeps = (properties.make_depends ?? []).filter(dep => !pkgNames.includes(dep)).map(dep => `${dep} (make)`); .flatMap(properties => (properties.depends ?? []).filter(dep => !pkgNames.includes(dep)))
const optDeps = (properties.opt_depends ?? []).filter(dep => !pkgNames.includes(dep)).map(dep => `${dep} (optional)`); .unique();
return [...deps, ...makeDeps, ...optDeps]; const makeDeps = pkgValues
}); .flatMap(properties => (properties.make_depends ?? []).filter(dep => !pkgNames.includes(dep)))
.map(dep => `${dep} (make)`)
.unique();
const optDeps = pkgValues
.flatMap(properties => (properties.opt_depends ?? []).filter(dep => !pkgNames.includes(dep)))
.map(dep => `${dep} (optional)`)
.unique();
const allDepends = [...deps, ...makeDeps, ...optDeps];
const implicitDepends = dependencies const implicitDepends = dependencies
? Object.values(dependencies.paths).flat() ? Object.values(dependencies.paths).flat()
@@ -70,7 +67,7 @@ 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={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(packagesList)}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}><StringList items={packagesList.unique()} /></Typography></Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid> <Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.version}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.version}</Typography></Grid>
</Grid> </Grid>
@@ -84,9 +81,9 @@ export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetails
<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={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(groups)}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}><StringList items={groups.unique()} /></Typography></Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid> <Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(licenses)}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}><StringList items={licenses.unique()} /></Typography></Grid>
</Grid> </Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}> <Grid container spacing={1} sx={{ mt: 0.5 }}>
@@ -110,9 +107,9 @@ export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetails
<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={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(allDepends)}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}><StringList items={allDepends} /></Typography></Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid> <Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{listToString(implicitDepends)}</Typography></Grid> <Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}><StringList items={implicitDepends.unique()} /></Typography></Grid>
</Grid> </Grid>
</>; </>;
} }

View File

@@ -27,6 +27,7 @@ import {
type GridRowId, type GridRowId,
useGridApiRef, useGridApiRef,
} from "@mui/x-data-grid"; } from "@mui/x-data-grid";
import StringList from "components/common/StringList";
import DashboardDialog from "components/dialogs/DashboardDialog"; import DashboardDialog from "components/dialogs/DashboardDialog";
import KeyImportDialog from "components/dialogs/KeyImportDialog"; import KeyImportDialog from "components/dialogs/KeyImportDialog";
import PackageAddDialog from "components/dialogs/PackageAddDialog"; import PackageAddDialog from "components/dialogs/PackageAddDialog";
@@ -57,9 +58,7 @@ function createListColumn(
...options, ...options,
valueGetter: (value: string[]) => (value ?? []).join(" "), valueGetter: (value: string[]) => (value ?? []).join(" "),
renderCell: (params: GridRenderCellParams<PackageRow>) => renderCell: (params: GridRenderCellParams<PackageRow>) =>
((params.row[field] as string[]) ?? []).map((item, index, items) => <Box sx={{ whiteSpace: "pre-line" }}><StringList items={(params.row[field] as string[]) ?? []} /></Box>,
<React.Fragment key={`${item}-${index}`}>{item}{index < items.length - 1 && <br />}</React.Fragment>,
),
sortComparator: (left: string, right: string) => left.localeCompare(right), sortComparator: (left: string, right: string) => left.localeCompare(right),
}; };
} }
@@ -177,8 +176,8 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
} }
table.setSelectedPackage(String(params.id)); table.setSelectedPackage(String(params.id));
}} }}
autoHeight
sx={{ sx={{
height: 500,
"& .MuiDataGrid-row": { cursor: "pointer" }, "& .MuiDataGrid-row": { cursor: "pointer" },
}} }}
density="compact" density="compact"

View File

@@ -18,22 +18,31 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { useLocalStorage } from "hooks/useLocalStorage"; import { useLocalStorage } from "hooks/useLocalStorage";
import { type Dispatch, type SetStateAction, useState } from "react"; import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
interface AutoRefreshResult { interface AutoRefreshResult {
interval: number; interval: number;
paused: boolean;
setInterval: Dispatch<SetStateAction<number>>; setInterval: Dispatch<SetStateAction<number>>;
setPaused: Dispatch<SetStateAction<boolean>>; setPaused: Dispatch<SetStateAction<boolean>>;
} }
export function useAutoRefresh(key: string, defaultInterval: number = 0): AutoRefreshResult { export function useAutoRefresh(key: string, defaultInterval: number): AutoRefreshResult {
const [interval, setInterval] = useLocalStorage<number>(`ahriman-${key}`, defaultInterval); const storageKey = `ahriman-${key}`;
const [interval, setInterval] = useLocalStorage<number>(storageKey, defaultInterval);
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
// Apply defaultInterval when it becomes available (e.g. after info endpoint loads)
// but only if the user hasn't explicitly set a preference
useEffect(() => {
if (defaultInterval > 0 && window.localStorage.getItem(storageKey) === null) {
setInterval(defaultInterval);
}
}, [storageKey, defaultInterval, setInterval]);
const effectiveInterval = paused ? 0 : interval;
return { return {
interval, interval: effectiveInterval,
paused,
setInterval, setInterval,
setPaused, setPaused,
}; };

View File

@@ -23,7 +23,7 @@ export function useLocalStorage<T>(key: string, initialValue: T): [T, Dispatch<S
const [storedValue, setStoredValue] = useState<T>(() => { const [storedValue, setStoredValue] = useState<T>(() => {
try { try {
const item = window.localStorage.getItem(key); const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue; return item !== null ? (JSON.parse(item) as T) : initialValue;
} catch { } catch {
return initialValue; return initialValue;
} }

View File

@@ -47,13 +47,13 @@ export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): Use
const { data: packages = [], isLoading } = useQuery({ const { data: packages = [], isLoading } = useQuery({
queryKey: current ? QueryKeys.packages(current) : ["packages"], queryKey: current ? QueryKeys.packages(current) : ["packages"],
queryFn: current ? () => client.fetchPackages(current) : skipToken, queryFn: current ? () => client.fetchPackages(current) : skipToken,
refetchInterval: autoRefresh.interval, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: status } = useQuery({ const { data: status } = useQuery({
queryKey: current ? QueryKeys.status(current) : ["status"], queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetchServerStatus(current) : skipToken, queryFn: current ? () => client.fetchServerStatus(current) : skipToken,
refetchInterval: autoRefresh.interval, 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]);

View File

@@ -39,11 +39,19 @@ export function defaultInterval(intervals: AutoRefreshInterval[]): number {
} }
declare global { declare global {
interface Array<T> {
unique(): T[];
}
interface Date { interface Date {
toISOStringShort(): string; toISOStringShort(): string;
} }
} }
Array.prototype.unique = function <T>(): T[] {
return [...new Set<T>(this)].sort();
};
// custom formatter to print pretty date, because there is no builtin for this // custom formatter to print pretty date, because there is no builtin for this
Date.prototype.toISOStringShort = function (): string { Date.prototype.toISOStringShort = function (): string {
const pad: (num: number) => string = num => String(num).padStart(2, "0"); const pad: (num: number) => string = num => String(num).padStart(2, "0");

View File

@@ -110,7 +110,7 @@ class Task(LazyLogging):
""" """
command = [self.build_command, "-r", str(self.paths.chroot)] command = [self.build_command, "-r", str(self.paths.chroot)]
command.extend(self.archbuild_flags) command.extend(self.archbuild_flags)
command.extend(["--"] + self.makechrootpkg_flags) command.extend(["--", "-D", str(self.paths.archive)] + self.makechrootpkg_flags)
command.extend(["--"] + self.makepkg_flags) command.extend(["--"] + self.makepkg_flags)
if dry_run: if dry_run:
command.extend(["--nobuild"]) command.extend(["--nobuild"])

View File

@@ -53,7 +53,10 @@ def test_build(task_ahriman: Task, mocker: MockerFixture) -> None:
assert task_ahriman.build(local) == [task_ahriman.package.base] assert task_ahriman.build(local) == [task_ahriman.package.base]
check_output_mock.assert_called_once_with( check_output_mock.assert_called_once_with(
"extra-x86_64-build", "-r", str(task_ahriman.paths.chroot), "--", "--", "--skippgpcheck", "extra-x86_64-build",
"-r", str(task_ahriman.paths.chroot),
"--", "-D", str(task_ahriman.paths.archive),
"--", "--skippgpcheck",
exception=pytest.helpers.anyvar(int), exception=pytest.helpers.anyvar(int),
cwd=local, cwd=local,
logger=task_ahriman.logger, logger=task_ahriman.logger,
@@ -76,7 +79,10 @@ def test_build_environment(task_ahriman: Task, mocker: MockerFixture) -> None:
task_ahriman.build(local, **environment, empty=None) task_ahriman.build(local, **environment, empty=None)
check_output_mock.assert_called_once_with( check_output_mock.assert_called_once_with(
"extra-x86_64-build", "-r", str(task_ahriman.paths.chroot), "--", "--", "--skippgpcheck", "extra-x86_64-build",
"-r", str(task_ahriman.paths.chroot),
"--", "-D", str(task_ahriman.paths.archive),
"--", "--skippgpcheck",
exception=pytest.helpers.anyvar(int), exception=pytest.helpers.anyvar(int),
cwd=local, cwd=local,
logger=task_ahriman.logger, logger=task_ahriman.logger,
@@ -96,7 +102,11 @@ def test_build_dry_run(task_ahriman: Task, mocker: MockerFixture) -> None:
assert task_ahriman.build(local, dry_run=True) == [task_ahriman.package.base] assert task_ahriman.build(local, dry_run=True) == [task_ahriman.package.base]
check_output_mock.assert_called_once_with( check_output_mock.assert_called_once_with(
"extra-x86_64-build", "-r", str(task_ahriman.paths.chroot), "--", "--", "--skippgpcheck", "--nobuild", "extra-x86_64-build",
"-r", str(task_ahriman.paths.chroot),
"--", "-D", str(task_ahriman.paths.archive),
"--", "--skippgpcheck",
"--nobuild",
exception=pytest.helpers.anyvar(int), exception=pytest.helpers.anyvar(int),
cwd=local, cwd=local,
logger=task_ahriman.logger, logger=task_ahriman.logger,