diff --git a/packages/manager/.changeset/pr-10899-changed-1725595024930.md b/packages/manager/.changeset/pr-10899-changed-1725595024930.md new file mode 100644 index 00000000000..9d38fbc00dd --- /dev/null +++ b/packages/manager/.changeset/pr-10899-changed-1725595024930.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Lower Events historical data fetching to 7 days ([#10899](https://github.com/linode/manager/pull/10899)) diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts index e1f3804b6a5..afd0300cf3b 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts @@ -1,7 +1,219 @@ +/** + * @file Integration tests for Cloud Manager's events fetching and polling behavior. + */ + +import { mockGetEvents, mockGetEventsPolling } from 'support/intercepts/events'; +import { DateTime } from 'luxon'; import { eventFactory } from 'src/factories'; -import { mockGetEvents } from 'support/intercepts/events'; +import { randomNumber } from 'support/util/random'; +import { Interception } from 'cypress/types/net-stubbing'; import { mockGetVolumes } from 'support/intercepts/volumes'; +describe('Event fetching and polling', () => { + /** + * - Confirms that Cloud Manager makes a request to the events endpoint on page load. + * - Confirms API filters are applied to the request to limit the number and type of events retrieved. + */ + it('Makes initial fetch to events endpoint', () => { + const mockNow = DateTime.now(); + + mockGetEvents([]).as('getEvents'); + + cy.clock(mockNow.toJSDate()); + cy.visitWithLogin('/'); + cy.wait('@getEvents').then((xhr) => { + const filters = xhr.request.headers['x-filter']; + const lastWeekTimestamp = mockNow + .minus({ weeks: 1 }) + .toUTC() + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"); + + const timestampFilter = `"created":{"+gt":"${lastWeekTimestamp}"`; + + /* + * Confirm that initial fetch request contains filters to achieve + * each of the following behaviors: + * + * - Exclude `profile_update` events. + * - Retrieve a maximum of 25 events. + * - Sort events by their created date. + * - Only retrieve events created within the past week. + */ + expect(filters).to.contain(timestampFilter); + expect(filters).to.contain('"+neq":"profile_update"'); + expect(filters).to.contain('"+order_by":"id"'); + }); + }); + + /** + * - Confirms that Cloud Manager makes subsequent events requests after the initial request. + * - Confirms API filters are applied to polling requests which differ from the initial request. + */ + it('Polls events endpoint after initial fetch', () => { + const mockEvent = eventFactory.build({ + id: randomNumber(10000, 99999), + created: DateTime.now() + .minus({ minutes: 5 }) + .toUTC() + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"), + duration: null, + rate: null, + percent_complete: null, + }); + + mockGetEvents([mockEvent]).as('getEvents'); + cy.visitWithLogin('/'); + + cy.wait(['@getEvents', '@getEvents']); + cy.get('@getEvents.all').then((xhrRequests: unknown) => { + // Cypress types for `cy.get().then(...)` seem to be wrong. + // Types suggest that `cy.get()` can only yield a jQuery HTML element, but + // when the alias is an HTTP route it yields the request and response data. + const secondRequest = (xhrRequests as Interception[])[1]; + const filters = secondRequest.request.headers['x-filter']; + + /* + * Confirm that polling fetch request contains filters to achieve + * each of the following behaviors: + * + * - Exclude `profile_update` events. + * - Only retrieve events created more recently than the most recent event in the initial fetch. + * - Exclude the most recent event that was included in the initial fetch. + * - Sort events by their ID (TODO). + */ + expect(filters).to.contain('"action":{"+neq":"profile_update"}'); + expect(filters).to.contain(`"created":{"+gte":"${mockEvent.created}"}`); + expect(filters).to.contain(`{"id":{"+neq":${mockEvent.id}}}]`); + expect(filters).to.contain('"+order_by":"id"'); + }); + }); + + /** + * - Confirms that Cloud Manager polls the events endpoint 16 times per second. + * - Confirms that Cloud Manager makes a request to the events endpoint after 16 seconds. + * - Confirms that Cloud Manager does not make a request to the events endpoint before 16 seconds have passed. + * - Confirms Cloud polling rate when there are no in-progress events. + */ + it('Polls events at a 16-second interval', () => { + // Expect Cloud to poll the events endpoint every 16 seconds, + // and configure the test to check if a request has been made + // every simulated second for 16 samples total. + const expectedPollingInterval = 16_000; + const pollingSamples = 16; + const mockNow = DateTime.now(); + const mockNowTimestamp = mockNow + .toUTC() + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"); + + const mockEvent = eventFactory.build({ + id: randomNumber(10000, 99999), + created: DateTime.now() + .minus({ minutes: 5 }) + .toFormat("yyyy-MM-dd'T'HH:mm:ss"), + duration: null, + rate: null, + percent_complete: null, + }); + + mockGetEvents([mockEvent]).as('getEventsInitialFetches'); + + // We need access to the `clock` object directly since we cannot call `cy.clock()` inside + // a `should(() => {})` callback because Cypress commands are disallowed there. + cy.clock(mockNow.toJSDate()).then((clock) => { + cy.visitWithLogin('/'); + + // Confirm that Cloud manager polls the requests endpoint no more than + // once every 16 seconds. + mockGetEventsPolling([mockEvent], mockNowTimestamp).as('getEventsPoll'); + for (let i = 0; i < pollingSamples; i += 1) { + cy.log( + `Confirming that Cloud has not made events request... (${ + i + 1 + }/${pollingSamples})` + ); + cy.get('@getEventsPoll.all').should('have.length', 0); + cy.tick(expectedPollingInterval / pollingSamples, { log: false }); + } + + cy.tick(50); + cy.wait('@getEventsPoll'); + cy.get('@getEventsPoll.all').should('have.length', 1); + }); + }); + + /** + * - Confirms that Cloud Manager polls the events endpoint 2 times per second when there are in-progress events. + * - Confirms that Cloud Manager makes a request to the events endpoint after 2 seconds. + * - Confirms that Cloud Manager does not make a request to the events endpoint before 2 seconds have passed. + * - Confirms Cloud polling rate when there are in-progress events. + */ + it('Polls in-progress events at a 2-second interval', () => { + // When in-progress events are present, expect Cloud to poll the + // events endpoint every 2 seconds, and configure the test to check + // if a request has been made every simulated tenth of a second for + // 20 samples total. + const expectedPollingInterval = 2_000; + const pollingSamples = 20; + const mockNow = DateTime.now(); + const mockNowTimestamp = mockNow + .toUTC() + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"); + + const mockEventBasic = eventFactory.build({ + id: randomNumber(10000, 99999), + created: DateTime.now() + .minus({ minutes: 5 }) + .startOf('second') // Helps with matching the timestamp at the start of the second + .toFormat("yyyy-MM-dd'T'HH:mm:ss"), + duration: null, + rate: null, + percent_complete: null, + }); + + const mockEventInProgress = eventFactory.build({ + id: randomNumber(10000, 99999), + created: DateTime.now().minus({ minutes: 6 }).toISO(), + duration: 0, + rate: null, + percent_complete: 50, + }); + + const mockEvents = [mockEventBasic, mockEventInProgress]; + + // Visit Cloud Manager, and wait for Cloud to fire its first two + // requests to the `events` endpoint: the initial request, and the + // initial polling request. + mockGetEvents(mockEvents).as('getEventsInitialFetches'); + + // We need access to the `clock` object directly since we cannot call `cy.clock()` inside + // a `should(() => {})` callback because Cypress commands are disallowed there. + cy.clock(Date.now()).then((clock) => { + cy.visitWithLogin('/'); + + // Confirm that Cloud manager polls the requests endpoint no more than once + // every 2 seconds. + mockGetEventsPolling(mockEvents, mockNowTimestamp).as('getEventsPoll'); + for (let i = 0; i < pollingSamples; i += 1) { + cy.log( + `Confirming that Cloud has not made events request... (${ + i + 1 + }/${pollingSamples})` + ); + cy.get('@getEventsPoll.all').should('have.length', 0); + cy.tick(expectedPollingInterval / pollingSamples, { log: false }); + } + + cy.tick(50); + cy.wait('@getEventsPoll'); + cy.get('@getEventsPoll.all').should('have.length', 1); + }); + }); +}); + describe('Event Handlers', () => { it('invokes event handlers when new events are polled and makes the correct number of requests', () => { // See https://github.com/linode/manager/pull/10824 diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts new file mode 100644 index 00000000000..79c1638848f --- /dev/null +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts @@ -0,0 +1,309 @@ +/** + * @file Integration tests for Cloud Manager's events menu. + */ + +import { mockGetEvents, mockMarkEventSeen } from 'support/intercepts/events'; +import { ui } from 'support/ui'; +import { eventFactory } from 'src/factories'; +import { buildArray } from 'support/util/arrays'; +import { DateTime } from 'luxon'; +import { randomLabel, randomNumber } from 'support/util/random'; + +describe('Notifications Menu', () => { + /* + * - Confirms that the notification menu shows all events when 20 or fewer exist. + */ + it('Shows all recent events when there are 20 or fewer', () => { + const mockEvents = buildArray(randomNumber(1, 20), (index) => { + return eventFactory.build({ + action: 'linode_delete', + // The response from the API will be ordered by created date, descending. + created: DateTime.local().minus({ minutes: index }).toISO(), + percent_complete: null, + rate: null, + seen: false, + duration: null, + status: 'scheduled', + entity: { + id: 1000 + index, + label: `my-linode-${index}`, + type: 'linode', + url: `/v4/linode/instances/${1000 + index}`, + }, + username: randomLabel(), + }); + }); + + mockGetEvents(mockEvents).as('getEvents'); + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Confirm that all mocked events are shown in the notification menu. + mockEvents.forEach((event) => { + cy.get(`[data-qa-event="${event.id}"]`) + .scrollIntoView() + .should('be.visible'); + }); + }); + }); + + /* + * - Confirms that the notification menu shows no more than 20 events. + * - Confirms that only the most recently created events are shown. + */ + it('Shows the 20 most recently created events', () => { + const mockEvents = buildArray(25, (index) => { + return eventFactory.build({ + action: 'linode_delete', + // The response from the API will be ordered by created date, descending. + created: DateTime.local().minus({ minutes: index }).toISO(), + percent_complete: null, + rate: null, + seen: false, + duration: null, + status: 'scheduled', + entity: { + id: 1000 + index, + label: `my-linode-${index}`, + type: 'linode', + url: `/v4/linode/instances/${1000 + index}`, + }, + username: randomLabel(), + }); + }); + + const shownEvents = mockEvents.slice(0, 20); + const hiddenEvents = mockEvents.slice(20); + + mockGetEvents(mockEvents).as('getEvents'); + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Confirm that first 20 events in response are displayed. + shownEvents.forEach((event) => { + cy.get(`[data-qa-event="${event.id}"]`) + .scrollIntoView() + .should('be.visible'); + }); + + // Confirm that last 5 events in response are not displayed. + hiddenEvents.forEach((event) => { + cy.get(`[data-qa-event="${event.id}"]`).should('not.exist'); + }); + }); + }); + + /* + * - Confirms that notification menu contains a notice when no recent events exist. + */ + it('Shows notice when there are no recent events', () => { + mockGetEvents([]).as('getEvents'); + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + // Find and click Notifications button in Cloud's top app bar. + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Use RegEx here to account for cases where the period is and is not present. + // Period is displayed in Notifications Menu v2, but omitted in v1. + cy.findByText(/No recent events to display\.?/).should('be.visible'); + }); + }); + + /* + * - Confirms that events in menu are marked as seen upon viewing. + * - Uses typical mock data setup where IDs are ordered (descending) and all create dates are unique. + * - Confirms that events are reflected in the UI as being seen or unseen. + */ + it('Marks events in menu as seen', () => { + const mockEvents = buildArray(10, (index) => { + return eventFactory.build({ + // The event with the highest ID is expected to come first in the array. + id: 5000 - index, + action: 'linode_delete', + // The response from the API will be ordered by created date, descending. + created: DateTime.local().minus({ minutes: index }).toISO(), + percent_complete: null, + seen: false, + rate: null, + duration: null, + status: 'scheduled', + entity: { + id: 1000 + index, + label: `my-linode-${index}`, + type: 'linode', + url: `/v4/linode/instances/${1000 + index}`, + }, + username: randomLabel(), + }); + }); + + // In this case, we know that the first event in the mocked events response + // will contain the highest event ID. + const highestEventId = mockEvents[0].id; + + mockGetEvents(mockEvents).as('getEvents'); + mockMarkEventSeen(highestEventId).as('markEventsSeen'); + + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + // Find and click Notifications button in Cloud's top app bar. + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm notification menu opens + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Confirm that UI reflects that every event is unseen. + cy.get('[data-qa-event-seen="false"]').should('have.length', 10); + }); + + // Dismiss the notifications menu by clicking the bell button again. + ui.appBar.find().within(() => { + // This time we have to pass `force: true` to cy.click() + // because otherwise Cypress thinks the element is blocked because + // of the notifications menu popover. + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click({ force: true }); + }); + + // Confirm that Cloud fires a request to the `/events/:id/seen` endpoint, + // where `id` corresponds to the mocked event with the highest ID. + // If Cloud attempts to mark the wrong event ID as seen, this assertion + // will fail. + cy.log(`Waiting for request to '/events/${highestEventId}/seen'`); + cy.wait('@markEventsSeen'); + + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get('[data-qa-notification-menu]') + .should('be.visible') + .within(() => { + // Confirm that UI reflects that every event is now seen. + cy.get('[data-qa-event-seen="true"]').should('have.length', 10); + }); + }); + + /* + * - Confirms event seen logic for non-typical event ordering edge case. + * - Confirms that Cloud marks the correct event as seen even when it's not the first result. + */ + it('Marks events in menu as seen with duplicate created dates and out-of-order IDs', () => { + /* + * When several events are triggered simultaneously, they may have the + * same `created` timestamp. Cloud asks for events to be sorted by created + * date when fetching from the API, but when events have identical timestamps, + * there is no guarantee in which order they will be returned. + * + * As a result, we have to account for cases where the most recent event + * in reality (e.g. as determined by its ID) is not returned first by the API. + * This is especially relevant when marking events as 'seen', as we have + * to explicitly mark the event with the highest ID as seen when the user + * closes their notification menu. + */ + const createTime = DateTime.local().minus({ minutes: 2 }).toISO(); + const mockEvents = buildArray(10, (index) => { + return eventFactory.build({ + // Events are not guaranteed to be ordered by ID; simulate this by using random IDs. + id: randomNumber(1000, 9999), + action: 'linode_delete', + // To simulate multiple events occurring simultaneously, give all + // events the same created timestamp. + created: createTime, + percent_complete: null, + seen: false, + rate: null, + duration: null, + status: 'scheduled', + entity: { + id: 1000 + index, + label: `my-linode-${index}`, + type: 'linode', + url: `/v4/linode/instances/${1000 + index}`, + }, + username: randomLabel(), + }); + }); + + // Sort the mockEvents array by id in descending order to simulate API response + mockEvents.sort((a, b) => b.id - a.id); + + const highestEventId = mockEvents[0].id; + + mockGetEvents(mockEvents).as('getEvents'); + mockMarkEventSeen(highestEventId).as('markEventsSeen'); + + cy.visitWithLogin('/'); + cy.wait('@getEvents'); + + // Find and click Notifications button in Cloud's top app bar. + ui.appBar.find().within(() => { + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm notification menu opens; we don't care about its contents. + cy.get('[data-qa-notification-menu]').should('be.visible'); + + // Dismiss the notifications menu by clicking the bell button again. + ui.appBar.find().within(() => { + // This time we have to pass `force: true` to cy.click() + // because otherwise Cypress thinks the element is blocked because + // of the notifications menu popover. + cy.findByLabelText('Notifications') + .should('be.visible') + .should('be.enabled') + .click({ force: true }); + }); + + // Confirm that Cloud fires a request to the `/events/:id/seen` endpoint, + // where `id` corresponds to the mocked event with the highest ID. + // If Cloud attempts to mark the wrong event ID as seen, this assertion + // will fail. + cy.log(`Waiting for request to '/events/${highestEventId}/seen'`); + cy.wait('@markEventsSeen'); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/events.ts b/packages/manager/cypress/support/intercepts/events.ts index eea7b5fac12..0e12a2f8fb9 100644 --- a/packages/manager/cypress/support/intercepts/events.ts +++ b/packages/manager/cypress/support/intercepts/events.ts @@ -2,9 +2,11 @@ * @file Mocks and intercepts related to notification and event handling. */ -import type { Event, Notification } from '@linode/api-v4'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; +import { makeResponse } from 'support/util/response'; + +import type { Event, Notification } from '@linode/api-v4'; /** * Intercepts GET request to fetch events and mocks response. @@ -21,6 +23,50 @@ export const mockGetEvents = (events: Event[]): Cypress.Chainable => { ); }; +/** + * Intercepts polling GET request to fetch events and mocks response. + * + * Unlike `mockGetEvents`, this utility only intercepts outgoing requests that + * occur while Cloud Manager is polling for events. + * + * @param events - Array of Events with which to mock response. + * @param pollingTimestamp - Timestamp to find when identifying polling requests. + * + * @returns Cypress chainable. + */ +export const mockGetEventsPolling = ( + events: Event[], + pollingTimestamp: string +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher('account/events*'), (req) => { + console.log({ headers: req.headers }); + if ( + req.headers['x-filter'].includes( + `{"created":{"+gte":"${pollingTimestamp}"}}` + ) + ) { + req.reply(paginateResponse(events)); + } else { + req.continue(); + } + }); +}; + +/** + * Intercepts POST request to mark an event as seen and mocks response. + * + * @param eventId - ID of the event for which to intercept request. + * + * @returns Cypress chainable. + */ +export const mockMarkEventSeen = (eventId: number): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`account/events/${eventId}/seen`), + makeResponse({}) + ); +}; + /** * Intercepts GET request to fetch notifications and mocks response. * diff --git a/packages/manager/cypress/support/ui/app-bar.ts b/packages/manager/cypress/support/ui/app-bar.ts new file mode 100644 index 00000000000..163e395760b --- /dev/null +++ b/packages/manager/cypress/support/ui/app-bar.ts @@ -0,0 +1,13 @@ +/** + * UI helpers for Cloud Manager top app bar. + */ +export const appBar = { + /** + * Finds the app bar. + * + * @returns Cypress chainable. + */ + find: () => { + return cy.get('[data-qa-appbar]'); + }, +}; diff --git a/packages/manager/cypress/support/ui/index.ts b/packages/manager/cypress/support/ui/index.ts index 26d8b89ac17..1cba6746bcb 100644 --- a/packages/manager/cypress/support/ui/index.ts +++ b/packages/manager/cypress/support/ui/index.ts @@ -1,5 +1,6 @@ import * as accordion from './accordion'; import * as actionMenu from './action-menu'; +import * as appBar from './app-bar'; import * as autocomplete from './autocomplete'; import * as breadcrumb from './breadcrumb'; import * as buttons from './buttons'; @@ -21,6 +22,7 @@ import * as userMenu from './user-menu'; export const ui = { ...accordion, ...actionMenu, + ...appBar, ...autocomplete, ...breadcrumb, ...buttons, diff --git a/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx index 446bffb1c48..b5ad7c1cb0e 100644 --- a/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx +++ b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx @@ -44,6 +44,8 @@ export const NotificationCenterEvent = React.memo( return ( diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx index 65d78b3cdcb..ffd769a1b95 100644 --- a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx @@ -23,7 +23,7 @@ import { usePrevious } from 'src/hooks/usePrevious'; import { useNotificationsQuery } from 'src/queries/account/notifications'; import { isInProgressEvent } from 'src/queries/events/event.helpers'; import { - useEventsInfiniteQuery, + useInitialEventsQuery, useMarkEventsAsSeen, } from 'src/queries/events/events'; import { rotate360 } from 'src/styles/keyframes'; @@ -37,7 +37,8 @@ export const NotificationMenu = () => { const formattedNotifications = useFormattedNotifications(); const notificationContext = React.useContext(_notificationContext); - const { data, events } = useEventsInfiniteQuery(); + const { data, events } = useInitialEventsQuery(); + const eventsData = data?.data ?? []; const { mutateAsync: markEventsAsSeen } = useMarkEventsAsSeen(); const numNotifications = @@ -132,6 +133,7 @@ export const NotificationMenu = () => { }, }} anchorEl={anchorRef.current} + data-qa-notification-menu id={id} onClose={handleClose} open={notificationContext.menuOpen} @@ -150,13 +152,22 @@ export const NotificationMenu = () => { - {data?.pages[0].data.slice(0, 20).map((event) => ( - - ))} + + {eventsData.length > 0 ? ( + eventsData + .slice(0, 20) + .map((event) => ( + + )) + ) : ( + + No recent events to display + + )} diff --git a/packages/manager/src/features/TopMenu/TopMenu.tsx b/packages/manager/src/features/TopMenu/TopMenu.tsx index c9f8c5bdc91..4276ca7695f 100644 --- a/packages/manager/src/features/TopMenu/TopMenu.tsx +++ b/packages/manager/src/features/TopMenu/TopMenu.tsx @@ -46,7 +46,7 @@ export const TopMenu = React.memo((props: TopMenuProps) => { )} - + ({ '&.MuiToolbar-root': { diff --git a/packages/manager/src/queries/events/events.ts b/packages/manager/src/queries/events/events.ts index 95009a10fc8..0528df5c6c6 100644 --- a/packages/manager/src/queries/events/events.ts +++ b/packages/manager/src/queries/events/events.ts @@ -6,7 +6,7 @@ import { useQueryClient, } from '@tanstack/react-query'; import { DateTime } from 'luxon'; -import { useEffect, useRef } from 'react'; +import { useEffect, useState } from 'react'; import { ISO_DATETIME_NO_TZ_FORMAT, POLLING_INTERVALS } from 'src/constants'; import { EVENTS_LIST_FILTER } from 'src/features/Events/constants'; @@ -26,6 +26,47 @@ import type { QueryKey, } from '@tanstack/react-query'; +/** + * This query exists to get the first 7 days of events when you load the app. + * + * Using the first page of useEventsInfiniteQuery would be ideal, but we are going to try this... + * + * @note This initial query should match X-Filtering that our poller does. If we want this query + * to have a different filter than our poller, we will need to implement filtering in + * `updateEventsQueries` like we do for our infinite queries. + */ +export const useInitialEventsQuery = () => { + /** + * We only want to get events from the last 7 days. + */ + const [defaultCreatedFilter] = useState( + DateTime.now() + .minus({ days: 7 }) + .setZone('utc') + .toFormat(ISO_DATETIME_NO_TZ_FORMAT) + ); + + const query = useQuery, APIError[]>({ + gcTime: Infinity, + queryFn: () => + getEvents( + {}, + { + ...EVENTS_LIST_FILTER, + '+order': 'desc', + '+order_by': 'id', + created: { '+gt': defaultCreatedFilter }, + } + ), + queryKey: ['events', 'initial'], + staleTime: Infinity, + }); + + const events = query.data?.data; + + return { ...query, events }; +}; + /** * Gets an infinitely scrollable list of all Events * @@ -107,32 +148,27 @@ export const useEventsPoller = () => { const queryClient = useQueryClient(); - const { events } = useEventsInfiniteQuery(); + const { data: initialEvents } = useInitialEventsQuery(); - const hasFetchedInitialEvents = events !== undefined; + const hasFetchedInitialEvents = initialEvents !== undefined; - const mountTimestamp = useRef( + const [mountTimestamp] = useState( DateTime.now().setZone('utc').toFormat(ISO_DATETIME_NO_TZ_FORMAT) ); - const { data: polledEvents } = useQuery({ + const { data: events } = useQuery({ enabled: hasFetchedInitialEvents, queryFn: () => { - const data = queryClient.getQueryData>>([ + const data = queryClient.getQueryData>([ 'events', - 'infinite', - EVENTS_LIST_FILTER, + 'initial', ]); - const events = data?.pages.reduce( - (events, page) => [...events, ...page.data], - [] - ); + const events = data?.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; + events && events.length > 0 ? events[0].created : mountTimestamp; const { eventsThatAlreadyHappenedAtTheFilterTime, @@ -161,15 +197,15 @@ export const useEventsPoller = () => { }); useEffect(() => { - if (polledEvents && polledEvents.length > 0) { - updateEventsQueries(polledEvents, queryClient); + if (events && events.length > 0) { + updateEventsQueries(events, queryClient); - for (const event of polledEvents) { + for (const event of events) { handleGlobalToast(event); handleEvent(event); } } - }, [polledEvents]); + }, [events]); return null; }; @@ -208,6 +244,25 @@ export const useMarkEventsAsSeen = () => { return useMutation<{}, APIError[], number>({ mutationFn: (eventId) => markEventSeen(eventId), onSuccess: (_, eventId) => { + // Update Initial Query + queryClient.setQueryData>( + ['events', 'initial'], + (prev) => { + if (!prev) { + return undefined; + } + + for (const event of prev.data) { + if (event.id <= eventId) { + event.seen = true; + } + } + + return prev; + } + ); + + // Update Infinite Queries queryClient.setQueriesData>>( { queryKey: ['events', 'infinite'] }, (prev) => { @@ -218,14 +273,9 @@ export const useMarkEventsAsSeen = () => { }; } - let foundLatestSeenEvent = false; - for (const page of prev.pages) { for (const event of page.data) { - if (event.id === eventId) { - foundLatestSeenEvent = true; - } - if (foundLatestSeenEvent) { + if (event.id <= eventId) { event.seen = true; } } @@ -269,6 +319,8 @@ export const updateEventsQueries = ( updateEventsQuery(filteredEvents, queryKey, queryClient); }); + + updateInitialEventsQuery(events, queryClient); }; /** @@ -333,3 +385,44 @@ export const updateEventsQuery = ( } ); }; + +export const updateInitialEventsQuery = ( + events: Event[], + queryClient: QueryClient +) => { + queryClient.setQueryData>( + ['events', 'initial'], + (prev) => { + if (!prev) { + return undefined; + } + const updatedEventIndexes: number[] = []; + + for (let i = 0; i < events.length; i++) { + const indexOfEvent = prev.data.findIndex((e) => e.id === events[i].id); + + if (indexOfEvent !== -1) { + prev.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.data = [...newEvents, ...prev.data]; + + prev.results += newEvents.length; + } + + return prev; + } + ); +};