diff --git a/packages/manager/.changeset/pr-13095-upcoming-features-1763130670048.md b/packages/manager/.changeset/pr-13095-upcoming-features-1763130670048.md new file mode 100644 index 00000000000..6236a2c67b8 --- /dev/null +++ b/packages/manager/.changeset/pr-13095-upcoming-features-1763130670048.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Edit Stream form: remove cluster IDs from the edited stream that no longer exist or have log generation disabled ([#13095](https://github.com/linode/manager/pull/13095)) diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx index 315d63f3c7c..fde6a031dd8 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx @@ -1,5 +1,6 @@ import { screen, + waitFor, waitForElementToBeRemoved, within, } from '@testing-library/react'; @@ -14,10 +15,6 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { StreamFormClusters } from './StreamFormClusters'; -const queryMocks = vi.hoisted(() => ({ - useOrderV2: vi.fn().mockReturnValue({}), -})); - const loadingTestId = 'circle-progress'; const testClustersDetails = [ { @@ -118,6 +115,48 @@ describe('StreamFormClusters', () => { ]); }); + it('should filter clusters by name', async () => { + await renderComponentWithoutSelectedClusters(); + const input = screen.getByPlaceholderText('Search'); + + // Type test value inside the search + await userEvent.click(input); + await userEvent.type(input, 'metrics'); + + await waitFor(() => + expect(getColumnsValuesFromTable()).toEqual(['metrics-stream-cluster']) + ); + }); + + it('should filter clusters by region', async () => { + await renderComponentWithoutSelectedClusters(); + const input = screen.getByPlaceholderText('Search'); + + // Type test value inside the search + await userEvent.click(input); + await userEvent.type(input, 'US,'); + + await waitFor(() => + expect(getColumnsValuesFromTable(2)).toEqual([ + 'US, Atalanta, GA', + 'US, Chicago, IL', + ]) + ); + }); + + it('should filter clusters by log generation status', async () => { + await renderComponentWithoutSelectedClusters(); + const input = screen.getByPlaceholderText('Search'); + + // Type test value inside the search + await userEvent.click(input); + await userEvent.type(input, 'enabled'); + + await waitFor(() => + expect(getColumnsValuesFromTable(3)).toEqual(['Enabled', 'Enabled']) + ); + }); + it('should toggle clusters checkboxes and header checkbox', async () => { await renderComponentWithoutSelectedClusters(); const table = screen.getByRole('table'); @@ -211,6 +250,56 @@ describe('StreamFormClusters', () => { expect(metricsStreamCheckbox).toBeChecked(); expect(prodClusterCheckbox).not.toBeChecked(); }); + + describe('and some of them are no longer eligible for log delivery', () => { + it('should remove non-eligible clusters and render table with properly selected clusters', async () => { + const modifiedClusters = clusters.map((cluster) => + cluster.id === 3 + ? { ...cluster, control_plane: { audit_logs_enabled: false } } + : cluster + ); + server.use( + http.get('*/lke/clusters', () => { + return HttpResponse.json(makeResourcePage(modifiedClusters)); + }) + ); + + renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + stream: { + details: { + cluster_ids: [2, 3], + is_auto_add_all_clusters_enabled: false, + }, + }, + }, + }, + }); + + const loadingElement = screen.queryByTestId(loadingTestId); + expect(loadingElement).toBeInTheDocument(); + await waitForElementToBeRemoved(loadingElement); + + const table = screen.getByRole('table'); + const headerCheckbox = within(table).getAllByRole('checkbox')[0]; + const gkeProdCheckbox = getCheckboxByClusterName( + 'gke-prod-europe-west1' + ); + const metricsStreamCheckbox = getCheckboxByClusterName( + 'metrics-stream-cluster' + ); + const prodClusterCheckbox = getCheckboxByClusterName('prod-cluster-eu'); + + await waitFor(() => { + expectCheckboxStateToBe(headerCheckbox, 'checked'); + }); + expect(gkeProdCheckbox).not.toBeChecked(); + expect(metricsStreamCheckbox).toBeChecked(); + expect(prodClusterCheckbox).not.toBeChecked(); + }); + }); }); it('should disable all table checkboxes if "Automatically include all" checkbox is selected', async () => { @@ -285,13 +374,6 @@ describe('StreamFormClusters', () => { expect(metricsStreamCheckbox).not.toBeChecked(); expect(prodClusterCheckbox).toBeChecked(); - // Sort by Cluster Name descending - queryMocks.useOrderV2.mockReturnValue({ - order: 'desc', - orderBy: 'label', - sortedData: clusters.reverse(), - }); - await userEvent.click(sortHeader); expect(gkeProdCheckbox).not.toBeChecked(); expect(metricsStreamCheckbox).not.toBeChecked(); diff --git a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx index e4c828ad92f..60384dfd948 100644 --- a/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx +++ b/packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.tsx @@ -1,4 +1,3 @@ -import { getAPIFilterFromQuery } from '@linode/search'; import { Box, Checkbox, @@ -9,19 +8,22 @@ import { Typography, } from '@linode/ui'; import { capitalize } from '@linode/utilities'; -import React, { useEffect, useState } from 'react'; +import { enqueueSnackbar } from 'notistack'; +import React, { useEffect, useMemo, useState } from 'react'; import { useWatch } from 'react-hook-form'; import { Controller, useFormContext } from 'react-hook-form'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { sortData } from 'src/components/OrderBy'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter.constants'; import { Table } from 'src/components/Table'; -import { StreamFormClusterTableContent } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable'; -import { useKubernetesClustersQuery } from 'src/queries/kubernetes'; +import { StreamFormClusterTableContent } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent'; +import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; +import type { KubernetesCluster } from '@linode/api-v4'; import type { FormMode } from 'src/features/Delivery/Shared/types'; -import type { OrderByKeys } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTable'; +import type { OrderByKeys } from 'src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClustersTableContent'; import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types'; const controlPaths = { @@ -45,44 +47,68 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { const [pageSize, setPageSize] = useState(MIN_PAGE_SIZE); const [searchText, setSearchText] = useState(''); - const { error: searchParseError, filter: searchFilter } = - getAPIFilterFromQuery(searchText, { - searchableFieldsWithoutOperator: ['label', 'region'], - }); - - const filter = { - ['+order']: order, - ['+order_by']: orderBy, - ...searchFilter, - }; - const { - data: clusters, + data: clusters = [], isLoading, error, - } = useKubernetesClustersQuery({ - filter, - params: { - page, - page_size: pageSize, - }, - }); + } = useAllKubernetesClustersQuery({ enabled: true }); - const idsWithLogsEnabled = clusters?.data - .filter((cluster) => cluster.control_plane.audit_logs_enabled) - .map(({ id }) => id); + const clusterIdsWithLogsEnabled = useMemo( + () => + clusters + ?.filter((cluster) => cluster.control_plane.audit_logs_enabled) + .map(({ id }) => id), + [clusters] + ); const [isAutoAddAllClustersEnabled, clusterIds] = useWatch({ control, name: [controlPaths.isAutoAddAllClustersEnabled, controlPaths.clusterIds], }); + const areArraysDifferent = (a: number[], b: number[]) => { + if (a.length !== b.length) { + return true; + } + + const setB = new Set(b); + + return !a.every((element) => setB.has(element)); + }; + + // Check for clusters that no longer have log generation enabled and remove them from cluster_ids useEffect(() => { - setValue( - controlPaths.clusterIds, - isAutoAddAllClustersEnabled ? idsWithLogsEnabled : clusterIds || [] - ); - }, [isLoading]); + if (!isLoading) { + const selectedClusterIds = clusterIds ?? []; + const filteredClusterIds = selectedClusterIds.filter((id) => + clusterIdsWithLogsEnabled.includes(id) + ); + + const nextValue = + (isAutoAddAllClustersEnabled + ? clusterIdsWithLogsEnabled + : filteredClusterIds) || []; + + if ( + !isAutoAddAllClustersEnabled && + areArraysDifferent(selectedClusterIds, filteredClusterIds) + ) { + enqueueSnackbar( + 'One or more clusters were removed from the selection because Log Generation is no longer enabled on them.', + { variant: 'info' } + ); + } + if (areArraysDifferent(selectedClusterIds, nextValue)) { + setValue(controlPaths.clusterIds, nextValue); + } + } + }, [ + isLoading, + clusterIds, + isAutoAddAllClustersEnabled, + setValue, + clusterIdsWithLogsEnabled, + ]); const handleOrderChange = (newOrderBy: OrderByKeys) => { if (orderBy === newOrderBy) { @@ -93,6 +119,46 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { } }; + const filteredClusters = !searchText + ? clusters + : clusters.filter((cluster) => { + const lowerSearch = searchText.toLowerCase(); + + return ( + cluster.label.toLowerCase().includes(lowerSearch) || + cluster.region.toLowerCase().includes(lowerSearch) || + (cluster.control_plane.audit_logs_enabled + ? 'enabled' + : 'disabled' + ).includes(lowerSearch) + ); + }); + + const sortedAndFilteredClusters = sortData( + orderBy, + order + )(filteredClusters); + + // Paginate clusters + const indexOfFirstClusterInPage = (page - 1) * pageSize; + const indexOfLastClusterInPage = indexOfFirstClusterInPage + pageSize; + const paginatedClusters = sortedAndFilteredClusters.slice( + indexOfFirstClusterInPage, + indexOfLastClusterInPage + ); + + // If the current page is out of range after filtering, change to the last available page + useEffect(() => { + if (indexOfFirstClusterInPage >= sortedAndFilteredClusters.length) { + const lastPage = Math.max( + 1, + Math.ceil(sortedAndFilteredClusters.length / pageSize) + ); + + setPage(lastPage); + } + }, [sortedAndFilteredClusters, indexOfFirstClusterInPage, pageSize]); + return ( Clusters @@ -120,7 +186,10 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { onChange={async (_, checked) => { field.onChange(checked); if (checked) { - setValue(controlPaths.clusterIds, idsWithLogsEnabled); + setValue( + controlPaths.clusterIds, + clusterIdsWithLogsEnabled + ); } else { setValue(controlPaths.clusterIds, []); } @@ -141,7 +210,6 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { }, }} debounceTime={250} - errorText={searchParseError?.message} hideLabel inputProps={{ 'data-pendo-id': `Logs Delivery Streams ${capitalize(mode)}-Clusters-Search`, @@ -165,9 +233,9 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => { name={controlPaths.clusterIds} render={({ field }) => ( { /> | undefined; + clusters: KubernetesCluster[] | undefined; field: ControllerRenderProps< StreamAndDestinationFormType, 'stream.details.cluster_ids' @@ -62,7 +62,7 @@ export const StreamFormClusterTableContent = ({ - {!!clusters?.results && ( + {!!clusters && ( - {clusters?.results ? ( - clusters.data.map( + {clusters?.length ? ( + clusters.map( ({ label, region,