mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-07-13 14:21:08 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f4d2a50b1 |
@@ -26,10 +26,6 @@ jobs:
|
|||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Set image date
|
|
||||||
id: args
|
|
||||||
run: echo "::set-output name=date::$(date -d yesterday +'%Y-%m-%d')"
|
|
||||||
|
|
||||||
- name: Login to docker hub
|
- name: Login to docker hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -57,8 +53,6 @@ jobs:
|
|||||||
- name: Build an image and push
|
- name: Build an image and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
build-args: |
|
|
||||||
BUILD_DATE=${{ steps.args.outputs.date }}
|
|
||||||
file: docker/Dockerfile
|
file: docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ pacman -Syyu --noconfirm
|
|||||||
# main dependencies
|
# main dependencies
|
||||||
pacman -S --noconfirm devtools git npm pyalpm python-bcrypt python-filelock python-inflection python-pyelftools python-requests python-systemd sudo
|
pacman -S --noconfirm devtools git npm pyalpm python-bcrypt python-filelock python-inflection python-pyelftools python-requests python-systemd sudo
|
||||||
# make dependencies
|
# make dependencies
|
||||||
pacman -S --noconfirm --asdeps base-devel python-build python-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
|
# optional dependencies
|
||||||
if [[ -z $MINIMAL_INSTALL ]]; then
|
if [[ -z $MINIMAL_INSTALL ]]; then
|
||||||
# web server
|
# web server
|
||||||
pacman -S --noconfirm python-aioauth-client python-aiohttp python-aiohttp-apispec-git python-aiohttp-cors python-aiohttp-jinja2 python-aiohttp-security python-aiohttp-session python-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
|
# additional features
|
||||||
pacman -S --noconfirm gnupg ipython python-boto3 python-cerberus python-matplotlib rsync
|
pacman -S --noconfirm gnupg ipython python-boto3 python-cerberus python-matplotlib rsync
|
||||||
fi
|
fi
|
||||||
|
|||||||
+2
-5
@@ -103,8 +103,5 @@ docs/html/
|
|||||||
# Frontend
|
# Frontend
|
||||||
node_modules/
|
node_modules/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
ahriman-web/package/share/ahriman/templates/static/index.js
|
package/share/ahriman/templates/static/index.js
|
||||||
ahriman-web/package/share/ahriman/templates/static/index.css
|
package/share/ahriman/templates/static/index.css
|
||||||
|
|
||||||
# local configs
|
|
||||||
/*.ini
|
|
||||||
|
|||||||
@@ -2,6 +2,4 @@
|
|||||||
addopts = --cov=ahriman --cov-report=term-missing:skip-covered --no-cov-on-fail --cov-fail-under=100 --spec
|
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_default_fixture_loop_scope = function
|
||||||
asyncio_mode = auto
|
asyncio_mode = auto
|
||||||
pythonpath = tests
|
|
||||||
resource-path.directory-name-test-resources = ../../tests/testresources
|
|
||||||
spec_test_format = {result} {docstring_summary}
|
spec_test_format = {result} {docstring_summary}
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
build:
|
build:
|
||||||
os: ubuntu-lts-latest
|
os: ubuntu-20.04
|
||||||
tools:
|
tools:
|
||||||
python: "3.13"
|
python: "3.12"
|
||||||
apt_packages:
|
apt_packages:
|
||||||
- graphviz
|
- graphviz
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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,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,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)
|
|
||||||
@@ -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=[]))
|
|
||||||
@@ -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"
|
|
||||||
@@ -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 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,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,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,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,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()
|
|
||||||
@@ -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
@@ -1,15 +1,13 @@
|
|||||||
# build image
|
# build image
|
||||||
FROM archlinux:base AS build
|
FROM archlinux:base AS build
|
||||||
|
|
||||||
ARG BUILD_DATE
|
|
||||||
|
|
||||||
# install environment
|
# install environment
|
||||||
## create build user
|
## create build user
|
||||||
RUN useradd -m -d "/home/build" -s "/usr/bin/nologin" build
|
RUN useradd -m -d "/home/build" -s "/usr/bin/nologin" build
|
||||||
|
|
||||||
## extract container creation date and set mirror for this timestamp, set PKGEXT and refresh database next
|
## extract container creation date and set mirror for this timestamp, set PKGEXT and refresh database next
|
||||||
RUN echo "Server = https://archive.archlinux.org/repos/${BUILD_DATE//-/\/}/\$repo/os/\$arch" > "/etc/pacman.d/mirrorlist" && \
|
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 -Syyuu --noconfirm
|
pacman -Sy
|
||||||
## setup package cache
|
## setup package cache
|
||||||
RUN runuser -u build -- mkdir "/tmp/pkg" && \
|
RUN runuser -u build -- mkdir "/tmp/pkg" && \
|
||||||
echo "PKGDEST=/tmp/pkg" >> "/etc/makepkg.conf" && \
|
echo "PKGDEST=/tmp/pkg" >> "/etc/makepkg.conf" && \
|
||||||
@@ -31,16 +29,17 @@ RUN pacman -S --noconfirm --asdeps \
|
|||||||
python-filelock \
|
python-filelock \
|
||||||
python-inflection \
|
python-inflection \
|
||||||
python-pyelftools \
|
python-pyelftools \
|
||||||
python-requests
|
python-requests \
|
||||||
RUN pacman -S --noconfirm --asdeps \
|
&& \
|
||||||
|
pacman -S --noconfirm --asdeps \
|
||||||
base-devel \
|
base-devel \
|
||||||
python-build \
|
python-build \
|
||||||
python-hatchling \
|
python-flit \
|
||||||
python-installer \
|
python-installer \
|
||||||
python-setuptools \
|
|
||||||
python-tox \
|
python-tox \
|
||||||
python-wheel
|
python-wheel \
|
||||||
RUN pacman -S --noconfirm --asdeps \
|
&& \
|
||||||
|
pacman -S --noconfirm --asdeps \
|
||||||
git \
|
git \
|
||||||
python-aiohttp \
|
python-aiohttp \
|
||||||
python-aiohttp-openmetrics \
|
python-aiohttp-openmetrics \
|
||||||
@@ -49,8 +48,9 @@ RUN pacman -S --noconfirm --asdeps \
|
|||||||
python-cryptography \
|
python-cryptography \
|
||||||
python-jinja \
|
python-jinja \
|
||||||
python-systemd \
|
python-systemd \
|
||||||
rsync
|
rsync \
|
||||||
RUN runuser -u build -- install-aur-package \
|
&& \
|
||||||
|
runuser -u build -- install-aur-package \
|
||||||
python-aioauth-client \
|
python-aioauth-client \
|
||||||
python-sphinx-typlog-theme \
|
python-sphinx-typlog-theme \
|
||||||
python-webargs \
|
python-webargs \
|
||||||
@@ -59,7 +59,6 @@ RUN runuser -u build -- install-aur-package \
|
|||||||
python-aiohttp-jinja2 \
|
python-aiohttp-jinja2 \
|
||||||
python-aiohttp-session \
|
python-aiohttp-session \
|
||||||
python-aiohttp-security \
|
python-aiohttp-security \
|
||||||
python-aiohttp-sse-git \
|
|
||||||
python-requests-unixsocket2
|
python-requests-unixsocket2
|
||||||
|
|
||||||
# install ahriman
|
# 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" && \
|
echo "Server = file:///var/cache/pacman/pkg" > "/etc/pacman.d/mirrorlist" && \
|
||||||
cp "/etc/pacman.conf" "/etc/pacman.conf.orig" && \
|
cp "/etc/pacman.conf" "/etc/pacman.conf.orig" && \
|
||||||
sed -i "s/SigLevel *=.*/SigLevel = Optional/g" "/etc/pacman.conf" && \
|
sed -i "s/SigLevel *=.*/SigLevel = Optional/g" "/etc/pacman.conf" && \
|
||||||
pacman -Syyuu --noconfirm
|
pacman -Sy
|
||||||
## install package and its optional dependencies
|
## install package and its optional dependencies
|
||||||
RUN pacman -S --noconfirm ahriman
|
RUN pacman -S --noconfirm ahriman
|
||||||
RUN pacman -S --noconfirm --asdeps \
|
RUN pacman -S --noconfirm --asdeps \
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ for PACKAGE in "$@"; do
|
|||||||
# clone the remote source
|
# clone the remote source
|
||||||
git clone https://aur.archlinux.org/"$PACKAGE".git "$BUILD_DIR"
|
git clone https://aur.archlinux.org/"$PACKAGE".git "$BUILD_DIR"
|
||||||
cd "$BUILD_DIR"
|
cd "$BUILD_DIR"
|
||||||
# FIXME monkey patch PKGBUILD for python
|
|
||||||
sed -i 's/python -m build/python -m build --skip-dependency-check/g' "PKGBUILD"
|
|
||||||
# checkout to the image date
|
# checkout to the image date
|
||||||
git checkout "$(git rev-list -1 --before="$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
|
# build and install the package
|
||||||
makepkg --nocheck --noconfirm --install --rmdeps --syncdeps
|
makepkg --nocheck --noconfirm --install --rmdeps --syncdeps
|
||||||
cd /
|
cd /
|
||||||
|
|||||||
@@ -164,14 +164,6 @@ ahriman.application.handlers.restore module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
ahriman.application.handlers.rollback module
|
|
||||||
--------------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: ahriman.application.handlers.rollback
|
|
||||||
:members:
|
|
||||||
:no-undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
ahriman.application.handlers.run module
|
ahriman.application.handlers.run module
|
||||||
---------------------------------------
|
---------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,6 @@ ahriman.core.status.client module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
: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
|
ahriman.core.status.local\_client module
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,6 @@ ahriman.web.middlewares.auth\_handler module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
: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
|
ahriman.web.middlewares.exception\_handler module
|
||||||
-------------------------------------------------
|
-------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -92,14 +92,6 @@ ahriman.web.schemas.error\_schema module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
: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
|
ahriman.web.schemas.event\_schema module
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
|
|
||||||
@@ -260,14 +252,6 @@ ahriman.web.schemas.package\_version\_schema module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
: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
|
ahriman.web.schemas.pagination\_schema module
|
||||||
---------------------------------------------
|
---------------------------------------------
|
||||||
|
|
||||||
@@ -348,14 +332,6 @@ ahriman.web.schemas.repository\_stats\_schema module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
: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
|
ahriman.web.schemas.search\_schema module
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
|
|
||||||
@@ -364,14 +340,6 @@ ahriman.web.schemas.search\_schema module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
: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
|
ahriman.web.schemas.status\_schema module
|
||||||
-----------------------------------------
|
-----------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,6 @@ ahriman.web.views.v1.auditlog package
|
|||||||
Submodules
|
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
|
ahriman.web.views.v1.auditlog.events module
|
||||||
-------------------------------------------
|
-------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -68,14 +68,6 @@ ahriman.web.views.v1.service.request module
|
|||||||
:no-undoc-members:
|
:no-undoc-members:
|
||||||
:show-inheritance:
|
: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
|
ahriman.web.views.v1.service.search module
|
||||||
------------------------------------------
|
------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -235,18 +235,6 @@ Remove packages
|
|||||||
|
|
||||||
This flow removes package from filesystem, updates repository database and also runs synchronization and reporting methods.
|
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
|
Check outdated packages
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* ``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.
|
* ``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``.
|
* ``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.
|
* ``host`` - host to bind, string, optional.
|
||||||
* ``index_url`` - full URL of the repository index page, 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_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.
|
* ``port`` - port to bind, integer, optional.
|
||||||
* ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``.
|
* ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``.
|
||||||
* ``static_path`` - path to directory with static files, string, required.
|
* ``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.
|
* ``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`` - 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.
|
* ``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
|
``archive`` group
|
||||||
-----------------
|
-----------------
|
||||||
|
|||||||
@@ -33,28 +33,3 @@ The service provides several commands aim to do easy repository backup and resto
|
|||||||
.. code-block:: shell
|
.. code-block:: shell
|
||||||
|
|
||||||
sudo -u ahriman ahriman repo-rebuild --from-database
|
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
|
|
||||||
|
|||||||
@@ -7,13 +7,10 @@ aiohttp==3.11.18
|
|||||||
# ahriman (pyproject.toml)
|
# ahriman (pyproject.toml)
|
||||||
# aiohttp-cors
|
# aiohttp-cors
|
||||||
# aiohttp-jinja2
|
# aiohttp-jinja2
|
||||||
# aiohttp-sse
|
|
||||||
aiohttp-cors==0.8.1
|
aiohttp-cors==0.8.1
|
||||||
# via ahriman (pyproject.toml)
|
# via ahriman (pyproject.toml)
|
||||||
aiohttp-jinja2==1.6
|
aiohttp-jinja2==1.6
|
||||||
# via ahriman (pyproject.toml)
|
# via ahriman (pyproject.toml)
|
||||||
aiohttp-sse==2.2.0
|
|
||||||
# via ahriman (pyproject.toml)
|
|
||||||
aiosignal==1.3.2
|
aiosignal==1.3.2
|
||||||
# via aiohttp
|
# via aiohttp
|
||||||
alabaster==1.0.0
|
alabaster==1.0.0
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
import stylistic from "@stylistic/eslint-plugin";
|
import stylistic from "@stylistic/eslint-plugin";
|
||||||
import react from "eslint-plugin-react";
|
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from "eslint-plugin-react-refresh";
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
import simpleImportSort from "eslint-plugin-simple-import-sort";
|
||||||
@@ -9,11 +8,7 @@ import tseslint from "typescript-eslint";
|
|||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist"] },
|
{ ignores: ["dist"] },
|
||||||
{
|
{
|
||||||
extends: [
|
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
|
||||||
js.configs.recommended,
|
|
||||||
react.configs.flat.recommended,
|
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
|
||||||
],
|
|
||||||
files: ["src/**/*.{ts,tsx}"],
|
files: ["src/**/*.{ts,tsx}"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
@@ -22,14 +17,13 @@ export default tseslint.config(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
"@stylistic": stylistic,
|
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
"react-refresh": reactRefresh,
|
"react-refresh": reactRefresh,
|
||||||
"simple-import-sort": simpleImportSort,
|
"simple-import-sort": simpleImportSort,
|
||||||
|
"@stylistic": stylistic,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
"react/react-in-jsx-scope": "off",
|
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
|
||||||
// imports
|
// imports
|
||||||
@@ -39,7 +33,7 @@ export default tseslint.config(
|
|||||||
// core
|
// core
|
||||||
"curly": "error",
|
"curly": "error",
|
||||||
"eqeqeq": "error",
|
"eqeqeq": "error",
|
||||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
"no-console": "error",
|
||||||
"no-eval": "error",
|
"no-eval": "error",
|
||||||
|
|
||||||
// stylistic
|
// stylistic
|
||||||
@@ -74,7 +68,6 @@ export default tseslint.config(
|
|||||||
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
|
||||||
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
|
"@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true }],
|
||||||
"@typescript-eslint/no-deprecated": "error",
|
"@typescript-eslint/no-deprecated": "error",
|
||||||
"@typescript-eslint/no-floating-promises": "error",
|
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||||
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
||||||
"@typescript-eslint/prefer-optional-chain": "error",
|
"@typescript-eslint/prefer-optional-chain": "error",
|
||||||
|
|||||||
+30
-32
@@ -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",
|
"name": "ahriman-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"version": "2.20.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -38,6 +10,32 @@
|
|||||||
"lint:fix": "eslint --fix src/",
|
"lint:fix": "eslint --fix src/",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"dependencies": {
|
||||||
"version": "2.20.0"
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@mui/icons-material": "^7.3.9",
|
||||||
|
"@mui/material": "^7.3.9",
|
||||||
|
"@mui/x-data-grid": "^8.27.4",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-chartjs-2": "^5.3.1",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-syntax-highlighter": "^16.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.3",
|
||||||
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.3",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.56.1",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import AppLayout from "components/layout/AppLayout";
|
import AppLayout from "components/layout/AppLayout";
|
||||||
import { AuthProvider } from "contexts/AuthProvider";
|
import { AuthProvider } from "contexts/AuthProvider";
|
||||||
import { ClientProvider } from "contexts/ClientProvider";
|
import { ClientProvider } from "contexts/ClientProvider";
|
||||||
import { EventStreamProvider } from "contexts/EventStreamProvider";
|
|
||||||
import { NotificationProvider } from "contexts/NotificationProvider";
|
import { NotificationProvider } from "contexts/NotificationProvider";
|
||||||
import { RepositoryProvider } from "contexts/RepositoryProvider";
|
import { RepositoryProvider } from "contexts/RepositoryProvider";
|
||||||
import { ThemeProvider } from "contexts/ThemeProvider";
|
import { ThemeProvider } from "contexts/ThemeProvider";
|
||||||
@@ -30,6 +29,7 @@ import type React from "react";
|
|||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -42,9 +42,7 @@ export default function App(): React.JSX.Element {
|
|||||||
<ClientProvider>
|
<ClientProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<RepositoryProvider>
|
<RepositoryProvider>
|
||||||
<EventStreamProvider>
|
<AppLayout />
|
||||||
<AppLayout />
|
|
||||||
</EventStreamProvider>
|
|
||||||
</RepositoryProvider>
|
</RepositoryProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
|
|||||||
@@ -18,10 +18,9 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
|
||||||
body: string;
|
|
||||||
status: number;
|
status: number;
|
||||||
statusText: string;
|
statusText: string;
|
||||||
|
body: string;
|
||||||
|
|
||||||
constructor(status: number, statusText: string, body: string) {
|
constructor(status: number, statusText: string, body: string) {
|
||||||
super(`${status} ${statusText}`);
|
super(`${status} ${statusText}`);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import type { Event } from "models/Event";
|
|||||||
import type { InfoResponse } from "models/InfoResponse";
|
import type { InfoResponse } from "models/InfoResponse";
|
||||||
import type { InternalStatus } from "models/InternalStatus";
|
import type { InternalStatus } from "models/InternalStatus";
|
||||||
import type { LogRecord } from "models/LogRecord";
|
import type { LogRecord } from "models/LogRecord";
|
||||||
import type { Package } from "models/Package";
|
|
||||||
import type { PackageStatus } from "models/PackageStatus";
|
import type { PackageStatus } from "models/PackageStatus";
|
||||||
import type { Patch } from "models/Patch";
|
import type { Patch } from "models/Patch";
|
||||||
import { RepositoryId } from "models/RepositoryId";
|
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> {
|
async fetchPackageChanges(packageBase: string, repository: RepositoryId): Promise<Changes> {
|
||||||
return this.client.request<Changes>(`/api/v1/packages/${encodeURIComponent(packageBase)}/changes`, {
|
return this.client.request<Changes>(`/api/v1/packages/${encodeURIComponent(packageBase)}/changes`, {
|
||||||
query: repository.toQuery(),
|
query: repository.toQuery(),
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
export interface RequestOptions {
|
export interface RequestOptions {
|
||||||
json?: unknown;
|
|
||||||
method?: string;
|
method?: string;
|
||||||
query?: Record<string, string | number | boolean>;
|
query?: Record<string, string | number | boolean>;
|
||||||
|
json?: unknown;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import type { PackageActionRequest } from "models/PackageActionRequest";
|
|||||||
import type { PGPKey } from "models/PGPKey";
|
import type { PGPKey } from "models/PGPKey";
|
||||||
import type { PGPKeyRequest } from "models/PGPKeyRequest";
|
import type { PGPKeyRequest } from "models/PGPKeyRequest";
|
||||||
import type { RepositoryId } from "models/RepositoryId";
|
import type { RepositoryId } from "models/RepositoryId";
|
||||||
import type { RollbackRequest } from "models/RollbackRequest";
|
|
||||||
|
|
||||||
export class ServiceClient {
|
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 });
|
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> {
|
async servicePackagePatchRemove(packageBase: string, key: string): Promise<void> {
|
||||||
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, {
|
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/patches/${encodeURIComponent(key)}`, {
|
||||||
method: "DELETE",
|
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[]> {
|
async servicePackageSearch(query: string): Promise<AURPackage[]> {
|
||||||
return this.client.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
|
return this.client.request<AURPackage[]>("/api/v1/service/search", { query: { for: query } });
|
||||||
}
|
}
|
||||||
@@ -95,6 +78,14 @@ export class ServiceClient {
|
|||||||
return this.client.request("/api/v1/service/pgp", { method: "POST", json: data });
|
return this.client.request("/api/v1/service/pgp", { method: "POST", json: data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async servicePackageHoldUpdate(packageBase: string, repository: RepositoryId, isHeld: boolean): Promise<void> {
|
||||||
|
return this.client.request(`/api/v1/packages/${encodeURIComponent(packageBase)}/hold`, {
|
||||||
|
method: "POST",
|
||||||
|
query: repository.toQuery(),
|
||||||
|
json: { is_held: isHeld },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async serviceRebuild(repository: RepositoryId, packages: string[]): Promise<void> {
|
async serviceRebuild(repository: RepositoryId, packages: string[]): Promise<void> {
|
||||||
return this.client.request("/api/v1/service/rebuild", {
|
return this.client.request("/api/v1/service/rebuild", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ export default function EventDurationLineChart({ events }: EventDurationLineChar
|
|||||||
labels: updateEvents.map(event => new Date(event.created * 1000).toISOStringShort()),
|
labels: updateEvents.map(event => new Date(event.created * 1000).toISOStringShort()),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
backgroundColor: blue[200],
|
|
||||||
borderColor: blue[500],
|
|
||||||
cubicInterpolationMode: "monotone" as const,
|
|
||||||
data: updateEvents.map(event => event.data?.took ?? 0),
|
|
||||||
label: "update duration, s",
|
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,
|
tension: 0.4,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,19 +29,19 @@ interface PackageCountBarChartProps {
|
|||||||
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
|
export default function PackageCountBarChart({ stats }: PackageCountBarChartProps): React.JSX.Element {
|
||||||
return <Bar
|
return <Bar
|
||||||
data={{
|
data={{
|
||||||
|
labels: ["packages"],
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
backgroundColor: indigo[300],
|
|
||||||
data: [stats.bases ?? 0],
|
|
||||||
label: "bases",
|
label: "bases",
|
||||||
|
data: [stats.bases ?? 0],
|
||||||
|
backgroundColor: indigo[300],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
backgroundColor: blue[500],
|
|
||||||
data: [stats.packages ?? 0],
|
|
||||||
label: "archives",
|
label: "archives",
|
||||||
|
data: [stats.packages ?? 0],
|
||||||
|
backgroundColor: blue[500],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
labels: ["packages"],
|
|
||||||
}}
|
}}
|
||||||
options={{
|
options={{
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ interface StatusPieChartProps {
|
|||||||
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
|
export default function StatusPieChart({ counters }: StatusPieChartProps): React.JSX.Element {
|
||||||
const labels = ["unknown", "pending", "building", "failed", "success"] as BuildStatus[];
|
const labels = ["unknown", "pending", "building", "failed", "success"] as BuildStatus[];
|
||||||
const data = {
|
const data = {
|
||||||
|
labels: labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
backgroundColor: labels.map(label => StatusColors[label]),
|
|
||||||
data: labels.map(label => counters[label]),
|
|
||||||
label: "packages in status",
|
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 }} />;
|
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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -46,24 +46,27 @@ export default function CodeBlock({
|
|||||||
|
|
||||||
return <Box sx={{ position: "relative" }}>
|
return <Box sx={{ position: "relative" }}>
|
||||||
<Box
|
<Box
|
||||||
onScroll={onScroll}
|
|
||||||
ref={preRef}
|
ref={preRef}
|
||||||
|
onScroll={onScroll}
|
||||||
sx={{ overflow: "auto", height }}
|
sx={{ overflow: "auto", height }}
|
||||||
>
|
>
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
customStyle={{
|
|
||||||
borderRadius: `${theme.shape.borderRadius}px`,
|
|
||||||
fontSize: "0.8rem",
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
}}
|
|
||||||
language={language}
|
language={language}
|
||||||
style={mode === "dark" ? vs2015 : githubGist}
|
style={mode === "dark" ? vs2015 : githubGist}
|
||||||
wrapLongLines
|
wrapLongLines
|
||||||
|
customStyle={{
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
borderRadius: `${theme.shape.borderRadius}px`,
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
margin: 0,
|
||||||
|
minHeight: "100%",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
</Box>
|
</Box>
|
||||||
{content && <Box sx={{ position: "absolute", right: 8, top: 8 }}>
|
{content && <Box sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||||
<CopyButton text={content} />
|
<CopyButton text={content} />
|
||||||
</Box>}
|
</Box>}
|
||||||
</Box>;
|
</Box>;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function CopyButton({ text }: CopyButtonProps): React.JSX.Element
|
|||||||
};
|
};
|
||||||
|
|
||||||
return <Tooltip title={copied ? "Copied!" : "Copy"}>
|
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" />}
|
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>;
|
</Tooltip>;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface DialogHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DialogHeader({ children, onClose, sx }: DialogHeaderProps): React.JSX.Element {
|
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}
|
{children}
|
||||||
<IconButton aria-label="Close" onClick={onClose} size="small" sx={{ color: "inherit" }}>
|
<IconButton aria-label="Close" onClick={onClose} size="small" sx={{ color: "inherit" }}>
|
||||||
<CloseIcon />
|
<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 (
|
return (
|
||||||
<Slide direction="down" in={show} mountOnEnter onExited={() => onClose(notification.id)} unmountOnExit>
|
<Slide direction="down" in={show} mountOnEnter unmountOnExit onExited={() => onClose(notification.id)}>
|
||||||
<Alert
|
<Alert
|
||||||
onClose={() => setShow(false)}
|
onClose={() => setShow(false)}
|
||||||
severity={notification.severity}
|
severity={notification.severity}
|
||||||
sx={{ width: "100%", pointerEvents: "auto" }}
|
|
||||||
variant="filled"
|
variant="filled"
|
||||||
|
sx={{ width: "100%", pointerEvents: "auto" }}
|
||||||
>
|
>
|
||||||
<strong>{notification.title}</strong>
|
<strong>{notification.title}</strong>
|
||||||
{notification.message && ` - ${notification.message}`}
|
{notification.message && ` - ${notification.message}`}
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export default function RepositorySelect({
|
|||||||
return <FormControl fullWidth margin="normal">
|
return <FormControl fullWidth margin="normal">
|
||||||
<InputLabel>repository</InputLabel>
|
<InputLabel>repository</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
|
value={repositorySelect.selectedKey || (currentRepository?.key ?? "")}
|
||||||
label="repository"
|
label="repository"
|
||||||
onChange={event => repositorySelect.setSelectedKey(event.target.value)}
|
onChange={event => repositorySelect.setSelectedKey(event.target.value)}
|
||||||
value={repositorySelect.selectedKey || (currentRepository?.key ?? "")}
|
|
||||||
>
|
>
|
||||||
{repositories.map(repository =>
|
{repositories.map(repository =>
|
||||||
<MenuItem key={repository.key} value={repository.key}>
|
<MenuItem key={repository.key} value={repository.key}>
|
||||||
|
|||||||
@@ -30,23 +30,23 @@ import type React from "react";
|
|||||||
import { StatusHeaderStyles } from "theme/StatusColors";
|
import { StatusHeaderStyles } from "theme/StatusColors";
|
||||||
|
|
||||||
interface DashboardDialogProps {
|
interface DashboardDialogProps {
|
||||||
onClose: () => void;
|
|
||||||
open: boolean;
|
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 client = useClient();
|
||||||
const { currentRepository } = useRepository();
|
const { currentRepository } = useRepository();
|
||||||
|
|
||||||
const { data: status } = useQuery<InternalStatus>({
|
const { data: status } = useQuery<InternalStatus>({
|
||||||
enabled: open,
|
|
||||||
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
|
|
||||||
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
|
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
|
||||||
|
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
|
||||||
|
enabled: open,
|
||||||
});
|
});
|
||||||
|
|
||||||
const headerStyle = status ? StatusHeaderStyles[status.status.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}>
|
<DialogHeader onClose={onClose} sx={headerStyle}>
|
||||||
System health
|
System health
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -55,43 +55,43 @@ export default function DashboardDialog({ onClose, open }: DashboardDialogProps)
|
|||||||
{status &&
|
{status &&
|
||||||
<>
|
<>
|
||||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
<Grid size={{ md: 3, xs: 6 }}>
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<Typography align="right" color="text.secondary" variant="body2">Repository name</Typography>
|
<Typography variant="body2" color="text.secondary" align="right">Repository name</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ md: 3, xs: 6 }}>
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<Typography variant="body2">{status.repository}</Typography>
|
<Typography variant="body2">{status.repository}</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ md: 3, xs: 6 }}>
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<Typography align="right" color="text.secondary" variant="body2">Repository architecture</Typography>
|
<Typography variant="body2" color="text.secondary" align="right">Repository architecture</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ md: 3, xs: 6 }}>
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<Typography variant="body2">{status.architecture}</Typography>
|
<Typography variant="body2">{status.architecture}</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||||
<Grid size={{ md: 3, xs: 6 }}>
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<Typography align="right" color="text.secondary" variant="body2">Current status</Typography>
|
<Typography variant="body2" color="text.secondary" align="right">Current status</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ md: 3, xs: 6 }}>
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<Typography variant="body2">{status.status.status}</Typography>
|
<Typography variant="body2">{status.status.status}</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ md: 3, xs: 6 }}>
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<Typography align="right" color="text.secondary" variant="body2">Updated at</Typography>
|
<Typography variant="body2" color="text.secondary" align="right">Updated at</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ md: 3, xs: 6 }}>
|
<Grid size={{ xs: 6, md: 3 }}>
|
||||||
<Typography variant="body2">{new Date(status.status.timestamp * 1000).toISOStringShort()}</Typography>
|
<Typography variant="body2">{new Date(status.status.timestamp * 1000).toISOStringShort()}</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||||
<Grid size={{ md: 6, xs: 12 }}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<Box sx={{ height: 300 }}>
|
<Box sx={{ height: 300 }}>
|
||||||
<PackageCountBarChart stats={status.stats} />
|
<PackageCountBarChart stats={status.stats} />
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ md: 6, xs: 12 }}>
|
<Grid size={{ xs: 12, md: 6 }}>
|
||||||
<Box sx={{ alignItems: "center", display: "flex", height: 300, justifyContent: "center" }}>
|
<Box sx={{ height: 300, display: "flex", justifyContent: "center", alignItems: "center" }}>
|
||||||
<StatusPieChart counters={status.packages} />
|
<StatusPieChart counters={status.packages} />
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ import { useNotification } from "hooks/useNotification";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
interface KeyImportDialogProps {
|
interface KeyImportDialogProps {
|
||||||
onClose: () => void;
|
|
||||||
open: boolean;
|
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 client = useClient();
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export default function KeyImportDialog({ onClose, open }: KeyImportDialogProps)
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFetch = async (): Promise<void> => {
|
const handleFetch: () => Promise<void> = async () => {
|
||||||
if (!fingerprint || !server) {
|
if (!fingerprint || !server) {
|
||||||
return;
|
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) {
|
if (!fingerprint || !server) {
|
||||||
return;
|
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}>
|
<DialogHeader onClose={handleClose}>
|
||||||
Import key from PGP server
|
Import key from PGP server
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
|
||||||
label="fingerprint"
|
label="fingerprint"
|
||||||
margin="normal"
|
|
||||||
onChange={event => setFingerprint(event.target.value)}
|
|
||||||
placeholder="PGP key fingerprint"
|
placeholder="PGP key fingerprint"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
value={fingerprint}
|
value={fingerprint}
|
||||||
|
onChange={event => setFingerprint(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
|
||||||
label="key server"
|
label="key server"
|
||||||
margin="normal"
|
|
||||||
onChange={event => setServer(event.target.value)}
|
|
||||||
placeholder="PGP key server"
|
placeholder="PGP key server"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
value={server}
|
value={server}
|
||||||
|
onChange={event => setServer(event.target.value)}
|
||||||
/>
|
/>
|
||||||
{keyBody &&
|
{keyBody &&
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<CodeBlock height={300} content={keyBody} />
|
<CodeBlock content={keyBody} height={300} />
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => void handleImport()} startIcon={<PlayArrowIcon />} variant="contained">import</Button>
|
<Button onClick={() => void handleImport()} variant="contained" startIcon={<PlayArrowIcon />}>import</Button>
|
||||||
<Button color="success" onClick={() => void handleFetch()} startIcon={<RefreshIcon />} variant="contained">fetch</Button>
|
<Button onClick={() => void handleFetch()} variant="contained" color="success" startIcon={<RefreshIcon />}>fetch</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ import { useNotification } from "hooks/useNotification";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
interface LoginDialogProps {
|
interface LoginDialogProps {
|
||||||
onClose: () => void;
|
|
||||||
open: boolean;
|
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 [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@@ -54,7 +54,7 @@ export default function LoginDialog({ onClose, open }: LoginDialogProps): React.
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (): Promise<void> => {
|
const handleSubmit: () => Promise<void> = async () => {
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
return;
|
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}>
|
<DialogHeader onClose={handleClose}>
|
||||||
Login
|
Login
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField
|
||||||
autoFocus
|
|
||||||
fullWidth
|
|
||||||
label="username"
|
label="username"
|
||||||
|
fullWidth
|
||||||
margin="normal"
|
margin="normal"
|
||||||
onChange={event => setUsername(event.target.value)}
|
|
||||||
value={username}
|
value={username}
|
||||||
|
onChange={event => setUsername(event.target.value)}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
|
||||||
label="password"
|
label="password"
|
||||||
|
fullWidth
|
||||||
margin="normal"
|
margin="normal"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
onChange={event => setPassword(event.target.value)}
|
onChange={event => setPassword(event.target.value)}
|
||||||
onKeyDown={event => {
|
onKeyDown={event => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
@@ -100,24 +102,17 @@ export default function LoginDialog({ onClose, open }: LoginDialogProps): React.
|
|||||||
input: {
|
input: {
|
||||||
endAdornment:
|
endAdornment:
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<IconButton
|
<IconButton aria-label={showPassword ? "Hide password" : "Show password"} onClick={() => setShowPassword(!showPassword)} edge="end" size="small">
|
||||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
|
||||||
edge="end"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
{showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</InputAdornment>,
|
</InputAdornment>,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
value={password}
|
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => void handleSubmit()} startIcon={<PersonIcon />} variant="contained">login</Button>
|
<Button onClick={() => void handleSubmit()} variant="contained" startIcon={<PersonIcon />}>login</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ interface EnvironmentVariable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PackageAddDialogProps {
|
interface PackageAddDialogProps {
|
||||||
onClose: () => void;
|
|
||||||
open: boolean;
|
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 client = useClient();
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
const repositorySelect = useSelectedRepository();
|
const repositorySelect = useSelectedRepository();
|
||||||
@@ -77,9 +77,9 @@ export default function PackageAddDialog({ onClose, open }: PackageAddDialogProp
|
|||||||
const debouncedSearch = useDebounce(packageName, 500);
|
const debouncedSearch = useDebounce(packageName, 500);
|
||||||
|
|
||||||
const { data: searchResults = [] } = useQuery<AURPackage[]>({
|
const { data: searchResults = [] } = useQuery<AURPackage[]>({
|
||||||
enabled: debouncedSearch.length >= 3,
|
|
||||||
queryFn: () => client.service.servicePackageSearch(debouncedSearch),
|
|
||||||
queryKey: QueryKeys.search(debouncedSearch),
|
queryKey: QueryKeys.search(debouncedSearch),
|
||||||
|
queryFn: () => client.service.servicePackageSearch(debouncedSearch),
|
||||||
|
enabled: debouncedSearch.length >= 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (action: "add" | "request"): Promise<void> => {
|
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}>
|
<DialogHeader onClose={handleClose}>
|
||||||
Add new packages
|
Add new packages
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -117,18 +117,20 @@ export default function PackageAddDialog({ onClose, open }: PackageAddDialogProp
|
|||||||
|
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
freeSolo
|
freeSolo
|
||||||
|
options={searchResults.map(pkg => pkg.package)}
|
||||||
inputValue={packageName}
|
inputValue={packageName}
|
||||||
onInputChange={(_, value) => setPackageName(value)}
|
onInputChange={(_, value) => setPackageName(value)}
|
||||||
options={searchResults.map(pkg => pkg.package)}
|
|
||||||
renderInput={params =>
|
|
||||||
<TextField {...params} label="package" margin="normal" placeholder="AUR package" />
|
|
||||||
}
|
|
||||||
renderOption={(props, option) => {
|
renderOption={(props, option) => {
|
||||||
const pkg = searchResults.find(pkg => pkg.package === option);
|
const pkg = searchResults.find(pkg => pkg.package === option);
|
||||||
return <li {...props} key={option}>
|
return (
|
||||||
{option}{pkg ? ` (${pkg.description})` : ""}
|
<li {...props} key={option}>
|
||||||
</li>;
|
{option}{pkg ? ` (${pkg.description})` : ""}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
|
renderInput={params =>
|
||||||
|
<TextField {...params} label="package" placeholder="AUR package" margin="normal" />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
@@ -138,50 +140,45 @@ export default function PackageAddDialog({ onClose, open }: PackageAddDialogProp
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const id = variableIdCounter.current++;
|
const id = variableIdCounter.current++;
|
||||||
setEnvironmentVariables(prev => [...prev, { id, key: "", value: "" }]);
|
setEnvironmentVariables(prev => [...prev, { id, key: "", value: "" }]);
|
||||||
}}
|
}}
|
||||||
startIcon={<AddIcon />}
|
|
||||||
sx={{ mt: 1 }}
|
sx={{ mt: 1 }}
|
||||||
variant="outlined"
|
|
||||||
>
|
>
|
||||||
add environment variable
|
add environment variable
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{environmentVariables.map(variable =>
|
{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
|
<TextField
|
||||||
|
size="small"
|
||||||
|
placeholder="name"
|
||||||
|
value={variable.key}
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
const newKey = event.target.value;
|
const newKey = event.target.value;
|
||||||
setEnvironmentVariables(prev =>
|
setEnvironmentVariables(prev =>
|
||||||
prev.map(entry => entry.id === variable.id ? { ...entry, key: newKey } : entry),
|
prev.map(entry => entry.id === variable.id ? { ...entry, key: newKey } : entry),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder="name"
|
|
||||||
size="small"
|
|
||||||
sx={{ flex: 1 }}
|
sx={{ flex: 1 }}
|
||||||
value={variable.key}
|
|
||||||
/>
|
/>
|
||||||
<Box>=</Box>
|
<Box>=</Box>
|
||||||
<TextField
|
<TextField
|
||||||
|
size="small"
|
||||||
placeholder="value"
|
placeholder="value"
|
||||||
|
value={variable.value}
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
const newValue = event.target.value;
|
const newValue = event.target.value;
|
||||||
setEnvironmentVariables(prev =>
|
setEnvironmentVariables(prev =>
|
||||||
prev.map(entry => entry.id === variable.id ? { ...entry, value: newValue } : entry),
|
prev.map(entry => entry.id === variable.id ? { ...entry, value: newValue } : entry),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
size="small"
|
|
||||||
sx={{ flex: 1 }}
|
sx={{ flex: 1 }}
|
||||||
value={variable.value}
|
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton size="small" color="error" aria-label="Remove variable" onClick={() => setEnvironmentVariables(prev => prev.filter(entry => entry.id !== variable.id))}>
|
||||||
aria-label="Remove variable"
|
|
||||||
color="error"
|
|
||||||
onClick={() => setEnvironmentVariables(prev => prev.filter(entry => entry.id !== variable.id))}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>,
|
</Box>,
|
||||||
@@ -189,8 +186,8 @@ export default function PackageAddDialog({ onClose, open }: PackageAddDialogProp
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => void handleSubmit("add")} startIcon={<PlayArrowIcon />} variant="contained">add</Button>
|
<Button onClick={() => void handleSubmit("add")} variant="contained" startIcon={<PlayArrowIcon />}>add</Button>
|
||||||
<Button color="success" onClick={() => void handleSubmit("request")} startIcon={<AddIcon />} variant="contained">request</Button>
|
<Button onClick={() => void handleSubmit("request")} variant="contained" color="success" startIcon={<AddIcon />}>request</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { Box, Dialog, DialogContent, Tab, Tabs } from "@mui/material";
|
|||||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ApiError } from "api/client/ApiError";
|
import { ApiError } from "api/client/ApiError";
|
||||||
import DialogHeader from "components/common/DialogHeader";
|
import DialogHeader from "components/common/DialogHeader";
|
||||||
import ArtifactsTab from "components/package/ArtifactsTab";
|
|
||||||
import BuildLogsTab from "components/package/BuildLogsTab";
|
import BuildLogsTab from "components/package/BuildLogsTab";
|
||||||
import ChangesTab from "components/package/ChangesTab";
|
import ChangesTab from "components/package/ChangesTab";
|
||||||
import EventsTab from "components/package/EventsTab";
|
import EventsTab from "components/package/EventsTab";
|
||||||
@@ -32,25 +31,30 @@ import PkgbuildTab from "components/package/PkgbuildTab";
|
|||||||
import { type TabKey, tabs } from "components/package/TabKey";
|
import { type TabKey, tabs } from "components/package/TabKey";
|
||||||
import { QueryKeys } from "hooks/QueryKeys";
|
import { QueryKeys } from "hooks/QueryKeys";
|
||||||
import { useAuth } from "hooks/useAuth";
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
import { useAutoRefresh } from "hooks/useAutoRefresh";
|
||||||
import { useClient } from "hooks/useClient";
|
import { useClient } from "hooks/useClient";
|
||||||
import { useNotification } from "hooks/useNotification";
|
import { useNotification } from "hooks/useNotification";
|
||||||
import { useRepository } from "hooks/useRepository";
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
|
||||||
import type { Dependencies } from "models/Dependencies";
|
import type { Dependencies } from "models/Dependencies";
|
||||||
import type { PackageStatus } from "models/PackageStatus";
|
import type { PackageStatus } from "models/PackageStatus";
|
||||||
import type { Patch } from "models/Patch";
|
import type { Patch } from "models/Patch";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { StatusHeaderStyles } from "theme/StatusColors";
|
import { StatusHeaderStyles } from "theme/StatusColors";
|
||||||
|
import { defaultInterval } from "utils";
|
||||||
|
|
||||||
interface PackageInfoDialogProps {
|
interface PackageInfoDialogProps {
|
||||||
onClose: () => void;
|
|
||||||
open: boolean;
|
|
||||||
packageBase: string | null;
|
packageBase: string | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
autoRefreshIntervals: AutoRefreshInterval[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageInfoDialog({
|
export default function PackageInfoDialog({
|
||||||
onClose,
|
|
||||||
open,
|
|
||||||
packageBase,
|
packageBase,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
autoRefreshIntervals,
|
||||||
}: PackageInfoDialogProps): React.JSX.Element {
|
}: PackageInfoDialogProps): React.JSX.Element {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { currentRepository } = useRepository();
|
const { currentRepository } = useRepository();
|
||||||
@@ -72,32 +76,35 @@ export default function PackageInfoDialog({
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
|
||||||
|
|
||||||
const { data: packageData } = useQuery<PackageStatus[]>({
|
const { data: packageData } = useQuery<PackageStatus[]>({
|
||||||
enabled: open,
|
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
|
||||||
queryFn: localPackageBase && currentRepository ?
|
queryFn: localPackageBase && currentRepository ?
|
||||||
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
|
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
|
||||||
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
|
enabled: open,
|
||||||
|
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: dependencies } = useQuery<Dependencies>({
|
const { data: dependencies } = useQuery<Dependencies>({
|
||||||
enabled: open,
|
queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"],
|
||||||
queryFn: localPackageBase && currentRepository ?
|
queryFn: localPackageBase && currentRepository ?
|
||||||
() => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken,
|
() => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken,
|
||||||
queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"],
|
enabled: open,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: patches = [] } = useQuery<Patch[]>({
|
const { data: patches = [] } = useQuery<Patch[]>({
|
||||||
enabled: open,
|
|
||||||
queryFn: localPackageBase ? () => client.fetch.fetchPackagePatches(localPackageBase) : skipToken,
|
|
||||||
queryKey: localPackageBase ? QueryKeys.patches(localPackageBase) : ["patches"],
|
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 pkg = description?.package;
|
||||||
const status = description?.status;
|
const status = description?.status;
|
||||||
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
|
const headerStyle = status ? StatusHeaderStyles[status.status] : {};
|
||||||
|
|
||||||
const handleUpdate = async (): Promise<void> => {
|
const handleUpdate: () => Promise<void> = async () => {
|
||||||
if (!localPackageBase || !currentRepository) {
|
if (!localPackageBase || !currentRepository) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -110,7 +117,7 @@ export default function PackageInfoDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemove = async (): Promise<void> => {
|
const handleRemove: () => Promise<void> = async () => {
|
||||||
if (!localPackageBase || !currentRepository) {
|
if (!localPackageBase || !currentRepository) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -123,20 +130,20 @@ export default function PackageInfoDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHoldToggle = async (): Promise<void> => {
|
const handleHoldToggle: () => Promise<void> = async () => {
|
||||||
if (!localPackageBase || !currentRepository) {
|
if (!localPackageBase || !currentRepository) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const newHeldStatus = !(status?.is_held ?? false);
|
const newHeldStatus = !(status?.is_held ?? false);
|
||||||
await client.service.servicePackageHold(localPackageBase, currentRepository, newHeldStatus);
|
await client.service.servicePackageHoldUpdate(localPackageBase, currentRepository, newHeldStatus);
|
||||||
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(localPackageBase, currentRepository) });
|
void queryClient.invalidateQueries({ queryKey: QueryKeys.package(localPackageBase, currentRepository) });
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
showError("Action failed", `Could not update hold status: ${ApiError.errorDetail(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) {
|
if (!localPackageBase) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -148,7 +155,7 @@ export default function PackageInfoDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Dialog fullWidth maxWidth="lg" onClose={handleClose} open={open}>
|
return <Dialog open={open} onClose={handleClose} maxWidth="lg" fullWidth>
|
||||||
<DialogHeader onClose={handleClose} sx={headerStyle}>
|
<DialogHeader onClose={handleClose} sx={headerStyle}>
|
||||||
{pkg && status
|
{pkg && status
|
||||||
? `${pkg.base} ${status.status} at ${new Date(status.timestamp * 1000).toISOStringShort()}`
|
? `${pkg.base} ${status.status} at ${new Date(status.timestamp * 1000).toISOStringShort()}`
|
||||||
@@ -158,16 +165,16 @@ export default function PackageInfoDialog({
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
{pkg &&
|
{pkg &&
|
||||||
<>
|
<>
|
||||||
<PackageDetailsGrid dependencies={dependencies} pkg={pkg} />
|
<PackageDetailsGrid pkg={pkg} dependencies={dependencies} />
|
||||||
<PackagePatchesList
|
<PackagePatchesList
|
||||||
|
patches={patches}
|
||||||
editable={isAuthorized}
|
editable={isAuthorized}
|
||||||
onDelete={key => void handleDeletePatch(key)}
|
onDelete={key => void handleDeletePatch(key)}
|
||||||
patches={patches}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
|
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
|
||||||
<Tabs onChange={(_, tab: TabKey) => setActiveTab(tab)} value={activeTab}>
|
<Tabs value={activeTab} onChange={(_, tab: TabKey) => setActiveTab(tab)}>
|
||||||
{tabs.map(({ key, label }) => <Tab key={key} label={label} value={key} />)}
|
{tabs.map(({ key, label }) => <Tab key={key} value={key} label={label} />)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -175,6 +182,7 @@ export default function PackageInfoDialog({
|
|||||||
<BuildLogsTab
|
<BuildLogsTab
|
||||||
packageBase={localPackageBase}
|
packageBase={localPackageBase}
|
||||||
repository={currentRepository}
|
repository={currentRepository}
|
||||||
|
refreshInterval={autoRefresh.interval}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{activeTab === "changes" && localPackageBase && currentRepository &&
|
{activeTab === "changes" && localPackageBase && currentRepository &&
|
||||||
@@ -186,25 +194,21 @@ export default function PackageInfoDialog({
|
|||||||
{activeTab === "events" && localPackageBase && currentRepository &&
|
{activeTab === "events" && localPackageBase && currentRepository &&
|
||||||
<EventsTab packageBase={localPackageBase} repository={currentRepository} />
|
<EventsTab packageBase={localPackageBase} repository={currentRepository} />
|
||||||
}
|
}
|
||||||
{activeTab === "artifacts" && localPackageBase && currentRepository &&
|
|
||||||
<ArtifactsTab
|
|
||||||
currentVersion={pkg.version}
|
|
||||||
packageBase={localPackageBase}
|
|
||||||
repository={currentRepository}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<PackageInfoActions
|
<PackageInfoActions
|
||||||
isAuthorized={isAuthorized}
|
isAuthorized={isAuthorized}
|
||||||
|
refreshDatabase={refreshDatabase}
|
||||||
|
onRefreshDatabaseChange={setRefreshDatabase}
|
||||||
isHeld={status?.is_held ?? false}
|
isHeld={status?.is_held ?? false}
|
||||||
onHoldToggle={() => void handleHoldToggle()}
|
onHoldToggle={() => void handleHoldToggle()}
|
||||||
onRefreshDatabaseChange={setRefreshDatabase}
|
|
||||||
onRemove={() => void handleRemove()}
|
|
||||||
onUpdate={() => void handleUpdate()}
|
onUpdate={() => void handleUpdate()}
|
||||||
refreshDatabase={refreshDatabase}
|
onRemove={() => void handleRemove()}
|
||||||
|
autoRefreshIntervals={autoRefreshIntervals}
|
||||||
|
autoRefreshInterval={autoRefresh.interval}
|
||||||
|
onAutoRefreshIntervalChange={autoRefresh.setInterval}
|
||||||
/>
|
/>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ import { useSelectedRepository } from "hooks/useSelectedRepository";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
interface PackageRebuildDialogProps {
|
interface PackageRebuildDialogProps {
|
||||||
onClose: () => void;
|
|
||||||
open: boolean;
|
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 client = useClient();
|
||||||
const { showSuccess, showError } = useNotification();
|
const { showSuccess, showError } = useNotification();
|
||||||
const repositorySelect = useSelectedRepository();
|
const repositorySelect = useSelectedRepository();
|
||||||
@@ -45,7 +45,7 @@ export default function PackageRebuildDialog({ onClose, open }: PackageRebuildDi
|
|||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRebuild = async (): Promise<void> => {
|
const handleRebuild: () => Promise<void> = async () => {
|
||||||
if (!dependency) {
|
if (!dependency) {
|
||||||
return;
|
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}>
|
<DialogHeader onClose={handleClose}>
|
||||||
Rebuild depending packages
|
Rebuild depending packages
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -72,17 +72,17 @@ export default function PackageRebuildDialog({ onClose, open }: PackageRebuildDi
|
|||||||
<RepositorySelect repositorySelect={repositorySelect} />
|
<RepositorySelect repositorySelect={repositorySelect} />
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
|
||||||
label="dependency"
|
label="dependency"
|
||||||
margin="normal"
|
|
||||||
placeholder="packages dependency"
|
placeholder="packages dependency"
|
||||||
onChange={event => setDependency(event.target.value)}
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
value={dependency}
|
value={dependency}
|
||||||
|
onChange={event => setDependency(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => void handleRebuild()} startIcon={<PlayArrowIcon />} variant="contained">rebuild</Button>
|
<Button onClick={() => void handleRebuild()} variant="contained" startIcon={<PlayArrowIcon />}>rebuild</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>;
|
</Dialog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export default function AppLayout(): React.JSX.Element {
|
|||||||
const [loginOpen, setLoginOpen] = useState(false);
|
const [loginOpen, setLoginOpen] = useState(false);
|
||||||
|
|
||||||
const { data: info } = useQuery<InfoResponse>({
|
const { data: info } = useQuery<InfoResponse>({
|
||||||
queryFn: () => client.fetch.fetchServerInfo(),
|
|
||||||
queryKey: QueryKeys.info,
|
queryKey: QueryKeys.info,
|
||||||
|
queryFn: () => client.fetch.fetchServerInfo(),
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,9 +55,9 @@ export default function AppLayout(): React.JSX.Element {
|
|||||||
}, [info, setAuthState, setRepositories]);
|
}, [info, setAuthState, setRepositories]);
|
||||||
|
|
||||||
return <Container maxWidth="xl">
|
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">
|
<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>
|
</a>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
@@ -69,15 +69,17 @@ export default function AppLayout(): React.JSX.Element {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<PackageTable />
|
<PackageTable
|
||||||
|
autoRefreshIntervals={info?.autorefresh_intervals ?? []}
|
||||||
|
/>
|
||||||
|
|
||||||
<Footer
|
<Footer
|
||||||
|
version={info?.version ?? ""}
|
||||||
docsEnabled={info?.docs_enabled ?? false}
|
docsEnabled={info?.docs_enabled ?? false}
|
||||||
indexUrl={info?.index_url}
|
indexUrl={info?.index_url}
|
||||||
onLoginClick={() => info?.auth.external ? window.location.assign("/api/v1/login") : setLoginOpen(true)}
|
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>;
|
</Container>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,41 +26,41 @@ import { useAuth } from "hooks/useAuth";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
|
version: string;
|
||||||
docsEnabled: boolean;
|
docsEnabled: boolean;
|
||||||
indexUrl?: string;
|
indexUrl?: string;
|
||||||
onLoginClick: () => void;
|
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();
|
const { enabled: authEnabled, username, logout } = useAuth();
|
||||||
|
|
||||||
return <Box
|
return <Box
|
||||||
component="footer"
|
component="footer"
|
||||||
sx={{
|
sx={{
|
||||||
alignItems: "center",
|
|
||||||
borderColor: "divider",
|
|
||||||
borderTop: 1,
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: "divider",
|
||||||
mt: 2,
|
mt: 2,
|
||||||
py: 1,
|
py: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ alignItems: "center", display: "flex", gap: 2 }}>
|
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
|
||||||
<Link color="inherit" href="https://github.com/arcan1s/ahriman" sx={{ alignItems: "center", display: "flex", gap: 0.5 }} underline="hover">
|
<Link href="https://github.com/arcan1s/ahriman" underline="hover" color="inherit" sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
<GitHubIcon fontSize="small" />
|
<GitHubIcon fontSize="small" />
|
||||||
<Typography variant="body2">ahriman {version}</Typography>
|
<Typography variant="body2">ahriman {version}</Typography>
|
||||||
</Link>
|
</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
|
releases
|
||||||
</Link>
|
</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
|
report a bug
|
||||||
</Link>
|
</Link>
|
||||||
{docsEnabled &&
|
{docsEnabled &&
|
||||||
<Link color="text.secondary" href="/api-docs" underline="hover" variant="body2">
|
<Link href="/api-docs" underline="hover" color="text.secondary" variant="body2">
|
||||||
api
|
api
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ export default function Footer({ docsEnabled, indexUrl, onLoginClick, version }:
|
|||||||
|
|
||||||
{indexUrl &&
|
{indexUrl &&
|
||||||
<Box>
|
<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" />
|
<HomeIcon fontSize="small" />
|
||||||
<Typography variant="body2">repo index</Typography>
|
<Typography variant="body2">repo index</Typography>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -78,11 +78,11 @@ export default function Footer({ docsEnabled, indexUrl, onLoginClick, version }:
|
|||||||
{authEnabled &&
|
{authEnabled &&
|
||||||
<Box>
|
<Box>
|
||||||
{username ?
|
{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})
|
logout ({username})
|
||||||
</Button>
|
</Button>
|
||||||
:
|
:
|
||||||
<Button onClick={onLoginClick} size="small" startIcon={<LoginIcon />} sx={{ textTransform: "none" }}>
|
<Button size="small" startIcon={<LoginIcon />} onClick={onLoginClick} sx={{ textTransform: "none" }}>
|
||||||
login
|
login
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,15 +35,15 @@ export default function Navbar(): React.JSX.Element | null {
|
|||||||
|
|
||||||
return <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
return <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
value={currentIndex >= 0 ? currentIndex : 0}
|
||||||
onChange={(_, newValue: number) => {
|
onChange={(_, newValue: number) => {
|
||||||
const repository = repositories[newValue];
|
const repository = repositories[newValue];
|
||||||
if (repository) {
|
if (repository) {
|
||||||
setCurrentRepository(repository);
|
setCurrentRepository(repository);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
scrollButtons="auto"
|
|
||||||
value={currentIndex >= 0 ? currentIndex : 0}
|
|
||||||
variant="scrollable"
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
>
|
>
|
||||||
{repositories.map(repository =>
|
{repositories.map(repository =>
|
||||||
<Tab
|
<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>;
|
|
||||||
}
|
|
||||||
@@ -23,22 +23,22 @@ import { keepPreviousData, skipToken, useQuery } from "@tanstack/react-query";
|
|||||||
import CodeBlock from "components/common/CodeBlock";
|
import CodeBlock from "components/common/CodeBlock";
|
||||||
import { QueryKeys } from "hooks/QueryKeys";
|
import { QueryKeys } from "hooks/QueryKeys";
|
||||||
import { useAutoScroll } from "hooks/useAutoScroll";
|
import { useAutoScroll } from "hooks/useAutoScroll";
|
||||||
import { useBuildLogStream } from "hooks/useBuildLogStream";
|
|
||||||
import { useClient } from "hooks/useClient";
|
import { useClient } from "hooks/useClient";
|
||||||
import type { LogRecord } from "models/LogRecord";
|
import type { LogRecord } from "models/LogRecord";
|
||||||
import type { RepositoryId } from "models/RepositoryId";
|
import type { RepositoryId } from "models/RepositoryId";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
interface Logs {
|
interface Logs {
|
||||||
|
version: string;
|
||||||
|
processId: string;
|
||||||
created: number;
|
created: number;
|
||||||
logs: string;
|
logs: string;
|
||||||
processId: string;
|
|
||||||
version: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BuildLogsTabProps {
|
interface BuildLogsTabProps {
|
||||||
packageBase: string;
|
packageBase: string;
|
||||||
repository: RepositoryId;
|
repository: RepositoryId;
|
||||||
|
refreshInterval: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string {
|
function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boolean): string {
|
||||||
@@ -51,16 +51,17 @@ function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boole
|
|||||||
export default function BuildLogsTab({
|
export default function BuildLogsTab({
|
||||||
packageBase,
|
packageBase,
|
||||||
repository,
|
repository,
|
||||||
|
refreshInterval,
|
||||||
}: BuildLogsTabProps): React.JSX.Element {
|
}: BuildLogsTabProps): React.JSX.Element {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
useBuildLogStream(packageBase, repository);
|
|
||||||
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
|
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
const { data: allLogs } = useQuery<LogRecord[]>({
|
const { data: allLogs } = useQuery<LogRecord[]>({
|
||||||
enabled: !!packageBase,
|
|
||||||
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
|
|
||||||
queryKey: QueryKeys.logs(packageBase, repository),
|
queryKey: QueryKeys.logs(packageBase, repository),
|
||||||
|
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
|
||||||
|
enabled: !!packageBase,
|
||||||
|
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build version selectors from all logs
|
// Build version selectors from all logs
|
||||||
@@ -83,13 +84,13 @@ export default function BuildLogsTab({
|
|||||||
return Object.values(grouped)
|
return Object.values(grouped)
|
||||||
.sort((left, right) => right.minCreated - left.minCreated)
|
.sort((left, right) => right.minCreated - left.minCreated)
|
||||||
.map(record => ({
|
.map(record => ({
|
||||||
|
version: record.version,
|
||||||
|
processId: record.process_id,
|
||||||
created: record.minCreated,
|
created: record.minCreated,
|
||||||
logs: convertLogs(
|
logs: convertLogs(
|
||||||
allLogs,
|
allLogs,
|
||||||
right => record.version === right.version && record.process_id === right.process_id,
|
right => record.version === right.version && record.process_id === right.process_id,
|
||||||
),
|
),
|
||||||
processId: record.process_id,
|
|
||||||
version: record.version,
|
|
||||||
}));
|
}));
|
||||||
}, [allLogs]);
|
}, [allLogs]);
|
||||||
|
|
||||||
@@ -109,13 +110,14 @@ export default function BuildLogsTab({
|
|||||||
|
|
||||||
// Refresh active version logs
|
// Refresh active version logs
|
||||||
const { data: versionLogs } = useQuery<LogRecord[]>({
|
const { data: versionLogs } = useQuery<LogRecord[]>({
|
||||||
placeholderData: keepPreviousData,
|
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
|
||||||
queryFn: activeVersion
|
queryFn: activeVersion
|
||||||
? () => client.fetch.fetchPackageLogs(
|
? () => client.fetch.fetchPackageLogs(
|
||||||
packageBase, repository, activeVersion.version, activeVersion.processId,
|
packageBase, repository, activeVersion.version, activeVersion.processId,
|
||||||
)
|
)
|
||||||
: skipToken,
|
: skipToken,
|
||||||
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
|
placeholderData: keepPreviousData,
|
||||||
|
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derive displayed logs: prefer fresh polled data when available
|
// Derive displayed logs: prefer fresh polled data when available
|
||||||
@@ -141,25 +143,25 @@ export default function BuildLogsTab({
|
|||||||
return <Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
return <Box sx={{ display: "flex", gap: 1, mt: 1 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Select version"
|
|
||||||
onClick={event => setAnchorEl(event.currentTarget)}
|
|
||||||
size="small"
|
size="small"
|
||||||
|
aria-label="Select version"
|
||||||
startIcon={<ListIcon />}
|
startIcon={<ListIcon />}
|
||||||
|
onClick={event => setAnchorEl(event.currentTarget)}
|
||||||
/>
|
/>
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={() => setAnchorEl(null)}
|
|
||||||
open={Boolean(anchorEl)}
|
open={Boolean(anchorEl)}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
>
|
>
|
||||||
{versions.map((logs, index) =>
|
{versions.map((logs, index) =>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={`${logs.version}-${logs.processId}`}
|
key={`${logs.version}-${logs.processId}`}
|
||||||
|
selected={index === activeIndex}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedVersionKey(`${logs.version}-${logs.processId}`);
|
setSelectedVersionKey(`${logs.version}-${logs.processId}`);
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
resetScroll();
|
resetScroll();
|
||||||
}}
|
}}
|
||||||
selected={index === activeIndex}
|
|
||||||
>
|
>
|
||||||
<Typography variant="body2">{new Date(logs.created * 1000).toISOStringShort()}</Typography>
|
<Typography variant="body2">{new Date(logs.created * 1000).toISOStringShort()}</Typography>
|
||||||
</MenuItem>,
|
</MenuItem>,
|
||||||
@@ -172,10 +174,10 @@ export default function BuildLogsTab({
|
|||||||
|
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
|
preRef={preRef}
|
||||||
content={displayedLogs}
|
content={displayedLogs}
|
||||||
height={400}
|
height={400}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
preRef={preRef}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>;
|
</Box>;
|
||||||
|
|||||||
@@ -30,5 +30,5 @@ interface ChangesTabProps {
|
|||||||
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
|
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
|
||||||
const data = usePackageChanges(packageBase, repository);
|
const data = usePackageChanges(packageBase, repository);
|
||||||
|
|
||||||
return <CodeBlock content={data?.changes ?? ""} height={400} language="diff" />;
|
return <CodeBlock language="diff" content={data?.changes ?? ""} height={400} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import type { Event } from "models/Event";
|
|||||||
import type { RepositoryId } from "models/RepositoryId";
|
import type { RepositoryId } from "models/RepositoryId";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { DETAIL_TABLE_PROPS } from "utils";
|
|
||||||
|
|
||||||
interface EventsTabProps {
|
interface EventsTabProps {
|
||||||
packageBase: string;
|
packageBase: string;
|
||||||
@@ -35,36 +34,46 @@ interface EventsTabProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface EventRow {
|
interface EventRow {
|
||||||
event: string;
|
|
||||||
id: number;
|
id: number;
|
||||||
message: string;
|
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
event: string;
|
||||||
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: GridColDef<EventRow>[] = [
|
const columns: GridColDef<EventRow>[] = [
|
||||||
{ align: "right", field: "timestamp", headerAlign: "right", headerName: "date", width: 180 },
|
{ field: "timestamp", headerName: "date", width: 180, align: "right", headerAlign: "right" },
|
||||||
{ field: "event", flex: 1, headerName: "event" },
|
{ field: "event", headerName: "event", flex: 1 },
|
||||||
{ field: "message", flex: 2, headerName: "description" },
|
{ field: "message", headerName: "description", flex: 2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function EventsTab({ packageBase, repository }: EventsTabProps): React.JSX.Element {
|
export default function EventsTab({ packageBase, repository }: EventsTabProps): React.JSX.Element {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
const { data: events = [] } = useQuery<Event[]>({
|
const { data: events = [] } = useQuery<Event[]>({
|
||||||
enabled: !!packageBase,
|
|
||||||
queryFn: () => client.fetch.fetchPackageEvents(repository, packageBase, 30),
|
|
||||||
queryKey: QueryKeys.events(repository, packageBase),
|
queryKey: QueryKeys.events(repository, packageBase),
|
||||||
|
queryFn: () => client.fetch.fetchPackageEvents(repository, packageBase, 30),
|
||||||
|
enabled: !!packageBase,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = useMemo<EventRow[]>(() => events.map((event, index) => ({
|
const rows = useMemo<EventRow[]>(() => events.map((event, index) => ({
|
||||||
event: event.event,
|
|
||||||
id: index,
|
id: index,
|
||||||
message: event.message ?? "",
|
|
||||||
timestamp: new Date(event.created * 1000).toISOStringShort(),
|
timestamp: new Date(event.created * 1000).toISOStringShort(),
|
||||||
|
event: event.event,
|
||||||
|
message: event.message ?? "",
|
||||||
})), [events]);
|
})), [events]);
|
||||||
|
|
||||||
return <Box sx={{ mt: 1 }}>
|
return <Box sx={{ mt: 1 }}>
|
||||||
<EventDurationLineChart events={events} />
|
<EventDurationLineChart events={events} />
|
||||||
<DataGrid columns={columns} rows={rows} {...DETAIL_TABLE_PROPS} />
|
<DataGrid
|
||||||
|
rows={rows}
|
||||||
|
columns={columns}
|
||||||
|
density="compact"
|
||||||
|
initialState={{
|
||||||
|
sorting: { sortModel: [{ field: "timestamp", sort: "desc" }] },
|
||||||
|
}}
|
||||||
|
pageSizeOptions={[10, 25]}
|
||||||
|
sx={{ height: 400, mt: 1 }}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
/>
|
||||||
</Box>;
|
</Box>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ import type { Package } from "models/Package";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
interface PackageDetailsGridProps {
|
interface PackageDetailsGridProps {
|
||||||
dependencies?: Dependencies;
|
|
||||||
pkg: Package;
|
pkg: Package;
|
||||||
|
dependencies?: Dependencies;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageDetailsGrid({ dependencies, pkg }: PackageDetailsGridProps): React.JSX.Element {
|
export default function PackageDetailsGrid({ pkg, dependencies }: PackageDetailsGridProps): React.JSX.Element {
|
||||||
const packagesList = Object.entries(pkg.packages)
|
const packagesList = Object.entries(pkg.packages)
|
||||||
.map(([name, properties]) => `${name}${properties.description ? ` (${properties.description})` : ""}`);
|
.map(([name, properties]) => `${name}${properties.description ? ` (${properties.description})` : ""}`);
|
||||||
|
|
||||||
@@ -65,50 +65,50 @@ export default function PackageDetailsGrid({ dependencies, pkg }: PackageDetails
|
|||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Grid container spacing={1} sx={{ mt: 1 }}>
|
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||||
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">packages</Typography></Grid>
|
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packages</Typography></Grid>
|
||||||
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{packagesList.unique().join("\n")}</Typography></Grid>
|
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{packagesList.unique().join("\n")}</Typography></Grid>
|
||||||
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">version</Typography></Grid>
|
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">version</Typography></Grid>
|
||||||
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2">{pkg.version}</Typography></Grid>
|
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.version}</Typography></Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">packager</Typography></Grid>
|
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">packager</Typography></Grid>
|
||||||
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
|
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2">{pkg.packager ?? ""}</Typography></Grid>
|
||||||
<Grid size={{ md: 1, xs: 4 }} />
|
<Grid size={{ xs: 4, md: 1 }} />
|
||||||
<Grid size={{ md: 5, xs: 8 }} />
|
<Grid size={{ xs: 8, md: 5 }} />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">groups</Typography></Grid>
|
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">groups</Typography></Grid>
|
||||||
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{groups.unique().join("\n")}</Typography></Grid>
|
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{groups.unique().join("\n")}</Typography></Grid>
|
||||||
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">licenses</Typography></Grid>
|
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">licenses</Typography></Grid>
|
||||||
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{licenses.unique().join("\n")}</Typography></Grid>
|
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{licenses.unique().join("\n")}</Typography></Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">upstream</Typography></Grid>
|
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">upstream</Typography></Grid>
|
||||||
<Grid size={{ md: 5, xs: 8 }}>
|
<Grid size={{ xs: 8, md: 5 }}>
|
||||||
{upstreamUrls.map(url =>
|
{upstreamUrls.map(url =>
|
||||||
<Link display="block" href={url} key={url} rel="noopener noreferrer" target="_blank" underline="hover" variant="body2">
|
<Link key={url} href={url} target="_blank" rel="noopener noreferrer" underline="hover" display="block" variant="body2">
|
||||||
{url}
|
{url}
|
||||||
</Link>,
|
</Link>,
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">AUR</Typography></Grid>
|
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">AUR</Typography></Grid>
|
||||||
<Grid size={{ md: 5, xs: 8 }}>
|
<Grid size={{ xs: 8, md: 5 }}>
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
{aurUrl &&
|
{aurUrl &&
|
||||||
<Link href={aurUrl} rel="noopener noreferrer" target="_blank" underline="hover">{aurUrl}</Link>
|
<Link href={aurUrl} target="_blank" rel="noopener noreferrer" underline="hover">AUR link</Link>
|
||||||
}
|
}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
<Grid container spacing={1} sx={{ mt: 0.5 }}>
|
||||||
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">depends</Typography></Grid>
|
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">depends</Typography></Grid>
|
||||||
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{allDepends.join("\n")}</Typography></Grid>
|
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{allDepends.join("\n")}</Typography></Grid>
|
||||||
<Grid size={{ md: 1, xs: 4 }}><Typography align="right" color="text.secondary" variant="body2">implicitly depends</Typography></Grid>
|
<Grid size={{ xs: 4, md: 1 }}><Typography variant="body2" color="text.secondary" align="right">implicitly depends</Typography></Grid>
|
||||||
<Grid size={{ md: 5, xs: 8 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{implicitDepends.unique().join("\n")}</Typography></Grid>
|
<Grid size={{ xs: 8, md: 5 }}><Typography variant="body2" sx={{ whiteSpace: "pre-line" }}>{implicitDepends.unique().join("\n")}</Typography></Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,26 +22,34 @@ import PauseCircleIcon from "@mui/icons-material/PauseCircle";
|
|||||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
import PlayCircleIcon from "@mui/icons-material/PlayCircle";
|
import PlayCircleIcon from "@mui/icons-material/PlayCircle";
|
||||||
import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material";
|
import { Button, Checkbox, DialogActions, FormControlLabel } from "@mui/material";
|
||||||
|
import AutoRefreshControl from "components/common/AutoRefreshControl";
|
||||||
|
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
interface PackageInfoActionsProps {
|
interface PackageInfoActionsProps {
|
||||||
isAuthorized: boolean;
|
isAuthorized: boolean;
|
||||||
isHeld: boolean;
|
isHeld: boolean;
|
||||||
onHoldToggle: () => void;
|
onHoldToggle: () => void;
|
||||||
onRefreshDatabaseChange: (checked: boolean) => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
onUpdate: () => void;
|
|
||||||
refreshDatabase: boolean;
|
refreshDatabase: boolean;
|
||||||
|
onRefreshDatabaseChange: (checked: boolean) => void;
|
||||||
|
onUpdate: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
autoRefreshIntervals: AutoRefreshInterval[];
|
||||||
|
autoRefreshInterval: number;
|
||||||
|
onAutoRefreshIntervalChange: (interval: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageInfoActions({
|
export default function PackageInfoActions({
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
|
refreshDatabase,
|
||||||
|
onRefreshDatabaseChange,
|
||||||
isHeld,
|
isHeld,
|
||||||
onHoldToggle,
|
onHoldToggle,
|
||||||
onRefreshDatabaseChange,
|
|
||||||
onRemove,
|
|
||||||
onUpdate,
|
onUpdate,
|
||||||
refreshDatabase,
|
onRemove,
|
||||||
|
autoRefreshIntervals,
|
||||||
|
autoRefreshInterval,
|
||||||
|
onAutoRefreshIntervalChange,
|
||||||
}: PackageInfoActionsProps): React.JSX.Element {
|
}: PackageInfoActionsProps): React.JSX.Element {
|
||||||
return <DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
|
return <DialogActions sx={{ flexWrap: "wrap", gap: 1 }}>
|
||||||
{isAuthorized &&
|
{isAuthorized &&
|
||||||
@@ -50,16 +58,21 @@ export default function PackageInfoActions({
|
|||||||
control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />}
|
control={<Checkbox checked={refreshDatabase} onChange={(_, checked) => onRefreshDatabaseChange(checked)} size="small" />}
|
||||||
label="update pacman databases"
|
label="update pacman databases"
|
||||||
/>
|
/>
|
||||||
<Button color="warning" onClick={onHoldToggle} size="small" startIcon={isHeld ? <PlayCircleIcon /> : <PauseCircleIcon />} variant="outlined">
|
<Button onClick={onHoldToggle} variant="outlined" color="warning" startIcon={isHeld ? <PlayCircleIcon /> : <PauseCircleIcon />} size="small">
|
||||||
{isHeld ? "unhold" : "hold"}
|
{isHeld ? "unhold" : "hold"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="success" onClick={onUpdate} size="small" startIcon={<PlayArrowIcon />} variant="contained">
|
<Button onClick={onUpdate} variant="contained" color="success" startIcon={<PlayArrowIcon />} size="small">
|
||||||
update
|
update
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="error" onClick={onRemove} size="small" startIcon={<DeleteIcon />} variant="contained">
|
<Button onClick={onRemove} variant="contained" color="error" startIcon={<DeleteIcon />} size="small">
|
||||||
remove
|
remove
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
<AutoRefreshControl
|
||||||
|
intervals={autoRefreshIntervals}
|
||||||
|
currentInterval={autoRefreshInterval}
|
||||||
|
onIntervalChange={onAutoRefreshIntervalChange}
|
||||||
|
/>
|
||||||
</DialogActions>;
|
</DialogActions>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,39 +23,39 @@ import type { Patch } from "models/Patch";
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
interface PackagePatchesListProps {
|
interface PackagePatchesListProps {
|
||||||
|
patches: Patch[];
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
onDelete: (key: string) => void;
|
onDelete: (key: string) => void;
|
||||||
patches: Patch[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackagePatchesList({
|
export default function PackagePatchesList({
|
||||||
|
patches,
|
||||||
editable,
|
editable,
|
||||||
onDelete,
|
onDelete,
|
||||||
patches,
|
|
||||||
}: PackagePatchesListProps): React.JSX.Element | null {
|
}: PackagePatchesListProps): React.JSX.Element | null {
|
||||||
if (patches.length === 0) {
|
if (patches.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Box sx={{ mt: 2 }}>
|
return <Box sx={{ mt: 2 }}>
|
||||||
<Typography gutterBottom variant="h6">Environment variables</Typography>
|
<Typography variant="h6" gutterBottom>Environment variables</Typography>
|
||||||
{patches.map(patch =>
|
{patches.map(patch =>
|
||||||
<Box key={patch.key} sx={{ alignItems: "center", display: "flex", gap: 1, mb: 0.5 }}>
|
<Box key={patch.key} sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
|
||||||
<TextField
|
<TextField
|
||||||
disabled
|
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ flex: 1 }}
|
|
||||||
value={patch.key}
|
value={patch.key}
|
||||||
|
disabled
|
||||||
|
sx={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<Box>=</Box>
|
<Box>=</Box>
|
||||||
<TextField
|
<TextField
|
||||||
disabled
|
|
||||||
value={JSON.stringify(patch.value)}
|
|
||||||
size="small"
|
size="small"
|
||||||
|
value={JSON.stringify(patch.value)}
|
||||||
|
disabled
|
||||||
sx={{ flex: 1 }}
|
sx={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
{editable &&
|
{editable &&
|
||||||
<IconButton aria-label="Remove patch" color="error" onClick={() => onDelete(patch.key)} size="small">
|
<IconButton size="small" color="error" aria-label="Remove patch" onClick={() => onDelete(patch.key)}>
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,5 +30,5 @@ interface PkgbuildTabProps {
|
|||||||
export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element {
|
export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element {
|
||||||
const data = usePackageChanges(packageBase, repository);
|
const data = usePackageChanges(packageBase, repository);
|
||||||
|
|
||||||
return <CodeBlock content={data?.pkgbuild ?? ""} height={400} language="bash" />;
|
return <CodeBlock language="bash" content={data?.pkgbuild ?? ""} height={400} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,11 @@
|
|||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
export type TabKey = "logs" | "changes" | "pkgbuild" | "events" | "artifacts";
|
export type TabKey = "logs" | "changes" | "pkgbuild" | "events";
|
||||||
|
|
||||||
export const tabs: { key: TabKey; label: string }[] = [
|
export const tabs: { key: TabKey; label: string }[] = [
|
||||||
{ key: "logs", label: "Build logs" },
|
{ key: "logs", label: "Build logs" },
|
||||||
{ key: "changes", label: "Changes" },
|
{ key: "changes", label: "Changes" },
|
||||||
{ key: "pkgbuild", label: "PKGBUILD" },
|
{ key: "pkgbuild", label: "PKGBUILD" },
|
||||||
{ key: "events", label: "Events" },
|
{ key: "events", label: "Events" },
|
||||||
{ key: "artifacts", label: "Artifacts" },
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
GRID_CHECKBOX_SELECTION_COL_DEF,
|
GRID_CHECKBOX_SELECTION_COL_DEF,
|
||||||
type GridColDef,
|
type GridColDef,
|
||||||
type GridFilterModel,
|
type GridFilterModel,
|
||||||
|
type GridRenderCellParams,
|
||||||
type GridRowId,
|
type GridRowId,
|
||||||
useGridApiRef,
|
useGridApiRef,
|
||||||
} from "@mui/x-data-grid";
|
} from "@mui/x-data-grid";
|
||||||
@@ -35,9 +36,16 @@ import PackageTableToolbar from "components/table/PackageTableToolbar";
|
|||||||
import StatusCell from "components/table/StatusCell";
|
import StatusCell from "components/table/StatusCell";
|
||||||
import { useDebounce } from "hooks/useDebounce";
|
import { useDebounce } from "hooks/useDebounce";
|
||||||
import { usePackageTable } from "hooks/usePackageTable";
|
import { usePackageTable } from "hooks/usePackageTable";
|
||||||
|
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
|
||||||
import type { PackageRow } from "models/PackageRow";
|
import type { PackageRow } from "models/PackageRow";
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
|
|
||||||
|
interface PackageTableProps {
|
||||||
|
autoRefreshIntervals: AutoRefreshInterval[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
function createListColumn(
|
function createListColumn(
|
||||||
field: keyof PackageRow,
|
field: keyof PackageRow,
|
||||||
headerName: string,
|
headerName: string,
|
||||||
@@ -47,15 +55,15 @@ function createListColumn(
|
|||||||
field,
|
field,
|
||||||
headerName,
|
headerName,
|
||||||
...options,
|
...options,
|
||||||
renderCell: params =>
|
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
||||||
|
renderCell: (params: GridRenderCellParams<PackageRow>) =>
|
||||||
<Box sx={{ whiteSpace: "pre-line" }}>{((params.row[field] as string[]) ?? []).join("\n")}</Box>,
|
<Box sx={{ whiteSpace: "pre-line" }}>{((params.row[field] as string[]) ?? []).join("\n")}</Box>,
|
||||||
sortComparator: (left: string, right: string) => left.localeCompare(right),
|
sortComparator: (left: string, right: string) => left.localeCompare(right),
|
||||||
valueGetter: (value: string[]) => (value ?? []).join(" "),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageTable(): React.JSX.Element {
|
export default function PackageTable({ autoRefreshIntervals }: PackageTableProps): React.JSX.Element {
|
||||||
const table = usePackageTable();
|
const table = usePackageTable(autoRefreshIntervals);
|
||||||
const apiRef = useGridApiRef();
|
const apiRef = useGridApiRef();
|
||||||
const debouncedSearch = useDebounce(table.searchText, 300);
|
const debouncedSearch = useDebounce(table.searchText, 300);
|
||||||
|
|
||||||
@@ -71,30 +79,36 @@ export default function PackageTable(): React.JSX.Element {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
field: "base",
|
field: "base",
|
||||||
flex: 1,
|
|
||||||
headerName: "package base",
|
headerName: "package base",
|
||||||
|
flex: 1,
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
renderCell: params =>
|
renderCell: (params: GridRenderCellParams<PackageRow>) =>
|
||||||
params.row.webUrl ?
|
params.row.webUrl ?
|
||||||
<Link href={params.row.webUrl} rel="noopener noreferrer" target="_blank" underline="hover">
|
<Link href={params.row.webUrl} target="_blank" rel="noopener noreferrer" underline="hover">
|
||||||
{params.value as string}
|
{params.value as string}
|
||||||
</Link>
|
</Link>
|
||||||
: params.value as string,
|
: params.value as string,
|
||||||
},
|
},
|
||||||
{ align: "right", field: "version", headerAlign: "right", headerName: "version", width: 180 },
|
{ field: "version", headerName: "version", width: 180, align: "right", headerAlign: "right" },
|
||||||
createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
|
createListColumn("packages", "packages", { flex: 1, minWidth: 120 }),
|
||||||
createListColumn("groups", "groups", { width: 150 }),
|
createListColumn("groups", "groups", { width: 150 }),
|
||||||
createListColumn("licenses", "licenses", { width: 150 }),
|
createListColumn("licenses", "licenses", { width: 150 }),
|
||||||
{ field: "packager", headerName: "packager", width: 150 },
|
{ field: "packager", headerName: "packager", width: 150 },
|
||||||
{ align: "right", field: "timestamp", headerName: "last update", headerAlign: "right", width: 180 },
|
|
||||||
{
|
{
|
||||||
align: "center",
|
field: "timestamp",
|
||||||
|
headerName: "last update",
|
||||||
|
width: 180,
|
||||||
|
align: "right",
|
||||||
|
headerAlign: "right",
|
||||||
|
},
|
||||||
|
{
|
||||||
field: "status",
|
field: "status",
|
||||||
headerAlign: "center",
|
|
||||||
headerName: "status",
|
headerName: "status",
|
||||||
renderCell: params =>
|
|
||||||
<StatusCell isHeld={params.row.isHeld} status={params.row.status} />,
|
|
||||||
width: 120,
|
width: 120,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
renderCell: (params: GridRenderCellParams<PackageRow>) =>
|
||||||
|
<StatusCell status={params.row.status} isHeld={params.row.isHeld} />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
@@ -102,37 +116,56 @@ export default function PackageTable(): React.JSX.Element {
|
|||||||
|
|
||||||
return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
return <Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||||
<PackageTableToolbar
|
<PackageTableToolbar
|
||||||
actions={{
|
|
||||||
onAddClick: () => table.setDialogOpen("add"),
|
|
||||||
onDashboardClick: () => table.setDialogOpen("dashboard"),
|
|
||||||
onExportClick: () => apiRef.current?.exportDataAsCsv(),
|
|
||||||
onKeyImportClick: () => table.setDialogOpen("keyImport"),
|
|
||||||
onRebuildClick: () => table.setDialogOpen("rebuild"),
|
|
||||||
onRefreshDatabaseClick: () => void table.handleRefreshDatabase(),
|
|
||||||
onReloadClick: table.handleReload,
|
|
||||||
onRemoveClick: () => void table.handleRemove(),
|
|
||||||
onUpdateClick: () => void table.handleUpdate(),
|
|
||||||
}}
|
|
||||||
isAuthorized={table.isAuthorized}
|
|
||||||
hasSelection={table.selectionModel.length > 0}
|
hasSelection={table.selectionModel.length > 0}
|
||||||
onSearchChange={table.setSearchText}
|
isAuthorized={table.isAuthorized}
|
||||||
searchText={table.searchText}
|
|
||||||
status={table.status}
|
status={table.status}
|
||||||
|
searchText={table.searchText}
|
||||||
|
onSearchChange={table.setSearchText}
|
||||||
|
autoRefresh={{
|
||||||
|
autoRefreshIntervals,
|
||||||
|
currentInterval: table.autoRefreshInterval,
|
||||||
|
onIntervalChange: table.onAutoRefreshIntervalChange,
|
||||||
|
}}
|
||||||
|
actions={{
|
||||||
|
onDashboardClick: () => table.setDialogOpen("dashboard"),
|
||||||
|
onAddClick: () => table.setDialogOpen("add"),
|
||||||
|
onUpdateClick: () => void table.handleUpdate(),
|
||||||
|
onRefreshDatabaseClick: () => void table.handleRefreshDatabase(),
|
||||||
|
onRebuildClick: () => table.setDialogOpen("rebuild"),
|
||||||
|
onRemoveClick: () => void table.handleRemove(),
|
||||||
|
onKeyImportClick: () => table.setDialogOpen("keyImport"),
|
||||||
|
onReloadClick: table.handleReload,
|
||||||
|
onExportClick: () => apiRef.current?.exportDataAsCsv(),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DataGrid
|
<DataGrid
|
||||||
apiRef={apiRef}
|
apiRef={apiRef}
|
||||||
checkboxSelection
|
rows={table.rows}
|
||||||
columnVisibilityModel={table.columnVisibility}
|
|
||||||
columns={columns}
|
columns={columns}
|
||||||
density="compact"
|
loading={table.isLoading}
|
||||||
disableRowSelectionOnClick
|
|
||||||
filterModel={effectiveFilterModel}
|
|
||||||
getRowHeight={() => "auto"}
|
getRowHeight={() => "auto"}
|
||||||
|
checkboxSelection
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
rowSelectionModel={{ type: "include", ids: new Set<GridRowId>(table.selectionModel) }}
|
||||||
|
onRowSelectionModelChange={model => {
|
||||||
|
if (model.type === "exclude") {
|
||||||
|
const excludeIds = new Set([...model.ids].map(String));
|
||||||
|
table.setSelectionModel(table.rows.map(row => row.id).filter(id => !excludeIds.has(id)));
|
||||||
|
} else {
|
||||||
|
table.setSelectionModel([...model.ids].map(String));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
paginationModel={table.paginationModel}
|
||||||
|
onPaginationModelChange={table.setPaginationModel}
|
||||||
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||||
|
columnVisibilityModel={table.columnVisibility}
|
||||||
|
onColumnVisibilityModelChange={table.setColumnVisibility}
|
||||||
|
filterModel={effectiveFilterModel}
|
||||||
|
onFilterModelChange={table.setFilterModel}
|
||||||
initialState={{
|
initialState={{
|
||||||
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
|
sorting: { sortModel: [{ field: "base", sort: "asc" }] },
|
||||||
}}
|
}}
|
||||||
loading={table.isLoading}
|
|
||||||
onCellClick={(params, event) => {
|
onCellClick={(params, event) => {
|
||||||
// Don't open info dialog when clicking checkbox or link
|
// Don't open info dialog when clicking checkbox or link
|
||||||
if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
|
if (params.field === GRID_CHECKBOX_SELECTION_COL_DEF.field) {
|
||||||
@@ -143,31 +176,22 @@ export default function PackageTable(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
table.setSelectedPackage(String(params.id));
|
table.setSelectedPackage(String(params.id));
|
||||||
}}
|
}}
|
||||||
onColumnVisibilityModelChange={table.setColumnVisibility}
|
sx={{
|
||||||
onFilterModelChange={table.setFilterModel}
|
flex: 1,
|
||||||
onPaginationModelChange={table.setPaginationModel}
|
"& .MuiDataGrid-row": { cursor: "pointer" },
|
||||||
onRowSelectionModelChange={model => {
|
|
||||||
if (model.type === "exclude") {
|
|
||||||
const excludeIds = new Set([...model.ids].map(String));
|
|
||||||
table.setSelectionModel(table.rows.map(row => row.id).filter(id => !excludeIds.has(id)));
|
|
||||||
} else {
|
|
||||||
table.setSelectionModel([...model.ids].map(String));
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
paginationModel={table.paginationModel}
|
density="compact"
|
||||||
rowSelectionModel={{ type: "include", ids: new Set<GridRowId>(table.selectionModel) }}
|
|
||||||
rows={table.rows}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DashboardDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "dashboard"} />
|
<DashboardDialog open={table.dialogOpen === "dashboard"} onClose={() => table.setDialogOpen(null)} />
|
||||||
<PackageAddDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "add"} />
|
<PackageAddDialog open={table.dialogOpen === "add"} onClose={() => table.setDialogOpen(null)} />
|
||||||
<PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} />
|
<PackageRebuildDialog open={table.dialogOpen === "rebuild"} onClose={() => table.setDialogOpen(null)} />
|
||||||
<KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} />
|
<KeyImportDialog open={table.dialogOpen === "keyImport"} onClose={() => table.setDialogOpen(null)} />
|
||||||
<PackageInfoDialog
|
<PackageInfoDialog
|
||||||
onClose={() => table.setSelectedPackage(null)}
|
|
||||||
open={table.selectedPackage !== null}
|
|
||||||
packageBase={table.selectedPackage}
|
packageBase={table.selectedPackage}
|
||||||
|
open={table.selectedPackage !== null}
|
||||||
|
onClose={() => table.setSelectedPackage(null)}
|
||||||
|
autoRefreshIntervals={autoRefreshIntervals}
|
||||||
/>
|
/>
|
||||||
</Box>;
|
</Box>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,50 +30,60 @@ import ReplayIcon from "@mui/icons-material/Replay";
|
|||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import VpnKeyIcon from "@mui/icons-material/VpnKey";
|
import VpnKeyIcon from "@mui/icons-material/VpnKey";
|
||||||
import { Box, Button, Divider, IconButton, InputAdornment, Menu, MenuItem, TextField, Tooltip } from "@mui/material";
|
import { Box, Button, Divider, IconButton, InputAdornment, Menu, MenuItem, TextField, Tooltip } from "@mui/material";
|
||||||
|
import AutoRefreshControl from "components/common/AutoRefreshControl";
|
||||||
|
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
|
||||||
import type { BuildStatus } from "models/BuildStatus";
|
import type { BuildStatus } from "models/BuildStatus";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { StatusColors } from "theme/StatusColors";
|
import { StatusColors } from "theme/StatusColors";
|
||||||
|
|
||||||
|
export interface AutoRefreshProps {
|
||||||
|
autoRefreshIntervals: AutoRefreshInterval[];
|
||||||
|
currentInterval: number;
|
||||||
|
onIntervalChange: (interval: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolbarActions {
|
export interface ToolbarActions {
|
||||||
onAddClick: () => void;
|
|
||||||
onDashboardClick: () => void;
|
onDashboardClick: () => void;
|
||||||
onExportClick: () => void;
|
onAddClick: () => void;
|
||||||
onKeyImportClick: () => void;
|
|
||||||
onRebuildClick: () => void;
|
|
||||||
onRefreshDatabaseClick: () => void;
|
|
||||||
onReloadClick: () => void;
|
|
||||||
onRemoveClick: () => void;
|
|
||||||
onUpdateClick: () => void;
|
onUpdateClick: () => void;
|
||||||
|
onRefreshDatabaseClick: () => void;
|
||||||
|
onRebuildClick: () => void;
|
||||||
|
onRemoveClick: () => void;
|
||||||
|
onKeyImportClick: () => void;
|
||||||
|
onReloadClick: () => void;
|
||||||
|
onExportClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PackageTableToolbarProps {
|
interface PackageTableToolbarProps {
|
||||||
actions: ToolbarActions;
|
|
||||||
hasSelection: boolean;
|
hasSelection: boolean;
|
||||||
isAuthorized: boolean;
|
isAuthorized: boolean;
|
||||||
onSearchChange: (text: string) => void;
|
|
||||||
searchText: string;
|
|
||||||
status?: BuildStatus;
|
status?: BuildStatus;
|
||||||
|
searchText: string;
|
||||||
|
onSearchChange: (text: string) => void;
|
||||||
|
autoRefresh: AutoRefreshProps;
|
||||||
|
actions: ToolbarActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageTableToolbar({
|
export default function PackageTableToolbar({
|
||||||
actions,
|
|
||||||
hasSelection,
|
hasSelection,
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
onSearchChange,
|
|
||||||
searchText,
|
|
||||||
status,
|
status,
|
||||||
|
searchText,
|
||||||
|
onSearchChange,
|
||||||
|
autoRefresh,
|
||||||
|
actions,
|
||||||
}: PackageTableToolbarProps): React.JSX.Element {
|
}: PackageTableToolbarProps): React.JSX.Element {
|
||||||
const [packagesAnchorEl, setPackagesAnchorEl] = useState<HTMLElement | null>(null);
|
const [packagesAnchorEl, setPackagesAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
return <Box sx={{ alignItems: "center", display: "flex", flexWrap: "wrap", gap: 1, mb: 1 }}>
|
return <Box sx={{ display: "flex", gap: 1, mb: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
<Tooltip title="System health">
|
<Tooltip title="System health">
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label="System health"
|
aria-label="System health"
|
||||||
onClick={actions.onDashboardClick}
|
onClick={actions.onDashboardClick}
|
||||||
sx={{
|
sx={{
|
||||||
borderColor: status ? StatusColors[status] : undefined,
|
borderColor: status ? StatusColors[status] : undefined,
|
||||||
borderStyle: "solid",
|
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
color: status ? StatusColors[status] : undefined,
|
color: status ? StatusColors[status] : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -84,16 +94,16 @@ export default function PackageTableToolbar({
|
|||||||
{isAuthorized &&
|
{isAuthorized &&
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={event => setPackagesAnchorEl(event.currentTarget)}
|
|
||||||
startIcon={<InventoryIcon />}
|
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
startIcon={<InventoryIcon />}
|
||||||
|
onClick={event => setPackagesAnchorEl(event.currentTarget)}
|
||||||
>
|
>
|
||||||
packages
|
packages
|
||||||
</Button>
|
</Button>
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={packagesAnchorEl}
|
anchorEl={packagesAnchorEl}
|
||||||
onClose={() => setPackagesAnchorEl(null)}
|
|
||||||
open={Boolean(packagesAnchorEl)}
|
open={Boolean(packagesAnchorEl)}
|
||||||
|
onClose={() => setPackagesAnchorEl(null)}
|
||||||
>
|
>
|
||||||
<MenuItem onClick={() => {
|
<MenuItem onClick={() => {
|
||||||
setPackagesAnchorEl(null); actions.onAddClick();
|
setPackagesAnchorEl(null); actions.onAddClick();
|
||||||
@@ -116,52 +126,58 @@ export default function PackageTableToolbar({
|
|||||||
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
|
<ReplayIcon fontSize="small" sx={{ mr: 1 }} /> rebuild
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
<MenuItem disabled={!hasSelection} onClick={() => {
|
<MenuItem onClick={() => {
|
||||||
setPackagesAnchorEl(null); actions.onRemoveClick();
|
setPackagesAnchorEl(null); actions.onRemoveClick();
|
||||||
}}>
|
}} disabled={!hasSelection}>
|
||||||
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
|
<DeleteIcon fontSize="small" sx={{ mr: 1 }} /> remove
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<Button color="info" onClick={actions.onKeyImportClick} startIcon={<VpnKeyIcon />} variant="contained">
|
<Button variant="contained" color="info" startIcon={<VpnKeyIcon />} onClick={actions.onKeyImportClick}>
|
||||||
import key
|
import key
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
<Button color="secondary" onClick={actions.onReloadClick} startIcon={<RefreshIcon />} variant="outlined">
|
<Button variant="outlined" color="secondary" startIcon={<RefreshIcon />} onClick={actions.onReloadClick}>
|
||||||
reload
|
reload
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<AutoRefreshControl
|
||||||
|
intervals={autoRefresh.autoRefreshIntervals}
|
||||||
|
currentInterval={autoRefresh.currentInterval}
|
||||||
|
onIntervalChange={autoRefresh.onIntervalChange}
|
||||||
|
/>
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1 }} />
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
aria-label="Search packages"
|
|
||||||
onChange={event => onSearchChange(event.target.value)}
|
|
||||||
placeholder="search packages..."
|
|
||||||
size="small"
|
size="small"
|
||||||
|
aria-label="Search packages"
|
||||||
|
placeholder="search packages..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={event => onSearchChange(event.target.value)}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
endAdornment: searchText ?
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<IconButton aria-label="Clear search" onClick={() => onSearchChange("")} size="small">
|
|
||||||
<ClearIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
: undefined,
|
|
||||||
startAdornment:
|
startAdornment:
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<SearchIcon fontSize="small" />
|
<SearchIcon fontSize="small" />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
,
|
,
|
||||||
|
endAdornment: searchText ?
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton size="small" aria-label="Clear search" onClick={() => onSearchChange("")}>
|
||||||
|
<ClearIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
sx={{ minWidth: 200 }}
|
sx={{ minWidth: 200 }}
|
||||||
value={searchText}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tooltip title="Export CSV">
|
<Tooltip title="Export CSV">
|
||||||
<IconButton aria-label="Export CSV" onClick={actions.onExportClick} size="small">
|
<IconButton size="small" aria-label="Export CSV" onClick={actions.onExportClick}>
|
||||||
<FileDownloadIcon fontSize="small" />
|
<FileDownloadIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ import type React from "react";
|
|||||||
import { StatusColors } from "theme/StatusColors";
|
import { StatusColors } from "theme/StatusColors";
|
||||||
|
|
||||||
interface StatusCellProps {
|
interface StatusCellProps {
|
||||||
isHeld?: boolean;
|
|
||||||
status: BuildStatus;
|
status: BuildStatus;
|
||||||
|
isHeld?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatusCell({ isHeld, status }: StatusCellProps): React.JSX.Element {
|
export default function StatusCell({ status, isHeld }: StatusCellProps): React.JSX.Element {
|
||||||
return <Chip
|
return <Chip
|
||||||
icon={isHeld ? <PauseCircleIcon /> : undefined}
|
icon={isHeld ? <PauseCircleIcon /> : undefined}
|
||||||
label={status}
|
label={status}
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ interface AuthState {
|
|||||||
|
|
||||||
export interface AuthContextValue extends AuthState {
|
export interface AuthContextValue extends AuthState {
|
||||||
isAuthorized: boolean;
|
isAuthorized: boolean;
|
||||||
|
setAuthState: (state: AuthState) => void;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
setAuthState: (state: AuthState) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ import React, { type ReactNode, useMemo } from "react";
|
|||||||
export function ClientProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
export function ClientProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
const client = useMemo(() => new AhrimanClient(), []);
|
const client = useMemo(() => new AhrimanClient(), []);
|
||||||
|
|
||||||
return <ClientContext.Provider value={client}>
|
return (
|
||||||
{children}
|
<ClientContext.Provider value={client}>
|
||||||
</ClientContext.Provider>;
|
{children}
|
||||||
|
</ClientContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +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 { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useEventStream } from "hooks/useEventStream";
|
|
||||||
import { useRepository } from "hooks/useRepository";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export function EventStreamProvider({ children }: { children: ReactNode }): ReactNode {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { currentRepository } = useRepository();
|
|
||||||
|
|
||||||
useEventStream(queryClient, currentRepository);
|
|
||||||
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
export interface NotificationContextValue {
|
export interface NotificationContextValue {
|
||||||
showError: (title: string, message: string) => void;
|
|
||||||
showSuccess: (title: string, message: string) => void;
|
showSuccess: (title: string, message: string) => void;
|
||||||
|
showError: (title: string, message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NotificationContext = createContext<NotificationContextValue | null>(null);
|
export const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||||
|
|||||||
@@ -55,17 +55,17 @@ export function NotificationProvider({ children }: { children: ReactNode }): Rea
|
|||||||
{children}
|
{children}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 16,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
zIndex: theme => theme.zIndex.snackbar,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
left: "50%",
|
|
||||||
maxWidth: 500,
|
maxWidth: 500,
|
||||||
pointerEvents: "none",
|
|
||||||
position: "fixed",
|
|
||||||
top: 16,
|
|
||||||
transform: "translateX(-50%)",
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
zIndex: theme => theme.zIndex.snackbar,
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{notifications.map(notification =>
|
{notifications.map(notification =>
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import type { RepositoryId } from "models/RepositoryId";
|
|||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
export interface RepositoryContextValue {
|
export interface RepositoryContextValue {
|
||||||
currentRepository: RepositoryId | null;
|
|
||||||
repositories: RepositoryId[];
|
repositories: RepositoryId[];
|
||||||
setCurrentRepository: (repository: RepositoryId) => void;
|
currentRepository: RepositoryId | null;
|
||||||
setRepositories: (repositories: RepositoryId[]) => void;
|
setRepositories: (repositories: RepositoryId[]) => void;
|
||||||
|
setCurrentRepository: (repository: RepositoryId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RepositoryContext = createContext<RepositoryContextValue | null>(null);
|
export const RepositoryContext = createContext<RepositoryContextValue | null>(null);
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ export function ThemeProvider({ children }: { children: React.ReactNode }): Reac
|
|||||||
const theme = useMemo(() => createAppTheme(mode), [mode]);
|
const theme = useMemo(() => createAppTheme(mode), [mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chartDefaults.color = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)";
|
const textColor = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)";
|
||||||
chartDefaults.borderColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
|
const gridColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
|
||||||
|
chartDefaults.color = textColor;
|
||||||
|
chartDefaults.borderColor = gridColor;
|
||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]);
|
const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]);
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ import type { RepositoryId } from "models/RepositoryId";
|
|||||||
|
|
||||||
export const QueryKeys = {
|
export const QueryKeys = {
|
||||||
|
|
||||||
artifacts: (packageBase: string, repository: RepositoryId) => ["artifacts", repository.key, packageBase] as const,
|
|
||||||
|
|
||||||
changes: (packageBase: string, repository: RepositoryId) => ["changes", repository.key, packageBase] as const,
|
changes: (packageBase: string, repository: RepositoryId) => ["changes", repository.key, packageBase] as const,
|
||||||
|
|
||||||
dependencies: (packageBase: string, repository: RepositoryId) => ["dependencies", repository.key, packageBase] as const,
|
dependencies: (packageBase: string, repository: RepositoryId) => ["dependencies", repository.key, packageBase] as const,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2021-2026 ahriman team.
|
||||||
|
*
|
||||||
|
* This file is part of ahriman
|
||||||
|
* (see https://github.com/arcan1s/ahriman).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||||
|
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface AutoRefreshResult {
|
||||||
|
interval: number;
|
||||||
|
setInterval: Dispatch<SetStateAction<number>>;
|
||||||
|
setPaused: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoRefresh(key: string, defaultInterval: number): AutoRefreshResult {
|
||||||
|
const storageKey = `ahriman-${key}`;
|
||||||
|
const [interval, setInterval] = useLocalStorage<number>(storageKey, defaultInterval);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
|
// Apply defaultInterval when it becomes available (e.g. after info endpoint loads)
|
||||||
|
// but only if the user hasn't explicitly set a preference
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultInterval > 0 && window.localStorage.getItem(storageKey) === null) {
|
||||||
|
setInterval(defaultInterval);
|
||||||
|
}
|
||||||
|
}, [storageKey, defaultInterval, setInterval]);
|
||||||
|
|
||||||
|
const effectiveInterval = paused ? 0 : interval;
|
||||||
|
|
||||||
|
return {
|
||||||
|
interval: effectiveInterval,
|
||||||
|
setInterval,
|
||||||
|
setPaused,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,10 +20,10 @@
|
|||||||
import { type RefObject, useCallback, useRef } from "react";
|
import { type RefObject, useCallback, useRef } from "react";
|
||||||
|
|
||||||
interface UseAutoScrollResult {
|
interface UseAutoScrollResult {
|
||||||
handleScroll: () => void;
|
|
||||||
preRef: RefObject<HTMLElement | null>;
|
preRef: RefObject<HTMLElement | null>;
|
||||||
resetScroll: () => void;
|
handleScroll: () => void;
|
||||||
scrollToBottom: () => void;
|
scrollToBottom: () => void;
|
||||||
|
resetScroll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAutoScroll(): UseAutoScrollResult {
|
export function useAutoScroll(): UseAutoScrollResult {
|
||||||
@@ -59,5 +59,5 @@ export function useAutoScroll(): UseAutoScrollResult {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { handleScroll, preRef, resetScroll, scrollToBottom };
|
return { preRef, handleScroll, scrollToBottom, resetScroll };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +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 type { QueryClient } from "@tanstack/react-query";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { buildEventStreamUrl } from "hooks/useEventStream";
|
|
||||||
import { useNotification } from "hooks/useNotification";
|
|
||||||
import type { LogRecord } from "models/LogRecord";
|
|
||||||
import type { RepositoryId } from "models/RepositoryId";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
interface BuildLogEvent {
|
|
||||||
created: number;
|
|
||||||
message: string;
|
|
||||||
process_id: string;
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendLogRecord(existing: LogRecord[] | undefined, record: LogRecord): LogRecord[] {
|
|
||||||
return [...existing ?? [], record];
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidateLogs(queryClient: QueryClient, repository: RepositoryId, packageBase: string): void {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["logs", repository.key, packageBase] });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBuildLogStream(packageBase: string, repository: RepositoryId): void {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { showError } = useNotification();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const source = new EventSource(buildEventStreamUrl(repository, ["build-log"], packageBase));
|
|
||||||
let needsRefresh = false;
|
|
||||||
|
|
||||||
source.addEventListener("error", () => {
|
|
||||||
needsRefresh = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
source.addEventListener("open", () => {
|
|
||||||
if (needsRefresh) {
|
|
||||||
invalidateLogs(queryClient, repository, packageBase);
|
|
||||||
needsRefresh = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
source.addEventListener("build-log", (event: MessageEvent<string>) => {
|
|
||||||
let data: BuildLogEvent;
|
|
||||||
try {
|
|
||||||
data = JSON.parse(event.data) as BuildLogEvent;
|
|
||||||
} catch {
|
|
||||||
showError("Live updates failed", "Could not parse build log event; refreshing logs.");
|
|
||||||
invalidateLogs(queryClient, repository, packageBase);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record: LogRecord = {
|
|
||||||
created: data.created,
|
|
||||||
message: data.message,
|
|
||||||
process_id: data.process_id,
|
|
||||||
version: data.version,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Append to the all-logs cache
|
|
||||||
queryClient.setQueryData<LogRecord[]>(
|
|
||||||
["logs", repository.key, packageBase],
|
|
||||||
existing => appendLogRecord(existing, record),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Append to the version-specific cache
|
|
||||||
queryClient.setQueryData<LogRecord[]>(
|
|
||||||
["logs", repository.key, packageBase, record.version, record.process_id],
|
|
||||||
existing => appendLogRecord(existing, record),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
source.close();
|
|
||||||
};
|
|
||||||
}, [queryClient, packageBase, repository, showError]);
|
|
||||||
}
|
|
||||||
@@ -1,127 +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 type { QueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNotification } from "hooks/useNotification";
|
|
||||||
import type { RepositoryId } from "models/RepositoryId";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
const GLOBAL_EVENT_TYPES = [
|
|
||||||
"package-held",
|
|
||||||
"package-outdated",
|
|
||||||
"package-removed",
|
|
||||||
"package-status-changed",
|
|
||||||
"package-update-failed",
|
|
||||||
"package-updated",
|
|
||||||
"service-status-changed",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function invalidateForEvent(
|
|
||||||
queryClient: QueryClient,
|
|
||||||
repositoryKey: string,
|
|
||||||
eventType: string,
|
|
||||||
objectId?: string,
|
|
||||||
): void {
|
|
||||||
switch (eventType) {
|
|
||||||
case "package-status-changed":
|
|
||||||
case "package-updated":
|
|
||||||
case "package-removed":
|
|
||||||
case "package-held":
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey] });
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["status", repositoryKey] });
|
|
||||||
if (objectId) {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey, objectId] });
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["events", repositoryKey, objectId] });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "service-status-changed":
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["status", repositoryKey] });
|
|
||||||
break;
|
|
||||||
case "package-outdated":
|
|
||||||
case "package-update-failed":
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey] });
|
|
||||||
if (objectId) {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey, objectId] });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidateRepository(queryClient: QueryClient, repositoryKey: string): void {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["packages", repositoryKey] });
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["status", repositoryKey] });
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["events", repositoryKey] });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildEventStreamUrl(
|
|
||||||
repository: RepositoryId,
|
|
||||||
events?: readonly string[],
|
|
||||||
objectId?: string,
|
|
||||||
): string {
|
|
||||||
const params = new URLSearchParams(repository.toQuery());
|
|
||||||
if (events) {
|
|
||||||
for (const event of events) {
|
|
||||||
params.append("event", event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (objectId) {
|
|
||||||
params.set("object_id", objectId);
|
|
||||||
}
|
|
||||||
return `/api/v1/events/stream?${params.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEventStream(queryClient: QueryClient, repository: RepositoryId | null): void {
|
|
||||||
const { showError } = useNotification();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!repository) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = new EventSource(buildEventStreamUrl(repository, GLOBAL_EVENT_TYPES));
|
|
||||||
let needsRefresh = false;
|
|
||||||
|
|
||||||
source.addEventListener("error", () => {
|
|
||||||
needsRefresh = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
source.addEventListener("open", () => {
|
|
||||||
if (needsRefresh) {
|
|
||||||
invalidateRepository(queryClient, repository.key);
|
|
||||||
needsRefresh = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const eventType of GLOBAL_EVENT_TYPES) {
|
|
||||||
source.addEventListener(eventType, (event: MessageEvent<string>) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data) as { object_id?: string };
|
|
||||||
invalidateForEvent(queryClient, repository.key, eventType, data.object_id ?? undefined);
|
|
||||||
} catch {
|
|
||||||
showError("Live updates failed", "Could not parse server event; refreshing data.");
|
|
||||||
invalidateRepository(queryClient, repository.key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
source.close();
|
|
||||||
};
|
|
||||||
}, [queryClient, repository, showError]);
|
|
||||||
}
|
|
||||||
@@ -26,10 +26,10 @@ import { useRepository } from "hooks/useRepository";
|
|||||||
import type { RepositoryId } from "models/RepositoryId";
|
import type { RepositoryId } from "models/RepositoryId";
|
||||||
|
|
||||||
export interface UsePackageActionsResult {
|
export interface UsePackageActionsResult {
|
||||||
handleRefreshDatabase: () => Promise<void>;
|
|
||||||
handleReload: () => void;
|
handleReload: () => void;
|
||||||
handleRemove: () => Promise<void>;
|
|
||||||
handleUpdate: () => Promise<void>;
|
handleUpdate: () => Promise<void>;
|
||||||
|
handleRefreshDatabase: () => Promise<void>;
|
||||||
|
handleRemove: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePackageActions(
|
export function usePackageActions(
|
||||||
@@ -63,7 +63,7 @@ export function usePackageActions(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReload = (): void => {
|
const handleReload: () => void = () => {
|
||||||
if (currentRepository !== null) {
|
if (currentRepository !== null) {
|
||||||
invalidate(currentRepository);
|
invalidate(currentRepository);
|
||||||
}
|
}
|
||||||
@@ -80,11 +80,11 @@ export function usePackageActions(
|
|||||||
|
|
||||||
const handleRefreshDatabase = (): Promise<void> => performAction(async (repository): Promise<string> => {
|
const handleRefreshDatabase = (): Promise<void> => performAction(async (repository): Promise<string> => {
|
||||||
await client.service.servicePackageUpdate(repository, {
|
await client.service.servicePackageUpdate(repository, {
|
||||||
|
packages: [],
|
||||||
|
refresh: true,
|
||||||
aur: false,
|
aur: false,
|
||||||
local: false,
|
local: false,
|
||||||
manual: false,
|
manual: false,
|
||||||
packages: [],
|
|
||||||
refresh: true,
|
|
||||||
});
|
});
|
||||||
return "Pacman database update has been requested";
|
return "Pacman database update has been requested";
|
||||||
}, "Could not update pacman databases");
|
}, "Could not update pacman databases");
|
||||||
@@ -100,9 +100,9 @@ export function usePackageActions(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleRefreshDatabase,
|
|
||||||
handleReload,
|
handleReload,
|
||||||
handleRemove,
|
|
||||||
handleUpdate,
|
handleUpdate,
|
||||||
|
handleRefreshDatabase,
|
||||||
|
handleRemove,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ export function usePackageChanges(packageBase: string, repository: RepositoryId)
|
|||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
|
||||||
const { data } = useQuery<Changes>({
|
const { data } = useQuery<Changes>({
|
||||||
enabled: !!packageBase,
|
|
||||||
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
|
|
||||||
queryKey: QueryKeys.changes(packageBase, repository),
|
queryKey: QueryKeys.changes(packageBase, repository),
|
||||||
|
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
|
||||||
|
enabled: !!packageBase,
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -20,40 +20,49 @@
|
|||||||
import { skipToken, useQuery } from "@tanstack/react-query";
|
import { skipToken, useQuery } from "@tanstack/react-query";
|
||||||
import { QueryKeys } from "hooks/QueryKeys";
|
import { QueryKeys } from "hooks/QueryKeys";
|
||||||
import { useAuth } from "hooks/useAuth";
|
import { useAuth } from "hooks/useAuth";
|
||||||
|
import { useAutoRefresh } from "hooks/useAutoRefresh";
|
||||||
import { useClient } from "hooks/useClient";
|
import { useClient } from "hooks/useClient";
|
||||||
import { useRepository } from "hooks/useRepository";
|
import { useRepository } from "hooks/useRepository";
|
||||||
|
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
|
||||||
import type { BuildStatus } from "models/BuildStatus";
|
import type { BuildStatus } from "models/BuildStatus";
|
||||||
import { PackageRow } from "models/PackageRow";
|
import { PackageRow } from "models/PackageRow";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { defaultInterval } from "utils";
|
||||||
|
|
||||||
export interface UsePackageDataResult {
|
export interface UsePackageDataResult {
|
||||||
isAuthorized: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
rows: PackageRow[];
|
rows: PackageRow[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthorized: boolean;
|
||||||
status: BuildStatus | undefined;
|
status: BuildStatus | undefined;
|
||||||
|
autoRefresh: ReturnType<typeof useAutoRefresh>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePackageData(): UsePackageDataResult {
|
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const { currentRepository } = useRepository();
|
const { currentRepository } = useRepository();
|
||||||
const { isAuthorized } = useAuth();
|
const { isAuthorized } = useAuth();
|
||||||
|
|
||||||
|
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
|
||||||
|
|
||||||
const { data: packages = [], isLoading } = useQuery({
|
const { data: packages = [], isLoading } = useQuery({
|
||||||
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
|
|
||||||
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
|
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
|
||||||
|
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
|
||||||
|
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: status } = useQuery({
|
const { data: status } = useQuery({
|
||||||
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
|
|
||||||
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
|
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
|
||||||
|
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
|
||||||
|
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);
|
const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
rows,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
rows,
|
|
||||||
status: status?.status.status,
|
status: status?.status.status,
|
||||||
|
autoRefresh,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,45 +21,66 @@ import type { GridFilterModel } from "@mui/x-data-grid";
|
|||||||
import { usePackageActions } from "hooks/usePackageActions";
|
import { usePackageActions } from "hooks/usePackageActions";
|
||||||
import { usePackageData } from "hooks/usePackageData";
|
import { usePackageData } from "hooks/usePackageData";
|
||||||
import { useTableState } from "hooks/useTableState";
|
import { useTableState } from "hooks/useTableState";
|
||||||
|
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
|
||||||
import type { BuildStatus } from "models/BuildStatus";
|
import type { BuildStatus } from "models/BuildStatus";
|
||||||
import type { PackageRow } from "models/PackageRow";
|
import type { PackageRow } from "models/PackageRow";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export interface UsePackageTableResult {
|
export interface UsePackageTableResult {
|
||||||
columnVisibility: Record<string, boolean>;
|
|
||||||
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
|
|
||||||
filterModel: GridFilterModel;
|
|
||||||
handleRefreshDatabase: () => Promise<void>;
|
|
||||||
handleReload: () => void;
|
|
||||||
handleRemove: () => Promise<void>;
|
|
||||||
handleUpdate: () => Promise<void>;
|
|
||||||
isAuthorized: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
paginationModel: { page: number; pageSize: number };
|
|
||||||
rows: PackageRow[];
|
rows: PackageRow[];
|
||||||
searchText: string;
|
isLoading: boolean;
|
||||||
selectedPackage: string | null;
|
isAuthorized: boolean;
|
||||||
selectionModel: string[];
|
|
||||||
setColumnVisibility: (model: Record<string, boolean>) => void;
|
|
||||||
setDialogOpen: (dialog: "dashboard" | "add" | "rebuild" | "keyImport" | null) => void;
|
|
||||||
setFilterModel: (model: GridFilterModel) => void;
|
|
||||||
setPaginationModel: (model: { page: number; pageSize: number }) => void;
|
|
||||||
setSearchText: (text: string) => void;
|
|
||||||
setSelectedPackage: (base: string | null) => void;
|
|
||||||
setSelectionModel: (model: string[]) => void;
|
|
||||||
status: BuildStatus | undefined;
|
status: BuildStatus | undefined;
|
||||||
|
|
||||||
|
selectionModel: string[];
|
||||||
|
setSelectionModel: (model: string[]) => void;
|
||||||
|
|
||||||
|
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
|
||||||
|
setDialogOpen: (dialog: "dashboard" | "add" | "rebuild" | "keyImport" | null) => void;
|
||||||
|
selectedPackage: string | null;
|
||||||
|
setSelectedPackage: (base: string | null) => void;
|
||||||
|
|
||||||
|
paginationModel: { pageSize: number; page: number };
|
||||||
|
setPaginationModel: (model: { pageSize: number; page: number }) => void;
|
||||||
|
columnVisibility: Record<string, boolean>;
|
||||||
|
setColumnVisibility: (model: Record<string, boolean>) => void;
|
||||||
|
filterModel: GridFilterModel;
|
||||||
|
setFilterModel: (model: GridFilterModel) => void;
|
||||||
|
searchText: string;
|
||||||
|
setSearchText: (text: string) => void;
|
||||||
|
|
||||||
|
autoRefreshInterval: number;
|
||||||
|
onAutoRefreshIntervalChange: (interval: number) => void;
|
||||||
|
|
||||||
|
handleReload: () => void;
|
||||||
|
handleUpdate: () => Promise<void>;
|
||||||
|
handleRefreshDatabase: () => Promise<void>;
|
||||||
|
handleRemove: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePackageTable(): UsePackageTableResult {
|
export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
|
||||||
const { rows, isLoading, isAuthorized, status } = usePackageData();
|
const { rows, isLoading, isAuthorized, status, autoRefresh } = usePackageData(autoRefreshIntervals);
|
||||||
const tableState = useTableState();
|
const tableState = useTableState();
|
||||||
const actions = usePackageActions(tableState.selectionModel, tableState.setSelectionModel);
|
const actions = usePackageActions(tableState.selectionModel, tableState.setSelectionModel);
|
||||||
|
|
||||||
|
// Pause auto-refresh when dialog is open
|
||||||
|
const isDialogOpen = tableState.dialogOpen !== null || tableState.selectedPackage !== null;
|
||||||
|
const setPaused = autoRefresh.setPaused;
|
||||||
|
useEffect(() => {
|
||||||
|
setPaused(isDialogOpen);
|
||||||
|
}, [isDialogOpen, setPaused]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
rows,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
rows,
|
|
||||||
status,
|
status,
|
||||||
...actions,
|
|
||||||
...tableState,
|
...tableState,
|
||||||
|
|
||||||
|
autoRefreshInterval: autoRefresh.interval,
|
||||||
|
onAutoRefreshIntervalChange: autoRefresh.setInterval,
|
||||||
|
|
||||||
|
...actions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user