Compare commits

..

2 Commits

Author SHA1 Message Date
bfb51434a0 initial impl 2026-03-18 16:53:25 +02:00
a04b6c3b9c refactor: move package archive lockup to package info trait 2026-03-15 20:23:20 +02:00
151 changed files with 871 additions and 1831 deletions

View File

@@ -1,7 +1,7 @@
version: 2
build:
os: ubuntu-lts-latest
os: ubuntu-20.04
tools:
python: "3.12"
apt_packages:

View File

@@ -164,14 +164,6 @@ ahriman.application.handlers.restore module
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.rollback module
--------------------------------------------
.. automodule:: ahriman.application.handlers.rollback
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.run module
---------------------------------------

View File

@@ -12,14 +12,6 @@ ahriman.web.middlewares.auth\_handler module
:no-undoc-members:
:show-inheritance:
ahriman.web.middlewares.etag\_handler module
--------------------------------------------
.. automodule:: ahriman.web.middlewares.etag_handler
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.middlewares.exception\_handler module
-------------------------------------------------

View File

@@ -252,14 +252,6 @@ ahriman.web.schemas.package\_version\_schema module
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.packager\_schema module
-------------------------------------------
.. automodule:: ahriman.web.schemas.packager_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.pagination\_schema module
---------------------------------------------
@@ -340,14 +332,6 @@ ahriman.web.schemas.repository\_stats\_schema module
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.rollback\_schema module
-------------------------------------------
.. automodule:: ahriman.web.schemas.rollback_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.search\_schema module
-----------------------------------------

View File

@@ -68,14 +68,6 @@ ahriman.web.views.v1.service.request module
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.service.rollback module
--------------------------------------------
.. automodule:: ahriman.web.views.v1.service.rollback
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.service.search module
------------------------------------------

View File

@@ -235,18 +235,6 @@ Remove packages
This flow removes package from filesystem, updates repository database and also runs synchronization and reporting methods.
Rollback packages
^^^^^^^^^^^^^^^^^
This flow restores a package to a previously built version:
#. Load the current package definition from the repository database.
#. Replace its version with the requested rollback target.
#. Search the archive directory for built artifacts (packages and signatures) matching the target version.
#. Add the found artifacts to the repository via the same path as ``package-add`` with ``PackageSource.Archive``.
#. Trigger an immediate update to process the added packages.
#. If ``--hold`` is enabled (the default), mark the package as held in the database to prevent automatic updates from overriding the rollback.
Check outdated packages
^^^^^^^^^^^^^^^^^^^^^^^

View File

@@ -180,10 +180,6 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed
* ``address`` - optional address in form ``proto://host:port`` (``port`` can be omitted in case of default ``proto`` ports), will be used instead of ``http://{host}:{port}`` in case if set, string, optional. This option is required in case if ``OAuth`` provider is used.
* ``autorefresh_intervals`` - enable page auto refresh options, space separated list of integers, optional. The first defined interval will be used as default. If no intervals set, the auto refresh buttons will be disabled. If first element of the list equals ``0``, auto refresh will be disabled by default.
* ``cors_allow_headers`` - allowed CORS headers, space separated list of strings, optional.
* ``cors_allow_methods`` - allowed CORS methods, space separated list of strings, optional.
* ``cors_allow_origins`` - allowed CORS origins, space separated list of strings, optional, default ``*``.
* ``cors_expose_headers`` - exposed CORS headers, space separated list of strings, optional.
* ``enable_archive_upload`` - allow to upload packages via HTTP (i.e. call of ``/api/v1/service/upload`` uri), boolean, optional, default ``no``.
* ``host`` - host to bind, string, optional.
* ``index_url`` - full URL of the repository index page, string, optional.

View File

