From 76cb1c3d8eee658029bce1834ad058a8fa622d0b Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Fri, 10 Apr 2026 12:48:01 +0200 Subject: [PATCH 1/3] Fix non-deterministic fleet names in recent activity (#25384) When activity entries target multiple fleets (e.g. INGEST_CONFIG_CHANGED), the backend returns targets in Set iteration order which varies across JVM instances. The frontend displayed only targets[0], causing fleet names to appear to swap when requests hit different nodes or after restarts. Sort targets alphabetically on the frontend and show "and N other fleet(s)" for multi-target entries. Narrow useRecentActivity return type to match the useCollectorsConfig pattern for type-safe test mocks. --- .../collectors/hooks/useActivityQueries.ts | 2 +- .../overview/RecentActivity.test.tsx | 119 ++++++++++++++++++ .../collectors/overview/RecentActivity.tsx | 22 +++- 3 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 graylog2-web-interface/src/components/collectors/overview/RecentActivity.test.tsx diff --git a/graylog2-web-interface/src/components/collectors/hooks/useActivityQueries.ts b/graylog2-web-interface/src/components/collectors/hooks/useActivityQueries.ts index cd9c1eea7a27..def168d17a62 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useActivityQueries.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useActivityQueries.ts @@ -27,7 +27,7 @@ export const ACTIVITY_KEY = ['collectors', 'activity', 'recent']; const fetchRecentActivity = (): Promise => CollectorsActivity.recent() as Promise; -export const useRecentActivity = () => +export const useRecentActivity = (): { data: RecentActivityResponse | undefined; isLoading: boolean } => useQuery({ queryKey: ACTIVITY_KEY, queryFn: () => diff --git a/graylog2-web-interface/src/components/collectors/overview/RecentActivity.test.tsx b/graylog2-web-interface/src/components/collectors/overview/RecentActivity.test.tsx new file mode 100644 index 000000000000..df06540df424 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/RecentActivity.test.tsx @@ -0,0 +1,119 @@ +/* + * 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 React from 'react'; +import { render, screen } from 'wrappedTestingLibrary'; + +import asMock from 'helpers/mocking/AsMock'; + +import RecentActivity from './RecentActivity'; + +import { useRecentActivity } from '../hooks'; +import type { RecentActivityResponse, TargetInfo } from '../types'; + +jest.mock('../hooks', () => ({ + ...jest.requireActual('../hooks'), + useRecentActivity: jest.fn(), +})); + +const fleetTarget = (id: string, name: string): TargetInfo => ({ id, name, type: 'fleet' }); + +const activityResponse = (targets: TargetInfo[]): { data: RecentActivityResponse; isLoading: boolean } => ({ + isLoading: false, + data: { + activities: [{ + seq: 1, + timestamp: '2026-04-10T08:00:00Z', + type: 'CONFIG_CHANGED', + actor: null, + targets, + details: {}, + }], + }, +}); + +describe('RecentActivity', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows single fleet target without additional text', async () => { + asMock(useRecentActivity).mockReturnValue( + activityResponse([fleetTarget('f1', 'Alpha Fleet')]), + ); + + render(); + + await screen.findByText('Alpha Fleet'); + + expect(screen.queryByText(/and \d+ other/)).not.toBeInTheDocument(); + }); + + it('shows "and 1 other fleet" for two targets', async () => { + asMock(useRecentActivity).mockReturnValue( + activityResponse([fleetTarget('f1', 'Alpha Fleet'), fleetTarget('f2', 'Beta Fleet')]), + ); + + render(); + + await screen.findByText('Alpha Fleet'); + await screen.findByText(/and 1 other fleet/); + }); + + it('shows "and N other fleets" for three or more targets', async () => { + asMock(useRecentActivity).mockReturnValue( + activityResponse([ + fleetTarget('f1', 'Alpha Fleet'), + fleetTarget('f2', 'Beta Fleet'), + fleetTarget('f3', 'Gamma Fleet'), + ]), + ); + + render(); + + await screen.findByText('Alpha Fleet'); + await screen.findByText(/and 2 other fleets/); + }); + + it('sorts targets alphabetically and displays the first', async () => { + asMock(useRecentActivity).mockReturnValue( + activityResponse([ + fleetTarget('f2', 'Zulu Fleet'), + fleetTarget('f1', 'Alpha Fleet'), + ]), + ); + + render(); + + const link = await screen.findByRole('link', { name: 'Alpha Fleet' }); + + expect(link).toBeInTheDocument(); + }); + + it('does not mutate the original targets array', () => { + const targets: TargetInfo[] = [ + fleetTarget('f2', 'Zulu Fleet'), + fleetTarget('f1', 'Alpha Fleet'), + ]; + const originalOrder = [...targets]; + + asMock(useRecentActivity).mockReturnValue(activityResponse(targets)); + + render(); + + expect(targets).toEqual(originalOrder); + }); +}); diff --git a/graylog2-web-interface/src/components/collectors/overview/RecentActivity.tsx b/graylog2-web-interface/src/components/collectors/overview/RecentActivity.tsx index 3fec9c127af0..549359c639ea 100644 --- a/graylog2-web-interface/src/components/collectors/overview/RecentActivity.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/RecentActivity.tsx @@ -23,6 +23,7 @@ import Routes from 'routing/Routes'; import { useRecentActivity } from '../hooks'; import type { ActivityEntry, TargetInfo } from '../types'; +import {naturalSortIgnoreCase} from 'util/SortUtils'; const SectionTitle = styled.h3( ({ theme }) => css` @@ -92,8 +93,19 @@ const targetLink = (target: TargetInfo) => { return {target.name}; }; +const additionalTargetText = (targets: TargetInfo[]) => { + if (targets.length <= 1) { + return <>; + } + if (targets.length === 2) { + return and 1 other {targets[0].type}; + } + return and {targets.length - 1} other {targets[0].type}s; +} + const renderDescription = (entry: ActivityEntry) => { - const target = entry.targets[0]; + const sortedTargets = entry.targets.toSorted((a, b) => naturalSortIgnoreCase(a.name, b.name)); + const target = sortedTargets[0]; if (!target) { return {entry.type}; @@ -103,25 +115,25 @@ const renderDescription = (entry: ActivityEntry) => { case 'CONFIG_CHANGED': return ( - Configuration updated for {target.type} {targetLink(target)} + Configuration updated for {target.type} {targetLink(target)}{additionalTargetText(sortedTargets)} ); case 'INGEST_CONFIG_CHANGED': return ( - Ingest configuration updated for {target.type} {targetLink(target)} + Ingest configuration updated for {target.type} {targetLink(target)}{additionalTargetText(sortedTargets)} ); case 'RESTART': return ( - Restart requested for {target.type} {targetLink(target)} + Restart requested for {target.type} {targetLink(target)}{additionalTargetText(sortedTargets)} ); case 'DISCOVERY_RUN': return ( - Discovery run triggered for {target.type} {targetLink(target)} + Discovery run triggered for {target.type} {targetLink(target)}{additionalTargetText(sortedTargets)} ); case 'FLEET_REASSIGNED': { From c6568d4942fe4330b603c12782b3860bc1666202 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Fri, 10 Apr 2026 12:58:05 +0200 Subject: [PATCH 2/3] Fix eslint errors: import order, useless fragment, padding line --- .../src/components/collectors/overview/RecentActivity.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/RecentActivity.tsx b/graylog2-web-interface/src/components/collectors/overview/RecentActivity.tsx index 549359c639ea..926f50263ec2 100644 --- a/graylog2-web-interface/src/components/collectors/overview/RecentActivity.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/RecentActivity.tsx @@ -20,10 +20,10 @@ import styled, { css } from 'styled-components'; import { Icon, Link, RelativeTime, Spinner, NoEntitiesExist } from 'components/common'; import type { IconName } from 'components/common/Icon/types'; import Routes from 'routing/Routes'; +import { naturalSortIgnoreCase } from 'util/SortUtils'; import { useRecentActivity } from '../hooks'; import type { ActivityEntry, TargetInfo } from '../types'; -import {naturalSortIgnoreCase} from 'util/SortUtils'; const SectionTitle = styled.h3( ({ theme }) => css` @@ -95,11 +95,12 @@ const targetLink = (target: TargetInfo) => { const additionalTargetText = (targets: TargetInfo[]) => { if (targets.length <= 1) { - return <>; + return null; } if (targets.length === 2) { return and 1 other {targets[0].type}; } + return and {targets.length - 1} other {targets[0].type}s; } From 9150b74caa0e087a78e8abec03bd6c2f1cfa7bc4 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Tue, 14 Apr 2026 11:23:28 +0200 Subject: [PATCH 3/3] remove details property from test fixture to align to stronger typing --- .../src/components/collectors/overview/RecentActivity.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/RecentActivity.test.tsx b/graylog2-web-interface/src/components/collectors/overview/RecentActivity.test.tsx index df06540df424..4e5e424451f6 100644 --- a/graylog2-web-interface/src/components/collectors/overview/RecentActivity.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/RecentActivity.test.tsx @@ -40,7 +40,6 @@ const activityResponse = (targets: TargetInfo[]): { data: RecentActivityResponse type: 'CONFIG_CHANGED', actor: null, targets, - details: {}, }], }, });