From cda1a96d171e3646ca4a5b0dc0cb6a13d94c9828 Mon Sep 17 00:00:00 2001 From: Mohamed Ould Hocine Date: Tue, 17 Feb 2026 17:12:27 +0100 Subject: [PATCH 1/4] Streams: Add Filter Rules count column --- .../resources/streams/StreamResource.java | 32 ++++++++++ .../StreamDestinationFilterService.java | 19 ++++++ .../StreamDestinationFilterServiceTest.java | 10 ++++ .../StreamsOverview/ColumnRenderers.tsx | 5 ++ .../streams/StreamsOverview/Constants.ts | 21 ++++--- .../StreamsOverview/StreamsOverview.test.tsx | 9 +++ .../cells/DestinationFilterRulesCell.tsx | 40 +++++++++++++ ...seStreamDestinationFilterRuleCount.test.ts | 51 ++++++++++++++++ .../useStreamDestinationFilterRuleCount.ts | 60 +++++++++++++++++++ .../src/routing/ApiRoutes.ts | 1 + 10 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 graylog2-web-interface/src/components/streams/StreamsOverview/cells/DestinationFilterRulesCell.tsx create mode 100644 graylog2-web-interface/src/components/streams/hooks/useStreamDestinationFilterRuleCount.test.ts create mode 100644 graylog2-web-interface/src/components/streams/hooks/useStreamDestinationFilterRuleCount.ts diff --git a/graylog2-server/src/main/java/org/graylog2/rest/resources/streams/StreamResource.java b/graylog2-server/src/main/java/org/graylog2/rest/resources/streams/StreamResource.java index bd980b9c5de2..447457ed4519 100644 --- a/graylog2-server/src/main/java/org/graylog2/rest/resources/streams/StreamResource.java +++ b/graylog2-server/src/main/java/org/graylog2/rest/resources/streams/StreamResource.java @@ -112,6 +112,7 @@ import org.graylog2.streams.StreamRouterEngine; import org.graylog2.streams.StreamRuleService; import org.graylog2.streams.StreamService; +import org.graylog2.streams.filters.StreamDestinationFilterService; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; import org.joda.time.format.ISODateTimeFormat; @@ -122,6 +123,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -163,6 +165,7 @@ public class StreamResource extends RestResource { private final MessageFactory messageFactory; private final StreamService streamService; private final StreamRuleService streamRuleService; + private final StreamDestinationFilterService streamDestinationFilterService; private final StreamRouterEngine.Factory streamRouterEngineFactory; private final IndexSetRegistry indexSetRegistry; private final RecentActivityService recentActivityService; @@ -179,6 +182,7 @@ public class StreamResource extends RestResource { public StreamResource(StreamService streamService, PaginatedStreamService paginatedStreamService, StreamRuleService streamRuleService, + StreamDestinationFilterService streamDestinationFilterService, StreamRouterEngine.Factory streamRouterEngineFactory, IndexSetRegistry indexSetRegistry, RecentActivityService recentActivityService, @@ -189,6 +193,7 @@ public StreamResource(StreamService streamService, EntitySharesService entitySharesService) { this.streamService = streamService; this.streamRuleService = streamRuleService; + this.streamDestinationFilterService = streamDestinationFilterService; this.streamRouterEngineFactory = streamRouterEngineFactory; this.indexSetRegistry = indexSetRegistry; this.paginatedStreamService = paginatedStreamService; @@ -661,6 +666,8 @@ public List getConnectedPipelines(@Parameter(name = "stre public record GetConnectedPipelinesRequest(List streamIds) {} + public record GetDestinationFilterRuleCountsRequest(List streamIds) {} + @POST @Path("/pipelines") @Operation(summary = "Get pipelines associated with specified streams") @@ -694,6 +701,31 @@ public Map> getConnectedPipelinesForStreams( })); } + @POST + @Path("/destinations/filters/count") + @Operation(summary = "Get destination filter rule counts associated with specified streams") + @NoAuditEvent("No data is changed.") + @Produces(MediaType.APPLICATION_JSON) + public Map getDestinationFilterRuleCountsForStreams(@Parameter(name = "streamIds", required = true) GetDestinationFilterRuleCountsRequest request) { + final var streamIds = request.streamIds.stream() + .filter(streamId -> { + if (!isPermitted(RestPermissions.STREAMS_READ, streamId)) { + throw new ForbiddenException("Not allowed to read configuration for stream with id: " + streamId); + } + return true; + }) + .collect(Collectors.toSet()); + final var countsByStreamId = streamDestinationFilterService.countByStreamIds( + streamIds, + dtoId -> isPermitted(RestPermissions.STREAM_DESTINATION_FILTERS_READ, dtoId) + ); + + final var response = new LinkedHashMap(); + request.streamIds.forEach(streamId -> response.put(streamId, countsByStreamId.getOrDefault(streamId, 0L))); + + return response; + } + @PUT @Path("/indexSet/{indexSetId}") @Timed diff --git a/graylog2-server/src/main/java/org/graylog2/streams/filters/StreamDestinationFilterService.java b/graylog2-server/src/main/java/org/graylog2/streams/filters/StreamDestinationFilterService.java index d0618edc2495..a7fce47e1f74 100644 --- a/graylog2-server/src/main/java/org/graylog2/streams/filters/StreamDestinationFilterService.java +++ b/graylog2-server/src/main/java/org/graylog2/streams/filters/StreamDestinationFilterService.java @@ -36,7 +36,10 @@ import org.graylog2.streams.events.StreamDeletedEvent; import org.mongojack.Id; +import java.util.Collection; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Consumer; @@ -44,6 +47,7 @@ import static com.mongodb.client.model.Filters.and; import static com.mongodb.client.model.Filters.eq; +import static com.mongodb.client.model.Filters.in; import static org.apache.commons.lang3.StringUtils.isBlank; import static org.graylog2.database.utils.MongoUtils.idEq; import static org.graylog2.database.utils.MongoUtils.insertedId; @@ -132,6 +136,21 @@ public Optional findByIdForStream(String streamI return utils.getById(id); } + public Map countByStreamIds(Collection streamIds, Predicate permissionSelector) { + if (streamIds.isEmpty()) { + return Map.of(); + } + + final Map countsByStreamId = new HashMap<>(); + collection.find(in(FIELD_STREAM_ID, streamIds)).forEach(dto -> { + if (permissionSelector.test(dto.id())) { + countsByStreamId.merge(dto.streamId(), 1L, Long::sum); + } + }); + + return countsByStreamId; + } + public StreamDestinationFilterRuleDTO createForStream(String streamId, StreamDestinationFilterRuleDTO dto) { if (!isBlank(dto.id())) { throw new IllegalArgumentException("id must be blank"); diff --git a/graylog2-server/src/test/java/org/graylog2/streams/filters/StreamDestinationFilterServiceTest.java b/graylog2-server/src/test/java/org/graylog2/streams/filters/StreamDestinationFilterServiceTest.java index 3fbf2985333a..f59f0e1a0793 100644 --- a/graylog2-server/src/test/java/org/graylog2/streams/filters/StreamDestinationFilterServiceTest.java +++ b/graylog2-server/src/test/java/org/graylog2/streams/filters/StreamDestinationFilterServiceTest.java @@ -91,6 +91,16 @@ void findPaginatedForStreamAndTargetWithQuery() { assertThat(result.delegate().get(0).status()).isEqualTo(StreamDestinationFilterRuleDTO.Status.DISABLED); } + @Test + @MongoDBFixtures("StreamDestinationFilterServiceTest-2024-07-01-1.json") + void countByStreamIds() { + final var countByStreamIds = service.countByStreamIds(List.of("54e3deadbeefdeadbeef1000", "54e3deadbeefdeadbeef2000"), id -> !"54e3deadbeefdeadbeef0001".equals(id)); + + assertThat(countByStreamIds) + .containsEntry("54e3deadbeefdeadbeef1000", 2L) + .containsEntry("54e3deadbeefdeadbeef2000", 1L); + } + @Test @MongoDBFixtures("StreamDestinationFilterServiceTest-2024-07-01-1.json") void findByIdForStream() { diff --git a/graylog2-web-interface/src/components/streams/StreamsOverview/ColumnRenderers.tsx b/graylog2-web-interface/src/components/streams/StreamsOverview/ColumnRenderers.tsx index 7a18f1810612..f2e7f11f60c9 100644 --- a/graylog2-web-interface/src/components/streams/StreamsOverview/ColumnRenderers.tsx +++ b/graylog2-web-interface/src/components/streams/StreamsOverview/ColumnRenderers.tsx @@ -32,6 +32,7 @@ import StreamRulesCell from './cells/StreamRulesCell'; import PipelinesCell from './cells/PipelinesCell'; import OutputsCell from './cells/OutputsCell'; import ArchivingsCell from './cells/ArchivingsCell'; +import DestinationFilterRulesCell from './cells/DestinationFilterRulesCell'; const getStreamDataLakeTableElements = PluginStore.exports('dataLake')?.[0]?.getStreamDataLakeTableElements; const pipelineRenderer = { @@ -72,6 +73,10 @@ const customColumnRenderers = ( renderCell: (_outputs: Output[], stream) => , staticWidth: 'matchHeader' as const, }, + destination_filters: { + renderCell: (_destinationFilters: string, stream) => , + staticWidth: 'matchHeader' as const, + }, archiving: { renderCell: (_archiving: boolean, stream) => , staticWidth: 'matchHeader' as const, diff --git a/graylog2-web-interface/src/components/streams/StreamsOverview/Constants.ts b/graylog2-web-interface/src/components/streams/StreamsOverview/Constants.ts index 71e2a043a34b..b9a267ed6371 100644 --- a/graylog2-web-interface/src/components/streams/StreamsOverview/Constants.ts +++ b/graylog2-web-interface/src/components/streams/StreamsOverview/Constants.ts @@ -40,26 +40,28 @@ const getStreamTableElements = ( defaultDisplayedAttributes: [ 'title', 'index_set_title', - 'archiving', - ...(streamDataLakeTableElements?.attributeName ? [streamDataLakeTableElements.attributeName] : []), 'rules', ...(isPipelineColumnPermitted ? ['pipelines'] : []), - ...(pluggableAttributes?.attributeNames || []), 'outputs', - 'throughput', + 'archiving', + ...(streamDataLakeTableElements?.attributeName ? [streamDataLakeTableElements.attributeName] : []), + 'destination_filters', + ...(pluggableAttributes?.attributeNames || []), 'disabled', + 'throughput', ], defaultColumnOrder: [ 'title', 'index_set_title', - 'archiving', - ...(streamDataLakeTableElements?.attributeName ? [streamDataLakeTableElements.attributeName] : []), 'rules', ...(isPipelineColumnPermitted ? ['pipelines'] : []), - ...(pluggableAttributes?.attributeNames || []), 'outputs', - 'throughput', + 'archiving', + ...(streamDataLakeTableElements?.attributeName ? [streamDataLakeTableElements.attributeName] : []), + 'destination_filters', + ...(pluggableAttributes?.attributeNames || []), 'disabled', + 'throughput', 'created_at', ], }; @@ -67,9 +69,10 @@ const getStreamTableElements = ( const additionalAttributes: Array = [ { id: 'index_set_title', title: 'Index Set', sortable: true, permissions: ['indexsets:read'] }, { id: 'throughput', title: 'Throughput' }, - { id: 'rules', title: 'Rules' }, + { id: 'rules', title: 'Stream Rules' }, ...(isPipelineColumnPermitted ? [{ id: 'pipelines', title: 'Pipelines' }] : []), { id: 'outputs', title: 'Outputs' }, + { id: 'destination_filters', title: 'Filter Rules' }, { id: 'archiving', title: 'Archiving' }, ...(streamDataLakeTableElements?.attributes || []), ...(pluggableAttributes?.attributes || []), diff --git a/graylog2-web-interface/src/components/streams/StreamsOverview/StreamsOverview.test.tsx b/graylog2-web-interface/src/components/streams/StreamsOverview/StreamsOverview.test.tsx index 4cd34967c01f..d2b020ba424c 100644 --- a/graylog2-web-interface/src/components/streams/StreamsOverview/StreamsOverview.test.tsx +++ b/graylog2-web-interface/src/components/streams/StreamsOverview/StreamsOverview.test.tsx @@ -27,12 +27,14 @@ import { layoutPreferences } from 'fixtures/entityListLayoutPreferences'; import useStreamRuleTypes from 'components/streams/hooks/useStreamRuleTypes'; import { streamRuleTypes } from 'fixtures/streamRuleTypes'; import DefaultQueryParamProvider from 'routing/DefaultQueryParamProvider'; +import useStreamDestinationFilterRuleCount from 'components/streams/hooks/useStreamDestinationFilterRuleCount'; import StreamsOverview from './StreamsOverview'; jest.mock('components/common/PaginatedEntityTable/useFetchEntities'); jest.mock('components/streams/hooks/useStreamRuleTypes'); jest.mock('components/common/EntityDataTable/hooks/useUserLayoutPreferences'); +jest.mock('components/streams/hooks/useStreamDestinationFilterRuleCount'); jest.mock('stores/inputs/StreamRulesInputsStore', () => ({ StreamRulesInputsActions: { @@ -98,6 +100,13 @@ describe('StreamsOverview', () => { }); asMock(useStreamRuleTypes).mockReturnValue({ data: streamRuleTypes }); + asMock(useStreamDestinationFilterRuleCount).mockReturnValue({ + data: 0, + refetch: () => {}, + isInitialLoading: false, + error: undefined, + isError: false, + }); }); it('should render empty', async () => { diff --git a/graylog2-web-interface/src/components/streams/StreamsOverview/cells/DestinationFilterRulesCell.tsx b/graylog2-web-interface/src/components/streams/StreamsOverview/cells/DestinationFilterRulesCell.tsx new file mode 100644 index 000000000000..e5401f795d90 --- /dev/null +++ b/graylog2-web-interface/src/components/streams/StreamsOverview/cells/DestinationFilterRulesCell.tsx @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import { useRef } from 'react'; + +import type { Stream } from 'stores/streams/StreamsStore'; +import { CountBadge } from 'components/common'; +import useStreamDestinationFilterRuleCount from 'components/streams/hooks/useStreamDestinationFilterRuleCount'; + +type Props = { + stream: Stream; +}; + +const DestinationFilterRulesCell = ({ stream }: Props) => { + const buttonRef = useRef(); + const hasFilterRules = !stream.is_default && stream.is_editable; + const { data: destinationFilterRuleCount } = useStreamDestinationFilterRuleCount(stream.id, hasFilterRules); + + if (!hasFilterRules) { + return null; + } + + return ; +}; + +export default DestinationFilterRulesCell; diff --git a/graylog2-web-interface/src/components/streams/hooks/useStreamDestinationFilterRuleCount.test.ts b/graylog2-web-interface/src/components/streams/hooks/useStreamDestinationFilterRuleCount.test.ts new file mode 100644 index 000000000000..ac09dd982e51 --- /dev/null +++ b/graylog2-web-interface/src/components/streams/hooks/useStreamDestinationFilterRuleCount.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import { renderHook } from 'wrappedTestingLibrary/hooks'; +import { waitFor } from 'wrappedTestingLibrary'; + +import asMock from 'helpers/mocking/AsMock'; +import fetch from 'logic/rest/FetchProvider'; +import ApiRoutes from 'routing/ApiRoutes'; +import { qualifyUrl } from 'util/URLUtils'; + +import useStreamDestinationFilterRuleCount from './useStreamDestinationFilterRuleCount'; + +jest.mock('logic/rest/FetchProvider', () => jest.fn()); + +describe('useStreamDestinationFilterRuleCount', () => { + it('batches subsequent requests', async () => { + asMock(fetch).mockResolvedValue({ + foo: 1, + bar: 2, + baz: 3, + }); + + renderHook(() => { + useStreamDestinationFilterRuleCount('foo'); + useStreamDestinationFilterRuleCount('bar'); + useStreamDestinationFilterRuleCount('baz'); + }); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith( + 'POST', + qualifyUrl(ApiRoutes.StreamOutputFilterRuleApiController.countByStreams().url), + { stream_ids: ['foo', 'bar', 'baz'] }, + ); + }); + }); +}); diff --git a/graylog2-web-interface/src/components/streams/hooks/useStreamDestinationFilterRuleCount.ts b/graylog2-web-interface/src/components/streams/hooks/useStreamDestinationFilterRuleCount.ts new file mode 100644 index 000000000000..f245043abf12 --- /dev/null +++ b/graylog2-web-interface/src/components/streams/hooks/useStreamDestinationFilterRuleCount.ts @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import { useQuery } from '@tanstack/react-query'; +import { create, windowScheduler, indexedResolver } from '@yornaath/batshit'; + +import type FetchError from 'logic/errors/FetchError'; +import ApiRoutes from 'routing/ApiRoutes'; +import fetch from 'logic/rest/FetchProvider'; +import { qualifyUrl } from 'util/URLUtils'; + +const streamDestinationFilterRuleCounts = create({ + fetcher: (streamIds: Array) => + fetch('POST', qualifyUrl(ApiRoutes.StreamOutputFilterRuleApiController.countByStreams().url), { + stream_ids: streamIds, + }), + resolver: indexedResolver(), + scheduler: windowScheduler(10), +}); + +const useStreamDestinationFilterRuleCount = ( + streamId: string, + enabled: boolean = true, +): { + data: number; + refetch: () => void; + isInitialLoading: boolean; + error: FetchError; + isError: boolean; +} => { + const { data, refetch, isInitialLoading, error, isError } = useQuery({ + queryKey: ['stream', 'destination-filters', 'count', streamId], + queryFn: () => streamDestinationFilterRuleCounts.fetch(streamId), + notifyOnChangeProps: ['data', 'error'], + enabled, + }); + + return { + data: data ?? 0, + refetch, + isInitialLoading, + error, + isError, + }; +}; + +export default useStreamDestinationFilterRuleCount; diff --git a/graylog2-web-interface/src/routing/ApiRoutes.ts b/graylog2-web-interface/src/routing/ApiRoutes.ts index dab407163694..0e02abb3760a 100644 --- a/graylog2-web-interface/src/routing/ApiRoutes.ts +++ b/graylog2-web-interface/src/routing/ApiRoutes.ts @@ -306,6 +306,7 @@ const ApiRoutes = { }, StreamOutputFilterRuleApiController: { get: (streamId: string) => ({ url: `/streams/${streamId}/destinations/filters` }), + countByStreams: () => ({ url: '/streams/destinations/filters/count' }), delete: (streamId: string, filterId: string) => ({ url: `/streams/${streamId}/destinations/filters/${filterId}` }), update: (streamId: string, filterId: string) => ({ url: `/streams/${streamId}/destinations/filters/${filterId}` }), create: (streamId: string) => ({ url: `/streams/${streamId}/destinations/filters` }), From 0cb64c561d54038e6f31b111d620ebae69cb0fee Mon Sep 17 00:00:00 2001 From: Mohamed Ould Hocine Date: Tue, 17 Feb 2026 17:12:52 +0100 Subject: [PATCH 2/4] Filter Rules expanded section --- .../ExpandedDestinationFilterRulesActions.tsx | 39 ++++++ .../ExpandedDestinationFilterRulesSection.tsx | 118 ++++++++++++++++++ .../StreamsOverview/StreamsOverview.test.tsx | 72 +++++++++++ .../cells/DestinationFilterRulesCell.tsx | 19 ++- .../hooks/useTableComponents.tsx | 23 +++- .../streams/hooks/useStreamOutputFilters.ts | 11 +- 6 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 graylog2-web-interface/src/components/streams/StreamsOverview/ExpandedDestinationFilterRulesActions.tsx create mode 100644 graylog2-web-interface/src/components/streams/StreamsOverview/ExpandedDestinationFilterRulesSection.tsx diff --git a/graylog2-web-interface/src/components/streams/StreamsOverview/ExpandedDestinationFilterRulesActions.tsx b/graylog2-web-interface/src/components/streams/StreamsOverview/ExpandedDestinationFilterRulesActions.tsx new file mode 100644 index 000000000000..7d3261616c2d --- /dev/null +++ b/graylog2-web-interface/src/components/streams/StreamsOverview/ExpandedDestinationFilterRulesActions.tsx @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; + +import type { Stream } from 'stores/streams/StreamsStore'; +import { Button } from 'components/bootstrap'; +import { LinkContainer } from 'components/common/router'; +import Routes from 'routing/Routes'; +import { IfPermitted } from 'components/common'; + +type Props = { + stream: Stream; +}; + +const ExpandedDestinationFilterRulesActions = ({ stream }: Props) => ( + + + + + +); + +export default ExpandedDestinationFilterRulesActions; diff --git a/graylog2-web-interface/src/components/streams/StreamsOverview/ExpandedDestinationFilterRulesSection.tsx b/graylog2-web-interface/src/components/streams/StreamsOverview/ExpandedDestinationFilterRulesSection.tsx new file mode 100644 index 000000000000..c3c433ab1aa7 --- /dev/null +++ b/graylog2-web-interface/src/components/streams/StreamsOverview/ExpandedDestinationFilterRulesSection.tsx @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import { useCallback, useState } from 'react'; +import styled, { css } from 'styled-components'; +import * as Immutable from 'immutable'; + +import type { Stream } from 'stores/streams/StreamsStore'; +import { DEFAULT_PAGINATION } from 'stores/PaginationTypes'; +import type { StreamOutputFilterRule } from 'components/streams/StreamDetails/output-filter/Types'; +import { DataTable, NoSearchResult, PaginatedList, Spinner, Text, Pluralize } from 'components/common'; +import { DEFAULT_PAGE_SIZES } from 'hooks/usePaginationQueryParameter'; +import useStreamOutputFilters from 'components/streams/hooks/useStreamOutputFilters'; +import FilterStatusCell from 'components/streams/StreamDetails/output-filter/FilterStatusCell'; + +const TABLE_HEADERS = ['Title', 'Destination', 'Status']; + +const StyledText = styled(Text)( + ({ theme }) => css` + color: ${theme.colors.gray[50]}; + `, +); + +const destinationTitle = (destinationType: string) => { + if (destinationType === 'indexer') { + return 'Index Set'; + } + + if (destinationType === 'data-lake') { + return 'Data Lake'; + } + + return 'Output'; +}; + +const _headerCellFormatter = (header: string) => {header}; + +const filterRuleItem = (filter: StreamOutputFilterRule) => ( + + + {filter.title} + {filter.description} + + {destinationTitle(filter.destination_type)} + + + + +); + +type Props = { + stream: Stream; +}; + +const ExpandedDestinationFilterRulesSection = ({ stream }: Props) => { + const [pagination, setPagination] = useState(DEFAULT_PAGINATION); + const { data: paginatedFilters, isLoading } = useStreamOutputFilters(stream.id, undefined, pagination); + + const onPaginationChange = useCallback( + (newPage: number, newPerPage: number) => + setPagination((currentPagination) => ({ + ...currentPagination, + page: newPage, + perPage: newPerPage, + })), + [], + ); + + if (isLoading && !paginatedFilters) { + return ; + } + + const filters = paginatedFilters?.list ?? Immutable.List(); + const total = paginatedFilters?.pagination?.total ?? 0; + + return ( + <> +

+ Showing {total} configured filter across Index Set, + Data Lake and Outputs destinations. +

+ + No filter rules have been found.} + rows={filters.toJS()} + dataRowFormatter={filterRuleItem} + /> + + + ); +}; + +export default ExpandedDestinationFilterRulesSection; diff --git a/graylog2-web-interface/src/components/streams/StreamsOverview/StreamsOverview.test.tsx b/graylog2-web-interface/src/components/streams/StreamsOverview/StreamsOverview.test.tsx index d2b020ba424c..1d189e29c549 100644 --- a/graylog2-web-interface/src/components/streams/StreamsOverview/StreamsOverview.test.tsx +++ b/graylog2-web-interface/src/components/streams/StreamsOverview/StreamsOverview.test.tsx @@ -17,6 +17,7 @@ import React from 'react'; import { render, screen, within } from 'wrappedTestingLibrary'; import userEvent from '@testing-library/user-event'; +import * as Immutable from 'immutable'; import { indexSets } from 'fixtures/indexSets'; import { asMock, MockStore } from 'helpers/mocking'; @@ -28,6 +29,7 @@ import useStreamRuleTypes from 'components/streams/hooks/useStreamRuleTypes'; import { streamRuleTypes } from 'fixtures/streamRuleTypes'; import DefaultQueryParamProvider from 'routing/DefaultQueryParamProvider'; import useStreamDestinationFilterRuleCount from 'components/streams/hooks/useStreamDestinationFilterRuleCount'; +import useStreamOutputFilters from 'components/streams/hooks/useStreamOutputFilters'; import StreamsOverview from './StreamsOverview'; @@ -35,6 +37,7 @@ jest.mock('components/common/PaginatedEntityTable/useFetchEntities'); jest.mock('components/streams/hooks/useStreamRuleTypes'); jest.mock('components/common/EntityDataTable/hooks/useUserLayoutPreferences'); jest.mock('components/streams/hooks/useStreamDestinationFilterRuleCount'); +jest.mock('components/streams/hooks/useStreamOutputFilters'); jest.mock('stores/inputs/StreamRulesInputsStore', () => ({ StreamRulesInputsActions: { @@ -93,6 +96,7 @@ describe('StreamsOverview', () => { title: { status: 'show' }, description: { status: 'show' }, rules: { status: 'show' }, + destination_filters: { status: 'show' }, }, }, isInitialLoading: false, @@ -107,6 +111,21 @@ describe('StreamsOverview', () => { error: undefined, isError: false, }); + asMock(useStreamOutputFilters).mockReturnValue({ + data: { + list: Immutable.List([]), + pagination: { + total: 0, + page: 1, + perPage: 10, + query: '', + count: 0, + }, + }, + refetch: () => {}, + isLoading: false, + isSuccess: true, + }); }); it('should render empty', async () => { @@ -179,4 +198,57 @@ describe('StreamsOverview', () => { expect(deleteStreamRuleButtons.length).toBe(2); expect(editStreamRuleButtons.length).toBe(2); }); + + it('should open and close filter rules overview for a stream', async () => { + asMock(useFetchEntities).mockReturnValue(paginatedStreams()); + asMock(useStreamDestinationFilterRuleCount).mockReturnValue({ + data: 1, + refetch: () => {}, + isInitialLoading: false, + error: undefined, + isError: false, + }); + asMock(useStreamOutputFilters).mockReturnValue({ + data: { + list: Immutable.List([ + { + id: 'filter-id-1', + stream_id: stream.id, + destination_type: 'indexer', + title: 'Only prod logs', + description: 'Drops noisy data', + status: 'enabled', + rule: { + operator: 'AND', + conditions: [], + actions: [], + }, + }, + ]), + pagination: { + total: 1, + page: 1, + perPage: 10, + query: '', + count: 1, + }, + }, + refetch: () => {}, + isLoading: false, + isSuccess: true, + }); + + renderSut(); + + const filterRulesBadge = await screen.findByTitle('Show filter rules'); + userEvent.click(filterRulesBadge); + + expect(screen.getByText('Only prod logs')).toBeInTheDocument(); + expect(screen.getByText(/Showing 1 configured filter/)).toBeInTheDocument(); + + const hideFilterRulesBadge = await screen.findByTitle('Hide filter rules'); + userEvent.click(hideFilterRulesBadge); + + expect(screen.queryByText('Only prod logs')).not.toBeInTheDocument(); + }); }); diff --git a/graylog2-web-interface/src/components/streams/StreamsOverview/cells/DestinationFilterRulesCell.tsx b/graylog2-web-interface/src/components/streams/StreamsOverview/cells/DestinationFilterRulesCell.tsx index e5401f795d90..30575f7a0377 100644 --- a/graylog2-web-interface/src/components/streams/StreamsOverview/cells/DestinationFilterRulesCell.tsx +++ b/graylog2-web-interface/src/components/streams/StreamsOverview/cells/DestinationFilterRulesCell.tsx @@ -15,11 +15,12 @@ * . */ import * as React from 'react'; -import { useRef } from 'react'; +import { useRef, useCallback } from 'react'; import type { Stream } from 'stores/streams/StreamsStore'; import { CountBadge } from 'components/common'; import useStreamDestinationFilterRuleCount from 'components/streams/hooks/useStreamDestinationFilterRuleCount'; +import useExpandedSections from 'components/common/EntityDataTable/hooks/useExpandedSections'; type Props = { stream: Stream; @@ -27,14 +28,28 @@ type Props = { const DestinationFilterRulesCell = ({ stream }: Props) => { const buttonRef = useRef(); + const { toggleSection, expandedSections } = useExpandedSections(); const hasFilterRules = !stream.is_default && stream.is_editable; const { data: destinationFilterRuleCount } = useStreamDestinationFilterRuleCount(stream.id, hasFilterRules); + const toggleFilterRulesSection = useCallback( + () => toggleSection(stream.id, 'destination_filters'), + [stream.id, toggleSection], + ); if (!hasFilterRules) { return null; } - return ; + const destinationFilterRulesSectionIsOpen = expandedSections?.[stream.id]?.includes('destination_filters'); + + return ( + + ); }; export default DestinationFilterRulesCell; diff --git a/graylog2-web-interface/src/components/streams/StreamsOverview/hooks/useTableComponents.tsx b/graylog2-web-interface/src/components/streams/StreamsOverview/hooks/useTableComponents.tsx index ce6c508ff188..db9cedc7c76b 100644 --- a/graylog2-web-interface/src/components/streams/StreamsOverview/hooks/useTableComponents.tsx +++ b/graylog2-web-interface/src/components/streams/StreamsOverview/hooks/useTableComponents.tsx @@ -22,6 +22,8 @@ import StreamActions from 'components/streams/StreamsOverview/StreamActions'; import BulkActions from 'components/streams/StreamsOverview/BulkActions'; import ExpandedRulesSection from 'components/streams/StreamsOverview/ExpandedRulesSection'; import ExpandedRulesActions from 'components/streams/StreamsOverview/ExpandedRulesActions'; +import ExpandedDestinationFilterRulesSection from 'components/streams/StreamsOverview/ExpandedDestinationFilterRulesSection'; +import ExpandedDestinationFilterRulesActions from 'components/streams/StreamsOverview/ExpandedDestinationFilterRulesActions'; import type { ExpandedSectionRenderer } from 'components/common/EntityDataTable/types'; const useTableElements = ({ @@ -38,6 +40,14 @@ const useTableElements = ({ const renderExpandedRules = useCallback((stream: Stream) => , []); const renderExpandedRulesActions = useCallback((stream: Stream) => , []); + const renderExpandedDestinationFilters = useCallback( + (stream: Stream) => , + [], + ); + const renderExpandedDestinationFiltersActions = useCallback( + (stream: Stream) => , + [], + ); const expandedSections = useMemo( () => ({ rules: { @@ -45,9 +55,20 @@ const useTableElements = ({ content: renderExpandedRules, actions: renderExpandedRulesActions, }, + destination_filters: { + title: 'Filter Rules', + content: renderExpandedDestinationFilters, + actions: renderExpandedDestinationFiltersActions, + }, ...pluggableExpandedSections, }), - [pluggableExpandedSections, renderExpandedRules, renderExpandedRulesActions], + [ + pluggableExpandedSections, + renderExpandedRules, + renderExpandedRulesActions, + renderExpandedDestinationFilters, + renderExpandedDestinationFiltersActions, + ], ); return { diff --git a/graylog2-web-interface/src/components/streams/hooks/useStreamOutputFilters.ts b/graylog2-web-interface/src/components/streams/hooks/useStreamOutputFilters.ts index 7b19236e2fac..6ae51b3f637b 100644 --- a/graylog2-web-interface/src/components/streams/hooks/useStreamOutputFilters.ts +++ b/graylog2-web-interface/src/components/streams/hooks/useStreamOutputFilters.ts @@ -34,10 +34,10 @@ type PaginatedResponse = { query: string; }; export const KEY_PREFIX = ['streams', 'output', 'filters']; -export const keyFn = (streamId: string, destinationType: string, pagination?: Pagination) => [ +export const keyFn = (streamId: string, destinationType?: string, pagination?: Pagination) => [ ...KEY_PREFIX, streamId, - destinationType, + destinationType ?? 'all', pagination, ]; const defaultParams = { page: 1, perPage: 10, query: '' }; @@ -69,7 +69,7 @@ export const fetchStreamOutputFilters = async (streamId: string, pagination: Pag const useStreamOutputFilters = ( streamId: string, - destinationType: string, + destinationType: string | undefined, pagination: Pagination = defaultParams, ): { data: PaginatedList; @@ -82,7 +82,10 @@ const useStreamOutputFilters = ( queryFn: () => defaultOnError( - fetchStreamOutputFilters(streamId, { ...pagination, query: `destination_type:${destinationType}` }), + fetchStreamOutputFilters( + streamId, + { ...pagination, query: destinationType ? `destination_type:${destinationType}` : pagination.query }, + ), 'Loading stream output filters failed with status', 'Could not load stream output filters', ), From 5ec1efcaa63128bab0a81758085d8bedcd487cb9 Mon Sep 17 00:00:00 2001 From: Mohamed Ould Hocine Date: Wed, 18 Feb 2026 12:03:06 +0100 Subject: [PATCH 3/4] fix copilot review comment --- .../StreamDestinationFilterService.java | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/graylog2-server/src/main/java/org/graylog2/streams/filters/StreamDestinationFilterService.java b/graylog2-server/src/main/java/org/graylog2/streams/filters/StreamDestinationFilterService.java index a7fce47e1f74..86484436c241 100644 --- a/graylog2-server/src/main/java/org/graylog2/streams/filters/StreamDestinationFilterService.java +++ b/graylog2-server/src/main/java/org/graylog2/streams/filters/StreamDestinationFilterService.java @@ -24,7 +24,9 @@ import com.mongodb.client.model.Accumulators; import com.mongodb.client.model.Aggregates; import com.mongodb.client.model.Indexes; +import com.mongodb.client.model.Projections; import jakarta.inject.Inject; +import org.bson.Document; import org.bson.conversions.Bson; import org.graylog2.database.MongoCollections; import org.graylog2.database.PaginatedList; @@ -61,6 +63,7 @@ public class StreamDestinationFilterService { public static final String COLLECTION = "stream_destination_filters"; + private static final String FIELD_ID = "_id"; private static final ImmutableMap SEARCH_FIELD_MAPPING = ImmutableMap.builder() .put(FIELD_TITLE, SearchQueryField.create(FIELD_TITLE)) @@ -142,11 +145,16 @@ public Map countByStreamIds(Collection streamIds, Predicat } final Map countsByStreamId = new HashMap<>(); - collection.find(in(FIELD_STREAM_ID, streamIds)).forEach(dto -> { - if (permissionSelector.test(dto.id())) { - countsByStreamId.merge(dto.streamId(), 1L, Long::sum); - } - }); + collection.find(in(FIELD_STREAM_ID, streamIds), Document.class) + .projection(Projections.include(FIELD_ID, FIELD_STREAM_ID)) + .forEach(document -> { + final var id = document.getObjectId(FIELD_ID); + final var streamId = document.getString(FIELD_STREAM_ID); + + if (id != null && streamId != null && permissionSelector.test(id.toHexString())) { + countsByStreamId.merge(streamId, 1L, Long::sum); + } + }); return countsByStreamId; } From fdd339f85710bc531c828a4a4f0d31e9d3059ffb Mon Sep 17 00:00:00 2001 From: Mohamed Ould Hocine Date: Wed, 18 Feb 2026 13:24:40 +0100 Subject: [PATCH 4/4] simplify filter rules text --- .../StreamsOverview/ExpandedDestinationFilterRulesSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graylog2-web-interface/src/components/streams/StreamsOverview/ExpandedDestinationFilterRulesSection.tsx b/graylog2-web-interface/src/components/streams/StreamsOverview/ExpandedDestinationFilterRulesSection.tsx index c3c433ab1aa7..0ec981506845 100644 --- a/graylog2-web-interface/src/components/streams/StreamsOverview/ExpandedDestinationFilterRulesSection.tsx +++ b/graylog2-web-interface/src/components/streams/StreamsOverview/ExpandedDestinationFilterRulesSection.tsx @@ -90,8 +90,8 @@ const ExpandedDestinationFilterRulesSection = ({ stream }: Props) => { return ( <>

- Showing {total} configured filter across Index Set, - Data Lake and Outputs destinations. + Showing {total} configured filter across all + destinations.