mirror of
https://github.com/arcan1s/ahriman.git
synced 2026-05-02 22:16:34 +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 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";
|
||||||
@@ -42,7 +43,9 @@ export default function App(): React.JSX.Element {
|
|||||||
<ClientProvider>
|
<ClientProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<RepositoryProvider>
|
<RepositoryProvider>
|
||||||
|
<EventStreamProvider>
|
||||||
<AppLayout />
|
<AppLayout />
|
||||||
|
</EventStreamProvider>
|
||||||
</RepositoryProvider>
|
</RepositoryProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ClientProvider>
|
</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 { 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 {
|
||||||
autoRefreshIntervals: AutoRefreshInterval[];
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
packageBase: string | null;
|
packageBase: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageInfoDialog({
|
export default function PackageInfoDialog({
|
||||||
autoRefreshIntervals,
|
|
||||||
onClose,
|
onClose,
|
||||||
open,
|
open,
|
||||||
packageBase,
|
packageBase,
|
||||||
@@ -77,14 +72,11 @@ 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,
|
enabled: open,
|
||||||
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"],
|
queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
|
||||||
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: dependencies } = useQuery<Dependencies>({
|
const { data: dependencies } = useQuery<Dependencies>({
|
||||||
@@ -182,7 +174,6 @@ export default function PackageInfoDialog({
|
|||||||
{activeTab === "logs" && localPackageBase && currentRepository &&
|
{activeTab === "logs" && localPackageBase && currentRepository &&
|
||||||
<BuildLogsTab
|
<BuildLogsTab
|
||||||
packageBase={localPackageBase}
|
packageBase={localPackageBase}
|
||||||
refreshInterval={autoRefresh.interval}
|
|
||||||
repository={currentRepository}
|
repository={currentRepository}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -207,11 +198,8 @@ export default function PackageInfoDialog({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<PackageInfoActions
|
<PackageInfoActions
|
||||||
autoRefreshInterval={autoRefresh.interval}
|
|
||||||
autoRefreshIntervals={autoRefreshIntervals}
|
|
||||||
isAuthorized={isAuthorized}
|
isAuthorized={isAuthorized}
|
||||||
isHeld={status?.is_held ?? false}
|
isHeld={status?.is_held ?? false}
|
||||||
onAutoRefreshIntervalChange={autoRefresh.setInterval}
|
|
||||||
onHoldToggle={() => void handleHoldToggle()}
|
onHoldToggle={() => void handleHoldToggle()}
|
||||||
onRefreshDatabaseChange={setRefreshDatabase}
|
onRefreshDatabaseChange={setRefreshDatabase}
|
||||||
onRemove={() => void handleRemove()}
|
onRemove={() => void handleRemove()}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function AppLayout(): React.JSX.Element {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<PackageTable autoRefreshIntervals={info?.autorefresh_intervals ?? []} />
|
<PackageTable />
|
||||||
|
|
||||||
<Footer
|
<Footer
|
||||||
docsEnabled={info?.docs_enabled ?? false}
|
docsEnabled={info?.docs_enabled ?? false}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ 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";
|
||||||
@@ -37,7 +38,6 @@ interface Logs {
|
|||||||
|
|
||||||
interface BuildLogsTabProps {
|
interface BuildLogsTabProps {
|
||||||
packageBase: string;
|
packageBase: string;
|
||||||
refreshInterval: number;
|
|
||||||
repository: RepositoryId;
|
repository: RepositoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +50,10 @@ function convertLogs(records: LogRecord[], filter?: (record: LogRecord) => boole
|
|||||||
|
|
||||||
export default function BuildLogsTab({
|
export default function BuildLogsTab({
|
||||||
packageBase,
|
packageBase,
|
||||||
refreshInterval,
|
|
||||||
repository,
|
repository,
|
||||||
}: 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);
|
||||||
|
|
||||||
@@ -61,7 +61,6 @@ export default function BuildLogsTab({
|
|||||||
enabled: !!packageBase,
|
enabled: !!packageBase,
|
||||||
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
|
queryFn: () => client.fetch.fetchPackageLogs(packageBase, repository),
|
||||||
queryKey: QueryKeys.logs(packageBase, repository),
|
queryKey: QueryKeys.logs(packageBase, repository),
|
||||||
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build version selectors from all logs
|
// Build version selectors from all logs
|
||||||
@@ -117,7 +116,6 @@ export default function BuildLogsTab({
|
|||||||
)
|
)
|
||||||
: skipToken,
|
: skipToken,
|
||||||
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
|
queryKey: QueryKeys.logsVersion(packageBase, repository, activeVersion?.version ?? "", activeVersion?.processId ?? ""),
|
||||||
refetchInterval: refreshInterval > 0 ? refreshInterval : false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derive displayed logs: prefer fresh polled data when available
|
// 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 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 {
|
||||||
autoRefreshInterval: number;
|
|
||||||
autoRefreshIntervals: AutoRefreshInterval[];
|
|
||||||
isAuthorized: boolean;
|
isAuthorized: boolean;
|
||||||
isHeld: boolean;
|
isHeld: boolean;
|
||||||
onAutoRefreshIntervalChange: (interval: number) => void;
|
|
||||||
onHoldToggle: () => void;
|
onHoldToggle: () => void;
|
||||||
onRefreshDatabaseChange: (checked: boolean) => void;
|
onRefreshDatabaseChange: (checked: boolean) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
@@ -40,11 +35,8 @@ interface PackageInfoActionsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageInfoActions({
|
export default function PackageInfoActions({
|
||||||
autoRefreshInterval,
|
|
||||||
autoRefreshIntervals,
|
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
isHeld,
|
isHeld,
|
||||||
onAutoRefreshIntervalChange,
|
|
||||||
onHoldToggle,
|
onHoldToggle,
|
||||||
onRefreshDatabaseChange,
|
onRefreshDatabaseChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -69,10 +61,5 @@ export default function PackageInfoActions({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
<AutoRefreshControl
|
|
||||||
currentInterval={autoRefreshInterval}
|
|
||||||
intervals={autoRefreshIntervals}
|
|
||||||
onIntervalChange={onAutoRefreshIntervalChange}
|
|
||||||
/>
|
|
||||||
</DialogActions>;
|
</DialogActions>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,14 +35,9 @@ 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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function createListColumn(
|
function createListColumn(
|
||||||
field: keyof PackageRow,
|
field: keyof PackageRow,
|
||||||
headerName: string,
|
headerName: string,
|
||||||
@@ -59,8 +54,8 @@ function createListColumn(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackageTable({ autoRefreshIntervals }: PackageTableProps): React.JSX.Element {
|
export default function PackageTable(): React.JSX.Element {
|
||||||
const table = usePackageTable(autoRefreshIntervals);
|
const table = usePackageTable();
|
||||||
const apiRef = useGridApiRef();
|
const apiRef = useGridApiRef();
|
||||||
const debouncedSearch = useDebounce(table.searchText, 300);
|
const debouncedSearch = useDebounce(table.searchText, 300);
|
||||||
|
|
||||||
@@ -118,11 +113,6 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
|
|||||||
onRemoveClick: () => void table.handleRemove(),
|
onRemoveClick: () => void table.handleRemove(),
|
||||||
onUpdateClick: () => void table.handleUpdate(),
|
onUpdateClick: () => void table.handleUpdate(),
|
||||||
}}
|
}}
|
||||||
autoRefresh={{
|
|
||||||
autoRefreshIntervals,
|
|
||||||
currentInterval: table.autoRefreshInterval,
|
|
||||||
onIntervalChange: table.onAutoRefreshIntervalChange,
|
|
||||||
}}
|
|
||||||
isAuthorized={table.isAuthorized}
|
isAuthorized={table.isAuthorized}
|
||||||
hasSelection={table.selectionModel.length > 0}
|
hasSelection={table.selectionModel.length > 0}
|
||||||
onSearchChange={table.setSearchText}
|
onSearchChange={table.setSearchText}
|
||||||
@@ -175,7 +165,6 @@ export default function PackageTable({ autoRefreshIntervals }: PackageTableProps
|
|||||||
<PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} />
|
<PackageRebuildDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "rebuild"} />
|
||||||
<KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} />
|
<KeyImportDialog onClose={() => table.setDialogOpen(null)} open={table.dialogOpen === "keyImport"} />
|
||||||
<PackageInfoDialog
|
<PackageInfoDialog
|
||||||
autoRefreshIntervals={autoRefreshIntervals}
|
|
||||||
onClose={() => table.setSelectedPackage(null)}
|
onClose={() => table.setSelectedPackage(null)}
|
||||||
open={table.selectedPackage !== null}
|
open={table.selectedPackage !== null}
|
||||||
packageBase={table.selectedPackage}
|
packageBase={table.selectedPackage}
|
||||||
|
|||||||
@@ -30,18 +30,10 @@ 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;
|
onAddClick: () => void;
|
||||||
onDashboardClick: () => void;
|
onDashboardClick: () => void;
|
||||||
@@ -56,7 +48,6 @@ export interface ToolbarActions {
|
|||||||
|
|
||||||
interface PackageTableToolbarProps {
|
interface PackageTableToolbarProps {
|
||||||
actions: ToolbarActions;
|
actions: ToolbarActions;
|
||||||
autoRefresh: AutoRefreshProps;
|
|
||||||
hasSelection: boolean;
|
hasSelection: boolean;
|
||||||
isAuthorized: boolean;
|
isAuthorized: boolean;
|
||||||
onSearchChange: (text: string) => void;
|
onSearchChange: (text: string) => void;
|
||||||
@@ -66,7 +57,6 @@ interface PackageTableToolbarProps {
|
|||||||
|
|
||||||
export default function PackageTableToolbar({
|
export default function PackageTableToolbar({
|
||||||
actions,
|
actions,
|
||||||
autoRefresh,
|
|
||||||
hasSelection,
|
hasSelection,
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
@@ -143,12 +133,6 @@ export default function PackageTableToolbar({
|
|||||||
reload
|
reload
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<AutoRefreshControl
|
|
||||||
currentInterval={autoRefresh.currentInterval}
|
|
||||||
intervals={autoRefresh.autoRefreshIntervals}
|
|
||||||
onIntervalChange={autoRefresh.onIntervalChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ flexGrow: 1 }} />
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
+12
-4
@@ -17,8 +17,16 @@
|
|||||||
* 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 interface AutoRefreshInterval {
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
interval: number;
|
import { useEventStream } from "hooks/useEventStream";
|
||||||
is_active: boolean;
|
import { useRepository } from "hooks/useRepository";
|
||||||
text: string;
|
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 { 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 {
|
||||||
autoRefresh: ReturnType<typeof useAutoRefresh>;
|
|
||||||
isAuthorized: boolean;
|
isAuthorized: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
rows: PackageRow[];
|
rows: PackageRow[];
|
||||||
status: BuildStatus | undefined;
|
status: BuildStatus | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
|
export function usePackageData(): 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,
|
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
|
||||||
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
|
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
|
||||||
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: status } = useQuery({
|
const { data: status } = useQuery({
|
||||||
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
|
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
|
||||||
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
|
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
|
||||||
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 {
|
||||||
autoRefresh,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
rows,
|
rows,
|
||||||
|
|||||||
@@ -21,13 +21,10 @@ 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 {
|
||||||
autoRefreshInterval: number;
|
|
||||||
columnVisibility: Record<string, boolean>;
|
columnVisibility: Record<string, boolean>;
|
||||||
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
|
dialogOpen: "dashboard" | "add" | "rebuild" | "keyImport" | null;
|
||||||
filterModel: GridFilterModel;
|
filterModel: GridFilterModel;
|
||||||
@@ -37,7 +34,6 @@ export interface UsePackageTableResult {
|
|||||||
handleUpdate: () => Promise<void>;
|
handleUpdate: () => Promise<void>;
|
||||||
isAuthorized: boolean;
|
isAuthorized: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onAutoRefreshIntervalChange: (interval: number) => void;
|
|
||||||
paginationModel: { page: number; pageSize: number };
|
paginationModel: { page: number; pageSize: number };
|
||||||
rows: PackageRow[];
|
rows: PackageRow[];
|
||||||
searchText: string;
|
searchText: string;
|
||||||
@@ -53,23 +49,14 @@ export interface UsePackageTableResult {
|
|||||||
status: BuildStatus | undefined;
|
status: BuildStatus | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePackageTable(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageTableResult {
|
export function usePackageTable(): UsePackageTableResult {
|
||||||
const { rows, isLoading, isAuthorized, status, autoRefresh } = usePackageData(autoRefreshIntervals);
|
const { rows, isLoading, isAuthorized, status } = usePackageData();
|
||||||
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 {
|
||||||
autoRefreshInterval: autoRefresh.interval,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
onAutoRefreshIntervalChange: autoRefresh.setInterval,
|
|
||||||
rows,
|
rows,
|
||||||
status,
|
status,
|
||||||
...actions,
|
...actions,
|
||||||
|
|||||||
@@ -18,12 +18,10 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import type { AuthInfo } from "models/AuthInfo";
|
import type { AuthInfo } from "models/AuthInfo";
|
||||||
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
|
|
||||||
import type { RepositoryId } from "models/RepositoryId";
|
import type { RepositoryId } from "models/RepositoryId";
|
||||||
|
|
||||||
export interface InfoResponse {
|
export interface InfoResponse {
|
||||||
auth: AuthInfo;
|
auth: AuthInfo;
|
||||||
autorefresh_intervals: AutoRefreshInterval[];
|
|
||||||
docs_enabled: boolean;
|
docs_enabled: boolean;
|
||||||
index_url?: string;
|
index_url?: string;
|
||||||
repositories: RepositoryId[];
|
repositories: RepositoryId[];
|
||||||
|
|||||||
@@ -17,8 +17,6 @@
|
|||||||
* 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/>.
|
||||||
*/
|
*/
|
||||||
import type { AutoRefreshInterval } from "models/AutoRefreshInterval";
|
|
||||||
|
|
||||||
export const DETAIL_TABLE_PROPS = {
|
export const DETAIL_TABLE_PROPS = {
|
||||||
density: "compact" as const,
|
density: "compact" as const,
|
||||||
disableColumnSorting: true,
|
disableColumnSorting: true,
|
||||||
@@ -27,10 +25,6 @@ export const DETAIL_TABLE_PROPS = {
|
|||||||
sx: { height: 400, mt: 1 },
|
sx: { height: 400, mt: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function defaultInterval(intervals: AutoRefreshInterval[]): number {
|
|
||||||
return intervals.find(interval => interval.is_active)?.interval ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Array<T> {
|
interface Array<T> {
|
||||||
unique(): T[];
|
unique(): T[];
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class EventBus(LazyLogging):
|
|||||||
self.max_size = max_size
|
self.max_size = max_size
|
||||||
|
|
||||||
self._lock = Lock()
|
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:
|
async def broadcast(self, event_type: EventType, object_id: str | None, **kwargs: Any) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -60,9 +60,11 @@ class EventBus(LazyLogging):
|
|||||||
event.update(kwargs)
|
event.update(kwargs)
|
||||||
|
|
||||||
async with self._lock:
|
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:
|
if topics is not None and event_type not in topics:
|
||||||
continue
|
continue
|
||||||
|
if filter_object_id is not None and object_id != filter_object_id:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
queue.put_nowait((event_type, event))
|
queue.put_nowait((event_type, event))
|
||||||
except QueueFull:
|
except QueueFull:
|
||||||
@@ -73,20 +75,23 @@ class EventBus(LazyLogging):
|
|||||||
gracefully shutdown all subscribers
|
gracefully shutdown all subscribers
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
for _, queue in self._subscribers.values():
|
for _, _, queue in self._subscribers.values():
|
||||||
try:
|
try:
|
||||||
queue.put_nowait(None)
|
queue.put_nowait(None)
|
||||||
except QueueFull:
|
except QueueFull:
|
||||||
pass
|
pass
|
||||||
queue.shutdown()
|
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
|
register new subscriber
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
topics(list[EventType] | None, optional): list of event types to filter by. If ``None`` is set,
|
topics(list[EventType] | None, optional): list of event types to filter by. If ``None`` is set,
|
||||||
all events will be delivered (Default value = None)
|
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:
|
Returns:
|
||||||
tuple[str, Queue[SSEvent | None]]: subscriber identifier and associated queue
|
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)
|
queue: Queue[SSEvent | None] = Queue(self.max_size)
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._subscribers[subscriber_id] = (topics, queue)
|
self._subscribers[subscriber_id] = (topics, object_id, queue)
|
||||||
|
|
||||||
return subscriber_id, queue
|
return subscriber_id, queue
|
||||||
|
|
||||||
@@ -109,5 +114,5 @@ class EventBus(LazyLogging):
|
|||||||
async with self._lock:
|
async with self._lock:
|
||||||
result = self._subscribers.pop(subscriber_id, None)
|
result = self._subscribers.pop(subscriber_id, None)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
_, queue = result
|
_, _, queue = result
|
||||||
queue.shutdown()
|
queue.shutdown()
|
||||||
|
|||||||
@@ -31,3 +31,7 @@ class EventBusFilterSchema(RepositoryIdSchema):
|
|||||||
"description": "Event type filter",
|
"description": "Event type filter",
|
||||||
"example": [EventType.PackageUpdated],
|
"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
|
topics = [EventType(event) for event in self.request.query.getall("event", [])] or None
|
||||||
except ValueError as ex:
|
except ValueError as ex:
|
||||||
raise HTTPBadRequest(reason=str(ex))
|
raise HTTPBadRequest(reason=str(ex))
|
||||||
|
object_id = self.request.query.get("object_id")
|
||||||
event_bus = self.service().event_bus
|
event_bus = self.service().event_bus
|
||||||
|
|
||||||
async with sse_response(self.request) as response:
|
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:
|
try:
|
||||||
await self._run(response, queue)
|
await self._run(response, queue)
|
||||||
|
|||||||
@@ -82,15 +82,42 @@ async def test_subscribe(event_bus: EventBus) -> None:
|
|||||||
assert subscriber_id in event_bus._subscribers
|
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:
|
async def test_subscribe_with_topics(event_bus: EventBus) -> None:
|
||||||
"""
|
"""
|
||||||
must register subscriber with topic filter
|
must register subscriber with topic filter
|
||||||
"""
|
"""
|
||||||
subscriber_id, _ = await event_bus.subscribe([EventType.BuildLog])
|
subscriber_id, _ = await event_bus.subscribe([EventType.BuildLog])
|
||||||
topics, _ = event_bus._subscribers[subscriber_id]
|
topics, _, _ = event_bus._subscribers[subscriber_id]
|
||||||
assert topics == [EventType.BuildLog]
|
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:
|
async def test_unsubscribe(event_bus: EventBus) -> None:
|
||||||
"""
|
"""
|
||||||
must remove subscriber
|
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
|
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:
|
async def test_get_bad_request(client: TestClient) -> None:
|
||||||
"""
|
"""
|
||||||
must return bad request for invalid event type
|
must return bad request for invalid event type
|
||||||
|
|||||||
Reference in New Issue
Block a user