mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-05-03 06:26:33 +00:00
zz
This commit is contained in:
@@ -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()}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-4
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user