[Observability Onboarding] Export package list from fleet (#179301)

## Summary

Resolves https://github.com/elastic/kibana/issues/178945.
Resolves #178947.
Resolves https://github.com/elastic/kibana/issues/178952.

~Undergoing the work to export package list in a sharable way while
minimally impacting the existing implementation used by the Fleet team.~


### Update

Updated to provide the flows for all three paths of the form. Synthetics
and APM still link to their respective apps. Logs icons link to the
embedded flows.

#### Things to test

- Check that the integration cards behave in a manner you'd expect.
There's a lot of custom card creation happening and it's possible I have
made a mistake in some linking.
- Icons: there are a few icons that don't correspond to the design. I
wasn't able to find the correct `src` for the colored-in APM logo, for
example, and I'm not sure where the one logs icon lives. I've used the
`logoFilebeat` icon in its place as it's quite similar and is heavily
used in the integrations list.
- For the searchable integrations list, I was unsure if I should re-hide
it when the user changes their radio selection in the top-level
question. Right now it'll just stay as-is with their original query.
- The `Collections` feature, make sure collection buttons properly apply
the search query.

~This now works with the exported package list grid from Fleet. I've
introduced a real package list in place of the dummy grid that we had
before. I also added a searchable grid below.~

~Surely there are some things to iron out still. I also still consider
the code rough, so if you should choose to review it you will likely
find areas you want to see improved.~


![20240405171834](b3e4553a-1841-4c57-9713-81571fae1b44)

---

**Note:** I will likely extract the Fleet-specific pieces of this to a
separate patch that we can merge to Kibana `main` to make it easier for
the Fleet team to review the changes. We can then merge those upstream
and pull them into our feature branch.

^^ this is still true, before we merged this I'll likely pull out the
Fleet-specific modifications we need and have the Fleet team review them
separately. We can still merge this to the feature branch in the
meantime and resolve it later.

---------

Co-authored-by: Joe Reuter <johannes.reuter@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Thom Heymann <190132+thomheymann@users.noreply.github.com>
This commit is contained in:
Justin Kambic 2024-04-15 09:20:53 -04:00 committed by GitHub
parent 01e2379929
commit a73de7319c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 590 additions and 57 deletions

View file

@ -6,21 +6,21 @@
*/
import { i18n } from '@kbn/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import {
EuiPageTemplate,
EuiSpacer,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { useHistory } from 'react-router-dom';
import { Route, Routes } from '@kbn/shared-ux-router';
import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public';
import { useNavigate, useLocation } from 'react-router-dom-v5-compat';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiPageTemplate,
EuiSpacer,
} from '@elastic/eui';
import { css } from '@emotion/react';
import backgroundImageUrl from './header/background.svg';
import { Footer } from './footer/footer';
import { OnboardingFlowForm } from './onboarding_flow_form/onboarding_flow_form';
@ -28,12 +28,14 @@ import { Header } from './header/header';
import { SystemLogsPanel } from './quickstart_flows/system_logs';
import { CustomLogsPanel } from './quickstart_flows/custom_logs';
const queryClient = new QueryClient();
export function ExperimentalOnboardingFlow() {
const history = useHistory();
const location = useLocation();
return (
<>
<QueryClientProvider client={queryClient}>
{/* Test buttons to be removed once integrations selector has been implemented */}
<EuiPageTemplate.Section grow={false} color="accent" restrictWidth>
<EuiFlexGroup>
@ -104,7 +106,7 @@ export function ExperimentalOnboardingFlow() {
<Footer />
<EuiSpacer size="xl" />
</EuiPageTemplate.Section>
</>
</QueryClientProvider>
);
}

View file

@ -6,27 +6,29 @@
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import React, { useCallback, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import type { FunctionComponent } from 'react';
import {
EuiAvatar,
EuiCheckableCard,
EuiTitle,
EuiText,
EuiPanel,
EuiSpacer,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiCard,
EuiIcon,
EuiAvatar,
useEuiTheme,
EuiPanel,
EuiSpacer,
EuiText,
EuiTitle,
useGeneratedHtmlId,
} from '@elastic/eui';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { OnboardingFlowPackageList } from '../packages_list';
import { useCustomMargin } from '../shared/use_custom_margin';
import { Category } from './types';
import { useCustomCardsForCategory } from './use_custom_cards_for_category';
interface UseCaseOption {
id: string;
id: Category;
label: string;
description: React.ReactNode;
}
@ -77,11 +79,31 @@ export const OnboardingFlowForm: FunctionComponent = () => {
},
];
const customMargin = useCustomMargin();
const radioGroupId = useGeneratedHtmlId({ prefix: 'onboardingCategory' });
const { euiTheme } = useEuiTheme();
const [searchParams, setSearchParams] = useSearchParams();
const packageListSearchBarRef = React.useRef<null | HTMLInputElement>(null);
const [integrationSearch, setIntegrationSearch] = useState('');
const createCollectionCardHandler = useCallback(
(query: string) => () => {
setIntegrationSearch(query);
if (packageListSearchBarRef.current) {
packageListSearchBarRef.current.focus();
packageListSearchBarRef.current.scrollIntoView({
behavior: 'auto',
block: 'center',
});
}
},
[setIntegrationSearch]
);
const customCards = useCustomCardsForCategory(
createCollectionCardHandler,
searchParams.get('category') as Category | null
);
return (
<EuiPanel hasBorder>
@ -96,12 +118,8 @@ export const OnboardingFlowForm: FunctionComponent = () => {
)}
/>
<EuiSpacer size="m" />
<EuiFlexGroup
css={{ margin: `calc(${euiTheme.size.xxl} / 2)` }}
gutterSize="m"
direction="column"
>
{options.map((option, index) => (
<EuiFlexGroup css={customMargin} gutterSize="m" direction="column">
{options.map((option) => (
<EuiFlexItem key={option.id}>
<EuiCheckableCard
id={`${radioGroupId}_${option.id}`}
@ -137,32 +155,22 @@ export const OnboardingFlowForm: FunctionComponent = () => {
/>
<EuiSpacer size="m" />
{/* Mock integrations grid */}
<EuiFlexGrid columns={3} css={{ margin: 20 }}>
{new Array(6).fill(null).map((_, index) => (
<EuiCard
key={index}
layout="horizontal"
title={searchParams.get('category')!}
titleSize="xs"
description={searchParams.get('category')!}
icon={<EuiIcon type="logoObservability" size="l" />}
betaBadgeProps={
index === 0
? {
label: 'Quick Start',
color: 'accent',
size: 's',
}
: undefined
}
hasBorder
css={{
borderColor: index === 0 ? '#ba3d76' : undefined,
}}
/>
))}
</EuiFlexGrid>
{Array.isArray(customCards) && (
<OnboardingFlowPackageList customCards={customCards} />
)}
<EuiText css={customMargin} size="s" color="subdued">
<FormattedMessage
id="xpack.observability_onboarding.experimentalOnboardingFlow.form.searchPromptText"
defaultMessage="Not seeing yours? Search through our 130 ways of ingesting data:"
/>
</EuiText>
<OnboardingFlowPackageList
showSearchBar={true}
searchQuery={integrationSearch}
setSearchQuery={setIntegrationSearch}
ref={packageListSearchBarRef}
/>
</>
)}
</EuiPanel>

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export type Category = 'apm' | 'infra' | 'logs';

View file

@ -0,0 +1,212 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
reactRouterNavigate,
useKibana,
} from '@kbn/kibana-react-plugin/public';
import { useHistory } from 'react-router-dom';
import { useLocation } from 'react-router-dom-v5-compat';
import { CustomCard, FeaturedCard } from '../packages_list/types';
import { Category } from './types';
function toFeaturedCard(name: string): FeaturedCard {
return { type: 'featured', name };
}
export function useCustomCardsForCategory(
createCollectionCardHandler: (query: string) => () => void,
category: Category | null
): CustomCard[] | undefined {
const history = useHistory();
const location = useLocation();
const getUrlForApp = useKibana()?.services.application?.getUrlForApp;
const { href: systemLogsUrl } = reactRouterNavigate(
history,
`/systemLogs/${location.search}`
);
const { href: customLogsUrl } = reactRouterNavigate(
history,
`/customLogs/${location.search}`
);
switch (category) {
case 'apm':
return [
{
id: 'apm-generated',
type: 'generated',
title: 'Elastic APM',
description:
'Collect distributed traces from your applications with Elastic APM',
name: 'apm',
categories: ['observability'],
icons: [
{
type: 'eui',
src: 'apmApp',
},
],
url: getUrlForApp?.('apm') ?? '',
version: '',
integration: '',
},
{
id: 'synthetics-generated',
type: 'generated',
title: 'Synthetic monitor',
description: 'Monitor endpoints, pages, and user journeys',
name: 'synthetics',
categories: ['observability'],
icons: [
{
type: 'eui',
src: 'logoUptime',
},
],
url: getUrlForApp?.('synthetics') ?? '',
version: '',
integration: '',
},
];
case 'infra':
return [
toFeaturedCard('kubernetes'),
toFeaturedCard('prometheus'),
toFeaturedCard('docker'),
{
id: 'azure-generated',
type: 'generated',
title: 'Azure',
description: 'Collect logs and metrics from Microsoft Azure',
name: 'azure',
categories: ['observability'],
icons: [],
url: 'https://azure.com',
version: '',
integration: '',
isCollectionCard: true,
onCardClick: createCollectionCardHandler('azure'),
},
{
id: 'aws-generated',
type: 'generated',
title: 'AWS',
description:
'Collect logs and metrics from Amazon Web Services (AWS)',
name: 'aws',
categories: ['observability'],
icons: [],
url: 'https://aws.com',
version: '',
integration: '',
isCollectionCard: true,
onCardClick: createCollectionCardHandler('aws'),
},
{
id: 'gcp-generated',
type: 'generated',
title: 'Google Cloud Platform',
description: 'Collect logs and metrics from Google Cloud Platform',
name: 'gcp',
categories: ['observability'],
icons: [],
url: '',
version: '',
integration: '',
isCollectionCard: true,
onCardClick: createCollectionCardHandler('gcp'),
},
];
case 'logs':
return [
{
id: 'system-logs',
type: 'generated',
title: 'Stream host system logs',
description:
'The quickest path to onboard log data from your own machine or server',
name: 'system-logs-generated',
categories: ['observability'],
icons: [
{
type: 'svg',
src: '/XXXXXXXXXXXX/plugins/home/assets/logos/system.svg',
},
],
url: systemLogsUrl,
version: '',
integration: '',
},
{
id: 'logs-logs',
type: 'generated',
title: 'Stream log files',
description:
'Stream any logs into Elastic in a simple way and explore their data',
name: 'logs-logs-generated',
categories: ['observability'],
icons: [
{
type: 'eui',
src: 'filebeatApp',
},
],
url: customLogsUrl,
version: '',
integration: '',
},
toFeaturedCard('nginx'),
{
id: 'azure-logs-generated',
type: 'generated',
title: 'Azure',
description: 'Collect logs from Microsoft Azure',
name: 'azure',
categories: ['observability'],
icons: [],
url: 'https://azure.com',
version: '',
integration: '',
isCollectionCard: true,
onCardClick: createCollectionCardHandler('azure'),
},
{
id: 'aws-logs-generated',
type: 'generated',
title: 'AWS',
description: 'Collect logs from Amazon Web Services (AWS)',
name: 'aws',
categories: ['observability'],
icons: [],
url: 'https://aws.com',
version: '',
integration: '',
isCollectionCard: true,
onCardClick: createCollectionCardHandler('aws'),
},
{
id: 'gcp-logs-generated',
type: 'generated',
title: 'Google Cloud Platform',
description: 'Collect logs from Google Cloud Platform',
name: 'gcp',
categories: ['observability'],
icons: [],
url: '',
version: '',
integration: '',
isCollectionCard: true,
onCardClick: createCollectionCardHandler('gcp'),
},
];
default:
return undefined;
}
}

View file

@ -0,0 +1,190 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
AvailablePackagesHookType,
IntegrationCardItem,
} from '@kbn/fleet-plugin/public';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButton,
EuiCallOut,
EuiSearchBar,
EuiSkeletonText,
} from '@elastic/eui';
import { css } from '@emotion/react';
import React, { useRef, Suspense, useState } from 'react';
import useAsyncRetry from 'react-use/lib/useAsyncRetry';
import { PackageList, fetchAvailablePackagesHook } from './lazy';
import { useIntegrationCardList } from './use_integration_card_list';
import { useCustomMargin } from '../shared/use_custom_margin';
import { CustomCard } from './types';
interface Props {
/**
* A subset of either existing card names to feature, or generated
* cards to display. The inclusion of CustomCards will override the default
* list functionality.
*/
customCards?: CustomCard[];
/**
* Override the default `observability` option.
*/
selectedCategory?: string;
showSearchBar?: boolean;
searchBarRef?: React.Ref<HTMLInputElement>;
searchQuery?: string;
setSearchQuery?: React.Dispatch<React.SetStateAction<string>>;
}
type WrapperProps = Props & {
useAvailablePackages: AvailablePackagesHookType;
};
const Loading = () => <EuiSkeletonText isLoading={true} lines={10} />;
const PackageListGridWrapper = ({
selectedCategory = 'observability',
useAvailablePackages,
showSearchBar = false,
searchBarRef,
searchQuery,
setSearchQuery,
customCards,
}: WrapperProps) => {
const [isInitialHidden, setIsInitialHidden] = useState(showSearchBar);
const customMargin = useCustomMargin();
const { filteredCards, isLoading } = useAvailablePackages({
prereleaseIntegrationsEnabled: false,
});
const list: IntegrationCardItem[] = useIntegrationCardList(
filteredCards,
selectedCategory,
customCards
);
React.useEffect(() => {
if (isInitialHidden && searchQuery) {
setIsInitialHidden(false);
}
}, [searchQuery, isInitialHidden]);
if (!isInitialHidden && isLoading) return <Loading />;
const showPackageList =
(showSearchBar && !isInitialHidden) || showSearchBar === false;
return (
<Suspense fallback={<Loading />}>
<div css={customMargin}>
{showSearchBar && (
<div
css={css`
max-width: 600px;
`}
>
<EuiSearchBar
box={{
incremental: true,
inputRef: (ref: any) => {
(
searchBarRef as React.MutableRefObject<HTMLInputElement>
).current = ref;
},
}}
onChange={(arg) => {
if (setSearchQuery) {
setSearchQuery(arg.queryText);
}
setIsInitialHidden(false);
}}
query={searchQuery}
/>
</div>
)}
{showPackageList && (
<PackageList
list={list}
searchTerm={searchQuery ?? ''}
showControls={false}
showSearchTools={false}
// we either don't need these properties (yet) or handle them upstream, but
// they are marked as required in the original API.
selectedCategory={selectedCategory}
setSearchTerm={() => {}}
setCategory={() => {}}
categories={[]}
setUrlandReplaceHistory={() => {}}
setUrlandPushHistory={() => {}}
/>
)}
</div>
</Suspense>
);
};
const WithAvailablePackages = React.forwardRef(
(props: Props, searchBarRef?: React.Ref<HTMLInputElement>) => {
const ref = useRef<AvailablePackagesHookType | null>(null);
const {
error: errorLoading,
retry: retryAsyncLoad,
loading: asyncLoading,
} = useAsyncRetry(async () => {
ref.current = await fetchAvailablePackagesHook();
});
if (errorLoading)
return (
<EuiCallOut
title={i18n.translate(
'xpack.observability_onboarding.asyncLoadFailureCallout.title',
{
defaultMessage: 'Loading failure',
}
)}
color="warning"
iconType="cross"
size="m"
>
<p>
<FormattedMessage
id="xpack.observability_onboarding.asyncLoadFailureCallout.copy"
defaultMessage="Some required elements failed to load."
/>
</p>
<EuiButton
color="warning"
data-test-subj="xpack.observability_onboarding.asyncLoadFailureCallout.button"
onClick={() => {
if (!asyncLoading) retryAsyncLoad();
}}
>
<FormattedMessage
id="xpack.observability_onboarding.asyncLoadFailureCallout.buttonContent"
defaultMessage="Retry"
/>
</EuiButton>
</EuiCallOut>
);
if (asyncLoading || ref.current === null) return <Loading />;
return (
<PackageListGridWrapper
{...props}
useAvailablePackages={ref.current}
searchBarRef={searchBarRef}
/>
);
}
);
export { WithAvailablePackages as OnboardingFlowPackageList };

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AvailablePackagesHookType } from '@kbn/fleet-plugin/public';
import { lazy } from 'react';
export const PackageList = lazy(async () => ({
default: await import('@kbn/fleet-plugin/public')
.then((module) => module.PackageList())
.then((pkg) => pkg.PackageListGrid),
}));
export const fetchAvailablePackagesHook =
(): Promise<AvailablePackagesHookType> =>
import('@kbn/fleet-plugin/public')
.then((module) => module.AvailablePackagesHook())
.then((hook) => hook.useAvailablePackages);

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { IntegrationCardItem } from '@kbn/fleet-plugin/public';
export type GeneratedCard = {
type: 'generated';
} & IntegrationCardItem;
export interface FeaturedCard {
type: 'featured';
name: string;
}
export type CustomCard = FeaturedCard | GeneratedCard;

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useMemo } from 'react';
import { IntegrationCardItem } from '@kbn/fleet-plugin/public';
import { CustomCard } from './types';
const QUICKSTART_FLOWS = ['kubernetes', 'nginx', 'system-logs-generated'];
const toCustomCard = (card: IntegrationCardItem) => ({
...card,
isQuickstart: QUICKSTART_FLOWS.includes(card.name),
});
function extractFeaturedCards(
filteredCards: IntegrationCardItem[],
featuredCardNames?: string[]
) {
const featuredCards: Record<string, IntegrationCardItem | undefined> = {};
filteredCards.forEach((card) => {
if (featuredCardNames?.includes(card.name)) {
featuredCards[card.name] = card;
}
});
return featuredCards;
}
export function useIntegrationCardList(
filteredCards: IntegrationCardItem[],
selectedCategory = 'observability',
customCards?: CustomCard[]
): IntegrationCardItem[] {
const featuredCards = useMemo(() => {
if (!customCards) return {};
return extractFeaturedCards(
filteredCards,
customCards.filter((c) => c.type === 'featured').map((c) => c.name)
);
}, [filteredCards, customCards]);
if (customCards && customCards.length > 0) {
return customCards
.map((c) => {
if (c.type === 'featured') {
return !!featuredCards[c.name]
? toCustomCard(featuredCards[c.name]!)
: null;
}
return toCustomCard(c);
})
.filter((c) => c) as IntegrationCardItem[];
}
return filteredCards
.filter((card) => card.categories.includes(selectedCategory))
.map(toCustomCard);
}

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEuiTheme } from '@elastic/eui';
export function useCustomMargin() {
const { euiTheme } = useEuiTheme();
return { margin: `calc(${euiTheme.size.xxl} / 2)` };
}