Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
screen,
waitFor,
waitForElementToBeRemoved,
within,
} from '@testing-library/react';
Expand All @@ -14,10 +15,6 @@

import { StreamFormClusters } from './StreamFormClusters';

const queryMocks = vi.hoisted(() => ({
useOrderV2: vi.fn().mockReturnValue({}),
}));

const loadingTestId = 'circle-progress';
const testClustersDetails = [
{
Expand Down Expand Up @@ -118,6 +115,48 @@
]);
});

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');
Expand Down Expand Up @@ -211,6 +250,56 @@
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) =>

Check warning on line 256 in packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Refactor this code to not nest functions more than 4 levels deep. Raw Output: {"ruleId":"sonarjs/no-nested-functions","severity":1,"message":"Refactor this code to not nest functions more than 4 levels deep.","line":256,"column":57,"nodeType":null,"endLine":256,"endColumn":59}
cluster.id === 3
? { ...cluster, control_plane: { audit_logs_enabled: false } }
: cluster
);
server.use(
http.get('*/lke/clusters', () => {

Check warning on line 262 in packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Refactor this code to not nest functions more than 4 levels deep. Raw Output: {"ruleId":"sonarjs/no-nested-functions","severity":1,"message":"Refactor this code to not nest functions more than 4 levels deep.","line":262,"column":41,"nodeType":null,"endLine":262,"endColumn":43}
return HttpResponse.json(makeResourcePage(modifiedClusters));
})
);

renderWithThemeAndHookFormContext({
component: <StreamFormClusters mode="edit" />,
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(() => {

Check warning on line 295 in packages/manager/src/features/Delivery/Streams/StreamForm/Clusters/StreamFormClusters.test.tsx

View workflow job for this annotation

GitHub Actions / ESLint Review (manager)

[eslint] reported by reviewdog 🐢 Refactor this code to not nest functions more than 4 levels deep. Raw Output: {"ruleId":"sonarjs/no-nested-functions","severity":1,"message":"Refactor this code to not nest functions more than 4 levels deep.","line":295,"column":26,"nodeType":null,"endLine":295,"endColumn":28}
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 () => {
Expand Down Expand Up @@ -285,13 +374,6 @@
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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getAPIFilterFromQuery } from '@linode/search';
import {
Box,
Checkbox,
Expand All @@ -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 = {
Expand All @@ -45,44 +47,68 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => {
const [pageSize, setPageSize] = useState<number>(MIN_PAGE_SIZE);
const [searchText, setSearchText] = useState<string>('');

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) {
Expand All @@ -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<KubernetesCluster>(
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 (
<Paper>
<Typography variant="h2">Clusters</Typography>
Expand Down Expand Up @@ -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, []);
}
Expand All @@ -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`,
Expand All @@ -165,9 +233,9 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => {
name={controlPaths.clusterIds}
render={({ field }) => (
<StreamFormClusterTableContent
clusters={clusters}
clusters={paginatedClusters}
field={field}
idsWithLogsEnabled={idsWithLogsEnabled}
idsWithLogsEnabled={clusterIdsWithLogsEnabled}
isAutoAddAllClustersEnabled={isAutoAddAllClustersEnabled}
onOrderChange={handleOrderChange}
order={order}
Expand All @@ -177,7 +245,7 @@ export const StreamFormClusters = (props: StreamFormClustersProps) => {
/>
</Table>
<PaginationFooter
count={clusters?.results || 0}
count={sortedAndFilteredClusters.length || 0}
eventCategory="Clusters Table"
handlePageChange={setPage}
handleSizeChange={setPageSize}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import { TableRow } from 'src/components/TableRow';
import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
import { TableSortCell } from 'src/components/TableSortCell';

import type { KubernetesCluster, ResourcePage } from '@linode/api-v4';
import type { KubernetesCluster } from '@linode/api-v4';
import type { StreamAndDestinationFormType } from 'src/features/Delivery/Streams/StreamForm/types';

export type OrderByKeys = 'label' | 'region';

interface StreamFormClusterTableContentProps {
clusters: ResourcePage<KubernetesCluster> | undefined;
clusters: KubernetesCluster[] | undefined;
field: ControllerRenderProps<
StreamAndDestinationFormType,
'stream.details.cluster_ids'
Expand Down Expand Up @@ -62,7 +62,7 @@ export const StreamFormClusterTableContent = ({
<TableHead>
<TableRow>
<TableCell sx={{ width: '5%' }}>
{!!clusters?.results && (
{!!clusters && (
<Checkbox
aria-label="Toggle all clusters"
checked={isAllSelected}
Expand Down Expand Up @@ -95,8 +95,8 @@ export const StreamFormClusterTableContent = ({
</TableRow>
</TableHead>
<TableBody>
{clusters?.results ? (
clusters.data.map(
{clusters?.length ? (
clusters.map(
({
label,
region,
Expand Down