diff --git a/packages/api-v4/.changeset/pr-9949-fixed-1706018014628.md b/packages/api-v4/.changeset/pr-9949-fixed-1706018014628.md new file mode 100644 index 00000000000..119590213ce --- /dev/null +++ b/packages/api-v4/.changeset/pr-9949-fixed-1706018014628.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Fixed +--- + +Removed incorrect `_initial` property on `Event` type ([#9949](https://github.com/linode/manager/pull/9949)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 20fff74a7e2..7d1658984d5 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -410,7 +410,6 @@ export interface Event { time_remaining: null | string; username: string | null; secondary_entity: Entity | null; - _initial?: boolean; message: string | null; } /** diff --git a/packages/manager/.changeset/pr-9949-tech-stories-1706017963330.md b/packages/manager/.changeset/pr-9949-tech-stories-1706017963330.md new file mode 100644 index 00000000000..52c2d308dd4 --- /dev/null +++ b/packages/manager/.changeset/pr-9949-tech-stories-1706017963330.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +React Query for Events ([#9949](https://github.com/linode/manager/pull/9949)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 0b3adce6f15..9047054e11d 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -68,7 +68,6 @@ "redux": "^4.0.4", "redux-thunk": "^2.3.0", "reselect": "^4.0.0", - "rxjs": "^5.5.6", "sanitize-html": "^2.11.0", "search-string": "^3.1.0", "throttle-debounce": "^2.0.0", diff --git a/packages/manager/src/App.tsx b/packages/manager/src/App.tsx index 6a2d738828e..1247e6972c0 100644 --- a/packages/manager/src/App.tsx +++ b/packages/manager/src/App.tsx @@ -14,11 +14,9 @@ import { GoTo } from './GoTo'; import { MainContent } from './MainContent'; import { SplashScreen } from './components/SplashScreen'; import { useAdobeAnalytics } from './hooks/useAdobeAnalytics'; -import { useEventHandlers } from './hooks/useEventHandlers'; -import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; import { useInitialRequests } from './hooks/useInitialRequests'; import { useNewRelic } from './hooks/useNewRelic'; -import { useToastNotifications } from './hooks/useToastNotifications'; +import { useEventsPoller } from './queries/events/events'; import { useSetupFeatureFlags } from './useSetupFeatureFlags'; // Ensure component's display name is 'App' @@ -31,14 +29,6 @@ const BaseApp = withDocumentTitleProvider( const { areFeatureFlagsLoading } = useSetupFeatureFlags(); - const { goToOpen, setGoToOpen } = useGlobalKeyboardListener(); - - useEventHandlers(); - useToastNotifications(); - - useAdobeAnalytics(); - useNewRelic(); - if (isLoading || areFeatureFlagsLoading) { return ; } @@ -56,11 +46,19 @@ const BaseApp = withDocumentTitleProvider( Opens an external site in a new window - setGoToOpen(false)} open={goToOpen} /> + + ); }) ) ); + +const GlobalListeners = () => { + useEventsPoller(); + useAdobeAnalytics(); + useNewRelic(); + return null; +}; diff --git a/packages/manager/src/GoTo.tsx b/packages/manager/src/GoTo.tsx index c0189b2a0bb..27b103936ad 100644 --- a/packages/manager/src/GoTo.tsx +++ b/packages/manager/src/GoTo.tsx @@ -1,13 +1,14 @@ import Dialog from '@mui/material/Dialog'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; +import { makeStyles } from 'tss-react/mui'; import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select'; import { useAccountManagement } from './hooks/useAccountManagement'; import { useFlags } from './hooks/useFlags'; +import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener'; const useStyles = makeStyles()((theme: Theme) => ({ input: { @@ -53,20 +54,21 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -interface Props { - onClose: () => void; - open: boolean; -} - -export const GoTo = React.memo((props: Props) => { +export const GoTo = React.memo(() => { const { classes } = useStyles(); const routerHistory = useHistory(); const { _hasAccountAccess, _isManagedAccount } = useAccountManagement(); const flags = useFlags(); + const { goToOpen, setGoToOpen } = useGlobalKeyboardListener(); + + const onClose = () => { + setGoToOpen(false); + }; + const onSelect = (item: Item) => { routerHistory.push(item.value); - props.onClose(); + onClose(); }; const links = React.useMemo( @@ -169,8 +171,8 @@ export const GoTo = React.memo((props: Props) => { return ( {/* I was about to put a "@todo" item for mobile display, but realized diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 5e4e80003ff..55be8533d22 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -159,8 +159,10 @@ const Help = React.lazy(() => const SearchLanding = React.lazy( () => import('src/features/Search/SearchLanding') ); -const EventsLanding = React.lazy( - () => import('src/features/Events/EventsLanding') +const EventsLanding = React.lazy(() => + import('src/features/Events/EventsLanding').then((module) => ({ + default: module.EventsLanding, + })) ); const AccountActivationLanding = React.lazy( () => import('src/components/AccountActivation/AccountActivationLanding') diff --git a/packages/manager/src/__data__/events.ts b/packages/manager/src/__data__/events.ts deleted file mode 100644 index e24ac3bf75f..00000000000 --- a/packages/manager/src/__data__/events.ts +++ /dev/null @@ -1,599 +0,0 @@ -import { Event } from '@linode/api-v4/lib/account'; - -import { ExtendedEvent } from 'src/store/events/event.types'; - -export const events: Event[] = [ - { - action: 'linode_reboot', - created: '2018-12-03T22:37:20', - duration: 0, - entity: { - id: 11241778, - label: 'node-server', - type: 'linode', - url: '/v4/linode/instances/11241778', - }, - id: 18029754, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_reboot', - created: '2018-12-03T22:38:16', - duration: 0, - entity: { - id: 11241778, - label: 'node-server', - type: 'linode', - url: '/v4/linode/instances/11241778', - }, - id: 18029767, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_reboot', - created: '2018-12-03T22:34:09', - duration: 0, - entity: { - id: 11241778, - label: 'node-server', - type: 'linode', - url: '/v4/linode/instances/11241778', - }, - id: 18029572, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-03T19:59:53', - duration: 0, - entity: { - id: 11642886, - label: 'linode11642886', - type: 'linode', - url: '/v4/linode/instances/11642886', - }, - id: 18022171, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-03T19:54:28', - duration: 0, - entity: { - id: 11642886, - label: 'linode11642886', - type: 'linode', - url: '/v4/linode/instances/11642886', - }, - id: 18022021, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_reboot', - created: '2018-12-03T19:53:26', - duration: 0, - entity: { - id: 11241778, - label: 'node-server', - type: 'linode', - url: '/v4/linode/instances/11241778', - }, - id: 18021942, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-02T23:15:45', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17957943, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_delete', - created: '2018-12-02T23:15:45', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17957944, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-02T23:08:38', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17957718, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-02T22:55:43', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17957108, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-02T22:48:37', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17956743, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-02T22:38:36', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17956360, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-02T22:31:24', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17955994, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-02T22:29:02', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17955841, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-02T22:25:39', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17955781, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-02T22:23:58', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17955694, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-02T22:21:51', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17955636, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-02T22:20:43', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17955613, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-02T22:19:10', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17955464, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-02T22:03:38', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17954738, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-02T20:45:52', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17951319, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-02T20:39:36', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17951106, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-02T20:35:54', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17950945, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-02T20:23:43', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17950407, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'lassie_reboot', - created: '2018-12-02T20:21:11', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17950183, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, -]; - -export const uniqueEvents: Event[] = [ - { - action: 'linode_boot', - created: '2018-12-02T20:23:43', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 1231234, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-02T20:23:43', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17950407, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, -]; - -export const reduxEvent: ExtendedEvent = { - _initial: false, - action: 'linode_boot', - created: '2018-12-02T20:23:43', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 123551234, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', -}; diff --git a/packages/manager/src/constants.ts b/packages/manager/src/constants.ts index c20c1c18442..23fc3560cab 100644 --- a/packages/manager/src/constants.ts +++ b/packages/manager/src/constants.ts @@ -100,10 +100,24 @@ export const MAX_VOLUME_SIZE = 10240; export const MAX_MONTHS_EOL_FILTER = 6; /** - * The lowest interval at which to make an Events request. This is later multiplied by the pollIteration - * to get the actual interval. + * Values used for our events polling system. + * Number values are in milliseconds */ -export const INTERVAL = 1000; +export const POLLING_INTERVALS = { + /** + * By default, we will poll for events every 16 seconds + */ + DEFAULT: 16_000, + /** + * If there are "in-progress" events, we will poll every 2 seconds to give users + * a real-time feeling experience + * + * The /v4/account/events endpoint has a rate-limit of 400 requets per minute. + * If we request events every 2 seconds, we will make 30 calls in 1 minute. + * We should be well within rate-limits. + */ + IN_PROGRESS: 2_000, +} as const; /** * Time after which data from the API is considered stale (half an hour) diff --git a/packages/manager/src/containers/events.container.ts b/packages/manager/src/containers/events.container.ts new file mode 100644 index 00000000000..37ffae66821 --- /dev/null +++ b/packages/manager/src/containers/events.container.ts @@ -0,0 +1,20 @@ +import React from 'react'; + +import { useEventsPollingActions } from 'src/queries/events/events'; + +export type WithEventsPollingActionProps = ReturnType< + typeof useEventsPollingActions +>; + +export const withEventsPollingActions = ( + Component: React.ComponentType +) => { + return (props: Props) => { + const polling = useEventsPollingActions(); + + return React.createElement(Component, { + ...props, + ...polling, + }); + }; +}; diff --git a/packages/manager/src/events.ts b/packages/manager/src/events.ts deleted file mode 100644 index b1195fb578c..00000000000 --- a/packages/manager/src/events.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * This file fills two needs; - * Maintain the polling interval for events, - * Push events from Redux onto the events$ stream. - * - * The pushing of events onto events$ is maintaining the existing API between Redux and components - * which consume the events$ stream. Once all of the consumers of events$ have been updated - * to consume directly from the Redux store, we can delete the events$ stream. - * - * The next step is to move this logic into a Redux connected component so we can more easily - * access Redux and control the start of the event loop. - */ -import { EntityEvent, Event } from '@linode/api-v4/lib/account'; -import { QueryClient } from 'react-query'; -import { Subject } from 'rxjs/Subject'; - -import { DISABLE_EVENT_THROTTLE, INTERVAL } from 'src/constants'; -import { - getPollingInterval, - getRequestDeadline, - setPollingInterval, - setRequestDeadline, -} from 'src/eventsPolling'; -import { getEvents } from 'src/store/events/event.request'; -import { ThunkDispatch } from 'src/store/types'; - -import { ApplicationStore } from './store'; - -export interface EventWithStore { - event: Event; - queryClient: QueryClient; - store: ApplicationStore; -} - -export interface EntityEventWithStore extends EventWithStore { - event: EntityEvent; -} - -export const events$ = new Subject(); - -let inProgress = false; - -export const requestEvents = ( - store: ApplicationStore, - queryClient: QueryClient -) => { - inProgress = true; - return (store.dispatch as ThunkDispatch)(getEvents()) - .then((events: Event[]) => { - const reversed = events.reverse(); - - /** - * This feeds the stream for consumers of events$. We're simply pushing the events from the - * request response onto the stream one at a time. - */ - reversed.forEach((event: Event) => { - events$.next({ event, queryClient, store }); - }); - inProgress = false; - }) - .catch((e) => e); -}; - -export const startEventsInterval = ( - store: ApplicationStore, - queryClient: QueryClient -) => - setInterval( - () => { - const now = Date.now(); - const pollIteration = getPollingInterval(); - const eventRequestDeadline = getRequestDeadline(); - - if (now > eventRequestDeadline) { - /** - * If we're waiting on a request, set reset the pollIteration and return to prevent - * overlapping requests. - */ - if (inProgress) { - /** leaving this commented out for now because I'm not sure if it'll break anything */ - // pollIteration = 1; - return; - } - - if (document.visibilityState !== 'visible') { - // don't request events in an inactive/minimized tab. - return; - } - - requestEvents(store, queryClient); - - if (DISABLE_EVENT_THROTTLE) { - /* - * If throttling is disabled manually set the timeout so tests wait to query the mock data store. - */ - setRequestDeadline(now + 500); - } else { - const timeout = INTERVAL * pollIteration; - /** Update the deadline */ - setRequestDeadline(now + timeout); - /* Update the iteration to a maximum of 16. */ - const newIteration = Math.min(pollIteration * 2, 16); - if (pollIteration < 16) { - setPollingInterval(newIteration); - } - } - } - }, - /* the following is the Nyquist rate for the minimum polling interval */ - INTERVAL / 2 - 1 - ); diff --git a/packages/manager/src/eventsPolling.ts b/packages/manager/src/eventsPolling.ts deleted file mode 100644 index 8a4986f26e4..00000000000 --- a/packages/manager/src/eventsPolling.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { INTERVAL } from 'src/constants'; - -let interval = 1; -let deadline = Date.now(); -export const resetEventsPolling = (newInterval: number = 1) => { - deadline = Date.now() + INTERVAL * newInterval; - interval = newInterval; -}; - -export const setRequestDeadline = (newDeadline: number) => - (deadline = newDeadline); -export const setPollingInterval = (newInterval: number) => - (interval = newInterval); - -export const getRequestDeadline = () => deadline; -export const getPollingInterval = () => interval; diff --git a/packages/manager/src/features/Domains/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainRecords.tsx index aaad7f77df1..ff5ed92a118 100644 --- a/packages/manager/src/features/Domains/DomainRecords.tsx +++ b/packages/manager/src/features/Domains/DomainRecords.tsx @@ -22,7 +22,6 @@ import { } from 'ramda'; import * as React from 'react'; import { compose as recompose } from 'recompose'; -import { Subscription } from 'rxjs/Subscription'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Button } from 'src/components/Button/Button'; @@ -330,8 +329,6 @@ class DomainRecords extends React.Component { this.updateConfirmDialog((c) => ({ ...c, submitting: true })); }; - eventsSubscription$: Subscription; - generateTypes = (): IType[] => [ /** SOA Record */ { diff --git a/packages/manager/src/features/Events/Event.helpers.test.ts b/packages/manager/src/features/Events/Event.helpers.test.ts index 3567d837373..cb2acd4fbd8 100644 --- a/packages/manager/src/features/Events/Event.helpers.test.ts +++ b/packages/manager/src/features/Events/Event.helpers.test.ts @@ -1,102 +1,14 @@ -import { EventAction } from '@linode/api-v4/lib/account'; - -import { reduxEvent, uniqueEvents } from 'src/__data__/events'; import { eventFactory } from 'src/factories/events'; import { - filterUniqueEvents, formatEventWithAppendedText, formatEventWithUsername, maybeRemoveTrailingPeriod, - percentCompleteHasUpdated, - shouldUpdateEvents, } from './Event.helpers'; -const inProgressEvents = { - 1234: 50, - 1235: 20, -}; - -const nextInProgressEvents = { - 1234: 80, -}; - -const mostRecentEventTime = '1556810353941'; -const nextTime = '1556810370715'; +import type { EventAction } from '@linode/api-v4'; describe('Utility Functions', () => { - it('should filter out unique events', () => { - expect(filterUniqueEvents([reduxEvent, reduxEvent])).toHaveLength(1); - expect(filterUniqueEvents(uniqueEvents)).toHaveLength(2); - }); - - it('should return true if percent complete has changed', () => { - expect( - percentCompleteHasUpdated(inProgressEvents, inProgressEvents) - ).toBeFalsy(); - expect( - percentCompleteHasUpdated(inProgressEvents, nextInProgressEvents) - ).toBeTruthy(); - expect(percentCompleteHasUpdated(inProgressEvents, {})).toBeTruthy(); - }); - - it('should update events if most recent event time has changed', () => { - expect( - shouldUpdateEvents( - { - inProgressEvents, - mostRecentEventTime, - }, - { - inProgressEvents, - mostRecentEventTime: nextTime, - } - ) - ).toBeTruthy(); - }); - - it('should update if event progress has updated', () => { - expect( - shouldUpdateEvents( - { - inProgressEvents, - mostRecentEventTime, - }, - { - inProgressEvents: nextInProgressEvents, - mostRecentEventTime, - } - ) - ).toBeTruthy(); - expect( - shouldUpdateEvents( - { - inProgressEvents, - mostRecentEventTime, - }, - { - inProgressEvents: {}, - mostRecentEventTime, - } - ) - ).toBeTruthy(); - }); - - it('should not update if nothing has changed', () => { - expect( - shouldUpdateEvents( - { - inProgressEvents, - mostRecentEventTime, - }, - { - inProgressEvents, - mostRecentEventTime, - } - ) - ).toBeFalsy(); - }); - describe('formatEventWithUsername utility', () => { it('it should return a message for an event without a username unchanged', () => { const message = 'a message'; diff --git a/packages/manager/src/features/Events/Event.helpers.ts b/packages/manager/src/features/Events/Event.helpers.ts index 3968f8c8bc0..714206fb423 100644 --- a/packages/manager/src/features/Events/Event.helpers.ts +++ b/packages/manager/src/features/Events/Event.helpers.ts @@ -1,75 +1,4 @@ import { Event, EventAction } from '@linode/api-v4/lib/account'; -import { equals } from 'ramda'; - -/** - * The point of this function is to ensure we don't have an activity stream - * that looks like: - * - * Linode hello_world has been booted - * Linode hello_world has been created - * Linode hello_world is scheduled to be booted - * Linode hello_world is scheduled to be created - * - * Basically, we're creating a cache and only adding to the cache if the Event - * ID doesn't already exist in the cache. This ensures that "has been created" - * events will replace the "is scheduled to" events - */ -export const filterUniqueEvents = (events: Event[]) => { - return events.reduce((acc, event) => { - const foundEventInAcc = acc.some( - (eachAccumEvent: Event) => eachAccumEvent.id === event.id - ); - return foundEventInAcc ? acc : [...acc, event]; - }, []); -}; - -/** - * Takes in the inProgressEvents which are sourced from Redux. These are a key-value - * pair where the key is the ID of the event in progress and the value is the percent_complete - * So it ends up comparing the values similar to - * - * { - * 1234: 50 - * } - * - * and - * - * { - * 1234: 79 - * } - * - * the "50" and the "79" are the things being compared - */ -export const percentCompleteHasUpdated = ( - prevEventsInProgress: Record, - nextEventsInProgress: Record -) => { - return Object.keys(prevEventsInProgress).some( - (eachEventID) => - prevEventsInProgress[eachEventID] !== nextEventsInProgress[eachEventID] - ); -}; - -interface Payload { - inProgressEvents: Record; - mostRecentEventTime: string; -} - -/** - * shouldComponentUpdate logic to determine if the list of events should update - * - * This is abstracted because it's shared logic between the EventsLanding the Activity Summary - */ -export const shouldUpdateEvents = (prevProps: Payload, nextProps: Payload) => { - return ( - !equals(prevProps.mostRecentEventTime, nextProps.mostRecentEventTime) || - !equals(prevProps.inProgressEvents, nextProps.inProgressEvents) || - percentCompleteHasUpdated( - prevProps.inProgressEvents, - nextProps.inProgressEvents - ) - ); -}; export const maybeRemoveTrailingPeriod = (string: string) => { const lastChar = string[string.length - 1]; diff --git a/packages/manager/src/features/Events/EventRow.tsx b/packages/manager/src/features/Events/EventRow.tsx index 4b1948212ef..87ee1115e8f 100644 --- a/packages/manager/src/features/Events/EventRow.tsx +++ b/packages/manager/src/features/Events/EventRow.tsx @@ -14,18 +14,14 @@ import { getLinkForEvent } from 'src/utilities/getEventsActionLink'; import { StyledGravatar } from './EventRow.styles'; -interface ExtendedEvent extends Event { - _deleted?: string; -} - interface EventRowProps { entityId?: number; - event: ExtendedEvent; + event: Event; } export const EventRow = (props: EventRowProps) => { const { entityId, event } = props; - const link = getLinkForEvent(event.action, event.entity, event._deleted); + const link = getLinkForEvent(event.action, event.entity); const type = pathOr('linode', ['entity', 'type'], event); const timestamp = getEventTimestamp(event); diff --git a/packages/manager/src/features/Events/EventsLanding.test.ts b/packages/manager/src/features/Events/EventsLanding.test.ts deleted file mode 100644 index 94d7674b6a0..00000000000 --- a/packages/manager/src/features/Events/EventsLanding.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Event } from '@linode/api-v4/lib/account'; - -import { reduxEvent, uniqueEvents } from 'src/__data__/events'; - -import { ReducerActions, ReducerState, reducer } from './EventsLanding'; - -const someEvent: Event[] = [ - { - action: 'linode_boot', - created: '2018-12-02T20:23:43', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 1234, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, -]; - -const currentState: ReducerState = { - eventsFromRedux: [], - inProgressEvents: { 123: 50 }, - mostRecentEventTime: 'hello world', - reactStateEvents: uniqueEvents, -}; - -const appendPayload: ReducerActions = { - payload: currentState, - type: 'append', -}; - -const prependPayloadNoChange: ReducerActions = { - payload: currentState, - type: 'prepend', -}; - -describe('utility functions', () => { - it(`should append new events and filter out duplicates to the list of events - when the "append" type is invoked`, () => { - expect(reducer(currentState, appendPayload)).toEqual(appendPayload.payload); - expect( - reducer(currentState, { - ...appendPayload, - payload: { - ...appendPayload.payload, - reactStateEvents: [...someEvent, ...someEvent], - }, - }) - ).toEqual({ - ...currentState, - reactStateEvents: [...uniqueEvents, ...someEvent], - }); - expect(reducer(currentState, appendPayload)).not.toEqual({ - ...currentState, - reactStateEvents: [...uniqueEvents, ...uniqueEvents], - }); - }); - - it('should prepend new events to the list of events and filter out duplicates when the "prepend" type is invoked', () => { - expect( - reducer(currentState, { - ...prependPayloadNoChange, - payload: { - ...prependPayloadNoChange.payload, - eventsFromRedux: [reduxEvent], - mostRecentEventTime: 'ahhhhhhhh', - }, - }) - ).toEqual({ - eventsFromRedux: [reduxEvent], - inProgressEvents: { - 123: 50, - }, - mostRecentEventTime: 'ahhhhhhhh', - reactStateEvents: [reduxEvent, ...uniqueEvents], - }); - - expect( - reducer(currentState, { - ...prependPayloadNoChange, - payload: { - ...prependPayloadNoChange.payload, - eventsFromRedux: [reduxEvent], - inProgressEvents: { 123: 70 }, - }, - }) - ).toEqual({ - eventsFromRedux: [reduxEvent], - inProgressEvents: { 123: 70 }, - mostRecentEventTime: 'hello world', - reactStateEvents: [reduxEvent, ...uniqueEvents], - }); - }); - - it('should not prepend when inProgress and mostRecentEventTime has changed', () => { - expect(reducer(currentState, prependPayloadNoChange)).toEqual(currentState); - }); -}); diff --git a/packages/manager/src/features/Events/EventsLanding.test.tsx b/packages/manager/src/features/Events/EventsLanding.test.tsx new file mode 100644 index 00000000000..4250cda4427 --- /dev/null +++ b/packages/manager/src/features/Events/EventsLanding.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import { eventFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EventsLanding } from './EventsLanding'; + +describe('EventsLanding', () => { + it('renders an empty state', async () => { + server.use( + rest.get('*/events', (req, res, ctx) => + res(ctx.json(makeResourcePage([]))) + ) + ); + + const { findByText } = renderWithTheme(); + + await findByText("You don't have any events on your account."); + }); + it('renders a custom empty state message', async () => { + server.use( + rest.get('*/events', (req, res, ctx) => + res(ctx.json(makeResourcePage([]))) + ) + ); + + const emptyMessage = 'No Linode Events :('; + + const { findByText } = renderWithTheme( + + ); + + await findByText(emptyMessage); + }); + + it('renders an event from the API', async () => { + const event = eventFactory.build({ + action: 'volume_create', + entity: { + label: 'my-volume', + type: 'volume', + }, + username: 'user', + }); + + server.use( + rest.get('*/events', (req, res, ctx) => + res(ctx.json(makeResourcePage([event]))) + ) + ); + + const { findByText } = renderWithTheme(); + + await findByText('my-volume'); + await findByText('is being created by user.', { exact: false }); + }); + + it('renders a message when there are no more events to load', async () => { + const event = eventFactory.build(); + + server.use( + rest.get('*/events', (req, res, ctx) => + res( + ctx.json(makeResourcePage([event], { page: 1, pages: 1, results: 1 })) + ) + ) + ); + + const { findByText } = renderWithTheme(); + + await findByText('No more events to show'); + }); +}); diff --git a/packages/manager/src/features/Events/EventsLanding.tsx b/packages/manager/src/features/Events/EventsLanding.tsx index 9e181ca0020..7486341c5cb 100644 --- a/packages/manager/src/features/Events/EventsLanding.tsx +++ b/packages/manager/src/features/Events/EventsLanding.tsx @@ -1,11 +1,5 @@ -import { Event, getEvents } from '@linode/api-v4/lib/account'; -import { ResourcePage } from '@linode/api-v4/lib/types'; -import { useSnackbar } from 'notistack'; -import { concat, compose as rCompose, uniq } from 'ramda'; import * as React from 'react'; -import { connect } from 'react-redux'; import { Waypoint } from 'react-waypoint'; -import { compose } from 'recompose'; import { Hidden } from 'src/components/Hidden'; import { Table } from 'src/components/Table'; @@ -16,12 +10,8 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; -import { ApplicationState } from 'src/store'; -import { setDeletedEvents } from 'src/store/events/event.helpers'; -import { ExtendedEvent } from 'src/store/events/event.types'; -import { removeBlocklistedEvents } from 'src/utilities/eventUtils'; +import { useEventsInfiniteQuery } from 'src/queries/events/events'; -import { filterUniqueEvents, shouldUpdateEvents } from './Event.helpers'; import { EventRow } from './EventRow'; import { StyledH1Header, @@ -30,224 +20,72 @@ import { StyledTypography, } from './EventsLanding.styles'; +import type { Filter } from '@linode/api-v4'; + interface Props { emptyMessage?: string; // Custom message for the empty state (i.e. no events). - // isEventsLandingForEntity?: boolean; - entityId?: number; - errorMessage?: string; // Custom error message (for an entity's Activity page, for example) - getEventsRequest?: typeof getEvents; -} - -type CombinedProps = Props & StateProps; - -const appendToEvents = (oldEvents: Event[], newEvents: Event[]) => - rCompose( - uniq, // Ensure no duplicates - concat(oldEvents), // Attach the new events - setDeletedEvents // Add a _deleted entry for each new event - )(newEvents); - -export interface ReducerState { - eventsFromRedux: ExtendedEvent[]; - inProgressEvents: Record; - mostRecentEventTime: string; - reactStateEvents: Event[]; -} - -interface Payload { entityId?: number; - eventsFromRedux: ExtendedEvent[]; - inProgressEvents: Record; - mostRecentEventTime: string; - reactStateEvents: Event[]; -} - -export interface ReducerActions { - payload: Payload; - type: 'append' | 'prepend'; } -type EventsReducer = React.Reducer; +export const EventsLanding = (props: Props) => { + const { emptyMessage, entityId } = props; -export const reducer: EventsReducer = (state, action) => { - const { - payload: { - entityId, - eventsFromRedux: nextReduxEvents, - inProgressEvents: nextInProgressEvents, - mostRecentEventTime: nextMostRecentEventTime, - reactStateEvents: nextReactEvents, - }, - } = action; + const filter: Filter = { action: { '+neq': 'profile_update' } }; - switch (action.type) { - case 'prepend': - if ( - shouldUpdateEvents( - { - inProgressEvents: state.inProgressEvents, - mostRecentEventTime: state.mostRecentEventTime, - }, - { - inProgressEvents: nextInProgressEvents, - mostRecentEventTime: nextMostRecentEventTime, - } - ) - ) { - return { - eventsFromRedux: nextReduxEvents, - inProgressEvents: nextInProgressEvents, - mostRecentEventTime: nextMostRecentEventTime, - reactStateEvents: filterUniqueEvents([ - /* - Pop new events from Redux on the top of the event stream, with some conditions - */ - ...nextReduxEvents.filter((eachEvent) => { - return ( - /** all events from Redux will have this flag as a boolean value */ - !eachEvent._initial && - /** - * so here we're basically determining whether or not - * an entityID prop was passed, and if so, only show the events - * that pertain to that entity. This is useful because it helps - * us show only relevant events on the Linode Activity panel, for example - */ - (typeof entityId === 'undefined' || - (eachEvent.entity && eachEvent.entity.id === entityId)) - ); - }), - /* - at this point, the state is populated with events from the cDM - request (which don't include the "_initial flag"), but it might also - contain events from Redux as well. We only want the ones where the "_initial" - flag doesn't exist - */ - ...nextReactEvents.filter( - (eachEvent) => typeof eachEvent._initial === 'undefined' - ), - ]), - }; - } - return { - eventsFromRedux: nextReduxEvents, - inProgressEvents: nextInProgressEvents, - mostRecentEventTime: nextMostRecentEventTime, - reactStateEvents: nextReactEvents, - }; - case 'append': - default: - return { - eventsFromRedux: nextReduxEvents, - inProgressEvents: nextInProgressEvents, - mostRecentEventTime: nextMostRecentEventTime, - reactStateEvents: appendToEvents( - state.reactStateEvents, - nextReactEvents - ), - }; + if (entityId) { + filter['entity.id'] = entityId; + filter['entity.type'] = 'linode'; } -}; - -export const EventsLanding = (props: CombinedProps) => { - const { enqueueSnackbar } = useSnackbar(); - - const [loading, setLoading] = React.useState(false); - const [loadMoreEvents, setLoadMoreEvents] = React.useState(false); - const [error, setError] = React.useState(undefined); - const [currentPage, setCurrentPage] = React.useState(1); - const [isRequesting, setRequesting] = React.useState(false); - const [initialLoaded, setInitialLoaded] = React.useState(false); - const [events, dispatch] = React.useReducer(reducer, { - eventsFromRedux: props.eventsFromRedux, - inProgressEvents: props.inProgressEvents, - mostRecentEventTime: props.mostRecentEventTime, - reactStateEvents: [], - }); - - const getNext = () => { - if (isRequesting) { - return; - } - setRequesting(true); - - const getEventsRequest = props.getEventsRequest || getEvents; - - getEventsRequest({ page: currentPage }) - .then(handleEventsRequestSuccess) - .catch(() => { - enqueueSnackbar('There was an error loading more events', { - variant: 'error', - }); - setLoading(false); - setRequesting(false); - }); - }; - - const handleEventsRequestSuccess = (response: ResourcePage) => { - setCurrentPage(currentPage + 1); - setLoadMoreEvents(true); - /** append our events to component state */ - dispatch({ - payload: { - entityId: props.entityId, - eventsFromRedux: props.eventsFromRedux, - inProgressEvents: props.inProgressEvents, - mostRecentEventTime: props.mostRecentEventTime, - reactStateEvents: response.data, - }, - type: 'append', - }); - setLoading(false); - setRequesting(false); - setError(undefined); - if (response.pages === currentPage) { - setLoadMoreEvents(false); + const { + error, + events, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useEventsInfiniteQuery(filter); + + const renderTableBody = () => { + if (isLoading) { + return ( + + ); + } else if (error) { + return ; + } else if (events && events.length === 0) { + return ( + + ); + } else { + return ( + <> + {events?.map((event) => ( + + ))} + {isFetchingNextPage && ( + + )} + + ); } }; - React.useEffect(() => { - setLoading(true); - setRequesting(true); - setError(undefined); - - const getEventsRequest = props.getEventsRequest || getEvents; - - getEventsRequest() - .then(handleEventsRequestSuccess) - .then(() => setInitialLoaded(true)) - .catch(() => { - setLoading(false); - setError('Error'); - }); - }, []); - - /** - * For the purposes of concat-ing the events from Redux and React state - * so we can display events in real-time - */ - React.useEffect(() => { - const { eventsFromRedux, inProgressEvents } = props; - /** in this case, we're getting new events from Redux, so we want to prepend */ - dispatch({ - payload: { - entityId: props.entityId, - eventsFromRedux, - inProgressEvents, - mostRecentEventTime: props.mostRecentEventTime, - reactStateEvents: events.reactStateEvents, - }, - type: 'prepend', - }); - }, [ - events.reactStateEvents, - props, - props.eventsFromRedux, - props.inProgressEvents, - ]); - - const { emptyMessage, entityId, errorMessage } = props; - return ( <> {/* Only display this title on the main Events landing page */} @@ -258,9 +96,7 @@ export const EventsLanding = (props: CombinedProps) => { - - Event - + Event Relative Date @@ -269,104 +105,18 @@ export const EventsLanding = (props: CombinedProps) => { - - {renderTableBody( - loading, - isRequesting, - errorMessage, - entityId, - error, - events.reactStateEvents, - emptyMessage - )} - + {renderTableBody()} - {loadMoreEvents && initialLoaded && !loading ? ( - + {hasNextPage ? ( + fetchNextPage()}>
) : ( - !loading && - !error && - events.reactStateEvents.length > 0 && ( + events && + events.length > 0 && ( No more events to show ) )} ); }; - -export const renderTableBody = ( - loading: boolean, - isRequesting: boolean, - errorMessage = 'There was an error retrieving the events on your account.', - entityId?: number, - error?: string, - events?: Event[], - emptyMessage = "You don't have any events on your account." -) => { - const filteredEvents = removeBlocklistedEvents(events, ['profile_update']); - - if (loading) { - return ( - - ); - } else if (error) { - return ( - - ); - } else if (filteredEvents.length === 0) { - return ( - - ); - } else { - return ( - <> - {filteredEvents.map((thisEvent, idx) => ( - - ))} - {isRequesting && ( - - )} - - ); - } -}; - -interface StateProps { - eventsFromRedux: ExtendedEvent[]; - inProgressEvents: Record; - mostRecentEventTime: string; -} - -const mapStateToProps = (state: ApplicationState) => ({ - eventsFromRedux: state.events.events, - inProgressEvents: state.events.inProgressEvents, - mostRecentEventTime: state.events.mostRecentEventTime, -}); - -const connected = connect(mapStateToProps); - -const enhanced = compose(connected); - -export default enhanced(EventsLanding); diff --git a/packages/manager/src/features/Events/eventMessageGenerator.ts b/packages/manager/src/features/Events/eventMessageGenerator.ts index 4a34e6c0444..3e1f12d73ff 100644 --- a/packages/manager/src/features/Events/eventMessageGenerator.ts +++ b/packages/manager/src/features/Events/eventMessageGenerator.ts @@ -983,11 +983,10 @@ export function applyLinking(event: Event, message: string) { return ''; } - const entityLinkTarget = getLinkForEvent(event.action, event.entity, false); + const entityLinkTarget = getLinkForEvent(event.action, event.entity); const secondaryEntityLinkTarget = getLinkForEvent( event.action, - event.secondary_entity, - false + event.secondary_entity ); let newMessage = message; diff --git a/packages/manager/src/features/Events/index.ts b/packages/manager/src/features/Events/index.ts deleted file mode 100644 index 2fdf42c8982..00000000000 --- a/packages/manager/src/features/Events/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as NotificationsLanding } from './EventsLanding'; diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index d8fe8d48351..6773a912703 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -1,11 +1,11 @@ import { Disk, Linode, getLinodeDisks } from '@linode/api-v4/lib/linodes'; import { APIError } from '@linode/api-v4/lib/types'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import { useSnackbar } from 'notistack'; import { equals } from 'ramda'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; +import { makeStyles } from 'tss-react/mui'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; @@ -15,10 +15,10 @@ import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; -import { resetEventsPolling } from 'src/eventsPolling'; import { DiskSelect } from 'src/features/Linodes/DiskSelect/DiskSelect'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { useFlags } from 'src/hooks/useFlags'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useCreateImageMutation } from 'src/queries/images'; import { useGrants, useProfile } from 'src/queries/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -102,6 +102,8 @@ export const CreateImageTab: React.FC = (props) => { const { mutateAsync: createImage } = useCreateImageMutation(); + const { checkForNewEvents } = useEventsPollingActions(); + const [selectedLinode, setSelectedLinode] = React.useState(); const [selectedDisk, setSelectedDisk] = React.useState(''); const [disks, setDisks] = React.useState([]); @@ -171,7 +173,7 @@ export const CreateImageTab: React.FC = (props) => { label, }) .then((_) => { - resetEventsPolling(); + checkForNewEvents(); setSubmitting(false); diff --git a/packages/manager/src/features/Images/ImagesDrawer.tsx b/packages/manager/src/features/Images/ImagesDrawer.tsx index a7078ea8184..bbe5b17e6b3 100644 --- a/packages/manager/src/features/Images/ImagesDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesDrawer.tsx @@ -1,11 +1,11 @@ import { Disk, getLinodeDisks } from '@linode/api-v4/lib/linodes'; import { APIError } from '@linode/api-v4/lib/types'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import { useSnackbar } from 'notistack'; import { equals } from 'ramda'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; +import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; @@ -13,9 +13,9 @@ import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { IMAGE_DEFAULT_LIMIT } from 'src/constants'; -import { resetEventsPolling } from 'src/eventsPolling'; import { DiskSelect } from 'src/features/Linodes/DiskSelect/DiskSelect'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useCreateImageMutation, useUpdateImageMutation, @@ -102,6 +102,8 @@ export const ImagesDrawer = (props: CombinedProps) => { permissionedLinodes: availableLinodes, } = useImageAndLinodeGrantCheck(); + const { checkForNewEvents } = useEventsPollingActions(); + const [mounted, setMounted] = React.useState(false); const [notice, setNotice] = React.useState(undefined); const [submitting, setSubmitting] = React.useState(false); @@ -228,7 +230,7 @@ export const ImagesDrawer = (props: CombinedProps) => { return; } - resetEventsPolling(); + checkForNewEvents(); setSubmitting(false); diff --git a/packages/manager/src/features/Images/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding.tsx index d17f0bae2a2..49d9b65514e 100644 --- a/packages/manager/src/features/Images/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding.tsx @@ -6,7 +6,6 @@ import produce from 'immer'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useQueryClient } from 'react-query'; -import { useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -30,14 +29,17 @@ import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { listToItemsByID } from 'src/queries/base'; +import { useEventsInfiniteQuery } from 'src/queries/events/events'; import { queryKey, removeImageFromCache, useDeleteImageMutation, useImagesQuery, } from 'src/queries/images'; -import { ApplicationState } from 'src/store'; -import imageEvents from 'src/store/selectors/imageEvents'; +import { + isEventImageUpload, + isEventInProgressDiskImagize, +} from 'src/queries/events/event.helpers'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import ImageRow, { ImageWithEvent } from './ImageRow'; @@ -185,19 +187,24 @@ export const ImagesLanding: React.FC = () => { const { mutateAsync: deleteImage } = useDeleteImageMutation(); - const eventState = useSelector((state: ApplicationState) => state.events); - const events = imageEvents(eventState); + const { events } = useEventsInfiniteQuery(); + + const imageEvents = + events?.filter( + (event) => + isEventInProgressDiskImagize(event) || isEventImageUpload(event) + ) ?? []; // Private images with the associated events tied in. const manualImagesData = getImagesWithEvents( manualImages?.data ?? [], - events + imageEvents ); // Automatic images with the associated events tied in. const automaticImagesData = getImagesWithEvents( automaticImages?.data ?? [], - events + imageEvents ); const [drawer, setDrawer] = React.useState( diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx index b42a4505a4a..6b5ee353879 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx @@ -21,8 +21,8 @@ import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; import { Typography } from 'src/components/Typography'; import { transitionText } from 'src/features/Linodes/transitions'; +import { useInProgressEvents } from 'src/queries/events/events'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { useRecentEventForLinode } from 'src/store/selectors/recentEventForLinode'; import { LinodeWithMaintenance } from 'src/utilities/linodes'; import NodeActionMenu from './NodeActionMenu'; @@ -219,7 +219,12 @@ export const NodeRow: React.FC = React.memo((props) => { const { classes } = useStyles(); - const recentEvent = useRecentEventForLinode(instanceId ?? -1); + const { data: events } = useInProgressEvents(); + + const recentEvent = events?.find( + (event) => + event.entity?.id === instanceId && event.entity?.type === 'linode' + ); const linodeLink = instanceId ? `/linodes/${instanceId}` : undefined; diff --git a/packages/manager/src/features/Linodes/CloneLanding/CloneLanding.tsx b/packages/manager/src/features/Linodes/CloneLanding/CloneLanding.tsx index 5bcdf213db8..148c7c40aad 100644 --- a/packages/manager/src/features/Linodes/CloneLanding/CloneLanding.tsx +++ b/packages/manager/src/features/Linodes/CloneLanding/CloneLanding.tsx @@ -27,7 +27,7 @@ import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { Typography } from 'src/components/Typography'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllLinodeConfigsQuery } from 'src/queries/linodes/configs'; import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; import { @@ -59,6 +59,8 @@ const CloneLanding = () => { const location = useLocation(); const theme = useTheme(); + const { checkForNewEvents } = useEventsPollingActions(); + const linodeId = Number(_linodeId); const { data: _configs } = useAllLinodeConfigsQuery(linodeId); @@ -240,7 +242,7 @@ const CloneLanding = () => { request() .then(() => { setSubmitting(false); - resetEventsPolling(); + checkForNewEvents(); history.push(`/linodes/${linodeId}/configurations`); }) .catch((errors) => { diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx index 27f14ae1c6a..816faf4c2bc 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx @@ -3,11 +3,11 @@ import * as React from 'react'; import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; import { notificationContext as _notificationContext } from 'src/features/NotificationCenter/NotificationContext'; import { useVPCConfigInterface } from 'src/hooks/useVPCConfigInterface'; +import { useInProgressEvents } from 'src/queries/events/events'; import { useAllImagesQuery } from 'src/queries/images'; import { useRegionsQuery } from 'src/queries/regions'; import { useTypeQuery } from 'src/queries/types'; import { useLinodeVolumesQuery } from 'src/queries/volumes'; -import { useRecentEventForLinode } from 'src/store/selectors/recentEventForLinode'; import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { LinodeEntityDetailBody } from './LinodeEntityDetailBody'; @@ -40,7 +40,11 @@ export const LinodeEntityDetail = (props: Props) => { const notificationContext = React.useContext(_notificationContext); - const recentEvent = useRecentEventForLinode(linode.id); + const { data: events } = useInProgressEvents(); + + const recentEvent = events?.find( + (event) => event.entity?.id === linode.id && event.entity.type === 'linode' + ); const { data: images } = useAllImagesQuery({}, {}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/ApiAwarenessModal.tsx b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/ApiAwarenessModal.tsx index 32d481889f1..3c5bffdc215 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/ApiAwarenessModal.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/ApiAwarenessModal/ApiAwarenessModal.tsx @@ -1,6 +1,6 @@ import { CreateLinodeRequest } from '@linode/api-v4/lib/linodes'; -import { styled } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles'; +import { styled } from '@mui/material/styles'; import React, { useEffect, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; @@ -13,7 +13,7 @@ import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { Typography } from 'src/components/Typography'; -import useEvents from 'src/hooks/useEvents'; +import { useInProgressEvents } from 'src/queries/events/events'; import { sendApiAwarenessClickEvent } from 'src/utilities/analytics'; import { generateCurlCommand } from 'src/utilities/generate-cURL'; import { generateCLICommand } from 'src/utilities/generate-cli'; @@ -32,16 +32,16 @@ export const ApiAwarenessModal = (props: ApiAwarenessModalProps) => { const theme = useTheme(); const history = useHistory(); - const { events } = useEvents(); + const { data: events } = useInProgressEvents(); - const createdLinode = events.filter( + const linodeCreationEvent = events?.find( (event) => (event.action === 'linode_create' || event.action === 'linode_clone') && event.entity?.label === payLoad.label && (event.status === 'scheduled' || event.status === 'started') ); - const isLinodeCreated = createdLinode.length === 1; + const isLinodeCreated = linodeCreationEvent !== undefined; const curlCommand = useMemo( () => generateCurlCommand(payLoad, '/linode/instances'), @@ -70,7 +70,7 @@ export const ApiAwarenessModal = (props: ApiAwarenessModalProps) => { useEffect(() => { if (isLinodeCreated && isOpen) { onClose(); - history.replace(`/linodes/${createdLinode[0].entity?.id}`); + history.replace(`/linodes/${linodeCreationEvent.entity?.id}`); } }, [isLinodeCreated]); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index c666a2d414f..72ea5e286bb 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -21,6 +21,10 @@ import { WithAccountSettingsProps, withAccountSettings, } from 'src/containers/accountSettings.container'; +import { + WithEventsPollingActionProps, + withEventsPollingActions, +} from 'src/containers/events.container'; import withImages, { DefaultProps as ImagesProps, } from 'src/containers/images.container'; @@ -45,7 +49,6 @@ import { WithQueryClientProps, withQueryClient, } from 'src/containers/withQueryClient.container'; -import { resetEventsPolling } from 'src/eventsPolling'; import withAgreements, { AgreementsProps, } from 'src/features/Account/Agreements/withAgreements'; @@ -63,12 +66,13 @@ import { } from 'src/utilities/analytics'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { ExtendedType, extendType } from 'src/utilities/extendType'; +import { isEURegion } from 'src/utilities/formatRegion'; import { getGDPRDetails, getSelectedRegionGroup, } from 'src/utilities/formatRegion'; -import { isEURegion } from 'src/utilities/formatRegion'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; +import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { validatePassword } from 'src/utilities/validatePassword'; @@ -85,7 +89,6 @@ import type { LinodeTypeClass, PriceObject, } from '@linode/api-v4/lib/linodes'; -import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; const DEFAULT_IMAGE = 'linode/debian11'; @@ -137,7 +140,8 @@ type CombinedProps = WithSnackbarProps & AgreementsProps & WithQueryClientProps & WithMarketplaceAppsProps & - WithAccountSettingsProps; + WithAccountSettingsProps & + WithEventsPollingActionProps; const defaultState: State = { assignPublicIPv4Address: false, @@ -866,7 +870,7 @@ class LinodeCreateContainer extends React.PureComponent { ); /** reset the Events polling */ - resetEventsPolling(); + this.props.checkForNewEvents(); // If a VPC was assigned, invalidate the query so that the relevant VPC data // gets displayed in the LinodeEntityDetail @@ -945,7 +949,8 @@ export default recompose( withAgreements, withQueryClient, withAccountSettings, - withMarketplaceApps + withMarketplaceApps, + withEventsPollingActions )(LinodeCreateContainer); const actionsAndLabels = { diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx index 7398340c845..af2bc8d954a 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromImageContent.test.tsx @@ -3,12 +3,11 @@ import * as React from 'react'; import { Provider } from 'react-redux'; import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; -import { queryClientFactory } from 'src/queries/base'; import { storeFactory } from 'src/store'; import { CombinedProps, FromImageContent } from './FromImageContent'; -const store = storeFactory(queryClientFactory()); +const store = storeFactory(); const mockProps: CombinedProps = { accountBackupsEnabled: false, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx index a2a1bc48a6c..4299dff10ee 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.test.tsx @@ -4,12 +4,11 @@ import { Provider } from 'react-redux'; import { linodes } from 'src/__data__/linodes'; import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; -import { queryClientFactory } from 'src/queries/base'; import { storeFactory } from 'src/store'; import { CombinedProps, FromLinodeContent } from './FromLinodeContent'; -const store = storeFactory(queryClientFactory()); +const store = storeFactory(); const mockProps: CombinedProps = { accountBackupsEnabled: false, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx index 68879bb6b47..aedc6d8d18d 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromStackScriptContent.test.tsx @@ -5,7 +5,6 @@ import { Provider } from 'react-redux'; import { UserDefinedFields as mockUserDefinedFields } from 'src/__data__/UserDefinedFields'; import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; import { imageFactory } from 'src/factories/images'; -import { queryClientFactory } from 'src/queries/base'; import { storeFactory } from 'src/store'; import { @@ -13,7 +12,7 @@ import { FromStackScriptContent, } from './FromStackScriptContent'; -const store = storeFactory(queryClientFactory()); +const store = storeFactory(); const mockImages = imageFactory.buildList(10); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeActivity/LinodeActivity.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeActivity/LinodeActivity.tsx index 73e64d06508..0547909441b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeActivity/LinodeActivity.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeActivity/LinodeActivity.tsx @@ -1,9 +1,7 @@ -import { Params } from '@linode/api-v4'; import * as React from 'react'; import { useParams } from 'react-router-dom'; -import EventsLanding from 'src/features/Events/EventsLanding'; -import { getEventsForEntity } from 'src/utilities/getEventsForEntity'; +import { EventsLanding } from 'src/features/Events/EventsLanding'; const LinodeActivity = () => { const { linodeId } = useParams<{ linodeId: string }>(); @@ -11,13 +9,9 @@ const LinodeActivity = () => { return ( - getEventsForEntity(params, 'linode', id) - } data-testid="linode-events-table" emptyMessage="No recent activity for this Linode." entityId={id} - errorMessage="There was an error retrieving activity for this Linode." /> ); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CancelBackupsDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CancelBackupsDialog.tsx index 9d95a20e899..51b52d4db5e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CancelBackupsDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CancelBackupsDialog.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useLinodeBackupsCancelMutation } from 'src/queries/linodes/backups'; import { sendBackupsDisabledEvent } from 'src/utilities/analytics'; @@ -24,13 +24,15 @@ export const CancelBackupsDialog = (props: Props) => { mutateAsync: cancelBackups, } = useLinodeBackupsCancelMutation(linodeId); + const { checkForNewEvents } = useEventsPollingActions(); + const onCancelBackups = async () => { await cancelBackups(); enqueueSnackbar('Backups are being canceled for this Linode', { variant: 'info', }); onClose(); - resetEventsPolling(); + checkForNewEvents(); sendBackupsDisabledEvent(); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx index 26ffee89946..3790cbfa58c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx @@ -5,12 +5,12 @@ import * as React from 'react'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; +import { FormControl } from 'src/components/FormControl'; import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; -import { FormControl } from 'src/components/FormControl'; -import { Paper } from 'src/components/Paper'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useLinodeBackupSnapshotMutation } from 'src/queries/linodes/backups'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -25,6 +25,8 @@ export const CaptureSnapshot = (props: Props) => { const { isReadOnly, linodeId } = props; const { enqueueSnackbar } = useSnackbar(); + const { checkForNewEvents } = useEventsPollingActions(); + const { error: snapshotError, isLoading: isSnapshotLoading, @@ -45,7 +47,7 @@ export const CaptureSnapshot = (props: Props) => { }); setIsSnapshotConfirmationDialogOpen(false); formikHelpers.resetForm(); - resetEventsPolling(); + checkForNewEvents(); }, }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx index e30b20a2b97..327aa57580e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx @@ -7,7 +7,7 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Currency } from 'src/components/Currency'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useLinodeBackupsEnableMutation } from 'src/queries/linodes/backups'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { useTypeQuery } from 'src/queries/types'; @@ -49,9 +49,11 @@ export const EnableBackupsDialog = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); + const { checkForNewEvents } = useEventsPollingActions(); + const handleEnableBackups = async () => { await enableBackups(); - resetEventsPolling(); + checkForNewEvents(); enqueueSnackbar('Backups are being enabled for this Linode.', { variant: 'success', }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx index f3f1ca41d98..f5800389e6c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx @@ -11,7 +11,7 @@ import { FormControl } from 'src/components/FormControl'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { FormHelperText } from 'src/components/FormHelperText'; import { Notice } from 'src/components/Notice/Notice'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useLinodeBackupRestoreMutation } from 'src/queries/linodes/backups'; import { useAllLinodesQuery, @@ -31,6 +31,8 @@ export const RestoreToLinodeDrawer = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const { data: linode } = useLinodeQuery(linodeId, open); + const { checkForNewEvents } = useEventsPollingActions(); + const { data: linodes, error: linodeError, @@ -67,7 +69,7 @@ export const RestoreToLinodeDrawer = (props: Props) => { `Started restoring Linode ${selectedLinodeOption?.label} from a backup`, { variant: 'info' } ); - resetEventsPolling(); + checkForNewEvents(); onClose(); }, }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/BootConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/BootConfigDialog.tsx index c18bbd66850..59562064701 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/BootConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/BootConfigDialog.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useRebootLinodeMutation } from 'src/queries/linodes/linodes'; interface Props { @@ -21,9 +21,11 @@ export const BootConfigDialog = (props: Props) => { const { error, isLoading, mutateAsync } = useRebootLinodeMutation(linodeId); + const { checkForNewEvents } = useEventsPollingActions(); + const onBoot = async () => { await mutateAsync({ config_id: config?.id ?? -1 }); - resetEventsPolling(); + checkForNewEvents(); enqueueSnackbar(`Successfully booted config ${config?.label}`, { variant: 'success', }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx index 971525af51d..9ce3b556f4f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx @@ -16,10 +16,10 @@ import { Checkbox } from 'src/components/Checkbox'; import { Divider } from 'src/components/Divider'; import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; -import { resetEventsPolling } from 'src/eventsPolling'; import { UserDataAccordion } from 'src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordion'; import { regionSupportsMetadata } from 'src/features/Linodes/LinodesCreate/utilities'; import { useFlags } from 'src/hooks/useFlags'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllImagesQuery } from 'src/queries/images'; import { usePreferences } from 'src/queries/preferences'; import { useRegionsQuery } from 'src/queries/regions'; @@ -79,6 +79,8 @@ export const RebuildFromImage = (props: Props) => { isLoading: isLoadingPreferences, } = usePreferences(); + const { checkForNewEvents } = useEventsPollingActions(); + const { enqueueSnackbar } = useSnackbar(); const flags = useFlags(); @@ -153,7 +155,7 @@ export const RebuildFromImage = (props: Props) => { rebuildLinode(linodeId, params) .then((_) => { // Reset events polling since an in-progress event (rebuild) is happening. - resetEventsPolling(); + checkForNewEvents(); setSubmitting(false); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx index 437e0f25427..cf4e0f2ba77 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx @@ -13,7 +13,6 @@ import { AccessPanel } from 'src/components/AccessPanel/AccessPanel'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; -import { resetEventsPolling } from 'src/eventsPolling'; import { ImageEmptyState } from 'src/features/Linodes/LinodesCreate/TabbedContent/ImageEmptyState'; import SelectStackScriptPanel from 'src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptPanel'; import StackScriptDialog from 'src/features/StackScripts/StackScriptDialog'; @@ -24,6 +23,7 @@ import { } from 'src/features/StackScripts/stackScriptUtils'; import { useStackScript } from 'src/hooks/useStackScript'; import { listToItemsByID } from 'src/queries/base'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllImagesQuery } from 'src/queries/images'; import { usePreferences } from 'src/queries/preferences'; import { filterImagesByType } from 'src/store/image/image.helpers'; @@ -73,6 +73,8 @@ export const RebuildFromStackScript = (props: Props) => { isLoading: isLoadingPreferences, } = usePreferences(); + const { checkForNewEvents } = useEventsPollingActions(); + const theme = useTheme(); const { enqueueSnackbar } = useSnackbar(); @@ -127,7 +129,7 @@ export const RebuildFromStackScript = (props: Props) => { }) .then((_) => { // Reset events polling since an in-progress event (rebuild) is happening. - resetEventsPolling(); + checkForNewEvents(); setSubmitting(false); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/BareMetalRescue.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/BareMetalRescue.tsx index cf214de3fa3..d8d0d5c1030 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/BareMetalRescue.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/BareMetalRescue.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -26,6 +26,8 @@ export const BareMetalRescue = (props: Props) => { linodeId !== undefined && isOpen ); + const { checkForNewEvents } = useEventsPollingActions(); + React.useEffect(() => { if (isOpen) { setError(undefined); @@ -42,7 +44,7 @@ export const BareMetalRescue = (props: Props) => { enqueueSnackbar('Linode rescue started.', { variant: 'info', }); - resetEventsPolling(); + checkForNewEvents(); onClose(); }) .catch((err) => { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx index 17378b35907..83ccb886572 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx @@ -10,8 +10,8 @@ import { Dialog } from 'src/components/Dialog/Dialog'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; -import { resetEventsPolling } from 'src/eventsPolling'; import { usePrevious } from 'src/hooks/usePrevious'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; import { useLinodeQuery, @@ -142,6 +142,8 @@ export const StandardRescueDialog = (props: Props) => { deviceMap ); + const { checkForNewEvents } = useEventsPollingActions(); + const { enqueueSnackbar } = useSnackbar(); const [APIError, setAPIError] = React.useState(''); @@ -171,7 +173,7 @@ export const StandardRescueDialog = (props: Props) => { enqueueSnackbar('Linode rescue started.', { variant: 'info', }); - resetEventsPolling(); + checkForNewEvents(); onClose(); }) .catch((errorResponse: APIError[]) => { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx index af7b8a27d6c..d63b0cde6e4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx @@ -17,9 +17,9 @@ import { Notice } from 'src/components/Notice/Notice'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; import { Typography } from 'src/components/Typography'; -import { resetEventsPolling } from 'src/eventsPolling'; import { linodeInTransition } from 'src/features/Linodes/transitions'; import { PlansPanel } from 'src/features/components/PlansPanel/PlansPanel'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; import { useLinodeQuery, @@ -85,6 +85,8 @@ export const LinodeResize = (props: Props) => { mutateAsync: resizeLinode, } = useLinodeResizeMutation(linodeId ?? -1); + const { checkForNewEvents } = useEventsPollingActions(); + const { data: regionsData } = useRegionsQuery(); const hostMaintenance = linode?.status === 'stopped'; @@ -115,7 +117,7 @@ export const LinodeResize = (props: Props) => { migration_type: values.migration_type, type: values.type, }); - resetEventsPolling(); + checkForNewEvents(); enqueueSnackbar('Linode queued for resize.', { variant: 'info', }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsDeletePanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsDeletePanel.tsx index 7378305b1ef..ff27b1d8c9b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsDeletePanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsDeletePanel.tsx @@ -6,7 +6,7 @@ import { Button } from 'src/components/Button/Button'; import { Notice } from 'src/components/Notice/Notice'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useDeleteLinodeMutation, useLinodeQuery, @@ -26,13 +26,15 @@ export const LinodeSettingsDeletePanel = (props: Props) => { mutateAsync: deleteLinode, } = useDeleteLinodeMutation(linodeId); + const { checkForNewEvents } = useEventsPollingActions(); + const history = useHistory(); const [open, setOpen] = React.useState(false); const onDelete = async () => { await deleteLinode(); - resetEventsPolling(); + checkForNewEvents(); history.push('/linodes'); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx index 0297f8516c0..30fe4d0df74 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx @@ -16,7 +16,7 @@ import { InputAdornment } from 'src/components/InputAdornment'; import { Mode, ModeSelect } from 'src/components/ModeSelect/ModeSelect'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllLinodeDisksQuery, useLinodeDiskCreateMutation, @@ -51,6 +51,8 @@ export const CreateDiskDrawer = (props: Props) => { const { linodeId, onClose, open } = props; const { enqueueSnackbar } = useSnackbar(); + const { checkForNewEvents } = useEventsPollingActions(); + const [selectedMode, setSelectedMode] = React.useState('empty'); const { data: linode } = useLinodeQuery(linodeId, open); @@ -92,7 +94,7 @@ export const CreateDiskDrawer = (props: Props) => { : values; await createDisk(cleanedValues); - resetEventsPolling(); + checkForNewEvents(); enqueueSnackbar(`Started creation of disk ${values.label}`, { variant: 'success', }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx index c87b7f0447f..f68bfa0d38b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDiskRow.tsx @@ -7,7 +7,7 @@ import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { Hidden } from 'src/components/Hidden'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { useEvents } from 'src/hooks/useEvents'; +import { useInProgressEvents } from 'src/queries/events/events'; import { LinodeDiskActionMenu } from './LinodeDiskActionMenu'; @@ -23,7 +23,7 @@ interface Props { } export const LinodeDiskRow = React.memo((props: Props) => { - const { inProgressEvents } = useEvents(); + const { data: events } = useInProgressEvents(); const theme = useTheme(); const { disk, @@ -44,7 +44,7 @@ export const LinodeDiskRow = React.memo((props: Props) => { const diskEventsToShowProgressFor = Object.keys(diskEventLabelMap); - const event = inProgressEvents.find( + const event = events?.find( (event) => event.secondary_entity?.id === disk.id && diskEventsToShowProgressFor.includes(event.action) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/ResizeDiskDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/ResizeDiskDrawer.tsx index 9d45b8c3f88..1218f35c4a6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/ResizeDiskDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/ResizeDiskDrawer.tsx @@ -8,13 +8,13 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Code } from 'src/components/Code/Code'; import { Drawer } from 'src/components/Drawer'; +import { FormHelperText } from 'src/components/FormHelperText'; +import { InputAdornment } from 'src/components/InputAdornment'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { TextTooltip } from 'src/components/TextTooltip'; -import { FormHelperText } from 'src/components/FormHelperText'; -import { InputAdornment } from 'src/components/InputAdornment'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllLinodeDisksQuery, useLinodeDiskResizeMutation, @@ -45,6 +45,8 @@ export const ResizeDiskDrawer = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); + const { checkForNewEvents } = useEventsPollingActions(); + const { mutateAsync: resizeDisk, reset } = useLinodeDiskResizeMutation( linodeId, disk?.id ?? -1 @@ -64,7 +66,7 @@ export const ResizeDiskDrawer = (props: Props) => { async onSubmit(values, helpers) { try { await resizeDisk(values); - resetEventsPolling(); + checkForNewEvents(); enqueueSnackbar('Disk queued for resizing.', { variant: 'success' }); onClose(); } catch (e) { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx index b96c599cb47..6a4d181e61e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { MBpsIntraDC } from 'src/constants'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useStartLinodeMutationMutation } from 'src/queries/linodes/actions'; import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; @@ -24,6 +24,8 @@ export const MutationNotification = (props: Props) => { const { data: linode } = useLinodeQuery(linodeId); + const { checkForNewEvents } = useEventsPollingActions(); + const { data: currentTypeInfo } = useTypeQuery( linode?.type ?? '', Boolean(linode?.type) @@ -50,7 +52,7 @@ export const MutationNotification = (props: Props) => { const initMutation = () => { startMutation().then(() => { setIsMutationDrawerOpen(false); - resetEventsPolling(); + checkForNewEvents(); enqueueSnackbar('Linode upgrade has been initiated.', { variant: 'info', }); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx b/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx index c444f59641c..e0b5dd916bb 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx @@ -4,9 +4,9 @@ import { useQueryClient } from 'react-query'; import { Notice } from 'src/components/Notice/Notice'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; -import { resetEventsPolling } from 'src/eventsPolling'; import { useFlags } from 'src/hooks/useFlags'; import { useAccount } from 'src/queries/account'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllLinodeConfigsQuery } from 'src/queries/linodes/configs'; import { useDeleteLinodeMutation, @@ -29,6 +29,8 @@ export const DeleteLinodeDialog = (props: Props) => { const flags = useFlags(); const { data: account } = useAccount(); + const { checkForNewEvents } = useEventsPollingActions(); + const enableVPCActions = isFeatureEnabled( 'VPCs', Boolean(flags.vpc), @@ -78,7 +80,7 @@ export const DeleteLinodeDialog = (props: Props) => { }); } onClose(); - resetEventsPolling(); + checkForNewEvents(); if (onSuccess) { onSuccess(); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx index 15d82f576ed..d89f5ea0ffd 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx @@ -17,8 +17,8 @@ import { transitionText, } from 'src/features/Linodes/transitions'; import { notificationContext as _notificationContext } from 'src/features/NotificationCenter/NotificationContext'; +import { useInProgressEvents } from 'src/queries/events/events'; import { useTypeQuery } from 'src/queries/types'; -import { useRecentEventForLinode } from 'src/store/selectors/recentEventForLinode'; import { capitalizeAllWords } from 'src/utilities/capitalize'; import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { LinodeWithMaintenance } from 'src/utilities/linodes'; @@ -53,7 +53,11 @@ export const LinodeRow = (props: Props) => { const { data: linodeType } = useTypeQuery(type ?? '', type !== null); - const recentEvent = useRecentEventForLinode(id); + const { data: events } = useInProgressEvents(); + + const recentEvent = events?.find( + (e) => e.entity?.type === 'linode' && e.entity.id === id + ); const isBareMetalInstance = linodeType?.class === 'metal'; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx index 49f314ddc3b..5d37d449092 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodesLanding.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import { connect } from 'react-redux'; import { Link, RouteComponentProps, withRouter } from 'react-router-dom'; import { compose } from 'recompose'; @@ -31,7 +30,6 @@ import { LinodeRebuildDialog } from '../LinodesDetail/LinodeRebuild/LinodeRebuil import { RescueDialog } from '../LinodesDetail/LinodeRescue/RescueDialog'; import { LinodeResize } from '../LinodesDetail/LinodeResize/LinodeResize'; import { Action, PowerActionsDialog } from '../PowerActionsDialogOrDrawer'; -import { linodesInTransition as _linodesInTransition } from '../transitions'; import { CardView } from './CardView'; import { DeleteLinodeDialog } from './DeleteLinodeDialog'; import { DisplayGroupedLinodes } from './DisplayGroupedLinodes'; @@ -48,7 +46,6 @@ import { ExtendedStatus, statusToPriority } from './utils'; import type { Config } from '@linode/api-v4/lib/linodes/types'; import type { APIError } from '@linode/api-v4/lib/types'; import type { PreferenceToggleProps } from 'src/components/PreferenceToggle/PreferenceToggle'; -import type { MapState } from 'src/store/types'; interface State { deleteDialogOpen: boolean; @@ -84,15 +81,13 @@ type RouteProps = RouteComponentProps; export interface LinodesLandingProps { LandingHeader?: React.ReactElement; linodesData: LinodeWithMaintenance[]; + linodesInTransition: Set; linodesRequestError?: APIError[]; linodesRequestLoading: boolean; someLinodesHaveScheduledMaintenance: boolean; } -type CombinedProps = LinodesLandingProps & - StateProps & - RouteProps & - WithProfileProps; +type CombinedProps = LinodesLandingProps & RouteProps & WithProfileProps; class ListLinodes extends React.Component { render() { @@ -435,21 +430,8 @@ const sendGroupByAnalytic = (value: boolean) => { sendGroupByTagEnabledEvent(eventCategory, value); }; -interface StateProps { - linodesInTransition: Set; -} - -const mapStateToProps: MapState = (state) => { - return { - linodesInTransition: _linodesInTransition(state.events.events), - }; -}; - -const connected = connect(mapStateToProps, undefined); - export const enhanced = compose( withRouter, - connected, withFeatureFlagConsumer, withProfile ); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/withRecentEvent.ts b/packages/manager/src/features/Linodes/LinodesLanding/withRecentEvent.ts deleted file mode 100644 index ba706119d35..00000000000 --- a/packages/manager/src/features/Linodes/LinodesLanding/withRecentEvent.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Event } from '@linode/api-v4/lib/account'; -import { connect } from 'react-redux'; - -import recentEventForLinode from 'src/store/selectors/recentEventForLinode'; -import { MapState } from 'src/store/types'; - -export interface WithRecentEvent { - recentEvent?: Event; -} - -const mapStateToProps: MapState = ( - state, - props -) => ({ - recentEvent: recentEventForLinode(props.id)(state), -}); - -export default connect(mapStateToProps); diff --git a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx index 8cc1f34c649..652ad7072b3 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx @@ -10,16 +10,19 @@ import { Notice } from 'src/components/Notice/Notice'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { MBpsInterDC } from 'src/constants'; -import { resetEventsPolling } from 'src/eventsPolling'; import { EUAgreementCheckbox } from 'src/features/Account/Agreements/EUAgreementCheckbox'; import { regionSupportsMetadata } from 'src/features/Linodes/LinodesCreate/utilities'; -import useEvents from 'src/hooks/useEvents'; import { useFlags } from 'src/hooks/useFlags'; import { reportAgreementSigningError, useAccountAgreements, useMutateAccountAgreements, } from 'src/queries/accountAgreements'; +import { isEventRelevantToLinode } from 'src/queries/events/event.helpers'; +import { + useEventsPollingActions, + useInProgressEvents, +} from 'src/queries/events/events'; import { useImageQuery } from 'src/queries/images'; import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; import { @@ -29,7 +32,6 @@ import { import { useProfile } from 'src/queries/profile'; import { useRegionsQuery } from 'src/queries/regions'; import { useTypeQuery } from 'src/queries/types'; -import { isEventRelevantToLinode } from 'src/store/events/event.selectors'; import { sendMigrationInitiatedEvent } from 'src/utilities/analytics'; import { formatDate } from 'src/utilities/formatDate'; import { getGDPRDetails } from 'src/utilities/formatRegion'; @@ -52,6 +54,8 @@ export const MigrateLinode = React.memo((props: Props) => { const theme = useTheme(); const { enqueueSnackbar } = useSnackbar(); + const { checkForNewEvents } = useEventsPollingActions(); + const { data: linode } = useLinodeQuery( linodeId ?? -1, linodeId !== undefined && open @@ -72,10 +76,10 @@ export const MigrateLinode = React.memo((props: Props) => { linodeId !== undefined && open ); - const { events } = useEvents(); + const { data: events } = useInProgressEvents(); const eventsForLinode = linodeId - ? events.filter((event) => isEventRelevantToLinode(event, linodeId)) + ? events?.filter((event) => isEventRelevantToLinode(event, linodeId)) ?? [] : []; const { @@ -156,7 +160,7 @@ export const MigrateLinode = React.memo((props: Props) => { return migrateLinode({ region: selectedRegion, }).then(() => { - resetEventsPolling(); + checkForNewEvents(); sendMigrationInitiatedEvent( region, selectedRegion, diff --git a/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx b/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx index 59b7d97182a..62904264a4d 100644 --- a/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx +++ b/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx @@ -8,7 +8,7 @@ import Select from 'src/components/EnhancedSelect/Select'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllLinodeConfigsQuery } from 'src/queries/linodes/configs'; import { useBootLinodeMutation, @@ -80,6 +80,8 @@ export const PowerActionsDialog = (props: Props) => { mutateAsync: shutdownLinode, } = useShutdownLinodeMutation(linodeId ?? -1); + const { checkForNewEvents } = useEventsPollingActions(); + const [selectedConfigID, setSelectConfigID] = React.useState( null ); @@ -117,7 +119,7 @@ export const PowerActionsDialog = (props: Props) => { const mutateAsync = mutationMap[action as 'Power Off']; await mutateAsync(); } - resetEventsPolling(); + checkForNewEvents(); onClose(); }; diff --git a/packages/manager/src/features/Linodes/index.tsx b/packages/manager/src/features/Linodes/index.tsx index 4baee4c06ea..e7bd952a866 100644 --- a/packages/manager/src/features/Linodes/index.tsx +++ b/packages/manager/src/features/Linodes/index.tsx @@ -3,8 +3,10 @@ import { Redirect, Route, Switch } from 'react-router-dom'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { useAllAccountMaintenanceQuery } from 'src/queries/accountMaintenance'; +import { useInProgressEvents } from 'src/queries/events/events'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { addMaintenanceToLinodes } from 'src/utilities/linodes'; +import { linodesInTransition } from './transitions'; const LinodesLanding = React.lazy( () => import('./LinodesLanding/LinodesLanding') @@ -46,6 +48,8 @@ const LinodesLandingWrapper: React.FC = React.memo(() => { (thisAccountMaintenance) => thisAccountMaintenance.entity.type === 'linode' ); + const { data: events } = useInProgressEvents(); + const linodesData = addMaintenanceToLinodes( accountMaintenanceData ?? [], linodes ?? [] @@ -57,6 +61,7 @@ const LinodesLandingWrapper: React.FC = React.memo(() => { someLinodesHaveScheduledMaintenance )} linodesData={linodesData} + linodesInTransition={linodesInTransition(events ?? [])} linodesRequestError={error ?? undefined} linodesRequestLoading={isLoading} /> diff --git a/packages/manager/src/features/Linodes/transitions.ts b/packages/manager/src/features/Linodes/transitions.ts index 853f7fb2ca7..38f2d02eab5 100644 --- a/packages/manager/src/features/Linodes/transitions.ts +++ b/packages/manager/src/features/Linodes/transitions.ts @@ -1,12 +1,11 @@ import { Event, EventAction } from '@linode/api-v4/lib/account'; -import { isInProgressEvent } from 'src/store/events/event.helpers'; import { isEventRelevantToLinode, + isInProgressEvent, isPrimaryEntity, isSecondaryEntity, -} from 'src/store/events/event.selectors'; -import { ExtendedEvent } from 'src/store/events/event.types'; +} from 'src/queries/events/event.helpers'; import { capitalizeAllWords } from 'src/utilities/capitalize'; export const transitionStatus = [ @@ -96,10 +95,8 @@ export const linodesInTransition = (events: Event[]) => { // an in-progress event, but we don't have the updated status from the API yet. // In this case it doesn't have a recentEvent attached (since it has completed), // but its status is still briefly in transition, so give it a progress of 100. -export const getProgressOrDefault = ( - event?: ExtendedEvent, - defaultProgress = 0 -) => event?.percent_complete ?? defaultProgress; +export const getProgressOrDefault = (event?: Event, defaultProgress = 0) => + event?.percent_complete ?? defaultProgress; // Linodes have a literal "status" given by the API (linode.status). There are // states the Linode can be in which aren't entirely communicated with the diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx index 3c20b6a712c..fb65737e227 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx @@ -2,9 +2,8 @@ import { Event, EventAction } from '@linode/api-v4/lib/account/types'; import { partition } from 'ramda'; import * as React from 'react'; -import useEvents from 'src/hooks/useEvents'; -import { isInProgressEvent } from 'src/store/events/event.helpers'; -import { ExtendedEvent } from 'src/store/events/event.types'; +import { useEventsInfiniteQuery } from 'src/queries/events/events'; +import { isInProgressEvent } from 'src/queries/events/event.helpers'; import { removeBlocklistedEvents } from 'src/utilities/eventUtils'; import { notificationContext as _notificationContext } from '../NotificationContext'; @@ -21,8 +20,10 @@ const unwantedEvents: EventAction[] = [ 'volume_update', ]; -export const useEventNotifications = (givenEvents?: ExtendedEvent[]) => { - const events = removeBlocklistedEvents(givenEvents ?? useEvents().events); +export const useEventNotifications = (givenEvents?: Event[]) => { + const events = removeBlocklistedEvents( + givenEvents ?? useEventsInfiniteQuery().events + ); const notificationContext = React.useContext(_notificationContext); const _events = events.filter( @@ -46,19 +47,21 @@ export const useEventNotifications = (givenEvents?: ExtendedEvent[]) => { }; const formatEventForDisplay = ( - event: ExtendedEvent, + event: Event, onClose: () => void ): NotificationItem => ({ body: , countInTotal: !event.seen, + eventId: event.id, id: `event-${event.id}`, }); const formatProgressEventForDisplay = ( - event: ExtendedEvent, + event: Event, onClose: () => void ): NotificationItem => ({ body: , countInTotal: !event.seen, + eventId: event.id, id: `progress-event-${event.id}`, }); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx index 1b1aa6c94ed..32b53185ebf 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx @@ -366,6 +366,7 @@ const formatNotificationForDisplay = ( ): NotificationItem => ({ body: , countInTotal: shouldIncludeInCount, + eventId: -1, id: `notification-${idx}`, }); diff --git a/packages/manager/src/features/NotificationCenter/NotificationSection.tsx b/packages/manager/src/features/NotificationCenter/NotificationSection.tsx index ba2ac6e99af..223aa3ee385 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationSection.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationSection.tsx @@ -40,6 +40,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ export interface NotificationItem { body: JSX.Element | string; countInTotal: boolean; + eventId: number; id: string; } diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx index 447c2ce18e5..c4cf65b9b16 100644 --- a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx @@ -21,7 +21,7 @@ import Notifications from 'src/features/NotificationCenter/Notifications'; import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; import { usePrevious } from 'src/hooks/usePrevious'; import { useNotificationsQuery } from 'src/queries/accountNotifications'; -import { markAllSeen } from 'src/store/events/event.request'; +import { useMarkEventsAsSeen } from 'src/queries/events/events'; import { ThunkDispatch } from 'src/store/types'; import { omittedProps } from 'src/utilities/omittedProps'; @@ -59,6 +59,7 @@ export const NotificationMenu = () => { const formattedNotifications = useFormattedNotifications(); const eventNotifications = useEventNotifications(); const notificationContext = React.useContext(_notificationContext); + const { mutateAsync: markEventsAsSeen } = useMarkEventsAsSeen(); const numNotifications = eventNotifications.filter((thisEvent) => thisEvent.countInTotal).length + @@ -98,12 +99,15 @@ export const NotificationMenu = () => { React.useEffect(() => { if (prevOpen && !notificationContext.menuOpen) { // Dismiss seen notifications after the menu has closed. - dispatch(markAllSeen()); + if (eventNotifications.length > 0) { + markEventsAsSeen(eventNotifications[0].eventId); + } dismissNotifications(notifications ?? [], { prefix: 'notificationMenu' }); } }, [ notificationContext.menuOpen, dismissNotifications, + eventNotifications, notifications, dispatch, prevOpen, diff --git a/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx b/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx index 8957a0d29c8..eafaa9d5bda 100644 --- a/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx @@ -10,8 +10,8 @@ import Select, { Item } from 'src/components/EnhancedSelect'; import { FormControl } from 'src/components/FormControl'; import { FormHelperText } from 'src/components/FormHelperText'; import { Notice } from 'src/components/Notice/Notice'; -import { resetEventsPolling } from 'src/eventsPolling'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllLinodeConfigsQuery } from 'src/queries/linodes/configs'; import { useGrants } from 'src/queries/profile'; import { useAttachVolumeMutation } from 'src/queries/volumes'; @@ -37,6 +37,8 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { const { enqueueSnackbar } = useSnackbar(); + const { checkForNewEvents } = useEventsPollingActions(); + const { data: grants } = useGrants(); const { error, mutateAsync: attachVolume } = useAttachVolumeMutation(); @@ -48,7 +50,7 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { volumeId: volume?.id ?? -1, ...values, }).then(() => { - resetEventsPolling(); + checkForNewEvents(); handleClose(); enqueueSnackbar(`Volume attachment started`, { variant: 'info', diff --git a/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx b/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx index 1c653956e58..b886b720a7e 100644 --- a/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx @@ -8,7 +8,7 @@ import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useGrants } from 'src/queries/profile'; import { useCloneVolumeMutation } from 'src/queries/volumes'; import { @@ -31,6 +31,8 @@ export const CloneVolumeDrawer = (props: Props) => { const { mutateAsync: cloneVolume } = useCloneVolumeMutation(); + const { checkForNewEvents } = useEventsPollingActions(); + const { data: grants } = useGrants(); // Even if a restricted user has the ability to create Volumes, they @@ -56,7 +58,7 @@ export const CloneVolumeDrawer = (props: Props) => { try { await cloneVolume({ label: values.label, volumeId: volume?.id ?? -1 }); onClose(); - resetEventsPolling(); + checkForNewEvents(); } catch (error) { handleFieldErrors(setErrors, error); handleGeneralErrors( diff --git a/packages/manager/src/features/Volumes/DeleteVolumeDialog.tsx b/packages/manager/src/features/Volumes/DeleteVolumeDialog.tsx index dfe66eafe19..762c4bc97c6 100644 --- a/packages/manager/src/features/Volumes/DeleteVolumeDialog.tsx +++ b/packages/manager/src/features/Volumes/DeleteVolumeDialog.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { Notice } from 'src/components/Notice/Notice'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useDeleteVolumeMutation } from 'src/queries/volumes'; interface Props { @@ -21,10 +21,12 @@ export const DeleteVolumeDialog = (props: Props) => { mutateAsync: deleteVolume, } = useDeleteVolumeMutation(); + const { checkForNewEvents } = useEventsPollingActions(); + const onDelete = () => { deleteVolume({ id: volume?.id ?? -1 }).then(() => { onClose(); - resetEventsPolling(); + checkForNewEvents(); }); }; diff --git a/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx b/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx index 2a6359eb6b5..6e9011a7cb7 100644 --- a/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx +++ b/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { Notice } from 'src/components/Notice/Notice'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { useDetachVolumeMutation } from 'src/queries/volumes'; @@ -20,6 +20,8 @@ export const DetachVolumeDialog = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); + const { checkForNewEvents } = useEventsPollingActions(); + const { data: linode } = useLinodeQuery( volume?.linode_id ?? -1, volume?.linode_id !== null && volume?.linode_id !== undefined @@ -34,7 +36,7 @@ export const DetachVolumeDialog = (props: Props) => { const onDetach = () => { detachVolume({ id: volume?.id ?? -1 }).then(() => { onClose(); - resetEventsPolling(); + checkForNewEvents(); enqueueSnackbar(`Volume detachment started`, { variant: 'info', }); diff --git a/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx b/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx index 7b587d57f6c..01bd737889a 100644 --- a/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useGrants } from 'src/queries/profile'; import { useResizeVolumeMutation } from 'src/queries/volumes'; import { @@ -29,6 +29,8 @@ export const ResizeVolumeDrawer = (props: Props) => { const { mutateAsync: resizeVolume } = useResizeVolumeMutation(); + const { checkForNewEvents } = useEventsPollingActions(); + const validationSchema = ResizeVolumeSchema(volume?.size ?? -1); const { enqueueSnackbar } = useSnackbar(); @@ -59,7 +61,7 @@ export const ResizeVolumeDrawer = (props: Props) => { resizeVolume({ size: Number(values.size), volumeId: volume?.id ?? -1 }) .then((_) => { setSubmitting(false); - resetEventsPolling(); + checkForNewEvents(); enqueueSnackbar(`Volume scheduled to be resized.`, { variant: 'success', }); diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx b/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx index 1a7b3df774e..229c2da924f 100644 --- a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx +++ b/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx @@ -7,7 +7,7 @@ import { number, object } from 'yup'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Notice } from 'src/components/Notice/Notice'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useGrants } from 'src/queries/profile'; import { useAttachVolumeMutation } from 'src/queries/volumes'; import { @@ -46,6 +46,8 @@ export const LinodeVolumeAttachForm = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); + const { checkForNewEvents } = useEventsPollingActions(); + const linodeGrant = grants?.linode.find( (grant: Grant) => grant.id === linode.id ); @@ -74,7 +76,7 @@ export const LinodeVolumeAttachForm = (props: Props) => { volumeId: values.volume_id, }); onClose(); - resetEventsPolling(); + checkForNewEvents(); enqueueSnackbar(`Volume attachment started`, { variant: 'info', }); diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeCreateForm.tsx b/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeCreateForm.tsx index 788f3aee40d..30debabe284 100644 --- a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeCreateForm.tsx +++ b/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeCreateForm.tsx @@ -10,7 +10,7 @@ import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { MAX_VOLUME_SIZE } from 'src/constants'; -import { resetEventsPolling } from 'src/eventsPolling'; +import { useEventsPollingActions } from 'src/queries/events/events'; import { useGrants, useProfile } from 'src/queries/profile'; import { useCreateVolumeMutation } from 'src/queries/volumes'; import { sendCreateVolumeEvent } from 'src/utilities/analytics'; @@ -57,6 +57,8 @@ export const LinodeVolumeCreateForm = (props: Props) => { const { data: grants } = useGrants(); const { mutateAsync: createVolume } = useCreateVolumeMutation(); + const { checkForNewEvents } = useEventsPollingActions(); + const disabled = profile?.restricted && !grants?.global.add_volumes; const { @@ -88,7 +90,7 @@ export const LinodeVolumeCreateForm = (props: Props) => { size: maybeCastToNumber(size), tags, }); - resetEventsPolling(); + checkForNewEvents(); enqueueSnackbar(`Volume scheduled for creation.`, { variant: 'success', }); diff --git a/packages/manager/src/features/Volumes/VolumeTableRow.tsx b/packages/manager/src/features/Volumes/VolumeTableRow.tsx index 69ba7e72ef5..1b0921cc45b 100644 --- a/packages/manager/src/features/Volumes/VolumeTableRow.tsx +++ b/packages/manager/src/features/Volumes/VolumeTableRow.tsx @@ -14,7 +14,6 @@ import { Typography } from 'src/components/Typography'; import { useRegionsQuery } from 'src/queries/regions'; import { ActionHandlers, VolumesActionMenu } from './VolumesActionMenu'; -// import useEvents from 'src/hooks/useEvents'; export const useStyles = makeStyles()({ chipWrapper: { @@ -74,14 +73,6 @@ export const VolumeTableRow = React.memo((props: Props) => { const regionLabel = regions?.find((r) => r.id === volume.region)?.label ?? volume.region; - // const { events } = useEvents(); - - // const recentEvent = events.find((event) => event.entity?.id === id); - - // Use this to show a progress bar - // const isUpdating = isVolumeUpdating(recentEvent); - // const progress = progressFromEvent(recentEvent); - return ( diff --git a/packages/manager/src/hooks/useEventHandlers.ts b/packages/manager/src/hooks/useEventHandlers.ts index 39d360620dc..73693eceed1 100644 --- a/packages/manager/src/hooks/useEventHandlers.ts +++ b/packages/manager/src/hooks/useEventHandlers.ts @@ -1,6 +1,5 @@ -import React from 'react'; +import { useQueryClient } from 'react-query'; -import { EventWithStore, events$ } from 'src/events'; import { oauthClientsEventHandler } from 'src/queries/accountOAuth'; import { databaseEventsHandler } from 'src/queries/databases'; import { domainEventsHandler } from 'src/queries/domains'; @@ -14,87 +13,86 @@ import { supportTicketEventHandler } from 'src/queries/support'; import { tokenEventHandler } from 'src/queries/tokens'; import { volumeEventsHandler } from 'src/queries/volumes'; -const eventHandlers: { - filter: (event: EventWithStore) => boolean; - handler: (event: EventWithStore) => void; +import type { Event } from '@linode/api-v4'; +import type { QueryClient } from 'react-query'; + +export interface EventHandlerData { + event: Event; + queryClient: QueryClient; +} + +export const eventHandlers: { + filter: (event: Event) => boolean; + handler: (data: EventHandlerData) => void; }[] = [ { - filter: ({ event }) => - event.action.startsWith('database') && !event._initial, + filter: (event) => event.action.startsWith('database'), handler: databaseEventsHandler, }, { - filter: ({ event }) => - event.action.startsWith('domain') && - !event._initial && - event.entity !== null, + filter: (event) => + event.action.startsWith('domain') && event.entity !== null, handler: domainEventsHandler, }, { - filter: ({ event }) => event.action.startsWith('volume') && !event._initial, + filter: (event) => event.action.startsWith('volume'), handler: volumeEventsHandler, }, { - filter: ({ event }) => - (event.action.startsWith('image') || event.action === 'disk_imagize') && - !event._initial, + filter: (event) => + event.action.startsWith('image') || event.action === 'disk_imagize', handler: imageEventsHandler, }, { - filter: ({ event }) => event.action.startsWith('token') && !event._initial, + filter: (event) => event.action.startsWith('token'), handler: tokenEventHandler, }, { - filter: ({ event }) => - event.action.startsWith('user_ssh_key') && !event._initial, + filter: (event) => event.action.startsWith('user_ssh_key'), handler: sshKeyEventHandler, }, { - filter: ({ event }) => - event.action.startsWith('firewall') && !event._initial, + filter: (event) => event.action.startsWith('firewall'), handler: firewallEventsHandler, }, { - filter: ({ event }) => - event.action.startsWith('nodebalancer') && !event._initial, + filter: (event) => event.action.startsWith('nodebalancer'), handler: nodebalanacerEventHandler, }, { - filter: ({ event }) => - event.action.startsWith('oauth_client') && !event._initial, + filter: (event) => event.action.startsWith('oauth_client'), handler: oauthClientsEventHandler, }, { - filter: ({ event }) => - (event.action.startsWith('linode') || - event.action.startsWith('backups')) && - !event._initial, + filter: (event) => + event.action.startsWith('linode') || event.action.startsWith('backups'), handler: linodeEventsHandler, }, { - filter: ({ event }) => event.action.startsWith('ticket') && !event._initial, + filter: (event) => event.action.startsWith('ticket'), handler: supportTicketEventHandler, }, { - filter: ({ event }) => event.action.startsWith('disk') && !event._initial, + filter: (event) => event.action.startsWith('disk'), handler: diskEventHandler, }, ]; -/** - * Subscribes to events and passes an event to the corresponding - * event handler as it comes in. - */ export const useEventHandlers = () => { - React.useEffect(() => { - const subscriptions = eventHandlers.map(({ filter, handler }) => - events$.filter(filter).subscribe(handler) - ); + const queryClient = useQueryClient(); - return () => { - subscriptions.forEach((sub) => sub.unsubscribe()); - }; - }, []); + /** + * Given an event, this function finds the corresponding + * event handler and invokes it. + */ + const handleEvent = (event: Event) => { + for (const eventHandler of eventHandlers) { + if (eventHandler.filter(event)) { + eventHandler.handler({ event, queryClient }); + return; + } + } + }; - return null; + return { handleEvent }; }; diff --git a/packages/manager/src/hooks/useEvents.ts b/packages/manager/src/hooks/useEvents.ts deleted file mode 100644 index f9f7d99aa47..00000000000 --- a/packages/manager/src/hooks/useEvents.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useSelector } from 'react-redux'; - -import { ApplicationState } from 'src/store'; -import { ExtendedEvent } from 'src/store/events/event.types'; - -export interface UseEvents { - events: ExtendedEvent[]; - inProgressEvents: ExtendedEvent[]; -} - -export const useEvents = () => { - const eventState = useSelector((state: ApplicationState) => state.events); - const events = eventState.events; - const inProgressIDs = Object.keys(eventState.inProgressEvents); - const inProgressEvents = events.filter((thisEvent) => - inProgressIDs.includes(String(thisEvent.id)) - ); - - return { events, inProgressEvents }; -}; - -export default useEvents; diff --git a/packages/manager/src/hooks/useInitialRequests.ts b/packages/manager/src/hooks/useInitialRequests.ts index 5157f22fd85..804cf1ef7de 100644 --- a/packages/manager/src/hooks/useInitialRequests.ts +++ b/packages/manager/src/hooks/useInitialRequests.ts @@ -2,9 +2,7 @@ import { getAccountInfo, getAccountSettings } from '@linode/api-v4/lib/account'; import { getProfile, getUserPreferences } from '@linode/api-v4/lib/profile'; import * as React from 'react'; import { useQueryClient } from 'react-query'; -import { useStore } from 'react-redux'; -import { startEventsInterval } from 'src/events'; import { useAuthentication } from 'src/hooks/useAuthentication'; import { usePendingUpload } from 'src/hooks/usePendingUpload'; import { queryKey as accountQueryKey } from 'src/queries/account'; @@ -16,7 +14,6 @@ import { redirectToLogin } from 'src/session'; * as we make our inital requests. */ export const useInitialRequests = () => { - const store = useStore(); const queryClient = useQueryClient(); const { token } = useAuthentication(); @@ -87,9 +84,6 @@ export const useInitialRequests = () => { }), ]; - // Start events polling - startEventsInterval(store, queryClient); - try { await Promise.all(dataFetchingPromises); } finally { diff --git a/packages/manager/src/hooks/useToastNotifications.tsx b/packages/manager/src/hooks/useToastNotifications.tsx index bc4887796c6..684bd6016d5 100644 --- a/packages/manager/src/hooks/useToastNotifications.tsx +++ b/packages/manager/src/hooks/useToastNotifications.tsx @@ -1,11 +1,9 @@ import { Event, EventAction } from '@linode/api-v4/lib/account/types'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import 'rxjs/add/operator/filter'; import { Link } from 'src/components/Link'; import { SupportLink } from 'src/components/SupportLink'; -import { events$ } from 'src/events'; import { sendLinodeDiskEvent } from 'src/utilities/analytics'; export const getLabel = (event: Event) => event.entity?.label ?? ''; @@ -142,71 +140,58 @@ const toasts: Toasts = { }, }; -/** - * Subscribes to incoming events and displays a toast notification if - * one is defined in `toasts`. - */ export const useToastNotifications = () => { const { enqueueSnackbar } = useSnackbar(); - React.useEffect(() => { - const subscription = events$ - .filter(({ event }) => !event._initial) - .subscribe(({ event }) => { - const toastInfo = toasts[event.action]; - - if (!toastInfo) { - return; - } - - if ( - ['finished', 'notification'].includes(event.status) && - toastInfo.success - ) { - const successMessage = - typeof toastInfo.success === 'function' - ? toastInfo.success(event) - : toastInfo.success; - - enqueueSnackbar(successMessage, { - variant: 'success', - }); - } - - if (event.status === 'failed' && toastInfo.failure) { - const failureMessage = - typeof toastInfo.failure === 'function' - ? toastInfo.failure(event) - : toastInfo.failure; - - const hasSupportLink = - failureMessage?.includes('contact Support') ?? false; - - const formattedFailureMessage = ( - <> - {failureMessage?.replace(/ contact Support/i, '') ?? - failureMessage} - {hasSupportLink ? ( - <> -   - . - - ) : null} - {toastInfo.link ? <> {toastInfo.link} : null} - - ); + const handleGlobalToast = (event: Event) => { + const toastInfo = toasts[event.action]; + + if (!toastInfo) { + return; + } + + if ( + ['finished', 'notification'].includes(event.status) && + toastInfo.success + ) { + const successMessage = + typeof toastInfo.success === 'function' + ? toastInfo.success(event) + : toastInfo.success; - enqueueSnackbar(formattedFailureMessage, { - persist: toastInfo.persistFailureMessage, - variant: 'error', - }); - } + enqueueSnackbar(successMessage, { + variant: 'success', }); + } - return () => { - subscription.unsubscribe(); - }; - }, []); + if (event.status === 'failed' && toastInfo.failure) { + const failureMessage = + typeof toastInfo.failure === 'function' + ? toastInfo.failure(event) + : toastInfo.failure; + + const hasSupportLink = + failureMessage?.includes('contact Support') ?? false; + + const formattedFailureMessage = ( + <> + {failureMessage?.replace(/ contact Support/i, '') ?? failureMessage} + {hasSupportLink ? ( + <> +   + . + + ) : null} + {toastInfo.link ? <> {toastInfo.link} : null} + + ); + + enqueueSnackbar(formattedFailureMessage, { + persist: toastInfo.persistFailureMessage, + variant: 'error', + }); + } + }; - return null; + return { handleGlobalToast }; }; diff --git a/packages/manager/src/index.tsx b/packages/manager/src/index.tsx index ed4c4c683cb..d3fee1824f2 100644 --- a/packages/manager/src/index.tsx +++ b/packages/manager/src/index.tsx @@ -23,7 +23,7 @@ import './index.css'; import { queryClientFactory } from './queries/base'; const queryClient = queryClientFactory(); -const store = storeFactory(queryClient); +const store = storeFactory(); setupInterceptors(store); diff --git a/packages/manager/src/queries/accountOAuth.ts b/packages/manager/src/queries/accountOAuth.ts index 755547104d2..23277144230 100644 --- a/packages/manager/src/queries/accountOAuth.ts +++ b/packages/manager/src/queries/accountOAuth.ts @@ -12,7 +12,7 @@ import { } from '@linode/api-v4'; import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { EventWithStore } from 'src/events'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { queryKey as accountQueryKey } from './account'; @@ -66,7 +66,7 @@ export const useUpdateOAuthClientMutation = (id: string) => { ); }; -export const oauthClientsEventHandler = ({ queryClient }: EventWithStore) => { +export const oauthClientsEventHandler = ({ queryClient }: EventHandlerData) => { // We may over-fetch because on `onSuccess` also invalidates, but this will be // good for UX because Cloud will always be up to date queryClient.invalidateQueries(queryKey); diff --git a/packages/manager/src/queries/databases.ts b/packages/manager/src/queries/databases.ts index 167117a62c7..a17e46f3daf 100644 --- a/packages/manager/src/queries/databases.ts +++ b/packages/manager/src/queries/databases.ts @@ -37,7 +37,7 @@ import { useQueryClient, } from 'react-query'; -import { EventWithStore } from 'src/events'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; import { queryPresets, updateInPaginatedStore } from './base'; @@ -211,7 +211,7 @@ export const useRestoreFromBackupMutation = ( ); }; -export const databaseEventsHandler = (event: EventWithStore) => { +export const databaseEventsHandler = (event: EventHandlerData) => { const { event: { action, entity, status }, queryClient, diff --git a/packages/manager/src/queries/domains.ts b/packages/manager/src/queries/domains.ts index 674e62a0f4b..fe57b612bfc 100644 --- a/packages/manager/src/queries/domains.ts +++ b/packages/manager/src/queries/domains.ts @@ -22,10 +22,10 @@ import { } from '@linode/api-v4/lib/types'; import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { EventWithStore } from 'src/events'; import { getAll } from 'src/utilities/getAll'; import { queryKey as PROFILE_QUERY_KEY } from './profile'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; export const queryKey = 'domains'; @@ -117,7 +117,7 @@ export const useUpdateDomainMutation = () => { ); }; -export const domainEventsHandler = ({ queryClient }: EventWithStore) => { +export const domainEventsHandler = ({ queryClient }: EventHandlerData) => { // Invalidation is agressive beacuse it will invalidate on every domain event, but // it is worth it for the UX benefits. We can fine tune this later if we need to. queryClient.invalidateQueries([queryKey]); diff --git a/packages/manager/src/queries/events/event.helpers.test.ts b/packages/manager/src/queries/events/event.helpers.test.ts new file mode 100644 index 00000000000..bb8a45b671c --- /dev/null +++ b/packages/manager/src/queries/events/event.helpers.test.ts @@ -0,0 +1,281 @@ +import { entityFactory, eventFactory } from 'src/factories/events'; + +import { + doesEventMatchAPIFilter, + generateInFilter, + generateNeqFilter, + generatePollingFilter, + getExistingEventDataForPollingFilterGenerator, + isEventRelevantToLinode, + isEventRelevantToLinodeAsSecondaryEntity, + isInProgressEvent, + isPrimaryEntity, + isSecondaryEntity, +} from './event.helpers'; + +describe('isInProgressEvent', () => { + it('should return true', () => { + const event = eventFactory.build({ percent_complete: 60 }); + const result = isInProgressEvent(event); + expect(result).toBeTruthy(); + }); + + it('should return false', () => { + const event = eventFactory.build({ percent_complete: 100 }); + const result = isInProgressEvent(event); + expect(result).toBeFalsy(); + }); +}); + +describe('isEventRelevantToLinode', () => { + const event0 = eventFactory.build({ + action: 'linode_create', + entity: entityFactory.build({ id: 0 }), + }); + const event1 = eventFactory.build({ + action: 'linode_create', + entity: entityFactory.build({ id: 1 }), + }); + const event2 = eventFactory.build({ + action: 'linode_create', + entity: entityFactory.build({ id: 100 }), + secondary_entity: entityFactory.build({ id: 1 }), + }); + const event3 = eventFactory.build({ + action: 'linode_clone', + secondary_entity: entityFactory.build({ id: 1 }), + }); + it("returns `true` when the linodeId is the event's entity ID, or if it's the event's secondary_entity ID and the event is relevant", () => { + expect(isEventRelevantToLinode(event0, 0)).toBe(true); + expect(isEventRelevantToLinode(event1, 0)).toBe(false); + expect(isEventRelevantToLinode(event2, 1)).toBe(false); + expect(isEventRelevantToLinode(event3, 1)).toBe(true); + }); +}); + +describe('isPrimaryEntity', () => { + const event = eventFactory.build({ + entity: entityFactory.build({ id: 1 }), + }); + it("returns `true` when the linodeId matches the event's entity ID", () => { + expect(isPrimaryEntity(event, 0)).toBe(false); + expect(isPrimaryEntity(event, 1)).toBe(true); + }); +}); + +describe('isSecondaryEntity', () => { + const event = eventFactory.build({ + secondary_entity: entityFactory.build({ id: 1 }), + }); + it("returns `true` when the linodeId matches the event's secondary_entity ID", () => { + expect(isSecondaryEntity(event, 0)).toBe(false); + expect(isSecondaryEntity(event, 1)).toBe(true); + }); +}); + +describe('isEventRelevantToLinodeAsSecondaryEntity', () => { + const linodeCreateEvent = eventFactory.build({ + action: 'linode_create', + }); + const linodeCloneEvent = eventFactory.build({ + action: 'linode_clone', + }); + it('returns `true` if the event type is relevant to Linodes as secondary entities', () => { + expect(isEventRelevantToLinodeAsSecondaryEntity(linodeCreateEvent)).toBe( + false + ); + expect(isEventRelevantToLinodeAsSecondaryEntity(linodeCloneEvent)).toBe( + true + ); + }); +}); + +describe('doesEventMatchAPIFilter', () => { + it('should return false if the API filter filters out profile_update events', () => { + const event = eventFactory.build({ action: 'profile_update' }); + const filter = { action: { '+neq': 'profile_update' } }; + + expect(doesEventMatchAPIFilter(event, filter)).toBe(false); + }); + + it('should return false because this event does not meet the API filter criteria', () => { + const event = eventFactory.build({ + action: 'profile_update', + entity: null, + }); + const filter = { 'entity.id': 2, 'entity.type': 'linode' }; + + expect(doesEventMatchAPIFilter(event, filter)).toBe(false); + }); + + it('should return true because linode_boot would be allowed by this event', () => { + const event = eventFactory.build({ action: 'linode_boot' }); + const filter = { action: { '+neq': 'profile_update' } }; + + expect(doesEventMatchAPIFilter(event, filter)).toBe(true); + }); + + it('should return true because the incoming entity matches the API filter', () => { + const event = eventFactory.build({ entity: { id: 1, type: 'linode' } }); + const filter = { 'entity.id': 1, 'entity.type': 'linode' }; + + expect(doesEventMatchAPIFilter(event, filter)).toBe(true); + }); + + it('should return false because the incoming event does not match the API filter', () => { + const event = eventFactory.build({ entity: { id: 1, type: 'linode' } }); + const filter = { 'entity.id': 2, 'entity.type': 'linode' }; + + expect(doesEventMatchAPIFilter(event, filter)).toBe(false); + }); + + it('should return false because the incoming event does not match the API filter', () => { + const event = eventFactory.build({ entity: null }); + const filter = { 'entity.id': 2, 'entity.type': 'linode' }; + + expect(doesEventMatchAPIFilter(event, filter)).toBe(false); + }); +}); + +describe('requestFilters', () => { + describe('generateInFilter', () => { + it('generates a filter from an array of values', () => { + const result = generateInFilter('id', [1, 2, 3]); + expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); + }); + }); + + describe('generateNeqFilter', () => { + it('generates +neq filter from key and array of values', () => { + const result = generateNeqFilter('id', [1, 2, 3]); + expect(result).toEqual([ + { id: { '+neq': 1 } }, + { id: { '+neq': 2 } }, + { id: { '+neq': 3 } }, + ]); + }); + }); + + describe('generatePollingFilter', () => { + const timestamp = '2020-01-01T00:00:00'; + + it('generates a simple filter when pollIDs is empty', () => { + const result = generatePollingFilter(timestamp, []); + expect(result).toEqual({ created: { '+gte': timestamp } }); + }); + + it('handles "in" IDs', () => { + const inIds = [1, 2, 3]; + const result = generatePollingFilter(timestamp, inIds); + expect(result).toEqual({ + '+or': [ + { created: { '+gte': timestamp } }, + { id: 1 }, + { id: 2 }, + { id: 3 }, + ], + }); + }); + + it('handles "+neq" IDs', () => { + const result = generatePollingFilter(timestamp, [], [1, 2, 3]); + expect(result).toEqual({ + '+and': [ + { created: { '+gte': timestamp } }, + { id: { '+neq': 1 } }, + { id: { '+neq': 2 } }, + { id: { '+neq': 3 } }, + ], + }); + }); + + it('handles "in" and "+neq" IDs together', () => { + const result = generatePollingFilter(timestamp, [1, 2, 3], [4, 5, 6]); + expect(result).toEqual({ + '+or': [ + { + '+and': [ + { created: { '+gte': timestamp } }, + { id: { '+neq': 4 } }, + { id: { '+neq': 5 } }, + { id: { '+neq': 6 } }, + ], + }, + { id: 1 }, + { id: 2 }, + { id: 3 }, + ], + }); + }); + }); +}); + +describe('getExistingEventDataForPollingFilterGenerator', () => { + it('returns the IDs in-progress of events', () => { + const inProgressEvent = eventFactory.build({ + percent_complete: 5, + status: 'started', + }); + const eventThatIsNotInProgress = eventFactory.build({ + percent_complete: null, + status: 'finished', + }); + + const { inProgressEvents } = getExistingEventDataForPollingFilterGenerator( + [eventThatIsNotInProgress, inProgressEvent], + '' + ); + + expect(inProgressEvents).toHaveLength(1); + expect(inProgressEvents[0]).toBe(inProgressEvent.id); + }); + + it('returns the IDs of events with the same timestamp', () => { + const timestamp = '2024-01-05T19:28:19'; + + const eventWithSameTimestamp = eventFactory.build({ + created: timestamp, + percent_complete: null, + status: 'finished', + }); + const eventWithDifferentTimestamp = eventFactory.build({ + created: '2024-01-01T18:28:19', + percent_complete: null, + status: 'finished', + }); + + const { + eventsThatAlreadyHappenedAtTheFilterTime, + } = getExistingEventDataForPollingFilterGenerator( + [eventWithDifferentTimestamp, eventWithSameTimestamp], + timestamp + ); + + expect(eventsThatAlreadyHappenedAtTheFilterTime).toHaveLength(1); + expect(eventsThatAlreadyHappenedAtTheFilterTime[0]).toBe( + eventWithSameTimestamp.id + ); + }); + + it('will return an in-progress event in `inProgressEvents` and not in `eventsThatAlreadyHappenedAtTheFilterTime` even if it happened same second', () => { + // we want this behavior because we want to keep polling in-progress events always + const timestamp = '2024-01-05T19:28:19'; + + const eventWithSameTimestamp = eventFactory.build({ + created: timestamp, + percent_complete: 5, + status: 'started', + }); + + const { + eventsThatAlreadyHappenedAtTheFilterTime, + inProgressEvents, + } = getExistingEventDataForPollingFilterGenerator( + [eventWithSameTimestamp], + timestamp + ); + + expect(eventsThatAlreadyHappenedAtTheFilterTime).toHaveLength(0); + expect(inProgressEvents).toHaveLength(1); + }); +}); diff --git a/packages/manager/src/queries/events/event.helpers.ts b/packages/manager/src/queries/events/event.helpers.ts new file mode 100644 index 00000000000..29c6dd979c1 --- /dev/null +++ b/packages/manager/src/queries/events/event.helpers.ts @@ -0,0 +1,180 @@ +import { Event, EventAction, Filter } from '@linode/api-v4'; + +export const isLongPendingEvent = (event: Event): boolean => { + const { action, status } = event; + return status === 'scheduled' && action === 'image_upload'; +}; + +export const isInProgressEvent = (event: Event) => { + const { percent_complete } = event; + if (percent_complete === null || isLongPendingEvent(event)) { + return false; + } else { + return percent_complete !== null && percent_complete < 100; + } +}; + +export const isEventInProgressDiskImagize = (event: Event): boolean => { + return ( + event.action === 'disk_imagize' && + Boolean(event.secondary_entity) && + isInProgressEvent(event) + ); +}; + +export const isEventImageUpload = (event: Event): boolean => { + return event.action === 'image_upload'; +}; + +export const isEventRelevantToLinode = (event: Event, linodeId: number) => + isPrimaryEntity(event, linodeId) || + (isSecondaryEntity(event, linodeId) && + isEventRelevantToLinodeAsSecondaryEntity(event)); + +export const isPrimaryEntity = (event: Event, linodeId: number) => + event?.entity?.type === 'linode' && event?.entity?.id === linodeId; + +export const isSecondaryEntity = (event: Event, linodeId: number) => + event?.secondary_entity?.type === 'linode' && + event?.secondary_entity?.id === linodeId; + +// Some event types include a Linode as a `secondary_entity`. A subset of these +// events should be included in the `eventsForLinode` selector since they are +// relevant to that Linode. +// +// An example: `clone_linode` events include the source Linode as the `entity` +// and the target Linode as the `secondary_entity`. In this case, we want the +// consumer of the `eventsForLinode` selector to have access to these events so +// it can do things like display progress bars. +export const eventActionsForLinodeAsSecondaryEntity: EventAction[] = [ + 'linode_clone', +]; +export const isEventRelevantToLinodeAsSecondaryEntity = (event: Event) => + eventActionsForLinodeAsSecondaryEntity.includes(event?.action); + +/** + * Because we're using one polling instance (without any API filter) and have many possible event infinite queires + * with various filters, we must make sure that we filter out API-filtered events when we update our filtered + * infinite queries. + * + * @returns This function return true if the API filter `filter` would match the given `event`. We are basiclly + * mimicing the API's filtering for the sake of updating our different events infinite queries. + */ +export const doesEventMatchAPIFilter = (event: Event, filter: Filter) => { + // @ts-expect-error todo fix filter type + const notEqualItems = filter.action?.['+neq']; + if (notEqualItems && notEqualItems.includes(event.action)) { + return false; + } + + if (filter?.['entity.id'] && filter['entity.id'] !== event.entity?.id) { + return false; + } + + if (filter?.['entity.type'] && filter['entity.type'] !== event.entity?.type) { + return false; + } + + return true; +}; + +/** + * Generates a "found in filter" list filter for the API. + * @example + * generateInFilter('id', [1, 22, 333, 4444]); + * would produce { '+or': [1, 22, 333, 4444] } + * and reads as "where `id` is 1, 22, 333, or 4444." + */ +export const generateInFilter = (keyName: string, arr: (number | string)[]) => { + return arr.map((el) => ({ [keyName]: el })); +}; + +export const generateNeqFilter = ( + keyName: string, + arr: (number | string)[] +) => { + return arr.map((el) => ({ [keyName]: { '+neq': el } })); +}; + +/** + * Generates a filter for API requests; + * If we have IDs: + * "If `created` is greater than the timestamp provided or the `id` is one of ids." + * or: + * "If `created` is greater than the timestamp provided." + * + * This filter is invoked on every events request to only get the latest or in-progress events. + */ +export const generatePollingFilter = ( + timestamp: string, + inIds: number[] = [], + neqIds: number[] = [] +) => { + let filter: Filter = { created: { '+gte': timestamp } }; + + if (neqIds.length > 0) { + filter = { + '+and': [filter, ...generateNeqFilter('id', neqIds)], + }; + } + + if (inIds.length > 0) { + filter = { + '+or': [filter, ...generateInFilter('id', inIds)], + }; + } + + return filter; +}; + +/** + * This function exists to... + * - Solve the "same second problem" + * - Continue to poll in-progress events + * + * ## The "same second problem" + * + * It's possible that we may poll for events and a new event might be created after we polled, + * but at the same second. We must overlap our requests so that we catch any "same second" events. + * This function returns the ID of events that happened at `latestEventTime` so that we can + * exclude them (using '+neq') them in our next request. + * + * + * ## Continue to poll in-progress events + * + * Given all events that have been fetched since the app loaded, + * this function returns the IDs of any in progress events so that we + * continue to poll them for updates. + * + * @param events All events that have been fetched since loading the app + * @param latestEventTime The created timestamp of the most revent event (for example: 2020-01-01T12:00:00) + */ +export const getExistingEventDataForPollingFilterGenerator = ( + events: Event[] | undefined, + latestEventTime: string +) => { + if (!events) { + return { + eventsThatAlreadyHappenedAtTheFilterTime: [], + inProgressEvents: [], + }; + } + + return events.reduce<{ + eventsThatAlreadyHappenedAtTheFilterTime: number[]; + inProgressEvents: number[]; + }>( + (acc, event) => { + if (isInProgressEvent(event)) { + acc.inProgressEvents.push(event.id); + return acc; + } + if (event.created === latestEventTime) { + acc.eventsThatAlreadyHappenedAtTheFilterTime.push(event.id); + return acc; + } + return acc; + }, + { eventsThatAlreadyHappenedAtTheFilterTime: [], inProgressEvents: [] } + ); +}; diff --git a/packages/manager/src/queries/events/events.ts b/packages/manager/src/queries/events/events.ts new file mode 100644 index 00000000000..ba2279ef3da --- /dev/null +++ b/packages/manager/src/queries/events/events.ts @@ -0,0 +1,322 @@ +import { getEvents, markEventSeen } from '@linode/api-v4'; +import { DateTime } from 'luxon'; +import { useRef } from 'react'; +import { + InfiniteData, + QueryClient, + QueryKey, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from 'react-query'; + +import { ISO_DATETIME_NO_TZ_FORMAT, POLLING_INTERVALS } from 'src/constants'; +import { useEventHandlers } from 'src/hooks/useEventHandlers'; +import { useToastNotifications } from 'src/hooks/useToastNotifications'; +import { + doesEventMatchAPIFilter, + generatePollingFilter, + getExistingEventDataForPollingFilterGenerator, + isInProgressEvent, +} from 'src/queries/events/event.helpers'; + +import type { APIError, Event, Filter, ResourcePage } from '@linode/api-v4'; + +/** + * Gets an infinitly scrollable list of all Events + * + * This query is kept up to date by `useEventsPoller`. + * + * @param filter an optional filter can be passed to filter out events. If you use a filter, + * you must make sure `doesEventMatchAPIFilter` implements the same filtering so the cache is updated correctly. + * + * The magic here is that we're doing cursor based pagination using the event `id`. + * We are doing this as opposed to page based pagination because we need an accurate way to get + * the next set of events when the items retrned by the server may have shifted. + */ +export const useEventsInfiniteQuery = (filter?: Filter) => { + const query = useInfiniteQuery, APIError[]>( + ['events', 'infinite', filter], + ({ pageParam }) => + getEvents( + {}, + { ...filter, id: pageParam ? { '+lt': pageParam } : undefined } + ), + { + cacheTime: Infinity, + getNextPageParam: ({ data, results }) => { + if (results === data.length) { + return undefined; + } + return data[data.length - 1].id; + }, + staleTime: Infinity, + } + ); + + const events = query.data?.pages.reduce( + (events, page) => [...events, ...page.data], + [] + ); + + return { ...query, events }; +}; + +/** + * A hook that allows you to access an `Event[]` of all in progress events. + * + * The benefit to using this hook is that it only returns in-progress events, + * which is generally a small number of items. This is good for performantly + * finding and rendering the status of an in-progress event. + * + * @example + * const { data: events } = useInProgressEvents(); + * + * const mostRecentLinodeEvent = events?.find( + * (e) => e.entity?.type === 'linode' && e.entity.id === id + * ); + * + * return ( + *

Linode In Progress Event: {event.percent_complete}

+ * ); + */ +export const useInProgressEvents = () => { + return useQuery({ + enabled: false, + queryKey: ['events', 'poller'], + }); +}; + +/** + * Mounting this hook will start polling the events endpoint and + * update our cache as new events come in. + * + * *Warning* This hook should only be mounted once! + */ +export const useEventsPoller = () => { + const { handleGlobalToast } = useToastNotifications(); + const { handleEvent } = useEventHandlers(); + + const queryClient = useQueryClient(); + + const { events } = useEventsInfiniteQuery(); + + const hasFetchedInitialEvents = events !== undefined; + + const mountTimestamp = useRef( + DateTime.now().setZone('utc').toFormat(ISO_DATETIME_NO_TZ_FORMAT) + ); + + useQuery({ + enabled: hasFetchedInitialEvents, + onSuccess(events) { + if (events.length > 0) { + updateEventsQueries(events, queryClient); + + for (const event of events) { + handleGlobalToast(event); + handleEvent(event); + } + } + }, + queryFn: () => { + const data = queryClient.getQueryData>>([ + 'events', + 'infinite', + undefined, + ]); + const events = data?.pages.reduce( + (events, page) => [...events, ...page.data], + [] + ); + // If the user has events, poll for new events based on the most recent event's created time. + // If the user has no events, poll events from the time the app mounted. + const latestEventTime = + events && events.length > 0 + ? events[0].created + : mountTimestamp.current; + + const { + eventsThatAlreadyHappenedAtTheFilterTime, + inProgressEvents, + } = getExistingEventDataForPollingFilterGenerator( + events, + latestEventTime + ); + + const filter = generatePollingFilter( + latestEventTime, + inProgressEvents, + eventsThatAlreadyHappenedAtTheFilterTime + ); + + return getEvents({}, filter).then((data) => data.data); + }, + queryKey: ['events', 'poller'], + refetchInterval: (data) => { + const hasInProgressEvents = data?.some(isInProgressEvent); + if (hasInProgressEvents) { + return POLLING_INTERVALS.IN_PROGRESS; + } + return POLLING_INTERVALS.DEFAULT; + }, + }); + + return null; +}; + +/** + * This hook returns functions that allow us to interact + * with our events polling system. + */ +export const useEventsPollingActions = () => { + const queryClient = useQueryClient(); + + const checkForNewEvents = () => { + // Invalidating the event poller will cause useEventsPoller's `queryFn` + // to re-run and pull down any new events. + queryClient.invalidateQueries(['events', 'poller']); + }; + + return { + /** + * Makes a request to `/v4/account/events` to check for any new events. + * + * This function is intended to be called *after* initiating a long-running + * event (like Linode create) so that Cloud Manager sees the new event and starts + * polling at a faster rate. We do this to give Cloud Manager users a more + * "realtime" feeling experience. + */ + checkForNewEvents, + }; +}; + +export const useMarkEventsAsSeen = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, APIError[], number>( + (eventId) => markEventSeen(eventId), + { + onSuccess: (_, eventId) => { + queryClient.setQueryData>>( + ['events', 'infinite', undefined], + (prev) => { + if (!prev) { + return { + pageParams: [], + pages: [], + }; + } + + let foundLatestSeenEvent = false; + + for (const page of prev.pages) { + for (const event of page.data) { + if (event.id === eventId) { + foundLatestSeenEvent = true; + } + if (foundLatestSeenEvent) { + event.seen = true; + } + } + } + + return { + pageParams: prev?.pageParams ?? [], + pages: prev?.pages ?? [], + }; + } + ); + }, + } + ); +}; + +/** + * For all infinite event queries (with any filter), update each infinite query in + * the cache. + * + * The catch here is that we must mimic the API filtering if we update an infinite query + * with an API filter. + */ +export const updateEventsQueries = ( + events: Event[], + queryClient: QueryClient +) => { + queryClient + .getQueryCache() + .findAll(['events', 'infinite']) + .forEach(({ queryKey }) => { + const apiFilter = queryKey[queryKey.length - 1] as Filter | undefined; + + if (apiFilter === undefined) { + updateEventsQuery(events, queryKey, queryClient); + return; + } + + const filteredEvents = events.filter((event) => { + return doesEventMatchAPIFilter(event, apiFilter); + }); + + updateEventsQuery(filteredEvents, queryKey, queryClient); + }); +}; + +/** + * Updates a events infinite query with incoming events from our polling + * + * This method should do two things with incoming events + * - If the events is already in the cache, update it + * - If the event is new, append it to the top of the first page. + */ +export const updateEventsQuery = ( + events: Event[], + queryKey: QueryKey, + queryClient: QueryClient +) => { + queryClient.setQueryData>>( + queryKey, + (prev) => { + if (!prev) { + return { + pageParams: [], + pages: [], + }; + } + + const updatedEventIndexes: number[] = []; + + for (const page of prev.pages) { + for (let i = 0; i < events.length; i++) { + const indexOfEvent = page.data.findIndex( + (e) => e.id === events[i].id + ); + + if (indexOfEvent !== -1) { + page.data[indexOfEvent] = events[i]; + updatedEventIndexes.push(i); + } + } + } + + const newEvents: Event[] = []; + + for (let i = 0; i < events.length; i++) { + if (!updatedEventIndexes.includes(i)) { + newEvents.push(events[i]); + } + } + + if (newEvents.length > 0) { + // For all events, that remain, append them to the top of the events list + prev.pages[0].data = [...newEvents, ...prev.pages[0].data]; + } + + return { + pageParams: prev.pageParams, + pages: prev.pages, + }; + } + ); +}; diff --git a/packages/manager/src/queries/firewalls.ts b/packages/manager/src/queries/firewalls.ts index 37c3324a011..2cb17838ee0 100644 --- a/packages/manager/src/queries/firewalls.ts +++ b/packages/manager/src/queries/firewalls.ts @@ -22,7 +22,7 @@ import { } from '@linode/api-v4/lib/types'; import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { EventWithStore } from 'src/events'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { getAll } from 'src/utilities/getAll'; @@ -188,7 +188,7 @@ const getAllFirewallsRequest = () => getFirewalls(passedParams, passedFilter) )().then((data) => data.data); -export const firewallEventsHandler = ({ queryClient }: EventWithStore) => { +export const firewallEventsHandler = ({ queryClient }: EventHandlerData) => { // We will over-fetch a little bit, bit this ensures Cloud firewalls are *always* up to date queryClient.invalidateQueries([queryKey]); }; diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index ba62c58eea3..28cd159874f 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -23,11 +23,11 @@ import { useQueryClient, } from 'react-query'; -import { EventWithStore } from 'src/events'; import { getAll } from 'src/utilities/getAll'; import { doesItemExistInPaginatedStore, updateInPaginatedStore } from './base'; import { queryKey as PROFILE_QUERY_KEY } from './profile'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; export const queryKey = 'images'; @@ -126,7 +126,7 @@ export const useAllImagesQuery = ( export const useUploadImageQuery = (payload: ImageUploadPayload) => useMutation(() => uploadImage(payload)); -export const imageEventsHandler = ({ event, queryClient }: EventWithStore) => { +export const imageEventsHandler = ({ event, queryClient }: EventHandlerData) => { const { action, entity, status } = event; // Keep the getAll query up to date so that when we have to use it, it contains accurate data diff --git a/packages/manager/src/queries/linodes/events.ts b/packages/manager/src/queries/linodes/events.ts index 0f7338c6067..2e73804202d 100644 --- a/packages/manager/src/queries/linodes/events.ts +++ b/packages/manager/src/queries/linodes/events.ts @@ -1,4 +1,4 @@ -import { EventWithStore } from 'src/events'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { queryKey as accountNotificationsQueryKey } from 'src/queries/accountNotifications'; import { queryKey as firewallsQueryKey } from 'src/queries/firewalls'; import { queryKey as volumesQueryKey } from 'src/queries/volumes'; @@ -13,7 +13,10 @@ import type { Event } from '@linode/api-v4'; * This event handler runs for any event prefixed with "linode". * For example, "linode_create", "linode_boot", "linode_resize", ... */ -export const linodeEventsHandler = ({ event, queryClient }: EventWithStore) => { +export const linodeEventsHandler = ({ + event, + queryClient, +}: EventHandlerData) => { const linodeId = event.entity?.id; // Early return to cut down the number of invalidations. @@ -111,7 +114,7 @@ export const linodeEventsHandler = ({ event, queryClient }: EventWithStore) => { * Disks have their own handler beacuse the actions are not prefixed with "linode_". * They are prefixed with "disk_". For example "disk_create" or "disk_delete". */ -export const diskEventHandler = ({ event, queryClient }: EventWithStore) => { +export const diskEventHandler = ({ event, queryClient }: EventHandlerData) => { const linodeId = event.entity?.id; if (!linodeId || ['scheduled', 'started'].includes(event.status)) { diff --git a/packages/manager/src/queries/nodebalancers.ts b/packages/manager/src/queries/nodebalancers.ts index 444805429b6..dc11cf2a47d 100644 --- a/packages/manager/src/queries/nodebalancers.ts +++ b/packages/manager/src/queries/nodebalancers.ts @@ -31,7 +31,7 @@ import { useQueryClient, } from 'react-query'; -import { EventWithStore } from 'src/events'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { queryKey as firewallsQueryKey } from 'src/queries/firewalls'; import { parseAPIDate } from 'src/utilities/date'; import { getAll } from 'src/utilities/getAll'; @@ -207,7 +207,7 @@ export const useInfiniteNodebalancersQuery = (filter: Filter) => export const nodebalanacerEventHandler = ({ event, queryClient, -}: EventWithStore) => { +}: EventHandlerData) => { if (event.action.startsWith('nodebalancer_config')) { queryClient.invalidateQueries([ queryKey, diff --git a/packages/manager/src/queries/profile.ts b/packages/manager/src/queries/profile.ts index 93b95599dd5..a95c92040a4 100644 --- a/packages/manager/src/queries/profile.ts +++ b/packages/manager/src/queries/profile.ts @@ -31,11 +31,10 @@ import { useQueryClient, } from 'react-query'; -import { EventWithStore } from 'src/events'; - import { Grants } from '../../../api-v4/lib'; import { queryKey as accountQueryKey } from './account'; import { queryPresets } from './base'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; import type { RequestOptions } from '@linode/api-v4'; @@ -160,7 +159,7 @@ export const useDeleteSSHKeyMutation = (id: number) => { }); }; -export const sshKeyEventHandler = (event: EventWithStore) => { +export const sshKeyEventHandler = (event: EventHandlerData) => { // This event handler is a bit agressive and will over-fetch, but UX will // be great because this will ensure Cloud has up to date data all the time. diff --git a/packages/manager/src/queries/support.ts b/packages/manager/src/queries/support.ts index a43bf940cf6..60aac525eef 100644 --- a/packages/manager/src/queries/support.ts +++ b/packages/manager/src/queries/support.ts @@ -15,7 +15,7 @@ import { useQueryClient, } from 'react-query'; -import { EventWithStore } from 'src/events'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; import type { APIError, @@ -70,6 +70,8 @@ export const useSupportTicketCloseMutation = (id: number) => { }); }; -export const supportTicketEventHandler = ({ queryClient }: EventWithStore) => { +export const supportTicketEventHandler = ({ + queryClient, +}: EventHandlerData) => { queryClient.invalidateQueries([queryKey]); }; diff --git a/packages/manager/src/queries/tokens.ts b/packages/manager/src/queries/tokens.ts index ce9f6d952b4..598204c72c7 100644 --- a/packages/manager/src/queries/tokens.ts +++ b/packages/manager/src/queries/tokens.ts @@ -15,10 +15,9 @@ import { } from '@linode/api-v4/lib/types'; import { useMutation, useQuery, useQueryClient } from 'react-query'; -import { EventWithStore } from 'src/events'; - import { updateInPaginatedStore } from './base'; import { queryKey } from './profile'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; export const useAppTokensQuery = (params?: Params, filter?: Filter) => { return useQuery, APIError[]>({ @@ -95,6 +94,6 @@ export const useRevokeAppAccessTokenMutation = (id: number) => { }); }; -export function tokenEventHandler({ queryClient }: EventWithStore) { +export function tokenEventHandler({ queryClient }: EventHandlerData) { queryClient.invalidateQueries([queryKey, 'personal-access-tokens']); } diff --git a/packages/manager/src/queries/volumes.ts b/packages/manager/src/queries/volumes.ts index 9b237de9b6e..bce51491ada 100644 --- a/packages/manager/src/queries/volumes.ts +++ b/packages/manager/src/queries/volumes.ts @@ -24,11 +24,11 @@ import { useQueryClient, } from 'react-query'; -import { EventWithStore } from 'src/events'; import { getAll } from 'src/utilities/getAll'; import { updateInPaginatedStore } from './base'; import { queryKey as PROFILE_QUERY_KEY } from './profile'; +import { EventHandlerData } from 'src/hooks/useEventHandlers'; export const queryKey = 'volumes'; @@ -177,7 +177,10 @@ export const useAttachVolumeMutation = () => { export const useDetachVolumeMutation = () => useMutation<{}, APIError[], { id: number }>(({ id }) => detachVolume(id)); -export const volumeEventsHandler = ({ event, queryClient }: EventWithStore) => { +export const volumeEventsHandler = ({ + event, + queryClient, +}: EventHandlerData) => { if (['failed', 'finished', 'notification'].includes(event.status)) { queryClient.invalidateQueries([queryKey]); } diff --git a/packages/manager/src/request.test.tsx b/packages/manager/src/request.test.tsx index 55f0fc7be16..8c63b66e11c 100644 --- a/packages/manager/src/request.test.tsx +++ b/packages/manager/src/request.test.tsx @@ -3,7 +3,6 @@ import { AxiosError, AxiosHeaders, AxiosResponse } from 'axios'; import { handleStartSession } from 'src/store/authentication/authentication.actions'; import { profileFactory } from './factories'; -import { queryClientFactory } from './queries/base'; import { LinodeError, getURL, @@ -14,7 +13,7 @@ import { storeFactory } from './store'; import type { APIError } from '@linode/api-v4'; -const store = storeFactory(queryClientFactory()); +const store = storeFactory(); const mockAxiosError = { isAxiosError: true, diff --git a/packages/manager/src/store/authentication/authentication.test.ts b/packages/manager/src/store/authentication/authentication.test.ts index 047c9a9c40c..d471873ba65 100644 --- a/packages/manager/src/store/authentication/authentication.test.ts +++ b/packages/manager/src/store/authentication/authentication.test.ts @@ -1,4 +1,3 @@ -import { queryClientFactory as queryClientFactory } from 'src/queries/base'; import { authentication } from 'src/utilities/storage'; import { storeFactory } from '..'; @@ -8,7 +7,7 @@ import { handleStartSession, } from './authentication.actions'; -const store = storeFactory(queryClientFactory()); +const store = storeFactory(); describe('Authentication', () => { authentication.expire.set('hello world'); diff --git a/packages/manager/src/store/events/event.actions.ts b/packages/manager/src/store/events/event.actions.ts deleted file mode 100644 index 33466fb5df2..00000000000 --- a/packages/manager/src/store/events/event.actions.ts +++ /dev/null @@ -1,19 +0,0 @@ -import actionCreatorFactory from 'typescript-fsa'; - -import { ExtendedEvent } from './event.types'; - -type Event = ExtendedEvent; - -export const ADD_EVENTS = `ADD_EVENTS`; - -export const UPDATE_EVENTS_AS_SEEN = `UPDATE_EVENTS_AS_SEEN`; - -export const actionCreator = actionCreatorFactory(`@@manager/events`); - -export const addEvents = actionCreator(ADD_EVENTS); - -export const updateEventsAsSeen = actionCreator(UPDATE_EVENTS_AS_SEEN); - -export const setPollingInterval = actionCreator('set-polling-interval'); - -export const setRequestDeadline = actionCreator('set-request-deadline'); diff --git a/packages/manager/src/store/events/event.helpers.test.ts b/packages/manager/src/store/events/event.helpers.test.ts deleted file mode 100644 index 306a071ab84..00000000000 --- a/packages/manager/src/store/events/event.helpers.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { Event } from '@linode/api-v4/lib/account'; -import { DateTime } from 'luxon'; - -import { eventFactory } from 'src/factories/events'; - -import { - addToEvents, - findInEvents, - getNumUnseenEvents, - isCompletedEvent, - isInProgressEvent, - mostRecentCreated, - setDeletedEvents, - updateInProgressEvents, -} from './event.helpers'; -import { ExtendedEvent } from './event.types'; - -describe('event.helpers', () => { - describe('findInEvents', () => { - const entity = { - id: 1, - label: 'something', - type: 'whatever', - url: 'whoecares', - }; - - const event: Pick = { entity }; - - it('should return index when entity is found', () => { - const result = findInEvents([event], entity); - expect(result).toBe(0); - }); - - it('should return index when entity is found', () => { - const result = findInEvents([], entity); - expect(result).toBe(-1); - }); - }); - - describe('getNumUnseenEvents', () => { - it('should return number of unseen events', () => { - const events = [ - { seen: true }, - { seen: false }, - { seen: false }, - { seen: true }, - { seen: false }, - { seen: true }, - ]; - const result = getNumUnseenEvents(events); - expect(result).toBe(3); - }); - }); - - describe('isCompletedEvent', () => { - it('should return true', () => { - const event = { percent_complete: 100 }; - const result = isCompletedEvent(event); - expect(result).toBeTruthy(); - }); - - it('should return false', () => { - const event = { percent_complete: 60 }; - const result = isCompletedEvent(event); - expect(result).toBeFalsy(); - }); - }); - - describe('isInProgressEvent', () => { - it('should return true', () => { - const event = eventFactory.build({ percent_complete: 60 }); - const result = isInProgressEvent(event); - expect(result).toBeTruthy(); - }); - - it('should return false', () => { - const event = eventFactory.build({ percent_complete: 100 }); - const result = isInProgressEvent(event); - expect(result).toBeFalsy(); - }); - }); - - describe('mostRecentCreated', () => { - it('should return the most recent event time', () => { - expect( - mostRecentCreated(new Date(`1970-01-01T00:00:00`).getTime(), { - created: `2018-12-03T22:37:20`, - }) - ).toBe( - DateTime.fromISO(`2018-12-03T22:37:20`, { zone: 'UTC' }).valueOf() - ); - - const recentTime = DateTime.fromISO(`2018-12-03T23:37:20`, { - zone: 'UTC', - }).valueOf(); - expect( - mostRecentCreated(recentTime, { created: `2018-12-03T22:37:20` }) - ).toBe(recentTime); - }); - }); - - describe('setDeletedEvents', () => { - it('should add a _deleted prop if entity refers to a deleted entity', () => { - const events: Event[] = [ - { - action: 'linode_delete', - created: '2018-12-02T23:15:45', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17957944, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-02T22:55:43', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17957108, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - ]; - - const expected: ExtendedEvent[] = [ - { - _deleted: '2018-12-02T23:15:45', - action: 'linode_delete', - created: '2018-12-02T23:15:45', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17957944, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - _deleted: '2018-12-02T23:15:45', - action: 'linode_boot', - created: '2018-12-02T22:55:43', - duration: 0, - entity: { - id: 11440645, - label: 'linode11440645', - type: 'linode', - url: '/v4/linode/instances/11440645', - }, - id: 17957108, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - ]; - - const result = setDeletedEvents(events); - expect(result).toEqual(expected); - }); - }); - - describe('addToEvents', () => { - it('should append the event to the list', () => { - const prevEvents: Event[] = [ - { - action: 'linode_delete', - created: '2018-12-02T23:15:45', - duration: 0, - entity: null, - id: 17957944, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-02T23:08:38', - duration: 0, - entity: null, - id: 17957718, - message: null, - percent_complete: 60, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'started', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-02T22:55:43', - duration: 0, - entity: null, - id: 17957108, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - ]; - const events: Event[] = [ - { - action: 'linode_shutdown', - created: '2018-12-02T23:08:38', - duration: 0, - entity: null, - id: 17957718, - message: null, - percent_complete: 70, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'started', - time_remaining: null, - username: 'test', - }, - ]; - const result = addToEvents(prevEvents, events); - - expect(result).toEqual([ - { - action: 'linode_delete', - created: '2018-12-02T23:15:45', - duration: 0, - entity: null, - id: 17957944, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-02T23:08:38', - duration: 0, - entity: null, - id: 17957718, - message: null, - percent_complete: 70, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'started', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_boot', - created: '2018-12-02T22:55:43', - duration: 0, - entity: null, - id: 17957108, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - ]); - }); - }); - - describe('updateInProgressEvents', () => { - it('should do nothing if there are no events', () => { - const inProgressEvents = {}; - const events: Event[] = []; - const result = updateInProgressEvents(inProgressEvents, events); - expect(result).toEqual({}); - }); - - it('should do nothing if there are no in-progress events', () => { - const inProgressEvents = { '999': 23 }; - const events = eventFactory.buildList(3, { percent_complete: 100 }); - const result = updateInProgressEvents(inProgressEvents, events); - expect(result).toEqual({ '999': 23 }); - }); - - it('should add in-progress events to the Map', () => { - const inProgressEvents = {}; - const events = eventFactory.buildList(3, { percent_complete: 100 }); - // Mark one event as in progress - events[1].percent_complete = 60; - const result = updateInProgressEvents(inProgressEvents, events); - - expect(result).toEqual({ [events[1].id]: 60 }); - }); - }); -}); diff --git a/packages/manager/src/store/events/event.helpers.ts b/packages/manager/src/store/events/event.helpers.ts deleted file mode 100644 index ac2776f5796..00000000000 --- a/packages/manager/src/store/events/event.helpers.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { - Entity, - Event, - EventAction, - EventStatus, -} from '@linode/api-v4/lib/account'; -import { compose, equals, findIndex, omit, take, update } from 'ramda'; - -import { parseAPIDate } from 'src/utilities/date'; -import { updateRight } from 'src/utilities/updateRight'; - -import { EntityEvent, ExtendedEvent } from './event.types'; - -/** We use the epoch on our initial request to get all of the users events. */ -export const epoch = new Date(`1970-01-01T00:00:00.000`).getTime(); - -/** - * isRelevantDeletionEvent - * - * Returns `true` if the event: - * a) has _delete in its action (so it's a deletion event) - * b) is not a special case (see below). - * c) the event indicates a non-failed deletion action - * - * If these conditions are met, the entity that the event is attached to - * is assumed to no longer exist in the database. - * - * @param action - */ -export const isRelevantDeletionEvent = ( - action: EventAction, - status: EventStatus -) => { - /** - * These events point to a Linode, not a disk/config/etc., - * but the Linode most likely still exists so we shouldn't mark - * all events related to that Linode as _deleted. - */ - const ignoredDeletionEvents = ['linode_config_delete', 'disk_delete']; - if (ignoredDeletionEvents.includes(action)) { - return false; - } - return action.includes(`_delete`) && status !== 'failed'; -}; - -/** - * Safely find an entity in a list of entities returning the index. - * Will return -1 if the index is not found. - * - * entities {Entity[]} - * entity {null | Entity} - */ -export const findInEvents = ( - events: Pick[], - entity: Partial | null = {} -) => findIndex((e) => equals(e.entity, entity), events); - -export const setDeletedEvents = (events: Event[]) => { - /** Create a list of deletion events. */ - const deletions = events.reduce((result: Event[], event) => { - const { action, entity, status } = event; - if (!entity) { - return result; - } - - if (!isRelevantDeletionEvent(action, status)) { - /** - * This is either a deletion event that hasn't finished - * (so the entity still exists) or it's something like - * disk_delete, where the entity itself (the Linode) - * has not been deleted. - */ - return result; - } - /** - * If we get all the way down here, we have an event - * which indicates that an entity has been deleted; - * add it to the list of deletion events. - */ - return [event, ...result]; - }, []); - - /** If there are no deletions to process, just return the events. */ - if (deletions.length === 0) { - return events; - } - - /** Map events to either deleted or not. */ - return events.map((e) => { - const indexOfFoundEvent = findInEvents(deletions, e.entity); - - return indexOfFoundEvent > -1 - ? { ...e, _deleted: deletions[indexOfFoundEvent].created } - : e; - }); -}; - -export const updateEvents = compose( - /** Finally we return the updated state (right) */ - ([prevEvents, events]) => events, - - /** Nested compose to get around Ramda's shotty typing. */ - compose( - /** Take only the last 100 events. */ - updateRight((prevEvents, events) => take(100, events)), - - /** Marked events "_deleted". */ - updateRight((prevEvents, events) => - setDeletedEvents(events) - ), - - /** Add events to the list. */ - updateRight((prevEvents, events) => - addToEvents(prevEvents, events) - ) - ), - - /** Convert the arguments to a tuple so we can use updateRight. */ - (prevEvents: Event[], events: Event[]) => [prevEvents, events] -); - -/** - * Compare the latestTime with the given Linode's created time and return the most recent. - * - */ -export const mostRecentCreated = ( - latestTime: number, - current: Pick -) => { - const time: number = parseAPIDate(current.created).valueOf(); // Unix time (milliseconds) - return latestTime > time ? latestTime : time; -}; - -/** - * Compile an updated list of events by either updating an event in place or prepending an event - * to prevEvents. - * - * I know this could be much more generic, but I cant get the typing right. - */ -export const addToEvents = (prevArr: Event[], arr: Event[]) => - arr.reduceRight((updatedArray, el) => { - /** - * We need to update in place to maintain the correct timeline of events. Update in-place - * by finding the index then updating at that index. - */ - const indexOfFoundEvent = findIndex(({ id }) => id === el.id, updatedArray); - return indexOfFoundEvent > -1 - ? update(indexOfFoundEvent, el, updatedArray) - : [el, ...updatedArray]; - }, prevArr); - -export const isLongPendingEvent = (event: Event): boolean => { - const { action, status } = event; - return status === 'scheduled' && action === 'image_upload'; -}; - -export const isInProgressEvent = (event: Event) => { - const { percent_complete } = event; - if (percent_complete === null || isLongPendingEvent(event)) { - return false; - } else { - return percent_complete !== null && percent_complete < 100; - } -}; - -export const isLongRunningProgressEventAction = (eventAction: EventAction) => { - const longRunningProgressEventActions: EventAction[] = [ - 'linode_resize', - 'linode_migrate', - 'linode_migrate_datacenter', - ]; - return longRunningProgressEventActions.includes(eventAction); -}; - -export const isCompletedEvent = ({ - percent_complete, -}: Pick) => - percent_complete !== null && percent_complete === 100; - -export const isEntityEvent = (e: Event): e is EntityEvent => Boolean(e.entity); - -export const isEventInProgressDiskImagize = (event: Event): boolean => { - return ( - event.action === 'disk_imagize' && - Boolean(event.secondary_entity) && - isInProgressEvent(event) - ); -}; - -export const isEventImageUpload = (event: Event): boolean => { - return event.action === 'image_upload'; -}; - -/** - * Iterate through new events. - * If an event is "in-progress" it's added to the inProgressEvents map. - * If an event is "completed" it is removed from the inProgressEvents map. - * Otherwise the inProgressEvents is unchanged. - * - * @returns { [key: number]: number } inProgressEvents: key value pair, where the - * key will be the ID of the event and the value will be the percent_complete - * - */ -export const updateInProgressEvents = ( - inProgressEvents: Record, - event: Event[] -) => { - return event.reduce((result, e) => { - const key = String(e.id); - - if (isCompletedEvent(e)) { - return omit([key], result); - } - - return isInProgressEvent(e) - ? { ...result, [key]: e.percent_complete } - : result; - }, inProgressEvents); -}; - -export const getNumUnseenEvents = (events: Pick[]) => - events.reduce((result, event) => (event.seen ? result : result + 1), 0); diff --git a/packages/manager/src/store/events/event.reducer.test.ts b/packages/manager/src/store/events/event.reducer.test.ts deleted file mode 100644 index e5df7301fbb..00000000000 --- a/packages/manager/src/store/events/event.reducer.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Event } from '@linode/api-v4/lib/account'; -import { DateTime } from 'luxon'; - -import { addEvents } from './event.actions'; -import reducer, { defaultState } from './event.reducer'; - -describe('events.reducer', () => { - describe('reducer', () => { - describe('ADD_EVENTS', () => { - describe('with no events', () => { - it('should return the initial state', () => { - const action = addEvents([]); - const state = reducer(defaultState, action); - expect(state).toEqual(defaultState); - }); - }); - - describe('with events', () => { - const events: Event[] = [ - { - action: 'linode_reboot', - created: '2018-12-03T22:34:09', - duration: 0, - entity: { - id: 11241778, - label: 'node-server', - type: 'linode', - url: '/v4/linode/instances/11241778', - }, - id: 18029572, - message: null, - percent_complete: 100, - rate: null, - read: false, - secondary_entity: null, - seen: true, - status: 'finished', - time_remaining: null, - username: 'test', - }, - { - action: 'linode_shutdown', - created: '2018-12-03T19:59:53', - duration: 0, - entity: { - id: 11642886, - label: 'linode11642886', - type: 'linode', - url: '/v4/linode/instances/11642886', - }, - id: 18022171, - message: null, - percent_complete: 80, - rate: null, - read: false, - secondary_entity: null, - seen: false, - status: 'started', - time_remaining: null, - username: 'test', - }, - ]; - const action = addEvents(events); - const state = reducer(defaultState, action); - - it('should add the events to the store.', () => { - expect(state).toHaveProperty('events', events); - }); - - it('should update the mostRecentEventTime', () => { - expect(state).toHaveProperty( - 'mostRecentEventTime', - DateTime.fromISO('2018-12-03T22:34:09', { zone: 'utc' }).valueOf() - ); - }); - - it('should update the countUnseenEvents', () => { - expect(state).toHaveProperty('countUnseenEvents', 1); - }); - - it('should update the inProgressEvents', () => { - expect(state).toHaveProperty('inProgressEvents', { 18022171: 80 }); - }); - }); - }); - }); -}); diff --git a/packages/manager/src/store/events/event.reducer.ts b/packages/manager/src/store/events/event.reducer.ts deleted file mode 100644 index bfb0705d011..00000000000 --- a/packages/manager/src/store/events/event.reducer.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { AnyAction, Reducer } from 'redux'; -import { isType } from 'typescript-fsa'; - -import { - addEvents, - setPollingInterval, - setRequestDeadline, - updateEventsAsSeen, -} from './event.actions'; -import { - epoch, - getNumUnseenEvents, - mostRecentCreated, - updateEvents, - updateInProgressEvents, -} from './event.helpers'; -import { ExtendedEvent } from './event.types'; - -export interface State { - countUnseenEvents: number; - events: ExtendedEvent[]; - inProgressEvents: Record; - mostRecentEventTime: number; - pollingInterval: number; - requestDeadline: number; -} - -export const defaultState: State = { - countUnseenEvents: 0, - events: [], - inProgressEvents: {}, - mostRecentEventTime: epoch, - pollingInterval: 1, - requestDeadline: Date.now(), -}; - -const reducer: Reducer = (state = defaultState, action: AnyAction) => { - if (isType(action, addEvents)) { - const { payload: events } = action; - const { - events: prevEvents, - inProgressEvents: prevInProgressEvents, - mostRecentEventTime, - } = state; - const updatedEvents = updateEvents(prevEvents, events); - - return { - ...state, - countUnseenEvents: getNumUnseenEvents(updatedEvents), - events: updatedEvents, - inProgressEvents: updateInProgressEvents(prevInProgressEvents, events), - mostRecentEventTime: events.reduce( - mostRecentCreated, - mostRecentEventTime - ), - }; - } - - if (isType(action, updateEventsAsSeen)) { - return { - ...state, - countUnseenEvents: 0, - events: state.events.map((event) => ({ ...event, seen: true })), - }; - } - - if (isType(action, setPollingInterval)) { - return { - ...state, - pollingInterval: action.payload, - }; - } - - if (isType(action, setRequestDeadline)) { - return { - ...state, - requestDeadline: action.payload, - }; - } - - return state; -}; - -export default reducer; diff --git a/packages/manager/src/store/events/event.request.ts b/packages/manager/src/store/events/event.request.ts deleted file mode 100644 index 8d37bb8ee97..00000000000 --- a/packages/manager/src/store/events/event.request.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - getEvents as _getEvents, - Event, - markEventSeen, -} from '@linode/api-v4/lib/account'; -import { DateTime } from 'luxon'; - -import { ISO_DATETIME_NO_TZ_FORMAT } from 'src/constants'; -import { parseAPIDate } from 'src/utilities/date'; -import { generatePollingFilter } from 'src/utilities/requestFilters'; - -import { ThunkActionCreator } from '../types'; -import { addEvents, updateEventsAsSeen } from './event.actions'; -import { epoch, isInProgressEvent, isLongPendingEvent } from './event.helpers'; - -/** - * Will send a filtered request for events which have been created on or after the most recent existing - * event or the epoch if there are no stored events. Exclude events already in memory with a "+neq" filter. - */ -export const getEvents: ThunkActionCreator> = () => ( - dispatch, - getState -) => { - const { - events: _events, - inProgressEvents, - mostRecentEventTime, - } = getState().events; - - // Regardless of date created, we request events that are still in-progress. - const inIds = Object.keys(inProgressEvents).map((thisId) => +thisId); - - // We need to keep polling the event for any database that is still creating. - // The same event will change its status from `notification` to `finished`. - const databaseEventIds = _events - .filter( - (event) => - event.action === 'database_create' && event.status === 'notification' - ) - .map((event) => event.id); - - const includeIds = [...inIds, ...databaseEventIds]; - - // Generate a list of event IDs for the "+neq" filter. We want to request events created - // on or after the most recent created date, minus any events we've already requested. - // This is to catch any events that may be "lost" if the request/query lands at just the - // right moment such that we receive some events with a specific created date, but not all. - const neqIds: number[] = []; - if (_events.length > 0) { - _events.forEach((thisEvent) => { - const thisEventCreated = parseAPIDate(thisEvent.created).valueOf(); - - if ( - thisEventCreated === mostRecentEventTime && - !isInProgressEvent(thisEvent) && - !isLongPendingEvent(thisEvent) - ) { - neqIds.push(thisEvent.id); - } - }); - } - - const filters = generatePollingFilter( - DateTime.fromMillis(mostRecentEventTime, { zone: 'utc' }).toFormat( - ISO_DATETIME_NO_TZ_FORMAT - ), - includeIds, - neqIds - ); - - return ( - _getEvents({ page_size: 25 }, filters) - .then((response) => response.data) - /** - * There is where we set _initial on the events. In the default state of events the - * mostRecentEventTime is set to epoch. On the completion of the first successful events - * update the mostRecentEventTime is updated, meaning it's impossible for subsequent events - * to be incorrectly marked as _initial. This addresses our reappearing toast issue. - */ - .then((events) => - events.map((e) => ({ ...e, _initial: mostRecentEventTime === epoch })) - ) - .then((events) => { - if (events.length > 0) { - dispatch(addEvents(events)); - } - return events; - }) - .catch((e) => []) - ); -}; - -/** - * Send a request to mark all currently stored events as seen, then call updateEventsAsSeen which - * iterates the events and marks them seen. - */ -export const markAllSeen: ThunkActionCreator> = () => ( - dispatch, - getState -) => { - const { - events: { events }, - } = getState(); - /** */ - const latestId = events.reduce( - (result, { id }) => (id > result ? id : result), - 0 - ); - - return markEventSeen(latestId) - .then(() => dispatch(updateEventsAsSeen())) - .catch(() => null); -}; diff --git a/packages/manager/src/store/events/event.selectors.test.ts b/packages/manager/src/store/events/event.selectors.test.ts deleted file mode 100644 index 2dc3680d1c1..00000000000 --- a/packages/manager/src/store/events/event.selectors.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { entityFactory, eventFactory } from 'src/factories/events'; - -import { - isEventRelevantToLinode, - isEventRelevantToLinodeAsSecondaryEntity, - isPrimaryEntity, - isSecondaryEntity, -} from './event.selectors'; - -describe('event selector helpers', () => { - describe('isEventRelevantToLinode', () => { - const event0 = eventFactory.build({ - action: 'linode_create', - entity: entityFactory.build({ id: 0 }), - }); - const event1 = eventFactory.build({ - action: 'linode_create', - entity: entityFactory.build({ id: 1 }), - }); - const event2 = eventFactory.build({ - action: 'linode_create', - entity: entityFactory.build({ id: 100 }), - secondary_entity: entityFactory.build({ id: 1 }), - }); - const event3 = eventFactory.build({ - action: 'linode_clone', - secondary_entity: entityFactory.build({ id: 1 }), - }); - it("returns `true` when the linodeId is the event's entity ID, or if it's the event's secondary_entity ID and the event is relevant", () => { - expect(isEventRelevantToLinode(event0, 0)).toBe(true); - expect(isEventRelevantToLinode(event1, 0)).toBe(false); - expect(isEventRelevantToLinode(event2, 1)).toBe(false); - expect(isEventRelevantToLinode(event3, 1)).toBe(true); - }); - }); - - describe('isPrimaryEntity', () => { - const event = eventFactory.build({ - entity: entityFactory.build({ id: 1 }), - }); - it("returns `true` when the linodeId matches the event's entity ID", () => { - expect(isPrimaryEntity(event, 0)).toBe(false); - expect(isPrimaryEntity(event, 1)).toBe(true); - }); - }); - - describe('isSecondaryEntity', () => { - const event = eventFactory.build({ - secondary_entity: entityFactory.build({ id: 1 }), - }); - it("returns `true` when the linodeId matches the event's secondary_entity ID", () => { - expect(isSecondaryEntity(event, 0)).toBe(false); - expect(isSecondaryEntity(event, 1)).toBe(true); - }); - }); - - describe('isEventRelevantToLinodeAsSecondaryEntity', () => { - const linodeCreateEvent = eventFactory.build({ - action: 'linode_create', - }); - const linodeCloneEvent = eventFactory.build({ - action: 'linode_clone', - }); - it('returns `true` if the event type is relevant to Linodes as secondary entities', () => { - expect(isEventRelevantToLinodeAsSecondaryEntity(linodeCreateEvent)).toBe( - false - ); - expect(isEventRelevantToLinodeAsSecondaryEntity(linodeCloneEvent)).toBe( - true - ); - }); - }); -}); diff --git a/packages/manager/src/store/events/event.selectors.ts b/packages/manager/src/store/events/event.selectors.ts deleted file mode 100644 index a3acebb20b6..00000000000 --- a/packages/manager/src/store/events/event.selectors.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Event, EventAction } from '@linode/api-v4/lib/account'; - -import { State } from './event.reducer'; - -export const eventsForLinode = (state: State, linodeId: number) => { - return state.events.filter((event) => - isEventRelevantToLinode(event, linodeId) - ); -}; - -export const isEventRelevantToLinode = (event: Event, linodeId: number) => - isPrimaryEntity(event, linodeId) || - (isSecondaryEntity(event, linodeId) && - isEventRelevantToLinodeAsSecondaryEntity(event)); - -export const isPrimaryEntity = (event: Event, linodeId: number) => - event?.entity?.type === 'linode' && event?.entity?.id === linodeId; - -export const isSecondaryEntity = (event: Event, linodeId: number) => - event?.secondary_entity?.type === 'linode' && - event?.secondary_entity?.id === linodeId; - -// Some event types include a Linode as a `secondary_entity`. A subset of these -// events should be included in the `eventsForLinode` selector since they are -// relevant to that Linode. -// -// An example: `clone_linode` events include the source Linode as the `entity` -// and the target Linode as the `secondary_entity`. In this case, we want the -// consumer of the `eventsForLinode` selector to have access to these events so -// it can do things like display progress bars. -export const eventActionsForLinodeAsSecondaryEntity: EventAction[] = [ - 'linode_clone', -]; -export const isEventRelevantToLinodeAsSecondaryEntity = (event: Event) => - eventActionsForLinodeAsSecondaryEntity.includes(event?.action); diff --git a/packages/manager/src/store/events/event.types.ts b/packages/manager/src/store/events/event.types.ts deleted file mode 100644 index 20a9d10bee1..00000000000 --- a/packages/manager/src/store/events/event.types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Entity, Event } from '@linode/api-v4/lib/account'; - -export interface ExtendedEvent extends Event { - _deleted?: string; - _initial?: boolean; -} - -export interface EntityEvent extends Omit { - entity: Entity; -} diff --git a/packages/manager/src/store/index.ts b/packages/manager/src/store/index.ts index adcc03c2ccd..2728fe0b015 100644 --- a/packages/manager/src/store/index.ts +++ b/packages/manager/src/store/index.ts @@ -1,21 +1,9 @@ -import { QueryClient } from 'react-query'; -import { - Store, - applyMiddleware, - combineReducers, - compose, - createStore, -} from 'redux'; -import thunk from 'redux-thunk'; +import { Store, applyMiddleware, combineReducers, createStore } from 'redux'; import { State as AuthState } from 'src/store/authentication'; import authentication, { defaultState as authenticationDefaultState, } from 'src/store/authentication/authentication.reducer'; -import events, { - State as EventsState, - defaultState as eventsDefaultState, -} from 'src/store/events/event.reducer'; import globalErrors, { State as GlobalErrorState, defaultState as defaultGlobalErrorState, @@ -37,7 +25,6 @@ import stackScriptDialog, { defaultState as stackScriptDialogDefaultState, } from 'src/store/stackScriptDialog'; -import combineEventsMiddleware from './middleware/combineEventsMiddleware'; import mockFeatureFlags, { MockFeatureFlagState, defaultMockFeatureFlagState, @@ -46,13 +33,11 @@ import pendingUpload, { State as PendingUploadState, defaultState as pendingUploadState, } from './pendingUpload'; - -const reduxDevTools = (window as any).__REDUX_DEVTOOLS_EXTENSION__; +import thunk from 'redux-thunk'; export interface ApplicationState { authentication: AuthState; createLinode: LinodeCreateState; - events: EventsState; globalErrors: GlobalErrorState; longviewClients: LongviewState; longviewStats: LongviewStatsState; @@ -64,7 +49,6 @@ export interface ApplicationState { export const defaultState: ApplicationState = { authentication: authenticationDefaultState, createLinode: linodeCreateDefaultState, - events: eventsDefaultState, globalErrors: defaultGlobalErrorState, longviewClients: defaultLongviewState, longviewStats: defaultLongviewStatsState, @@ -79,7 +63,6 @@ export const defaultState: ApplicationState = { const reducers = combineReducers({ authentication, createLinode: linodeCreateReducer, - events, globalErrors, longviewClients: longview, longviewStats, @@ -88,14 +71,7 @@ const reducers = combineReducers({ stackScriptDialog, }); -const enhancersFactory = (queryClient: QueryClient) => - compose( - applyMiddleware(thunk, combineEventsMiddleware([], queryClient)), - reduxDevTools ? reduxDevTools() : (f: any) => f - ) as any; - -// We need an instance of the query client for some event event handlers -export const storeFactory = (queryClient: QueryClient) => - createStore(reducers, defaultState, enhancersFactory(queryClient)); +export const storeFactory = () => + createStore(reducers, defaultState, applyMiddleware(thunk)); export type ApplicationStore = Store; diff --git a/packages/manager/src/store/middleware/combineEventsMiddleware.ts b/packages/manager/src/store/middleware/combineEventsMiddleware.ts deleted file mode 100644 index 1d4b6dfa7fd..00000000000 --- a/packages/manager/src/store/middleware/combineEventsMiddleware.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { compose, equals, uniqWith } from 'ramda'; -import { QueryClient } from 'react-query'; -import { Middleware } from 'redux'; -import { isType } from 'typescript-fsa'; - -import { resetEventsPolling } from 'src/eventsPolling'; -import { - isEntityEvent, - isInProgressEvent, - isLongRunningProgressEventAction, -} from 'src/store/events/event.helpers'; -import { EventHandler } from 'src/store/types'; - -import { addEvents } from '../events/event.actions'; -import { ExtendedEvent } from '../events/event.types'; - -const eventsMiddlewareFactory = ( - eventHandlers: EventHandler[], - queryClient: QueryClient -): Middleware => ({ dispatch, getState }) => (next) => (action: any) => { - if (isType(action, addEvents)) { - const { payload } = action; - /** - * We never want to dispatch actions for initial events, so filter them out. - * We also need to only deal with one event per entity at a time, so uniqueEntityEvents - * handles filtering for unique events. Notably linode_create/linode_boot and others. - */ - const eventsToDispatch = compose( - uniqueEntityEvents, - filterInitial - )(payload); - - /** - * The incoming events is an array, usually of one but potentially many, so we have - * to handle each one. - */ - for (const event of eventsToDispatch) { - /** - * We can bail immediately if there is no associated entity since we need an entity - * to update the store. - * - * but we still need to dispatch the action to add the event to the store - */ - if (!isEntityEvent(event)) { - next(action); - return; - } - - /** - * Having an event we care about, we can call the handlers with the event and dispatch. - */ - for (const handler of eventHandlers) { - handler(event, dispatch, getState, queryClient); - } - - /** - * Finally, if any of these events were in-progress we want to reset the events polling - * interval to keep things moving quickly. - */ - if ( - isInProgressEvent(event) && - // Don't poll aggressively on long-running progress events (like migration). - !isLongRunningProgressEventAction(event.action) - ) { - resetEventsPolling(1.5); - } - } - } - - next(action); -}; - -export default eventsMiddlewareFactory; - -/** - * When creating a Linode with an image you get a [ linode_create, linode_boot ] (in order). - * When deleting a Linode (always) you get a [ linode_shutdown, linode_delete ] (in order). - * Here we're taking the first event unique to the entity, so we will never experience - * multiple events being dispatched for a single entity. - * - * We reverse because events come back in reverse order and want the first (boot and delete). - * - * The better user exp during deletion would be to see the shutdown, then the delete, but we - * can't poll for the deleted Linode's status because it's no longer available from the API. - */ -const uniqueEntityEvents = compose( - (e: ExtendedEvent[]) => e.reverse(), - uniqWith((left, right) => - equals(left.entity, right.entity) - ), - (e: ExtendedEvent[]) => e.reverse() -); - -const filterInitial = (events: ExtendedEvent[]) => - events.filter((e) => !e._initial); diff --git a/packages/manager/src/store/selectors/imageEvents.ts b/packages/manager/src/store/selectors/imageEvents.ts deleted file mode 100644 index c92ef4b1708..00000000000 --- a/packages/manager/src/store/selectors/imageEvents.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Event } from '@linode/api-v4/lib/account'; -import { createSelector } from 'reselect'; - -import { ApplicationState } from 'src/store'; -import { - isEventImageUpload, - isEventInProgressDiskImagize, -} from 'src/store/events/event.helpers'; - -/** - * Return a list of all in-progress - * disk_imagize events where the event - * is in progress and has a secondary_entity - * (which will be the actual Image) - */ -export default createSelector( - (state: ApplicationState['events']) => state.events || [], - (events) => - events.filter( - (thisEvent: Event) => - isEventInProgressDiskImagize(thisEvent) || isEventImageUpload(thisEvent) - ) -); diff --git a/packages/manager/src/store/selectors/inProgressEvents.ts b/packages/manager/src/store/selectors/inProgressEvents.ts deleted file mode 100644 index bc3427e0b60..00000000000 --- a/packages/manager/src/store/selectors/inProgressEvents.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createSelector } from 'reselect'; - -import { ApplicationState } from 'src/store'; -import { isInProgressEvent } from 'src/store/events/event.helpers'; - -export default (type: string) => - createSelector( - (state: ApplicationState) => state.events.events, - (events) => - events.filter( - (e) => isInProgressEvent(e) && e.entity && e.entity.type === type - ) - ); diff --git a/packages/manager/src/store/selectors/recentEventForLinode.test.ts b/packages/manager/src/store/selectors/recentEventForLinode.test.ts deleted file mode 100644 index 9133fffef6e..00000000000 --- a/packages/manager/src/store/selectors/recentEventForLinode.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { entityFactory, eventFactory } from 'src/factories/events'; - -import recentEventForLinode from './recentEventForLinode'; - -describe('recentEventForLinode selector', () => { - const selector = recentEventForLinode(1); - const entity = entityFactory.build({ - id: 1, - }); - - it('only returns in-progress events', () => { - const nonInProgressEvent = eventFactory.build({ - entity, - percent_complete: null, - }); - const inProgressEvent = eventFactory.build({ - entity, - percent_complete: 50, - }); - const mockState1: any = { events: { events: [nonInProgressEvent] } }; - const mockState2: any = { events: { events: [inProgressEvent] } }; - expect(selector(mockState1)).toBeUndefined(); - expect(selector(mockState2)).toBeDefined(); - }); - - it('only returns events relevant to the Linode', () => { - const irrelevantEvent = eventFactory.build({ - entity: entityFactory.build({ - id: 2, - }), - }); - const relevantEvent = eventFactory.build({ - entity, - }); - const mockState1: any = { events: { events: [irrelevantEvent] } }; - const mockState2: any = { events: { events: [relevantEvent] } }; - expect(selector(mockState1)).toBeUndefined(); - expect(selector(mockState2)).toBeDefined(); - }); -}); diff --git a/packages/manager/src/store/selectors/recentEventForLinode.ts b/packages/manager/src/store/selectors/recentEventForLinode.ts deleted file mode 100644 index 4ea56ea2b6f..00000000000 --- a/packages/manager/src/store/selectors/recentEventForLinode.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useSelector } from 'react-redux'; -import { createSelector } from 'reselect'; - -import { ApplicationState } from 'src/store'; - -import { isInProgressEvent } from '../events/event.helpers'; -import { isEventRelevantToLinode } from '../events/event.selectors'; - -export const useRecentEventForLinode = (linodeId: number) => { - const events = useSelector((state: ApplicationState) => state.events.events); - let idx = 0; - const len = events.length; - for (; idx < len; idx += 1) { - const event = events[idx]; - if (isInProgressEvent(event) && isEventRelevantToLinode(event, linodeId)) { - return event; - } - } - return undefined; -}; - -export default (linodeId: number) => - createSelector( - (state: ApplicationState) => state.events.events, - (events) => { - let idx = 0; - const len = events.length; - for (; idx < len; idx += 1) { - const event = events[idx]; - if ( - isInProgressEvent(event) && - isEventRelevantToLinode(event, linodeId) - ) { - return event; - } - } - return undefined; - } - ); diff --git a/packages/manager/src/utilities/getEventsActionLink.ts b/packages/manager/src/utilities/getEventsActionLink.ts index b73c0801281..20d24bf41d5 100644 --- a/packages/manager/src/utilities/getEventsActionLink.ts +++ b/packages/manager/src/utilities/getEventsActionLink.ts @@ -6,11 +6,7 @@ export const getEngineFromDatabaseEntityURL = (url: string) => { return url.match(/databases\/(\w*)\/instances/i)?.[1]; }; -export const getLinkForEvent = ( - action: EventAction, - entity: Entity | null, - deleted: boolean | string | undefined -) => { +export const getLinkForEvent = (action: EventAction, entity: Entity | null) => { const type = entity?.type; const id = entity?.id; const label = entity?.label; @@ -40,7 +36,7 @@ export const getLinkForEvent = ( * If we have a deletion event or an event that is marked as referring to a deleted entity * we don't want a clickable action. */ - if (action.includes('_delete') || deleted) { + if (action.includes('_delete')) { return; } diff --git a/packages/manager/src/utilities/getEventsForEntity.ts b/packages/manager/src/utilities/getEventsForEntity.ts deleted file mode 100644 index 1e3752bd087..00000000000 --- a/packages/manager/src/utilities/getEventsForEntity.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Params } from '@linode/api-v4'; -import { getEvents } from '@linode/api-v4/lib/account'; - -type EntityTypes = 'domain' | 'linode' | 'nodebalancer' | 'volume'; - -export const getEventsForEntity = ( - params: Params = {}, - entityType: EntityTypes, - entityId: number -) => - getEvents(params, { - 'entity.id': entityId, - 'entity.type': entityType, - }); diff --git a/packages/manager/src/utilities/promiseToObservable.ts b/packages/manager/src/utilities/promiseToObservable.ts deleted file mode 100644 index 4e70483aacd..00000000000 --- a/packages/manager/src/utilities/promiseToObservable.ts +++ /dev/null @@ -1,33 +0,0 @@ -import 'rxjs/add/operator/switchMap'; -import { fromPromise } from 'rxjs/observable/fromPromise'; - -/** - * This function will transform a given promise into an observable - * but also cancel the previous inner observable in favor of the new one - * - * This lets us prevent overlapping network requests from occurring - * - * learn more about switchMap: - * https://www.learnrxjs.io/operators/transformation/switchmap.html - * - * Example usage: - * - * transformPromiseToCancellableObservable(getLinodes) - * .subscribe( - * () => console.log('onSuccess'), - * () => console.log('onFailure), - * () => console.log('onCompletion') - * ) - * @param promiseFn a function that returns a promise (i.e ) - */ -const transformPromiseToCancellableObservable = ( - promiseFn: () => Promise -) => { - return ( - fromPromise(promiseFn()) - /** cancel previous subscribed request in favor of the new one */ - .switchMap(() => promiseFn()) - ); -}; - -export default transformPromiseToCancellableObservable; diff --git a/packages/manager/src/utilities/requestFilters.test.ts b/packages/manager/src/utilities/requestFilters.test.ts deleted file mode 100644 index 3ceac2298d1..00000000000 --- a/packages/manager/src/utilities/requestFilters.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - generateInFilter, - generateNeqFilter, - generatePollingFilter, -} from './requestFilters'; - -describe('requestFilters', () => { - describe('generateInFilter', () => { - it('generates a filter from an array of values', () => { - const result = generateInFilter('id', [1, 2, 3]); - expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]); - }); - }); - - describe('generateNeqFilter', () => { - it('generates +neq filter from key and array of values', () => { - const result = generateNeqFilter('id', [1, 2, 3]); - expect(result).toEqual([ - { id: { '+neq': 1 } }, - { id: { '+neq': 2 } }, - { id: { '+neq': 3 } }, - ]); - }); - }); - - describe('generatePollingFilter', () => { - const timestamp = '2020-01-01T00:00:00'; - - it('generates a simple filter when pollIDs is empty', () => { - const result = generatePollingFilter(timestamp, []); - expect(result).toEqual({ created: { '+gte': timestamp } }); - }); - - it('handles "in" IDs', () => { - const inIds = [1, 2, 3]; - const result = generatePollingFilter(timestamp, inIds); - expect(result).toEqual({ - '+or': [ - { created: { '+gte': timestamp } }, - { id: 1 }, - { id: 2 }, - { id: 3 }, - ], - }); - }); - - it('handles "+neq" IDs', () => { - const result = generatePollingFilter(timestamp, [], [1, 2, 3]); - expect(result).toEqual({ - '+and': [ - { created: { '+gte': timestamp } }, - { id: { '+neq': 1 } }, - { id: { '+neq': 2 } }, - { id: { '+neq': 3 } }, - ], - }); - }); - - it('handles "in" and "+neq" IDs together', () => { - const result = generatePollingFilter(timestamp, [1, 2, 3], [4, 5, 6]); - expect(result).toEqual({ - '+or': [ - { - '+and': [ - { created: { '+gte': timestamp } }, - { id: { '+neq': 4 } }, - { id: { '+neq': 5 } }, - { id: { '+neq': 6 } }, - ], - }, - { id: 1 }, - { id: 2 }, - { id: 3 }, - ], - }); - }); - }); -}); diff --git a/packages/manager/src/utilities/requestFilters.ts b/packages/manager/src/utilities/requestFilters.ts deleted file mode 100644 index dfe52233fa0..00000000000 --- a/packages/manager/src/utilities/requestFilters.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Filter } from '@linode/api-v4'; - -/** - * Generates a "found in filter" list filter for the API. - * @example - * generateInFilter('id', [1, 22, 333, 4444]); - * would produce { '+or': [1, 22, 333, 4444] } - * and reads as "where `id` is 1, 22, 333, or 4444." - */ -export const generateInFilter = (keyName: string, arr: (number | string)[]) => { - return arr.map((el) => ({ [keyName]: el })); -}; - -export const generateNeqFilter = ( - keyName: string, - arr: (number | string)[] -) => { - return arr.map((el) => ({ [keyName]: { '+neq': el } })); -}; - -/** - * Generates a filter for API requests; - * If we have IDs: - * "If `created` is greater than the timestamp provided or the `id` is one of ids." - * or: - * "If `created` is greater than the timestamp provided." - * - * This filter is invoked on every events request to only get the latest or in-progress events. - */ -export const generatePollingFilter = ( - timestamp: string, - inIds: number[] = [], - neqIds: number[] = [] -) => { - let filter: Filter = { created: { '+gte': timestamp } }; - - if (neqIds.length > 0) { - filter = { - '+and': [filter, ...generateNeqFilter('id', neqIds)], - }; - } - - if (inIds.length > 0) { - filter = { - '+or': [filter, ...generateInFilter('id', inIds)], - }; - } - - return filter; -}; diff --git a/packages/manager/src/utilities/testHelpers.tsx b/packages/manager/src/utilities/testHelpers.tsx index 05aba12a843..0f872368071 100644 --- a/packages/manager/src/utilities/testHelpers.tsx +++ b/packages/manager/src/utilities/testHelpers.tsx @@ -82,9 +82,7 @@ export const baseStore = (customStore: DeepPartial = {}) => export const wrapWithTheme = (ui: any, options: Options = {}) => { const { customStore, queryClient: passedQueryClient } = options; const queryClient = passedQueryClient ?? queryClientFactory(); - const storeToPass = customStore - ? baseStore(customStore) - : storeFactory(queryClient); + const storeToPass = customStore ? baseStore(customStore) : storeFactory(); // we have to call setupInterceptors so that our API error normalization works as expected // I'm sorry that it makes us pass it the "ApplicationStore" @@ -132,8 +130,7 @@ export const wrapWithStore = (props: { queryClient?: QueryClient; store?: ApplicationStore; }) => { - const queryClient = props.queryClient ?? queryClientFactory(); - const store = props.store ?? storeFactory(queryClient); + const store = props.store ?? storeFactory(); return {props.children}; }; diff --git a/yarn.lock b/yarn.lock index 41e735cd4e7..9db8480f628 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13404,13 +13404,6 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rxjs@^5.5.6: - version "5.5.12" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-5.5.12.tgz#6fa61b8a77c3d793dbaf270bee2f43f652d741cc" - integrity sha512-xx2itnL5sBbqeeiVgNPVuQQ1nC8Jp2WfNJhXWHmElW9YmrpS9UVnNzhP3EH3HFqexO5Tlp8GhYY+WEcqcVMvGw== - dependencies: - symbol-observable "1.0.1" - rxjs@^6.5.2, rxjs@^6.6.0: version "6.6.7" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9" @@ -14176,11 +14169,6 @@ svg-pathdata@^6.0.3: resolved "https://registry.yarnpkg.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz#80b0e0283b652ccbafb69ad4f8f73e8d3fbf2cac" integrity sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw== -symbol-observable@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4" - integrity sha512-Kb3PrPYz4HanVF1LVGuAdW6LoVgIwjUYJGzFe7NDrBLCN4lsV/5J0MFurV+ygS4bRVwrCEt2c7MQ1R2a72oJDw== - symbol-observable@^1.0.4: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"