From 3e1e24cb50787e2417eeb5a111326a1897d0762b Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Fri, 8 May 2026 10:20:03 +0300 Subject: [PATCH] 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 --- .github/workflows/setup.sh | 2 +- docker/Dockerfile | 2 + docs/ahriman.core.status.rst | 8 + docs/ahriman.web.schemas.rst | 16 ++ docs/ahriman.web.views.v1.auditlog.rst | 8 + docs/configuration.rst | 3 +- docs/requirements.txt | 3 + frontend/src/App.tsx | 5 +- .../components/common/AutoRefreshControl.tsx | 91 ------ .../components/dialogs/PackageInfoDialog.tsx | 12 - frontend/src/components/layout/AppLayout.tsx | 2 +- .../src/components/package/BuildLogsTab.tsx | 6 +- .../components/package/PackageInfoActions.tsx | 13 - .../src/components/table/PackageTable.tsx | 15 +- .../components/table/PackageTableToolbar.tsx | 16 -- .../EventStreamProvider.tsx} | 16 +- frontend/src/hooks/useAutoRefresh.ts | 49 ---- frontend/src/hooks/useBuildLogStream.ts | 70 +++++ frontend/src/hooks/useEventStream.ts | 101 +++++++ frontend/src/hooks/usePackageData.ts | 11 +- frontend/src/hooks/usePackageTable.ts | 17 +- frontend/src/models/InfoResponse.ts | 2 - frontend/src/utils.ts | 6 - package/archlinux/PKGBUILD | 2 +- .../ahriman/settings/ahriman.ini.d/00-web.ini | 2 + pyproject.toml | 1 + src/ahriman/core/status/event_bus.py | 135 +++++++++ src/ahriman/core/status/watcher.py | 258 +++++++++++++---- src/ahriman/models/event.py | 8 + src/ahriman/web/schemas/__init__.py | 2 + .../web/schemas/event_bus_filter_schema.py | 37 +++ src/ahriman/web/schemas/sse_schema.py | 35 +++ .../web/views/v1/auditlog/event_bus.py | 102 +++++++ src/ahriman/web/views/v1/auditlog/events.py | 4 +- src/ahriman/web/views/v1/packages/archives.py | 2 +- src/ahriman/web/views/v1/packages/changes.py | 4 +- .../web/views/v1/packages/dependencies.py | 4 +- src/ahriman/web/views/v1/packages/hold.py | 2 +- src/ahriman/web/views/v1/packages/logs.py | 8 +- src/ahriman/web/views/v1/packages/package.py | 8 +- src/ahriman/web/views/v1/packages/packages.py | 4 +- src/ahriman/web/views/v1/packages/patch.py | 4 +- src/ahriman/web/views/v1/packages/patches.py | 4 +- src/ahriman/web/views/v1/service/logs.py | 2 +- src/ahriman/web/views/v1/status/status.py | 4 +- src/ahriman/web/views/v2/packages/logs.py | 2 +- src/ahriman/web/web.py | 9 +- tests/ahriman/conftest.py | 4 +- tests/ahriman/core/status/conftest.py | 12 + tests/ahriman/core/status/test_event_bus.py | 144 ++++++++++ tests/ahriman/core/status/test_watcher.py | 268 ++++++++++++++---- .../schemas/test_event_bus_filter_schema.py | 1 + tests/ahriman/web/schemas/test_sse_schema.py | 1 + .../test_view_v1_auditlog_event_bus.py | 147 ++++++++++ 54 files changed, 1311 insertions(+), 383 deletions(-) delete mode 100644 frontend/src/components/common/AutoRefreshControl.tsx rename frontend/src/{models/AutoRefreshInterval.ts => contexts/EventStreamProvider.tsx} (62%) delete mode 100644 frontend/src/hooks/useAutoRefresh.ts create mode 100644 frontend/src/hooks/useBuildLogStream.ts create mode 100644 frontend/src/hooks/useEventStream.ts create mode 100644 src/ahriman/core/status/event_bus.py create mode 100644 src/ahriman/web/schemas/event_bus_filter_schema.py create mode 100644 src/ahriman/web/schemas/sse_schema.py create mode 100644 src/ahriman/web/views/v1/auditlog/event_bus.py create mode 100644 tests/ahriman/core/status/test_event_bus.py create mode 100644 tests/ahriman/web/schemas/test_event_bus_filter_schema.py create mode 100644 tests/ahriman/web/schemas/test_sse_schema.py create mode 100644 tests/ahriman/web/views/v1/auditlog/test_view_v1_auditlog_event_bus.py diff --git a/.github/workflows/setup.sh b/.github/workflows/setup.sh index e61926cd..48dd2118 100755 --- a/.github/workflows/setup.sh +++ b/.github/workflows/setup.sh @@ -16,7 +16,7 @@ pacman -S --noconfirm --asdeps base-devel python-build python-flit python-instal # optional dependencies if [[ -z $MINIMAL_INSTALL ]]; then # 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 pacman -S --noconfirm gnupg ipython python-boto3 python-cerberus python-matplotlib rsync fi diff --git a/docker/Dockerfile b/docker/Dockerfile index bdbe5ba6..0bac9e1a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -37,6 +37,7 @@ RUN pacman -S --noconfirm --asdeps \ python-build \ python-flit \ python-installer \ + python-setuptools \ python-tox \ python-wheel RUN pacman -S --noconfirm --asdeps \ @@ -58,6 +59,7 @@ RUN runuser -u build -- install-aur-package \ python-aiohttp-jinja2 \ python-aiohttp-session \ python-aiohttp-security \ + python-aiohttp-sse-git \ python-requests-unixsocket2 # install ahriman diff --git a/docs/ahriman.core.status.rst b/docs/ahriman.core.status.rst index b0096df3..3e57418c 100644 --- a/docs/ahriman.core.status.rst +++ b/docs/ahriman.core.status.rst @@ -12,6 +12,14 @@ ahriman.core.status.client module :no-undoc-members: :show-inheritance: +ahriman.core.status.event\_bus module +------------------------------------- + +.. automodule:: ahriman.core.status.event_bus + :members: + :no-undoc-members: + :show-inheritance: + ahriman.core.status.local\_client module ---------------------------------------- diff --git a/docs/ahriman.web.schemas.rst b/docs/ahriman.web.schemas.rst index 7a697397..4c2f4399 100644 --- a/docs/ahriman.web.schemas.rst +++ b/docs/ahriman.web.schemas.rst @@ -92,6 +92,14 @@ ahriman.web.schemas.error\_schema module :no-undoc-members: :show-inheritance: +ahriman.web.schemas.event\_bus\_filter\_schema module +----------------------------------------------------- + +.. automodule:: ahriman.web.schemas.event_bus_filter_schema + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.schemas.event\_schema module ---------------------------------------- @@ -356,6 +364,14 @@ ahriman.web.schemas.search\_schema module :no-undoc-members: :show-inheritance: +ahriman.web.schemas.sse\_schema module +-------------------------------------- + +.. automodule:: ahriman.web.schemas.sse_schema + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.schemas.status\_schema module ----------------------------------------- diff --git a/docs/ahriman.web.views.v1.auditlog.rst b/docs/ahriman.web.views.v1.auditlog.rst index 05cc602f..1b9105f4 100644 --- a/docs/ahriman.web.views.v1.auditlog.rst +++ b/docs/ahriman.web.views.v1.auditlog.rst @@ -4,6 +4,14 @@ ahriman.web.views.v1.auditlog package Submodules ---------- +ahriman.web.views.v1.auditlog.event\_bus module +----------------------------------------------- + +.. automodule:: ahriman.web.views.v1.auditlog.event_bus + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.views.v1.auditlog.events module ------------------------------------------- diff --git a/docs/configuration.rst b/docs/configuration.rst index e346277c..e1248fc6 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -188,6 +188,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed * ``host`` - host to bind, string, optional. * ``index_url`` - full URL of the repository index page, string, optional. * ``max_body_size`` - max body size in bytes to be validated for archive upload, integer, optional. If not set, validation will be disabled. +* ``max_queue_size`` - max queue size for server sent event streams, integer, optional, default ``0``. If set to ``0``, queue is unlimited. * ``port`` - port to bind, integer, optional. * ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``. * ``static_path`` - path to directory with static files, string, required. @@ -195,7 +196,7 @@ Web server settings. This feature requires ``aiohttp`` libraries to be installed * ``templates`` - path to templates directories, space separated list of paths, required. * ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization. * ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration. -* ``wait_timeout`` - wait timeout in seconds, maximum amount of time to be waited before lock will be free, integer, optional. +* ``wait_timeout`` - wait timeout in seconds, maximum amount of time to be waited before lock will be free, integer, optional. If set to ``0``, wait infinitely. ``archive`` group ----------------- diff --git a/docs/requirements.txt b/docs/requirements.txt index 54fe9240..0dc651b3 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -7,10 +7,13 @@ aiohttp==3.11.18 # ahriman (pyproject.toml) # aiohttp-cors # aiohttp-jinja2 + # aiohttp-sse aiohttp-cors==0.8.1 # via ahriman (pyproject.toml) aiohttp-jinja2==1.6 # via ahriman (pyproject.toml) +aiohttp-sse==2.2.0 + # via ahriman (pyproject.toml) aiosignal==1.3.2 # via aiohttp alabaster==1.0.0 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 431485a7..42efabbe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import AppLayout from "components/layout/AppLayout"; import { AuthProvider } from "contexts/AuthProvider"; import { ClientProvider } from "contexts/ClientProvider"; +import { EventStreamProvider } from "contexts/EventStreamProvider"; import { NotificationProvider } from "contexts/NotificationProvider"; import { RepositoryProvider } from "contexts/RepositoryProvider"; import { ThemeProvider } from "contexts/ThemeProvider"; @@ -42,7 +43,9 @@ export default function App(): React.JSX.Element { - + + + diff --git a/frontend/src/components/common/AutoRefreshControl.tsx b/frontend/src/components/common/AutoRefreshControl.tsx deleted file mode 100644 index 479281c4..00000000 --- a/frontend/src/components/common/AutoRefreshControl.tsx +++ /dev/null @@ -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 . - */ -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(null); - - if (intervals.length === 0) { - return null; - } - - const enabled = currentInterval > 0; - - return <> - - setAnchorEl(event.currentTarget)} - size="small" - > - {enabled ? : } - - - setAnchorEl(null)} - open={Boolean(anchorEl)} - > - { - onIntervalChange(0); - setAnchorEl(null); - }} - selected={!enabled} - > - - {!enabled && } - - Off - - {intervals.map(interval => - { - onIntervalChange(interval.interval); - setAnchorEl(null); - }} - selected={enabled && interval.interval === currentInterval} - > - - {enabled && interval.interval === currentInterval && } - - {interval.text} - , - )} - - ; -} diff --git a/frontend/src/components/dialogs/PackageInfoDialog.tsx b/frontend/src/components/dialogs/PackageInfoDialog.tsx index 8066b260..0a608596 100644 --- a/frontend/src/components/dialogs/PackageInfoDialog.tsx +++ b/frontend/src/components/dialogs/PackageInfoDialog.tsx @@ -32,27 +32,22 @@ import PkgbuildTab from "components/package/PkgbuildTab"; import { type TabKey, tabs } from "components/package/TabKey"; import { QueryKeys } from "hooks/QueryKeys"; import { useAuth } from "hooks/useAuth"; -import { useAutoRefresh } from "hooks/useAutoRefresh"; import { useClient } from "hooks/useClient"; import { useNotification } from "hooks/useNotification"; import { useRepository } from "hooks/useRepository"; -import type { AutoRefreshInterval } from "models/AutoRefreshInterval"; import type { Dependencies } from "models/Dependencies"; import type { PackageStatus } from "models/PackageStatus"; import type { Patch } from "models/Patch"; import React, { useState } from "react"; import { StatusHeaderStyles } from "theme/StatusColors"; -import { defaultInterval } from "utils"; interface PackageInfoDialogProps { - autoRefreshIntervals: AutoRefreshInterval[]; onClose: () => void; open: boolean; packageBase: string | null; } export default function PackageInfoDialog({ - autoRefreshIntervals, onClose, open, packageBase, @@ -77,14 +72,11 @@ export default function PackageInfoDialog({ onClose(); }; - const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals)); - const { data: packageData } = useQuery({ enabled: open, queryFn: localPackageBase && currentRepository ? () => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken, queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"], - refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, }); const { data: dependencies } = useQuery({ @@ -182,7 +174,6 @@ export default function PackageInfoDialog({ {activeTab === "logs" && localPackageBase && currentRepository && } @@ -207,11 +198,8 @@ export default function PackageInfoDialog({ void handleHoldToggle()} onRefreshDatabaseChange={setRefreshDatabase} onRemove={() => void handleRemove()} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index feb20241..dde95ff3 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -69,7 +69,7 @@ export default function AppLayout(): React.JSX.Element { - +