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
This commit is contained in:
2026-02-25 22:49:38 +02:00
parent 49ebbc34fa
commit 2fcff48094
152 changed files with 5753 additions and 129 deletions

View File

@@ -0,0 +1,194 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import "highlight.js/styles/github.css";
import ListIcon from "@mui/icons-material/List";
import { Box, Button, Menu, MenuItem, Typography } from "@mui/material";
import { keepPreviousData, skipToken, useQuery } from "@tanstack/react-query";
import CodeBlock from "components/common/CodeBlock";
import { formatTimestamp } from "components/common/formatTimestamp";
import hljs from "highlight.js/lib/core";
import plaintext from "highlight.js/lib/languages/plaintext";
import { QueryKeys } from "hooks/QueryKeys";
import { useAutoScroll } from "hooks/useAutoScroll";
import { useClient } from "hooks/useClient";
import type { LogRecord } from "models/LogRecord";
import type { RepositoryId } from "models/RepositoryId";
import React, { useEffect, useMemo, useRef, useState } from "react";
hljs.registerLanguage("plaintext", plaintext);
interface LogVersion {
version: string;
processId: string;
created: number;
logs: string;
}
interface BuildLogsTabProps {
packageBase: string;
repo: RepositoryId;
refetchInterval: number;
}
function convertLogs(records: LogRecord[], filter?: (r: LogRecord) => boolean): string {
const filtered = filter ? records.filter(filter) : records;
return filtered
.map(r => `[${new Date(r.created * 1000).toISOString()}] ${r.message}`)
.join("\n");
}
export default function BuildLogsTab({ packageBase, repo, refetchInterval }: BuildLogsTabProps): React.JSX.Element {
const client = useClient();
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const codeRef = useRef<HTMLElement>(null);
const { data: allLogs } = useQuery<LogRecord[]>({
queryKey: QueryKeys.logs(packageBase, repo),
queryFn: () => client.fetchPackageLogs(packageBase, repo),
enabled: !!packageBase,
refetchInterval,
});
// Build version selectors from all logs
const versions = useMemo<LogVersion[]>(() => {
if (!allLogs || allLogs.length === 0) {
return [];
}
const grouped: Record<string, LogRecord & { minCreated: number }> = {};
for (const record of allLogs) {
const key = `${record.version}-${record.process_id}`;
const existing = grouped[key];
if (!existing) {
grouped[key] = { ...record, minCreated: record.created };
} else {
existing.minCreated = Math.min(existing.minCreated, record.created);
}
}
return Object.values(grouped)
.sort((a, b) => b.minCreated - a.minCreated)
.map(v => ({
version: v.version,
processId: v.process_id,
created: v.minCreated,
logs: convertLogs(
allLogs,
r => r.version === v.version && r.process_id === v.process_id,
),
}));
}, [allLogs]);
// Compute active index from selected version key, defaulting to newest (index 0)
const activeIndex = useMemo(() => {
if (selectedVersionKey) {
const idx = versions.findIndex(v => `${v.version}-${v.processId}` === selectedVersionKey);
if (idx >= 0) {
return idx;
}
}
return 0;
}, [versions, selectedVersionKey]);
const activeVersion = versions[activeIndex];
const activeVersionKey = activeVersion ? `${activeVersion.version}-${activeVersion.processId}` : null;
// Refresh active version logs
const { data: versionLogs } = useQuery<LogRecord[]>({
queryKey: QueryKeys.logsVersion(packageBase, repo, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
queryFn: activeVersion
? () => client.fetchPackageLogs(packageBase, repo, activeVersion.version, activeVersion.processId)
: skipToken,
placeholderData: keepPreviousData,
refetchInterval,
});
// Derive displayed logs: prefer fresh polled data when available
const displayedLogs = useMemo(() => {
if (versionLogs && versionLogs.length > 0) {
return convertLogs(versionLogs);
}
return activeVersion?.logs ?? "";
}, [versionLogs, activeVersion]);
const { preRef, handleScroll, scrollToBottom, resetScroll } = useAutoScroll();
// Reset scroll tracking when active version changes
useEffect(() => {
resetScroll();
}, [activeVersionKey, resetScroll]);
// Highlight code, then scroll to bottom
useEffect(() => {
if (codeRef.current && displayedLogs) {
codeRef.current.innerHTML = hljs.highlight(displayedLogs, { language: "plaintext" }).value;
}
scrollToBottom();
}, [displayedLogs, scrollToBottom]);
return (
<Box sx={{ display: "flex", gap: 1, mt: 1 }}>
<Box>
<Button
size="small"
aria-label="Select version"
startIcon={<ListIcon />}
onClick={e => setAnchorEl(e.currentTarget)}
/>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
{versions.map((v, idx) => (
<MenuItem
key={`${v.version}-${v.processId}`}
selected={idx === activeIndex}
onClick={() => {
setSelectedVersionKey(`${v.version}-${v.processId}`);
setAnchorEl(null);
resetScroll();
}}
>
<Typography variant="body2">{formatTimestamp(v.created)}</Typography>
</MenuItem>
))}
{versions.length === 0 && (
<MenuItem disabled>No logs available</MenuItem>
)}
</Menu>
</Box>
<Box sx={{ flex: 1 }}>
<CodeBlock
codeRef={codeRef}
preRef={preRef}
className="language-plaintext"
getText={() => displayedLogs}
height={400}
onScroll={handleScroll}
wordBreak
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import "highlight.js/styles/github.css";
import { Box } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import CodeBlock from "components/common/CodeBlock";
import hljs from "highlight.js/lib/core";
import diff from "highlight.js/lib/languages/diff";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Changes } from "models/Changes";
import type { RepositoryId } from "models/RepositoryId";
import React, { useEffect, useRef } from "react";
hljs.registerLanguage("diff", diff);
interface ChangesTabProps {
packageBase: string;
repo: RepositoryId;
}
export default function ChangesTab({ packageBase, repo }: ChangesTabProps): React.JSX.Element {
const client = useClient();
const codeRef = useRef<HTMLElement>(null);
const { data } = useQuery<Changes>({
queryKey: QueryKeys.changes(packageBase, repo),
queryFn: () => client.fetchPackageChanges(packageBase, repo),
enabled: !!packageBase,
});
const changesText = data?.changes ?? "";
useEffect(() => {
if (codeRef.current) {
codeRef.current.innerHTML = hljs.highlight(changesText, { language: "diff" }).value;
}
}, [changesText]);
return (
<Box sx={{ mt: 1 }}>
<CodeBlock codeRef={codeRef} className="language-diff" getText={() => changesText} maxHeight={400} />
</Box>
);
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Box } from "@mui/material";
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
import { useQuery } from "@tanstack/react-query";
import EventDurationLineChart from "components/charts/EventDurationLineChart";
import { formatTimestamp } from "components/common/formatTimestamp";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Event } from "models/Event";
import type { RepositoryId } from "models/RepositoryId";
import type React from "react";
interface EventsTabProps {
packageBase: string;
repo: RepositoryId;
}
interface EventRow {
id: number;
timestamp: string;
event: string;
message: string;
}
const columns: GridColDef<EventRow>[] = [
{ field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
{ field: "event", headerName: "event", flex: 1 },
{ field: "message", headerName: "description", flex: 2 },
];
export default function EventsTab({ packageBase, repo }: EventsTabProps): React.JSX.Element {
const client = useClient();
const { data: events = [] } = useQuery<Event[]>({
queryKey: QueryKeys.events(repo, packageBase),
queryFn: () => client.fetchPackageEvents(repo, packageBase, 30),
enabled: !!packageBase,
});
const rows: EventRow[] = events.map((e, idx) => ({
id: idx,
timestamp: formatTimestamp(e.created),
event: e.event,
message: e.message ?? "",
}));
return (
<Box sx={{ mt: 1 }}>
<EventDurationLineChart events={events} />
<DataGrid
rows={rows}
columns={columns}
density="compact"
initialState={{
sorting: { sortModel: [{ field: "timestamp", sort: "desc" }] },
}}
pageSizeOptions={[10, 25]}
autoHeight
sx={{ mt: 1 }}
disableRowSelectionOnClick
/>
</Box>
);
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Grid, Link,Typography } from "@mui/material";
import type { Dependencies } from "models/Dependencies";
import type { Package } from "models/Package";
import type { PackageProperties } from "models/PackageProperties";
import React from "react";
interface PackageDetailsGridProps {
pkg: Package;
dependencies?: Dependencies;
}
function listToString(items: string[]): React.ReactNode {
const unique = [...new Set(items)].sort();
return unique.map((item, i) => (
<React.Fragment key={item}>
{item}
{i < unique.length - 1 && <br />}
</React.Fragment>
));
}
export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetailsGridProps): React.JSX.Element {
const packagesList = Object.entries(pkg.packages)
.map(([name, p]) => `${name}${p.description ? ` (${p.description})` : ""}`);
const groups = Object.values(pkg.packages)
.flatMap((p: PackageProperties) => p.groups ?? []);
const licenses = Object.values(pkg.packages)
.flatMap((p: PackageProperties) => p.licenses ?? []);
const upstreamUrls = [...new Set(
Object.values(pkg.packages)
.map((p: PackageProperties) => p.url)
.filter((u): u is string => !!u),
)].sort();
const aurUrl = pkg.remote.web_url;
const pkgNames = Object.keys(pkg.packages);
const allDepends = Object.values(pkg.packages).flatMap((p: PackageProperties) => {
const deps = (p.depends ?? []).filter(d => !pkgNames.includes(d));
const makeDeps = (p.make_depends ?? []).filter(d => !pkgNames.includes(d)).map(d => `${d} (make)`);
const optDeps = (p.opt_depends ?? []).filter(d => !pkgNames.includes(d)).map(d => `${d} (optional)`);
return [...deps, ...makeDeps, ...optDeps];
});
const implicitDepends = dependencies
? Object.values(dependencies.paths).flat()
: [];
return (
<>
<Grid container spacing={1} sx={{ mt: 1 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{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>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packager</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
<Grid size={{ xs: 4, md: 1 }} />
<Grid size={{ xs: 8, md: 5 }} />
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{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">{listToString(licenses)}</Typography></Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">upstream</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}>
{upstreamUrls.map(url => (
<Link key={url} href={url} target="_blank" rel="noopener" underline="hover" display="block" variant="body2">
{url}
</Link>
))}
</Grid>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}>
<Typography variant="body2">
{aurUrl && (
<Link href={aurUrl} target="_blank" rel="noopener" underline="hover">AUR link</Link>
)}
</Typography>
</Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 0.5 }}>
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{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">{listToString(implicitDepends)}</Typography></Grid>
</Grid>
</>
);
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import DeleteIcon from "@mui/icons-material/Delete";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RefreshIcon from "@mui/icons-material/Refresh";
import { Button, Checkbox,DialogActions, FormControlLabel } from "@mui/material";
import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type React from "react";
interface PackageInfoActionsProps {
isAuthorized: boolean;
refreshDb: boolean;
onRefreshDbChange: (checked: boolean) => void;
onUpdate: () => void;
onRemove: () => void;
onReload: () => void;
autorefreshIntervals: AutoRefreshInterval[];
autoRefreshInterval: number;
onAutoRefreshIntervalChange: (interval: number) => void;
}
export default function PackageInfoActions({
isAuthorized,
refreshDb,
onRefreshDbChange,
onUpdate,
onRemove,
onReload,
autorefreshIntervals,
autoRefreshInterval,
onAutoRefreshIntervalChange,
}: PackageInfoActionsProps): React.JSX.Element {
return (
<DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
{isAuthorized && (
<>
<FormControlLabel
control={<Checkbox checked={refreshDb} onChange={(_, checked) => onRefreshDbChange(checked)} size="small" />}
label="update pacman databases"
/>
<Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
update
</Button>
<Button onClick={onRemove} variant="contained" color="error" startIcon={<DeleteIcon />} size="small">
remove
</Button>
</>
)}
<Button onClick={onReload} variant="outlined" color="secondary" startIcon={<RefreshIcon />} size="small">
reload
</Button>
<AutoRefreshControl
intervals={autorefreshIntervals}
currentInterval={autoRefreshInterval}
onIntervalChange={onAutoRefreshIntervalChange}
/>
</DialogActions>
);
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import DeleteIcon from "@mui/icons-material/Delete";
import { Box, Chip, IconButton,Typography } from "@mui/material";
import type { Patch } from "models/Patch";
import type React from "react";
interface PackagePatchesListProps {
patches: Patch[];
editable: boolean;
onDelete: (key: string) => void;
}
export default function PackagePatchesList({ patches, editable, onDelete }: PackagePatchesListProps): React.JSX.Element | null {
if (patches.length === 0) {
return null;
}
return (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>Environment variables</Typography>
{patches.map(patch => (
<Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<Chip label={patch.key} size="small" />
<Typography variant="body2">=</Typography>
<Typography variant="body2" sx={{ fontFamily: "monospace" }}>{JSON.stringify(patch.value)}</Typography>
{editable && (
<IconButton size="small" color="error" onClick={() => onDelete(patch.key)}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Box>
))}
</Box>
);
}