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,