Compare commits

..

1 Commits

Author SHA1 Message Date
cd6fbaae31 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 03:29:23 +02:00
16 changed files with 51 additions and 101 deletions

View File

@@ -1,28 +0,0 @@
/*
* 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] : {};
return <Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
return <Dialog open={open} onClose={onClose} keepMounted maxWidth="lg" fullWidth>
<DialogHeader onClose={onClose} sx={headerStyle}>
System health
</DialogHeader>

View File

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

View File

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

View File

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

View File

@@ -132,7 +132,7 @@ export default function PackageInfoDialog({
}
};
return <Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
return <Dialog open={open} onClose={handleClose} keepMounted maxWidth="lg" fullWidth>
<DialogHeader onClose={handleClose} sx={headerStyle}>
{pkg && status
? `${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} maxWidth="md" fullWidth>
return <Dialog open={open} onClose={handleClose} keepMounted maxWidth="md" fullWidth>
<DialogHeader onClose={handleClose}>
Rebuild depending packages
</DialogHeader>

View File

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

View File

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

View File

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

View File

@@ -18,31 +18,22 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useLocalStorage } from "hooks/useLocalStorage";
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
import { type Dispatch, type SetStateAction, useState } from "react";
interface AutoRefreshResult {
interval: number;
paused: boolean;
setInterval: Dispatch<SetStateAction<number>>;
setPaused: Dispatch<SetStateAction<boolean>>;
}
export function useAutoRefresh(key: string, defaultInterval: number): AutoRefreshResult {
const storageKey = `ahriman-${key}`;
const [interval, setInterval] = useLocalStorage<number>(storageKey, defaultInterval);
export function useAutoRefresh(key: string, defaultInterval: number = 0): AutoRefreshResult {
const [interval, setInterval] = useLocalStorage<number>(`ahriman-${key}`, defaultInterval);
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 {
interval: effectiveInterval,
interval,
paused,
setInterval,
setPaused,
};

View File

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

View File

@@ -47,13 +47,13 @@ export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): Use
const { data: packages = [], isLoading } = useQuery({
queryKey: current ? QueryKeys.packages(current) : ["packages"],
queryFn: current ? () => client.fetchPackages(current) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
refetchInterval: autoRefresh.interval,
});
const { data: status } = useQuery({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetchServerStatus(current) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
refetchInterval: autoRefresh.interval,
});
const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);

View File

@@ -39,19 +39,11 @@ export function defaultInterval(intervals: AutoRefreshInterval[]): number {
}
declare global {
interface Array<T> {
unique(): T[];
}
interface Date {
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
Date.prototype.toISOStringShort = function (): string {
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.extend(self.archbuild_flags)
command.extend(["--", "-D", str(self.paths.archive)] + self.makechrootpkg_flags)
command.extend(["--"] + self.makechrootpkg_flags)
command.extend(["--"] + self.makepkg_flags)
if dry_run:
command.extend(["--nobuild"])

View File

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