From d4c0da5ed242ff989a3bb21d65685a1ae58b9b8b Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Tue, 28 Jul 2026 18:02:28 +0200 Subject: [PATCH 01/16] wip clean up onboarding success page --- .../components/collectors/common/Constants.ts | 42 +++ .../collectors/common/DetailRow.tsx | 48 ++++ .../collectors/common/InstanceStatusLabel.tsx | 41 +++ .../collectors/common/collectorOsName.ts | 40 +++ .../collectors/instances/ColumnRenderers.tsx | 22 +- .../instances/InstanceDetailDrawer.tsx | 45 +--- .../onboarding/ConnectionSuccess.test.tsx | 29 +- .../overview/onboarding/ConnectionSuccess.tsx | 255 +++++++----------- .../src/components/common/SimpleGrid.tsx | 32 +++ .../src/components/common/index.tsx | 1 + .../CollectorsOnboardingInstancePage.tsx | 6 +- 11 files changed, 333 insertions(+), 228 deletions(-) create mode 100644 graylog2-web-interface/src/components/collectors/common/Constants.ts create mode 100644 graylog2-web-interface/src/components/collectors/common/DetailRow.tsx create mode 100644 graylog2-web-interface/src/components/collectors/common/InstanceStatusLabel.tsx create mode 100644 graylog2-web-interface/src/components/collectors/common/collectorOsName.ts create mode 100644 graylog2-web-interface/src/components/common/SimpleGrid.tsx diff --git a/graylog2-web-interface/src/components/collectors/common/Constants.ts b/graylog2-web-interface/src/components/collectors/common/Constants.ts new file mode 100644 index 000000000000..f5fa8e50839d --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/common/Constants.ts @@ -0,0 +1,42 @@ +/* + * 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 type { ColorVariant } from '@graylog/sawmill'; + +import type { CollectorInstanceView } from '../types'; + +type InstanceStatus = CollectorInstanceView['status']; + +/** + * How a collector's online/offline state is worded and coloured. Render via `InstanceStatusLabel` + * rather than reading this directly, unless you need the raw wording (sorting, export, a title + * attribute). + */ +export const INSTANCE_STATUS_LABELS: Record = { + online: { label: 'Online', style: 'success' }, + offline: { label: 'Offline', style: 'default' }, +}; + +/** + * Human names for the operating systems a collector reports. These are the agent's own values, so + * `darwin` rather than the `macos` id the install-platform list uses. Unknown values should fall + * back to the raw string rather than being hidden. + */ +export const OS_LABELS: Record = { + linux: 'Linux', + windows: 'Windows', + darwin: 'macOS', +}; diff --git a/graylog2-web-interface/src/components/collectors/common/DetailRow.tsx b/graylog2-web-interface/src/components/collectors/common/DetailRow.tsx new file mode 100644 index 000000000000..9f96d21409dc --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/common/DetailRow.tsx @@ -0,0 +1,48 @@ +/* + * 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 styled, { css } from 'styled-components'; + +/** + * Label/value row primitives for collector detail views, shared between the instance detail drawer + * and the onboarding summary so both render identically. + */ +export const DetailRow = styled.div( + ({ theme }) => css` + display: flex; + align-items: center; + gap: ${theme.spacings.xs}; + margin-bottom: ${theme.spacings.xs}; + + /* Set here rather than on the label so a row's label and value can never disagree. The label + used to declare this on its own while values inherited the larger body size, which left + every row visibly mismatched. */ + font-size: ${theme.fonts.size.small}; + + /* Long values (certificate hashes, attribute strings) wrap instead of overflowing a narrow + container; a flex child defaults to min-width auto and would otherwise refuse to shrink. */ + > :last-child { + min-width: 0; + overflow-wrap: anywhere; + } + `, +); + +/** The leading label of a {@link DetailRow}. Reserves a fixed width so values line up. */ +export const DetailLabel = styled.span` + font-weight: 500; + min-width: 120px; +`; diff --git a/graylog2-web-interface/src/components/collectors/common/InstanceStatusLabel.tsx b/graylog2-web-interface/src/components/collectors/common/InstanceStatusLabel.tsx new file mode 100644 index 000000000000..34b3627feac2 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/common/InstanceStatusLabel.tsx @@ -0,0 +1,41 @@ +/* + * 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 { Label } from 'components/bootstrap'; + +import { INSTANCE_STATUS_LABELS } from './Constants'; + +import type { CollectorInstanceView } from '../types'; + +type Props = { + status: CollectorInstanceView['status']; +}; + +/** + * The collector's online/offline state. Shared between the instances table column, the instance + * detail drawer and the onboarding summary so all three render identically. + */ +const InstanceStatusLabel = ({ status }: Props) => { + // A status the frontend does not know about reads as offline, matching the previous behaviour of + // the `status === 'online'` checks this replaced. + const { label, style } = INSTANCE_STATUS_LABELS[status] ?? INSTANCE_STATUS_LABELS.offline; + + return ; +}; + +export default InstanceStatusLabel; diff --git a/graylog2-web-interface/src/components/collectors/common/collectorOsName.ts b/graylog2-web-interface/src/components/collectors/common/collectorOsName.ts new file mode 100644 index 000000000000..be23c19c231c --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/common/collectorOsName.ts @@ -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 { OS_LABELS } from './Constants'; + +import type { CollectorInstanceView } from '../types'; + +/** + * The collector's operating system, for display in detail views. Prefers the description the agent + * reports (the most specific, e.g. "Ubuntu 22.04.3 LTS") and otherwise translates the bare `os` + * value to a human name, keeping the raw value if it is one we do not know. + */ +const collectorOsName = (instance: CollectorInstanceView): string => { + const description = instance.non_identifying_attributes?.['os.description'] as string | undefined; + + if (description) { + return description; + } + + if (instance.os) { + return OS_LABELS[instance.os] ?? instance.os; + } + + return 'Unknown'; +}; + +export default collectorOsName; diff --git a/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.tsx b/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.tsx index 4f9162a016a0..d742ed21f45a 100644 --- a/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.tsx @@ -16,24 +16,18 @@ */ import * as React from 'react'; -import { Label } from 'components/bootstrap'; import { Link, RelativeTime } from 'components/common'; import Routes from 'routing/Routes'; import type { ColumnRenderers } from 'components/common/EntityDataTable'; +import InstanceStatusLabel from '../common/InstanceStatusLabel'; import SyncStateIndicator from '../common/SyncStateIndicator'; +import collectorOsName from '../common/collectorOsName'; import type { CollectorInstanceView } from '../types'; -const OsIcon = ({ os }: { os: string | null }) => { - if (os === 'linux') return Linux; - if (os === 'windows') return Windows; - if (os === 'darwin') return macOS; - - return ( - - Unknown - - ); +const OsName = ({instance}: { instance?: CollectorInstanceView }) => { + const label = collectorOsName(instance); + return {label}; }; type Props = { @@ -44,9 +38,7 @@ const customColumnRenderers = ({ fleetNames }: Props): ColumnRenderers ( - + ), staticWidth: 100, }, @@ -63,7 +55,7 @@ const customColumnRenderers = ({ fleetNames }: Props): ColumnRenderers , + renderCell: (_os: string, instance: CollectorInstanceView) => , staticWidth: 60, }, fleet_id: { diff --git a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx index e2a14a04eb2b..8adbe54686cb 100644 --- a/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/InstanceDetailDrawer.tsx @@ -26,7 +26,10 @@ import Routes from 'routing/Routes'; import { naturalSortIgnoreCase } from 'util/SortUtils'; import ActivityEntryList from '../common/ActivityEntryList'; +import { DetailRow, DetailLabel } from '../common/DetailRow'; import { IconRow, IconRowList } from '../common/IconRowList'; +import InstanceStatusLabel from '../common/InstanceStatusLabel'; +import collectorOsName from '../common/collectorOsName'; import SyncStateIndicator from '../common/SyncStateIndicator'; import collectorReceivedMessagesUrl from '../common/collectorReceivedMessagesUrl'; import { COLLECTOR_INSTANCE_UID_FIELD } from '../common/fields'; @@ -57,23 +60,6 @@ const SectionTitle = styled.h4( `, ); -const DetailRow = styled.div( - ({ theme }) => css` - display: flex; - align-items: center; - gap: ${theme.spacings.xs}; - margin-bottom: ${theme.spacings.xs}; - `, -); - -const Title = styled.span( - ({ theme }) => css` - font-weight: 500; - min-width: 120px; - font-size: ${theme.fonts.size.small}; - `, -); - const EmptyText = styled.span( ({ theme }) => css` color: ${theme.colors.gray[60]}; @@ -134,7 +120,6 @@ const pendingActions = (coalesced: CoalescedActions): PendingAction[] => { }; const InstanceDetailDrawer = ({ instance, sources, fleetName, onClose }: Props) => { - const osDescription = (instance.non_identifying_attributes?.['os.description'] as string) ?? null; const { data: pendingDetail, isError: pendingError } = useInstancePendingChanges(instance.instance_uid); // Use the backend's authoritative flag (consistent with the table); fall back to the table row's // value until the detail loads. Deriving from activities.length would wrongly show "In sync" for an @@ -171,49 +156,47 @@ const InstanceDetailDrawer = ({ instance, sources, fleetName, onClose }: Props)
- Status: - + Status: + - Sync: + Sync: - Fleet: + Fleet: {fleetName} - OS: - {osDescription || instance.os || 'Unknown'} + OS: + {collectorOsName(instance)} - Last Seen: + Last Seen: - Enrolled: + Enrolled: - Version: + Version: {instance.version || 'Unknown'} - Logs: + Logs: View System Logs - Messages: + Messages: Received messages diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx index 9b47363b5305..94d367b330a5 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx @@ -67,35 +67,29 @@ describe('ConnectionSuccess', () => { }); it('shows real instance data', () => { - render(); + render(); expect(screen.getByText('web-prod-01')).toBeInTheDocument(); - expect(screen.getByText(/1\.2\.3/)).toBeInTheDocument(); - expect(screen.getByText('Default Fleet')).toBeInTheDocument(); + expect(screen.getByText('1.2.3')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Default Fleet' })).toBeInTheDocument(); }); it('previews source logs for the connected instance', () => { - render(); + render(); expect(useCollectorLogPreview).toHaveBeenCalledWith('uid-42'); expect(screen.getByText(/a source log line/)).toBeInTheDocument(); }); - it('shows the message total from the source logs preview', () => { - render(); - - expect(screen.getByText('23')).toBeInTheDocument(); - }); - it('shows the fleet source count', () => { - render(); + render(); expect(useSources).toHaveBeenCalledWith('fleet-1'); - expect(screen.getAllByText('2').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText(/2 configured/)).toBeInTheDocument(); }); it('renders the self-logs section collapsed', () => { - render(); + render(); expect(screen.getByRole('heading', { name: /collector logs/i })).toBeInTheDocument(); expect(screen.getByTestId('collapseButton')).toBeInTheDocument(); @@ -103,16 +97,17 @@ describe('ConnectionSuccess', () => { it('falls back to the instance uid when hostname is missing', () => { render( - , + , ); expect(screen.getByText('uid-42')).toBeInTheDocument(); }); - it('omits the platform chip when platformId is not known', () => { + it('renders the what-is-next links', () => { render(); - expect(screen.queryByText('Linux')).not.toBeInTheDocument(); - expect(screen.getByText('web-prod-01')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Manage Fleets' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Configure Sources' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'View Instances' })).toBeInTheDocument(); }); }); diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx index a2564c7504e8..1160a834fa02 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx @@ -15,157 +15,116 @@ * . */ import * as React from 'react'; -import styled, { css } from 'styled-components'; -import { Alert, Label } from 'components/bootstrap'; -import { AccessibleCard } from 'components/common'; -import useHistory from 'routing/useHistory'; +import {Label} from 'components/bootstrap'; +import {Icon, Link, RelativeTime, Section, SimpleGrid, Stack} from 'components/common'; import Routes from 'routing/Routes'; -import type { CollectorInstanceView } from 'components/collectors/types'; -import { useSources } from 'components/collectors/hooks/useSourceQueries'; +import {defaultCompare} from 'logic/DefaultCompare'; +import type {CollectorInstanceView} from 'components/collectors/types'; +import {useSources} from 'components/collectors/hooks/useSourceQueries'; -import type { PlatformId } from './platforms'; -import PLATFORMS from './platforms'; import useCollectorLogPreview from './useCollectorLogPreview'; import LogPreviewSection from './LogPreviewSection'; -import StatCard from '../../common/StatCard'; +import {DetailLabel, DetailRow} from '../../common/DetailRow'; +import {IconRow, IconRowList} from '../../common/IconRowList'; +import InstanceStatusLabel from '../../common/InstanceStatusLabel'; +import collectorOsName from '../../common/collectorOsName'; import collectorReceivedMessagesUrl from '../../common/collectorReceivedMessagesUrl'; import collectorSystemLogsUrl from '../../common/collectorSystemLogsUrl'; -import { COLLECTOR_INSTANCE_UID_FIELD } from '../../common/fields'; +import {COLLECTOR_INSTANCE_UID_FIELD} from '../../common/fields'; +import {SOURCE_TYPE_LABELS} from '../../sources/Constants'; type Props = { - platformId?: PlatformId; instance: CollectorInstanceView; fleetName: string | undefined; }; -// Asset auto-detection has no backend yet — intentionally stays mocked until that feature ships. -const MOCK_ASSETS = [ - { type: 'host', name: 'example-host' }, - { type: 'user', name: 'root' }, -]; - -const SummaryRow = styled.div( - ({ theme }) => css` - display: flex; - gap: ${theme.spacings.sm}; - flex-wrap: wrap; - margin-bottom: ${theme.spacings.lg}; - `, -); - -const StatsRow = styled.div( - ({ theme }) => css` - display: flex; - gap: ${theme.spacings.md}; - flex-wrap: wrap; - margin-bottom: ${theme.spacings.lg}; - `, -); - -const SectionTitle = styled.h3( - ({ theme }) => css` - font-size: ${theme.fonts.size.h3}; - margin: 0 0 ${theme.spacings.sm} 0; - `, -); - -const AssetsGrid = styled.div( - ({ theme }) => css` - display: flex; - gap: ${theme.spacings.md}; - flex-wrap: wrap; - margin-bottom: ${theme.spacings.lg}; - `, -); - -const AssetCard = styled(AccessibleCard)( - ({ theme }) => css` - min-width: 150px; - padding: ${theme.spacings.md}; - `, -); - -const AssetType = styled.div( - ({ theme }) => css` - font-size: ${theme.fonts.size.small}; - color: ${theme.colors.gray[60]}; - text-transform: uppercase; - margin-bottom: ${theme.spacings.xxs}; - `, -); - -const AssetName = styled.div` - font-weight: 500; -`; - -const NextGrid = styled.div( - ({ theme }) => css` - display: flex; - gap: ${theme.spacings.md}; - flex-wrap: wrap; - `, -); - -const NextCard = styled(AccessibleCard)( - ({ theme }) => css` - flex: 1; - min-width: 180px; - padding: ${theme.spacings.md}; - - h4 { - margin: 0 0 ${theme.spacings.xxs} 0; - font-size: ${theme.fonts.size.large}; - } - - p { - margin: 0; - font-size: ${theme.fonts.size.small}; - color: ${theme.colors.gray[60]}; - } - `, -); - -const LogPreviewsWrapper = styled.div( - ({ theme }) => css` - margin-bottom: ${theme.spacings.lg}; - `, -); - -const ConnectionSuccess = ({ platformId = undefined, instance, fleetName }: Props) => { - const history = useHistory(); - const platform = PLATFORMS.find((p) => p.id === platformId); - const { selfLogs, sourceLogs, selfLogsError, sourceLogsError, isLoading } = useCollectorLogPreview( +const ConnectionSuccess = ({instance, fleetName}: Props) => { + const {selfLogs, sourceLogs, selfLogsError, sourceLogsError, isLoading} = useCollectorLogPreview( instance.instance_uid, ); - const { data: sources } = useSources(instance.fleet_id); - - return ( -
- - Collector connected — {instance.hostname ?? instance.instance_uid} - {instance.version && ( - <> - {' '} - running v{instance.version} - - )} - + const {data: sources} = useSources(instance.fleet_id); - - {fleetName && } - {platform && } - - + const attributes = [ + ...Object.entries(instance.identifying_attributes ?? {}), + ...Object.entries(instance.non_identifying_attributes ?? {}), + ].sort((attr1, attr2) => defaultCompare(attr1[0], attr2[0])); - - - - - - - + return ( + +
+ +
+ + Status: + + + + OS: + {collectorOsName(instance)} + + + Version: + {instance.version || 'Unknown'} + + + Last seen: + + + + Enrolled: + + + + {attributes.map(([key, value]) => ( + + {key} + {String(value)} + + ))} +
+ +
+ + Name: + {fleetName ?? 'Unknown'} + + + Sources: + {sources?.length ?? 0} configured + + + + {sources?.map((source) => ( + + + {source.name} + + ))} + +
+ +
+ + + + Manage Fleets + + + + Configure Sources + + + + View Instances + + +
+
+
+ {/* Zero gap: each `Section` already carries its own bottom margin. */} + -
- - Auto-detected assets - - {MOCK_ASSETS.map((asset) => ( - - {asset.type} - {asset.name} - - ))} - - - What's next? - - history.push(Routes.SYSTEM.COLLECTORS.FLEETS)}> -

Manage Fleets

-

Group collectors by environment or team.

-
- history.push(Routes.SYSTEM.COLLECTORS.FLEET(instance.fleet_id))}> -

Configure Sources

-

Add file paths, journald, or Windows Event Log sources.

-
- history.push(Routes.SYSTEM.COLLECTORS.INSTANCES)}> -

View Instances

-

Monitor all connected collector instances.

-
-
-
+ + ); }; diff --git a/graylog2-web-interface/src/components/common/SimpleGrid.tsx b/graylog2-web-interface/src/components/common/SimpleGrid.tsx new file mode 100644 index 000000000000..14da8e9062cd --- /dev/null +++ b/graylog2-web-interface/src/components/common/SimpleGrid.tsx @@ -0,0 +1,32 @@ +/* + * 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 { forwardRef } from 'react'; +import { SimpleGrid as MantineSimpleGrid } from '@mantine/core'; + +// Equal-width columns via `repeat(cols, minmax(0, 1fr))`. Use this for card +// grids that should reflow at breakpoints (`cols={{ base: 1, sm: 2, md: 3 }}`); +// for a row of items that shrink instead of reflowing, use `Group` with `grow`. +// Unlike its `Box`/`Group`/`Stack` siblings this forwards its ref, so +// measurement hooks work against the grid element. +type Props = Omit, 'ref'>; + +const SimpleGrid = (props: Props, ref: React.ForwardedRef) => ( + +); + +export default forwardRef(SimpleGrid); diff --git a/graylog2-web-interface/src/components/common/index.tsx b/graylog2-web-interface/src/components/common/index.tsx index d3331999f6df..d05039ed6cd3 100644 --- a/graylog2-web-interface/src/components/common/index.tsx +++ b/graylog2-web-interface/src/components/common/index.tsx @@ -130,6 +130,7 @@ export { default as SelectPopover } from './SelectPopover'; export { default as SelectableList } from './SelectableList'; export { default as ShareButton } from './ShareButton'; export { default as ShareMenuItem } from './ShareMenuItem'; +export { default as SimpleGrid } from './SimpleGrid'; export { default as SortableList } from './SortableList'; export { default as Stack } from './Stack'; export { default as CreateModal } from './CreateModal'; diff --git a/graylog2-web-interface/src/pages/CollectorsOnboardingInstancePage.tsx b/graylog2-web-interface/src/pages/CollectorsOnboardingInstancePage.tsx index 2d89c7da41c0..9debab8df525 100644 --- a/graylog2-web-interface/src/pages/CollectorsOnboardingInstancePage.tsx +++ b/graylog2-web-interface/src/pages/CollectorsOnboardingInstancePage.tsx @@ -25,7 +25,6 @@ import { CollectorsPageNavigation } from 'components/collectors/common'; import { useInstance } from 'components/collectors/hooks/useInstanceQueries'; import { useFleet } from 'components/collectors/hooks/useFleetQueries'; import ConnectionSuccess from 'components/collectors/overview/onboarding/ConnectionSuccess'; -import type { PlatformId } from 'components/collectors/overview/onboarding/platforms'; import Routes from 'routing/Routes'; import useLocation from 'routing/useLocation'; import { extractErrorMessage } from 'util/extractErrorMessage'; @@ -35,9 +34,8 @@ import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; const CollectorsOnboardingInstancePage = () => { const { instanceUid } = useParams<{ instanceUid: string }>(); - const location = useLocation<{ platformId?: PlatformId; fleetName?: string } | null>(); + const location = useLocation<{ fleetName?: string } | null>(); // Set by the onboarding wizard's history push; absent on direct visits. - const platformId = location.state?.platformId; const stateFleetName = location.state?.fleetName; const { data: instance, isLoading, error } = useInstance(instanceUid); @@ -74,7 +72,7 @@ const CollectorsOnboardingInstancePage = () => { ); } - return ; + return ; }; return ( From c7b46005fcc86dc89c08b86eb4ad52215693b774 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Wed, 29 Jul 2026 18:55:24 +0200 Subject: [PATCH 02/16] new version of the onboarding success page with a much friendlier layout some data is still mocked, we need to add aggregations to the search to fill the sources throughput etc --- .../collectors/hooks/useInstanceQueries.ts | 3 +- .../onboarding/CollectorFactsSection.tsx | 118 ++++++++++ .../onboarding/ConnectionSuccess.test.tsx | 109 ++++++--- .../overview/onboarding/ConnectionSuccess.tsx | 206 ++++++++++-------- .../overview/onboarding/LogPreviewSection.tsx | 13 +- .../overview/onboarding/NextSteps.tsx | 133 +++++++++++ .../onboarding/OnboardingTimeline.tsx | 107 +++++++++ .../onboarding/SourceStatusSection.tsx | 131 +++++++++++ .../CollectorsOnboardingInstancePage.test.tsx | 11 - 9 files changed, 690 insertions(+), 141 deletions(-) create mode 100644 graylog2-web-interface/src/components/collectors/overview/onboarding/CollectorFactsSection.tsx create mode 100644 graylog2-web-interface/src/components/collectors/overview/onboarding/NextSteps.tsx create mode 100644 graylog2-web-interface/src/components/collectors/overview/onboarding/OnboardingTimeline.tsx create mode 100644 graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx diff --git a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts index 1c4fc842b35b..d4b65af66a8f 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts @@ -29,6 +29,7 @@ import type { CollectorInstanceView } from '../types'; const NO_SESSION_EXT: RequestOptions = { requestShouldExtendSession: false }; export const INSTANCES_KEY_PREFIX = ['collectors', 'instances']; export const instancesKeyFn = (searchParams: SearchParams) => [...INSTANCES_KEY_PREFIX, 'paginated', searchParams]; +export const instanceKeyFn = (instanceUid: string | undefined) => [...INSTANCES_KEY_PREFIX, 'single', instanceUid]; type ApiInstanceResponse = Awaited>['elements'][number]; @@ -120,7 +121,7 @@ export const useInstances = (fleetId?: string, options: { refetchInterval?: numb export const useInstance = (instanceUid: string | undefined) => { const { data, isLoading, error, isError } = useQuery({ - queryKey: [...INSTANCES_KEY_PREFIX, 'single', instanceUid], + queryKey: instanceKeyFn(instanceUid), queryFn: () => defaultOnError( Collectors.getInstance(instanceUid).then((response) => toView(response)), diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/CollectorFactsSection.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/CollectorFactsSection.tsx new file mode 100644 index 000000000000..ce07c00f3689 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/CollectorFactsSection.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 { useState } from 'react'; +import styled, { css } from 'styled-components'; + +import { Button } from 'components/bootstrap'; +import { Link, RelativeTime, Section, SimpleGrid } from 'components/common'; +import Routes from 'routing/Routes'; +import { defaultCompare } from 'logic/DefaultCompare'; +import type { CollectorInstanceView } from 'components/collectors/types'; + +import collectorOsName from '../../common/collectorOsName'; + +type Props = { + instance: CollectorInstanceView; + fleetName: string | undefined; +}; + +const FactLabel = styled.div( + ({ theme }) => css` + font-size: ${theme.fonts.size.tiny}; + text-transform: uppercase; + letter-spacing: 0.04em; + color: ${theme.colors.gray[60]}; + `, +); + +const FactValue = styled.div( + ({ theme }) => css` + font-size: ${theme.fonts.size.small}; + font-weight: 600; + overflow-wrap: anywhere; + `, +); + +const StatusDot = styled.span<{ $online: boolean }>( + ({ $online, theme }) => css` + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: ${theme.spacings.xxs}; + background-color: ${$online ? theme.colors.variant.success : theme.colors.variant.warning}; + `, +); + +const ToggleButton = styled(Button)` + padding-left: 0; +`; + +const Fact = ({ label, children = undefined }: React.PropsWithChildren<{ label: string }>) => ( +
+ {label} + {children} +
+); + +/** + * The curated collector facts (host, OS, version, fleet, enrollment and heartbeat times) with the + * full attribute list tucked behind a toggle — the handful of values that matter during onboarding + * stay scannable while everything the agent reported remains one click away. + */ +const CollectorFactsSection = ({ instance, fleetName }: Props) => { + const [showAttributes, setShowAttributes] = useState(false); + + const attributes = [ + ...Object.entries(instance.identifying_attributes ?? {}), + ...Object.entries(instance.non_identifying_attributes ?? {}), + ].sort((attr1, attr2) => defaultCompare(attr1[0], attr2[0])); + + return ( +
+ + {instance.hostname ?? instance.instance_uid} + {collectorOsName(instance)} + {instance.version ?? 'Unknown'} + + {fleetName ?? 'Unknown'} + + + + + + + + + {showAttributes && + attributes.map(([key, value]) => ( + + {String(value)} + + ))} + + {attributes.length > 0 && ( + setShowAttributes((show) => !show)}> + {showAttributes ? 'Hide attributes' : `Show all ${attributes.length} attributes`} + + )} +
+ ); +}; + +export default CollectorFactsSection; diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx index 94d367b330a5..3dad824bf106 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx @@ -16,10 +16,11 @@ */ import * as React from 'react'; import { render, screen } from 'wrappedTestingLibrary'; +import userEvent from '@testing-library/user-event'; import asMock from 'helpers/mocking/AsMock'; import { useSources } from 'components/collectors/hooks/useSourceQueries'; -import type { CollectorInstanceView } from 'components/collectors/types'; +import type { CollectorInstanceView, Source } from 'components/collectors/types'; import ConnectionSuccess from './ConnectionSuccess'; import useCollectorLogPreview from './useCollectorLogPreview'; @@ -36,34 +37,39 @@ const instance = { enrolled_at: '2026-06-10T12:00:00Z', last_seen: '2026-06-10T12:01:00Z', status: 'online', - identifying_attributes: {}, - non_identifying_attributes: {}, + identifying_attributes: { 'service.instance.id': 'uid-42' }, + non_identifying_attributes: { 'host.arch': 'arm64' }, hostname: 'web-prod-01', os: 'linux', version: '1.2.3', -} as CollectorInstanceView; +} as unknown as CollectorInstanceView; + +const sources = [ + { id: 's1', name: 'Syslog', type: 'file', enabled: true }, + { id: 's2', name: 'System Journal', type: 'journald', enabled: true }, + { id: 's3', name: 'Windows Event Log', type: 'windows_event_log', enabled: true }, +] as Array; + +const logPreview = { + sourceLogs: { + messages: [{ id: 'm1', timestamp: '2026-06-10T12:00:30.000Z', text: 'a source log line' }], + total: 23, + }, + selfLogs: { + messages: [{ id: 'm2', timestamp: '2026-06-10T12:00:10.000Z', text: 'collector started' }], + total: 7, + }, + selfLogsError: null, + sourceLogsError: null, + isLoading: false, +}; describe('ConnectionSuccess', () => { beforeEach(() => { jest.clearAllMocks(); - asMock(useCollectorLogPreview).mockReturnValue({ - sourceLogs: { - messages: [{ id: 'm1', timestamp: '2026-06-10T12:00:30.000Z', text: 'a source log line' }], - total: 23, - }, - selfLogs: { - messages: [{ id: 'm2', timestamp: '2026-06-10T12:00:10.000Z', text: 'collector started' }], - total: 7, - }, - selfLogsError: null, - sourceLogsError: null, - isLoading: false, - }); - - asMock(useSources).mockReturnValue({ - data: [{ id: 's1' }, { id: 's2' }], - } as ReturnType); + asMock(useCollectorLogPreview).mockReturnValue(logPreview); + asMock(useSources).mockReturnValue({ data: sources } as ReturnType); }); it('shows real instance data', () => { @@ -71,7 +77,7 @@ describe('ConnectionSuccess', () => { expect(screen.getByText('web-prod-01')).toBeInTheDocument(); expect(screen.getByText('1.2.3')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Default Fleet' })).toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: 'Default Fleet' })).toHaveLength(2); }); it('previews source logs for the connected instance', () => { @@ -81,24 +87,46 @@ describe('ConnectionSuccess', () => { expect(screen.getByText(/a source log line/)).toBeInTheDocument(); }); - it('shows the fleet source count', () => { + it('walks through the completed onboarding steps', () => { render(); expect(useSources).toHaveBeenCalledWith('fleet-1'); - expect(screen.getByText(/2 configured/)).toBeInTheDocument(); + expect(screen.getByText('Connected')).toBeInTheDocument(); + expect(screen.getByText(/3 sources from fleet/)).toBeInTheDocument(); + expect(screen.getByText(/23 messages in the last 15 minutes/)).toBeInTheDocument(); + }); + + it('spins on the first-messages step until source messages arrive', () => { + asMock(useCollectorLogPreview).mockReturnValue({ + ...logPreview, + sourceLogs: { messages: [], total: 0 }, + }); + + render(); + + expect(screen.getByText('Listening... usually under a minute')).toBeInTheDocument(); + expect(screen.getAllByText('Waiting for first messages...').length).toBeGreaterThan(0); + expect(screen.getByText('Checking every few seconds')).toBeInTheDocument(); }); - it('renders the self-logs section collapsed', () => { + it('marks sources that cannot apply to the collector platform', () => { render(); - expect(screen.getByRole('heading', { name: /collector logs/i })).toBeInTheDocument(); - expect(screen.getByTestId('collapseButton')).toBeInTheDocument(); + expect(screen.getByText('Not applicable on Linux')).toBeInTheDocument(); + }); + + it('reveals all attributes behind the toggle', async () => { + render(); + + expect(screen.queryByText('arm64')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Show all 2 attributes' })); + + expect(screen.getByText('arm64')).toBeInTheDocument(); }); it('falls back to the instance uid when hostname is missing', () => { - render( - , - ); + render(); expect(screen.getByText('uid-42')).toBeInTheDocument(); }); @@ -106,8 +134,23 @@ describe('ConnectionSuccess', () => { it('renders the what-is-next links', () => { render(); - expect(screen.getByRole('link', { name: 'Manage Fleets' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Configure Sources' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'View Instances' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Explore your data' })).toBeInTheDocument(); + // Also offered as the Log sources section action, hence two of them. + expect(screen.getAllByRole('link', { name: 'Configure sources' })).toHaveLength(2); + expect(screen.getByRole('link', { name: 'Manage fleets' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Install another collector' })).toBeInTheDocument(); + }); + + it('switches to the troubleshooting view when the collector is offline', () => { + render(); + + expect(screen.getByText('Connection lost')).toBeInTheDocument(); + expect(screen.getByText('Get it back online')).toBeInTheDocument(); + expect(screen.getAllByText('Paused — collector offline').length).toBeGreaterThan(0); + expect(screen.getByRole('link', { name: 'View instances' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Check again' })).toBeInTheDocument(); + // The preview switches from source messages to the collector's own logs. + expect(screen.getByText(/collector started/)).toBeInTheDocument(); + expect(screen.queryByText(/a source log line/)).not.toBeInTheDocument(); }); }); diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx index 1160a834fa02..50d6a226d9d6 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx @@ -15,133 +15,149 @@ * . */ import * as React from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import styled, { css } from 'styled-components'; -import {Label} from 'components/bootstrap'; -import {Icon, Link, RelativeTime, Section, SimpleGrid, Stack} from 'components/common'; +import { Button } from 'components/bootstrap'; +import { Group, LinkContainer, RelativeTime, Stack } from 'components/common'; import Routes from 'routing/Routes'; -import {defaultCompare} from 'logic/DefaultCompare'; -import type {CollectorInstanceView} from 'components/collectors/types'; -import {useSources} from 'components/collectors/hooks/useSourceQueries'; +import type { CollectorInstanceView } from 'components/collectors/types'; +import { useSources } from 'components/collectors/hooks/useSourceQueries'; +import { instanceKeyFn } from 'components/collectors/hooks/useInstanceQueries'; import useCollectorLogPreview from './useCollectorLogPreview'; import LogPreviewSection from './LogPreviewSection'; +import OnboardingTimeline from './OnboardingTimeline'; +import NextSteps from './NextSteps'; +import CollectorFactsSection from './CollectorFactsSection'; +import SourceStatusSection from './SourceStatusSection'; -import {DetailLabel, DetailRow} from '../../common/DetailRow'; -import {IconRow, IconRowList} from '../../common/IconRowList'; import InstanceStatusLabel from '../../common/InstanceStatusLabel'; -import collectorOsName from '../../common/collectorOsName'; import collectorReceivedMessagesUrl from '../../common/collectorReceivedMessagesUrl'; import collectorSystemLogsUrl from '../../common/collectorSystemLogsUrl'; -import {COLLECTOR_INSTANCE_UID_FIELD} from '../../common/fields'; -import {SOURCE_TYPE_LABELS} from '../../sources/Constants'; +import { COLLECTOR_INSTANCE_UID_FIELD } from '../../common/fields'; type Props = { instance: CollectorInstanceView; fleetName: string | undefined; }; -const ConnectionSuccess = ({instance, fleetName}: Props) => { - const {selfLogs, sourceLogs, selfLogsError, sourceLogsError, isLoading} = useCollectorLogPreview( +const Title = styled.h2` + margin: 0; +`; + +const Subtitle = styled.div( + ({ theme }) => css` + margin-top: ${theme.spacings.xxs}; + color: ${theme.colors.gray[60]}; + `, +); + +// The guided timeline gets a fixed rail; the detail sections take the rest. Below the tablet +// breakpoint the rail stacks on top instead of squeezing the sections. +const Columns = styled.div( + ({ theme }) => css` + display: grid; + grid-template-columns: minmax(280px, 400px) minmax(0, 1fr); + gap: ${theme.spacings.xl}; + align-items: start; + + @media (max-width: ${theme.breakpoints.max.md}) { + grid-template-columns: minmax(0, 1fr); + } + `, +); + +const ConnectionSuccess = ({ instance, fleetName }: Props) => { + const { selfLogs, sourceLogs, selfLogsError, sourceLogsError, isLoading } = useCollectorLogPreview( instance.instance_uid, ); - const {data: sources} = useSources(instance.fleet_id); + const { data: sources } = useSources(instance.fleet_id); + const queryClient = useQueryClient(); + + const online = instance.status === 'online'; + const receiving = (sourceLogs?.total ?? 0) > 0; + const sourceLogsUrl = collectorReceivedMessagesUrl(COLLECTOR_INSTANCE_UID_FIELD, instance.instance_uid); - const attributes = [ - ...Object.entries(instance.identifying_attributes ?? {}), - ...Object.entries(instance.non_identifying_attributes ?? {}), - ].sort((attr1, attr2) => defaultCompare(attr1[0], attr2[0])); + const subtitle = () => { + if (!online) + return ( + <> + The collector connected once but hasn't reported in since{' '} + . + + ); + if (receiving) return <>The collector is connected and delivering messages.; + + return <>Almost there — the collector is connected and we're listening for its first messages.; + }; return ( -
- -
- - Status: - - - - OS: - {collectorOsName(instance)} - - - Version: - {instance.version || 'Unknown'} - - - Last seen: - - - - Enrolled: - - - - {attributes.map(([key, value]) => ( - - {key} - {String(value)} - - ))} -
- -
- - Name: - {fleetName ?? 'Unknown'} - - - Sources: - {sources?.length ?? 0} configured - - - - {sources?.map((source) => ( - - - {source.name} - - ))} - -
- -
- - - - Manage Fleets - - - - Configure Sources - - - - View Instances - - -
-
-
- {/* Zero gap: each `Section` already carries its own bottom margin. */} - + +
+ + Setting up {instance.hostname ?? instance.instance_uid} + {!online && } + + {subtitle()} +
+ {online ? ( + + + + ) : ( + + + + + + + )} +
+ + +
+ + +
+ + + + + +
+ + {/* While healthy the preview tails what the collector delivers; once it drops offline the + collector's own logs usually hold the reason, so they take over. */} + {online ? ( - + ) : ( -
+ )}
); }; diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/LogPreviewSection.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/LogPreviewSection.tsx index 83ff8796b67e..ebebde697785 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/LogPreviewSection.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/LogPreviewSection.tsx @@ -31,6 +31,8 @@ type Props = { isLoading: boolean; error: Error | null; collapsible?: boolean; + /** Explains what the preview is showing (and when it refreshes), rendered below the messages. */ + caption?: React.ReactNode; }; const MessageRow = styled.div( @@ -61,6 +63,14 @@ const EmptyState = styled.div( `, ); +const Caption = styled.div( + ({ theme }) => css` + margin-top: ${theme.spacings.sm}; + font-size: ${theme.fonts.size.small}; + color: ${theme.colors.gray[60]}; + `, +); + const PreviewBody = ({ preview, isLoading, error }: Pick) => { // Last good results win over transient refresh errors. if (preview && preview.messages.length > 0) { @@ -100,7 +110,7 @@ const PreviewBody = ({ preview, isLoading, error }: Pick ( +const LogPreviewSection = ({ title, searchUrl, preview, isLoading, error, collapsible = false, caption = undefined }: Props) => (
{preview ? preview.total : '—'} : undefined} actions={Open in search}> + {caption && {caption}}
); diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/NextSteps.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/NextSteps.tsx new file mode 100644 index 000000000000..abefaaab4c2d --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/NextSteps.tsx @@ -0,0 +1,133 @@ +/* + * 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 styled, { css } from 'styled-components'; + +import { Icon, Link } from 'components/common'; +import type { IconName } from 'components/common/Icon/types'; +import Routes from 'routing/Routes'; +import type { CollectorInstanceView } from 'components/collectors/types'; + +import { IconRow, IconRowList } from '../../common/IconRowList'; +import collectorReceivedMessagesUrl from '../../common/collectorReceivedMessagesUrl'; +import { COLLECTOR_INSTANCE_UID_FIELD } from '../../common/fields'; + +type Props = { + instance: CollectorInstanceView; +}; + +/** Separates this panel from the timeline above it. */ +const Panel = styled.div( + ({ theme }) => css` + margin-top: ${theme.spacings.lg}; + padding-top: ${theme.spacings.md}; + border-top: 1px solid ${theme.colors.gray[90]}; + `, +); + +const PanelTitle = styled.h4( + ({ theme }) => css` + margin-bottom: ${theme.spacings.sm}; + font-weight: 600; + `, +); + +const LinkDescription = styled.div( + ({ theme }) => css` + font-size: ${theme.fonts.size.small}; + color: ${theme.colors.gray[60]}; + `, +); + +const TroubleshootingList = styled.ol( + ({ theme }) => css` + margin: 0; + padding-left: ${theme.spacings.lg}; + font-size: ${theme.fonts.size.small}; + + > li { + margin-bottom: ${theme.spacings.xs}; + } + `, +); + +type NextLink = { icon: IconName; title: string; description: string; to: string }; + +const nextLinks = (instance: CollectorInstanceView): Array => [ + { + icon: 'search', + title: 'Explore your data', + description: 'Open search filtered to this collector', + to: collectorReceivedMessagesUrl(COLLECTOR_INSTANCE_UID_FIELD, instance.instance_uid), + }, + { + icon: 'settings', + title: 'Configure sources', + description: 'Add or tune what this fleet collects', + to: Routes.SYSTEM.COLLECTORS.FLEET(instance.fleet_id), + }, + { + icon: 'hub', + title: 'Manage fleets', + description: 'Move this collector to a permanent fleet', + to: Routes.SYSTEM.COLLECTORS.FLEETS, + }, + { + icon: 'add_circle', + title: 'Install another collector', + description: 'Repeat this setup on another host', + to: Routes.SYSTEM.COLLECTORS.OVERVIEW, + }, +]; + +/** + * The panel below the onboarding timeline: pointers to what to do next while everything is + * healthy, or recovery steps when the collector has gone offline. + */ +const NextSteps = ({ instance }: Props) => { + if (instance.status !== 'online') { + return ( + + Get it back online + +
  • Check that the collector service is running on the host.
  • +
  • Verify the host can reach this Graylog server.
  • +
  • Review the collector's own logs in the preview below for connection errors.
  • +
    +
    + ); + } + + return ( + + What's next + + {nextLinks(instance).map(({ icon, title, description, to }) => ( + + +
    + {title} + {description} +
    +
    + ))} +
    +
    + ); +}; + +export default NextSteps; diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/OnboardingTimeline.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/OnboardingTimeline.tsx new file mode 100644 index 000000000000..0c9df851790a --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/OnboardingTimeline.tsx @@ -0,0 +1,107 @@ +/* + * 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 styled, { css } from 'styled-components'; + +import { Icon, Link, RelativeTime, Spinner, Timeline } from 'components/common'; +import Routes from 'routing/Routes'; +import type { CollectorInstanceView } from 'components/collectors/types'; + +type Props = { + instance: CollectorInstanceView; + fleetName: string | undefined; + sourceCount: number; + /** Source messages received in the preview window; undefined while the first search is running. */ + receivedTotal: number | undefined; +}; + +const StepDetail = styled.div( + ({ theme }) => css` + font-size: ${theme.fonts.size.small}; + color: ${theme.colors.gray[60]}; + `, +); + +const CheckBullet = () => ; + +/** + * The onboarding journey as a guided timeline: enrolled → connected → sources configured → first + * messages. The last step is the only one that can still be in flight — it spins until the log + * preview sees source messages, completes once messages arrive, and turns into a warning when the + * collector drops offline before delivering any. + */ +const OnboardingTimeline = ({ instance, fleetName, sourceCount, receivedTotal }: Props) => { + const offline = instance.status !== 'online'; + const receiving = (receivedTotal ?? 0) > 0; + // Steps 0-2 are always in the past for an enrolled collector; step 3 joins them once messages + // flow (or fails visibly when the collector goes dark). + const activeStep = receiving || offline ? 3 : 2; + + const lastStep = () => { + if (offline) { + return ( + }> + + Last heartbeat + + + ); + } + + if (receiving) { + return ( + }> + + Receiving · {receivedTotal} {receivedTotal === 1 ? 'message' : 'messages'} in the last 15 minutes + + + ); + } + + return ( + + + + + + ); + }; + + return ( + + }> + + Registered with an enrollment token · + + + }> + + Heartbeat received · + + + }> + + {sourceCount} {sourceCount === 1 ? 'source' : 'sources'} from fleet{' '} + {fleetName ?? 'Unknown'} + + + {lastStep()} + + ); +}; + +export default OnboardingTimeline; diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx new file mode 100644 index 000000000000..11aa85315ba4 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx @@ -0,0 +1,131 @@ +/* + * 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 styled, { css } from 'styled-components'; + +import { Label } from 'components/bootstrap'; +import { Icon, Link, Section, Spinner } from 'components/common'; +import Routes from 'routing/Routes'; +import type { CollectorInstanceView, Source, SourceType } from 'components/collectors/types'; + +import { DividedIconRow, IconRowList } from '../../common/IconRowList'; +import { OS_LABELS } from '../../common/Constants'; +import { SOURCE_TYPE_LABELS } from '../../sources/Constants'; + +type Props = { + instance: CollectorInstanceView; + sources: Source[] | undefined; + /** Whether the collector delivered any source messages in the preview window. */ + receiving: boolean; +}; + +const SourceName = styled.span` + font-weight: 600; +`; + +const RowStatus = styled.span<{ $variant?: 'muted' | 'success' | 'warning' }>( + ({ $variant = 'muted', theme }) => css` + margin-left: auto; + display: inline-flex; + align-items: center; + gap: ${theme.spacings.xxs}; + font-size: ${theme.fonts.size.small}; + color: ${{ + muted: theme.colors.gray[60], + success: theme.colors.variant.success, + warning: theme.colors.variant.darker.warning, + }[$variant]}; + `, +); + +const Footer = styled.div( + ({ theme }) => css` + margin-top: ${theme.spacings.sm}; + font-size: ${theme.fonts.size.small}; + color: ${theme.colors.gray[60]}; + `, +); + +// The platform a source type is restricted to; sources without an entry run everywhere. +const SOURCE_PLATFORM: Partial> = { + journald: 'linux', + windows_event_log: 'windows', +}; + +const SourceStatus = ({ source, instance, receiving }: { source: Source } & Omit) => { + const requiredPlatform = SOURCE_PLATFORM[source.type]; + + if (!source.enabled) { + return Disabled; + } + + if (instance.os && requiredPlatform && requiredPlatform !== instance.os) { + return Not applicable on {OS_LABELS[instance.os] ?? instance.os}; + } + + if (instance.status !== 'online') { + return Paused — collector offline; + } + + // The preview search counts messages per collector, not per source, so once anything arrives + // every active source reads as receiving. Honest per-source rates need backend support. + if (receiving) { + return ( + + Receiving + + ); + } + + return ( + + + + ); +}; + +/** + * The fleet's sources with a per-source status: what should be collecting on this host, what + * cannot apply to its platform, and whether messages are flowing yet. + */ +const SourceStatusSection = ({ instance, sources, receiving }: Props) => { + const online = instance.status === 'online'; + + return ( +
    Configure sources}> + {!sources?.length ? ( +
    No sources configured for this fleet yet.
    + ) : ( + + {sources.map((source) => ( + + + {source.name} + + + ))} + + )} + {online && !receiving &&
    Checking every few seconds
    } +
    + ); +}; + +export default SourceStatusSection; diff --git a/graylog2-web-interface/src/pages/CollectorsOnboardingInstancePage.test.tsx b/graylog2-web-interface/src/pages/CollectorsOnboardingInstancePage.test.tsx index da79c36e54ea..bea53de15604 100644 --- a/graylog2-web-interface/src/pages/CollectorsOnboardingInstancePage.test.tsx +++ b/graylog2-web-interface/src/pages/CollectorsOnboardingInstancePage.test.tsx @@ -42,18 +42,15 @@ jest.mock( function ConnectionSuccessStub({ instance, fleetName = undefined, - platformId = undefined, }: { instance: { hostname: string | null }; fleetName?: string; - platformId?: string; }) { return (
    Collector connected {instance.hostname} {fleetName} - {platformId ?? 'none'}
    ); }, @@ -109,14 +106,6 @@ describe('CollectorsOnboardingInstancePage', () => { expect(screen.getByText('Default Fleet')).toBeInTheDocument(); }); - it('passes the platform from router location state', () => { - mockUseLocation.mockReturnValue({ state: { platformId: 'linux' } }); - - render(); - - expect(screen.getByTestId('platform-id')).toHaveTextContent('linux'); - }); - it('falls back to the fleet name from location state while the fleet loads', () => { mockUseLocation.mockReturnValue({ state: { fleetName: 'Fresh Fleet' } }); asMock(useFleet).mockReturnValue({ data: undefined } as ReturnType); From 0eb9c45f79655d1e3012839fa6d7fe33cfc108db Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 13:35:03 +0200 Subject: [PATCH 03/16] rearrange content makes the page better to follow: status timeline, detail information in the middle, next steps or repair info on the right source logs at the bottom or in the error case we show the self logs added some tests added QuietSection to make the Sections in the middle less visually heavy, should probably become a prop on Section --- .../onboarding/CollectorFactsSection.tsx | 8 +- .../onboarding/ConnectionSuccess.test.tsx | 80 +++++++++++ .../overview/onboarding/ConnectionSuccess.tsx | 62 ++++---- .../onboarding/LogPreviewSection.test.tsx | 29 ++++ .../overview/onboarding/NextSteps.tsx | 21 +-- .../overview/onboarding/QuietSection.tsx | 37 +++++ .../onboarding/SourceStatusSection.test.tsx | 136 ++++++++++++++++++ .../onboarding/SourceStatusSection.tsx | 11 +- 8 files changed, 329 insertions(+), 55 deletions(-) create mode 100644 graylog2-web-interface/src/components/collectors/overview/onboarding/QuietSection.tsx create mode 100644 graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/CollectorFactsSection.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/CollectorFactsSection.tsx index ce07c00f3689..8a17ac22d0e2 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/CollectorFactsSection.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/CollectorFactsSection.tsx @@ -19,11 +19,13 @@ import { useState } from 'react'; import styled, { css } from 'styled-components'; import { Button } from 'components/bootstrap'; -import { Link, RelativeTime, Section, SimpleGrid } from 'components/common'; +import { Link, RelativeTime, SimpleGrid } from 'components/common'; import Routes from 'routing/Routes'; import { defaultCompare } from 'logic/DefaultCompare'; import type { CollectorInstanceView } from 'components/collectors/types'; +import QuietSection from './QuietSection'; + import collectorOsName from '../../common/collectorOsName'; type Props = { @@ -84,7 +86,7 @@ const CollectorFactsSection = ({ instance, fleetName }: Props) => { ].sort((attr1, attr2) => defaultCompare(attr1[0], attr2[0])); return ( -
    + {instance.hostname ?? instance.instance_uid} {collectorOsName(instance)} @@ -111,7 +113,7 @@ const CollectorFactsSection = ({ instance, fleetName }: Props) => { {showAttributes ? 'Hide attributes' : `Show all ${attributes.length} attributes`} )} -
    + ); }; diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx index 3dad824bf106..9795ebb0dbd5 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx @@ -17,9 +17,11 @@ import * as React from 'react'; import { render, screen } from 'wrappedTestingLibrary'; import userEvent from '@testing-library/user-event'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import asMock from 'helpers/mocking/AsMock'; import { useSources } from 'components/collectors/hooks/useSourceQueries'; +import { instanceKeyFn } from 'components/collectors/hooks/useInstanceQueries'; import type { CollectorInstanceView, Source } from 'components/collectors/types'; import ConnectionSuccess from './ConnectionSuccess'; @@ -153,4 +155,82 @@ describe('ConnectionSuccess', () => { expect(screen.getByText(/collector started/)).toBeInTheDocument(); expect(screen.queryByText(/a source log line/)).not.toBeInTheDocument(); }); + + it('invalidates the instance query when checking again', async () => { + // A real client so the assertion covers the key itself: invalidating a key that matches no + // query is a silent no-op, so a mismatch here would leave the button looking functional. + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const decoyKey = ['collectors', 'instances', 'unrelated']; + queryClient.setQueryData(instanceKeyFn('uid-42'), { instance_uid: 'uid-42' }); + queryClient.setQueryData(decoyKey, { untouched: true }); + + render( + + + , + ); + + expect(queryClient.getQueryState(instanceKeyFn('uid-42'))?.isInvalidated).toBe(false); + + await userEvent.click(screen.getByRole('button', { name: 'Check again' })); + + expect(queryClient.getQueryState(instanceKeyFn('uid-42'))?.isInvalidated).toBe(true); + // Only this instance is refreshed, not every collector query in the cache. + expect(queryClient.getQueryState(decoyKey)?.isInvalidated).toBe(false); + }); + + it('shows the empty state when the fleet has no sources', () => { + asMock(useSources).mockReturnValue({ data: [] } as ReturnType); + + render(); + + expect(screen.getByText(/0 sources from fleet/)).toBeInTheDocument(); + expect(screen.getByText('No sources configured for this fleet yet.')).toBeInTheDocument(); + }); + + it('uses singular wording for a single source and a single message', () => { + asMock(useSources).mockReturnValue({ + data: [sources[0]], + } as ReturnType); + asMock(useCollectorLogPreview).mockReturnValue({ + ...logPreview, + sourceLogs: { messages: logPreview.sourceLogs.messages, total: 1 }, + }); + + render(); + + expect(screen.getByText(/1 source from fleet/)).toBeInTheDocument(); + expect(screen.getByText(/1 message in the last 15 minutes/)).toBeInTheDocument(); + }); + + it('keeps listening while the first log search is still running', () => { + asMock(useCollectorLogPreview).mockReturnValue({ ...logPreview, sourceLogs: undefined }); + + render(); + + expect(screen.getByText('Listening... usually under a minute')).toBeInTheDocument(); + }); + + it('falls back to Unknown for a missing fleet name', () => { + render(); + + // The fleet is linked from both the timeline step and the collector facts. + expect(screen.getAllByRole('link', { name: 'Unknown' })).toHaveLength(2); + }); + + it('falls back to Unknown for a missing collector version', () => { + render(); + + expect(screen.getByText('Unknown')).toBeInTheDocument(); + }); + + it('captions the preview differently depending on the collector status', () => { + const { rerender } = render(); + + expect(screen.getByText(/Showing the newest messages from this collector/)).toBeInTheDocument(); + + rerender(); + + expect(screen.getByText(/Showing the collector's own logs/)).toBeInTheDocument(); + }); }); diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx index 50d6a226d9d6..1d4de52bd8a9 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx @@ -17,6 +17,7 @@ import * as React from 'react'; import { useQueryClient } from '@tanstack/react-query'; import styled, { css } from 'styled-components'; +import { Grid } from '@mantine/core'; import { Button } from 'components/bootstrap'; import { Group, LinkContainer, RelativeTime, Stack } from 'components/common'; @@ -53,20 +54,10 @@ const Subtitle = styled.div( `, ); -// The guided timeline gets a fixed rail; the detail sections take the rest. Below the tablet -// breakpoint the rail stacks on top instead of squeezing the sections. -const Columns = styled.div( - ({ theme }) => css` - display: grid; - grid-template-columns: minmax(280px, 400px) minmax(0, 1fr); - gap: ${theme.spacings.xl}; - align-items: start; - - @media (max-width: ${theme.breakpoints.max.md}) { - grid-template-columns: minmax(0, 1fr); - } - `, -); +// The timeline and next-steps rails keep a readable width; the detail column takes what is left. +const ColContainer = styled.div` + min-width: 350px; +`; const ConnectionSuccess = ({ instance, fleetName }: Props) => { const { selfLogs, sourceLogs, selfLogsError, sourceLogsError, isLoading } = useCollectorLogPreview( @@ -83,8 +74,7 @@ const ConnectionSuccess = ({ instance, fleetName }: Props) => { if (!online) return ( <> - The collector connected once but hasn't reported in since{' '} - . + The collector connected once but hasn't reported in since . ); if (receiving) return <>The collector is connected and delivering messages.; @@ -120,22 +110,30 @@ const ConnectionSuccess = ({ instance, fleetName }: Props) => { )} - -
    - - -
    - - - - - -
    + + + + + + + + + + + + + + + + + + + {/* While healthy the preview tails what the collector delivers; once it drops offline the collector's own logs usually hold the reason, so they take over. */} diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/LogPreviewSection.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/LogPreviewSection.test.tsx index e3cff40d4572..3189b13954b4 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/LogPreviewSection.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/LogPreviewSection.test.tsx @@ -125,6 +125,35 @@ describe('LogPreviewSection', () => { expect(screen.queryByText('0')).not.toBeInTheDocument(); }); + it('renders a caption below the preview when one is given', () => { + render( + , + ); + + expect(screen.getByText('Showing the newest messages from this collector')).toBeInTheDocument(); + }); + + it('renders no caption when none is given', () => { + render( + , + ); + + expect(screen.queryByText(/showing the newest messages/i)).not.toBeInTheDocument(); + }); + it('renders a message count in the header when collapsible', () => { render( css` - margin-top: ${theme.spacings.lg}; - padding-top: ${theme.spacings.md}; - border-top: 1px solid ${theme.colors.gray[90]}; - `, -); - const PanelTitle = styled.h4( ({ theme }) => css` margin-bottom: ${theme.spacings.sm}; @@ -99,21 +91,22 @@ const nextLinks = (instance: CollectorInstanceView): Array => [ * healthy, or recovery steps when the collector has gone offline. */ const NextSteps = ({ instance }: Props) => { + const productName = useProductName(); if (instance.status !== 'online') { return ( - +
    Get it back online
  • Check that the collector service is running on the host.
  • -
  • Verify the host can reach this Graylog server.
  • +
  • Verify the host can reach this {productName} server.
  • Review the collector's own logs in the preview below for connection errors.
  • - +
    ); } return ( - +
    What's next {nextLinks(instance).map(({ icon, title, description, to }) => ( @@ -126,7 +119,7 @@ const NextSteps = ({ instance }: Props) => { ))} - +
    ); }; diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/QuietSection.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/QuietSection.tsx new file mode 100644 index 000000000000..bf22a383480a --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/QuietSection.tsx @@ -0,0 +1,37 @@ +/* + * 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 styled, { css } from 'styled-components'; + +import { Section } from 'components/common'; + +/** + * A `Section` that sits flush with the page instead of in a tinted well. + * + * `Section` only ships a "filled" variant, and its `section.filled.background` token is lighter + * than the page background in the light theme but darker than it in the dark theme — so a filled + * section reads as sunken there. On the onboarding page the timeline is meant to lead, so the + * supporting detail sections keep the section chrome (heading, radius, padding) while dropping the + * fill and softening the border. + */ +const QuietSection = styled(Section)( + ({ theme }) => css` + background-color: ${theme.colors.global.contentBackground}; + border-color: ${theme.colors.cards.border}; + `, +); + +export default QuietSection; diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx new file mode 100644 index 000000000000..c5d311380187 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx @@ -0,0 +1,136 @@ +/* + * 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 { render, screen } from 'wrappedTestingLibrary'; + +import type { CollectorInstanceView, Source } from 'components/collectors/types'; + +import SourceStatusSection from './SourceStatusSection'; + +const instance = { + id: 'uid-42', + instance_uid: 'uid-42', + fleet_id: 'fleet-1', + status: 'online', + os: 'linux', +} as unknown as CollectorInstanceView; + +const offlineInstance = { ...instance, status: 'offline' } as CollectorInstanceView; + +const source = (overrides: Partial) => + ({ id: 's1', name: 'Syslog', type: 'file', enabled: true, ...overrides }) as Source; + +describe('SourceStatusSection', () => { + it('tells the user when the fleet has no sources yet', () => { + render(); + + expect(screen.getByText('No sources configured for this fleet yet.')).toBeInTheDocument(); + }); + + it('tells the user when the sources are still loading', () => { + render(); + + expect(screen.getByText('No sources configured for this fleet yet.')).toBeInTheDocument(); + }); + + it('waits for the first messages while nothing is flowing', () => { + render(); + + expect(screen.getByText('Syslog')).toBeInTheDocument(); + expect(screen.getByText('Waiting for first messages...')).toBeInTheDocument(); + expect(screen.getByText('Checking every few seconds')).toBeInTheDocument(); + }); + + it('reports sources as receiving once messages arrive', () => { + render(); + + expect(screen.getByText('Receiving')).toBeInTheDocument(); + // The polling hint is only useful while we are still waiting for something to show up. + expect(screen.queryByText('Checking every few seconds')).not.toBeInTheDocument(); + }); + + it('pauses every source while the collector is offline', () => { + render(); + + expect(screen.getByText('Paused — collector offline')).toBeInTheDocument(); + expect(screen.queryByText('Checking every few seconds')).not.toBeInTheDocument(); + }); + + it('marks sources that cannot run on the collector platform', () => { + render( + , + ); + + expect(screen.getByText('Not applicable on Linux')).toBeInTheDocument(); + }); + + it('applies the platform restriction per source type', () => { + render( + , + ); + + // journald cannot run on Windows, the event log can, and a plain file source runs everywhere. + expect(screen.getByText('Not applicable on Windows')).toBeInTheDocument(); + expect(screen.getAllByText('Receiving')).toHaveLength(2); + }); + + it('reports a disabled source as disabled rather than as waiting', () => { + render(); + + expect(screen.getByText('Disabled')).toBeInTheDocument(); + expect(screen.queryByText('Waiting for first messages...')).not.toBeInTheDocument(); + }); + + it('prefers the disabled status over the offline and platform ones', () => { + render( + , + ); + + expect(screen.getByText('Disabled')).toBeInTheDocument(); + expect(screen.queryByText('Paused — collector offline')).not.toBeInTheDocument(); + expect(screen.queryByText('Not applicable on Linux')).not.toBeInTheDocument(); + }); + + it('prefers the platform status over the offline one', () => { + render( + , + ); + + expect(screen.getByText('Not applicable on Linux')).toBeInTheDocument(); + expect(screen.queryByText('Paused — collector offline')).not.toBeInTheDocument(); + }); +}); diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx index 11aa85315ba4..d0f4b6f207ca 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx @@ -17,14 +17,14 @@ import * as React from 'react'; import styled, { css } from 'styled-components'; -import { Label } from 'components/bootstrap'; -import { Icon, Link, Section, Spinner } from 'components/common'; +import { Icon, Link, Spinner } from 'components/common'; import Routes from 'routing/Routes'; import type { CollectorInstanceView, Source, SourceType } from 'components/collectors/types'; +import QuietSection from './QuietSection'; + import { DividedIconRow, IconRowList } from '../../common/IconRowList'; import { OS_LABELS } from '../../common/Constants'; -import { SOURCE_TYPE_LABELS } from '../../sources/Constants'; type Props = { instance: CollectorInstanceView; @@ -106,7 +106,7 @@ const SourceStatusSection = ({ instance, sources, receiving }: Props) => { const online = instance.status === 'online'; return ( -
    Configure sources}> @@ -116,7 +116,6 @@ const SourceStatusSection = ({ instance, sources, receiving }: Props) => { {sources.map((source) => ( - {source.name} @@ -124,7 +123,7 @@ const SourceStatusSection = ({ instance, sources, receiving }: Props) => { )} {online && !receiving &&
    Checking every few seconds
    } -
    + ); }; From b95fc6b833f61c90802362e1dc2c253441281bdc Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 14:03:50 +0200 Subject: [PATCH 04/16] test: look up preview search types by type instead of index --- .../overview/onboarding/useCollectorLogPreview.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts index 767749e27373..0f0de14530bc 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts @@ -22,6 +22,7 @@ import { startJob, executeJobResult } from 'views/logic/slices/executeJobResult' import type Search from 'views/logic/search/Search'; import MessageSortConfig from 'views/logic/searchtypes/messages/MessageSortConfig'; import Direction from 'views/logic/aggregationbuilder/Direction'; +import type Query from 'views/logic/queries/Query'; import useCollectorLogPreview from './useCollectorLogPreview'; @@ -56,6 +57,8 @@ describe('useCollectorLogPreview', () => { }; }; + const searchTypeIdByType = (query: Query, type: string) => query.searchTypes.find((st) => st.type === type).id; + // Lets executeJobResult mocks reference the search that createSearch received. const captureCreatedSearch = () => { let createdSearch: Search; @@ -90,7 +93,7 @@ describe('useCollectorLogPreview', () => { if (queryId === sourceQuery.id) { return { searchTypes: { - [sourceQuery.searchTypes[0].id]: { + [searchTypeIdByType(sourceQuery, 'messages')]: { type: 'messages', messages: [resultMessage('m1', '2026-06-10T12:00:00.000Z', 'a source log line')], total: 42, @@ -102,7 +105,7 @@ describe('useCollectorLogPreview', () => { if (queryId === selfQuery.id) { return { searchTypes: { - [selfQuery.searchTypes[0].id]: { + [searchTypeIdByType(selfQuery, 'messages')]: { type: 'messages', messages: [resultMessage('m2', '2026-06-10T11:59:00.000Z', 'collector started')], total: 7, From b59fc3875d4020eca459f1516b1a709349bb542c Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 14:10:15 +0200 Subject: [PATCH 05/16] feat: aggregate collector preview message counts per source Adds a per-source aggregation search type to the preview search so useCollectorLogPreview exposes sourceCounts (Record | undefined), keyed by Source.id. undefined means the aggregation result was unavailable, distinct from "every source produced nothing". Also updates ConnectionSuccess.test.tsx's shared useCollectorLogPreview mock to include the new required field so the type-check stays green; no rendering behaviour changed there. --- .../onboarding/ConnectionSuccess.test.tsx | 1 + .../onboarding/useCollectorLogPreview.test.ts | 70 ++++++++++++++++++- .../onboarding/useCollectorLogPreview.ts | 56 ++++++++++++++- 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx index 9795ebb0dbd5..209392322abb 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx @@ -61,6 +61,7 @@ const logPreview = { messages: [{ id: 'm2', timestamp: '2026-06-10T12:00:10.000Z', text: 'collector started' }], total: 7, }, + sourceCounts: undefined, selfLogsError: null, sourceLogsError: null, isLoading: false, diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts index 0f0de14530bc..82a8fa27acbc 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts @@ -23,6 +23,7 @@ import type Search from 'views/logic/search/Search'; import MessageSortConfig from 'views/logic/searchtypes/messages/MessageSortConfig'; import Direction from 'views/logic/aggregationbuilder/Direction'; import type Query from 'views/logic/queries/Query'; +import type { AggregationSearchType } from 'views/logic/queries/SearchType'; import useCollectorLogPreview from './useCollectorLogPreview'; @@ -98,6 +99,22 @@ describe('useCollectorLogPreview', () => { messages: [resultMessage('m1', '2026-06-10T12:00:00.000Z', 'a source log line')], total: 42, }, + [searchTypeIdByType(sourceQuery, 'aggregation')]: { + type: 'pivot', + total: 42, + rows: [ + { + source: 'leaf', + key: ['s1'], + values: [{ source: 'row-leaf', key: ['count()'], value: 40, rollup: false }], + }, + { + source: 'leaf', + key: ['s2'], + values: [{ source: 'row-leaf', key: ['count()'], value: 2, rollup: false }], + }, + ], + }, }, }; } @@ -172,8 +189,10 @@ describe('useCollectorLogPreview', () => { queries.forEach((q) => { expect(q.timerange).toEqual({ type: 'relative', from: 900 }); - expect(q.searchTypes).toHaveLength(1); - expect(q.searchTypes[0]).toEqual( + + const messagesSearchType = q.searchTypes.find((st) => st.type === 'messages'); + + expect(messagesSearchType).toEqual( expect.objectContaining({ type: 'messages', limit: 10, @@ -182,6 +201,53 @@ describe('useCollectorLogPreview', () => { }), ); }); + + // The self-query only ever needs message previews; the source query also carries the + // per-source aggregation used to compute sourceCounts. + expect(selfQuery.searchTypes).toHaveLength(1); + expect(sourceQuery.searchTypes).toHaveLength(2); + }); + + it('aggregates message counts per source', async () => { + const getCreatedSearch = captureCreatedSearch(); + + asMock(executeJobResult).mockImplementation(async () => asExecutionResult(makeResultsMock(getCreatedSearch()))); + + const { result } = renderHook(() => useCollectorLogPreview('uid-42')); + + await waitFor(() => expect(result.current.sourceCounts).toBeDefined()); + + expect(result.current.sourceCounts).toEqual({ s1: 40, s2: 2 }); + }); + + it('groups the source counts by source id with an explicit bucket limit', async () => { + mockEmptyResults(); + + renderHook(() => useCollectorLogPreview('uid-42')); + + await waitFor(() => expect(createSearch).toHaveBeenCalledTimes(1)); + + const search: Search = asMock(createSearch).mock.calls[0][0]; + const { sourceQuery } = splitQueries(search); + // Query.searchTypes is a plain Array; the union's `type` field isn't a narrowing + // discriminant (it's typed as `string`), so the aggregation-only fields need this assertion. + const aggregation = sourceQuery.searchTypes.find((st) => st.type === 'aggregation') as + | AggregationSearchType + | undefined; + + expect(aggregation.row_groups).toEqual([{ type: 'values', fields: ['collector_source_id'], limit: 100 }]); + // A `count` series must carry no field: count() counts occurrences of that field. + expect(aggregation.series).toEqual([{ id: 'count()', type: 'count' }]); + }); + + it('reports unknown source counts as undefined rather than as all-zero', async () => { + mockEmptyResults(); + + const { result } = renderHook(() => useCollectorLogPreview('uid-42')); + + await waitFor(() => expect(result.current.sourceLogs).toBeDefined()); + + expect(result.current.sourceCounts).toBeUndefined(); }); it('executes the created search with the execution helpers', async () => { diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts index d9af0f7c3d88..652f94748212 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts @@ -20,6 +20,7 @@ import { COLLECTOR_INSTANCE_UID_FIELD, COLLECTOR_LOG_RECEIVER_TYPE, COLLECTOR_RECEIVER_TYPE_FIELD, + COLLECTOR_SOURCE_ID_FIELD, COLLECTOR_SYSTEM_LOGS_STREAM_ID, } from 'components/collectors/common/fields'; import generateId from 'logic/generateId'; @@ -31,7 +32,7 @@ import createSearch from 'views/logic/slices/createSearch'; import { startJob, executeJobResult } from 'views/logic/slices/executeJobResult'; import MessageSortConfig from 'views/logic/searchtypes/messages/MessageSortConfig'; import Direction from 'views/logic/aggregationbuilder/Direction'; -import type { MessagesSearchType } from 'views/logic/queries/SearchType'; +import type { MessagesSearchType, AggregationSearchType } from 'views/logic/queries/SearchType'; // Deliberately outside the 'collectors' prefix: useCollectorsMutations invalidates // ['collectors'] wholesale on every mutation, which would re-create the backend search. @@ -40,6 +41,17 @@ export const ONBOARDING_KEY_PREFIX = ['collector-onboarding']; const REFRESH_INTERVAL_MS = 5000; const PREVIEW_RANGE_SECONDS = 900; // last 15 minutes const PREVIEW_MESSAGE_LIMIT = 10; +// A `values` bucket returns only buckets that exist, capped at this limit, so the limit must exceed +// the fleet's source count: absent-from-the-result is read as zero, and truncated buckets would be +// the lowest-count sources. Onboarding preconfigures 4 sources and fleets are not expected to pass +// ~50, so 100 is roughly double the realistic ceiling while staying a real guardrail. +const SOURCE_BUCKET_LIMIT = 100; + +// `AggregationSearchType` requires `field` on every series, but a `count` series has none +// (`Count.field()` is Optional server-side). Widening that shared views type is deferred, so the +// series is asserted here instead. Do not "fix" this by adding a field: `count()` counts +// occurrences of that field and is a different aggregation from `count()`. +const COUNT_SERIES = [{ id: 'count()', type: 'count' }] as AggregationSearchType['series']; export type PreviewMessage = { id: string; @@ -59,6 +71,7 @@ type PreviewSearch = { selfSearchTypeId: string; sourceQueryId: string; sourceSearchTypeId: string; + sourceCountsSearchTypeId: string; }; }; @@ -78,6 +91,23 @@ const messagesSearchType = (id: string): MessagesSearchType => ({ stream_categories: [], }); +const sourceCountsSearchType = (id: string): AggregationSearchType => ({ + id, + type: 'aggregation', + row_groups: [{ type: 'values', fields: [COLLECTOR_SOURCE_ID_FIELD], limit: SOURCE_BUCKET_LIMIT }], + column_groups: [], + series: COUNT_SERIES, + sort: [], + rollup: false, + filter: undefined, + filters: undefined, + name: undefined, + query: undefined, + timerange: undefined, + streams: [], + stream_categories: [], +}); + const previewTimerange: RelativeTimeRangeWithEnd = { type: 'relative', from: PREVIEW_RANGE_SECONDS }; const buildPreviewSearch = (instanceUid: string): PreviewSearch => { @@ -86,6 +116,7 @@ const buildPreviewSearch = (instanceUid: string): PreviewSearch => { selfSearchTypeId: generateId(), sourceQueryId: generateId(), sourceSearchTypeId: generateId(), + sourceCountsSearchTypeId: generateId(), }; // Self-logs live in the dedicated (system-scoped) collector logs stream. @@ -107,7 +138,7 @@ const buildPreviewSearch = (instanceUid: string): PreviewSearch => { ), ) .timerange(previewTimerange) - .searchTypes([messagesSearchType(ids.sourceSearchTypeId)]) + .searchTypes([messagesSearchType(ids.sourceSearchTypeId), sourceCountsSearchType(ids.sourceCountsSearchTypeId)]) .build(); const search = Search.builder().newId().queries([sourceLogsQuery, selfLogsQuery]).parameters([]).build(); @@ -117,6 +148,9 @@ const buildPreviewSearch = (instanceUid: string): PreviewSearch => { type RawResultMessage = { message: { _id: string; timestamp: string; message: unknown } }; type RawMessagesResult = { messages?: Array; total?: number }; +type RawPivotValue = { source: string; value: unknown }; +type RawPivotRow = { source: string; key: Array; values?: Array }; +type RawPivotResult = { rows?: Array }; const toPreview = (searchTypeResult: RawMessagesResult | undefined): LogPreview => ({ messages: (searchTypeResult?.messages ?? []).map((m) => ({ @@ -127,6 +161,20 @@ const toPreview = (searchTypeResult: RawMessagesResult | undefined): LogPreview total: searchTypeResult?.total ?? 0, }); +// A missing result means the aggregation was unavailable, which must stay distinguishable from +// "every source produced nothing" — the caller falls back to the aggregate status in that case. +const toSourceCounts = (searchTypeResult: RawPivotResult | undefined): Record | undefined => { + if (!searchTypeResult?.rows) { + return undefined; + } + + return Object.fromEntries( + searchTypeResult.rows + .filter((row) => row.source === 'leaf' && row.key.length > 0) + .map((row) => [row.key[0], Number(row.values?.find((value) => value.source === 'row-leaf')?.value ?? 0)]), + ); +}; + const useCollectorLogPreview = (instanceUid: string) => { const { data: created, error: createError } = useQuery({ queryKey: [...ONBOARDING_KEY_PREFIX, 'preview-search', instanceUid], @@ -171,6 +219,9 @@ const useCollectorLogPreview = (instanceUid: string) => { sourceLogs: toPreview( result.forId(ids.sourceQueryId)?.searchTypes?.[ids.sourceSearchTypeId] as RawMessagesResult | undefined, ), + sourceCounts: toSourceCounts( + result.forId(ids.sourceQueryId)?.searchTypes?.[ids.sourceCountsSearchTypeId] as RawPivotResult | undefined, + ), selfLogsError: errorForQuery(ids.selfQueryId), sourceLogsError: errorForQuery(ids.sourceQueryId), }; @@ -183,6 +234,7 @@ const useCollectorLogPreview = (instanceUid: string) => { return { selfLogs: results?.selfLogs, sourceLogs: results?.sourceLogs, + sourceCounts: results?.sourceCounts, selfLogsError: paneError(results?.selfLogsError), sourceLogsError: paneError(results?.sourceLogsError), isLoading: !createError && (!created || isLoading), From fbc1308bf6db3cb5feb877bf788f900de3a73011 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 14:24:43 +0200 Subject: [PATCH 06/16] feat: report per-source message counts and honest per-source status SourceStatusSection previously claimed every enabled source was "Receiving" as soon as any message arrived for the collector, which is untrue on a per-source basis. It now consumes the per-source counts produced by useCollectorLogPreview (Task 1) and renders an honest per-source status plus a message-count column, following a 7-row precedence table (disabled, platform mismatch, offline, receiving, no messages yet, waiting for first messages, and unknown-count fallback). --- .../onboarding/ConnectionSuccess.test.tsx | 4 +- .../onboarding/SourceStatusSection.test.tsx | 94 ++++++++++++++++++- .../onboarding/SourceStatusSection.tsx | 70 +++++++++++--- 3 files changed, 151 insertions(+), 17 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx index 209392322abb..adce42323247 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx @@ -109,7 +109,9 @@ describe('ConnectionSuccess', () => { expect(screen.getByText('Listening... usually under a minute')).toBeInTheDocument(); expect(screen.getAllByText('Waiting for first messages...').length).toBeGreaterThan(0); - expect(screen.getByText('Checking every few seconds')).toBeInTheDocument(); + // The source status footer and the empty log preview both surface this hint while nothing has + // arrived yet, so more than one match is expected here. + expect(screen.getAllByText(/checking every few seconds/).length).toBeGreaterThan(0); }); it('marks sources that cannot apply to the collector platform', () => { diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx index c5d311380187..77a05740fce5 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx @@ -52,7 +52,7 @@ describe('SourceStatusSection', () => { expect(screen.getByText('Syslog')).toBeInTheDocument(); expect(screen.getByText('Waiting for first messages...')).toBeInTheDocument(); - expect(screen.getByText('Checking every few seconds')).toBeInTheDocument(); + expect(screen.getByText(/checking every few seconds/)).toBeInTheDocument(); }); it('reports sources as receiving once messages arrive', () => { @@ -60,14 +60,14 @@ describe('SourceStatusSection', () => { expect(screen.getByText('Receiving')).toBeInTheDocument(); // The polling hint is only useful while we are still waiting for something to show up. - expect(screen.queryByText('Checking every few seconds')).not.toBeInTheDocument(); + expect(screen.queryByText(/checking every few seconds/)).not.toBeInTheDocument(); }); it('pauses every source while the collector is offline', () => { render(); expect(screen.getByText('Paused — collector offline')).toBeInTheDocument(); - expect(screen.queryByText('Checking every few seconds')).not.toBeInTheDocument(); + expect(screen.queryByText(/checking every few seconds/)).not.toBeInTheDocument(); }); it('marks sources that cannot run on the collector platform', () => { @@ -133,4 +133,92 @@ describe('SourceStatusSection', () => { expect(screen.getByText('Not applicable on Linux')).toBeInTheDocument(); expect(screen.queryByText('Paused — collector offline')).not.toBeInTheDocument(); }); + + it('shows each source its own message count', () => { + render( + , + ); + + expect(screen.getByText('1,204')).toBeInTheDocument(); + expect(screen.getByText('38')).toBeInTheDocument(); + expect(screen.getAllByText('Receiving')).toHaveLength(2); + }); + + it('does not claim a source is receiving when only its siblings are', () => { + render( + , + ); + + expect(screen.getByText('Receiving')).toBeInTheDocument(); + // s2 has no bucket, so it produced nothing even though the collector is delivering. + expect(screen.getByText('No messages yet')).toBeInTheDocument(); + expect(screen.queryByText('Waiting for first messages...')).not.toBeInTheDocument(); + }); + + it('waits for first messages when nothing has arrived for the collector at all', () => { + render( + , + ); + + expect(screen.getByText('Waiting for first messages...')).toBeInTheDocument(); + expect(screen.queryByText('No messages yet')).not.toBeInTheDocument(); + }); + + it('falls back to the aggregate status when counts are unavailable', () => { + render( + , + ); + + // No count is known, so the row must not claim zero. + expect(screen.getByText('Receiving')).toBeInTheDocument(); + expect(screen.queryByText('No messages yet')).not.toBeInTheDocument(); + expect(screen.queryByText('0')).not.toBeInTheDocument(); + }); + + it('shows no count for sources that cannot be collecting', () => { + render( + , + ); + + // Structural non-collection must not read as a throughput number. + expect(screen.queryByText('5')).not.toBeInTheDocument(); + expect(screen.queryByText('7')).not.toBeInTheDocument(); + expect(screen.getAllByText('—')).toHaveLength(2); + }); + + it('explains the window the counts cover', () => { + render( + , + ); + + expect(screen.getByText(/Messages received in the last 15 minutes/)).toBeInTheDocument(); + }); }); diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx index d0f4b6f207ca..fb745fcd56f0 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx @@ -20,6 +20,7 @@ import styled, { css } from 'styled-components'; import { Icon, Link, Spinner } from 'components/common'; import Routes from 'routing/Routes'; import type { CollectorInstanceView, Source, SourceType } from 'components/collectors/types'; +import { formatNumber } from 'util/NumberFormatting'; import QuietSection from './QuietSection'; @@ -31,6 +32,8 @@ type Props = { sources: Source[] | undefined; /** Whether the collector delivered any source messages in the preview window. */ receiving: boolean; + /** Message count per source id for the preview window. `undefined` when the aggregation failed. */ + sourceCounts?: Record; }; const SourceName = styled.span` @@ -39,7 +42,7 @@ const SourceName = styled.span` const RowStatus = styled.span<{ $variant?: 'muted' | 'success' | 'warning' }>( ({ $variant = 'muted', theme }) => css` - margin-left: auto; + min-width: 12rem; display: inline-flex; align-items: center; gap: ${theme.spacings.xxs}; @@ -52,6 +55,17 @@ const RowStatus = styled.span<{ $variant?: 'muted' | 'success' | 'warning' }>( `, ); +const SourceCount = styled.span( + ({ theme }) => css` + margin-left: auto; + min-width: 6ch; + text-align: right; + font-size: ${theme.fonts.size.small}; + font-variant-numeric: tabular-nums; + color: ${theme.colors.gray[60]}; + `, +); + const Footer = styled.div( ({ theme }) => css` margin-top: ${theme.spacings.sm}; @@ -66,7 +80,19 @@ const SOURCE_PLATFORM: Partial> = { windows_event_log: 'windows', }; -const SourceStatus = ({ source, instance, receiving }: { source: Source } & Omit) => { +const NO_COUNT = '—'; + +/** The count for a source, or an em dash when a number would be meaningless or unknown. */ +const SourceCountCell = ({ count }: { count: number | undefined }) => ( + {count === undefined ? NO_COUNT : formatNumber(count)} +); + +const SourceStatus = ({ + source, + instance, + receiving, + count, +}: { source: Source; count: number | undefined } & Omit) => { const requiredPlatform = SOURCE_PLATFORM[source.type]; if (!source.enabled) { @@ -81,9 +107,7 @@ const SourceStatus = ({ source, instance, receiving }: { source: Source } & Omit return Paused — collector offline; } - // The preview search counts messages per collector, not per source, so once anything arrives - // every active source reads as receiving. Honest per-source rates need backend support. - if (receiving) { + if (count === undefined ? receiving : count > 0) { return ( Receiving @@ -91,6 +115,12 @@ const SourceStatus = ({ source, instance, receiving }: { source: Source } & Omit ); } + // Once the collector is proven to be delivering, a silent source is a steady state rather than a + // pending one, so it gets no spinner. + if (receiving) { + return No messages yet; + } + return ( @@ -102,7 +132,7 @@ const SourceStatus = ({ source, instance, receiving }: { source: Source } & Omit * The fleet's sources with a per-source status: what should be collecting on this host, what * cannot apply to its platform, and whether messages are flowing yet. */ -const SourceStatusSection = ({ instance, sources, receiving }: Props) => { +const SourceStatusSection = ({ instance, sources, receiving, sourceCounts = undefined }: Props) => { const online = instance.status === 'online'; return ( @@ -114,15 +144,29 @@ const SourceStatusSection = ({ instance, sources, receiving }: Props) => {
    No sources configured for this fleet yet.
    ) : ( - {sources.map((source) => ( - - {source.name} - - - ))} + {sources.map((source) => { + // A present map with no entry for this source means it produced nothing; an absent map + // means the aggregation is unavailable and no number should be claimed. + const count = sourceCounts ? (sourceCounts[source.id] ?? 0) : undefined; + // Mirrors status rules 1–2 only. A source restricted to a *matching* platform is still + // collecting, so testing `SOURCE_PLATFORM[source.type]` alone would be too strict. + const requiredPlatform = SOURCE_PLATFORM[source.type]; + const mismatched = Boolean(instance.os && requiredPlatform && requiredPlatform !== instance.os); + const collecting = source.enabled && !mismatched; + + return ( + + {source.name} + + + + ); + })} )} - {online && !receiving &&
    Checking every few seconds
    } + {online && ( +
    Messages received in the last 15 minutes{!receiving && ' · checking every few seconds'}
    + )} ); }; From 9307f84bc83cd1e15025d8f2b0d731e75e6da811 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 14:30:05 +0200 Subject: [PATCH 07/16] test: pin the expected count of polling-hint elements --- .../collectors/overview/onboarding/ConnectionSuccess.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx index adce42323247..9053534ebbaa 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx @@ -111,7 +111,7 @@ describe('ConnectionSuccess', () => { expect(screen.getAllByText('Waiting for first messages...').length).toBeGreaterThan(0); // The source status footer and the empty log preview both surface this hint while nothing has // arrived yet, so more than one match is expected here. - expect(screen.getAllByText(/checking every few seconds/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/checking every few seconds/)).toHaveLength(2); }); it('marks sources that cannot apply to the collector platform', () => { From a12fdcffb920bc8bf4716823c677d8b115d9f3b2 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 14:34:23 +0200 Subject: [PATCH 08/16] feat: show per-source message counts on the onboarding page --- .../overview/onboarding/ConnectionSuccess.test.tsx | 12 +++++++++++- .../overview/onboarding/ConnectionSuccess.tsx | 9 +++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx index 9053534ebbaa..5304aae97e02 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx @@ -61,7 +61,7 @@ const logPreview = { messages: [{ id: 'm2', timestamp: '2026-06-10T12:00:10.000Z', text: 'collector started' }], total: 7, }, - sourceCounts: undefined, + sourceCounts: { s1: 1204, s2: 38 }, selfLogsError: null, sourceLogsError: null, isLoading: false, @@ -103,6 +103,7 @@ describe('ConnectionSuccess', () => { asMock(useCollectorLogPreview).mockReturnValue({ ...logPreview, sourceLogs: { messages: [], total: 0 }, + sourceCounts: undefined, }); render(); @@ -236,4 +237,13 @@ describe('ConnectionSuccess', () => { expect(screen.getByText(/Showing the collector's own logs/)).toBeInTheDocument(); }); + + it('shows per-source message counts from the aggregation', () => { + render(); + + expect(screen.getByText('1,204')).toBeInTheDocument(); + expect(screen.getByText('38')).toBeInTheDocument(); + // s3 is the windows_event_log source, which cannot collect on this Linux host. + expect(screen.getByText('Not applicable on Linux')).toBeInTheDocument(); + }); }); diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx index 1d4de52bd8a9..217a49b88d69 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx @@ -60,7 +60,7 @@ const ColContainer = styled.div` `; const ConnectionSuccess = ({ instance, fleetName }: Props) => { - const { selfLogs, sourceLogs, selfLogsError, sourceLogsError, isLoading } = useCollectorLogPreview( + const { selfLogs, sourceLogs, sourceCounts, selfLogsError, sourceLogsError, isLoading } = useCollectorLogPreview( instance.instance_uid, ); const { data: sources } = useSources(instance.fleet_id); @@ -125,7 +125,12 @@ const ConnectionSuccess = ({ instance, fleetName }: Props) => { - + From f68d3de87294217e876fb4189eda014d6b375cf1 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 14:39:23 +0200 Subject: [PATCH 09/16] test: model a working-but-empty aggregation instead of an absent one --- .../collectors/overview/onboarding/ConnectionSuccess.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx index 5304aae97e02..2ff0e95ad012 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx @@ -103,7 +103,7 @@ describe('ConnectionSuccess', () => { asMock(useCollectorLogPreview).mockReturnValue({ ...logPreview, sourceLogs: { messages: [], total: 0 }, - sourceCounts: undefined, + sourceCounts: {}, }); render(); From ca8235b153fc3a475273275e240dcc03d63c3215 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 14:57:31 +0200 Subject: [PATCH 10/16] Fix collector onboarding review findings: error attribution, layout, tests, lint - Make search error attribution search-type aware in useCollectorLogPreview so an aggregation-only failure no longer poisons the healthy messages pane that shares its query. - Let SourceName shrink (flex: 1, min-width: 0, overflow-wrap) so long source names no longer overflow the log-sources row, mirroring DetailRow's fix for the same problem. - Add coverage for the aggregation-scoped-error case and for an empty (but present) aggregation result, pinning sourceCounts === {} as distinct from undefined. - Replace `as unknown as CollectorInstanceView` casts in two test fixtures with a plain assertion or a fully populated fixture, per CONTRIBUTING.md. - Rename a SourceStatusSection test to describe its actual behaviour (empty copy for undefined sources) instead of a loading state it doesn't implement, and note the gap in a comment. - Fix eslint findings in ColumnRenderers.tsx (missing blank line before return; make the always-passed `instance` prop required instead of defaulting it) and reformat LogPreviewSection.tsx's over-width line. --- .../collectors/instances/ColumnRenderers.tsx | 3 +- .../onboarding/ConnectionSuccess.test.tsx | 10 +- .../overview/onboarding/LogPreviewSection.tsx | 10 +- .../onboarding/SourceStatusSection.test.tsx | 7 +- .../onboarding/SourceStatusSection.tsx | 7 ++ .../onboarding/useCollectorLogPreview.test.ts | 91 +++++++++++++++++++ .../onboarding/useCollectorLogPreview.ts | 12 ++- 7 files changed, 131 insertions(+), 9 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.tsx b/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.tsx index d742ed21f45a..9f828c76e67f 100644 --- a/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.tsx @@ -25,8 +25,9 @@ import SyncStateIndicator from '../common/SyncStateIndicator'; import collectorOsName from '../common/collectorOsName'; import type { CollectorInstanceView } from '../types'; -const OsName = ({instance}: { instance?: CollectorInstanceView }) => { +const OsName = ({ instance }: { instance: CollectorInstanceView }) => { const label = collectorOsName(instance); + return {label}; }; diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx index 2ff0e95ad012..c9e171920709 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx @@ -32,19 +32,25 @@ jest.mock('components/collectors/hooks/useSourceQueries', () => ({ useSources: jest.fn(), })); -const instance = { +const instance: CollectorInstanceView = { id: 'uid-42', instance_uid: 'uid-42', fleet_id: 'fleet-1', + capabilities: 15, enrolled_at: '2026-06-10T12:00:00Z', last_seen: '2026-06-10T12:01:00Z', + active_certificate_fingerprint: 'aa:bb:cc', + active_certificate_expires_at: '2027-06-10T12:00:00Z', + next_certificate_fingerprint: null, + next_certificate_expires_at: null, status: 'online', identifying_attributes: { 'service.instance.id': 'uid-42' }, non_identifying_attributes: { 'host.arch': 'arm64' }, hostname: 'web-prod-01', os: 'linux', version: '1.2.3', -} as unknown as CollectorInstanceView; + has_pending_changes: false, +}; const sources = [ { id: 's1', name: 'Syslog', type: 'file', enabled: true }, diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/LogPreviewSection.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/LogPreviewSection.tsx index ebebde697785..569f193be120 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/LogPreviewSection.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/LogPreviewSection.tsx @@ -110,7 +110,15 @@ const PreviewBody = ({ preview, isLoading, error }: Pick ( +const LogPreviewSection = ({ + title, + searchUrl, + preview, + isLoading, + error, + collapsible = false, + caption = undefined, +}: Props) => (
    { expect(screen.getByText('No sources configured for this fleet yet.')).toBeInTheDocument(); }); - it('tells the user when the sources are still loading', () => { + it('shows the empty-fleet copy when sources is undefined (loading is not yet distinguished from empty)', () => { + // Known gap: the component currently treats `sources === undefined` (still loading) the same + // as an empty fleet. A real loading state (e.g. a Spinner) is a separate scoping decision for + // a human to make, not something to add here. render(); expect(screen.getByText('No sources configured for this fleet yet.')).toBeInTheDocument(); diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx index fb745fcd56f0..d3ae7533cfe9 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx @@ -38,6 +38,13 @@ type Props = { const SourceName = styled.span` font-weight: 600; + + /* A long source name is otherwise the only row member with no size floor, so nothing shrinks + and the row overflows the section; a flex child also defaults to min-width auto and would + refuse to shrink below its content's width without this. */ + flex: 1; + min-width: 0; + overflow-wrap: anywhere; `; const RowStatus = styled.span<{ $variant?: 'muted' | 'success' | 'warning' }>( diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts index 82a8fa27acbc..c3dfe7a1f586 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts @@ -250,6 +250,53 @@ describe('useCollectorLogPreview', () => { expect(result.current.sourceCounts).toBeUndefined(); }); + it('reports an empty aggregation as an empty object, distinct from an unavailable one', async () => { + const getCreatedSearch = captureCreatedSearch(); + + asMock(executeJobResult).mockImplementation(async () => { + const { sourceQuery, selfQuery } = splitQueries(getCreatedSearch()); + + return asExecutionResult({ + result: { + errors: [], + forId: (queryId: string) => { + if (queryId === sourceQuery.id) { + return { + searchTypes: { + [searchTypeIdByType(sourceQuery, 'messages')]: { + type: 'messages', + messages: [], + total: 0, + }, + [searchTypeIdByType(sourceQuery, 'aggregation')]: { + type: 'pivot', + total: 0, + rows: [], + }, + }, + }; + } + + if (queryId === selfQuery.id) { + return { searchTypes: {} }; + } + + return undefined; + }, + }, + }); + }); + + const { result } = renderHook(() => useCollectorLogPreview('uid-42')); + + await waitFor(() => expect(result.current.sourceLogs).toBeDefined()); + + // The aggregation ran and returned no rows: every source is confirmed silent, which must stay + // distinguishable from "the aggregation didn't run at all" (`undefined`). + expect(result.current.sourceCounts).toEqual({}); + expect(result.current.sourceCounts).not.toBeUndefined(); + }); + it('executes the created search with the execution helpers', async () => { mockEmptyResults(); @@ -373,4 +420,48 @@ describe('useCollectorLogPreview', () => { expect(result.current.sourceLogs.messages).toHaveLength(1); expect(result.current.sourceLogsError).toBeNull(); }); + + it('an aggregation-scoped error does not poison the healthy messages pane on the same query', async () => { + const getCreatedSearch = captureCreatedSearch(); + + asMock(executeJobResult).mockImplementation(async () => { + const { sourceQuery, selfQuery } = splitQueries(getCreatedSearch()); + const aggregationSearchTypeId = searchTypeIdByType(sourceQuery, 'aggregation'); + + return asExecutionResult({ + result: { + errors: [{ queryId: sourceQuery.id, searchTypeId: aggregationSearchTypeId, description: 'agg failed' }], + forId: (queryId: string) => { + if (queryId === sourceQuery.id) { + return { + searchTypes: { + [searchTypeIdByType(sourceQuery, 'messages')]: { + type: 'messages', + messages: [resultMessage('m1', '2026-06-10T12:00:00.000Z', 'a source log line')], + total: 42, + }, + }, + }; + } + + if (queryId === selfQuery.id) { + return { searchTypes: {} }; + } + + return undefined; + }, + }, + }); + }); + + const { result } = renderHook(() => useCollectorLogPreview('uid-42')); + + await waitFor(() => expect(result.current.sourceLogs).toBeDefined()); + + // The failure belongs to the aggregation search type on the source query, not to the messages + // search type that shares that query, so the messages pane must stay healthy. + expect(result.current.sourceLogsError).toBeNull(); + expect(result.current.sourceLogs.messages).toHaveLength(1); + expect(result.current.sourceLogs.messages[0].text).toBe('a source log line'); + }); }); diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts index 652f94748212..091a05b59dcc 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts @@ -210,7 +210,13 @@ const useCollectorLogPreview = (instanceUid: string) => { throw new Error(errors[0].description ?? 'Search failed'); } - const errorForQuery = (queryId: string) => errors.find((e) => e.queryId === queryId)?.description; + // A query-level error (no searchTypeId) applies to every pane of that query; a search-type-scoped + // error applies only to the pane backed by that search type. Without this, a failure in the + // aggregation search type would otherwise be misattributed to the healthy messages pane, since + // both search types now live on the same (source) query. + const errorForSearchType = (queryId: string, searchTypeId: string) => + errors.find((e) => e.queryId === queryId && (!e.searchTypeId || e.searchTypeId === searchTypeId)) + ?.description; return { selfLogs: toPreview( @@ -222,8 +228,8 @@ const useCollectorLogPreview = (instanceUid: string) => { sourceCounts: toSourceCounts( result.forId(ids.sourceQueryId)?.searchTypes?.[ids.sourceCountsSearchTypeId] as RawPivotResult | undefined, ), - selfLogsError: errorForQuery(ids.selfQueryId), - sourceLogsError: errorForQuery(ids.sourceQueryId), + selfLogsError: errorForSearchType(ids.selfQueryId, ids.selfSearchTypeId), + sourceLogsError: errorForSearchType(ids.sourceQueryId, ids.sourceSearchTypeId), }; }, }); From f5a9da599998f48429244a36053d883ac56754ca Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 14:59:43 +0200 Subject: [PATCH 11/16] Apply oxfmt formatting to useCollectorLogPreview.ts --- .../collectors/overview/onboarding/useCollectorLogPreview.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts index 091a05b59dcc..99fd3145c4e7 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts @@ -215,8 +215,7 @@ const useCollectorLogPreview = (instanceUid: string) => { // aggregation search type would otherwise be misattributed to the healthy messages pane, since // both search types now live on the same (source) query. const errorForSearchType = (queryId: string, searchTypeId: string) => - errors.find((e) => e.queryId === queryId && (!e.searchTypeId || e.searchTypeId === searchTypeId)) - ?.description; + errors.find((e) => e.queryId === queryId && (!e.searchTypeId || e.searchTypeId === searchTypeId))?.description; return { selfLogs: toPreview( From 4fc5015c9e3567c6638955727a791ae924bb7a63 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 15:11:09 +0200 Subject: [PATCH 12/16] fix: send the pivot wire type for the per-source count aggregation The search type was sent as `aggregation`, which is the frontend's PluggableSearchType key rather than the wire discriminator. Server-side the name is `Pivot.NAME` = `pivot`; an unrecognised type deserialises to SearchType.Fallback, whose `filters` is a plain nullable field, and SearchFilterNormalizer then NPEs on it and fails the whole search. --- .../onboarding/useCollectorLogPreview.test.ts | 15 +++++++++------ .../overview/onboarding/useCollectorLogPreview.ts | 6 +++++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts index c3dfe7a1f586..0eedf54ccb20 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.test.ts @@ -99,7 +99,7 @@ describe('useCollectorLogPreview', () => { messages: [resultMessage('m1', '2026-06-10T12:00:00.000Z', 'a source log line')], total: 42, }, - [searchTypeIdByType(sourceQuery, 'aggregation')]: { + [searchTypeIdByType(sourceQuery, 'pivot')]: { type: 'pivot', total: 42, rows: [ @@ -231,10 +231,13 @@ describe('useCollectorLogPreview', () => { const { sourceQuery } = splitQueries(search); // Query.searchTypes is a plain Array; the union's `type` field isn't a narrowing // discriminant (it's typed as `string`), so the aggregation-only fields need this assertion. - const aggregation = sourceQuery.searchTypes.find((st) => st.type === 'aggregation') as - | AggregationSearchType - | undefined; + const aggregation = sourceQuery.searchTypes.find((st) => st.type === 'pivot') as AggregationSearchType | undefined; + // The wire discriminator must be `pivot` (`Pivot.NAME` server-side), not the frontend's + // `PluggableSearchType` key `aggregation`. Sending `aggregation` deserialises to + // `SearchType.Fallback`, whose null `filters` makes the search filter normalizer throw and fails + // the entire search, so this assertion is load-bearing rather than cosmetic. + expect(aggregation.type).toBe('pivot'); expect(aggregation.row_groups).toEqual([{ type: 'values', fields: ['collector_source_id'], limit: 100 }]); // A `count` series must carry no field: count() counts occurrences of that field. expect(aggregation.series).toEqual([{ id: 'count()', type: 'count' }]); @@ -268,7 +271,7 @@ describe('useCollectorLogPreview', () => { messages: [], total: 0, }, - [searchTypeIdByType(sourceQuery, 'aggregation')]: { + [searchTypeIdByType(sourceQuery, 'pivot')]: { type: 'pivot', total: 0, rows: [], @@ -426,7 +429,7 @@ describe('useCollectorLogPreview', () => { asMock(executeJobResult).mockImplementation(async () => { const { sourceQuery, selfQuery } = splitQueries(getCreatedSearch()); - const aggregationSearchTypeId = searchTypeIdByType(sourceQuery, 'aggregation'); + const aggregationSearchTypeId = searchTypeIdByType(sourceQuery, 'pivot'); return asExecutionResult({ result: { diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts index 99fd3145c4e7..07849441b0e2 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/useCollectorLogPreview.ts @@ -93,7 +93,11 @@ const messagesSearchType = (id: string): MessagesSearchType => ({ const sourceCountsSearchType = (id: string): AggregationSearchType => ({ id, - type: 'aggregation', + // `pivot`, not `aggregation`: the wire discriminator is `Pivot.NAME` server-side. `aggregation` is + // only the frontend's `PluggableSearchType` key — it coincides with the wire name for `messages` + // but not here. An unrecognised type deserialises to `SearchType.Fallback`, whose `filters` is a + // plain nullable field, and the search filter normalizer then NPEs on it and fails the whole search. + type: 'pivot', row_groups: [{ type: 'values', fields: [COLLECTOR_SOURCE_ID_FIELD], limit: SOURCE_BUCKET_LIMIT }], column_groups: [], series: COUNT_SERIES, From 39e2ebd5978cb316ad5e2cbdbcb5ef4eb7f5e7e8 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 15:21:56 +0200 Subject: [PATCH 13/16] don't render 0 messages next to "no messages received" --- .../collectors/overview/onboarding/SourceStatusSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx index d3ae7533cfe9..061ae2851334 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx @@ -91,7 +91,7 @@ const NO_COUNT = '—'; /** The count for a source, or an em dash when a number would be meaningless or unknown. */ const SourceCountCell = ({ count }: { count: number | undefined }) => ( - {count === undefined ? NO_COUNT : formatNumber(count)} + {count === undefined ? NO_COUNT : (count === 0 ? '' : `${formatNumber(count)} messages`)} ); const SourceStatus = ({ From 2d1360b036665265566614bff82cb6a07e1560fb Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 15:33:23 +0200 Subject: [PATCH 14/16] feat: decline to guess at unrecognised source types Source types can exist in the database before the frontend knows them - a definition still on an unmerged branch is stored and returned by the API while SourceType and SOURCE_TYPE_LABELS have no entry for it. Such a source used to read as "Waiting for first messages...", which pretends we are waiting on something we cannot reason about. Membership in SOURCE_TYPE_LABELS is the registry of known types because it is exhaustive over SourceType; SOURCE_PLATFORM is not, since types that run everywhere are legitimately absent from it. Deliberately not reusing "Not applicable": that asserts a definite incompatibility, whereas an unrecognised type may be collecting fine server-side. --- .../onboarding/ConnectionSuccess.test.tsx | 4 +- .../onboarding/SourceStatusSection.test.tsx | 88 ++++++++++++++++++- .../onboarding/SourceStatusSection.tsx | 47 ++++++++-- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx index c9e171920709..d3bcd8f8c10d 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.test.tsx @@ -247,8 +247,8 @@ describe('ConnectionSuccess', () => { it('shows per-source message counts from the aggregation', () => { render(); - expect(screen.getByText('1,204')).toBeInTheDocument(); - expect(screen.getByText('38')).toBeInTheDocument(); + expect(screen.getByText('1,204 messages')).toBeInTheDocument(); + expect(screen.getByText('38 messages')).toBeInTheDocument(); // s3 is the windows_event_log source, which cannot collect on this Linux host. expect(screen.getByText('Not applicable on Linux')).toBeInTheDocument(); }); diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx index 9361581aa37f..a03565f3a6e0 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx @@ -17,10 +17,15 @@ import * as React from 'react'; import { render, screen } from 'wrappedTestingLibrary'; -import type { CollectorInstanceView, Source } from 'components/collectors/types'; +import type { CollectorInstanceView, Source, SourceType } from 'components/collectors/types'; import SourceStatusSection from './SourceStatusSection'; +// A type the API returns but the frontend does not know yet, because its definition is still on an +// unmerged branch. Widened through `string` on purpose: the `SourceType` union claims this value +// cannot exist, which is precisely the situation being tested. +const UNRECOGNISED_TYPE = 'macos_unified_logging' as string as SourceType; + const instance = { id: 'uid-42', instance_uid: 'uid-42', @@ -124,6 +129,83 @@ describe('SourceStatusSection', () => { expect(screen.queryByText('Not applicable on Linux')).not.toBeInTheDocument(); }); + it('declines to guess at a source type it does not recognise', () => { + render( + , + ); + + expect(screen.getByText('Unrecognised source type')).toBeInTheDocument(); + // It must not pretend to be waiting on messages for a source it cannot reason about. + expect(screen.queryByText('Waiting for first messages...')).not.toBeInTheDocument(); + expect(screen.queryByText('No messages yet')).not.toBeInTheDocument(); + // Nor claim a definite incompatibility, which is what the platform copy asserts. + expect(screen.queryByText(/Not applicable/)).not.toBeInTheDocument(); + }); + + it('claims no message count for an unrecognised source type', () => { + render( + , + ); + + expect(screen.getByText('—')).toBeInTheDocument(); + expect(screen.queryByText('1,204 messages')).not.toBeInTheDocument(); + expect(screen.queryByText('Receiving')).not.toBeInTheDocument(); + }); + + it('prefers the disabled status over the unrecognised one', () => { + render( + , + ); + + expect(screen.getByText('Disabled')).toBeInTheDocument(); + expect(screen.queryByText('Unrecognised source type')).not.toBeInTheDocument(); + }); + + it('prefers the unrecognised status over the offline one', () => { + render( + , + ); + + expect(screen.getByText('Unrecognised source type')).toBeInTheDocument(); + expect(screen.queryByText('Paused — collector offline')).not.toBeInTheDocument(); + }); + + it('still recognises every type the frontend knows about', () => { + render( + , + ); + + // Guards against the membership check accidentally rejecting known types. + expect(screen.queryByText('Unrecognised source type')).not.toBeInTheDocument(); + expect(screen.getAllByText('Receiving')).toHaveLength(2); + }); + it('prefers the platform status over the offline one', () => { render( { />, ); - expect(screen.getByText('1,204')).toBeInTheDocument(); - expect(screen.getByText('38')).toBeInTheDocument(); + expect(screen.getByText('1,204 messages')).toBeInTheDocument(); + expect(screen.getByText('38 messages')).toBeInTheDocument(); expect(screen.getAllByText('Receiving')).toHaveLength(2); }); diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx index 061ae2851334..8cb7f5b05e70 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx @@ -26,6 +26,7 @@ import QuietSection from './QuietSection'; import { DividedIconRow, IconRowList } from '../../common/IconRowList'; import { OS_LABELS } from '../../common/Constants'; +import { SOURCE_TYPE_LABELS } from '../../sources/Constants'; type Props = { instance: CollectorInstanceView; @@ -87,12 +88,38 @@ const SOURCE_PLATFORM: Partial> = { windows_event_log: 'windows', }; +/** + * Whether the frontend knows this source type at all. + * + * Types can exist in the database before the frontend learns about them — a source type whose + * definition is still on an unmerged branch is stored and returned by the API while `SourceType` and + * `SOURCE_TYPE_LABELS` have no entry for it. `SOURCE_TYPE_LABELS` is exhaustive over `SourceType`, so + * membership in it is the registry of known types; `SOURCE_PLATFORM` is not, because types that run + * everywhere are legitimately absent from it. + * + * This is a runtime check against a case the `SourceType` union claims cannot happen, which is why it + * tests the object rather than comparing to a list of literals. + */ +const isRecognised = (type: SourceType) => Object.hasOwn(SOURCE_TYPE_LABELS, type); + const NO_COUNT = '—'; +const countLabel = (count: number | undefined) => { + if (count === undefined) { + return NO_COUNT; + } + + // A zero would sit right next to "No messages yet" or "Waiting for first messages...", which + // already say it; the digit only adds noise. + if (count === 0) { + return ''; + } + + return `${formatNumber(count)} messages`; +}; + /** The count for a source, or an em dash when a number would be meaningless or unknown. */ -const SourceCountCell = ({ count }: { count: number | undefined }) => ( - {count === undefined ? NO_COUNT : (count === 0 ? '' : `${formatNumber(count)} messages`)} -); +const SourceCountCell = ({ count }: { count: number | undefined }) => {countLabel(count)}; const SourceStatus = ({ source, @@ -110,6 +137,12 @@ const SourceStatus = ({ return Not applicable on {OS_LABELS[instance.os] ?? instance.os}; } + // Deliberately not "Not applicable": that asserts a definite incompatibility, whereas an + // unrecognised type may well be collecting perfectly well server-side. We only decline to guess. + if (!isRecognised(source.type)) { + return Unrecognised source type; + } + if (instance.status !== 'online') { return Paused — collector offline; } @@ -155,11 +188,13 @@ const SourceStatusSection = ({ instance, sources, receiving, sourceCounts = unde // A present map with no entry for this source means it produced nothing; an absent map // means the aggregation is unavailable and no number should be claimed. const count = sourceCounts ? (sourceCounts[source.id] ?? 0) : undefined; - // Mirrors status rules 1–2 only. A source restricted to a *matching* platform is still - // collecting, so testing `SOURCE_PLATFORM[source.type]` alone would be too strict. + // Mirrors the structural status rules only. A source restricted to a *matching* platform + // is still collecting, so testing `SOURCE_PLATFORM[source.type]` alone would be too + // strict. An unrecognised type claims no number either: we cannot say whether a zero + // means "silent" or "we do not understand this source". const requiredPlatform = SOURCE_PLATFORM[source.type]; const mismatched = Boolean(instance.os && requiredPlatform && requiredPlatform !== instance.os); - const collecting = source.enabled && !mismatched; + const collecting = source.enabled && !mismatched && isRecognised(source.type); return ( From f84df13dbbd8ac7a0151bf861c894c62661a4ac4 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 15:51:40 +0200 Subject: [PATCH 15/16] fix: poll the collector instance so status changes appear on their own useInstance fetched once and then only refetched when something invalidated its key, so a page rendered while the collector was healthy kept asserting "online" indefinitely - a collector that stops reporting in is marked offline server-side with no user interaction to trigger a refetch. Polls at the heartbeat cadence via useCollectorRefetchInterval, since the data cannot become fresher than the heartbeat. Requests opt out of session extension per the periodic-request rule, and errors no longer go through defaultOnError because a persistent failure would raise a toast on every poll; the page already renders the failure from the returned error. --- .../hooks/useInstanceQueries.test.ts | 33 +++++++++++++++++-- .../collectors/hooks/useInstanceQueries.ts | 27 +++++++++++---- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.test.ts b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.test.ts index 153c4c9e38c1..d135b8b66e8f 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.test.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.test.ts @@ -14,7 +14,7 @@ * along with this program. If not, see * . */ -import { renderHook, waitFor } from 'wrappedTestingLibrary/hooks'; +import { act, renderHook, waitFor } from 'wrappedTestingLibrary/hooks'; import { OrderedMap } from 'immutable'; import { Collectors } from '@graylog/server-api'; @@ -61,7 +61,8 @@ describe('useInstance', () => { await waitFor(() => expect(result.current.data).toBeTruthy()); - expect(Collectors.getInstance).toHaveBeenCalledWith('uid-42'); + // The query polls, so it must not keep an idle user's session alive. + expect(Collectors.getInstance).toHaveBeenCalledWith('uid-42', { requestShouldExtendSession: false }); expect(result.current.data).toEqual( expect.objectContaining({ @@ -91,6 +92,34 @@ describe('useInstance', () => { expect(Collectors.getInstance).not.toHaveBeenCalled(); }); + + // A collector that stops reporting in is marked offline server-side, with no user interaction to + // trigger a refetch. Without polling, a page rendered while the collector was healthy keeps + // asserting "online" forever. + it('picks up a status change without any user interaction', async () => { + jest.useFakeTimers(); + + asMock(Collectors.getInstance).mockResolvedValue(asInstanceResponse('uid-42')); + + const { result } = renderHook(() => useInstance('uid-42')); + + await waitFor(() => expect(result.current.data?.status).toBe('online')); + + asMock(Collectors.getInstance).mockResolvedValue({ + ...dto('uid-42'), + status: 'offline', + } as unknown as Awaited>); + + // Past the fallback heartbeat cadence used until the collectors config has loaded. Wrapped in + // `act` because the refetch it triggers re-renders the hook outside React's own scheduling. + await act(async () => { + await jest.advanceTimersByTimeAsync(31_000); + }); + + await waitFor(() => expect(result.current.data?.status).toBe('offline')); + + jest.useRealTimers(); + }); }); describe('fetchPaginatedInstances session extension', () => { diff --git a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts index d4b65af66a8f..13b3c582f46c 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts @@ -24,6 +24,8 @@ import { defaultOnError } from 'util/conditional/onError'; import type { PaginatedResponse } from 'components/common/PaginatedEntityTable/useFetchEntities'; import type { RequestOptions } from 'routing/request'; +import useCollectorRefetchInterval from './useCollectorRefetchInterval'; + import type { CollectorInstanceView } from '../types'; const NO_SESSION_EXT: RequestOptions = { requestShouldExtendSession: false }; @@ -119,16 +121,29 @@ export const useInstances = (fleetId?: string, options: { refetchInterval?: numb refetchInterval: options.refetchInterval, }); +/** + * A single instance, polled at the collector heartbeat cadence. + * + * The status and `last_seen` of a live collector change without any user interaction — a collector + * that stops reporting in is marked offline server-side — so a one-shot fetch would leave a page + * asserting "online" indefinitely. Polling is tied to the heartbeat interval because the data cannot + * become fresher than that: a shorter interval only adds requests. + * + * Two consequences of polling, both deliberate: + * - Requests do not extend the session, per the periodic-request rule. Arriving on a page still + * extends it through the sibling queries (fleet, config), which do not opt out — the same reasoning + * documented for `isBackgroundRefresh` above. + * - Errors are not reported through `defaultOnError`. A persistent failure would otherwise raise a + * toast on every poll; callers get `error`/`isError` and render the failure themselves. + */ export const useInstance = (instanceUid: string | undefined) => { + const refetchInterval = useCollectorRefetchInterval(); + const { data, isLoading, error, isError } = useQuery({ queryKey: instanceKeyFn(instanceUid), - queryFn: () => - defaultOnError( - Collectors.getInstance(instanceUid).then((response) => toView(response)), - 'Loading Collector instance failed with status', - 'Could not load Collector instance', - ), + queryFn: () => Collectors.getInstance(instanceUid, NO_SESSION_EXT).then((response) => toView(response)), enabled: !!instanceUid, + refetchInterval, }); return { data, isLoading, error, isError }; From f338896aae688845ea01ae827e86f10ff6815835 Mon Sep 17 00:00:00 2001 From: Kay Roepke Date: Thu, 30 Jul 2026 15:59:28 +0200 Subject: [PATCH 16/16] add changelog --- changelog/unreleased/pr-26839.toml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog/unreleased/pr-26839.toml diff --git a/changelog/unreleased/pr-26839.toml b/changelog/unreleased/pr-26839.toml new file mode 100644 index 000000000000..982032aeeccf --- /dev/null +++ b/changelog/unreleased/pr-26839.toml @@ -0,0 +1,8 @@ +# PLEASE REMOVE COMMENTS AND OPTIONAL FIELDS! THANKS! + +# Entry type according to https://keepachangelog.com/en/1.0.0/ +# One of: a(dded), c(hanged), d(eprecated), r(emoved), f(ixed), s(ecurity) +type = "a" +message = "Added Collector onboarding page" + +pulls = ["26839"]