@@ -33,28 +33,3 @@ The service provides several commands aim to do easy repository backup and resto
.. code-block:: shell
sudo -u ahriman ahriman repo-rebuild --from-database
Package rollback
================
If the ``archive.keep_built_packages`` option is enabled, the service keeps previously built package files in the archive directory. These archives can be used to rollback a package to a previous successfully built version.
#.
List available archive versions for a package:
.. code-block:: shell
ahriman package-archives ahriman
#.
Rollback the package to the desired version:
.. code-block:: shell
sudo -u ahriman ahriman package-rollback ahriman 2.19.0-1
By default, the ``--hold`` flag is enabled, which prevents the package from being automatically updated on subsequent ``repo-update`` runs. To rollback without holding the package use:
.. code-block:: shell
sudo -u ahriman ahriman package-rollback ahriman 2.19.0-1 --no-hold

View File

@@ -1,6 +1,5 @@
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import simpleImportSort from "eslint-plugin-simple-import-sort";
@@ -9,11 +8,7 @@ import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [
js.configs.recommended,
react.configs.flat.recommended,
...tseslint.configs.recommendedTypeChecked,
],
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
@@ -22,14 +17,13 @@ export default tseslint.config(
},
},
plugins: {
"@stylistic": stylistic,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"simple-import-sort": simpleImportSort,
"@stylistic": stylistic,
},
rules: {
...reactHooks.configs.recommended.rules,
"react/react-in-jsx-scope": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// imports
@@ -39,7 +33,7 @@ export default tseslint.config(
// core
"curly": "error",
"eqeqeq": "error",
"no-console": ["warn", { allow: ["warn", "error"] }],
"no-console": "error",
"no-eval": "error",
// stylistic
@@ -74,7 +68,6 @@ export default tseslint.config(
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
"@typescript-eslint/no-deprecated": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",

View File

@@ -1,36 +1,8 @@
{
"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-error-boundary": ">=6.1.0 <6.2.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": ">=7.37.0 <7.38.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",
"private": true,
"type": "module",
"version": "2.20.0",
"scripts": {
"build": "tsc && vite build",
"dev": "vite",
@@ -38,6 +10,32 @@
"lint:fix": "eslint --fix src/",
"preview": "vite preview"
},
"type": "module",
"version": "2.20.0"
"dependencies": {
"@emotion/react": "^11.14.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.90.21",
"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({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
retry: 1,
},
},
});

View File

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

View File

@@ -24,7 +24,6 @@ import type { Event } from "models/Event";
import type { InfoResponse } from "models/InfoResponse";
import type { InternalStatus } from "models/InternalStatus";
import type { LogRecord } from "models/LogRecord";
import type { Package } from "models/Package";
import type { PackageStatus } from "models/PackageStatus";
import type { Patch } from "models/Patch";
import { RepositoryId } from "models/RepositoryId";
@@ -43,12 +42,6 @@ export class FetchClient {
});
}
async fetchPackageArtifacts(packageBase: string, repository: RepositoryId): Promise<Package[]> {
return this.client.request<Package[]>(`/api/v1/packages/${encodeURIComponent(packageBase)}/archives`, {
query: repository.toQuery(),
});
}
async fetchPackageChanges(packageBase: string, repository: RepositoryId): Promise<Changes> {
return this.client.request<Changes>(`/api/v1/packages/${encodeURIComponent(packageBase)}/changes`, {
query: repository.toQuery(),

View File

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

View File

@@ -23,7 +23,6 @@ import type { PackageActionRequest } from "models/PackageActionRequest";
import type { PGPKey } from "models/PGPKey";
import type { PGPKeyRequest } from "models/PGPKeyRequest";
import type { RepositoryId } from "models/RepositoryId";
import type { RollbackRequest } from "models/RollbackRequest";
export class ServiceClient {
@@ -37,14 +36,6 @@ export class ServiceClient {
return this.client.request("/api/v1/service/add", { method: "POST", query: repository.toQuery(), json: data });
}
async servicePackageHold(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 servicePackagePatchRemove(packageBase: string, key: string): Promise<void> {
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, {
method: "DELETE",
@@ -67,14 +58,6 @@ 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[]> {
return this.client.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
}
@@ -95,6 +78,14 @@ export class ServiceClient {
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> {
return this.client.request("/api/v1/service/rebuild", {
method: "POST",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,55 +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 { Box, Button, Typography } from "@mui/material";
import type React from "react";
import type { FallbackProps } from "react-error-boundary";
interface ErrorDetails {
message: string;
stack: string | undefined;
}
export default function ErrorFallback({ error }: FallbackProps): React.JSX.Element {
const details: ErrorDetails = error instanceof Error
? { message: error.message, stack: error.stack }
: { message: String(error), stack: undefined };
return <Box role="alert" sx={{ color: "text.primary", minHeight: "100vh", p: 6 }}>
<Typography sx={{ fontWeight: 700 }} variant="h4">
Something went wrong
</Typography>
<Typography color="error" sx={{ fontFamily: "monospace", mt: 2 }}>
{details.message}
</Typography>
{details.stack && <Typography
component="pre"
sx={{ color: "text.secondary", fontFamily: "monospace", fontSize: "0.75rem", mt: 3, whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
{details.stack}
</Typography>}
<Box sx={{ display: "flex", gap: 2, mt: 4 }}>
<Button onClick={() => window.location.reload()} variant="outlined">Reload page</Button>
</Box>
</Box>;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,119 +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 RestoreIcon from "@mui/icons-material/Restore";
import { Box, IconButton, Tooltip } from "@mui/material";
import { DataGrid, type GridColDef } from "@mui/x-data-grid";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError } from "api/client/ApiError";
import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth";
import { useClient } from "hooks/useClient";
import { useNotification } from "hooks/useNotification";
import type { RepositoryId } from "models/RepositoryId";
import type React from "react";
import { useCallback, useMemo } from "react";
import { DETAIL_TABLE_PROPS } from "utils";
interface ArtifactsTabProps {
currentVersion: string;
packageBase: string;
repository: RepositoryId;
}
interface ArtifactRow {
id: string;
packager: string;
packages: string[];
version: string;
}
const staticColumns: GridColDef<ArtifactRow>[] = [
{ align: "right", field: "version", flex: 1, headerAlign: "right", headerName: "version" },
{
field: "packages",
flex: 2,
headerName: "packages",
renderCell: params =>
<Box sx={{ whiteSpace: "pre-line" }}>{params.row.packages.join("\n")}</Box>,
},
{ field: "packager", flex: 1, headerName: "packager" },
];
export default function ArtifactsTab({
currentVersion,
packageBase,
repository,
}: ArtifactsTabProps): React.JSX.Element {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthorized } = useAuth();
const { showSuccess, showError } = useNotification();
const { data: rows = [] } = useQuery<ArtifactRow[]>({
enabled: !!packageBase,
queryFn: async () => {
const packages = await client.fetch.fetchPackageArtifacts(packageBase, repository);
return packages.map(artifact => ({
id: artifact.version,
packager: artifact.packager ?? "",
packages: Object.keys(artifact.packages).sort(),
version: artifact.version,
})).reverse();
},
queryKey: QueryKeys.artifacts(packageBase, repository),
});
const handleRollback = useCallback(async (version: string): Promise<void> => {
try {
await client.service.servicePackageRollback(repository, { package: packageBase, version });
void queryClient.invalidateQueries({ queryKey: QueryKeys.artifacts(packageBase, repository) });
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(packageBase, repository) });
showSuccess("Success", `Rollback ${packageBase} to ${version} has been started`);
} catch (exception) {
showError("Action failed", `Rollback failed: ${ApiError.errorDetail(exception)}`);
}
}, [client, repository, packageBase, queryClient, showSuccess, showError]);
const columns = useMemo<GridColDef<ArtifactRow>[]>(() => [
...staticColumns,
...isAuthorized ? [{
field: "actions",
filterable: false,
headerName: "",
renderCell: params =>
<Tooltip title={params.row.version === currentVersion ? "Current version" : "Rollback to this version"}>
<span>
<IconButton
disabled={params.row.version === currentVersion}
onClick={() => void handleRollback(params.row.version)}
size="small"
>
<RestoreIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>,
width: 60,
} satisfies GridColDef<ArtifactRow>] : [],
], [isAuthorized, currentVersion, handleRollback]);
return <Box sx={{ mt: 1 }}>
<DataGrid columns={columns} getRowHeight={() => "auto"} rows={rows} {...DETAIL_TABLE_PROPS} />
</Box>;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,12 +17,11 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export type TabKey = "logs" | "changes" | "pkgbuild" | "events" | "artifacts";
export type TabKey = "logs" | "changes" | "pkgbuild" | "events";
export const tabs: { key: TabKey; label: string }[] = [
{ key: "logs", label: "Build logs" },
{ key: "changes", label: "Changes" },
{ key: "pkgbuild", label: "PKGBUILD" },
{ key: "events", label: "Events" },
{ key: "artifacts", label: "Artifacts" },
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,8 +21,6 @@ import type { RepositoryId } from "models/RepositoryId";
export const QueryKeys = {
artifacts: (packageBase: string, repository: RepositoryId) => ["artifacts", repository.key, packageBase] as const,
changes: (packageBase: string, repository: RepositoryId) => ["changes", repository.key, packageBase] as const,
dependencies: (packageBase: string, repository: RepositoryId) => ["dependencies", repository.key, packageBase] as const,

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,30 +27,35 @@ import type { PackageRow } from "models/PackageRow";
import { useEffect } from "react";
export interface UsePackageTableResult {
autoRefreshInterval: number;
columnVisibility: Record<string, boolean>;
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
filterModel: GridFilterModel;
handleRefreshDatabase: () => Promise<void>;
handleReload: () => 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;
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;
onAutoRefreshIntervalChange: (interval: number) => void;
handleReload: () => void;
handleUpdate: () => Promise<void>;
handleRefreshDatabase: () => Promise<void>;
handleRemove: () => Promise<void>;
}
export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
@@ -66,13 +71,16 @@ export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): Us
}, [isDialogOpen, setPaused]);
return {
autoRefreshInterval: autoRefresh.interval,
rows,
isLoading,
isAuthorized,
onAutoRefreshIntervalChange: autoRefresh.setInterval,
rows,
status,
...actions,
...tableState,
autoRefreshInterval: autoRefresh.interval,
onAutoRefreshIntervalChange: autoRefresh.setInterval,
...actions,
};
}

View File

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

View File

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

View File

@@ -21,18 +21,11 @@ import "chartSetup";
import "utils";
import App from "App";
import ErrorFallback from "components/common/ErrorBoundary";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ErrorBoundary } from "react-error-boundary";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, info) => console.error("Uncaught error:", error, info.componentStack)}
>
<App />
</ErrorBoundary>
</StrictMode>,
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +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/>.
*/
export interface RollbackRequest {
package: string;
version: string;
}

View File

@@ -20,7 +20,7 @@
import type { BuildStatus } from "models/BuildStatus";
export interface Status {
is_held?: boolean;
status: BuildStatus;
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";
const base: Record<BuildStatus, string> = {
unknown: grey[600],
pending: amber[700],
building: orange[800],
failed: red[700],
pending: amber[700],
success: green[700],
unknown: grey[600],
};
const headerBase: Record<BuildStatus, string> = {
unknown: grey[600],
pending: amber[700],
building: orange[600],
failed: red[500],
pending: amber[700],
success: green[600],
unknown: grey[600],
};
export const StatusColors = base;

View File

@@ -19,14 +19,6 @@
*/
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 {
return intervals.find(interval => interval.is_active)?.interval ?? 0;
}

View File

@@ -4,18 +4,18 @@
"baseUrl": "src",
"isolatedModules": true,
"jsx": "react-jsx",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ESNext",
"target": "ES2020",
"useDefineForClassFields": true
},
"include": ["src"]

View File

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

View File

@@ -30,14 +30,6 @@ allow_read_only = yes
; If no intervals set, auto refresh will be disabled. 0 can only be the first element and will disable auto refresh
; by default.
autorefresh_intervals = 5 1 10 30 60
; Allowed CORS headers. By default everything is allowed.
;cors_allow_headers =
; Allowed CORS methods. By default everything is allowed.
;cors_allow_methods =
; Allowed CORS origins.
;cors_allow_origins = *
; Exposed CORS headers. By default everything is exposed.
;cors_expose_headers =
; Enable file upload endpoint used by some triggers.
;enable_archive_upload = no
; Address to bind the server.

View File

@@ -156,14 +156,15 @@ class ApplicationRepository(ApplicationProperties):
result = Result()
# process already built packages if any
if built_packages := self.repository.packages_built(): # speedup a bit
built_packages = self.repository.packages_built()
if built_packages: # speedup a bit
build_result = self.repository.process_update(built_packages, packagers)
self.on_result(build_result)
result.merge(build_result)
# filter packages which were prebuilt
succeeded = {package.base for package in build_result.success}
updates = [package for package in updates if package.base not in succeeded]
updates = filter(lambda package: package.base not in succeeded, updates)
builder = Updater.load(self.repository_id, self.configuration, self.repository)

View File

@@ -19,7 +19,6 @@
#
import argparse
from dataclasses import replace
from pathlib import Path
from ahriman.application.application import Application
@@ -63,9 +62,9 @@ class Rollback(Handler):
application.reporter.package_hold_update(package.base, enabled=True)
@staticmethod
def _set_package_rollback_parser(root: SubParserAction) -> argparse.ArgumentParser:
def _set_package_archives_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package rollback subcommand
add parser for package archives subcommand
Args:
root(SubParserAction): subparsers for the commands
@@ -81,14 +80,14 @@ class Rollback(Handler):
action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("-u", "--username", help="build as user", default=extract_user())
parser.set_defaults(aur=False, changes=False, check_files=False, dependencies=False, dry_run=False,
exit_code=True, increment=False, now=True, local=False, manual=False, refresh=False,
exit_code=False, increment=False, now=True, local=False, manual=False, refresh=False,
source=PackageSource.Archive, variable=None, vcs=False)
return parser
@staticmethod
def package_artifacts(application: Application, package: Package) -> list[Path]:
"""
look for requested package artifacts and return paths to them
look for package artifacts and returns paths to them if any
Args:
application(Application): application instance
@@ -103,13 +102,13 @@ class Rollback(Handler):
# lookup for built artifacts
artifacts = application.repository.package_archives_lookup(package)
if not artifacts:
raise UnknownPackageError(package.base)
raise UnknownPackageError(package.base) from None
return artifacts
@staticmethod
def package_load(application: Application, package_base: str, version: str) -> Package:
"""
load package from repository, while setting requested version
load package from given arguments
Args:
application(Application): application instance
@@ -124,8 +123,10 @@ class Rollback(Handler):
"""
try:
package, _ = next(iter(application.reporter.package_get(package_base)))
return replace(package, version=version)
package.version = version
return package
except StopIteration:
raise UnknownPackageError(package_base) from None
arguments = [_set_package_rollback_parser]
arguments = [_set_package_archives_parser]

View File

@@ -104,7 +104,7 @@ class PkgbuildParser(shlex.shlex):
# ignore substitution and extend bash symbols
self.wordchars += "${}#:+-@!"
# in case of default behavior, it will ignore, for example, segment part of url outside of quotes
# in case of default behaviour, it will ignore, for example, segment part of url outside of quotes
self.commenters = ""
@staticmethod

View File

@@ -72,7 +72,7 @@ class AUR(Remote):
parse RPC response to package list
Args:
response(dict[str, Any]): RPC response JSON
response(dict[str, Any]): RPC response json
Returns:
list[AURPackage]: list of parsed packages

View File

@@ -74,7 +74,7 @@ class Official(Remote):
parse RPC response to package list
Args:
response(dict[str, Any]): RPC response JSON
response(dict[str, Any]): RPC response json
Returns:
list[AURPackage]: list of parsed packages

View File

@@ -32,7 +32,7 @@ class OfficialSyncdb(Official):
updates.
This approach also has limitations, because we don't require superuser rights (neither going to download database
separately), the database file might be outdated and must be handled manually (or kind of). This behavior might be
separately), the database file might be outdated and must be handled manually (or kind of). This behaviour might be
changed in the future.
Still we leave search function based on the official repositories RPC.

View File

@@ -50,13 +50,13 @@ class Auth(LazyLogging):
@property
def auth_control(self) -> str:
"""
This workaround is required to make different behavior for login interface.
This workaround is required to make different behaviour for login interface.
In case of internal authentication it must provide an interface (modal form) to log in with button sends POST
request. But for an external providers behavior can be different: e.g. OAuth provider requires sending GET
request. But for an external providers behaviour can be different: e.g. OAuth provider requires sending GET
request to external resource
Returns:
str: login control as HTML code to insert
str: login control as html code to insert
"""
return "<button type=\"button\" class=\"btn btn-link\" data-bs-toggle=\"modal\" data-bs-target=\"#login-modal\" style=\"text-decoration: none\"><i class=\"bi bi-box-arrow-in-right\"></i> login</button>"

View File

@@ -30,13 +30,7 @@ except ImportError:
from typing import Any
__all__ = [
"authorized_userid",
"check_authorized",
"forget",
"get_session",
"remember",
]
__all__ = ["authorized_userid", "check_authorized", "forget", "remember"]
async def authorized_userid(*args: Any, **kwargs: Any) -> Any:

View File

@@ -62,10 +62,10 @@ class OAuth(Mapping):
@property
def auth_control(self) -> str:
"""
get authorization HTML control
get authorization html control
Returns:
str: login control as HTML code to insert
str: login control as html code to insert
"""
return "<a class=\"nav-link\" href=\"/api/v1/login\" title=\"login via OAuth2\"><i class=\"bi bi-box-arrow-in-right\"></i> login</a>"

View File

@@ -416,7 +416,7 @@ class Sources(LazyLogging):
else:
patch.write(sources_dir / "PKGBUILD")
def read(self, sources_dir: Path, commit_sha: str, path: Path) -> str | None:
def read(self, sources_dir: Path, commit_sha: str, path: Path) -> str:
"""
read file content from the specified commit
@@ -426,10 +426,6 @@ class Sources(LazyLogging):
path(Path): path to file inside the repository
Returns:
str | None: file content at specified commit if available
str: file content at specified commit
"""
try:
return check_output(*self.git(), "show", f"{commit_sha}:{path}", cwd=sources_dir, logger=self.logger)
except CalledProcessError:
self.logger.exception("failed to read file %s at %s", path, commit_sha)
return None

View File

@@ -72,7 +72,7 @@ class Configuration(configparser.RawConfigParser):
def __init__(self, allow_no_value: bool = False, allow_multi_key: bool = True) -> None:
"""
Args:
allow_no_value(bool, optional): copies :class:`configparser.RawConfigParser` behavior. In case if it is set
allow_no_value(bool, optional): copies :class:`configparser.RawConfigParser` behaviour. In case if it is set
to ``True``, the keys without values will be allowed (Default value = False)
allow_multi_key(bool, optional): if set to ``False``, then the default dictionary class will be used to
store keys internally. Otherwise, the special implementation will be used, which supports arrays
@@ -80,7 +80,7 @@ class Configuration(configparser.RawConfigParser):
"""
configparser.RawConfigParser.__init__(
self,
dict_type=ConfigurationMultiDict if allow_multi_key else dict,
dict_type=ConfigurationMultiDict if allow_multi_key else dict, # type: ignore[arg-type]
allow_no_value=allow_no_value,
strict=False,
empty_lines_in_values=not allow_multi_key,

View File

@@ -358,38 +358,6 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"min": 0,
},
},
"cors_allow_headers": {
"type": "list",
"coerce": "list",
"schema": {
"type": "string",
"empty": False,
},
},
"cors_allow_methods": {
"type": "list",
"coerce": "list",
"schema": {
"type": "string",
"empty": False,
},
},
"cors_allow_origins": {
"type": "list",
"coerce": "list",
"schema": {
"type": "string",
"empty": False,
},
},
"cors_expose_headers": {
"type": "list",
"coerce": "list",
"schema": {
"type": "string",
"empty": False,
},
},
"enable_archive_upload": {
"type": "boolean",
"coerce": "boolean",

View File

@@ -150,6 +150,6 @@ class ShellTemplate(Template):
break
kwargs.update(mapping)
kwargs.update(dict(generator(kwargs)))
substituted = dict(generator(kwargs))
return self.safe_substitute(kwargs)
return self.safe_substitute(kwargs | substituted)

View File

@@ -81,7 +81,7 @@ class ChangesOperations(Operations):
values
(:package_base, :last_commit_sha, :changes, :pkgbuild, :repository)
on conflict (package_base, repository) do update set
last_commit_sha = :last_commit_sha, changes = :changes, pkgbuild = coalesce(:pkgbuild, pkgbuild)
last_commit_sha = :last_commit_sha, changes = :changes, pkgbuild = :pkgbuild
""",
{
"package_base": package_base,

View File

@@ -171,7 +171,7 @@ class SyncHttpClient(LazyLogging):
headers(dict[str, str] | None, optional): request headers (Default value = None)
params(list[tuple[str, str]] | None, optional): request query parameters (Default value = None)
data(Any | None, optional): request raw data parameters (Default value = None)
json(dict[str, Any] | None, optional): request JSON parameters (Default value = None)
json(dict[str, Any] | None, optional): request json parameters (Default value = None)
files(dict[str, MultipartType] | None, optional): multipart upload (Default value = None)
stream(bool | None, optional): handle response as stream (Default value = None)
session(requests.Session | None, optional): session object if any (Default value = None)

View File

@@ -27,7 +27,7 @@ from ahriman.models.result import Result
class Console(Report):
"""
HTML report generator
html report generator
Attributes:
use_utf(bool): print utf8 symbols instead of ASCII

View File

@@ -27,10 +27,10 @@ from ahriman.models.result import Result
class HTML(Report, JinjaTemplate):
"""
HTML report generator
html report generator
Attributes:
report_path(Path): output path to HTML report
report_path(Path): output path to html report
template(Path | str): name or path to template for full package list
"""

View File

@@ -71,7 +71,7 @@ class Report(LazyLogging):
Args:
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
target(str): target to generate report aka section name (e.g. HTML)
target(str): target to generate report aka section name (e.g. html)
Returns:
Report: client according to current settings

View File

@@ -88,13 +88,13 @@ class PackageInfo(LazyLogging):
return sorted(result)
def load_archives(self, packages: Iterable[Path], *, latest_only: bool = True) -> list[Package]:
def load_archives(self, packages: Iterable[Path], *, newest_only: bool = True) -> list[Package]:
"""
load packages from list of archives
Args:
packages(Iterable[Path]): paths to package archives
latest_only(bool, optional): filter packages with the same base, keeping only fresh packages installed
newest_only(bool, optional): filter packages with the same base, keeping only fresh packages installed
(Default value = True)
Returns:
@@ -116,7 +116,7 @@ class PackageInfo(LazyLogging):
except Exception:
self.logger.exception("could not load package from %s", full_path)
if latest_only:
if newest_only:
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
for package_base, versions in result.items():
newest = max(versions.values(), key=cmp_to_key(comparator))
@@ -140,17 +140,16 @@ class PackageInfo(LazyLogging):
Returns:
list[Package]: list of packages belonging to this base, sorted by version by ascension
"""
archive = self.paths.archive_for(package_base)
if not archive.is_dir():
return []
packages = self.load_archives(filter(package_like, archive.iterdir()), latest_only=False)
packages: dict[tuple[str, str], Package] = {}
# we can't use here load_archives, because it ignores versions
for full_path in filter(package_like, self.paths.archive_for(package_base).iterdir()):
local = Package.from_archive(full_path)
if not local.supports_architecture(self.repository_id.architecture):
continue
packages.setdefault((local.base, local.version), local).packages.update(local.packages)
comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version)
return sorted(
(package for package in packages if package.supports_architecture(self.repository_id.architecture)),
key=cmp_to_key(comparator),
)
return sorted(packages.values(), key=cmp_to_key(comparator))
def package_archives_lookup(self, package: Package) -> list[Path]:
"""
@@ -163,11 +162,18 @@ class PackageInfo(LazyLogging):
list[Path]: list of built packages and signatures if available, empty list otherwise
"""
archive = self.paths.archive_for(package.base)
if not archive.is_dir():
return []
for built in self.package_archives(package.base):
for built in self.load_archives(filter(package_like, archive.iterdir()), newest_only=False):
# check if package version is the same
if built.version != package.version:
continue
# all packages must be either any or same architecture
if not built.supports_architecture(self.repository_id.architecture):
continue
return list_flatmap(built.packages.values(), lambda single: archive.glob(f"{single.filename}*"))
return []

View File

@@ -232,27 +232,6 @@ class Spawn(Thread, LazyLogging):
"""
return self._spawn_process(repository_id, "package-remove", *packages)
def packages_rollback(self, repository_id: RepositoryId, package: str, version: str, username: str | None, *,
hold: bool) -> str:
"""
rollback package
Args:
repository_id(RepositoryId): repository unique identifier
package(str): package base to rollback
version(str): package version to rollback
username(str | None): optional override of username for build process
hold(bool): hold package after rollback
Returns:
str: spawned process identifier
"""
kwargs = {
"username": username,
self.boolean_action_argument("hold", hold): "",
}
return self._spawn_process(repository_id, "package-rollback", package, version, **kwargs)
def packages_update(self, repository_id: RepositoryId, username: str | None, *,
aur: bool, local: bool, manual: bool, increment: bool, refresh: bool) -> str:
"""

View File

@@ -34,7 +34,7 @@ class Trigger(LazyLogging):
Attributes:
CONFIGURATION_SCHEMA(ConfigurationSchema): (class attribute) configuration schema template
REQUIRES_REPOSITORY(bool): (class attribute) either trigger requires a repository to be loaded or not
REQUIRES_REPOSITORY(bool): (class attribute) either trigger requires loaded repository or not
configuration(Configuration): configuration instance
repository_id(RepositoryId): repository unique identifier

View File

@@ -41,7 +41,7 @@ class HttpUpload(SyncHttpClient):
str: calculated checksum of the file
"""
with path.open("rb") as local_file:
md5 = hashlib.md5(local_file.read(), usedforsecurity=False)
md5 = hashlib.md5(local_file.read()) # nosec
return md5.hexdigest()
@staticmethod

View File

@@ -62,7 +62,9 @@ class S3(Upload):
@staticmethod
def calculate_etag(path: Path, chunk_size: int) -> str:
"""
calculate amazon s3 etag. Credits to https://teppen.io/2018/10/23/aws_s3_verify_etags/
calculate amazon s3 etag
credits to https://teppen.io/2018/10/23/aws_s3_verify_etags/
For this method we have to define nosec because it is out of any security context and provided by AWS
Args:
path(Path): path to local file
@@ -74,17 +76,14 @@ class S3(Upload):
md5s = []
with path.open("rb") as local_file:
for chunk in iter(lambda: local_file.read(chunk_size), b""):
md5s.append(hashlib.md5(chunk, usedforsecurity=False))
md5s.append(hashlib.md5(chunk)) # nosec
# in case if there is only one chunk it must be just this checksum
if len(md5s) == 1:
return md5s[0].hexdigest()
# otherwise it is checksum of joined digest (including empty list)
md5 = hashlib.md5(b"".join(md5.digest() for md5 in md5s), usedforsecurity=False)
# in case if there are more (exactly) than one chunk it should be appended with amount of chunks
# and checksum of joined digest otherwise (including empty list)
checksum = md5s[0] if len(md5s) == 1 else hashlib.md5(b"".join(md5.digest() for md5 in md5s)) # nosec
# in case if there are more than one chunk it should be appended with amount of chunks
suffix = f"-{len(md5s)}" if len(md5s) > 1 else ""
return f"{md5.hexdigest()}{suffix}"
return f"{checksum.hexdigest()}{suffix}"
@staticmethod
def files_remove(local_files: dict[Path, str], remote_objects: dict[Path, Any]) -> None:

Some files were not shown because too many files have changed in this diff Show More