diff --git a/changelog/unreleased/pr-26240.toml b/changelog/unreleased/pr-26240.toml new file mode 100644 index 000000000000..dcb4dac663fc --- /dev/null +++ b/changelog/unreleased/pr-26240.toml @@ -0,0 +1,3 @@ +type = "a" +message = "Added Collector onboarding UI." +pulls = ["26240"] diff --git a/graylog2-web-interface/src/components/collectors/hooks/useCollectorsMutations.ts b/graylog2-web-interface/src/components/collectors/hooks/useCollectorsMutations.ts index fbfc3696843e..9e390f2c73d1 100644 --- a/graylog2-web-interface/src/components/collectors/hooks/useCollectorsMutations.ts +++ b/graylog2-web-interface/src/components/collectors/hooks/useCollectorsMutations.ts @@ -31,6 +31,8 @@ import type { CollectorsConfigRequest, Fleet, Source } from '../types'; type CreateSourceInput = { fleetId: string; source: Omit; + // When true, suppress the per-source success notification (e.g. bulk creation during onboarding). + silent?: boolean; }; type UpdateSourceInput = { fleetId: string; @@ -106,8 +108,10 @@ const useCollectorsMutations = () => { config: { type: source.type, ...source.config }, }) as Promise, onError: onMutationError('Creating source'), - onSuccess: (source) => { - UserNotification.success(`Source "${source.name}" has been created.`, 'Success!'); + onSuccess: (source, { silent }) => { + if (!silent) { + UserNotification.success(`Source "${source.name}" has been created.`, 'Success!'); + } return invalidateCollectorsQueries(); }, diff --git a/graylog2-web-interface/src/components/collectors/overview/CollectorsOverview.tsx b/graylog2-web-interface/src/components/collectors/overview/CollectorsOverview.tsx index e3635db5f7e0..4c4525a522a6 100644 --- a/graylog2-web-interface/src/components/collectors/overview/CollectorsOverview.tsx +++ b/graylog2-web-interface/src/components/collectors/overview/CollectorsOverview.tsx @@ -15,24 +15,26 @@ * . */ import * as React from 'react'; -import { useState } from 'react'; -import styled, { css } from 'styled-components'; +import {useState} from 'react'; +import styled, {css} from 'styled-components'; -import { Alert, Input } from 'components/bootstrap'; -import { Spinner } from 'components/common'; +import {Alert, Input} from 'components/bootstrap'; +import {Spinner} from 'components/common'; import useHistory from 'routing/useHistory'; import Routes from 'routing/Routes'; -import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; +import {TELEMETRY_EVENT_TYPE} from 'logic/telemetry/Constants'; +import type {CollectorStats} from 'components/collectors/types'; import FleetCardsGrid from './FleetCardsGrid'; import RecentActivity from './RecentActivity'; +import FirstOnboarding from './FirstOnboarding'; -import { useCollectorStats, useFleetsBulkStats } from '../hooks'; +import {useCollectorStats, useFleetsBulkStats} from '../hooks'; import useSendCollectorsTelemetry from '../hooks/useSendCollectorsTelemetry'; -import StatCard, { type Variant as StatCardVariant } from '../common/StatCard'; +import StatCard, {type Variant as StatCardVariant} from '../common/StatCard'; const StatsRow = styled.div( - ({ theme }) => css` + ({theme}) => css` display: flex; margin-bottom: ${theme.spacings.lg}; gap: ${theme.spacings.md}; @@ -41,7 +43,7 @@ const StatsRow = styled.div( ); const SectionHeader = styled.div( - ({ theme }) => css` + ({theme}) => css` display: flex; justify-content: space-between; align-items: center; @@ -50,14 +52,13 @@ const SectionHeader = styled.div( ); const SectionTitle = styled.h3( - ({ theme }) => css` + ({theme}) => css` margin: 0; font-size: ${theme.fonts.size.h3}; `, ); -const StatsSection = () => { - const { data: stats, isLoading, isError } = useCollectorStats(); +const StatsSection = ({stats}: { stats: CollectorStats }) => { const history = useHistory(); const sendTelemetry = useSendCollectorsTelemetry(); @@ -85,10 +86,6 @@ const StatsSection = () => { }); }; - if (isLoading) return ; - - if (isError) return Could not load Collector stats.; - return ( { ); }; -const FleetsSection = ({ filter }: { filter: string }) => { - const { data: bulkStats, isLoading, isError } = useFleetsBulkStats(); +const FleetsSection = ({filter}: { filter: string }) => { + const {data: bulkStats, isLoading, isError} = useFleetsBulkStats(); if (isLoading) return ; @@ -150,10 +147,17 @@ const FleetsSection = ({ filter }: { filter: string }) => { const CollectorsOverview = () => { const [filter, setFilter] = useState(''); + const {data: stats, isLoading, isError} = useCollectorStats(); + + if (isLoading) return ; + + if (isError) return Could not load Collector stats.; + + if (stats.total_instances == 0) return ; return (
- + Fleets @@ -162,7 +166,7 @@ const CollectorsOverview = () => { placeholder="Filter fleets..." value={filter} onChange={(e) => setFilter(e.target.value)} - style={{ width: 200, marginBottom: 0 }} + style={{width: 200, marginBottom: 0}} /> diff --git a/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx b/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx new file mode 100644 index 000000000000..67cac5c08e53 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.test.tsx @@ -0,0 +1,354 @@ +/* + * 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, waitFor, within } from 'wrappedTestingLibrary'; +import userEvent from '@testing-library/user-event'; + +import { asMock } from 'helpers/mocking'; +import selectEvent from 'helpers/selectEvent'; + +import FirstOnboarding from './FirstOnboarding'; + +import { useCollectorsConfig, useCollectorsMutations, useFleets } from '../hooks'; +import { mockCollectorsMutations } from '../testing/mockMutations'; + +jest.mock('../hooks'); +jest.mock('util/Version', () => ({ + getMajorAndMinorVersion: () => '7.1', +})); +jest.mock('util/copyToClipboard', () => jest.fn(() => Promise.resolve())); +jest.mock('components/common/Tooltip', () => ({ children }: { children: React.ReactNode }) => <>{children}); +jest.mock('routing/useHistory', () => () => ({ push: jest.fn() })); + +const mockConfig = { + http: { hostname: 'graylog.example', port: 4317 }, + ca_cert_id: null, + signing_cert_id: null, + token_signing_key: null, + otlp_server_cert_id: null, + collector_offline_threshold: 'PT5M', + collector_default_visibility_threshold: 'PT1H', + collector_expiration_threshold: 'P30D', +}; + +const mockFleets = [ + { id: 'fleet-1', name: 'Default Fleet', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' }, +]; + +const multipleFleets = [ + { id: 'fleet-1', name: 'Production', created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z' }, + { + id: 'fleet-2', + name: 'Staging', + description: 'Pre-release staging environment', + created_at: '2026-01-02T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + }, +]; + +describe('FirstOnboarding', () => { + const createEnrollmentToken = jest.fn(); + const createFleet = jest.fn(); + const createSource = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + asMock(useCollectorsConfig).mockReturnValue({ data: mockConfig, isLoading: false }); + asMock(useFleets).mockReturnValue({ data: mockFleets, isLoading: false }); + asMock(useCollectorsMutations).mockReturnValue( + mockCollectorsMutations({ createEnrollmentToken, createFleet, createSource }), + ); + createEnrollmentToken.mockResolvedValue({ + token: 'test-token-abc', + fleet_id: 'fleet-1', + expires_at: '2026-06-04T00:00:00Z', + }); + createFleet.mockResolvedValue({ + id: 'new-fleet-id', + name: 'Onboarding - 2026-05-28', + description: 'Created by Graylog 7.1 onboarding wizard', + created_at: '2026-05-28T00:00:00Z', + updated_at: '2026-05-28T00:00:00Z', + }); + createSource.mockResolvedValue({}); + }); + + it('renders the platform picker initially', () => { + render(); + + expect(screen.getByText(/deploy lightweight collectors/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /linux/i })).toBeInTheDocument(); + }); + + it('auto-selects the single fleet and shows install command', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: /linux/i })); + + await waitFor(() => { + expect(screen.getByText(/waiting for connection/i)).toBeInTheDocument(); + }); + + expect(createFleet).not.toHaveBeenCalled(); + expect(createEnrollmentToken).toHaveBeenCalledWith({ + name: 'onboarding', + fleetId: 'fleet-1', + expiresIn: 'P1D', + }); + + expect(screen.getByText(/test-token-abc/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /linux/i })).toBeInTheDocument(); + + // A single fleet auto-selects without prompting, but the (changeable) fleet box is still shown. + expect(screen.queryByRole('button', { name: /create new fleet/i })).not.toBeInTheDocument(); + expect(screen.getByText('Default Fleet')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /change fleet/i })).toBeInTheDocument(); + }); + + it('lets the user change the auto-selected fleet when only one exists', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: /linux/i })); + await userEvent.click(await screen.findByRole('button', { name: /change fleet/i })); + + // Changing reveals the full create-or-select choice. + expect(screen.getByRole('button', { name: /create new fleet/i })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: /select existing fleet/i })).toBeInTheDocument(); + expect(screen.queryByText(/run this on linux/i)).not.toBeInTheDocument(); + }); + + it('creates an onboarding fleet when no fleets exist', async () => { + asMock(useFleets).mockReturnValue({ data: [], isLoading: false }); + + render(); + + await userEvent.click(screen.getByRole('button', { name: /linux/i })); + + await waitFor(() => { + expect(createFleet).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringContaining('Onboarding'), + description: expect.stringContaining('7.1'), + }), + ); + }); + + expect(createSource).toHaveBeenCalledTimes(3); + + await waitFor(() => { + expect(createEnrollmentToken).toHaveBeenCalledWith({ + name: 'onboarding', + fleetId: 'new-fleet-id', + expiresIn: 'P1D', + }); + }); + }); + + it('does not show the fleet choice until a platform is selected', () => { + asMock(useFleets).mockReturnValue({ data: multipleFleets, isLoading: false }); + + render(); + + expect(screen.getByRole('button', { name: /linux/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /create new fleet/i })).not.toBeInTheDocument(); + }); + + it('shows the fleet choice after a platform is selected, before any command', async () => { + asMock(useFleets).mockReturnValue({ data: multipleFleets, isLoading: false }); + + render(); + + await userEvent.click(screen.getByRole('button', { name: /linux/i })); + + expect(await screen.findByRole('button', { name: /create new fleet/i })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: /select existing fleet/i })).toBeInTheDocument(); + + // No fleet decided yet: the command box must not appear. + expect(screen.queryByText(/run this on linux/i)).not.toBeInTheDocument(); + expect(createEnrollmentToken).not.toHaveBeenCalled(); + }); + + it('creates a new fleet from the create-new button when multiple fleets exist', async () => { + asMock(useFleets).mockReturnValue({ data: multipleFleets, isLoading: false }); + + render(); + + await userEvent.click(screen.getByRole('button', { name: /linux/i })); + await userEvent.click(await screen.findByRole('button', { name: /create new fleet/i })); + + await waitFor(() => { + expect(createFleet).toHaveBeenCalledWith( + expect.objectContaining({ name: expect.stringContaining('Onboarding') }), + ); + }); + + expect(createSource).toHaveBeenCalledTimes(3); + + await waitFor(() => { + expect(createEnrollmentToken).toHaveBeenCalledWith({ + name: 'onboarding', + fleetId: 'new-fleet-id', + expiresIn: 'P1D', + }); + }); + + expect(await screen.findByText(/run this on linux/i)).toBeInTheDocument(); + }); + + it('uses an existing fleet selected from the dropdown', async () => { + asMock(useFleets).mockReturnValue({ data: multipleFleets, isLoading: false }); + + render(); + + await userEvent.click(screen.getByRole('button', { name: /linux/i })); + await screen.findByRole('button', { name: /create new fleet/i }); + + await selectEvent.chooseOption('Select existing fleet', 'Staging'); + + await waitFor(() => { + expect(createEnrollmentToken).toHaveBeenCalledWith({ + name: 'onboarding', + fleetId: 'fleet-2', + expiresIn: 'P1D', + }); + }); + + expect(createFleet).not.toHaveBeenCalled(); + expect(await screen.findByText(/run this on linux/i)).toBeInTheDocument(); + }); + + it('shows the selected fleet name and description with a change button once chosen', async () => { + asMock(useFleets).mockReturnValue({ data: multipleFleets, isLoading: false }); + + render(); + + await userEvent.click(screen.getByRole('button', { name: /linux/i })); + await screen.findByRole('button', { name: /create new fleet/i }); + await selectEvent.chooseOption('Select existing fleet', 'Staging'); + + expect(await screen.findByText(/run this on linux/i)).toBeInTheDocument(); + + // The choice controls are replaced by a summary of the selected fleet. + expect(screen.getByText('Staging')).toBeInTheDocument(); + expect(screen.getByText(/pre-release staging environment/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /change fleet/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /create new fleet/i })).not.toBeInTheDocument(); + }); + + it('shows the newly created fleet details after using the create-new button', async () => { + asMock(useFleets).mockReturnValue({ data: multipleFleets, isLoading: false }); + + render(); + + await userEvent.click(screen.getByRole('button', { name: /linux/i })); + await userEvent.click(await screen.findByRole('button', { name: /create new fleet/i })); + + expect(await screen.findByText('Onboarding - 2026-05-28')).toBeInTheDocument(); + expect(screen.getByText(/created by graylog 7\.1 onboarding wizard/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /change fleet/i })).toBeInTheDocument(); + }); + + it('returns to the fleet choice and hides the command when changing the fleet', async () => { + asMock(useFleets).mockReturnValue({ data: multipleFleets, isLoading: false }); + + render(); + + await userEvent.click(screen.getByRole('button', { name: /linux/i })); + await screen.findByRole('button', { name: /create new fleet/i }); + await selectEvent.chooseOption('Select existing fleet', 'Staging'); + + await userEvent.click(await screen.findByRole('button', { name: /change fleet/i })); + + expect(screen.getByRole('button', { name: /create new fleet/i })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: /select existing fleet/i })).toBeInTheDocument(); + expect(screen.queryByText(/run this on linux/i)).not.toBeInTheDocument(); + }); + + it('reuses the token when switching platforms', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: /linux/i })); + + await waitFor(() => { + expect(screen.getByText(/waiting for connection/i)).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button', { name: /windows/i })); + + await waitFor(() => { + expect(screen.getByText(/run this on windows/i)).toBeInTheDocument(); + }); + + expect(createEnrollmentToken).toHaveBeenCalledTimes(1); + }); + + it('transitions to connected phase after simulating connection', async () => { + render(); + + await userEvent.click(screen.getByRole('button', { name: /linux/i })); + + await waitFor(() => { + expect(screen.getByText(/waiting for connection/i)).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole('button', { name: /simulate connection/i })); + + expect(screen.getByText(/collector connected/i)).toBeInTheDocument(); + // web-prod-01 appears twice: as the connection hostname and as an auto-detected host asset + expect(screen.getAllByText(/web-prod-01/i).length).toBeGreaterThan(0); + }); + + it('collapses to a compact, non-interactive summary once connected', async () => { + asMock(useFleets).mockReturnValue({ data: multipleFleets, isLoading: false }); + + render(); + + await userEvent.click(screen.getByRole('button', { name: /linux/i })); + await screen.findByRole('button', { name: /create new fleet/i }); + await selectEvent.chooseOption('Select existing fleet', 'Staging'); + + await waitFor(() => { + expect(screen.getByText(/run this on linux/i)).toBeInTheDocument(); + }); + + // While waiting, the fleet box is still editable. + expect(screen.getByRole('button', { name: /change fleet/i })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /simulate connection/i })); + + expect(screen.getByText(/collector connected/i)).toBeInTheDocument(); + + // The OS grid and the editable fleet box collapse — nothing left to change. + expect(screen.queryByText(/get started with collectors/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /change fleet/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox', { name: /select existing fleet/i })).not.toBeInTheDocument(); + + // A compact read-only summary shows the platform and fleet name. + const summary = screen.getByTestId('onboarding-summary'); + expect(within(summary).getByText('Linux')).toBeInTheDocument(); + expect(within(summary).getByText('Staging')).toBeInTheDocument(); + }); + + it('shows spinner while config or fleets are loading', async () => { + asMock(useCollectorsConfig).mockReturnValue({ data: undefined, isLoading: true }); + + render(); + + // Spinner renders behind a 200ms Delayed wrapper, so wait for it to appear + expect(await screen.findByText(/loading/i)).toBeInTheDocument(); + }); +}); diff --git a/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.tsx b/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.tsx new file mode 100644 index 000000000000..4f49c308d242 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/FirstOnboarding.tsx @@ -0,0 +1,228 @@ +/* + * 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, useCallback, useRef } from 'react'; +import styled, { css } from 'styled-components'; + +import { Spinner } from 'components/common'; +import { getMajorAndMinorVersion } from 'util/Version'; + +import PlatformPicker from './onboarding/PlatformPicker'; +import InstallCommand from './onboarding/InstallCommand'; +import WaitingForConnection from './onboarding/WaitingForConnection'; +import ConnectionSuccess from './onboarding/ConnectionSuccess'; +import FleetChoice from './onboarding/FleetChoice'; +import type { FleetChoiceValue } from './onboarding/FleetChoice'; +import OnboardingSummary from './onboarding/OnboardingSummary'; +import PLATFORMS from './onboarding/platforms'; +import type { PlatformId } from './onboarding/platforms'; +import DEFAULT_SOURCES from './onboarding/defaultSources'; + +import { useCollectorsConfig, useCollectorsMutations, useFleets } from '../hooks'; +import type { Fleet } from '../types'; + +// 'setup' = still collecting platform/fleet; 'waiting'/'connected' = command box is live. +type Phase = 'setup' | 'waiting' | 'connected'; + +// The platform picker (700px) and fleet selector (400px) own their own centered widths. +// The install/connected body gets a wider column so the command, stat cards, and asset grid have room. +const BodyContainer = styled.div( + ({ theme }) => css` + max-width: 960px; + margin: 0 auto ${theme.spacings.lg}; + `, +); + +const formatDate = () => { + const now = new Date(); + + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; +}; + +const FirstOnboarding = () => { + const [phase, setPhase] = useState('setup'); + const [selectedPlatform, setSelectedPlatform] = useState(null); + const [installCommand, setInstallCommand] = useState(''); + // Only ever set by the fleet-choice UI, which renders only when more than one fleet exists. + const [fleetChoice, setFleetChoice] = useState(null); + // The fleet the collector will enroll into, once resolved (looked up or freshly created). + const [resolvedFleet, setResolvedFleet] = useState(null); + + // The enrollment token is minted once and reused while switching platforms. + const tokenRef = useRef(null); + + const { data: config, isLoading: isConfigLoading } = useCollectorsConfig(); + const { data: fleets, isLoading: isFleetsLoading } = useFleets(); + const { + createFleet, isCreatingFleet, + createSource, + createEnrollmentToken, isCreatingEnrollmentToken, + } = useCollectorsMutations(); + + const buildCommand = useCallback( + (platformId: PlatformId, token: string) => { + if (!config) return ''; + + const platform = PLATFORMS.find((p) => p.id === platformId); + + if (!platform) return ''; + + return platform.commandTemplate(config.http.hostname, config.http.port, token); + }, + [config], + ); + + const createOnboardingFleet = useCallback(async () => { + const version = getMajorAndMinorVersion(); + const fleet = await createFleet({ + name: `Onboarding - ${formatDate()}`, + description: `Created by Graylog ${version} onboarding wizard`, + }); + + await Promise.all( + DEFAULT_SOURCES.map((source) => createSource({ fleetId: fleet.id, source, silent: true })), + ); + + return fleet; + }, [createFleet, createSource]); + + // The fleet to use without asking the user: none -> create one, exactly one -> use it, + // more than one -> null (the user must decide via the fleet-choice UI). + const autoChoice = useCallback((): FleetChoiceValue | null => { + if (!fleets || fleets.length === 0) return { kind: 'create-new' }; + if (fleets.length === 1) return { kind: 'existing', fleetId: fleets[0].id }; + + return null; + }, [fleets]); + + // Both gates (platform, fleet) converge here once both are known. Builds the command box. + const showCommand = useCallback( + async (platformId: PlatformId, choice: FleetChoiceValue) => { + try { + if (!tokenRef.current) { + const fleet = choice.kind === 'create-new' + ? await createOnboardingFleet() + : fleets?.find((f) => f.id === choice.fleetId); + + if (!fleet) return; + + // Reflect the fleet right away so the box lands on its details view without a flash of the prompt. + setResolvedFleet(fleet); + + const { token } = await createEnrollmentToken({ name: 'onboarding', fleetId: fleet.id, expiresIn: 'P1D' }); + tokenRef.current = token; + } + + setInstallCommand(buildCommand(platformId, tokenRef.current)); + setPhase('waiting'); + } catch { + // Error notification handled by useCollectorsMutations onError callback + } + }, + [fleets, createOnboardingFleet, createEnrollmentToken, buildCommand], + ); + + const handlePlatformSelect = useCallback( + (platformId: PlatformId) => { + setSelectedPlatform(platformId); + + // 0/1-fleet falls straight through; >1 fleets waits for the user's choice. + const choice = fleetChoice ?? autoChoice(); + if (choice) showCommand(platformId, choice); + }, + [fleetChoice, autoChoice, showCommand], + ); + + const handleFleetChoice = useCallback( + (choice: FleetChoiceValue) => { + setFleetChoice(choice); + tokenRef.current = null; // fleet changed -> a new token is needed + + if (selectedPlatform) showCommand(selectedPlatform, choice); + }, + [selectedPlatform, showCommand], + ); + + // "Change fleet": drop the resolved fleet and token, and fall back to the choice UI. + const handleChangeFleet = useCallback(() => { + setFleetChoice(null); + setResolvedFleet(null); + tokenRef.current = null; + setPhase('setup'); + }, []); + + const handleSimulateConnection = useCallback(() => { + setPhase('connected'); + }, []); + + if (isConfigLoading || isFleetsLoading) return ; + + const isBusy = isCreatingFleet || isCreatingEnrollmentToken; + // Show the fleet box whenever an existing fleet could be chosen. With exactly one fleet it + // auto-resolves (no prompt — see autoChoice), but stays visible so the user can change it. + const showFleetChoice = (fleets?.length ?? 0) >= 1; + + return ( +
+ {phase === 'connected' && selectedPlatform ? ( + // Connected: the OS grid and fleet picker are pointless now — show a read-only recap. + + ) : ( + <> + {/* 1. Always: pick the operating system. */} + + + {/* 2. Only when a platform is picked and at least one fleet exists. + Shows the choice controls until a fleet is resolved, then its details. */} + {selectedPlatform && showFleetChoice && ( + + )} + + )} + + {/* 3. The command box, once the preconditions are satisfied. */} + {phase !== 'setup' && selectedPlatform && ( + + {phase === 'waiting' && ( + <> + p.id === selectedPlatform)?.label ?? ''} + tokenDuration='P1D' + /> + + + )} + + {phase === 'connected' && } + + )} +
+ ); +}; + +export default FirstOnboarding; diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx new file mode 100644 index 000000000000..e016ff104c9e --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/ConnectionSuccess.tsx @@ -0,0 +1,179 @@ +/* + * 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 { Alert, Label } from 'components/bootstrap'; +import { AccessibleCard } from 'components/common'; +import useHistory from 'routing/useHistory'; +import Routes from 'routing/Routes'; + +import type { PlatformId } from './platforms'; +import PLATFORMS from './platforms'; + +import StatCard from '../../common/StatCard'; + +type Props = { + platformId: PlatformId; +}; + +const MOCK_CONNECTION = { + hostname: 'web-prod-01', + version: 'v1.0.0', + fleetName: 'Default Fleet', + sources: ['syslog', 'auth.log'], + assets: [ + { type: 'host', name: 'web-prod-01' }, + { type: 'user', name: 'root' }, + ], + messageCount: 142, +}; + +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 ConnectionSuccess = ({ platformId }: Props) => { + const history = useHistory(); + const platform = PLATFORMS.find((p) => p.id === platformId); + + return ( +
+ + Collector connected — {MOCK_CONNECTION.hostname} running{' '} + {MOCK_CONNECTION.version} + + + + + + + + + + + + + + + Auto-detected assets + + {MOCK_CONNECTION.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.FLEETS)}> +

Configure Sources

+

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

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

View Instances

+

Monitor all connected collector instances.

+
+
+
+ ); +}; + +export default ConnectionSuccess; diff --git a/graylog2-web-interface/src/components/collectors/overview/onboarding/FleetChoice.tsx b/graylog2-web-interface/src/components/collectors/overview/onboarding/FleetChoice.tsx new file mode 100644 index 000000000000..6f544ed7a779 --- /dev/null +++ b/graylog2-web-interface/src/components/collectors/overview/onboarding/FleetChoice.tsx @@ -0,0 +1,159 @@ +/* + * 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 { Button } from 'components/bootstrap'; +import { Select } from 'components/common'; + +import type { Fleet } from '../../types'; + +// A resolved decision about which fleet the collector should enroll into. +export type FleetChoiceValue = { kind: 'existing'; fleetId: string } | { kind: 'create-new' }; + +type Props = { + fleets: Fleet[]; + // When set, the fleet is locked in and we show its details instead of the choice controls. + selectedFleet: Fleet | null; + onSelect: (choice: FleetChoiceValue) => void; + onChange: () => void; + disabled: boolean; +}; + +const Container = styled.div( + ({ theme }) => css` + max-width: 700px; + margin: 0 auto ${theme.spacings.md}; + text-align: center; + `, +); + +const Heading = styled.h3( + ({ theme }) => css` + margin: 0 0 ${theme.spacings.sm}; + font-size: ${theme.fonts.size.h4}; + `, +); + +// Button and dropdown sit side by side, separated by an "or". +const Row = styled.div( + ({ theme }) => css` + display: flex; + align-items: center; + justify-content: center; + gap: ${theme.spacings.md}; + `, +); + +const Separator = styled.span( + ({ theme }) => css` + color: ${theme.colors.gray[60]}; + font-style: italic; + `, +); + +const SelectField = styled.div` + flex: 1; + max-width: 320px; + text-align: left; +`; + +const HiddenLabel = styled.label` + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); +`; + +// The locked-in fleet: details on the left, a "Change fleet" escape hatch on the right. +const SelectedBox = styled.div( + ({ theme }) => css` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${theme.spacings.md}; + padding: ${theme.spacings.sm} ${theme.spacings.md}; + border: 1px solid ${theme.colors.cards.border}; + border-radius: ${theme.spacings.xs}; + text-align: left; + `, +); + +const FleetName = styled.div( + ({ theme }) => css` + font-weight: 500; + font-size: ${theme.fonts.size.large}; + `, +); + +const FleetDescription = styled.div( + ({ theme }) => css` + color: ${theme.colors.gray[60]}; + font-size: ${theme.fonts.size.small}; + `, +); + +const FLEET_SELECT_ID = 'onboarding-fleet-select'; + +const FleetChoice = ({ fleets, selectedFleet, onSelect, onChange, disabled }: Props) => { + if (selectedFleet) { + return ( + + Fleet for this collector + +
+ {selectedFleet.name} + {selectedFleet.description && {selectedFleet.description}} +
+ +
+
+ ); + } + + const options = fleets.map((fleet) => ({ label: fleet.name, value: fleet.id })); + + return ( + + Choose a fleet for this collector + + + or + + Select existing fleet +