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"] 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/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 1c4fc842b35b..13b3c582f46c 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useInstanceQueries.ts @@ -24,11 +24,14 @@ 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 }; 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]; @@ -118,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: [...INSTANCES_KEY_PREFIX, 'single', instanceUid], - queryFn: () => - defaultOnError( - Collectors.getInstance(instanceUid).then((response) => toView(response)), - 'Loading Collector instance failed with status', - 'Could not load Collector instance', - ), + queryKey: instanceKeyFn(instanceUid), + queryFn: () => Collectors.getInstance(instanceUid, NO_SESSION_EXT).then((response) => toView(response)), enabled: !!instanceUid, + refetchInterval, }); return { data, isLoading, error, isError }; diff --git a/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.tsx b/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.tsx index 4f9162a016a0..9f828c76e67f 100644 --- a/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.tsx +++ b/graylog2-web-interface/src/components/collectors/instances/ColumnRenderers.tsx @@ -16,24 +16,19 @@ */ 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; +const OsName = ({ instance }: { instance: CollectorInstanceView }) => { + const label = collectorOsName(instance); - return ( - - Unknown - - ); + return {label}; }; type Props = { @@ -44,9 +39,7 @@ const customColumnRenderers = ({ fleetNames }: Props): ColumnRenderers ( - + ), staticWidth: 100, }, @@ -63,7 +56,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/CollectorFactsSection.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/CollectorFactsSection.tsx new file mode 100644 index 000000000000..8a17ac22d0e2 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/CollectorFactsSection.tsx @@ -0,0 +1,120 @@ +/* + * 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, 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 = { + 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 9b47363b5305..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 @@ -16,10 +16,13 @@ */ 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 type { CollectorInstanceView } from 'components/collectors/types'; +import { instanceKeyFn } from 'components/collectors/hooks/useInstanceQueries'; +import type { CollectorInstanceView, Source } from 'components/collectors/types'; import ConnectionSuccess from './ConnectionSuccess'; import useCollectorLogPreview from './useCollectorLogPreview'; @@ -29,90 +32,224 @@ 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: {}, - 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; + has_pending_changes: false, +}; + +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, + }, + sourceCounts: { s1: 1204, s2: 38 }, + 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', () => { - 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.getAllByRole('link', { name: 'Default Fleet' })).toHaveLength(2); }); 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(); + it('walks through the completed onboarding steps', () => { + render(); - expect(screen.getByText('23')).toBeInTheDocument(); + expect(useSources).toHaveBeenCalledWith('fleet-1'); + 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('shows the fleet source count', () => { - render(); + it('spins on the first-messages step until source messages arrive', () => { + asMock(useCollectorLogPreview).mockReturnValue({ + ...logPreview, + sourceLogs: { messages: [], total: 0 }, + sourceCounts: {}, + }); - expect(useSources).toHaveBeenCalledWith('fleet-1'); - expect(screen.getAllByText('2').length).toBeGreaterThanOrEqual(1); + render(); + + expect(screen.getByText('Listening... usually under a minute')).toBeInTheDocument(); + 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/)).toHaveLength(2); }); - it('renders the self-logs section collapsed', () => { - render(); + 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(); + + expect(screen.getByText('uid-42')).toBeInTheDocument(); + }); + + it('renders the what-is-next links', () => { + render(); + + 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(); + }); + + 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(screen.getByText('uid-42')).toBeInTheDocument(); + 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('omits the platform chip when platformId is not known', () => { + it('shows the empty state when the fleet has no sources', () => { + asMock(useSources).mockReturnValue({ data: [] } as ReturnType); + render(); - expect(screen.queryByText('Linux')).not.toBeInTheDocument(); - expect(screen.getByText('web-prod-01')).toBeInTheDocument(); + 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(); + }); + + it('shows per-source message counts from the aggregation', () => { + render(); + + 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/ConnectionSuccess.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx index a2564c7504e8..217a49b88d69 100644 --- a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx @@ -15,201 +15,153 @@ * . */ import * as React from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import styled, { css } from 'styled-components'; +import { Grid } from '@mantine/core'; -import { Alert, Label } from 'components/bootstrap'; -import { AccessibleCard } from 'components/common'; -import useHistory from 'routing/useHistory'; +import { Button } from 'components/bootstrap'; +import { Group, LinkContainer, RelativeTime, Stack } from 'components/common'; import Routes from 'routing/Routes'; import type { CollectorInstanceView } from 'components/collectors/types'; import { useSources } from 'components/collectors/hooks/useSourceQueries'; +import { instanceKeyFn } from 'components/collectors/hooks/useInstanceQueries'; -import type { PlatformId } from './platforms'; -import PLATFORMS from './platforms'; 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 StatCard from '../../common/StatCard'; +import InstanceStatusLabel from '../../common/InstanceStatusLabel'; import collectorReceivedMessagesUrl from '../../common/collectorReceivedMessagesUrl'; import collectorSystemLogsUrl from '../../common/collectorSystemLogsUrl'; import { COLLECTOR_INSTANCE_UID_FIELD } from '../../common/fields'; 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 Title = styled.h2` + margin: 0; +`; -const AssetType = styled.div( +const Subtitle = styled.div( ({ theme }) => css` - font-size: ${theme.fonts.size.small}; + margin-top: ${theme.spacings.xxs}; color: ${theme.colors.gray[60]}; - text-transform: uppercase; - margin-bottom: ${theme.spacings.xxs}; `, ); -const AssetName = styled.div` - font-weight: 500; +// 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 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, sourceCounts, selfLogsError, sourceLogsError, isLoading } = useCollectorLogPreview( instance.instance_uid, ); const { data: sources } = useSources(instance.fleet_id); + const queryClient = useQueryClient(); - return ( -
- - Collector connected — {instance.hostname ?? instance.instance_uid} - {instance.version && ( - <> - {' '} - running v{instance.version} - - )} - + const online = instance.status === 'online'; + const receiving = (sourceLogs?.total ?? 0) > 0; + const sourceLogsUrl = collectorReceivedMessagesUrl(COLLECTOR_INSTANCE_UID_FIELD, instance.instance_uid); - - {fleetName && } - {platform && } - - + 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 ( + + +
+ + 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 ? ( - + ) : ( -
- - 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/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.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,15 @@ 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..5088122a3830 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/NextSteps.tsx @@ -0,0 +1,126 @@ +/* + * 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 useProductName from 'brand-customization/useProductName'; +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; +}; + +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) => { + 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 {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 }) => ( + + +
    + {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/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..a03565f3a6e0 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.test.tsx @@ -0,0 +1,309 @@ +/* + * 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, 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', + fleet_id: 'fleet-1', + status: 'online', + os: 'linux', +} 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('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(); + }); + + 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('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('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 messages')).toBeInTheDocument(); + expect(screen.getByText('38 messages')).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 new file mode 100644 index 000000000000..8cb7f5b05e70 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/SourceStatusSection.tsx @@ -0,0 +1,216 @@ +/* + * 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, 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'; + +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; + /** Message count per source id for the preview window. `undefined` when the aggregation failed. */ + sourceCounts?: Record; +}; + +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' }>( + ({ $variant = 'muted', theme }) => css` + min-width: 12rem; + 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 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}; + 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', +}; + +/** + * 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 }) => {countLabel(count)}; + +const SourceStatus = ({ + source, + instance, + receiving, + count, +}: { source: Source; count: number | undefined } & 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}; + } + + // 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; + } + + if (count === undefined ? receiving : count > 0) { + return ( + + Receiving + + ); + } + + // 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 ( + + + + ); +}; + +/** + * 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, sourceCounts = undefined }: Props) => { + const online = instance.status === 'online'; + + return ( + Configure sources}> + {!sources?.length ? ( +
    No sources configured for this fleet yet.
    + ) : ( + + {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 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 && isRecognised(source.type); + + return ( + + {source.name} + + + + ); + })} + + )} + {online && ( +
    Messages received in the last 15 minutes{!receiving && ' · checking every few seconds'}
    + )} +
    + ); +}; + +export default SourceStatusSection; 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..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 @@ -22,6 +22,8 @@ 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 type { AggregationSearchType } from 'views/logic/queries/SearchType'; import useCollectorLogPreview from './useCollectorLogPreview'; @@ -56,6 +58,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,11 +94,27 @@ 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, }, + [searchTypeIdByType(sourceQuery, 'pivot')]: { + 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 }], + }, + ], + }, }, }; } @@ -102,7 +122,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, @@ -169,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, @@ -179,6 +201,103 @@ 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 === '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' }]); + }); + + 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('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, 'pivot')]: { + 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 () => { @@ -304,4 +423,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, 'pivot'); + + 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 d9af0f7c3d88..07849441b0e2 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,27 @@ const messagesSearchType = (id: string): MessagesSearchType => ({ stream_categories: [], }); +const sourceCountsSearchType = (id: string): AggregationSearchType => ({ + id, + // `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, + 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 +120,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 +142,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 +152,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 +165,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], @@ -162,7 +214,12 @@ 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( @@ -171,8 +228,11 @@ const useCollectorLogPreview = (instanceUid: string) => { sourceLogs: toPreview( result.forId(ids.sourceQueryId)?.searchTypes?.[ids.sourceSearchTypeId] as RawMessagesResult | undefined, ), - selfLogsError: errorForQuery(ids.selfQueryId), - sourceLogsError: errorForQuery(ids.sourceQueryId), + sourceCounts: toSourceCounts( + result.forId(ids.sourceQueryId)?.searchTypes?.[ids.sourceCountsSearchTypeId] as RawPivotResult | undefined, + ), + selfLogsError: errorForSearchType(ids.selfQueryId, ids.selfSearchTypeId), + sourceLogsError: errorForSearchType(ids.sourceQueryId, ids.sourceSearchTypeId), }; }, }); @@ -183,6 +243,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), 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.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); 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 (