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..4e5e424451f6 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/RecentActivity.test.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 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, + }], + }, +}); + +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 87e233243140..c8e438b104cf 100644 --- a/graylog2-web-interface/src/components/collectors/overview/RecentActivity.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/RecentActivity.tsx @@ -20,6 +20,7 @@ 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'; @@ -92,8 +93,20 @@ const targetLink = (target: TargetInfo) => { return {target.name}; }; +const additionalTargetText = (targets: TargetInfo[]) => { + if (targets.length <= 1) { + return null; + } + 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 +116,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': {