Compare commits

..

3 Commits

Author SHA1 Message Date
arcanis d99b61d8c0 Release 2.20.1 2026-03-13 17:06:27 +02:00
arcanis 34d0b8cf73 fix: fallback to timestamp in case if crypto context is not available
Previous tests have been done in either secure (HTTPS) or loopback
(localhost) environments, so I missed the case, that crypto methods
might not be available

Fixes #159
2026-03-13 17:05:48 +02:00
arcanis cc082dbd6f feat: add dark theme support and increase contrast 2026-03-13 17:05:04 +02:00
902 changed files with 2689 additions and 6754 deletions
-6
View File
@@ -26,10 +26,6 @@ jobs:
- 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
uses: docker/login-action@v3
with:
@@ -57,8 +53,6 @@ jobs:
- name: Build an image and push
uses: docker/build-push-action@v6
with:
build-args: |
BUILD_DATE=${{ steps.args.outputs.date }}
file: docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
+2 -2
View File
@@ -12,11 +12,11 @@ pacman -Syyu --noconfirm
# main dependencies
pacman -S --noconfirm devtools git npm pyalpm python-bcrypt python-filelock python-inflection python-pyelftools python-requests python-systemd sudo
# make dependencies
pacman -S --noconfirm --asdeps base-devel python-build python-hatchling python-installer python-tox python-wheel
pacman -S --noconfirm --asdeps base-devel python-build python-flit python-installer python-tox python-wheel
# 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-aiohttp-sse-git 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-cryptography python-jinja
# additional features
pacman -S --noconfirm gnupg ipython python-boto3 python-cerberus python-matplotlib rsync
fi
+2 -5
View File
@@ -103,8 +103,5 @@ docs/html/
# Frontend
node_modules/
package-lock.json
ahriman-web/package/share/ahriman/templates/static/index.js
ahriman-web/package/share/ahriman/templates/static/index.css
# local configs
/*.ini
package/share/ahriman/templates/static/index.js
package/share/ahriman/templates/static/index.css
-2
View File
@@ -2,6 +2,4 @@
addopts = --cov=ahriman --cov-report=term-missing:skip-covered --no-cov-on-fail --cov-fail-under=100 --spec
asyncio_default_fixture_loop_scope = function
asyncio_mode = auto
pythonpath = tests
resource-path.directory-name-test-resources = ../../tests/testresources
spec_test_format = {result} {docstring_summary}
+2 -2
View File
@@ -1,9 +1,9 @@
version: 2
build:
os: ubuntu-lts-latest
os: ubuntu-20.04
tools:
python: "3.13"
python: "3.12"
apt_packages:
- graphviz
-68
View File
@@ -1,68 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "ahriman-core"
description = "ArcH linux ReposItory MANager, core package"
readme = "../README.md"
requires-python = ">=3.13"
license = {file = "../COPYING"}
authors = [
{name = "ahriman team"},
]
dependencies = [
"bcrypt",
"filelock",
"inflection",
"pyelftools",
"requests",
]
dynamic = ["version"]
[project.optional-dependencies]
journald = [
"systemd-python",
]
# FIXME technically this dependency is required, but in some cases we do not have access to
# the libalpm which is required in order to install the package. Thus in case if we do not
# really need to run the application we can move it to "optional" dependencies
pacman = [
"pyalpm",
]
reports = [
"Jinja2",
]
s3 = [
"boto3",
]
shell = [
"IPython",
]
stats = [
"matplotlib",
]
unixsocket = [
"requests-unixsocket2",
]
validator = [
"cerberus",
]
[project.scripts]
ahriman = "ahriman.application.ahriman:run"
[project.urls]
Documentation = "https://ahriman.readthedocs.io/"
Repository = "https://github.com/arcan1s/ahriman"
Changelog = "https://github.com/arcan1s/ahriman/releases"
[tool.hatch.version]
path = "src/ahriman/__init__.py"
[tool.hatch.build.targets.wheel]
packages = ["src/ahriman"]
[tool.hatch.build.targets.wheel.shared-data]
"package/lib" = "lib"
"package/share" = "share"
-20
View File
@@ -1,20 +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/>.
#
__version__ = "2.20.0"
@@ -1,81 +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 argparse
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration
from ahriman.core.formatters import PackagePrinter
from ahriman.models.action import Action
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.repository_id import RepositoryId
class Archives(Handler):
"""
package archives handler
"""
ALLOW_MULTI_ARCHITECTURE_RUN = False # conflicting io
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
application = Application(repository_id, configuration, report=True)
match args.action:
case Action.List:
archives = application.repository.package_archives(args.package)
for package in archives:
PackagePrinter(package, BuildStatus(BuildStatusEnum.Success))(verbose=args.info)
Archives.check_status(args.exit_code, bool(archives))
@staticmethod
def _set_package_archives_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package archives subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-archives", help="list package archive versions",
description="list available archive versions for the package")
parser.add_argument("package", help="package base")
parser.add_argument("-e", "--exit-code", help="return non-zero exit status if result is empty",
action="store_true")
parser.add_argument("--info", help="show additional package information",
action=argparse.BooleanOptionalAction, default=False)
parser.set_defaults(action=Action.List, lock=None, quiet=True, report=False, unsafe=True)
return parser
arguments = [_set_package_archives_parser]
@@ -1,93 +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 argparse
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration
from ahriman.models.action import Action
from ahriman.models.repository_id import RepositoryId
class Hold(Handler):
"""
package hold handler
"""
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
client = Application(repository_id, configuration, report=True).reporter
match args.action:
case Action.Remove:
for package in args.package:
client.package_hold_update(package, enabled=False)
case Action.Update:
for package in args.package:
client.package_hold_update(package, enabled=True)
@staticmethod
def _set_package_hold_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for hold package subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-hold", help="hold package",
description="hold package from automatic updates")
parser.add_argument("package", help="package base", nargs="+")
parser.set_defaults(action=Action.Update, lock=None, quiet=True, report=False, unsafe=True)
return parser
@staticmethod
def _set_package_unhold_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for unhold package subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-unhold", help="unhold package",
description="remove package hold, allowing automatic updates")
parser.add_argument("package", help="package base", nargs="+")
parser.set_defaults(action=Action.Remove, lock=None, quiet=True, report=False, unsafe=True)
return parser
arguments = [
_set_package_hold_parser,
_set_package_unhold_parser,
]
@@ -1,100 +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 argparse
from dataclasses import replace
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration
from ahriman.core.formatters import PkgbuildPrinter
from ahriman.models.action import Action
from ahriman.models.repository_id import RepositoryId
class Pkgbuild(Handler):
"""
package pkgbuild handler
"""
ALLOW_MULTI_ARCHITECTURE_RUN = False # conflicting io
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
client = Application(repository_id, configuration, report=True).reporter
match args.action:
case Action.List:
changes = client.package_changes_get(args.package)
PkgbuildPrinter(changes)(verbose=True, separator="")
Pkgbuild.check_status(args.exit_code, changes.pkgbuild is not None)
case Action.Remove:
changes = client.package_changes_get(args.package)
client.package_changes_update(args.package, replace(changes, pkgbuild=None))
@staticmethod
def _set_package_pkgbuild_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package pkgbuild subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-pkgbuild", help="get package pkgbuild",
description="retrieve package PKGBUILD stored in database",
epilog="This command requests package status from the web interface "
"if it is available.")
parser.add_argument("package", help="package base")
parser.add_argument("-e", "--exit-code", help="return non-zero exit status if result is empty",
action="store_true")
parser.set_defaults(action=Action.List, lock=None, quiet=True, report=False, unsafe=True)
return parser
@staticmethod
def _set_package_pkgbuild_remove_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package pkgbuild remove subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-pkgbuild-remove", help="remove package pkgbuild",
description="remove the package PKGBUILD stored remotely")
parser.add_argument("package", help="package base")
parser.set_defaults(action=Action.Remove, exit_code=False, lock=None, quiet=True, report=False, unsafe=True)
return parser
arguments = [_set_package_pkgbuild_parser, _set_package_pkgbuild_remove_parser]
@@ -1,131 +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 argparse
from dataclasses import replace
from pathlib import Path
from ahriman.application.application import Application
from ahriman.application.handlers.add import Add
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.utils import extract_user
from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource
from ahriman.models.repository_id import RepositoryId
class Rollback(Handler):
"""
package rollback handler
"""
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
application = Application(repository_id, configuration, report=report)
application.on_start()
package = Rollback.package_load(application, args.package, args.version)
artifacts = Rollback.package_artifacts(application, package)
args.package = [str(artifact) for artifact in artifacts]
Add.perform_action(application, args)
if args.hold:
application.reporter.package_hold_update(package.base, enabled=True)
@staticmethod
def _set_package_rollback_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package rollback subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-rollback", help="rollback package",
description="rollback package to specified version from archives")
parser.add_argument("package", help="package base")
parser.add_argument("version", help="package version")
parser.add_argument("--hold", help="hold package afterwards",
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,
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
Args:
application(Application): application instance
package(Package): package descriptor
Returns:
list[Path]: paths to found artifacts
Raises:
UnknownPackageError: if artifacts do not exist
"""
# lookup for built artifacts
artifacts = application.repository.package_archives_lookup(package)
if not artifacts:
raise UnknownPackageError(package.base)
return artifacts
@staticmethod
def package_load(application: Application, package_base: str, version: str) -> Package:
"""
load package from repository, while setting requested version
Args:
application(Application): application instance
package_base(str): package base
version(str): package version
Returns:
Package: loaded package
Raises:
UnknownPackageError: if package does not exist
"""
try:
package, _ = next(iter(application.reporter.package_get(package_base)))
return replace(package, version=version)
except StopIteration:
raise UnknownPackageError(package_base) from None
arguments = [_set_package_rollback_parser]
@@ -1,81 +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/>.
#
from pathlib import Path
from pyalpm import Handle, Package # type: ignore[import-not-found]
from tempfile import TemporaryDirectory
from typing import Any, ClassVar, Self
class PacmanHandle:
"""
lightweight wrapper for pacman handle to be used for direct alpm operations (e.g. package load)
Attributes:
handle(Handle): pyalpm handle instance
"""
_ephemeral: ClassVar[Self | None] = None
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""
Args:
*args(Any): positional arguments for :class:`pyalpm.Handle`
**kwargs(Any): keyword arguments for :class:`pyalpm.Handle`
"""
self.handle = Handle(*args, **kwargs)
@classmethod
def ephemeral(cls) -> Self:
"""
create temporary instance with no access to real databases
Returns:
Self: loaded class
"""
if cls._ephemeral is None:
# handle creates alpm version file, but we don't use it
# so it is ok to just remove it
with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
cls._ephemeral = cls("/", dir_name)
return cls._ephemeral
def package_load(self, path: Path) -> Package:
"""
load package from path to the archive
Args:
path(Path): path to package archive
Returns:
Package: package instance
"""
return self.handle.load_pkg(str(path))
def __getattr__(self, item: str) -> Any:
"""
proxy methods for :class:`pyalpm.Handle`, because it doesn't allow subclassing
Args:
item(str): property name
Returns:
Any: attribute by its name
"""
return self.handle.__getattribute__(item)
@@ -1,25 +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/>.
#
__all__ = ["steps"]
steps = [
"""alter table package_changes add column pkgbuild text""",
]
@@ -1,25 +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/>.
#
__all__ = ["steps"]
steps = [
"""alter table package_statuses add column is_held integer not null default 0""",
]
@@ -1,62 +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/>.
#
from ahriman.core.formatters.printer import Printer
from ahriman.models.changes import Changes
from ahriman.models.property import Property
class PkgbuildPrinter(Printer):
"""
print content of the pkgbuild stored in changes
Attributes:
changes(Changes): package changes
"""
def __init__(self, changes: Changes) -> None:
"""
Args:
changes(Changes): package changes
"""
Printer.__init__(self)
self.changes = changes
def properties(self) -> list[Property]:
"""
convert content into printable data
Returns:
list[Property]: list of content properties
"""
if self.changes.pkgbuild is None:
return []
return [Property("", self.changes.pkgbuild, is_required=True, indent=0)]
# pylint: disable=redundant-returns-doc
def title(self) -> str | None:
"""
generate entry title from content
Returns:
str | None: content title if it can be generated and ``None`` otherwise
"""
if self.changes.pkgbuild is None:
return None
return self.changes.last_commit_sha
@@ -1,136 +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 uuid
from asyncio import Lock, Queue, QueueFull, QueueShutDown
from dataclasses import dataclass
from typing import Any
from ahriman.core.log import LazyLogging
from ahriman.models.event import EventType
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):
"""
event bus implementation
Attributes:
max_size(int): maximum size of queue
"""
def __init__(self, max_size: int) -> None:
"""
Args:
max_size(int): maximum size of queue
"""
self.max_size = max_size
self._lock = Lock()
self._subscribers: dict[str, _Subscription] = {}
async def broadcast(self, event_type: EventType, object_id: str | None, **kwargs: Any) -> None:
"""
broadcast event to all subscribers
Args:
event_type(EventType): event type
object_id(str | None): object identifier (e.g. package base)
**kwargs(Any): additional event data
"""
event: dict[str, Any] = {"object_id": object_id}
event.update(kwargs)
async with self._lock:
snapshot = list(self._subscribers.items())
for subscriber_id, subscription in snapshot:
if subscription.topics is not None and event_type not in subscription.topics:
continue
if subscription.object_id is not None and object_id != subscription.object_id:
continue
try:
subscription.queue.put_nowait((event_type, event))
except QueueFull:
self.logger.warning("discard message to slow subscriber %s", subscriber_id)
except QueueShutDown:
pass
async def shutdown(self) -> None:
"""
gracefully shutdown all subscribers
"""
async with self._lock:
for subscription in self._subscribers.values():
subscription.queue.shutdown()
self._subscribers.clear()
async def subscribe(self, topics: list[EventType] | None = None,
object_id: str | None = None) -> tuple[str, Queue[SSEvent]]:
"""
register new subscriber
Args:
topics(list[EventType] | None, optional): list of event types to filter by. If ``None`` is set,
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:
tuple[str, Queue[SSEvent]]: subscriber identifier and associated queue
"""
subscriber_id = str(uuid.uuid4())
queue: Queue[SSEvent] = Queue(self.max_size)
async with self._lock:
self._subscribers[subscriber_id] = _Subscription(topics=topics, object_id=object_id, queue=queue)
return subscriber_id, queue
async def unsubscribe(self, subscriber_id: str) -> None:
"""
unsubscribe from events
Args:
subscriber_id(str): subscriber unique identifier
"""
async with self._lock:
subscription = self._subscribers.pop(subscriber_id, None)
if subscription is not None:
subscription.queue.shutdown()
@@ -1,361 +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/>.
#
# pylint: disable=too-many-public-methods
from asyncio import Lock
from dataclasses import replace
from typing import Self
from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.log import LazyLogging
from ahriman.core.repository.package_info import PackageInfo
from ahriman.core.status import Client
from ahriman.core.status.event_bus import EventBus
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType
from ahriman.models.log_record import LogRecord
from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
class Watcher(LazyLogging):
"""
package status watcher
Attributes:
client(Client): reporter instance
event_bus(EventBus): event bus instance
package_info(PackageInfo): package info instance
status(BuildStatus): daemon status
"""
def __init__(self, client: Client, package_info: PackageInfo, event_bus: EventBus) -> None:
"""
Args:
client(Client): reporter instance
package_info(PackageInfo): package info instance
event_bus(EventBus): event bus instance
"""
self.client = client
self.package_info = package_info
self.event_bus = event_bus
self._lock = Lock()
self._known: dict[str, tuple[Package, BuildStatus]] = {}
self.status = BuildStatus()
async def event_add(self, event: Event) -> None:
"""
create new event
Args:
event(Event): audit log event
"""
self.client.event_add(event)
async def event_get(self, event: str | EventType | None, object_id: str | None,
from_date: int | float | None = None, to_date: int | float | None = None,
limit: int = -1, offset: int = 0) -> list[Event]:
"""
retrieve list of events
Args:
event(str | EventType | None): filter by event type
object_id(str | None): filter by event object
from_date(int | float | None, optional): minimal creation date, inclusive (Default value = None)
to_date(int | float | None, optional): maximal creation date, exclusive (Default value = None)
limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1)
offset(int, optional): records offset (Default value = 0)
Returns:
list[Event]: list of audit log events
"""
return self.client.event_get(event, object_id, from_date, to_date, limit, offset)
async def load(self) -> None:
"""
load packages from local database
"""
async with self._lock:
self._known = {
package.base: (package, status)
for package, status in self.client.package_get(None)
}
async def logs_rotate(self, keep_last_records: int) -> None:
"""
remove older logs from storage
Args:
keep_last_records(int): number of last records to keep
"""
self.client.logs_rotate(keep_last_records)
async def package_archives(self, package_base: str) -> list[Package]:
"""
get known package archives
Args:
package_base(str): package base
Returns:
list[Package]: list of built package for this package base
"""
return self.package_info.package_archives(package_base)
async def package_changes_get(self, package_base: str) -> Changes:
"""
get package changes
Args:
package_base(str): package base to retrieve
Returns:
Changes: package changes if available and empty object otherwise
"""
return self.client.package_changes_get(package_base)
async def package_changes_update(self, package_base: str, changes: Changes) -> None:
"""
update package changes
Args:
package_base(str): package base to update
changes(Changes): changes descriptor
"""
self.client.package_changes_update(package_base, changes)
async def package_dependencies_get(self, package_base: str) -> Dependencies:
"""
get package dependencies
Args:
package_base(str): package base to retrieve
Returns:
list[Dependencies]: package implicit dependencies if available
"""
return self.client.package_dependencies_get(package_base)
async def package_dependencies_update(self, package_base: str, dependencies: Dependencies) -> None:
"""
update package dependencies
Args:
package_base(str): package base to update
dependencies(Dependencies): dependencies descriptor
"""
self.client.package_dependencies_update(package_base, dependencies)
async def package_get(self, package_base: str) -> tuple[Package, BuildStatus]:
"""
get current package base build status
Args:
package_base(str): package base
Returns:
tuple[Package, BuildStatus]: package and its status
Raises:
UnknownPackageError: if no package found
"""
try:
async with self._lock:
return self._known[package_base]
except KeyError:
raise UnknownPackageError(package_base) from None
async def package_hold_update(self, package_base: str, *, enabled: bool) -> None:
"""
update package hold status
Args:
package_base(str): package base name
enabled(bool): new hold status
"""
package, status = await self.package_get(package_base)
async with self._lock:
self._known[package_base] = (package, replace(status, is_held=enabled))
self.client.package_hold_update(package_base, enabled=enabled)
await self.event_bus.broadcast(EventType.PackageHeld, package_base, is_held=enabled)
async def package_logs_add(self, log_record: LogRecord) -> None:
"""
post log record
Args:
log_record(LogRecord): log record
"""
self.client.package_logs_add(log_record)
await self.event_bus.broadcast(EventType.BuildLog, log_record.log_record_id.package_base, **log_record.view())
async def package_logs_get(self, package_base: str, version: str | None = None, process_id: str | None = None,
limit: int = -1, offset: int = 0) -> list[LogRecord]:
"""
get package logs
Args:
package_base(str): package base
version(str | None, optional): package version to search (Default value = None)
process_id(str | None, optional): process identifier to search (Default value = None)
limit(int, optional): limit records to the specified count, -1 means unlimited (Default value = -1)
offset(int, optional): records offset (Default value = 0)
Returns:
list[LogRecord]: package logs
"""
return self.client.package_logs_get(package_base, version, process_id, limit, offset)
async def package_logs_remove(self, package_base: str, version: str | None) -> None:
"""
remove package logs
Args:
package_base(str): package base
version(str | None): package version to remove logs. If ``None`` is set, all logs will be removed
"""
self.client.package_logs_remove(package_base, version)
async def package_patches_get(self, package_base: str, variable: str | None) -> list[PkgbuildPatch]:
"""
get package patches
Args:
package_base(str): package base to retrieve
variable(str | None): optional filter by patch variable
Returns:
list[PkgbuildPatch]: list of patches for the specified package
"""
return self.client.package_patches_get(package_base, variable)
async def package_patches_remove(self, package_base: str, variable: str | None) -> None:
"""
remove package patch
Args:
package_base(str): package base to update
variable(str | None): patch name. If ``None`` is set, all patches will be removed
"""
self.client.package_patches_remove(package_base, variable)
async def package_patches_update(self, package_base: str, patch: PkgbuildPatch) -> None:
"""
create or update package patch
Args:
package_base(str): package base to update
patch(PkgbuildPatch): package patch
"""
self.client.package_patches_update(package_base, patch)
async def package_remove(self, package_base: str) -> None:
"""
remove package base from known list if any
Args:
package_base(str): package base
"""
async with self._lock:
self._known.pop(package_base, None)
self.client.package_remove(package_base)
await self.event_bus.broadcast(EventType.PackageRemoved, package_base)
async def package_status_update(self, package_base: str, status: BuildStatusEnum) -> None:
"""
update package status
Args:
package_base(str): package base to update
status(BuildStatusEnum): new build status
"""
package, current_status = await self.package_get(package_base)
async with self._lock:
self._known[package_base] = (package, BuildStatus(status, is_held=current_status.is_held))
self.client.package_status_update(package_base, status)
await self.event_bus.broadcast(EventType.PackageStatusChanged, package_base, status=status.value)
async def package_update(self, package: Package, status: BuildStatusEnum) -> None:
"""
update package
Args:
package(Package): package description
status(BuildStatusEnum): new build status
"""
async with self._lock:
_, current_status = self._known.get(package.base, (package, BuildStatus()))
self._known[package.base] = (package, BuildStatus(status, is_held=current_status.is_held))
self.client.package_update(package, status)
await self.event_bus.broadcast(
EventType.PackageUpdated, package.base, status=status.value, version=package.version,
)
async def packages(self) -> list[tuple[Package, BuildStatus]]:
"""
get current known packages list
Returns:
list[tuple[Package, BuildStatus]]: list of packages together with their statuses
"""
async with self._lock:
return list(self._known.values())
async def shutdown(self) -> None:
"""
gracefully shutdown watcher
"""
await self.event_bus.shutdown()
async def status_update(self, status: BuildStatusEnum) -> None:
"""
update service status
Args:
status(BuildStatusEnum): new service status
"""
self.status = BuildStatus(status)
await self.event_bus.broadcast(EventType.ServiceStatusChanged, None, status=status.value)
def __call__(self, package_base: str | None) -> Self:
"""
extract client for future calls
Args:
package_base(str | None): package base to validate that package exists if applicable
Returns:
Self: instance of self to pass calls to the client
Raises:
UnknownPackageError: if no package found
"""
# keep check here instead of calling package_get to keep this method synchronized
if package_base is not None and package_base not in self._known:
raise UnknownPackageError(package_base)
return self
@@ -1,85 +0,0 @@
import argparse
import pytest
from pytest_mock import MockerFixture
from ahriman.application.application import Application
from ahriman.application.handlers.add import Add
from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository
from ahriman.models.package_source import PackageSource
from ahriman.models.pkgbuild_patch import PkgbuildPatch
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
Args:
args(argparse.Namespace): command line arguments fixture
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.package = ["ahriman"]
args.now = False
args.refresh = 0
args.source = PackageSource.Auto
args.username = "username"
args.variable = None
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
on_start_mock = mocker.patch("ahriman.application.application.Application.on_start")
perform_mock = mocker.patch("ahriman.application.handlers.add.Add.perform_action")
_, repository_id = configuration.check_loaded()
Add.run(args, repository_id, configuration, report=False)
on_start_mock.assert_called_once_with()
perform_mock.assert_called_once_with(pytest.helpers.anyvar(int), args)
def test_perform_action(args: argparse.Namespace, application: Application, mocker: MockerFixture) -> None:
"""
must perform add action
"""
args = _default_args(args)
application_mock = mocker.patch("ahriman.application.application.Application.add")
update_mock = mocker.patch("ahriman.application.handlers.update.Update.perform_action")
Add.perform_action(application, args)
application_mock.assert_called_once_with(args.package, args.source, args.username)
update_mock.assert_not_called()
def test_perform_action_with_patches(args: argparse.Namespace, application: Application, mocker: MockerFixture) -> None:
"""
must perform add action and insert temporary patches
"""
args = _default_args(args)
args.variable = ["KEY=VALUE"]
mocker.patch("ahriman.application.application.Application.add")
patches_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_patches_update")
Add.perform_action(application, args)
patches_mock.assert_called_once_with(args.package[0], PkgbuildPatch("KEY", "VALUE"))
def test_perform_action_with_updates(args: argparse.Namespace, application: Application, mocker: MockerFixture) -> None:
"""
must perform add action with updates after
"""
args = _default_args(args)
args.now = True
mocker.patch("ahriman.application.application.Application.add")
update_mock = mocker.patch("ahriman.application.handlers.update.Update.perform_action")
Add.perform_action(application, args)
update_mock.assert_called_once_with(application, args)
@@ -1,84 +0,0 @@
import argparse
import pytest
from pytest_mock import MockerFixture
from ahriman.application.handlers.archives import Archives
from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.repository import Repository
from ahriman.models.action import Action
from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
Args:
args(argparse.Namespace): command line arguments fixture
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.action = Action.List
args.exit_code = False
args.info = False
args.package = "package"
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
application_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives",
return_value=[package_ahriman])
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
print_mock = mocker.patch("ahriman.core.formatters.Printer.print")
_, repository_id = configuration.check_loaded()
Archives.run(args, repository_id, configuration, report=False)
application_mock.assert_called_once_with(args.package)
check_mock.assert_called_once_with(False, True)
print_mock.assert_called_once_with(verbose=False, log_fn=pytest.helpers.anyvar(int), separator=": ")
def test_run_empty_exception(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must raise ExitCode exception on empty archives result
"""
args = _default_args(args)
args.exit_code = True
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[])
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
_, repository_id = configuration.check_loaded()
Archives.run(args, repository_id, configuration, report=False)
check_mock.assert_called_once_with(True, False)
def test_imply_with_report(args: argparse.Namespace, configuration: Configuration, database: SQLite,
mocker: MockerFixture) -> None:
"""
must create application object with native reporting
"""
args = _default_args(args)
mocker.patch("ahriman.core.database.SQLite.load", return_value=database)
load_mock = mocker.patch("ahriman.core.repository.Repository.load")
_, repository_id = configuration.check_loaded()
Archives.run(args, repository_id, configuration, report=False)
load_mock.assert_called_once_with(repository_id, configuration, database, report=True, refresh_pacman_database=0)
def test_disallow_multi_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Archives.ALLOW_MULTI_ARCHITECTURE_RUN
@@ -1,53 +0,0 @@
import argparse
import pytest
from pytest_mock import MockerFixture
from ahriman.application.handlers.hold import Hold
from ahriman.core.configuration import Configuration
from ahriman.core.repository import Repository
from ahriman.models.action import Action
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
Args:
args(argparse.Namespace): command line arguments fixture
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.package = ["ahriman"]
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
args.action = Action.Update
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
hold_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update")
_, repository_id = configuration.check_loaded()
Hold.run(args, repository_id, configuration, report=False)
hold_mock.assert_called_once_with("ahriman", enabled=True)
def test_run_remove(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must remove held status
"""
args = _default_args(args)
args.action = Action.Remove
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
hold_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update")
_, repository_id = configuration.check_loaded()
Hold.run(args, repository_id, configuration, report=False)
hold_mock.assert_called_once_with("ahriman", enabled=False)
@@ -1,100 +0,0 @@
import argparse
import pytest
from pytest_mock import MockerFixture
from ahriman.application.handlers.pkgbuild import Pkgbuild
from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.repository import Repository
from ahriman.models.action import Action
from ahriman.models.changes import Changes
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
Args:
args(argparse.Namespace): command line arguments fixture
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.action = Action.List
args.exit_code = False
args.package = "package"
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
application_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get",
return_value=Changes("sha", "change", "pkgbuild content"))
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
print_mock = mocker.patch("ahriman.core.formatters.Printer.print")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
application_mock.assert_called_once_with(args.package)
check_mock.assert_called_once_with(False, True)
print_mock.assert_called_once_with(verbose=True, log_fn=pytest.helpers.anyvar(int), separator="")
def test_run_empty_exception(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must raise ExitCode exception on empty pkgbuild result
"""
args = _default_args(args)
args.exit_code = True
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=Changes())
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
check_mock.assert_called_once_with(True, False)
def test_run_remove(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must remove package pkgbuild
"""
args = _default_args(args)
args.action = Action.Remove
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
changes = Changes("sha", "change", "pkgbuild content")
mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes)
update_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
update_mock.assert_called_once_with(args.package, Changes("sha", "change", None))
def test_imply_with_report(args: argparse.Namespace, configuration: Configuration, database: SQLite,
mocker: MockerFixture) -> None:
"""
must create application object with native reporting
"""
args = _default_args(args)
mocker.patch("ahriman.core.database.SQLite.load", return_value=database)
load_mock = mocker.patch("ahriman.core.repository.Repository.load")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
load_mock.assert_called_once_with(repository_id, configuration, database, report=True, refresh_pacman_database=0)
def test_disallow_multi_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Pkgbuild.ALLOW_MULTI_ARCHITECTURE_RUN
@@ -1,113 +0,0 @@
import argparse
import pytest
from pytest_mock import MockerFixture
from ahriman.application.application import Application
from ahriman.application.handlers.rollback import Rollback
from ahriman.core.configuration import Configuration
from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.repository import Repository
from ahriman.models.package import Package
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
Args:
args(argparse.Namespace): command line arguments fixture
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.package = "ahriman"
args.version = "1.0.0-1"
args.hold = False
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
artifacts = [package.filepath for package in package_ahriman.packages.values()]
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
on_start_mock = mocker.patch("ahriman.application.application.Application.on_start")
load_mock = mocker.patch("ahriman.application.handlers.rollback.Rollback.package_load",
return_value=package_ahriman)
artifacts_mock = mocker.patch("ahriman.application.handlers.rollback.Rollback.package_artifacts",
return_value=artifacts)
perform_mock = mocker.patch("ahriman.application.handlers.add.Add.perform_action")
hold_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update")
_, repository_id = configuration.check_loaded()
Rollback.run(args, repository_id, configuration, report=False)
on_start_mock.assert_called_once_with()
load_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman.base, args.version)
artifacts_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman)
perform_mock.assert_called_once_with(pytest.helpers.anyvar(int), args)
hold_mock.assert_not_called()
def test_run_hold(args: argparse.Namespace, configuration: Configuration, repository: Repository,
package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must hold package after rollback
"""
args = _default_args(args)
args.hold = True
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.application.application.Application.on_start")
mocker.patch("ahriman.application.handlers.rollback.Rollback.package_load", return_value=package_ahriman)
mocker.patch("ahriman.application.handlers.rollback.Rollback.package_artifacts", return_value=[])
mocker.patch("ahriman.application.handlers.add.Add.perform_action")
hold_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update")
_, repository_id = configuration.check_loaded()
Rollback.run(args, repository_id, configuration, report=False)
hold_mock.assert_called_once_with(package_ahriman.base, enabled=True)
def test_package_artifacts(application: Application, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must return package artifacts
"""
artifacts = [package.filepath for package in package_ahriman.packages.values()]
lookup_mock = mocker.patch("ahriman.core.repository.Repository.package_archives_lookup", return_value=artifacts)
assert Rollback.package_artifacts(application, package_ahriman) == artifacts
lookup_mock.assert_called_once_with(package_ahriman)
def test_package_artifacts_empty(application: Application, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must raise UnknownPackageError if no artifacts found
"""
mocker.patch("ahriman.core.repository.Repository.package_archives_lookup", return_value=[])
with pytest.raises(UnknownPackageError):
Rollback.package_artifacts(application, package_ahriman)
def test_package_load(application: Application, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must load package from reporter
"""
package_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_get",
return_value=[(package_ahriman, None)])
result = Rollback.package_load(application, package_ahriman.base, "2.0.0-1")
assert result.version == "2.0.0-1"
package_mock.assert_called_once_with(package_ahriman.base)
def test_package_load_unknown(application: Application, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must raise UnknownPackageError if package not found
"""
mocker.patch("ahriman.core.status.local_client.LocalClient.package_get", return_value=[])
with pytest.raises(UnknownPackageError):
Rollback.package_load(application, package_ahriman.base, package_ahriman.version)
@@ -1,37 +0,0 @@
import pytest
from pathlib import Path
from unittest.mock import MagicMock
from ahriman.core.alpm.pacman_handle import PacmanHandle
def test_package_load() -> None:
"""
must load package from archive path
"""
local = Path("local")
instance = PacmanHandle.ephemeral()
handle_mock = instance.handle = MagicMock()
instance.package_load(local)
handle_mock.load_pkg.assert_called_once_with(str(local))
PacmanHandle._ephemeral = None
def test_getattr() -> None:
"""
must proxy attribute access to underlying handle
"""
instance = PacmanHandle.ephemeral()
assert instance.dbpath
def test_getattr_not_found() -> None:
"""
must raise AttributeError for missing handle attributes
"""
instance = PacmanHandle.ephemeral()
with pytest.raises(AttributeError):
assert instance.random_attribute
@@ -1,8 +0,0 @@
from ahriman.core.database.migrations.m017_pkgbuild import steps
def test_migration_pkgbuild() -> None:
"""
migration must not be empty
"""
assert steps
@@ -1,8 +0,0 @@
from ahriman.core.database.migrations.m018_package_hold import steps
def test_migration_package_hold() -> None:
"""
migration must not be empty
"""
assert steps
@@ -1,32 +0,0 @@
from ahriman.core.formatters import PkgbuildPrinter
from ahriman.models.changes import Changes
def test_properties(pkgbuild_printer: PkgbuildPrinter) -> None:
"""
must return non-empty properties list
"""
assert pkgbuild_printer.properties()
def test_properties_empty() -> None:
"""
must return empty properties list if pkgbuild is empty
"""
assert not PkgbuildPrinter(Changes()).properties()
assert not PkgbuildPrinter(Changes("sha", "changes")).properties()
def test_title(pkgbuild_printer: PkgbuildPrinter) -> None:
"""
must return non-empty title
"""
assert pkgbuild_printer.title()
def test_title_empty() -> None:
"""
must return empty title if change is empty
"""
assert not PkgbuildPrinter(Changes()).title()
assert not PkgbuildPrinter(Changes("sha")).title()
@@ -1,268 +0,0 @@
import pytest
from dataclasses import replace
from pathlib import Path
from pytest_mock import MockerFixture
from unittest.mock import MagicMock
from ahriman.core.repository import Repository
from ahriman.models.changes import Changes
from ahriman.models.package import Package
def test_full_depends(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
pyalpm_package_ahriman: MagicMock) -> None:
"""
must extract all dependencies from the package
"""
package_python_schedule.packages[package_python_schedule.base].provides = ["python3-schedule"]
database_mock = MagicMock()
database_mock.pkgcache = [pyalpm_package_ahriman]
repository.pacman = MagicMock()
repository.pacman.handle.get_syncdbs.return_value = [database_mock]
assert repository.full_depends(package_ahriman, [package_python_schedule]) == package_ahriman.depends
package_python_schedule.packages[package_python_schedule.base].depends = [package_ahriman.base]
expected = sorted(set(package_python_schedule.depends + package_ahriman.depends))
assert repository.full_depends(package_python_schedule, [package_python_schedule]) == expected
def test_load_archives(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must return all packages grouped by package base
"""
single_packages = [
Package(base=package_python_schedule.base,
version=package_python_schedule.version,
remote=package_python_schedule.remote,
packages={package: props})
for package, props in package_python_schedule.packages.items()
] + [package_ahriman]
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=single_packages)
mocker.patch("ahriman.core.status.local_client.LocalClient.package_get", return_value=[
(package_ahriman, None),
])
packages = repository.load_archives([Path("a.pkg.tar.xz"), Path("b.pkg.tar.xz"), Path("c.pkg.tar.xz")])
assert len(packages) == 2
assert {package.base for package in packages} == {package_ahriman.base, package_python_schedule.base}
archives = sum((list(package.packages.keys()) for package in packages), start=[])
assert len(archives) == 3
expected = set(package_ahriman.packages.keys())
expected.update(package_python_schedule.packages.keys())
assert set(archives) == expected
def test_load_archives_failed(repository: Repository, mocker: MockerFixture) -> None:
"""
must skip packages which cannot be loaded
"""
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=Exception)
assert not repository.load_archives([Path("a.pkg.tar.xz")])
def test_load_archives_not_package(repository: Repository) -> None:
"""
must skip not packages from iteration
"""
assert not repository.load_archives([Path("a.tar.xz")])
def test_load_archives_different_version(repository: Repository, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must load packages with different versions choosing maximal
"""
single_packages = [
Package(base=package_python_schedule.base,
version=package_python_schedule.version,
remote=package_python_schedule.remote,
packages={package: props})
for package, props in package_python_schedule.packages.items()
]
single_packages[0].version = "0.0.1-1"
mocker.patch("ahriman.models.package.Package.from_archive", side_effect=single_packages)
packages = repository.load_archives([Path("a.pkg.tar.xz"), Path("b.pkg.tar.xz")])
assert len(packages) == 1
assert packages[0].version == package_python_schedule.version
def test_load_archives_all_versions(repository: Repository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must load packages with different versions keeping all when latest_only is False
"""
mocker.patch("ahriman.models.package.Package.from_archive",
side_effect=[package_ahriman, replace(package_ahriman, version="0.0.1-1")])
mocker.patch("ahriman.core.status.local_client.LocalClient.package_get", return_value=[])
packages = repository.load_archives([Path("a.pkg.tar.xz"), Path("b.pkg.tar.xz")], latest_only=False)
assert len(packages) == 2
def test_package_archives(repository: Repository, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must load package archives sorted by version
"""
mocker.patch("pathlib.Path.is_dir", return_value=True)
mocker.patch("pathlib.Path.iterdir")
load_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives",
return_value=[replace(package_ahriman, version=str(i)) for i in range(5)])
result = repository.package_archives(package_ahriman.base)
assert len(result) == 5
assert [p.version for p in result] == [str(i) for i in range(5)]
load_mock.assert_called_once_with(pytest.helpers.anyvar(int), latest_only=False)
def test_package_archives_no_directory(repository: Repository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must return empty list if archive directory does not exist
"""
mocker.patch("pathlib.Path.is_dir", return_value=False)
assert repository.package_archives(package_ahriman.base) == []
def test_package_archives_architecture_mismatch(repository: Repository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must skip packages with mismatched architecture
"""
package_ahriman.packages[package_ahriman.base].architecture = "i686"
mocker.patch("pathlib.Path.is_dir", return_value=True)
mocker.patch("pathlib.Path.iterdir")
mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives",
return_value=[package_ahriman])
result = repository.package_archives(package_ahriman.base)
assert len(result) == 0
def test_package_archives_lookup(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must existing packages which match the version
"""
archives_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[
package_ahriman,
package_python_schedule,
replace(package_ahriman, version="1"),
])
glob_mock = mocker.patch("pathlib.Path.glob", return_value=[Path("1.pkg.tar.xz")])
assert repository.package_archives_lookup(package_ahriman) == [Path("1.pkg.tar.xz")]
archives_mock.assert_called_once_with(package_ahriman.base)
glob_mock.assert_called_once_with(f"{package_ahriman.packages[package_ahriman.base].filename}*")
def test_package_archives_lookup_version_mismatch(repository: Repository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must return nothing if no packages found with the same version
"""
mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives",
return_value=[replace(package_ahriman, version="1")])
assert repository.package_archives_lookup(package_ahriman) == []
def test_package_archives_lookup_architecture_mismatch(repository: Repository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must return nothing if architecture doesn't match
"""
mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[])
assert repository.package_archives_lookup(package_ahriman) == []
def test_package_archives_lookup_no_archive_directory(repository: Repository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must return nothing if no archive directory found
"""
mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[])
assert repository.package_archives_lookup(package_ahriman) == []
def test_package_changes(repository: Repository, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must load package changes
"""
changes = Changes("sha", "change")
load_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.load", return_value="sha2")
changes_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.changes", return_value=changes)
assert repository.package_changes(package_ahriman, changes.last_commit_sha) == changes
load_mock.assert_called_once_with(
pytest.helpers.anyvar(int), package_ahriman, [], repository.configuration.repository_paths)
changes_mock.assert_called_once_with(pytest.helpers.anyvar(int), changes.last_commit_sha)
def test_package_changes_skip(repository: Repository, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must skip loading package changes if no new commits
"""
mocker.patch("ahriman.core.build_tools.sources.Sources.load", return_value="sha")
changes_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.changes")
assert repository.package_changes(package_ahriman, "sha") is None
changes_mock.assert_not_called()
def test_packages(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must return repository packages
"""
mocker.patch("pathlib.Path.iterdir")
load_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives",
return_value=[package_ahriman, package_python_schedule])
assert repository.packages() == [package_ahriman, package_python_schedule]
# it uses filter object, so we cannot verify argument list =/
load_mock.assert_called_once_with(pytest.helpers.anyvar(int))
def test_packages_filter(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must filter result by bases
"""
mocker.patch("pathlib.Path.iterdir")
mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives",
return_value=[package_ahriman, package_python_schedule])
assert repository.packages([package_ahriman.base]) == [package_ahriman]
def test_packages_built(repository: Repository, mocker: MockerFixture) -> None:
"""
must return build packages
"""
mocker.patch("pathlib.Path.iterdir", return_value=[Path("a.tar.xz"), Path("b.pkg.tar.xz")])
assert repository.packages_built() == [Path("b.pkg.tar.xz")]
def test_packages_depend_on(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must filter packages by depends list
"""
mocker.patch("ahriman.core.repository.repository.Repository.packages",
return_value=[package_ahriman, package_python_schedule])
assert repository.packages_depend_on([package_ahriman], {"python-srcinfo"}) == [package_ahriman]
def test_packages_depend_on_empty(repository: Repository, package_ahriman: Package, package_python_schedule: Package,
mocker: MockerFixture) -> None:
"""
must return all packages in case if no filter is provided
"""
mocker.patch("ahriman.core.repository.repository.Repository.packages",
return_value=[package_ahriman, package_python_schedule])
assert repository.packages_depend_on([package_ahriman, package_python_schedule], None) == \
[package_ahriman, package_python_schedule]
@@ -1,145 +0,0 @@
import pytest
from asyncio import QueueShutDown
from ahriman.core.status.event_bus import EventBus
from ahriman.models.event import EventType
from ahriman.models.package import Package
async def test_broadcast(event_bus: EventBus, package_ahriman: Package) -> None:
"""
must broadcast event to all subscribers
"""
_, queue = await event_bus.subscribe()
await event_bus.broadcast(EventType.PackageUpdated, package_ahriman.base, version=package_ahriman.version)
message = queue.get_nowait()
assert message == (
EventType.PackageUpdated,
{"object_id": package_ahriman.base, "version": package_ahriman.version},
)
async def test_broadcast_with_topics(event_bus: EventBus, package_ahriman: Package) -> None:
"""
must broadcast event to subscribers with matching topics
"""
_, queue = await event_bus.subscribe([EventType.PackageUpdated])
await event_bus.broadcast(EventType.PackageUpdated, package_ahriman.base)
assert not queue.empty()
async def test_broadcast_topic_isolation(event_bus: EventBus, package_ahriman: Package) -> None:
"""
must not broadcast event to subscribers with non-matching topics
"""
_, queue = await event_bus.subscribe([EventType.BuildLog])
await event_bus.broadcast(EventType.PackageUpdated, package_ahriman.base)
assert queue.empty()
async def test_broadcast_queue_full(event_bus: EventBus, package_ahriman: Package) -> None:
"""
must discard message to slow subscriber
"""
event_bus.max_size = 1
_, queue = await event_bus.subscribe()
await event_bus.broadcast(EventType.PackageUpdated, package_ahriman.base)
await event_bus.broadcast(EventType.PackageRemoved, package_ahriman.base)
assert queue.qsize() == 1
async def test_broadcast_queue_shutdown(event_bus: EventBus, package_ahriman: Package) -> None:
"""
must skip subscriber whose queue was shutdown concurrently
"""
_, 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()
assert subscriber_id not in event_bus._subscribers
with pytest.raises(QueueShutDown):
queue.get_nowait()
async def test_shutdown_queue_full(event_bus: EventBus, package_ahriman: Package) -> None:
"""
must handle shutdown when queue is full
"""
event_bus.max_size = 1
_, queue = await event_bus.subscribe()
await event_bus.broadcast(EventType.PackageUpdated, package_ahriman.base)
await event_bus.shutdown()
async def test_subscribe(event_bus: EventBus) -> None:
"""
must register new subscriber
"""
subscriber_id, queue = await event_bus.subscribe()
assert subscriber_id
assert queue.empty()
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:
"""
must register subscriber with topic filter
"""
subscriber_id, _ = await event_bus.subscribe([EventType.BuildLog])
assert event_bus._subscribers[subscriber_id].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:
"""
must remove subscriber
"""
subscriber_id, _ = await event_bus.subscribe()
await event_bus.unsubscribe(subscriber_id)
assert subscriber_id not in event_bus._subscribers
async def test_unsubscribe_unknown(event_bus: EventBus) -> None:
"""
must not fail on unknown subscriber removal
"""
await event_bus.unsubscribe("unknown")
@@ -1,385 +0,0 @@
import pytest
from pytest_mock import MockerFixture
from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.status.watcher import Watcher
from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.changes import Changes
from ahriman.models.dependencies import Dependencies
from ahriman.models.event import Event, EventType
from ahriman.models.log_record import LogRecord
from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
async def test_event_add(watcher: Watcher, mocker: MockerFixture) -> None:
"""
must create new event
"""
event = Event("event", "object")
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.event_add")
await watcher.event_add(event)
cache_mock.assert_called_once_with(event)
async def test_event_get(watcher: Watcher, mocker: MockerFixture) -> None:
"""
must retrieve events
"""
event = Event("event", "object")
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.event_get", return_value=[event])
result = await watcher.event_get(None, None)
assert result == [event]
cache_mock.assert_called_once_with(None, None, None, None, -1, 0)
async def test_load(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must correctly load packages
"""
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_get",
return_value=[(package_ahriman, BuildStatus())])
await watcher.load()
cache_mock.assert_called_once_with(None)
package, status = watcher._known[package_ahriman.base]
assert package == package_ahriman
assert status.status == BuildStatusEnum.Unknown
async def test_load_known(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must correctly load packages with known statuses
"""
status = BuildStatus(BuildStatusEnum.Success)
mocker.patch("ahriman.core.status.local_client.LocalClient.package_get", return_value=[(package_ahriman, status)])
watcher._known = {package_ahriman.base: (package_ahriman, status)}
await watcher.load()
_, status = watcher._known[package_ahriman.base]
assert status.status == BuildStatusEnum.Success
async def test_logs_rotate(watcher: Watcher, mocker: MockerFixture) -> None:
"""
must rotate logs
"""
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.logs_rotate")
await watcher.logs_rotate(42)
cache_mock.assert_called_once_with(42)
async def test_package_archives(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must return package archives from package info
"""
archives_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives",
return_value=[package_ahriman])
result = await watcher.package_archives(package_ahriman.base)
assert result == [package_ahriman]
archives_mock.assert_called_once_with(package_ahriman.base)
async def test_package_get(watcher: Watcher, package_ahriman: Package) -> None:
"""
must return package status
"""
watcher._known = {package_ahriman.base: (package_ahriman, BuildStatus())}
package, status = await watcher.package_get(package_ahriman.base)
assert package == package_ahriman
assert status.status == BuildStatusEnum.Unknown
async def test_package_get_failed(watcher: Watcher, package_ahriman: Package) -> None:
"""
must fail on unknown package
"""
with pytest.raises(UnknownPackageError):
await watcher.package_get(package_ahriman.base)
async def test_package_changes_get(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must return package changes
"""
changes = Changes("sha")
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get",
return_value=changes)
assert await watcher.package_changes_get(package_ahriman.base) == changes
cache_mock.assert_called_once_with(package_ahriman.base)
async def test_package_changes_update(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must update package changes
"""
changes = Changes("sha")
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
await watcher.package_changes_update(package_ahriman.base, changes)
cache_mock.assert_called_once_with(package_ahriman.base, changes)
async def test_package_dependencies_get(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must return package dependencies
"""
dependencies = Dependencies({"path": [package_ahriman.base]})
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_dependencies_get",
return_value=dependencies)
assert await watcher.package_dependencies_get(package_ahriman.base) == dependencies
cache_mock.assert_called_once_with(package_ahriman.base)
async def test_package_dependencies_update(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must update package dependencies
"""
dependencies = Dependencies({"path": [package_ahriman.base]})
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_dependencies_update")
await watcher.package_dependencies_update(package_ahriman.base, dependencies)
cache_mock.assert_called_once_with(package_ahriman.base, dependencies)
async def test_package_hold_update(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must update package hold status
"""
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update")
broadcast_mock = mocker.patch("ahriman.core.status.event_bus.EventBus.broadcast")
watcher._known = {package_ahriman.base: (package_ahriman, BuildStatus())}
await watcher.package_hold_update(package_ahriman.base, enabled=True)
cache_mock.assert_called_once_with(package_ahriman.base, enabled=True)
_, status = watcher._known[package_ahriman.base]
assert status.is_held is True
broadcast_mock.assert_called_once_with(EventType.PackageHeld, package_ahriman.base, is_held=True)
async def test_package_hold_update_unknown(watcher: Watcher, package_ahriman: Package) -> None:
"""
must fail on unknown package hold update
"""
with pytest.raises(UnknownPackageError):
await watcher.package_hold_update(package_ahriman.base, enabled=True)
async def test_package_logs_add(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must post log record
"""
log_record = LogRecord(LogRecordId(package_ahriman.base, "1.0.0"), 42.0, "message")
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_logs_add")
broadcast_mock = mocker.patch("ahriman.core.status.event_bus.EventBus.broadcast")
await watcher.package_logs_add(log_record)
cache_mock.assert_called_once_with(log_record)
broadcast_mock.assert_called_once_with(EventType.BuildLog, package_ahriman.base, **log_record.view())
async def test_package_logs_get(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must return package logs
"""
log_record = LogRecord(LogRecordId(package_ahriman.base, "1.0.0"), 42.0, "message")
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_logs_get",
return_value=[log_record])
assert await watcher.package_logs_get(package_ahriman.base) == [log_record]
cache_mock.assert_called_once_with(package_ahriman.base, None, None, -1, 0)
async def test_package_logs_remove(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must remove package logs
"""
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_logs_remove")
await watcher.package_logs_remove(package_ahriman.base, None)
cache_mock.assert_called_once_with(package_ahriman.base, None)
async def test_package_patches_get(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must return package patches
"""
patch = PkgbuildPatch("key", "value")
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_patches_get", return_value=[patch])
assert await watcher.package_patches_get(package_ahriman.base, None) == [patch]
cache_mock.assert_called_once_with(package_ahriman.base, None)
async def test_package_patches_remove(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must remove package patches
"""
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_patches_remove")
await watcher.package_patches_remove(package_ahriman.base, None)
cache_mock.assert_called_once_with(package_ahriman.base, None)
async def test_package_patches_update(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must update package patches
"""
patch = PkgbuildPatch("key", "value")
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_patches_update")
await watcher.package_patches_update(package_ahriman.base, patch)
cache_mock.assert_called_once_with(package_ahriman.base, patch)
async def test_package_remove(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must remove package base
"""
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove")
broadcast_mock = mocker.patch("ahriman.core.status.event_bus.EventBus.broadcast")
watcher._known = {package_ahriman.base: (package_ahriman, BuildStatus())}
await watcher.package_remove(package_ahriman.base)
assert not watcher._known
cache_mock.assert_called_once_with(package_ahriman.base)
broadcast_mock.assert_called_once_with(EventType.PackageRemoved, package_ahriman.base)
async def test_package_remove_unknown(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must not fail on unknown base removal
"""
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_remove")
broadcast_mock = mocker.patch("ahriman.core.status.event_bus.EventBus.broadcast")
await watcher.package_remove(package_ahriman.base)
cache_mock.assert_called_once_with(package_ahriman.base)
broadcast_mock.assert_called_once_with(EventType.PackageRemoved, package_ahriman.base)
async def test_package_status_update(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must update package status only for known package
"""
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_status_update")
broadcast_mock = mocker.patch("ahriman.core.status.event_bus.EventBus.broadcast")
watcher._known = {package_ahriman.base: (package_ahriman, BuildStatus())}
await watcher.package_status_update(package_ahriman.base, BuildStatusEnum.Success)
cache_mock.assert_called_once_with(package_ahriman.base, pytest.helpers.anyvar(int))
package, status = watcher._known[package_ahriman.base]
assert package == package_ahriman
assert status.status == BuildStatusEnum.Success
broadcast_mock.assert_called_once_with(
EventType.PackageStatusChanged, package_ahriman.base, status=BuildStatusEnum.Success.value,
)
async def test_package_status_update_preserves_hold(watcher: Watcher, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must preserve hold status on package status update
"""
mocker.patch("ahriman.core.status.local_client.LocalClient.package_status_update")
mocker.patch("ahriman.core.status.event_bus.EventBus.broadcast")
watcher._known = {package_ahriman.base: (package_ahriman, BuildStatus(is_held=True))}
await watcher.package_status_update(package_ahriman.base, BuildStatusEnum.Success)
_, status = watcher._known[package_ahriman.base]
assert status.is_held is True
async def test_package_status_update_unknown(watcher: Watcher, package_ahriman: Package) -> None:
"""
must fail on unknown package status update only
"""
with pytest.raises(UnknownPackageError):
await watcher.package_status_update(package_ahriman.base, BuildStatusEnum.Unknown)
async def test_package_update(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must add package to cache
"""
cache_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_update")
broadcast_mock = mocker.patch("ahriman.core.status.event_bus.EventBus.broadcast")
await watcher.package_update(package_ahriman, BuildStatusEnum.Unknown)
assert await watcher.packages()
cache_mock.assert_called_once_with(package_ahriman, pytest.helpers.anyvar(int))
broadcast_mock.assert_called_once_with(
EventType.PackageUpdated, package_ahriman.base,
status=BuildStatusEnum.Unknown.value, version=package_ahriman.version,
)
async def test_package_update_preserves_hold(watcher: Watcher, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must preserve hold status on package update
"""
mocker.patch("ahriman.core.status.local_client.LocalClient.package_update")
watcher._known = {package_ahriman.base: (package_ahriman, BuildStatus(is_held=True))}
await watcher.package_update(package_ahriman, BuildStatusEnum.Success)
_, status = watcher._known[package_ahriman.base]
assert status.is_held is True
async def test_packages(watcher: Watcher, package_ahriman: Package) -> None:
"""
must return list of available packages
"""
assert not await watcher.packages()
watcher._known = {package_ahriman.base: (package_ahriman, BuildStatus())}
assert await watcher.packages()
async def test_shutdown(watcher: Watcher, mocker: MockerFixture) -> None:
"""
must gracefully shutdown watcher
"""
shutdown_mock = mocker.patch("ahriman.core.status.event_bus.EventBus.shutdown")
await watcher.shutdown()
shutdown_mock.assert_called_once_with()
async def test_status_update(watcher: Watcher, mocker: MockerFixture) -> None:
"""
must update service status
"""
broadcast_mock = mocker.patch("ahriman.core.status.event_bus.EventBus.broadcast")
await watcher.status_update(BuildStatusEnum.Success)
assert watcher.status.status == BuildStatusEnum.Success
broadcast_mock.assert_called_once_with(EventType.ServiceStatusChanged, None, status=BuildStatusEnum.Success.value)
def test_call(watcher: Watcher, package_ahriman: Package) -> None:
"""
must return self instance if package exists
"""
watcher._known = {package_ahriman.base: (package_ahriman, BuildStatus())}
assert watcher(package_ahriman.base)
def test_call_skip(watcher: Watcher) -> None:
"""
must return self instance if no package base set
"""
assert watcher(None)
def test_call_failed(watcher: Watcher, package_ahriman: Package) -> None:
"""
must raise UnknownPackage
"""
with pytest.raises(UnknownPackageError):
assert watcher(package_ahriman.base)
-162
View File
@@ -1,162 +0,0 @@
import datetime
import pytest
from unittest.mock import MagicMock, PropertyMock
from ahriman.core.alpm.pacman import Pacman
from ahriman.core.alpm.remote import AUR
from ahriman.core.configuration import Configuration
from ahriman.models.aur_package import AURPackage
from ahriman.models.package import Package
from ahriman.models.package_description import PackageDescription
from ahriman.models.package_source import PackageSource
from ahriman.models.pacman_synchronization import PacmanSynchronization
from ahriman.models.remote_source import RemoteSource
from ahriman.models.scan_paths import ScanPaths
@pytest.fixture
def aur_package_akonadi() -> AURPackage:
"""
fixture for AUR package
Returns:
AURPackage: AUR package test instance
"""
return AURPackage(
id=0,
name="akonadi",
package_base_id=0,
package_base="akonadi",
version="21.12.3-2",
description="PIM layer, which provides an asynchronous API to access all kind of PIM data",
num_votes=0,
popularity=0.0,
first_submitted=datetime.datetime.fromtimestamp(0, datetime.UTC),
last_modified=datetime.datetime.fromtimestamp(1646555990.610, datetime.UTC),
url_path="",
url="https://kontact.kde.org",
out_of_date=None,
maintainer="felixonmars",
repository="extra",
depends=[
"libakonadi",
"mariadb",
],
make_depends=[
"boost",
"doxygen",
"extra-cmake-modules",
"kaccounts-integration",
"kitemmodels",
"postgresql",
"qt5-tools",
],
opt_depends=[
"postgresql: PostgreSQL backend",
],
conflicts=[],
provides=[],
license=["LGPL"],
keywords=[],
groups=[],
)
@pytest.fixture
def package_tpacpi_bat_git() -> Package:
"""
git package fixture
Returns:
Package: git package test instance
"""
return Package(
base="tpacpi-bat-git",
version="3.1.r12.g4959b52-1",
remote=RemoteSource(
source=PackageSource.AUR,
git_url=AUR.remote_git_url("tpacpi-bat-git", "aur"),
web_url=AUR.remote_web_url("tpacpi-bat-git"),
path=".",
branch="master",
),
packages={"tpacpi-bat-git": PackageDescription()})
@pytest.fixture
def pacman(configuration: Configuration) -> Pacman:
"""
fixture for pacman wrapper
Args:
configuration(Configuration): configuration fixture
Returns:
Pacman: pacman wrapper test instance
"""
_, repository_id = configuration.check_loaded()
return Pacman(repository_id, configuration, refresh_database=PacmanSynchronization.Disabled)
@pytest.fixture
def passwd() -> MagicMock:
"""
get passwd structure for the user
Returns:
MagicMock: passwd structure test instance
"""
passwd = MagicMock()
passwd.pw_dir = "home"
passwd.pw_name = "ahriman"
return passwd
@pytest.fixture
def pyalpm_package_ahriman(aur_package_ahriman: AURPackage) -> MagicMock:
"""
mock object for pyalpm package
Args:
aur_package_ahriman(AURPackage): package fixture
Returns:
MagicMock: pyalpm package mock
"""
mock = MagicMock()
db = type(mock).db = MagicMock()
type(mock).base = PropertyMock(return_value=aur_package_ahriman.package_base)
type(mock).builddate = PropertyMock(
return_value=aur_package_ahriman.last_modified.replace(tzinfo=datetime.timezone.utc).timestamp())
type(mock).conflicts = PropertyMock(return_value=aur_package_ahriman.conflicts)
type(db).name = PropertyMock(return_value="aur")
type(mock).depends = PropertyMock(return_value=aur_package_ahriman.depends)
type(mock).desc = PropertyMock(return_value=aur_package_ahriman.description)
type(mock).licenses = PropertyMock(return_value=aur_package_ahriman.license)
type(mock).makedepends = PropertyMock(return_value=aur_package_ahriman.make_depends)
type(mock).name = PropertyMock(return_value=aur_package_ahriman.name)
type(mock).optdepends = PropertyMock(return_value=aur_package_ahriman.opt_depends)
type(mock).checkdepends = PropertyMock(return_value=aur_package_ahriman.check_depends)
type(mock).packager = PropertyMock(return_value="packager")
type(mock).provides = PropertyMock(return_value=aur_package_ahriman.provides)
type(mock).version = PropertyMock(return_value=aur_package_ahriman.version)
type(mock).url = PropertyMock(return_value=aur_package_ahriman.url)
type(mock).groups = PropertyMock(return_value=aur_package_ahriman.groups)
return mock
@pytest.fixture
def scan_paths(configuration: Configuration) -> ScanPaths:
"""
scan paths fixture
Args:
configuration(Configuration): configuration test instance
Returns:
ScanPaths: scan paths test instance
"""
return ScanPaths(configuration.getlist("build", "scan_paths", fallback=[]))
-32
View File
@@ -1,32 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "ahriman-triggers"
description = "ArcH linux ReposItory MANager, additional extensions"
readme = "../README.md"
requires-python = ">=3.13"
license = {file = "../COPYING"}
authors = [
{name = "ahriman team"},
]
dependencies = [
"ahriman-core",
]
dynamic = ["version"]
[project.urls]
Documentation = "https://ahriman.readthedocs.io/"
Repository = "https://github.com/arcan1s/ahriman"
Changelog = "https://github.com/arcan1s/ahriman/releases"
[tool.hatch.version]
path = "../ahriman-core/src/ahriman/__init__.py"
[tool.hatch.build.targets.wheel]
only-include = ["src/ahriman"]
sources = ["src"]
[tool.hatch.build.targets.wheel.shared-data]
"package/share" = "share"
View File
-55
View File
@@ -1,55 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "ahriman-web"
description = "ArcH linux ReposItory MANager, web server"
readme = "../README.md"
requires-python = ">=3.13"
license = {file = "../COPYING"}
authors = [
{name = "ahriman team"},
]
dependencies = [
"ahriman-core",
"aiohttp",
"aiohttp_cors",
"aiohttp_jinja2",
"aiohttp_sse",
]
dynamic = ["version"]
[project.optional-dependencies]
auth = [
"aiohttp_session",
"aiohttp_security",
"cryptography",
]
docs = [
"aiohttp-apispec",
"setuptools",
]
metrics = [
"aiohttp-openmetrics",
]
oauth2 = [
"ahriman-web[auth]",
"aioauth-client",
]
[project.urls]
Documentation = "https://ahriman.readthedocs.io/"
Repository = "https://github.com/arcan1s/ahriman"
Changelog = "https://github.com/arcan1s/ahriman/releases"
[tool.hatch.version]
path = "../ahriman-core/src/ahriman/__init__.py"
[tool.hatch.build.targets.wheel]
only-include = ["src/ahriman"]
sources = ["src"]
[tool.hatch.build.targets.wheel.shared-data]
"package/lib" = "lib"
"package/share" = "share"
@@ -1,61 +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 hashlib
from aiohttp import ETag
from aiohttp.typedefs import Middleware
from aiohttp.web import HTTPNotModified, Request, Response, StreamResponse, middleware
from ahriman.web.middlewares import HandlerType
__all__ = ["etag_handler"]
def etag_handler() -> Middleware:
"""
middleware to handle ETag header for conditional requests. It computes ETag from the response body
and returns 304 Not Modified if the client sends a matching ``If-None-Match`` header
Returns:
Middleware: built middleware
Raises:
HTTPNotModified: if content matches ``If-None-Match`` header sent
"""
@middleware
async def handle(request: Request, handler: HandlerType) -> StreamResponse:
response = await handler(request)
if not isinstance(response, Response) or not isinstance(response.body, bytes):
return response
if request.method not in ("GET", "HEAD"):
return response
etag = ETag(value=hashlib.md5(response.body, usedforsecurity=False).hexdigest())
response.etag = etag
if request.if_none_match is not None and etag in request.if_none_match:
raise HTTPNotModified(headers={"ETag": response.headers["ETag"]})
return response
return handle
@@ -1,37 +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/>.
#
from ahriman.models.event import EventType
from ahriman.web.apispec import fields
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
class EventBusFilterSchema(RepositoryIdSchema):
"""
request event bus filter schema
"""
event = fields.List(fields.String(), metadata={
"description": "Event type filter",
"example": [EventType.PackageUpdated],
})
object_id = fields.String(metadata={
"description": "Object identifier filter",
"example": "ahriman",
})
@@ -1,30 +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/>.
#
from ahriman.web.apispec import Schema, fields
class HoldSchema(Schema):
"""
request hold schema
"""
is_held = fields.Boolean(required=True, metadata={
"description": "Package hold status",
})
@@ -1,30 +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/>.
#
from ahriman.web.apispec import Schema, fields
class PackagerSchema(Schema):
"""
request packager schema
"""
packager = fields.String(metadata={
"description": "Packager identity if applicable",
})
@@ -1,40 +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/>.
#
from ahriman import __version__
from ahriman.web.apispec import fields
from ahriman.web.schemas.packager_schema import PackagerSchema
class RollbackSchema(PackagerSchema):
"""
request schema for package rollback
"""
hold = fields.Boolean(dump_default=True, metadata={
"description": "Hold package after rollback",
})
package = fields.String(required=True, metadata={
"description": "Package name",
"example": "ahriman",
})
version = fields.String(required=True, metadata={
"description": "Package version",
"example": __version__,
})
@@ -1,35 +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/>.
#
from ahriman.models.event import EventType
from ahriman.web.apispec import Schema, fields
class SSESchema(Schema):
"""
response SSE schema
"""
event = fields.String(required=True, metadata={
"description": "Event type",
"example": EventType.PackageUpdated,
})
data = fields.Dict(keys=fields.String(), values=fields.Raw(), metadata={
"description": "Event data",
})
@@ -1,164 +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 json
from aiohttp.web import HTTPBadRequest, Request, Response, StreamResponse
from aiohttp_sse import EventSourceResponse, sse_response
from asyncio import Queue, QueueShutDown, wait_for
from typing import ClassVar
from ahriman.core.status.event_bus import SSEvent
from ahriman.models.event import EventType
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import EventBusFilterSchema, SSESchema
from ahriman.web.views.base import BaseView
class EventBusView(BaseView):
"""
event bus SSE view
Attributes:
READ_EVENTS(set[EventType]): (class attribute) events which are allowed for read-only users
"""
READ_EVENTS: ClassVar[set[EventType]] = {
EventType.PackageHeld,
EventType.PackageOutdated,
EventType.PackageRemoved,
EventType.PackageStatusChanged,
EventType.PackageUpdateFailed,
EventType.PackageUpdated,
EventType.ServiceStatusChanged,
}
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
async def _run(response: EventSourceResponse, queue: Queue[SSEvent]) -> None:
"""
read events from queue and send them to the client
Args:
response(EventSourceResponse): SSE response instance
queue(Queue[SSEvent]): subscriber queue
"""
while response.is_connected():
try:
event_type, data = await wait_for(queue.get(), timeout=response.ping_interval)
except TimeoutError:
continue
except QueueShutDown:
break
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(
tags=["Audit log"],
summary="Live updates",
description="Stream live updates via SSE. Read-only users may subscribe only when all requested event filters "
"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_404_description="Repository is unknown",
schema=SSESchema(many=True),
query_schema=EventBusFilterSchema,
)
async def get(self) -> StreamResponse:
"""
subscribe on updates
Returns:
StreamResponse: 200 with streaming updates
"""
topics = self._topics()
object_id = self.request.query.get("object_id")
event_bus = self.service().event_bus
async with sse_response(self.request) as response:
subscription_id, queue = await event_bus.subscribe(topics, object_id=object_id)
try:
await self._run(response, queue)
except (ConnectionResetError, QueueShutDown):
pass
finally:
await event_bus.unsubscribe(subscription_id)
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",
})
@@ -1,65 +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/>.
#
from aiohttp.web import Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import PackageNameSchema, PackageSchema, RepositoryIdSchema
from ahriman.web.views.base import BaseView
from ahriman.web.views.status_view_guard import StatusViewGuard
class Archives(StatusViewGuard, BaseView):
"""
package archives web view
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
"""
GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Reporter
ROUTES = ["/api/v1/packages/{package}/archives"]
@apidocs(
tags=["Packages"],
summary="Get package archives",
description="Retrieve built package archives for the base",
permission=GET_PERMISSION,
error_404_description="Package base and/or repository are unknown",
schema=PackageSchema(many=True),
match_schema=PackageNameSchema,
query_schema=RepositoryIdSchema,
)
async def get(self) -> Response:
"""
get package archives
Returns:
Response: 200 with package archives on success
Raises:
HTTPNotFound: if no package was found
"""
package_base = self.request.match_info["package"]
archives = await self.service(package_base=package_base).package_archives(package_base)
return self.json_response([archive.view() for archive in archives])
@@ -1,75 +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/>.
#
from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound
from typing import ClassVar
from ahriman.core.exceptions import UnknownPackageError
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import HoldSchema, PackageNameSchema, RepositoryIdSchema
from ahriman.web.views.base import BaseView
from ahriman.web.views.status_view_guard import StatusViewGuard
class HoldView(StatusViewGuard, BaseView):
"""
package hold web view
Attributes:
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/packages/{package}/hold"]
@apidocs(
tags=["Packages"],
summary="Update package hold status",
description="Set package hold status",
permission=POST_PERMISSION,
error_400_enabled=True,
error_404_description="Package base and/or repository are unknown",
match_schema=PackageNameSchema,
query_schema=RepositoryIdSchema,
body_schema=HoldSchema,
)
async def post(self) -> None:
"""
update package hold status
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: in case of success response
HTTPNotFound: if no package was found
"""
package_base = self.request.match_info["package"]
try:
data = await self.request.json()
is_held = data["is_held"]
except Exception as ex:
raise HTTPBadRequest(reason=str(ex))
try:
await self.service().package_hold_update(package_base, enabled=is_held)
except UnknownPackageError:
raise HTTPNotFound(reason=f"Package {package_base} is unknown")
raise HTTPNoContent
@@ -1,77 +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/>.
#
from aiohttp.web import HTTPBadRequest, Response
from typing import ClassVar
from ahriman.models.user_access import UserAccess
from ahriman.web.apispec.decorators import apidocs
from ahriman.web.schemas import ProcessIdSchema, RepositoryIdSchema, RollbackSchema
from ahriman.web.views.base import BaseView
class RollbackView(BaseView):
"""
package rollback web view
Attributes:
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full
ROUTES = ["/api/v1/service/rollback"]
@apidocs(
tags=["Actions"],
summary="Rollback package",
description="Rollback package to specified version",
permission=POST_PERMISSION,
error_400_enabled=True,
schema=ProcessIdSchema,
query_schema=RepositoryIdSchema,
body_schema=RollbackSchema,
)
async def post(self) -> Response:
"""
run package rollback
Returns:
Response: 200 with spawned process id
Raises:
HTTPBadRequest: if bad data is supplied
"""
try:
data = await self.request.json()
package = self.get_non_empty(lambda key: data[key], "package")
version = self.get_non_empty(lambda key: data[key], "version")
except Exception as ex:
raise HTTPBadRequest(reason=str(ex))
repository_id = self.repository_id()
username = await self.username()
process_id = self.spawner.packages_rollback(
repository_id,
package,
version,
username,
hold=data.get("hold", True),
)
return self.json_response({"process_id": process_id})
@@ -1,19 +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/>.
#
@@ -1,85 +0,0 @@
import hashlib
import pytest
from aiohttp import ETag
from aiohttp.web import HTTPNotModified, Response, StreamResponse
from unittest.mock import AsyncMock
from ahriman.web.middlewares.etag_handler import etag_handler
async def test_etag_handler() -> None:
"""
must set ETag header on GET responses
"""
request = pytest.helpers.request("", "", "GET")
request.if_none_match = None
request_handler = AsyncMock(return_value=Response(body=b"hello"))
handler = etag_handler()
result = await handler(request, request_handler)
assert result.etag is not None
async def test_etag_handler_not_modified() -> None:
"""
must raise NotModified when ETag matches If-None-Match
"""
body = b"hello"
request = pytest.helpers.request("", "", "GET")
request.if_none_match = (ETag(value=hashlib.md5(body, usedforsecurity=False).hexdigest()),)
request_handler = AsyncMock(return_value=Response(body=body))
handler = etag_handler()
with pytest.raises(HTTPNotModified):
await handler(request, request_handler)
async def test_etag_handler_no_match() -> None:
"""
must return full response when ETag does not match If-None-Match
"""
request = pytest.helpers.request("", "", "GET")
request.if_none_match = (ETag(value="outdated"),)
request_handler = AsyncMock(return_value=Response(body=b"hello"))
handler = etag_handler()
result = await handler(request, request_handler)
assert result.status == 200
assert result.etag is not None
async def test_etag_handler_skip_post() -> None:
"""
must skip ETag for non-GET/HEAD methods
"""
request = pytest.helpers.request("", "", "POST")
request_handler = AsyncMock(return_value=Response(body=b"hello"))
handler = etag_handler()
result = await handler(request, request_handler)
assert result.etag is None
async def test_etag_handler_skip_no_body() -> None:
"""
must skip ETag for responses without body
"""
request = pytest.helpers.request("", "", "GET")
request_handler = AsyncMock(return_value=Response())
handler = etag_handler()
result = await handler(request, request_handler)
assert result.etag is None
async def test_etag_handler_skip_stream() -> None:
"""
must skip ETag for streaming responses
"""
request = pytest.helpers.request("", "", "GET")
request_handler = AsyncMock(return_value=StreamResponse())
handler = etag_handler()
result = await handler(request, request_handler)
assert "ETag" not in result.headers
@@ -1 +0,0 @@
# schema testing goes in view class tests
@@ -1 +0,0 @@
# schema testing goes in view class tests
@@ -1 +0,0 @@
# schema testing goes in view class tests
@@ -1 +0,0 @@
# schema testing goes in view class tests
@@ -1 +0,0 @@
# schema testing goes in view class tests
@@ -1,55 +0,0 @@
import aiohttp_cors
import pytest
from aiohttp.web import Application
from pytest_mock import MockerFixture
from ahriman.web.cors import setup_cors
from ahriman.web.keys import ConfigurationKey
def test_setup_cors(application: Application) -> None:
"""
must setup CORS
"""
cors = application[aiohttp_cors.APP_CONFIG_KEY]
# let's test here that it is enabled for all requests
for route in application.router.routes():
# we don't want to deal with match info here though
try:
url = route.url_for()
except (KeyError, TypeError):
continue
request = pytest.helpers.request(application, url, route.method, resource=route.resource)
assert cors._cors_impl._router_adapter.is_cors_enabled_on_request(request)
def test_setup_cors_custom_origins(application: Application, mocker: MockerFixture) -> None:
"""
must setup CORS with custom origins
"""
configuration = application[ConfigurationKey]
configuration.set_option("web", "cors_allow_origins", "https://example.com https://httpbin.com")
setup_mock = mocker.patch("ahriman.web.cors.aiohttp_cors.setup", return_value=mocker.MagicMock())
setup_cors(application, configuration)
defaults = setup_mock.call_args.kwargs["defaults"]
assert "https://example.com" in defaults
assert "https://httpbin.com" in defaults
assert "*" not in defaults
def test_setup_cors_custom_methods(application: Application, mocker: MockerFixture) -> None:
"""
must setup CORS with custom methods
"""
configuration = application[ConfigurationKey]
configuration.set_option("web", "cors_allow_methods", "GET POST")
setup_mock = mocker.patch("ahriman.web.cors.aiohttp_cors.setup", return_value=mocker.MagicMock())
setup_cors(application, configuration)
defaults = setup_mock.call_args.kwargs["defaults"]
resource_options = next(iter(defaults.values()))
assert resource_options.allow_methods == {"GET", "POST"}
@@ -1,254 +0,0 @@
import asyncio
import pytest
from aiohttp.test_utils import TestClient
from aiohttp.web import HTTPBadRequest
from asyncio import Queue
from multidict import MultiDict
from pytest_mock import MockerFixture
from unittest.mock import AsyncMock
from ahriman.core.status.watcher import Watcher
from ahriman.models.event import EventType
from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess
from ahriman.web.keys import WatcherKey
from ahriman.web.views.base import BaseView
from ahriman.web.views.v1.auditlog.event_bus import EventBusView
async def _producer(watcher: Watcher, package_ahriman: Package) -> None:
"""
create producer
Args:
watcher(Watcher): watcher test instance
package_ahriman(Package): package test instance
"""
await asyncio.sleep(0.1)
await watcher.event_bus.broadcast(EventType.PackageRemoved, package_ahriman.base)
await watcher.event_bus.broadcast(EventType.PackageUpdated, package_ahriman.base, status="success")
await asyncio.sleep(0.1)
await watcher.event_bus.shutdown()
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET",):
request = pytest.helpers.request("", "", method)
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:
"""
must return correct routes
"""
assert EventBusView.ROUTES == ["/api/v1/events/stream"]
async def test_run_timeout() -> None:
"""
must handle timeout and continue loop
"""
queue = Queue()
async def _shutdown() -> None:
await asyncio.sleep(0.05)
queue.shutdown()
response = AsyncMock()
response.is_connected = lambda: True
response.ping_interval = 0.01
asyncio.create_task(_shutdown())
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:
"""
must stream events via SSE
"""
watcher = next(iter(client.app[WatcherKey].values()))
asyncio.create_task(_producer(watcher, package_ahriman))
request_schema = pytest.helpers.schema_request(EventBusView.get, location="querystring")
# no content validation here because it is a streaming response
assert not request_schema.validate({})
response = await client.get("/api/v1/events/stream")
assert response.status == 200
body = await response.text()
assert EventType.PackageUpdated in body
assert "ahriman" in body
async def test_get_with_topic_filter(client: TestClient, package_ahriman: Package) -> None:
"""
must filter events by topic
"""
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 = {"event": [EventType.PackageUpdated]}
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 EventType.PackageUpdated 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:
"""
must return bad request for invalid event type
"""
response_schema = pytest.helpers.schema_response(EventBusView.get, code=400)
response = await client.get("/api/v1/events/stream", params={"event": "invalid"})
assert response.status == 400
assert not response_schema.validate(await response.json())
async def test_get_not_found(client: TestClient) -> None:
"""
must return not found for unknown repository
"""
response_schema = pytest.helpers.schema_response(EventBusView.get, code=404)
response = await client.get("/api/v1/events/stream", params={"architecture": "unknown", "repository": "unknown"})
assert response.status == 404
assert not response_schema.validate(await response.json())
async def test_get_connection_reset(client: TestClient, mocker: MockerFixture) -> None:
"""
must handle connection reset
"""
mocker.patch.object(EventBusView, "_run", side_effect=ConnectionResetError)
response = await client.get("/api/v1/events/stream")
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()
@@ -1,52 +0,0 @@
import pytest
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.packages.archives import Archives
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET",):
request = pytest.helpers.request("", "", method)
assert await Archives.get_permission(request) == UserAccess.Reporter
def test_routes() -> None:
"""
must return correct routes
"""
assert Archives.ROUTES == ["/api/v1/packages/{package}/archives"]
async def test_get(client: TestClient, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must get archives for package
"""
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
mocker.patch("ahriman.core.status.watcher.Watcher.package_archives", return_value=[package_ahriman])
response_schema = pytest.helpers.schema_response(Archives.get)
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/archives")
assert response.status == 200
archives = await response.json()
assert not response_schema.validate(archives)
async def test_get_not_found(client: TestClient, package_ahriman: Package) -> None:
"""
must return not found for missing package
"""
response_schema = pytest.helpers.schema_response(Archives.get, code=404)
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/archives")
assert response.status == 404
assert not response_schema.validate(await response.json())
@@ -1,60 +0,0 @@
import pytest
from aiohttp.test_utils import TestClient
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.packages.hold import HoldView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await HoldView.get_permission(request) == UserAccess.Full
def test_routes() -> None:
"""
must return correct routes
"""
assert HoldView.ROUTES == ["/api/v1/packages/{package}/hold"]
async def test_post(client: TestClient, package_ahriman: Package) -> None:
"""
must update package hold status
"""
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
request_schema = pytest.helpers.schema_request(HoldView.post)
payload = {"is_held": True}
assert not request_schema.validate(payload)
response = await client.post(f"/api/v1/packages/{package_ahriman.base}/hold", json=payload)
assert response.status == 204
async def test_post_not_found(client: TestClient, package_ahriman: Package) -> None:
"""
must return Not Found for unknown package
"""
response_schema = pytest.helpers.schema_response(HoldView.post, code=404)
response = await client.post(f"/api/v1/packages/{package_ahriman.base}/hold", json={"is_held": False})
assert response.status == 404
assert not response_schema.validate(await response.json())
async def test_post_exception(client: TestClient, package_ahriman: Package) -> None:
"""
must raise exception on invalid payload
"""
response_schema = pytest.helpers.schema_response(HoldView.post, code=400)
response = await client.post(f"/api/v1/packages/{package_ahriman.base}/hold", json=[])
assert response.status == 400
assert not response_schema.validate(await response.json())
@@ -1,70 +0,0 @@
import pytest
from aiohttp.test_utils import TestClient
from pytest_mock import MockerFixture
from unittest.mock import AsyncMock
from ahriman.models.repository_id import RepositoryId
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.service.rollback import RollbackView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await RollbackView.get_permission(request) == UserAccess.Full
def test_routes() -> None:
"""
must return correct routes
"""
assert RollbackView.ROUTES == ["/api/v1/service/rollback"]
async def test_post(client: TestClient, repository_id: RepositoryId, mocker: MockerFixture) -> None:
"""
must call post request correctly
"""
rollback_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_rollback", return_value="abc")
user_mock = AsyncMock()
user_mock.return_value = "username"
mocker.patch("ahriman.web.views.base.BaseView.username", side_effect=user_mock)
request_schema = pytest.helpers.schema_request(RollbackView.post)
response_schema = pytest.helpers.schema_response(RollbackView.post)
payload = {"package": "ahriman", "version": "version"}
assert not request_schema.validate(payload)
response = await client.post("/api/v1/service/rollback", json=payload)
assert response.ok
rollback_mock.assert_called_once_with(repository_id, "ahriman", "version", "username", hold=True)
json = await response.json()
assert json["process_id"] == "abc"
assert not response_schema.validate(json)
async def test_post_empty(client: TestClient, mocker: MockerFixture) -> None:
"""
must call raise 400 on empty request
"""
rollback_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_rollback")
response_schema = pytest.helpers.schema_response(RollbackView.post, code=400)
response = await client.post("/api/v1/service/rollback", json={"package": "", "version": "version"})
assert response.status == 400
assert not response_schema.validate(await response.json())
rollback_mock.assert_not_called()
response = await client.post("/api/v1/service/rollback", json={"package": "ahriman", "version": ""})
assert response.status == 400
assert not response_schema.validate(await response.json())
rollback_mock.assert_not_called()
response = await client.post("/api/v1/service/rollback", json={})
assert response.status == 400
assert not response_schema.validate(await response.json())
rollback_mock.assert_not_called()
-18
View File
@@ -1,18 +0,0 @@
import pytest
from ahriman.core.auth import Auth
from ahriman.core.configuration import Configuration
@pytest.fixture
def auth(configuration: Configuration) -> Auth:
"""
auth provider fixture
Args:
configuration(Configuration): configuration fixture
Returns:
Auth: auth service instance
"""
return Auth(configuration)
+13 -14
View File
@@ -1,15 +1,13 @@
# build image
FROM archlinux:base AS build
ARG BUILD_DATE
# install environment
## create build user
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
RUN echo "Server = https://archive.archlinux.org/repos/${BUILD_DATE//-/\/}/\$repo/os/\$arch" > "/etc/pacman.d/mirrorlist" && \
pacman -Syyuu --noconfirm
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" && \
pacman -Sy
## setup package cache
RUN runuser -u build -- mkdir "/tmp/pkg" && \
echo "PKGDEST=/tmp/pkg" >> "/etc/makepkg.conf" && \
@@ -31,16 +29,17 @@ RUN pacman -S --noconfirm --asdeps \
python-filelock \
python-inflection \
python-pyelftools \
python-requests
RUN pacman -S --noconfirm --asdeps \
python-requests \
&& \
pacman -S --noconfirm --asdeps \
base-devel \
python-build \
python-hatchling \
python-flit \
python-installer \
python-setuptools \
python-tox \
python-wheel
RUN pacman -S --noconfirm --asdeps \
python-wheel \
&& \
pacman -S --noconfirm --asdeps \
git \
python-aiohttp \
python-aiohttp-openmetrics \
@@ -49,8 +48,9 @@ RUN pacman -S --noconfirm --asdeps \
python-cryptography \
python-jinja \
python-systemd \
rsync
RUN runuser -u build -- install-aur-package \
rsync \
&& \
runuser -u build -- install-aur-package \
python-aioauth-client \
python-sphinx-typlog-theme \
python-webargs \
@@ -59,7 +59,6 @@ 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
@@ -110,7 +109,7 @@ RUN cp "/etc/pacman.d/mirrorlist" "/etc/pacman.d/mirrorlist.orig" && \
echo "Server = file:///var/cache/pacman/pkg" > "/etc/pacman.d/mirrorlist" && \
cp "/etc/pacman.conf" "/etc/pacman.conf.orig" && \
sed -i "s/SigLevel *=.*/SigLevel = Optional/g" "/etc/pacman.conf" && \
pacman -Syyuu --noconfirm
pacman -Sy
## install package and its optional dependencies
RUN pacman -S --noconfirm ahriman
RUN pacman -S --noconfirm --asdeps \
+1 -3
View File
@@ -7,10 +7,8 @@ for PACKAGE in "$@"; do
# clone the remote source
git clone https://aur.archlinux.org/"$PACKAGE".git "$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
git checkout "$(git rev-list -1 --before="$BUILD_DATE" master)"
git checkout "$(git rev-list -1 --before="$(stat -c "%y" "/var/lib/pacman" | cut -d " " -f 1)" master)"
# build and install the package
makepkg --nocheck --noconfirm --install --rmdeps --syncdeps
cd /
-32
View File
@@ -12,14 +12,6 @@ ahriman.application.handlers.add module
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.archives module
--------------------------------------------
.. automodule:: ahriman.application.handlers.archives
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.backup module
------------------------------------------
@@ -84,14 +76,6 @@ ahriman.application.handlers.help module
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.hold module
----------------------------------------
.. automodule:: ahriman.application.handlers.hold
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.key\_import module
-----------------------------------------------
@@ -108,14 +92,6 @@ ahriman.application.handlers.patch module
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.pkgbuild module
--------------------------------------------
.. automodule:: ahriman.application.handlers.pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.rebuild module
-------------------------------------------
@@ -164,14 +140,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
---------------------------------------
-8
View File
@@ -28,14 +28,6 @@ ahriman.core.alpm.pacman\_database module
:no-undoc-members:
:show-inheritance:
ahriman.core.alpm.pacman\_handle module
---------------------------------------
.. automodule:: ahriman.core.alpm.pacman_handle
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.alpm.pkgbuild\_parser module
-----------------------------------------
-16
View File
@@ -140,22 +140,6 @@ ahriman.core.database.migrations.m016\_archive module
:no-undoc-members:
:show-inheritance:
ahriman.core.database.migrations.m017\_pkgbuild module
------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m017_pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.database.migrations.m018\_package\_hold module
-----------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m018_package_hold
:members:
:no-undoc-members:
:show-inheritance:
Module contents
---------------
-8
View File
@@ -76,14 +76,6 @@ ahriman.core.formatters.patch\_printer module
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.pkgbuild\_printer module
------------------------------------------------
.. automodule:: ahriman.core.formatters.pkgbuild_printer
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.printer module
--------------------------------------
-8
View File
@@ -12,14 +12,6 @@ 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
----------------------------------------
-8
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
-------------------------------------------------
-40
View File
@@ -92,14 +92,6 @@ 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
----------------------------------------
@@ -124,14 +116,6 @@ ahriman.web.schemas.file\_schema module
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.hold\_schema module
---------------------------------------
.. automodule:: ahriman.web.schemas.hold_schema
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.schemas.info\_schema module
---------------------------------------
@@ -260,14 +244,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
---------------------------------------------
@@ -348,14 +324,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
-----------------------------------------
@@ -364,14 +332,6 @@ 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
-----------------------------------------
-8
View File
@@ -4,14 +4,6 @@ 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
-------------------------------------------
-16
View File
@@ -4,14 +4,6 @@ ahriman.web.views.v1.packages package
Submodules
----------
ahriman.web.views.v1.packages.archives module
---------------------------------------------
.. automodule:: ahriman.web.views.v1.packages.archives
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.packages.changes module
--------------------------------------------
@@ -28,14 +20,6 @@ ahriman.web.views.v1.packages.dependencies module
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.packages.hold module
-----------------------------------------
.. automodule:: ahriman.web.views.v1.packages.hold
:members:
:no-undoc-members:
:show-inheritance:
ahriman.web.views.v1.packages.logs module
-----------------------------------------
-8
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
------------------------------------------
-12
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
^^^^^^^^^^^^^^^^^^^^^^^
+1 -6
View File
@@ -180,15 +180,10 @@ 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.
* ``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.
@@ -196,7 +191,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. If set to ``0``, wait infinitely.
* ``wait_timeout`` - wait timeout in seconds, maximum amount of time to be waited before lock will be free, integer, optional.
``archive`` group
-----------------
-25
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
-3
View File
@@ -7,13 +7,10 @@ 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
+3 -10
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",
+31 -32
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.101.0 <5.102.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.1.0 <7.2.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.1.0 <8.2.0"
},
"name": "ahriman-frontend",
"private": true,
"type": "module",
"version": "2.20.1",
"scripts": {
"build": "tsc && vite build",
"dev": "vite",
@@ -38,6 +10,33 @@
"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": "^5.1.4",
"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": "^7.3.1",
"vite-tsconfig-paths": "^6.1.1"
}
}
+2 -4
View File
@@ -21,7 +21,6 @@ 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";
@@ -30,6 +29,7 @@ import type React from "react";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
@@ -42,9 +42,7 @@ export default function App(): React.JSX.Element {
<ClientProvider>
<AuthProvider>
<RepositoryProvider>
<EventStreamProvider>
<AppLayout />
</EventStreamProvider>
<AppLayout />
</RepositoryProvider>
</AuthProvider>
</ClientProvider>
+1 -2
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}`);
-7
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(),
+1 -1
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;
}
-17
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 } });
}
@@ -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,
},
],
@@ -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,
@@ -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 }} />;
@@ -0,0 +1,91 @@
/*
* 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 {
intervals: AutoRefreshInterval[];
currentInterval: number;
onIntervalChange: (interval: number) => void;
}
export default function AutoRefreshControl({
intervals,
currentInterval,
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
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}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
<MenuItem
selected={!enabled}
onClick={() => {
onIntervalChange(0);
setAnchorEl(null);
}}
>
<ListItemIcon>
{!enabled && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>Off</ListItemText>
</MenuItem>
{intervals.map(interval =>
<MenuItem
key={interval.interval}
selected={enabled && interval.interval === currentInterval}
onClick={() => {
onIntervalChange(interval.interval);
setAnchorEl(null);
}}
>
<ListItemIcon>
{enabled && interval.interval === currentInterval && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>{interval.text}</ListItemText>
</MenuItem>,
)}
</Menu>
</>;
}
+27 -34
View File
@@ -17,54 +17,47 @@
* 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 "components/common/syntaxLanguages";
import { Box, useTheme } from "@mui/material";
import { Box } from "@mui/material";
import CopyButton from "components/common/CopyButton";
import { useThemeMode } from "hooks/useThemeMode";
import React, { type RefObject } from "react";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { githubGist, vs2015 } from "react-syntax-highlighter/dist/esm/styles/hljs";
interface CodeBlockProps {
content: string;
height?: number | string;
language?: string;
onScroll?: () => void;
preRef?: RefObject<HTMLElement | null>;
getText: () => string;
height?: number | string;
onScroll?: () => void;
wordBreak?: boolean;
}
export default function CodeBlock({
content,
height,
language = "text",
onScroll,
preRef,
getText,
height,
onScroll,
wordBreak,
}: CodeBlockProps): React.JSX.Element {
const { mode } = useThemeMode();
const theme = useTheme();
return <Box sx={{ position: "relative" }}>
<Box
onScroll={onScroll}
ref={preRef}
sx={{ overflow: "auto", height }}
component="pre"
onScroll={onScroll}
sx={{
backgroundColor: "action.hover",
p: 2,
borderRadius: 1,
overflow: "auto",
height,
fontSize: "0.8rem",
fontFamily: "monospace",
...wordBreak ? { whiteSpace: "pre-wrap", wordBreak: "break-all" } : {},
}}
>
<SyntaxHighlighter
customStyle={{
borderRadius: `${theme.shape.borderRadius}px`,
fontSize: "0.8rem",
padding: theme.spacing(2),
}}
language={language}
style={mode === "dark" ? vs2015 : githubGist}
wrapLongLines
>
{content}
</SyntaxHighlighter>
<code>
{getText()}
</code>
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={getText} />
</Box>
{content && <Box sx={{ position: "absolute", right: 8, top: 8 }}>
<CopyButton text={content} />
</Box>}
</Box>;
}
@@ -23,24 +23,24 @@ import { IconButton, Tooltip } from "@mui/material";
import React, { useEffect, useRef, useState } from "react";
interface CopyButtonProps {
text: string;
getText: () => string;
}
export default function CopyButton({ text }: CopyButtonProps): React.JSX.Element {
export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const handleCopy: () => Promise<void> = async () => {
await navigator.clipboard.writeText(text);
await navigator.clipboard.writeText(getText());
setCopied(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 2000);
};
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>;
@@ -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 />
@@ -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>;
}
@@ -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}`}
@@ -25,14 +25,14 @@ import type React from "react";
export default function RepositorySelect({
repositorySelect,
}: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element {
const { repositories, currentRepository } = useRepository();
const { repositories, current } = useRepository();
return <FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel>
<Select
value={repositorySelect.selectedKey || (current?.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}>
@@ -1,27 +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 { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import bash from "react-syntax-highlighter/dist/esm/languages/hljs/bash";
import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff";
import plaintext from "react-syntax-highlighter/dist/esm/languages/hljs/plaintext";
SyntaxHighlighter.registerLanguage("bash", bash);
SyntaxHighlighter.registerLanguage("diff", diff);
SyntaxHighlighter.registerLanguage("text", plaintext);
@@ -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 { current } = useRepository();
const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
enabled: open,
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
});
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>
@@ -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 getText={() => 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>;
}
+12 -17
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>;
}
@@ -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}>
{option}{pkg ? ` (${pkg.description})` : ""}
</li>;
return (
<li {...props} key={option}>
{option}{pkg ? ` (${pkg.description})` : ""}
</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>;
}
@@ -21,39 +21,41 @@ 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";
import PackageDetailsGrid from "components/package/PackageDetailsGrid";
import PackageInfoActions from "components/package/PackageInfoActions";
import PackagePatchesList from "components/package/PackagePatchesList";
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 {
onClose: () => void;
open: boolean;
packageBase: string | null;
open: boolean;
onClose: () => void;
autoRefreshIntervals: AutoRefreshInterval[];
}
export default function PackageInfoDialog({
onClose,
open,
packageBase,
open,
onClose,
autoRefreshIntervals,
}: PackageInfoDialogProps): React.JSX.Element {
const client = useClient();
const { currentRepository } = useRepository();
const { current } = useRepository();
const { isAuthorized } = useAuth();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
@@ -63,59 +65,60 @@ export default function PackageInfoDialog({
setLocalPackageBase(packageBase);
}
const [activeTab, setActiveTab] = useState<TabKey>("logs");
const [tabIndex, setTabIndex] = useState(0);
const [refreshDatabase, setRefreshDatabase] = useState(true);
const handleClose = (): void => {
setActiveTab("logs");
setTabIndex(0);
setRefreshDatabase(true);
onClose();
};
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packageData } = useQuery<PackageStatus[]>({
queryKey: localPackageBase && current ? QueryKeys.package(localPackageBase, current) : ["packages"],
queryFn: localPackageBase && current ? () => client.fetch.fetchPackage(localPackageBase, current) : skipToken,
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<Dependencies>({
queryKey: localPackageBase && current ? QueryKeys.dependencies(localPackageBase, current) : ["dependencies"],
queryFn: localPackageBase && current
? () => client.fetch.fetchPackageDependencies(localPackageBase, current) : skipToken,
enabled: open,
queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken,
queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"],
});
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> => {
if (!localPackageBase || !currentRepository) {
const handleUpdate: () => Promise<void> = async () => {
if (!localPackageBase || !current) {
return;
}
try {
await client.service.servicePackageAdd(
currentRepository, { packages: [localPackageBase], refresh: refreshDatabase });
await client.service.servicePackageAdd(current, { packages: [localPackageBase], refresh: refreshDatabase });
showSuccess("Success", `Run update for packages ${localPackageBase}`);
} catch (exception) {
showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`);
}
};
const handleRemove = async (): Promise<void> => {
if (!localPackageBase || !currentRepository) {
const handleRemove: () => Promise<void> = async () => {
if (!localPackageBase || !current) {
return;
}
try {
await client.service.servicePackageRemove(currentRepository, [localPackageBase]);
await client.service.servicePackageRemove(current, [localPackageBase]);
showSuccess("Success", `Packages ${localPackageBase} have been removed`);
onClose();
} catch (exception) {
@@ -123,20 +126,7 @@ export default function PackageInfoDialog({
}
};
const handleHoldToggle = async (): Promise<void> => {
if (!localPackageBase || !currentRepository) {
return;
}
try {
const newHeldStatus = !(status?.is_held ?? false);
await client.service.servicePackageHold(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;
}
@@ -148,7 +138,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()}`
@@ -158,40 +148,33 @@ 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={tabIndex} onChange={(_, index: number) => setTabIndex(index)}>
<Tab label="Build logs" />
<Tab label="Changes" />
<Tab label="Events" />
</Tabs>
</Box>
{activeTab === "logs" && localPackageBase && currentRepository &&
{tabIndex === 0 && localPackageBase && current &&
<BuildLogsTab
packageBase={localPackageBase}
repository={currentRepository}
repository={current}
refreshInterval={autoRefresh.interval}
/>
}
{activeTab === "changes" && localPackageBase && currentRepository &&
<ChangesTab packageBase={localPackageBase} repository={currentRepository} />
{tabIndex === 1 && localPackageBase && current &&
<ChangesTab packageBase={localPackageBase} repository={current} />
}
{activeTab === "pkgbuild" && localPackageBase && currentRepository &&
<PkgbuildTab packageBase={localPackageBase} repository={currentRepository} />
}
{activeTab === "events" && localPackageBase && currentRepository &&
<EventsTab packageBase={localPackageBase} repository={currentRepository} />
}
{activeTab === "artifacts" && localPackageBase && currentRepository &&
<ArtifactsTab
currentVersion={pkg.version}
packageBase={localPackageBase}
repository={currentRepository}
/>
{tabIndex === 2 && localPackageBase && current &&
<EventsTab packageBase={localPackageBase} repository={current} />
}
</>
}
@@ -199,12 +182,13 @@ export default function PackageInfoDialog({
<PackageInfoActions
isAuthorized={isAuthorized}
isHeld={status?.is_held ?? false}
onHoldToggle={() => void handleHoldToggle()}
onRefreshDatabaseChange={setRefreshDatabase}
onRemove={() => void handleRemove()}
onUpdate={() => void handleUpdate()}
refreshDatabase={refreshDatabase}
onRefreshDatabaseChange={setRefreshDatabase}
onUpdate={() => void handleUpdate()}
onRemove={() => void handleRemove()}
autoRefreshIntervals={autoRefreshIntervals}
autoRefreshInterval={autoRefresh.interval}
onAutoRefreshIntervalChange={autoRefresh.setInterval}
/>
</Dialog>;
}
@@ -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>;
}
+8 -6
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 />
<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>;
}
+13 -13
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>
}
+6 -7
View File
@@ -22,28 +22,27 @@ import { useRepository } from "hooks/useRepository";
import type React from "react";
export default function Navbar(): React.JSX.Element | null {
const { repositories, currentRepository, setCurrentRepository } = useRepository();
const { repositories, current, setCurrent } = useRepository();
if (repositories.length === 0 || !currentRepository) {
if (repositories.length === 0 || !current) {
return null;
}
const currentIndex = repositories.findIndex(repository =>
repository.architecture === currentRepository.architecture &&
repository.repository === currentRepository.repository,
repository.architecture === current.architecture && repository.repository === current.repository,
);
return <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs
value={currentIndex >= 0 ? currentIndex : 0}
onChange={(_, newValue: number) => {
const repository = repositories[newValue];
if (repository) {
setCurrentRepository(repository);
setCurrent(repository);
}
}}
scrollButtons="auto"
value={currentIndex >= 0 ? currentIndex : 0}
variant="scrollable"
scrollButtons="auto"
>
{repositories.map(repository =>
<Tab
@@ -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>;
}

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