Compare commits

..

14 Commits

Author SHA1 Message Date
arcanis 92ac036f78 migrate to hatch 2026-07-11 12:24:53 +03:00
arcanis aef7fa5f93 build: update frontend dependencies 2026-07-06 16:16:31 +03:00
arcanis c412b08135 fix: remove staleTime defaults for query client 2026-07-06 15:33:20 +03:00
arcanis a3b6372c11 fix: override head method for sse endpoint to make it actually working 2026-07-06 14:44:29 +03:00
arcanis 78288befb8 build: use python 3.13 lowerbound 2026-07-06 13:56:02 +03:00
arcanis 49cd3d43c8 fix: handle errors in sse stream 2026-06-09 01:01:08 +03:00
arcanis d9b52806c0 fix: clear subscriber map on shutdown
Even though it is not a case in the application, the interface could be
used externally
2026-06-07 11:58:04 +03:00
arcanis 774db2d780 type: fix typing ignore with the last typing update 2026-05-31 20:04:25 +03:00
arcanis 68afda4c71 feat: allow readonly events with read permission 2026-05-26 20:51:06 +03:00
arcanis fa9fa73078 docs: bump python version to 2.13 2026-05-08 10:25:24 +03:00
arcanis 3e1e24cb50 feat: SSE support (#162)
* event bus implementation

* update tests

* docs update

* review fixes

* update configs

* fix typo

* frontend changes

* install missing pacakge

* queue processing simplification
2026-05-08 10:20:03 +03:00
arcanis 18fe38c30b build: use build_date argument for docker image instead of guessing from pacman root 2026-05-05 13:45:42 +03:00
arcanis 6ee8b26bd5 feat: show aur url in interface 2026-04-22 06:56:28 +03:00
arcanis fce49f22c9 type: fix mypy warnings in updated version 2026-04-03 13:36:41 +03:00
34 changed files with 606 additions and 327 deletions
+6
View File
@@ -26,6 +26,10 @@ jobs:
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v3
- name: Set image date
id: args
run: echo "::set-output name=date::$(date -d yesterday +'%Y-%m-%d')"
- name: Login to docker hub - name: Login to docker hub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
@@ -53,6 +57,8 @@ jobs:
- name: Build an image and push - name: Build an image and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
build-args: |
BUILD_DATE=${{ steps.args.outputs.date }}
file: docker/Dockerfile file: docker/Dockerfile
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
+2 -2
View File
@@ -12,11 +12,11 @@ pacman -Syyu --noconfirm
# main dependencies # main dependencies
pacman -S --noconfirm devtools git npm pyalpm python-bcrypt python-filelock python-inflection python-pyelftools python-requests python-systemd sudo pacman -S --noconfirm devtools git npm pyalpm python-bcrypt python-filelock python-inflection python-pyelftools python-requests python-systemd sudo
# make dependencies # make dependencies
pacman -S --noconfirm --asdeps base-devel python-build python-flit python-installer python-tox python-wheel pacman -S --noconfirm --asdeps base-devel python-build python-hatchling python-installer python-tox python-wheel
# optional dependencies # optional dependencies
if [[ -z $MINIMAL_INSTALL ]]; then if [[ -z $MINIMAL_INSTALL ]]; then
# web server # web server
pacman -S --noconfirm python-aioauth-client python-aiohttp python-aiohttp-apispec-git python-aiohttp-cors python-aiohttp-jinja2 python-aiohttp-security python-aiohttp-session python-cryptography python-jinja pacman -S --noconfirm python-aioauth-client python-aiohttp python-aiohttp-apispec-git python-aiohttp-cors python-aiohttp-jinja2 python-aiohttp-security python-aiohttp-session python-aiohttp-sse-git python-cryptography python-jinja
# additional features # additional features
pacman -S --noconfirm gnupg ipython python-boto3 python-cerberus python-matplotlib rsync pacman -S --noconfirm gnupg ipython python-boto3 python-cerberus python-matplotlib rsync
fi fi
+1 -1
View File
@@ -3,7 +3,7 @@ version: 2
build: build:
os: ubuntu-lts-latest os: ubuntu-lts-latest
tools: tools:
python: "3.12" python: "3.13"
apt_packages: apt_packages:
- graphviz - graphviz
+6 -4
View File
@@ -1,13 +1,15 @@
# build image # build image
FROM archlinux:base AS build FROM archlinux:base AS build
ARG BUILD_DATE
# install environment # install environment
## create build user ## create build user
RUN useradd -m -d "/home/build" -s "/usr/bin/nologin" build RUN useradd -m -d "/home/build" -s "/usr/bin/nologin" build
## extract container creation date and set mirror for this timestamp, set PKGEXT and refresh database next ## extract container creation date and set mirror for this timestamp, set PKGEXT and refresh database next
RUN echo "Server = https://archive.archlinux.org/repos/$(stat -c "%y" "/var/lib/pacman" | cut -d " " -f 1 | sed "s,-,/,g")/\$repo/os/\$arch" > "/etc/pacman.d/mirrorlist" && \ RUN echo "Server = https://archive.archlinux.org/repos/${BUILD_DATE//-/\/}/\$repo/os/\$arch" > "/etc/pacman.d/mirrorlist" && \
pacman -Sy pacman -Syyuu --noconfirm
## setup package cache ## setup package cache
RUN runuser -u build -- mkdir "/tmp/pkg" && \ RUN runuser -u build -- mkdir "/tmp/pkg" && \
echo "PKGDEST=/tmp/pkg" >> "/etc/makepkg.conf" && \ echo "PKGDEST=/tmp/pkg" >> "/etc/makepkg.conf" && \
@@ -33,7 +35,7 @@ RUN pacman -S --noconfirm --asdeps \
RUN pacman -S --noconfirm --asdeps \ RUN pacman -S --noconfirm --asdeps \
base-devel \ base-devel \
python-build \ python-build \
python-flit \ python-hatchling \
python-installer \ python-installer \
python-setuptools \ python-setuptools \
python-tox \ python-tox \
@@ -108,7 +110,7 @@ RUN cp "/etc/pacman.d/mirrorlist" "/etc/pacman.d/mirrorlist.orig" && \
echo "Server = file:///var/cache/pacman/pkg" > "/etc/pacman.d/mirrorlist" && \ echo "Server = file:///var/cache/pacman/pkg" > "/etc/pacman.d/mirrorlist" && \
cp "/etc/pacman.conf" "/etc/pacman.conf.orig" && \ cp "/etc/pacman.conf" "/etc/pacman.conf.orig" && \
sed -i "s/SigLevel *=.*/SigLevel = Optional/g" "/etc/pacman.conf" && \ sed -i "s/SigLevel *=.*/SigLevel = Optional/g" "/etc/pacman.conf" && \
pacman -Sy pacman -Syyuu --noconfirm
## install package and its optional dependencies ## install package and its optional dependencies
RUN pacman -S --noconfirm ahriman RUN pacman -S --noconfirm ahriman
RUN pacman -S --noconfirm --asdeps \ RUN pacman -S --noconfirm --asdeps \
+3 -1
View File
@@ -7,8 +7,10 @@ for PACKAGE in "$@"; do
# clone the remote source # clone the remote source
git clone https://aur.archlinux.org/"$PACKAGE".git "$BUILD_DIR" git clone https://aur.archlinux.org/"$PACKAGE".git "$BUILD_DIR"
cd "$BUILD_DIR" cd "$BUILD_DIR"
# FIXME monkey patch PKGBUILD for python
sed -i 's/python -m build/python -m build --skip-dependency-check/g' "PKGBUILD"
# checkout to the image date # checkout to the image date
git checkout "$(git rev-list -1 --before="$(stat -c "%y" "/var/lib/pacman" | cut -d " " -f 1)" master)" git checkout "$(git rev-list -1 --before="$BUILD_DATE" master)"
# build and install the package # build and install the package
makepkg --nocheck --noconfirm --install --rmdeps --syncdeps makepkg --nocheck --noconfirm --install --rmdeps --syncdeps
cd / cd /
+3 -3
View File
@@ -5,7 +5,7 @@
"@mui/icons-material": ">=7.3.0 <7.4.0", "@mui/icons-material": ">=7.3.0 <7.4.0",
"@mui/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", "@mui/x-data-grid": ">=8.28.0 <8.29.0",
"@tanstack/react-query": ">=5.94.0 <5.95.0", "@tanstack/react-query": ">=5.101.0 <5.102.0",
"chart.js": ">=4.5.0 <4.6.0", "chart.js": ">=4.5.0 <4.6.0",
"react": ">=19.2.0 <19.3.0", "react": ">=19.2.0 <19.3.0",
"react-chartjs-2": ">=5.3.0 <5.4.0", "react-chartjs-2": ">=5.3.0 <5.4.0",
@@ -22,12 +22,12 @@
"@vitejs/plugin-react": ">=6.0.0 <6.1.0", "@vitejs/plugin-react": ">=6.0.0 <6.1.0",
"eslint": ">=9.39.0 <9.40.0", "eslint": ">=9.39.0 <9.40.0",
"eslint-plugin-react": ">=7.37.0 <7.38.0", "eslint-plugin-react": ">=7.37.0 <7.38.0",
"eslint-plugin-react-hooks": ">=7.0.0 <7.1.0", "eslint-plugin-react-hooks": ">=7.1.0 <7.2.0",
"eslint-plugin-react-refresh": ">=0.5.0 <0.6.0", "eslint-plugin-react-refresh": ">=0.5.0 <0.6.0",
"eslint-plugin-simple-import-sort": ">=12.1.0 <12.2.0", "eslint-plugin-simple-import-sort": ">=12.1.0 <12.2.0",
"typescript": ">=5.9.0 <5.10.0", "typescript": ">=5.9.0 <5.10.0",
"typescript-eslint": ">=8.57.0 <8.58.0", "typescript-eslint": ">=8.57.0 <8.58.0",
"vite": ">=8.0.0 <8.1.0" "vite": ">=8.1.0 <8.2.0"
}, },
"name": "ahriman-frontend", "name": "ahriman-frontend",
"private": true, "private": true,
+3 -1
View File
@@ -21,6 +21,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AppLayout from "components/layout/AppLayout"; import AppLayout from "components/layout/AppLayout";
import { AuthProvider } from "contexts/AuthProvider"; import { AuthProvider } from "contexts/AuthProvider";
import { ClientProvider } from "contexts/ClientProvider"; import { ClientProvider } from "contexts/ClientProvider";
import { EventStreamProvider } from "contexts/EventStreamProvider";
import { NotificationProvider } from "contexts/NotificationProvider"; import { NotificationProvider } from "contexts/NotificationProvider";
import { RepositoryProvider } from "contexts/RepositoryProvider"; import { RepositoryProvider } from "contexts/RepositoryProvider";
import { ThemeProvider } from "contexts/ThemeProvider"; import { ThemeProvider } from "contexts/ThemeProvider";
@@ -30,7 +31,6 @@ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
retry: 1, retry: 1,
staleTime: 30_000,
}, },
}, },
}); });
@@ -42,7 +42,9 @@ export default function App(): React.JSX.Element {
<ClientProvider> <ClientProvider>
<AuthProvider> <AuthProvider>
<RepositoryProvider> <RepositoryProvider>
<EventStreamProvider>
<AppLayout /> <AppLayout />
</EventStreamProvider>
</RepositoryProvider> </RepositoryProvider>
</AuthProvider> </AuthProvider>
</ClientProvider> </ClientProvider>
@@ -1,91 +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 CheckIcon from "@mui/icons-material/Check";
import TimerIcon from "@mui/icons-material/Timer";
import TimerOffIcon from "@mui/icons-material/TimerOff";
import { IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Tooltip } from "@mui/material";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import React, { useState } from "react";
interface AutoRefreshControlProps {
currentInterval: number;
intervals: AutoRefreshInterval[];
onIntervalChange: (interval: number) => void;
}
export default function AutoRefreshControl({
currentInterval,
intervals,
onIntervalChange,
}: AutoRefreshControlProps): React.JSX.Element | null {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
if (intervals.length === 0) {
return null;
}
const enabled = currentInterval > 0;
return <>
<Tooltip title="Auto-refresh">
<IconButton
aria-label="Auto-refresh"
color={enabled ? "primary" : "default"}
onClick={event => setAnchorEl(event.currentTarget)}
size="small"
>
{enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
open={Boolean(anchorEl)}
>
<MenuItem
onClick={() => {
onIntervalChange(0);
setAnchorEl(null);
}}
selected={!enabled}
>
<ListItemIcon>
{!enabled && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>Off</ListItemText>
</MenuItem>
{intervals.map(interval =>
<MenuItem
key={interval.interval}
onClick={() => {
onIntervalChange(interval.interval);
setAnchorEl(null);
}}
selected={enabled && interval.interval === currentInterval}
>
<ListItemIcon>
{enabled && interval.interval === currentInterval && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>{interval.text}</ListItemText>
</MenuItem>,
)}
</Menu>
</>;
}
@@ -32,27 +32,22 @@ import PkgbuildTab from "components/package/PkgbuildTab";
import { type TabKey, tabs } from "components/package/TabKey"; import { type TabKey, tabs } from "components/package/TabKey";
import { QueryKeys } from "hooks/QueryKeys"; import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth"; import { useAuth } from "hooks/useAuth";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { useClient } from "hooks/useClient"; import { useClient } from "hooks/useClient";
import { useNotification } from "hooks/useNotification"; import { useNotification } from "hooks/useNotification";
import { useRepository } from "hooks/useRepository"; import { useRepository } from "hooks/useRepository";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { Dependencies } from "models/Dependencies"; import type { Dependencies } from "models/Dependencies";
import type { PackageStatus } from "models/PackageStatus"; import type { PackageStatus } from "models/PackageStatus";
import type { Patch } from "models/Patch"; import type { Patch } from "models/Patch";
import React, { useState } from "react"; import React, { useState } from "react";
import { StatusHeaderStyles } from "theme/StatusColors"; import { StatusHeaderStyles } from "theme/StatusColors";
import { defaultInterval } from "utils";
interface PackageInfoDialogProps { interface PackageInfoDialogProps {
autoRefreshIntervals: AutoRefreshInterval[];
onClose: () => void; onClose: () => void;
open: boolean; open: boolean;
packageBase: string | null; packageBase: string | null;
} }
export default function PackageInfoDialog({ export default function PackageInfoDialog({
autoRefreshIntervals,
onClose, onClose,
open, open,
packageBase, packageBase,
@@ -77,14 +72,11 @@ export default function PackageInfoDialog({
onClose(); onClose();
}; };
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packageData } = useQuery<PackageStatus[]>({ const { data: packageData } = useQuery<PackageStatus[]>({
enabled: open, enabled: open,
queryFn: localPackageBase && currentRepository ? queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken, () => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"], queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: dependencies } = useQuery<Dependencies>({ const { data: dependencies } = useQuery<Dependencies>({
@@ -182,7 +174,6 @@ export default function PackageInfoDialog({
{activeTab === "logs" && localPackageBase && currentRepository && {activeTab === "logs" && localPackageBase && currentRepository &&
<BuildLogsTab <BuildLogsTab
packageBase={localPackageBase} packageBase={localPackageBase}
refreshInterval={autoRefresh.interval}
repository={currentRepository} repository={currentRepository}
/> />
} }
@@ -207,11 +198,8 @@ export default function PackageInfoDialog({
</DialogContent> </DialogContent>
<PackageInfoActions <PackageInfoActions
autoRefreshInterval={autoRefresh.interval}
autoRefreshIntervals={autoRefreshIntervals}
isAuthorized={isAuthorized} isAuthorized={isAuthorized}
isHeld={status?.is_held ?? false} isHeld={status?.is_held ?? false}
onAutoRefreshIntervalChange={autoRefresh.setInterval}
onHoldToggle={() => void handleHoldToggle()} onHoldToggle={() => void handleHoldToggle()}
onRefreshDatabaseChange={setRefreshDatabase} onRefreshDatabaseChange={setRefreshDatabase}
onRemove={() => void handleRemove()} onRemove={() => void handleRemove()}
+1 -1
View File
@@ -69,7 +69,7 @@ export default function AppLayout(): React.JSX.Element {
</Tooltip> </Tooltip>
</Box> </Box>
<PackageTable autoRefreshIntervals={info?.autorefresh_intervals ?? []} /> <PackageTable />
<Footer <Footer
docsEnabled={info?.docs_enabled ?? false} docsEnabled={info?.docs_enabled ?? false}
@@ -23,6 +23,7 @@ import { keepPreviousData, skipToken, useQuery } from "@tanstack/react-query";
import CodeBlock from "components/common/CodeBlock"; import CodeBlock from "components/common/CodeBlock";
import { QueryKeys } from "hooks/QueryKeys"; import { QueryKeys } from "hooks/QueryKeys";
import { useAutoScroll } from "hooks/useAutoScroll"; import { useAutoScroll } from "hooks/useAutoScroll";
import { useBuildLogStream } from "hooks/useBuildLogStream";
import { useClient } from "hooks/useClient"; import { useClient } from "hooks/useClient";
import type { LogRecord } from "models/LogRecord"; import type { LogRecord } from "models/LogRecord";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
@@ -37,7 +38,6 @@ interface Logs {
interface BuildLogsTabProps { interface BuildLogsTabProps {
packageBase: string; packageBase: string;
refreshInterval: number;
repository: RepositoryId; repository: RepositoryId;
} }
@@ -50,10 +50,10 @@ function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boole
export default function BuildLogsTab({ export default function BuildLogsTab({
packageBase, packageBase,
refreshInterval,
repository, repository,
}: BuildLogsTabProps): React.JSX.Element { }: BuildLogsTabProps): React.JSX.Element {
const client = useClient(); const client = useClient();
useBuildLogStream(packageBase, repository);
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null); const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
@@ -61,7 +61,6 @@ export default function BuildLogsTab({
enabled: !!packageBase, enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository), queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
queryKey: QueryKeys.logs(packageBase, repository), queryKey: QueryKeys.logs(packageBase, repository),
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
}); });
// Build version selectors from all logs // Build version selectors from all logs
@@ -117,7 +116,6 @@ export default function BuildLogsTab({
) )
: skipToken, : skipToken,
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""), queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
}); });
// Derive displayed logs: prefer fresh polled data when available // Derive displayed logs: prefer fresh polled data when available
@@ -98,7 +98,7 @@ export default function PackageDetailsGrid({ dependencies, pkg }: PackageDetails
<Grid size={{ md: 5, xs: 8 }}> <Grid size={{ md: 5, xs: 8 }}>
<Typography variant="body2"> <Typography variant="body2">
{aurUrl && {aurUrl &&
<Link href={aurUrl} rel="noopener noreferrer" target="_blank" underline="hover">AUR link</Link> <Link href={aurUrl} rel="noopener noreferrer" target="_blank" underline="hover">{aurUrl}</Link>
} }
</Typography> </Typography>
</Grid> </Grid>
@@ -22,16 +22,11 @@ import PauseCircleIcon from "@mui/icons-material/PauseCircle";
import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PlayCircleIcon from "@mui/icons-material/PlayCircle"; import PlayCircleIcon from "@mui/icons-material/PlayCircle";
import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material"; import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material";
import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type React from "react"; import type React from "react";
interface PackageInfoActionsProps { interface PackageInfoActionsProps {
autoRefreshInterval: number;
autoRefreshIntervals: AutoRefreshInterval[];
isAuthorized: boolean; isAuthorized: boolean;
isHeld: boolean; isHeld: boolean;
onAutoRefreshIntervalChange: (interval: number) => void;
onHoldToggle: () => void; onHoldToggle: () => void;
onRefreshDatabaseChange: (checked: boolean) => void; onRefreshDatabaseChange: (checked: boolean) => void;
onRemove: () => void; onRemove: () => void;
@@ -40,11 +35,8 @@ interface PackageInfoActionsProps {
} }
export default function PackageInfoActions({ export default function PackageInfoActions({
autoRefreshInterval,
autoRefreshIntervals,
isAuthorized, isAuthorized,
isHeld, isHeld,
onAutoRefreshIntervalChange,
onHoldToggle, onHoldToggle,
onRefreshDatabaseChange, onRefreshDatabaseChange,
onRemove, onRemove,
@@ -69,10 +61,5 @@ export default function PackageInfoActions({
</Button> </Button>
</> </>
} }
<AutoRefreshControl
currentInterval={autoRefreshInterval}
intervals={autoRefreshIntervals}
onIntervalChange={onAutoRefreshIntervalChange}
/>
</DialogActions>; </DialogActions>;
} }
+2 -13
View File
@@ -35,14 +35,9 @@ import PackageTableToolbar from "components/table/PackageTableToolbar";
import StatusCell from "components/table/StatusCell"; import StatusCell from "components/table/StatusCell";
import { useDebounce } from "hooks/useDebounce"; import { useDebounce } from "hooks/useDebounce";
import { usePackageTable } from "hooks/usePackageTable"; import { usePackageTable } from "hooks/usePackageTable";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { PackageRow } from "models/PackageRow"; import type { PackageRow } from "models/PackageRow";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
interface PackageTableProps {
autoRefreshIntervals: AutoRefreshInterval[];
}
function createListColumn( function createListColumn(
field: keyof PackageRow, field: keyof PackageRow,
headerName: string, headerName: string,
@@ -59,8 +54,8 @@ function createListColumn(
}; };
} }
export default function PackageTable({ autoRefreshIntervals }: PackageTableProps): React.JSX.Element { export default function PackageTable(): React.JSX.Element {
const table = usePackageTable(autoRefreshIntervals); const table = usePackageTable();
const apiRef = useGridApiRef(); const apiRef = useGridApiRef();
const debouncedSearch = useDebounce(table.searchText, 300); const debouncedSearch = useDebounce(table.searchText, 300);
@@ -118,11 +113,6 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
onRemoveClick: () => void table.handleRemove(), onRemoveClick: () => void table.handleRemove(),
onUpdateClick: () => void table.handleUpdate(), onUpdateClick: () => void table.handleUpdate(),
}} }}
autoRefresh={{
autoRefreshIntervals,
currentInterval: table.autoRefreshInterval,
onIntervalChange: table.onAutoRefreshIntervalChange,
}}
isAuthorized={table.isAuthorized} isAuthorized={table.isAuthorized}
hasSelection={table.selectionModel.length > 0} hasSelection={table.selectionModel.length > 0}
onSearchChange={table.setSearchText} onSearchChange={table.setSearchText}
@@ -175,7 +165,6 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
<PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} /> <PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} />
<KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} /> <KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} />
<PackageInfoDialog <PackageInfoDialog
autoRefreshIntervals={autoRefreshIntervals}
onClose={() => table.setSelectedPackage(null)} onClose={() => table.setSelectedPackage(null)}
open={table.selectedPackage !== null} open={table.selectedPackage !== null}
packageBase={table.selectedPackage} packageBase={table.selectedPackage}
@@ -30,18 +30,10 @@ import ReplayIcon from "@mui/icons-material/Replay";
import SearchIcon from "@mui/icons-material/Search"; import SearchIcon from "@mui/icons-material/Search";
import VpnKeyIcon from "@mui/icons-material/VpnKey"; import VpnKeyIcon from "@mui/icons-material/VpnKey";
import { Box, Button, Divider, IconButton, InputAdornment, Menu, MenuItem, TextField, Tooltip } from "@mui/material"; import { Box, Button, Divider, IconButton, InputAdornment, Menu, MenuItem, TextField, Tooltip } from "@mui/material";
import AutoRefreshControl from "components/common/AutoRefreshControl";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
import React, { useState } from "react"; import React, { useState } from "react";
import { StatusColors } from "theme/StatusColors"; import { StatusColors } from "theme/StatusColors";
export interface AutoRefreshProps {
autoRefreshIntervals: AutoRefreshInterval[];
currentInterval: number;
onIntervalChange: (interval: number) => void;
}
export interface ToolbarActions { export interface ToolbarActions {
onAddClick: () => void; onAddClick: () => void;
onDashboardClick: () => void; onDashboardClick: () => void;
@@ -56,7 +48,6 @@ export interface ToolbarActions {
interface PackageTableToolbarProps { interface PackageTableToolbarProps {
actions: ToolbarActions; actions: ToolbarActions;
autoRefresh: AutoRefreshProps;
hasSelection: boolean; hasSelection: boolean;
isAuthorized: boolean; isAuthorized: boolean;
onSearchChange: (text: string) => void; onSearchChange: (text: string) => void;
@@ -66,7 +57,6 @@ interface PackageTableToolbarProps {
export default function PackageTableToolbar({ export default function PackageTableToolbar({
actions, actions,
autoRefresh,
hasSelection, hasSelection,
isAuthorized, isAuthorized,
onSearchChange, onSearchChange,
@@ -143,12 +133,6 @@ export default function PackageTableToolbar({
reload reload
</Button> </Button>
<AutoRefreshControl
currentInterval={autoRefresh.currentInterval}
intervals={autoRefresh.autoRefreshIntervals}
onIntervalChange={autoRefresh.onIntervalChange}
/>
<Box sx={{ flexGrow: 1 }} /> <Box sx={{ flexGrow: 1 }} />
<TextField <TextField
@@ -17,8 +17,16 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
export interface AutoRefreshInterval { import { useQueryClient } from "@tanstack/react-query";
interval: number; import { useEventStream } from "hooks/useEventStream";
is_active: boolean; import { useRepository } from "hooks/useRepository";
text: string; import type { ReactNode } from "react";
export function EventStreamProvider({ children }: { children: ReactNode }): ReactNode {
const queryClient = useQueryClient();
const { currentRepository } = useRepository();
useEventStream(queryClient, currentRepository);
return children;
} }
-49
View File
@@ -1,49 +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 { useLocalStorage } from "hooks/useLocalStorage";
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
interface AutoRefreshResult {
interval: number;
setInterval: Dispatch<SetStateAction<number>>;
setPaused: Dispatch<SetStateAction<boolean>>;
}
export function useAutoRefresh(key: string, defaultInterval: number): AutoRefreshResult {
const storageKey = `ahriman-${key}`;
const [interval, setInterval] = useLocalStorage<number>(storageKey, defaultInterval);
const [paused, setPaused] = useState(false);
// Apply defaultInterval when it becomes available (e.g. after info endpoint loads)
// but only if the user hasn't explicitly set a preference
useEffect(() => {
if (defaultInterval > 0 && window.localStorage.getItem(storageKey) === null) {
setInterval(defaultInterval);
}
}, [storageKey, defaultInterval, setInterval]);
const effectiveInterval = paused ? 0 : interval;
return {
interval: effectiveInterval,
setInterval,
setPaused,
};
}
+96
View File
@@ -0,0 +1,96 @@
/*
* 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 type { QueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { buildEventStreamUrl } from "hooks/useEventStream";
import { useNotification } from "hooks/useNotification";
import type { LogRecord } from "models/LogRecord";
import type { RepositoryId } from "models/RepositoryId";
import { useEffect } from "react";
interface BuildLogEvent {
created: number;
message: string;
process_id: string;
version: string;
}
function appendLogRecord(existing: LogRecord[] | undefined, record: LogRecord): LogRecord[] {
return [...existing ?? [], record];
}
function invalidateLogs(queryClient: QueryClient, repository: RepositoryId, packageBase: string): void {
void queryClient.invalidateQueries({ queryKey: ["logs", repository.key, packageBase] });
}
export function useBuildLogStream(packageBase: string, repository: RepositoryId): void {
const queryClient = useQueryClient();
const { showError } = useNotification();
useEffect(() => {
const source = new EventSource(buildEventStreamUrl(repository, ["build-log"], packageBase));
let needsRefresh = false;
source.addEventListener("error", () => {
needsRefresh = true;
});
source.addEventListener("open", () => {
if (needsRefresh) {
invalidateLogs(queryClient, repository, packageBase);
needsRefresh = false;
}
});
source.addEventListener("build-log", (event: MessageEvent<string>) => {
let data: BuildLogEvent;
try {
data = JSON.parse(event.data) as BuildLogEvent;
} catch {
showError("Live updates failed", "Could not parse build log event; refreshing logs.");
invalidateLogs(queryClient, repository, packageBase);
return;
}
const record: LogRecord = {
created: data.created,
message: data.message,
process_id: data.process_id,
version: data.version,
};
// Append to the all-logs cache
queryClient.setQueryData<LogRecord[]>(
["logs", repository.key, packageBase],
existing => appendLogRecord(existing, record),
);
// Append to the version-specific cache
queryClient.setQueryData<LogRecord[]>(
["logs", repository.key, packageBase, record.version, record.process_id],
existing => appendLogRecord(existing, record),
);
});
return () => {
source.close();
};
}, [queryClient, packageBase, repository, showError]);
}
+127
View File
@@ -0,0 +1,127 @@
/*
* 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 type { QueryClient } from "@tanstack/react-query";
import { useNotification } from "hooks/useNotification";
import type { RepositoryId } from "models/RepositoryId";
import { useEffect } from "react";
const GLOBAL_EVENT_TYPES = [
"package-held",
"package-outdated",
"package-removed",
"package-status-changed",
"package-update-failed",
"package-updated",
"service-status-changed",
] as const;
function invalidateForEvent(
queryClient: QueryClient,
repositoryKey: string,
eventType: string,
objectId?: string,
): void {
switch (eventType) {
case "package-status-changed":
case "package-updated":
case "package-removed":
case "package-held":
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey] });
void queryClient.invalidateQueries({ queryKey: ["status", repositoryKey] });
if (objectId) {
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey, objectId] });
void queryClient.invalidateQueries({ queryKey: ["events", repositoryKey, objectId] });
}
break;
case "service-status-changed":
void queryClient.invalidateQueries({ queryKey: ["status", repositoryKey] });
break;
case "package-outdated":
case "package-update-failed":
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey] });
if (objectId) {
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey, objectId] });
}
break;
}
}
function invalidateRepository(queryClient: QueryClient, repositoryKey: string): void {
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey] });
void queryClient.invalidateQueries({ queryKey: ["status", repositoryKey] });
void queryClient.invalidateQueries({ queryKey: ["events", repositoryKey] });
}
export function buildEventStreamUrl(
repository: RepositoryId,
events?: readonly string[],
objectId?: string,
): string {
const params = new URLSearchParams(repository.toQuery());
if (events) {
for (const event of events) {
params.append("event", event);
}
}
if (objectId) {
params.set("object_id", objectId);
}
return `/api/v1/events/stream?${params.toString()}`;
}
export function useEventStream(queryClient: QueryClient, repository: RepositoryId | null): void {
const { showError } = useNotification();
useEffect(() => {
if (!repository) {
return;
}
const source = new EventSource(buildEventStreamUrl(repository, GLOBAL_EVENT_TYPES));
let needsRefresh = false;
source.addEventListener("error", () => {
needsRefresh = true;
});
source.addEventListener("open", () => {
if (needsRefresh) {
invalidateRepository(queryClient, repository.key);
needsRefresh = false;
}
});
for (const eventType of GLOBAL_EVENT_TYPES) {
source.addEventListener(eventType, (event: MessageEvent<string>) => {
try {
const data = JSON.parse(event.data) as { object_id?: string };
invalidateForEvent(queryClient, repository.key, eventType, data.object_id ?? undefined);
} catch {
showError("Live updates failed", "Could not parse server event; refreshing data.");
invalidateRepository(queryClient, repository.key);
}
});
}
return () => {
source.close();
};
}, [queryClient, repository, showError]);
}
+1 -10
View File
@@ -20,46 +20,37 @@
import { skipToken, useQuery } from "@tanstack/react-query"; import { skipToken, useQuery } from "@tanstack/react-query";
import { QueryKeys } from "hooks/QueryKeys"; import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth"; import { useAuth } from "hooks/useAuth";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { useClient } from "hooks/useClient"; import { useClient } from "hooks/useClient";
import { useRepository } from "hooks/useRepository"; import { useRepository } from "hooks/useRepository";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
import { PackageRow } from "models/PackageRow"; import { PackageRow } from "models/PackageRow";
import { useMemo } from "react"; import { useMemo } from "react";
import { defaultInterval } from "utils";
export interface UsePackageDataResult { export interface UsePackageDataResult {
autoRefresh: ReturnType<typeof useAutoRefresh>;
isAuthorized: boolean; isAuthorized: boolean;
isLoading: boolean; isLoading: boolean;
rows: PackageRow[]; rows: PackageRow[];
status: BuildStatus | undefined; status: BuildStatus | undefined;
} }
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult { export function usePackageData(): UsePackageDataResult {
const client = useClient(); const client = useClient();
const { currentRepository } = useRepository(); const { currentRepository } = useRepository();
const { isAuthorized } = useAuth(); const { isAuthorized } = useAuth();
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packages = [], isLoading } = useQuery({ const { data: packages = [], isLoading } = useQuery({
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken, queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"], queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: status } = useQuery({ const { data: status } = useQuery({
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken, queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"], queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]); const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);
return { return {
autoRefresh,
isLoading, isLoading,
isAuthorized, isAuthorized,
rows, rows,
+2 -15
View File
@@ -21,13 +21,10 @@ import type { GridFilterModel } from "@mui/x-data-grid";
import { usePackageActions } from "hooks/usePackageActions"; import { usePackageActions } from "hooks/usePackageActions";
import { usePackageData } from "hooks/usePackageData"; import { usePackageData } from "hooks/usePackageData";
import { useTableState } from "hooks/useTableState"; import { useTableState } from "hooks/useTableState";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { BuildStatus } from "models/BuildStatus"; import type { BuildStatus } from "models/BuildStatus";
import type { PackageRow } from "models/PackageRow"; import type { PackageRow } from "models/PackageRow";
import { useEffect } from "react";
export interface UsePackageTableResult { export interface UsePackageTableResult {
autoRefreshInterval: number;
columnVisibility: Record<string, boolean>; columnVisibility: Record<string, boolean>;
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null; dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
filterModel: GridFilterModel; filterModel: GridFilterModel;
@@ -37,7 +34,6 @@ export interface UsePackageTableResult {
handleUpdate: () => Promise<void>; handleUpdate: () => Promise<void>;
isAuthorized: boolean; isAuthorized: boolean;
isLoading: boolean; isLoading: boolean;
onAutoRefreshIntervalChange: (interval: number) => void;
paginationModel: { page: number; pageSize: number }; paginationModel: { page: number; pageSize: number };
rows: PackageRow[]; rows: PackageRow[];
searchText: string; searchText: string;
@@ -53,23 +49,14 @@ export interface UsePackageTableResult {
status: BuildStatus | undefined; status: BuildStatus | undefined;
} }
export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult { export function usePackageTable(): UsePackageTableResult {
const { rows, isLoading, isAuthorized, status, autoRefresh } = usePackageData(autoRefreshIntervals); const { rows, isLoading, isAuthorized, status } = usePackageData();
const tableState = useTableState(); const tableState = useTableState();
const actions = usePackageActions(tableState.selectionModel, tableState.setSelectionModel); const actions = usePackageActions(tableState.selectionModel, tableState.setSelectionModel);
// Pause auto-refresh when dialog is open
const isDialogOpen = tableState.dialogOpen !== null || tableState.selectedPackage !== null;
const setPaused = autoRefresh.setPaused;
useEffect(() => {
setPaused(isDialogOpen);
}, [isDialogOpen, setPaused]);
return { return {
autoRefreshInterval: autoRefresh.interval,
isLoading, isLoading,
isAuthorized, isAuthorized,
onAutoRefreshIntervalChange: autoRefresh.setInterval,
rows, rows,
status, status,
...actions, ...actions,
-2
View File
@@ -18,12 +18,10 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import type { AuthInfo } from "models/AuthInfo"; import type { AuthInfo } from "models/AuthInfo";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
export interface InfoResponse { export interface InfoResponse {
auth: AuthInfo; auth: AuthInfo;
autorefresh_intervals: AutoRefreshInterval[];
docs_enabled: boolean; docs_enabled: boolean;
index_url?: string; index_url?: string;
repositories: RepositoryId[]; repositories: RepositoryId[];
-6
View File
@@ -17,8 +17,6 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
export const DETAIL_TABLE_PROPS = { export const DETAIL_TABLE_PROPS = {
density: "compact" as const, density: "compact" as const,
disableColumnSorting: true, disableColumnSorting: true,
@@ -27,10 +25,6 @@ export const DETAIL_TABLE_PROPS = {
sx: { height: 400, mt: 1 }, sx: { height: 400, mt: 1 },
}; };
export function defaultInterval(intervals: AutoRefreshInterval[]): number {
return intervals.find(interval => interval.is_active)?.interval ?? 0;
}
declare global { declare global {
interface Array<T> { interface Array<T> {
unique(): T[]; unique(): T[];
+1 -1
View File
@@ -9,7 +9,7 @@ arch=('any')
url="https://ahriman.readthedocs.io/" url="https://ahriman.readthedocs.io/"
license=('GPL-3.0-or-later') license=('GPL-3.0-or-later')
depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-bcrypt' 'python-filelock' 'python-inflection' 'python-pyelftools' 'python-requests') depends=('devtools>=1:1.0.0' 'git' 'pyalpm' 'python-bcrypt' 'python-filelock' 'python-inflection' 'python-pyelftools' 'python-requests')
makedepends=('npm' 'python-build' 'python-flit' 'python-installer' 'python-wheel') makedepends=('npm' 'python-build' 'python-hatchling' 'python-installer' 'python-wheel')
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgbase-$pkgver.tar.gz" source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgbase-$pkgver.tar.gz"
"$pkgbase.sysusers" "$pkgbase.sysusers"
"$pkgbase.tmpfiles") "$pkgbase.tmpfiles")
+27 -17
View File
@@ -1,6 +1,6 @@
[build-system] [build-system]
requires = ["flit_core"] requires = ["hatchling"]
build-backend = "flit_core.buildapi" build-backend = "hatchling.build"
[project] [project]
name = "ahriman" name = "ahriman"
@@ -8,8 +8,7 @@ name = "ahriman"
description = "ArcH linux ReposItory MANager" description = "ArcH linux ReposItory MANager"
readme = "README.md" readme = "README.md"
# Actually we are using features from the latest python, however, ubuntu, which is used for CI doesn't have it requires-python = ">=3.13"
requires-python = ">=3"
license = {file = "COPYING"} license = {file = "COPYING"}
authors = [ authors = [
@@ -113,21 +112,32 @@ tests = [
"pytest-spec", "pytest-spec",
] ]
[tool.flit.sdist] [tool.hatch.version]
path = "src/ahriman/__init__.py"
[tool.hatch.build.targets.sdist]
include = [ include = [
"AUTHORS", "/AUTHORS",
"CONTRIBUTING.md", "/CONTRIBUTING.md",
"SECURITY.md", "/COPYING",
"package", "/README.md",
"frontend", "/SECURITY.md",
"subpackages.py", "/frontend",
"web.png", "/package",
"/pyproject.toml",
"/src",
"/subpackages.py",
"/web.png",
] ]
exclude = [ exclude = [
"package/archlinux", "/package/archlinux",
"frontend/node_modules", "/frontend/node_modules",
"frontend/package-lock.json", "/frontend/package-lock.json",
] ]
[tool.flit.external-data] [tool.hatch.build.targets.wheel]
directory = "package" packages = ["src/ahriman"]
[tool.hatch.build.targets.wheel.shared-data]
"package/lib" = "lib"
"package/share" = "share"
@@ -72,17 +72,17 @@ class PackageArchive:
if not PackageArchive.is_elf(binary_file): if not PackageArchive.is_elf(binary_file):
return [] return []
elf_file = ELFFile(binary_file) # type: ignore[no-untyped-call] elf_file = ELFFile(binary_file)
dynamic_section = next( dynamic_section = next(
(section for section in elf_file.iter_sections() # type: ignore[no-untyped-call] (section for section in elf_file.iter_sections()
if isinstance(section, DynamicSection)), if isinstance(section, DynamicSection)),
None) None)
if dynamic_section is None: if dynamic_section is None:
return [] return []
return [ return [
tag.needed tag.needed # type: ignore[attr-defined]
for tag in dynamic_section.iter_tags() # type: ignore[no-untyped-call] for tag in dynamic_section.iter_tags()
if tag.entry.d_tag == "DT_NEEDED" if tag.entry.d_tag == "DT_NEEDED"
] ]
@@ -80,7 +80,7 @@ class Configuration(configparser.RawConfigParser):
""" """
configparser.RawConfigParser.__init__( configparser.RawConfigParser.__init__(
self, self,
dict_type=ConfigurationMultiDict if allow_multi_key else dict, # type: ignore[arg-type] dict_type=ConfigurationMultiDict if allow_multi_key else dict,
allow_no_value=allow_no_value, allow_no_value=allow_no_value,
strict=False, strict=False,
empty_lines_in_values=not allow_multi_key, empty_lines_in_values=not allow_multi_key,
@@ -150,6 +150,6 @@ class ShellTemplate(Template):
break break
kwargs.update(mapping) kwargs.update(mapping)
substituted = dict(generator(kwargs)) kwargs.update(dict(generator(kwargs)))
return self.safe_substitute(kwargs | substituted) return self.safe_substitute(kwargs)
+42 -19
View File
@@ -19,7 +19,8 @@
# #
import uuid import uuid
from asyncio import Lock, Queue, QueueFull from asyncio import Lock, Queue, QueueFull, QueueShutDown
from dataclasses import dataclass
from typing import Any from typing import Any
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
@@ -29,6 +30,22 @@ from ahriman.models.event import EventType
SSEvent = tuple[str, dict[str, Any]] SSEvent = tuple[str, dict[str, Any]]
@dataclass(frozen=True)
class _Subscription:
"""
internal event bus subscription record
Attributes:
topics(list[EventType] | None): event type filter, ``None`` means all
object_id(str | None): object identifier filter, ``None`` means all
queue(Queue[SSEvent]): per-subscriber event queue
"""
topics: list[EventType] | None
object_id: str | None
queue: Queue[SSEvent]
class EventBus(LazyLogging): class EventBus(LazyLogging):
""" """
event bus implementation event bus implementation
@@ -45,7 +62,7 @@ class EventBus(LazyLogging):
self.max_size = max_size self.max_size = max_size
self._lock = Lock() self._lock = Lock()
self._subscribers: dict[str, tuple[list[EventType] | None, Queue[SSEvent | None]]] = {} self._subscribers: dict[str, _Subscription] = {}
async def broadcast(self, event_type: EventType, object_id: str | None, **kwargs: Any) -> None: async def broadcast(self, event_type: EventType, object_id: str | None, **kwargs: Any) -> None:
""" """
@@ -60,42 +77,49 @@ class EventBus(LazyLogging):
event.update(kwargs) event.update(kwargs)
async with self._lock: async with self._lock:
for subscriber_id, (topics, queue) in self._subscribers.items(): snapshot = list(self._subscribers.items())
if topics is not None and event_type not in topics:
for subscriber_id, subscription in snapshot:
if subscription.topics is not None and event_type not in subscription.topics:
continue continue
if subscription.object_id is not None and object_id != subscription.object_id:
continue
try: try:
queue.put_nowait((event_type, event)) subscription.queue.put_nowait((event_type, event))
except QueueFull: except QueueFull:
self.logger.warning("discard message to slow subscriber %s", subscriber_id) self.logger.warning("discard message to slow subscriber %s", subscriber_id)
except QueueShutDown:
pass
async def shutdown(self) -> None: async def shutdown(self) -> None:
""" """
gracefully shutdown all subscribers gracefully shutdown all subscribers
""" """
async with self._lock: async with self._lock:
for _, queue in self._subscribers.values(): for subscription in self._subscribers.values():
try: subscription.queue.shutdown()
queue.put_nowait(None) self._subscribers.clear()
except QueueFull:
pass
queue.shutdown()
async def subscribe(self, topics: list[EventType] | None = None) -> tuple[str, Queue[SSEvent | None]]: async def subscribe(self, topics: list[EventType] | None = None,
object_id: str | None = None) -> tuple[str, Queue[SSEvent]]:
""" """
register new subscriber register new subscriber
Args: Args:
topics(list[EventType] | None, optional): list of event types to filter by. If ``None`` is set, topics(list[EventType] | None, optional): list of event types to filter by. If ``None`` is set,
all events will be delivered (Default value = None) all events will be delivered (Default value = None)
object_id(str | None, optional): object identifier to filter by. If ``None`` is set,
events for all objects will be delivered (Default value = None)
Returns: Returns:
tuple[str, Queue[SSEvent | None]]: subscriber identifier and associated queue tuple[str, Queue[SSEvent]]: subscriber identifier and associated queue
""" """
subscriber_id = str(uuid.uuid4()) subscriber_id = str(uuid.uuid4())
queue: Queue[SSEvent | None] = Queue(self.max_size) queue: Queue[SSEvent] = Queue(self.max_size)
async with self._lock: async with self._lock:
self._subscribers[subscriber_id] = (topics, queue) self._subscribers[subscriber_id] = _Subscription(topics=topics, object_id=object_id, queue=queue)
return subscriber_id, queue return subscriber_id, queue
@@ -107,7 +131,6 @@ class EventBus(LazyLogging):
subscriber_id(str): subscriber unique identifier subscriber_id(str): subscriber unique identifier
""" """
async with self._lock: async with self._lock:
result = self._subscribers.pop(subscriber_id, None) subscription = self._subscribers.pop(subscriber_id, None)
if result is not None: if subscription is not None:
_, queue = result subscription.queue.shutdown()
queue.shutdown()
@@ -31,3 +31,7 @@ class EventBusFilterSchema(RepositoryIdSchema):
"description": "Event type filter", "description": "Event type filter",
"example": [EventType.PackageUpdated], "example": [EventType.PackageUpdated],
}) })
object_id = fields.String(metadata={
"description": "Object identifier filter",
"example": "ahriman",
})
+2 -2
View File
@@ -209,8 +209,8 @@ class BaseView(View, CorsViewMixin):
HTTPBadRequest: if supplied parameters are invalid HTTPBadRequest: if supplied parameters are invalid
""" """
try: try:
limit = int(self.request.query.get("limit", default=-1)) limit = int(self.request.query.get("limit", -1))
offset = int(self.request.query.get("offset", default=0)) offset = int(self.request.query.get("offset", 0))
except ValueError as ex: except ValueError as ex:
raise HTTPBadRequest(reason=str(ex)) raise HTTPBadRequest(reason=str(ex))
+81 -20
View File
@@ -19,7 +19,7 @@
# #
import json import json
from aiohttp.web import HTTPBadRequest, StreamResponse from aiohttp.web import HTTPBadRequest, Request, Response, StreamResponse
from aiohttp_sse import EventSourceResponse, sse_response from aiohttp_sse import EventSourceResponse, sse_response
from asyncio import Queue, QueueShutDown, wait_for from asyncio import Queue, QueueShutDown, wait_for
from typing import ClassVar from typing import ClassVar
@@ -37,38 +37,89 @@ class EventBusView(BaseView):
event bus SSE view event bus SSE view
Attributes: Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self READ_EVENTS(set[EventType]): (class attribute) events which are allowed for read-only users
""" """
GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Full READ_EVENTS: ClassVar[set[EventType]] = {
EventType.PackageHeld,
EventType.PackageOutdated,
EventType.PackageRemoved,
EventType.PackageStatusChanged,
EventType.PackageUpdateFailed,
EventType.PackageUpdated,
EventType.ServiceStatusChanged,
}
ROUTES = ["/api/v1/events/stream"] ROUTES = ["/api/v1/events/stream"]
@classmethod
async def get_permission(cls, request: Request) -> UserAccess:
"""
retrieve user permission from the request
Args:
request(Request): request object
Returns:
UserAccess: extracted permission
"""
if request.method.upper() not in ("GET", "HEAD"):
return await BaseView.get_permission(request)
permission = UserAccess.Full
try:
topics = cls(request)._topics()
except HTTPBadRequest:
topics = None
if topics is not None and set(topics).issubset(cls.READ_EVENTS):
permission = UserAccess.Read
return permission
@staticmethod @staticmethod
async def _run(response: EventSourceResponse, queue: Queue[SSEvent | None]) -> None: async def _run(response: EventSourceResponse, queue: Queue[SSEvent]) -> None:
""" """
read events from queue and send them to the client read events from queue and send them to the client
Args: Args:
response(EventSourceResponse): SSE response instance response(EventSourceResponse): SSE response instance
queue(Queue[SSEvent | None]): subscriber queue queue(Queue[SSEvent]): subscriber queue
""" """
while response.is_connected(): while response.is_connected():
try: try:
message = await wait_for(queue.get(), timeout=response.ping_interval) event_type, data = await wait_for(queue.get(), timeout=response.ping_interval)
except TimeoutError: except TimeoutError:
continue continue
except QueueShutDown:
if message is None: break
break # terminate queue on sentinel event
event_type, data = message
await response.send(json.dumps(data), event=event_type) await response.send(json.dumps(data), event=event_type)
def _topics(self) -> list[EventType] | None:
"""
parse event filter from request query
Returns:
list[EventType] | None: event filter if any
Raises:
HTTPBadRequest: if invalid event type is supplied
"""
if self.request.query is None:
return None
try:
return [EventType(event) for event in self.request.query.getall("event", [])] or None
except ValueError as ex:
raise HTTPBadRequest(reason=str(ex))
@apidocs( @apidocs(
tags=["Audit log"], tags=["Audit log"],
summary="Live updates", summary="Live updates",
description="Stream live updates via SSE", description="Stream live updates via SSE. Read-only users may subscribe only when all requested event filters "
permission=GET_PERMISSION, "belong to read-safe package and service status events; build log or unfiltered streams require "
"full access. Streams are live-only and do not replay missed events after reconnect.",
permission=UserAccess.Full,
error_400_enabled=True, error_400_enabled=True,
error_404_description="Repository is unknown", error_404_description="Repository is unknown",
schema=SSESchema(many=True), schema=SSESchema(many=True),
@@ -80,18 +131,13 @@ class EventBusView(BaseView):
Returns: Returns:
StreamResponse: 200 with streaming updates StreamResponse: 200 with streaming updates
Raises:
HTTPBadRequest: if invalid event type is supplied
""" """
try: topics = self._topics()
topics = [EventType(event) for event in self.request.query.getall("event", [])] or None object_id = self.request.query.get("object_id")
except ValueError as ex:
raise HTTPBadRequest(reason=str(ex))
event_bus = self.service().event_bus event_bus = self.service().event_bus
async with sse_response(self.request) as response: async with sse_response(self.request) as response:
subscription_id, queue = await event_bus.subscribe(topics) subscription_id, queue = await event_bus.subscribe(topics, object_id=object_id)
try: try:
await self._run(response, queue) await self._run(response, queue)
@@ -101,3 +147,18 @@ class EventBusView(BaseView):
await event_bus.unsubscribe(subscription_id) await event_bus.unsubscribe(subscription_id)
return response return response
async def head(self) -> StreamResponse:
"""
HEAD method implementation based on the result of GET method
Returns:
StreamResponse: generated response for the request
"""
self._topics()
self.service()
return Response(headers={
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
})
+44 -6
View File
@@ -1,5 +1,7 @@
import pytest import pytest
from asyncio import QueueShutDown
from ahriman.core.status.event_bus import EventBus from ahriman.core.status.event_bus import EventBus
from ahriman.models.event import EventType from ahriman.models.event import EventType
from ahriman.models.package import Package from ahriman.models.package import Package
@@ -49,15 +51,26 @@ async def test_broadcast_queue_full(event_bus: EventBus, package_ahriman: Packag
assert queue.qsize() == 1 assert queue.qsize() == 1
async def test_shutdown(event_bus: EventBus) -> None: async def test_broadcast_queue_shutdown(event_bus: EventBus, package_ahriman: Package) -> None:
""" """
must send sentinel to all subscribers on shutdown must skip subscriber whose queue was shutdown concurrently
""" """
_, queue = await event_bus.subscribe() _, queue = await event_bus.subscribe()
queue.shutdown()
await event_bus.broadcast(EventType.PackageUpdated, package_ahriman.base)
async def test_shutdown(event_bus: EventBus) -> None:
"""
must shutdown all subscriber queues on shutdown
"""
subscriber_id, queue = await event_bus.subscribe()
await event_bus.shutdown() await event_bus.shutdown()
message = queue.get_nowait() assert subscriber_id not in event_bus._subscribers
assert message is None with pytest.raises(QueueShutDown):
queue.get_nowait()
async def test_shutdown_queue_full(event_bus: EventBus, package_ahriman: Package) -> None: async def test_shutdown_queue_full(event_bus: EventBus, package_ahriman: Package) -> None:
@@ -82,13 +95,38 @@ async def test_subscribe(event_bus: EventBus) -> None:
assert subscriber_id in event_bus._subscribers assert subscriber_id in event_bus._subscribers
async def test_broadcast_with_object_id(event_bus: EventBus, package_ahriman: Package) -> None:
"""
must broadcast event to subscribers with matching object_id
"""
_, queue = await event_bus.subscribe(object_id=package_ahriman.base)
await event_bus.broadcast(EventType.PackageUpdated, package_ahriman.base)
assert not queue.empty()
async def test_broadcast_object_id_isolation(event_bus: EventBus, package_ahriman: Package) -> None:
"""
must not broadcast event to subscribers with non-matching object_id
"""
_, queue = await event_bus.subscribe(object_id="other-package")
await event_bus.broadcast(EventType.PackageUpdated, package_ahriman.base)
assert queue.empty()
async def test_subscribe_with_topics(event_bus: EventBus) -> None: async def test_subscribe_with_topics(event_bus: EventBus) -> None:
""" """
must register subscriber with topic filter must register subscriber with topic filter
""" """
subscriber_id, _ = await event_bus.subscribe([EventType.BuildLog]) subscriber_id, _ = await event_bus.subscribe([EventType.BuildLog])
topics, _ = event_bus._subscribers[subscriber_id] assert event_bus._subscribers[subscriber_id].topics == [EventType.BuildLog]
assert topics == [EventType.BuildLog]
async def test_subscribe_with_object_id(event_bus: EventBus, package_ahriman: Package) -> None:
"""
must register subscriber with object_id filter
"""
subscriber_id, _ = await event_bus.subscribe(object_id=package_ahriman.base)
assert event_bus._subscribers[subscriber_id].object_id == package_ahriman.base
async def test_unsubscribe(event_bus: EventBus) -> None: async def test_unsubscribe(event_bus: EventBus) -> None:
@@ -2,7 +2,9 @@ import asyncio
import pytest import pytest
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from aiohttp.web import HTTPBadRequest
from asyncio import Queue from asyncio import Queue
from multidict import MultiDict
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -11,6 +13,7 @@ from ahriman.models.event import EventType
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.keys import WatcherKey from ahriman.web.keys import WatcherKey
from ahriman.web.views.base import BaseView
from ahriman.web.views.v1.auditlog.event_bus import EventBusView from ahriman.web.views.v1.auditlog.event_bus import EventBusView
@@ -38,6 +41,51 @@ async def test_get_permission() -> None:
assert await EventBusView.get_permission(request) == UserAccess.Full assert await EventBusView.get_permission(request) == UserAccess.Full
async def test_get_permission_build_log() -> None:
"""
must return full permission for build log stream
"""
request = pytest.helpers.request("", "", "GET", params=MultiDict(event=EventType.BuildLog))
assert await EventBusView.get_permission(request) == UserAccess.Full
async def test_get_permission_build_log_with_read_events() -> None:
"""
must return full permission for mixed build log and read event stream
"""
request = pytest.helpers.request("", "", "GET", params=MultiDict([
("event", EventType.BuildLog),
("event", EventType.PackageUpdated),
]))
assert await EventBusView.get_permission(request) == UserAccess.Full
async def test_get_permission_invalid_event() -> None:
"""
must return full permission for invalid event type
"""
request = pytest.helpers.request("", "", "GET", params=MultiDict(event="invalid"))
assert await EventBusView.get_permission(request) == UserAccess.Full
async def test_get_permission_post() -> None:
"""
must use default permission for non-get requests
"""
request = pytest.helpers.request("", "", "POST", params=MultiDict(event=EventType.PackageUpdated))
assert await EventBusView.get_permission(request) == await BaseView.get_permission(request)
async def test_get_permission_read_events() -> None:
"""
must return read permission for package and status streams
"""
request = pytest.helpers.request("", "", "GET", params=MultiDict(
("event", event_type) for event_type in EventBusView.READ_EVENTS
))
assert await EventBusView.get_permission(request) == UserAccess.Read
def test_routes() -> None: def test_routes() -> None:
""" """
must return correct routes must return correct routes
@@ -53,7 +101,7 @@ async def test_run_timeout() -> None:
async def _shutdown() -> None: async def _shutdown() -> None:
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
await queue.put(None) queue.shutdown()
response = AsyncMock() response = AsyncMock()
response.is_connected = lambda: True response.is_connected = lambda: True
@@ -63,6 +111,37 @@ async def test_run_timeout() -> None:
await EventBusView._run(response, queue) await EventBusView._run(response, queue)
def test_topics() -> None:
"""
must parse event filters
"""
request = pytest.helpers.request("", "", "GET", params=MultiDict([
("event", EventType.PackageUpdated),
("event", EventType.PackageRemoved),
]))
assert EventBusView(request)._topics() == [EventType.PackageUpdated, EventType.PackageRemoved]
def test_topics_empty() -> None:
"""
must return None for missing event filters
"""
request = pytest.helpers.request("", "", "GET", params=MultiDict())
assert EventBusView(request)._topics() is None
def test_topics_invalid() -> None:
"""
must raise bad request for invalid event filters
"""
request = pytest.helpers.request("", "", "GET", params=MultiDict(event="invalid"))
with pytest.raises(HTTPBadRequest):
EventBusView(request)._topics()
async def test_get(client: TestClient, package_ahriman: Package) -> None: async def test_get(client: TestClient, package_ahriman: Package) -> None:
""" """
must stream events via SSE must stream events via SSE
@@ -99,6 +178,23 @@ async def test_get_with_topic_filter(client: TestClient, package_ahriman: Packag
assert EventType.PackageRemoved not in body assert EventType.PackageRemoved not in body
async def test_get_with_object_id_filter(client: TestClient, package_ahriman: Package) -> None:
"""
must filter events by object_id
"""
watcher = next(iter(client.app[WatcherKey].values()))
asyncio.create_task(_producer(watcher, package_ahriman))
request_schema = pytest.helpers.schema_request(EventBusView.get, location="querystring")
payload = {"object_id": "non-existent-package"}
assert not request_schema.validate(payload)
response = await client.get("/api/v1/events/stream", params=payload)
assert response.status == 200
body = await response.text()
assert "ahriman" not in body
async def test_get_bad_request(client: TestClient) -> None: async def test_get_bad_request(client: TestClient) -> None:
""" """
must return bad request for invalid event type must return bad request for invalid event type
@@ -128,3 +224,31 @@ async def test_get_connection_reset(client: TestClient, mocker: MockerFixture) -
mocker.patch.object(EventBusView, "_run", side_effect=ConnectionResetError) mocker.patch.object(EventBusView, "_run", side_effect=ConnectionResetError)
response = await client.get("/api/v1/events/stream") response = await client.get("/api/v1/events/stream")
assert response.status == 200 assert response.status == 200
async def test_head(client: TestClient) -> None:
"""
must check stream availability without opening SSE stream
"""
response = await client.head("/api/v1/events/stream", params={"event": EventType.PackageUpdated})
assert response.status == 200
assert response.headers["Content-Type"] == "text/event-stream"
assert not await response.text()
async def test_head_bad_request(client: TestClient) -> None:
"""
must return bad request for invalid event type
"""
response = await client.head("/api/v1/events/stream", params={"event": "invalid"})
assert response.status == 400
assert not await response.text()
async def test_head_not_found(client: TestClient) -> None:
"""
must return not found for unknown repository
"""
response = await client.head("/api/v1/events/stream", params={"architecture": "unknown", "repository": "unknown"})
assert response.status == 404
assert not await response.text()