This commit is contained in:
2026-04-27 12:56:08 +03:00
parent 4934205b9e
commit a713c67948
21 changed files with 257 additions and 245 deletions
+3
View File
@@ -21,6 +21,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AppLayout from "components/layout/AppLayout";
import { AuthProvider } from "contexts/AuthProvider";
import { ClientProvider } from "contexts/ClientProvider";
import { EventStreamProvider } from "contexts/EventStreamProvider";
import { NotificationProvider } from "contexts/NotificationProvider";
import { RepositoryProvider } from "contexts/RepositoryProvider";
import { ThemeProvider } from "contexts/ThemeProvider";
@@ -42,7 +43,9 @@ export default function App(): React.JSX.Element {
<ClientProvider>
<AuthProvider>
<RepositoryProvider>
<EventStreamProvider>
<AppLayout />
</EventStreamProvider>
</RepositoryProvider>
</AuthProvider>
</ClientProvider>
@@ -1,91 +0,0 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import CheckIcon from "@mui/icons-material/Check";
import TimerIcon from "@mui/icons-material/Timer";
import TimerOffIcon from "@mui/icons-material/TimerOff";
import { IconButton, ListItemIcon, ListItemText, Menu, MenuItem, Tooltip } from "@mui/material";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import React, { useState } from "react";
interface AutoRefreshControlProps {
currentInterval: number;
intervals: AutoRefreshInterval[];
onIntervalChange: (interval: number) => void;
}
export default function AutoRefreshControl({
currentInterval,
intervals,
onIntervalChange,
}: AutoRefreshControlProps): React.JSX.Element | null {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
if (intervals.length === 0) {
return null;
}
const enabled = currentInterval > 0;
return <>
<Tooltip title="Auto-refresh">
<IconButton
aria-label="Auto-refresh"
color={enabled ? "primary" : "default"}
onClick={event => setAnchorEl(event.currentTarget)}
size="small"
>
{enabled ? <TimerIcon fontSize="small" /> : <TimerOffIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
open={Boolean(anchorEl)}
>
<MenuItem
onClick={() => {
onIntervalChange(0);
setAnchorEl(null);
}}
selected={!enabled}
>
<ListItemIcon>
{!enabled && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>Off</ListItemText>
</MenuItem>
{intervals.map(interval =>
<MenuItem
key={interval.interval}
onClick={() => {
onIntervalChange(interval.interval);
setAnchorEl(null);
}}
selected={enabled && interval.interval === currentInterval}
>
<ListItemIcon>
{enabled && interval.interval === currentInterval && <CheckIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>{interval.text}</ListItemText>
</MenuItem>,
)}
</Menu>
</>;
}
@@ -32,27 +32,22 @@ import PkgbuildTab from "components/package/PkgbuildTab";
import { type TabKey, tabs } from "components/package/TabKey";
import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { useClient } from "hooks/useClient";
import { useNotification } from "hooks/useNotification";
import { useRepository } from "hooks/useRepository";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { Dependencies } from "models/Dependencies";
import type { PackageStatus } from "models/PackageStatus";
import type { Patch } from "models/Patch";
import React, { useState } from "react";
import { StatusHeaderStyles } from "theme/StatusColors";
import { defaultInterval } from "utils";
interface PackageInfoDialogProps {
autoRefreshIntervals: AutoRefreshInterval[];
onClose: () => void;
open: boolean;
packageBase: string | null;
}
export default function PackageInfoDialog({
autoRefreshIntervals,
onClose,
open,
packageBase,
@@ -77,14 +72,11 @@ export default function PackageInfoDialog({
onClose();
};
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packageData } = useQuery<PackageStatus[]>({
enabled: open,
queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
});
const { data: dependencies } = useQuery<Dependencies>({
@@ -182,7 +174,6 @@ export default function PackageInfoDialog({
{activeTab === "logs" && localPackageBase && currentRepository &&
<BuildLogsTab
packageBase={localPackageBase}
refreshInterval={autoRefresh.interval}
repository={currentRepository}
/>
}
@@ -207,11 +198,8 @@ export default function PackageInfoDialog({
</DialogContent>
<PackageInfoActions
autoRefreshInterval={autoRefresh.interval}
autoRefreshIntervals={autoRefreshIntervals}
isAuthorized={isAuthorized}
isHeld={status?.is_held ?? false}
onAutoRefreshIntervalChange={autoRefresh.setInterval}
onHoldToggle={() => void handleHoldToggle()}
onRefreshDatabaseChange={setRefreshDatabase}
onRemove={() => void handleRemove()}
+1 -1
View File
@@ -69,7 +69,7 @@ export default function AppLayout(): React.JSX.Element {
</Tooltip>
</Box>
<PackageTable autoRefreshIntervals={info?.autorefresh_intervals ?? []} />
<PackageTable />
<Footer
docsEnabled={info?.docs_enabled ?? false}
@@ -23,6 +23,7 @@ import { keepPreviousData, skipToken, useQuery } from "@tanstack/react-query";
import CodeBlock from "components/common/CodeBlock";
import { QueryKeys } from "hooks/QueryKeys";
import { useAutoScroll } from "hooks/useAutoScroll";
import { useBuildLogStream } from "hooks/useBuildLogStream";
import { useClient } from "hooks/useClient";
import type { LogRecord } from "models/LogRecord";
import type { RepositoryId } from "models/RepositoryId";
@@ -37,7 +38,6 @@ interface Logs {
interface BuildLogsTabProps {
packageBase: string;
refreshInterval: number;
repository: RepositoryId;
}
@@ -50,10 +50,10 @@ function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boole
export default function BuildLogsTab({
packageBase,
refreshInterval,
repository,
}: BuildLogsTabProps): React.JSX.Element {
const client = useClient();
useBuildLogStream(packageBase, repository);
const [selectedVersionKey, setSelectedVersionKey] = useState<string | null>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
@@ -61,7 +61,6 @@ export default function BuildLogsTab({
enabled: !!packageBase,
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
queryKey: QueryKeys.logs(packageBase, repository),
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
});
// Build version selectors from all logs
@@ -117,7 +116,6 @@ export default function BuildLogsTab({
)
: skipToken,
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
});
// Derive displayed logs: prefer fresh polled data when available
@@ -22,16 +22,11 @@ import PauseCircleIcon from "@mui/icons-material/PauseCircle";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PlayCircleIcon from "@mui/icons-material/PlayCircle";
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";
interface PackageInfoActionsProps {
autoRefreshInterval: number;
autoRefreshIntervals: AutoRefreshInterval[];
isAuthorized: boolean;
isHeld: boolean;
onAutoRefreshIntervalChange: (interval: number) => void;
onHoldToggle: () => void;
onRefreshDatabaseChange: (checked: boolean) => void;
onRemove: () => void;
@@ -40,11 +35,8 @@ interface PackageInfoActionsProps {
}
export default function PackageInfoActions({
autoRefreshInterval,
autoRefreshIntervals,
isAuthorized,
isHeld,
onAutoRefreshIntervalChange,
onHoldToggle,
onRefreshDatabaseChange,
onRemove,
@@ -69,10 +61,5 @@ export default function PackageInfoActions({
</Button>
</>
}
<AutoRefreshControl
currentInterval={autoRefreshInterval}
intervals={autoRefreshIntervals}
onIntervalChange={onAutoRefreshIntervalChange}
/>
</DialogActions>;
}
+2 -13
View File
@@ -35,14 +35,9 @@ import PackageTableToolbar from "components/table/PackageTableToolbar";
import StatusCell from "components/table/StatusCell";
import { useDebounce } from "hooks/useDebounce";
import { usePackageTable } from "hooks/usePackageTable";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { PackageRow } from "models/PackageRow";
import React, { useMemo } from "react";
interface PackageTableProps {
autoRefreshIntervals: AutoRefreshInterval[];
}
function createListColumn(
field: keyof PackageRow,
headerName: string,
@@ -59,8 +54,8 @@ function createListColumn(
};
}
export default function PackageTable({ autoRefreshIntervals }: PackageTableProps): React.JSX.Element {
const table = usePackageTable(autoRefreshIntervals);
export default function PackageTable(): React.JSX.Element {
const table = usePackageTable();
const apiRef = useGridApiRef();
const debouncedSearch = useDebounce(table.searchText, 300);
@@ -118,11 +113,6 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
onRemoveClick: () => void table.handleRemove(),
onUpdateClick: () => void table.handleUpdate(),
}}
autoRefresh={{
autoRefreshIntervals,
currentInterval: table.autoRefreshInterval,
onIntervalChange: table.onAutoRefreshIntervalChange,
}}
isAuthorized={table.isAuthorized}
hasSelection={table.selectionModel.length > 0}
onSearchChange={table.setSearchText}
@@ -175,7 +165,6 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
<PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} />
<KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} />
<PackageInfoDialog
autoRefreshIntervals={autoRefreshIntervals}
onClose={() => table.setSelectedPackage(null)}
open={table.selectedPackage !== null}
packageBase={table.selectedPackage}
@@ -30,18 +30,10 @@ import ReplayIcon from "@mui/icons-material/Replay";
import SearchIcon from "@mui/icons-material/Search";
import VpnKeyIcon from "@mui/icons-material/VpnKey";
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 React, { useState } from "react";
import { StatusColors } from "theme/StatusColors";
export interface AutoRefreshProps {
autoRefreshIntervals: AutoRefreshInterval[];
currentInterval: number;
onIntervalChange: (interval: number) => void;
}
export interface ToolbarActions {
onAddClick: () => void;
onDashboardClick: () => void;
@@ -56,7 +48,6 @@ export interface ToolbarActions {
interface PackageTableToolbarProps {
actions: ToolbarActions;
autoRefresh: AutoRefreshProps;
hasSelection: boolean;
isAuthorized: boolean;
onSearchChange: (text: string) => void;
@@ -66,7 +57,6 @@ interface PackageTableToolbarProps {
export default function PackageTableToolbar({
actions,
autoRefresh,
hasSelection,
isAuthorized,
onSearchChange,
@@ -143,12 +133,6 @@ export default function PackageTableToolbar({
reload
</Button>
<AutoRefreshControl
currentInterval={autoRefresh.currentInterval}
intervals={autoRefresh.autoRefreshIntervals}
onIntervalChange={autoRefresh.onIntervalChange}
/>
<Box sx={{ flexGrow: 1 }} />
<TextField
@@ -17,8 +17,16 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export interface AutoRefreshInterval {
interval: number;
is_active: boolean;
text: string;
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;
}
-49
View File
@@ -1,49 +0,0 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useLocalStorage } from "hooks/useLocalStorage";
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
interface AutoRefreshResult {
interval: number;
setInterval: Dispatch<SetStateAction<number>>;
setPaused: Dispatch<SetStateAction<boolean>>;
}
export function useAutoRefresh(key: string, defaultInterval: number): AutoRefreshResult {
const storageKey = `ahriman-${key}`;
const [interval, setInterval] = useLocalStorage<number>(storageKey, defaultInterval);
const [paused, setPaused] = useState(false);
// Apply defaultInterval when it becomes available (e.g. after info endpoint loads)
// but only if the user hasn't explicitly set a preference
useEffect(() => {
if (defaultInterval > 0 && window.localStorage.getItem(storageKey) === null) {
setInterval(defaultInterval);
}
}, [storageKey, defaultInterval, setInterval]);
const effectiveInterval = paused ? 0 : interval;
return {
interval: effectiveInterval,
setInterval,
setPaused,
};
}
+70
View File
@@ -0,0 +1,70 @@
/*
* 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 { buildEventStreamUrl } from "hooks/useEventStream";
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];
}
export function useBuildLogStream(packageBase: string, repository: RepositoryId): void {
const queryClient = useQueryClient();
useEffect(() => {
const source = new EventSource(buildEventStreamUrl(repository, ["build-log"], packageBase));
source.addEventListener("build-log", (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as BuildLogEvent;
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]);
}
+101
View File
@@ -0,0 +1,101 @@
/*
* 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 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;
}
}
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 {
useEffect(() => {
if (!repository) {
return;
}
const source = new EventSource(buildEventStreamUrl(repository, GLOBAL_EVENT_TYPES));
for (const eventType of GLOBAL_EVENT_TYPES) {
source.addEventListener(eventType, (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as { object_id?: string };
invalidateForEvent(queryClient, repository.key, eventType, data.object_id ?? undefined);
});
}
return () => {
source.close();
};
}, [queryClient, repository]);
}
+1 -10
View File
@@ -20,46 +20,37 @@
import { skipToken, useQuery } from "@tanstack/react-query";
import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth";
import { useAutoRefresh } from "hooks/useAutoRefresh";
import { useClient } from "hooks/useClient";
import { useRepository } from "hooks/useRepository";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { BuildStatus } from "models/BuildStatus";
import { PackageRow } from "models/PackageRow";
import { useMemo } from "react";
import { defaultInterval } from "utils";
export interface UsePackageDataResult {
autoRefresh: ReturnType<typeof useAutoRefresh>;
isAuthorized: boolean;
isLoading: boolean;
rows: PackageRow[];
status: BuildStatus | undefined;
}
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
export function usePackageData(): UsePackageDataResult {
const client = useClient();
const { currentRepository } = useRepository();
const { isAuthorized } = useAuth();
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packages = [], isLoading } = useQuery({
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
});
const { data: status } = useQuery({
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
});
const rows = useMemo(() => packages.map(descriptor => new PackageRow(descriptor)), [packages]);
return {
autoRefresh,
isLoading,
isAuthorized,
rows,
+2 -15
View File
@@ -21,13 +21,10 @@ import type { GridFilterModel } from "@mui/x-data-grid";
import { usePackageActions } from "hooks/usePackageActions";
import { usePackageData } from "hooks/usePackageData";
import { useTableState } from "hooks/useTableState";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { BuildStatus } from "models/BuildStatus";
import type { PackageRow } from "models/PackageRow";
import { useEffect } from "react";
export interface UsePackageTableResult {
autoRefreshInterval: number;
columnVisibility: Record<string, boolean>;
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
filterModel: GridFilterModel;
@@ -37,7 +34,6 @@ export interface UsePackageTableResult {
handleUpdate: () => Promise<void>;
isAuthorized: boolean;
isLoading: boolean;
onAutoRefreshIntervalChange: (interval: number) => void;
paginationModel: { page: number; pageSize: number };
rows: PackageRow[];
searchText: string;
@@ -53,23 +49,14 @@ export interface UsePackageTableResult {
status: BuildStatus | undefined;
}
export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
const { rows, isLoading, isAuthorized, status, autoRefresh } = usePackageData(autoRefreshIntervals);
export function usePackageTable(): UsePackageTableResult {
const { rows, isLoading, isAuthorized, status } = usePackageData();
const tableState = useTableState();
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 {
autoRefreshInterval: autoRefresh.interval,
isLoading,
isAuthorized,
onAutoRefreshIntervalChange: autoRefresh.setInterval,
rows,
status,
...actions,
-2
View File
@@ -18,12 +18,10 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type { AuthInfo } from "models/AuthInfo";
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
import type { RepositoryId } from "models/RepositoryId";
export interface InfoResponse {
auth: AuthInfo;
autorefresh_intervals: AutoRefreshInterval[];
docs_enabled: boolean;
index_url?: string;
repositories: RepositoryId[];
-6
View File
@@ -17,8 +17,6 @@
* 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 { AutoRefreshInterval } from "models/AutoRefreshInterval";
export const DETAIL_TABLE_PROPS = {
density: "compact" as const,
disableColumnSorting: true,
@@ -27,10 +25,6 @@ export const DETAIL_TABLE_PROPS = {
sx: { height: 400, mt: 1 },
};
export function defaultInterval(intervals: AutoRefreshInterval[]): number {
return intervals.find(interval => interval.is_active)?.interval ?? 0;
}
declare global {
interface Array<T> {
unique(): T[];
+11 -6
View File
@@ -45,7 +45,7 @@ class EventBus(LazyLogging):
self.max_size = max_size
self._lock = Lock()
self._subscribers: dict[str, tuple[list[EventType] | None, Queue[SSEvent | None]]] = {}
self._subscribers: dict[str, tuple[list[EventType] | None, str | None, Queue[SSEvent | None]]] = {}
async def broadcast(self, event_type: EventType, object_id: str | None, **kwargs: Any) -> None:
"""
@@ -60,9 +60,11 @@ class EventBus(LazyLogging):
event.update(kwargs)
async with self._lock:
for subscriber_id, (topics, queue) in self._subscribers.items():
for subscriber_id, (topics, filter_object_id, queue) in self._subscribers.items():
if topics is not None and event_type not in topics:
continue
if filter_object_id is not None and object_id != filter_object_id:
continue
try:
queue.put_nowait((event_type, event))
except QueueFull:
@@ -73,20 +75,23 @@ class EventBus(LazyLogging):
gracefully shutdown all subscribers
"""
async with self._lock:
for _, queue in self._subscribers.values():
for _, _, queue in self._subscribers.values():
try:
queue.put_nowait(None)
except QueueFull:
pass
queue.shutdown()
async def subscribe(self, topics: list[EventType] | None = None) -> tuple[str, Queue[SSEvent | None]]:
async def subscribe(self, topics: list[EventType] | None = None,
object_id: str | None = None) -> tuple[str, Queue[SSEvent | None]]:
"""
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 | None]]: subscriber identifier and associated queue
@@ -95,7 +100,7 @@ class EventBus(LazyLogging):
queue: Queue[SSEvent | None] = Queue(self.max_size)
async with self._lock:
self._subscribers[subscriber_id] = (topics, queue)
self._subscribers[subscriber_id] = (topics, object_id, queue)
return subscriber_id, queue
@@ -109,5 +114,5 @@ class EventBus(LazyLogging):
async with self._lock:
result = self._subscribers.pop(subscriber_id, None)
if result is not None:
_, queue = result
_, _, queue = result
queue.shutdown()
@@ -31,3 +31,7 @@ class EventBusFilterSchema(RepositoryIdSchema):
"description": "Event type filter",
"example": [EventType.PackageUpdated],
})
object_id = fields.String(metadata={
"description": "Object identifier filter",
"example": "ahriman",
})
@@ -88,10 +88,11 @@ class EventBusView(BaseView):
topics = [EventType(event) for event in self.request.query.getall("event", [])] or None
except ValueError as ex:
raise HTTPBadRequest(reason=str(ex))
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)
subscription_id, queue = await event_bus.subscribe(topics, object_id=object_id)
try:
await self._run(response, queue)
+28 -1
View File
@@ -82,15 +82,42 @@ async def test_subscribe(event_bus: EventBus) -> None:
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])
topics, _ = event_bus._subscribers[subscriber_id]
topics, _, _ = event_bus._subscribers[subscriber_id]
assert topics == [EventType.BuildLog]
async def test_subscribe_with_object_id(event_bus: EventBus, package_ahriman: Package) -> None:
"""
must register subscriber with object_id filter
"""
subscriber_id, _ = await event_bus.subscribe(object_id=package_ahriman.base)
_, object_id, _ = event_bus._subscribers[subscriber_id]
assert object_id == package_ahriman.base
async def test_unsubscribe(event_bus: EventBus) -> None:
"""
must remove subscriber
@@ -99,6 +99,23 @@ async def test_get_with_topic_filter(client: TestClient, package_ahriman: Packag
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