[Fleet] Add assets tab (#102517) (#102936)

* very wip

* added new assets screen
* added routes to new assets view on the package details view

* Finished styling the assets page layout, need to work on adding
links

* rather use EuiHorizontalRule

* only show the assets tab if installed

* Added hacky version of linking to assets.

* added comment about deprecation of current linking functionality

* added an initial version of the success toast with a link to the agent flyout

* First iteration of end-to-end UX working. Need to add a lot of tests!

* fixed navigation bug and added a comment

* added a lot more padding to bottom of form

* restructured code for clarity, updated deprecation comments and moved relevant code closer together

* added a longer form comment about the origin policyId

* added logic for handling load error

* refactor assets accordions out of assets page component

* slightly larger text in badge

* added some basic jest test for view data step in enrollment flyout

* adjusted sizing of numbers in badges again, EuiText does not know about size="l"

* updated size limits for fleet

* updated styling and layout of assets accordion based on original
designs

* remove unused EuiTitle

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Jean-Louis Leysens <jloleysens@gmail.com>
This commit is contained in:
Kibana Machine 2021-06-22 13:14:10 -04:00 committed by GitHub
parent b911f5df4f
commit ba95a28dd8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 676 additions and 125 deletions

View file

@ -34,7 +34,7 @@ pageLoadAssetSize:
indexManagement: 140608
indexPatternManagement: 28222
infra: 184320
fleet: 450005
fleet: 465774
ingestPipelines: 58003
inputControlVis: 172819
inspector: 148999

View file

@ -43,7 +43,7 @@ export type InstallSource = 'registry' | 'upload';
export type EpmPackageInstallStatus = 'installed' | 'installing';
export type DetailViewPanelName = 'overview' | 'policies' | 'settings' | 'custom';
export type DetailViewPanelName = 'overview' | 'policies' | 'assets' | 'settings' | 'custom';
export type ServiceName = 'kibana' | 'elasticsearch';
export type AgentAssetType = typeof agentAssetTypes;
export type DocAssetType = 'doc' | 'notice';

View file

@ -19,10 +19,12 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiLink,
} from '@elastic/eui';
import type { EuiStepProps } from '@elastic/eui/src/components/steps/step';
import type { ApplicationStart } from 'kibana/public';
import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public';
import type {
AgentPolicy,
PackageInfo,
@ -60,7 +62,7 @@ const StepsWithLessPadding = styled(EuiSteps)`
`;
const CustomEuiBottomBar = styled(EuiBottomBar)`
// Set a relatively _low_ z-index value here to account for EuiComboBox popover that might appear under the bottom bar
/* A relatively _low_ z-index value here to account for EuiComboBox popover that might appear under the bottom bar */
z-index: 50;
`;
@ -84,11 +86,26 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => {
const history = useHistory();
const handleNavigateTo = useNavigateToCallback();
const routeState = useIntraAppState<CreatePackagePolicyRouteState>();
const from: CreatePackagePolicyFrom = 'policyId' in params ? 'policy' : 'package';
const { search } = useLocation();
const queryParams = useMemo(() => new URLSearchParams(search), [search]);
const policyId = useMemo(() => queryParams.get('policyId') ?? undefined, [queryParams]);
const queryParamsPolicyId = useMemo(() => queryParams.get('policyId') ?? undefined, [
queryParams,
]);
/**
* Please note: policyId can come from one of two sources. The URL param (in the URL path) or
* in the query params (?policyId=foo).
*
* Either way, we take this as an indication that a user is "coming from" the fleet policy UI
* since we link them out to packages (a.k.a. integrations) UI when choosing a new package. It is
* no longer possible to choose a package directly in the create package form.
*
* We may want to deprecate the ability to pass in policyId from URL params since there is no package
* creation possible if a user has not chosen one from the packages UI.
*/
const from: CreatePackagePolicyFrom =
'policyId' in params || queryParamsPolicyId ? 'policy' : 'package';
// Agent policy and package info states
const [agentPolicy, setAgentPolicy] = useState<AgentPolicy | undefined>();
@ -280,6 +297,13 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => {
);
}
const fromPolicyWithoutAgentsAssigned = from === 'policy' && agentPolicy && agentCount === 0;
const fromPackageWithoutAgentsAssigned =
from === 'package' && packageInfo && agentPolicy && agentCount === 0;
const hasAgentsAssigned = agentCount && agentPolicy;
notifications.toasts.addSuccess({
title: i18n.translate('xpack.fleet.createPackagePolicy.addedNotificationTitle', {
defaultMessage: `'{packagePolicyName}' integration added.`,
@ -287,22 +311,47 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => {
packagePolicyName: packagePolicy.name,
},
}),
text:
agentCount && agentPolicy
? i18n.translate('xpack.fleet.createPackagePolicy.addedNotificationMessage', {
defaultMessage: `Fleet will deploy updates to all agents that use the '{agentPolicyName}' policy.`,
values: {
agentPolicyName: agentPolicy.name,
},
})
: (params as AddToPolicyParams)?.policyId && agentPolicy && agentCount === 0
? i18n.translate('xpack.fleet.createPackagePolicy.addAgentNextNotification', {
text: fromPolicyWithoutAgentsAssigned
? i18n.translate(
'xpack.fleet.createPackagePolicy.policyContextAddAgentNextNotificationMessage',
{
defaultMessage: `The policy has been updated. Add an agent to the '{agentPolicyName}' policy to deploy this policy.`,
values: {
agentPolicyName: agentPolicy.name,
agentPolicyName: agentPolicy!.name,
},
})
: undefined,
}
)
: fromPackageWithoutAgentsAssigned
? toMountPoint(
// To render the link below we need to mount this JSX in the success toast
<FormattedMessage
id="xpack.fleet.createPackagePolicy.integrationsContextaddAgentNextNotificationMessage"
defaultMessage="Next, {link} to start ingesting data."
values={{
link: (
<EuiLink
href={getHref('integration_details_policies', {
pkgkey: `${packageInfo!.name}-${packageInfo!.version}`,
addAgentToPolicyId: agentPolicy!.id,
})}
>
{i18n.translate(
'xpack.fleet.createPackagePolicy.integrationsContextAddAgentLinkMessage',
{ defaultMessage: 'add an agent' }
)}
</EuiLink>
),
}}
/>
)
: hasAgentsAssigned
? i18n.translate('xpack.fleet.createPackagePolicy.addedNotificationMessage', {
defaultMessage: `Fleet will deploy updates to all agents that use the '{agentPolicyName}' policy.`,
values: {
agentPolicyName: agentPolicy!.name,
},
})
: undefined,
'data-test-subj': 'packagePolicyCreateSuccessToast',
});
} else {
@ -312,6 +361,9 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => {
setFormState('VALID');
}
}, [
getHref,
from,
packageInfo,
agentCount,
agentPolicy,
formState,
@ -353,13 +405,13 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => {
<StepSelectAgentPolicy
pkgkey={(params as AddToPolicyParams).pkgkey}
updatePackageInfo={updatePackageInfo}
defaultAgentPolicyId={policyId}
defaultAgentPolicyId={queryParamsPolicyId}
agentPolicy={agentPolicy}
updateAgentPolicy={updateAgentPolicy}
setIsLoadingSecondStep={setIsLoadingAgentPolicyStep}
/>
),
[params, updatePackageInfo, agentPolicy, updateAgentPolicy, policyId]
[params, updatePackageInfo, agentPolicy, updateAgentPolicy, queryParamsPolicyId]
);
const ExtensionView = useUIExtension(packagePolicy.package?.name ?? '', 'package-policy-create');
@ -455,7 +507,8 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => {
<PolicyBreadcrumb policyName={agentPolicy.name} policyId={agentPolicy.id} />
)}
<StepsWithLessPadding steps={steps} />
<EuiSpacer size="l" />
<EuiSpacer size="xl" />
<EuiSpacer size="xl" />
<CustomEuiBottomBar data-test-subj="integrationsBottomBar">
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>

View file

@ -9,10 +9,11 @@ import React, { memo } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import { useCapabilities, useLink } from '../../../../../hooks';
import { useCapabilities, useStartServices } from '../../../../../hooks';
import { pagePathGetters, INTEGRATIONS_PLUGIN_ID } from '../../../../../constants';
export const NoPackagePolicies = memo<{ policyId: string }>(({ policyId }) => {
const { getHref } = useLink();
const { application } = useStartServices();
const hasWriteCapabilities = useCapabilities().write;
return (
@ -36,7 +37,12 @@ export const NoPackagePolicies = memo<{ policyId: string }>(({ policyId }) => {
<EuiButton
isDisabled={!hasWriteCapabilities}
fill
href={getHref('add_integration_from_policy', { policyId })}
onClick={() =>
application.navigateToApp(INTEGRATIONS_PLUGIN_ID, {
path: `#${pagePathGetters.integrations_all()[1]}`,
state: { forAgentPolicyId: policyId },
})
}
>
<FormattedMessage
id="xpack.fleet.policyDetailsPackagePolicies.createFirstButtonText"

View file

@ -0,0 +1,138 @@
/*
* 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 React, { useEffect, useState } from 'react';
import { Redirect } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiSpacer } from '@elastic/eui';
import { Loading, Error } from '../../../../../components';
import type { PackageInfo } from '../../../../../types';
import { InstallStatus } from '../../../../../types';
import { useGetPackageInstallStatus, useLink, useStartServices } from '../../../../../hooks';
import type { AssetSavedObject } from './types';
import { allowedAssetTypes } from './constants';
import { AssetsAccordion } from './assets_accordion';
interface AssetsPanelProps {
packageInfo: PackageInfo;
}
export const AssetsPage = ({ packageInfo }: AssetsPanelProps) => {
const { name, version } = packageInfo;
const {
savedObjects: { client: savedObjectsClient },
} = useStartServices();
const { getPath } = useLink();
const getPackageInstallStatus = useGetPackageInstallStatus();
const packageInstallStatus = getPackageInstallStatus(packageInfo.name);
const [assetSavedObjects, setAssetsSavedObjects] = useState<undefined | AssetSavedObject[]>();
const [fetchError, setFetchError] = useState<undefined | Error>();
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
const fetchAssetSavedObjects = async () => {
if ('savedObject' in packageInfo) {
const {
savedObject: { attributes: packageAttributes },
} = packageInfo;
if (
!packageAttributes.installed_kibana ||
packageAttributes.installed_kibana.length === 0
) {
setIsLoading(false);
return;
}
try {
const objectsToGet = packageAttributes.installed_kibana.map(({ id, type }) => ({
id,
type,
}));
const { savedObjects } = await savedObjectsClient.bulkGet(objectsToGet);
setAssetsSavedObjects(savedObjects as AssetSavedObject[]);
} catch (e) {
setFetchError(e);
} finally {
setIsLoading(false);
}
} else {
setIsLoading(false);
}
};
fetchAssetSavedObjects();
}, [savedObjectsClient, packageInfo]);
// if they arrive at this page and the package is not installed, send them to overview
// this happens if they arrive with a direct url or they uninstall while on this tab
if (packageInstallStatus.status !== InstallStatus.installed) {
return (
<Redirect
to={getPath('integration_details_overview', {
pkgkey: `${name}-${version}`,
})}
/>
);
}
let content: JSX.Element | Array<JSX.Element | null>;
if (isLoading) {
content = <Loading />;
} else if (fetchError) {
content = (
<Error
title={
<FormattedMessage
id="xpack.fleet.epm.packageDetails.assets.fetchAssetsErrorTitle"
defaultMessage="Error loading assets"
/>
}
error={fetchError}
/>
);
} else if (assetSavedObjects === undefined) {
content = (
<EuiTitle>
<h2>
<FormattedMessage
id="xpack.fleet.epm.packageDetails.assets.noAssetsFoundLabel"
defaultMessage="No assets found"
/>
</h2>
</EuiTitle>
);
} else {
content = allowedAssetTypes.map((assetType) => {
const sectionAssetSavedObjects = assetSavedObjects.filter((so) => so.type === assetType);
if (!sectionAssetSavedObjects.length) {
return null;
}
return (
<>
<AssetsAccordion savedObjects={sectionAssetSavedObjects} type={assetType} />
<EuiSpacer size="l" />
</>
);
});
}
return (
<EuiFlexGroup alignItems="flexStart">
<EuiFlexItem grow={1} />
<EuiFlexItem grow={6}>{content}</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,92 @@
/*
* 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 React from 'react';
import type { FunctionComponent } from 'react';
import {
EuiAccordion,
EuiFlexGroup,
EuiFlexItem,
EuiSplitPanel,
EuiSpacer,
EuiText,
EuiLink,
EuiHorizontalRule,
EuiNotificationBadge,
} from '@elastic/eui';
import { AssetTitleMap } from '../../../../../constants';
import { getHrefToObjectInKibanaApp, useStartServices } from '../../../../../hooks';
import type { AllowedAssetType, AssetSavedObject } from './types';
interface Props {
type: AllowedAssetType;
savedObjects: AssetSavedObject[];
}
export const AssetsAccordion: FunctionComponent<Props> = ({ savedObjects, type }) => {
const { http } = useStartServices();
return (
<EuiAccordion
buttonContent={
<EuiFlexGroup justifyContent="center" alignItems="center" gutterSize="s" responsive={false}>
<EuiFlexItem grow={false}>
<EuiText size="m">
<h3>{AssetTitleMap[type]}</h3>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiNotificationBadge color="subdued" size="m">
<h3>{savedObjects.length}</h3>
</EuiNotificationBadge>
</EuiFlexItem>
</EuiFlexGroup>
}
id={type}
>
<>
<EuiSpacer size="m" />
<EuiSplitPanel.Outer hasBorder hasShadow={false}>
{savedObjects.map(({ id, attributes: { title, description } }, idx) => {
const pathToObjectInApp = getHrefToObjectInKibanaApp({
http,
id,
type,
});
return (
<>
<EuiSplitPanel.Inner grow={false} key={idx}>
<EuiText size="m">
<p>
{pathToObjectInApp ? (
<EuiLink href={pathToObjectInApp}>{title}</EuiLink>
) : (
title
)}
</p>
</EuiText>
{description && (
<>
<EuiSpacer size="s" />
<EuiText size="s" color="subdued">
<p>{description}</p>
</EuiText>
</>
)}
</EuiSplitPanel.Inner>
{idx + 1 < savedObjects.length && <EuiHorizontalRule margin="none" />}
</>
);
})}
</EuiSplitPanel.Outer>
</>
</EuiAccordion>
);
};

View file

@ -0,0 +1,16 @@
/*
* 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 { KibanaAssetType } from '../../../../../types';
import type { AllowedAssetTypes } from './types';
export const allowedAssetTypes: AllowedAssetTypes = [
KibanaAssetType.dashboard,
KibanaAssetType.search,
KibanaAssetType.visualization,
];

View file

@ -0,0 +1,7 @@
/*
* 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 { AssetsPage } from './assets';

View file

@ -0,0 +1,20 @@
/*
* 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 { SimpleSavedObject } from 'src/core/public';
import type { KibanaAssetType } from '../../../../../types';
export type AssetSavedObject = SimpleSavedObject<{ title: string; description?: string }>;
export type AllowedAssetTypes = [
KibanaAssetType.dashboard,
KibanaAssetType.search,
KibanaAssetType.visualization
];
export type AllowedAssetType = AllowedAssetTypes[number];

View file

@ -56,6 +56,7 @@ import { WithHeaderLayout } from '../../../../layouts';
import { RELEASE_BADGE_DESCRIPTION, RELEASE_BADGE_LABEL } from '../../components/release_badge';
import { IntegrationAgentPolicyCount, UpdateIcon, IconPanel, LoadingIconPanel } from './components';
import { AssetsPage } from './assets';
import { OverviewPage } from './overview';
import { PackagePoliciesPage } from './policies';
import { SettingsPage } from './settings';
@ -408,6 +409,24 @@ export function Detail() {
});
}
if (packageInstallStatus === InstallStatus.installed && packageInfo.assets) {
tabs.push({
id: 'assets',
name: (
<FormattedMessage
id="xpack.fleet.epm.packageDetailsNav.packageAssetsLinkText"
defaultMessage="Assets"
/>
),
isSelected: panel === 'assets',
'data-test-subj': `tab-assets`,
href: getHref('integration_details_assets', {
pkgkey: packageInfoKey,
...(integration ? { integration } : {}),
}),
});
}
tabs.push({
id: 'settings',
name: (
@ -476,6 +495,9 @@ export function Detail() {
<Route path={INTEGRATIONS_ROUTING_PATHS.integration_details_settings}>
<SettingsPage packageInfo={packageInfo} />
</Route>
<Route path={INTEGRATIONS_ROUTING_PATHS.integration_details_assets}>
<AssetsPage packageInfo={packageInfo} />
</Route>
<Route path={INTEGRATIONS_ROUTING_PATHS.integration_details_policies}>
<PackagePoliciesPage name={packageInfo.name} version={packageInfo.version} />
</Route>

View file

@ -4,9 +4,9 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { stringify, parse } from 'query-string';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { Redirect } from 'react-router-dom';
import { Redirect, useLocation, useHistory } from 'react-router-dom';
import type { CriteriaWithPagination, EuiTableFieldDataColumnType } from '@elastic/eui';
import {
EuiButtonIcon,
@ -15,6 +15,9 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiToolTip,
EuiText,
EuiButton,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedRelative, FormattedMessage } from '@kbn/i18n/react';
@ -66,8 +69,16 @@ interface PackagePoliciesPanelProps {
version: string;
}
export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps) => {
const [flyoutOpenForPolicyId, setFlyoutOpenForPolicyId] = useState<string | null>(null);
const { getPath } = useLink();
const { search } = useLocation();
const history = useHistory();
const queryParams = useMemo(() => new URLSearchParams(search), [search]);
const agentPolicyIdFromParams = useMemo(() => queryParams.get('addAgentToPolicyId'), [
queryParams,
]);
const [flyoutOpenForPolicyId, setFlyoutOpenForPolicyId] = useState<string | null>(
agentPolicyIdFromParams
);
const { getPath, getHref } = useLink();
const getPackageInstallStatus = useGetPackageInstallStatus();
const packageInstallStatus = getPackageInstallStatus(name);
const { pagination, pageSizeOptions, setPagination } = useUrlPagination();
@ -87,6 +98,36 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps
[setPagination]
);
const renderViewDataStepContent = useCallback(
() => (
<>
<EuiText>
<FormattedMessage
id="xpack.fleet.agentEnrollment.viewDataDescription"
defaultMessage="After your agent starts, you can view your data in Kibana by using the integration's installed assets. {pleaseNote}: it may take a few minutes for the initial data to arrive."
values={{
pleaseNote: (
<strong>
{i18n.translate(
'xpack.fleet.epm.agentEnrollment.viewDataDescription.pleaseNoteLabel',
{ defaultMessage: 'Please note' }
)}
</strong>
),
}}
/>
</EuiText>
<EuiSpacer size="l" />
<EuiButton href={getHref('integration_details_assets', { pkgkey: `${name}-${version}` })}>
{i18n.translate('xpack.fleet.epm.agentEnrollment.viewDataAssetsLabel', {
defaultMessage: 'View assets',
})}
</EuiButton>
</>
),
[name, version, getHref]
);
const columns: Array<EuiTableFieldDataColumnType<PackagePolicyAndAgentPolicy>> = useMemo(
() => [
{
@ -186,12 +227,16 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps
align: 'right',
render({ agentPolicy, packagePolicy }) {
return (
<PackagePolicyActionsMenu agentPolicy={agentPolicy} packagePolicy={packagePolicy} />
<PackagePolicyActionsMenu
agentPolicy={agentPolicy}
packagePolicy={packagePolicy}
viewDataStepContent={renderViewDataStepContent()}
/>
);
},
},
],
[]
[renderViewDataStepContent]
);
const noItemsMessage = useMemo(() => {
@ -236,14 +281,18 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps
/>
</EuiFlexItem>
</EuiFlexGroup>
{flyoutOpenForPolicyId && (
{flyoutOpenForPolicyId && !isLoading && (
<AgentEnrollmentFlyout
onClose={() => setFlyoutOpenForPolicyId(null)}
agentPolicies={
data?.items
.filter(({ agentPolicy }) => agentPolicy.id === flyoutOpenForPolicyId)
.map(({ agentPolicy }) => agentPolicy) ?? []
onClose={() => {
setFlyoutOpenForPolicyId(null);
const { addAgentToPolicyId, ...rest } = parse(search);
history.replace({ search: stringify(rest) });
}}
agentPolicy={
data?.items.find(({ agentPolicy }) => agentPolicy.id === flyoutOpenForPolicyId)
?.agentPolicy
}
viewDataStepContent={renderViewDataStepContent()}
/>
)}
</AgentPolicyRefreshContext.Provider>

View file

@ -37,6 +37,7 @@ jest.mock('./steps', () => {
...module,
AgentPolicySelectionStep: jest.fn(),
AgentEnrollmentKeySelectionStep: jest.fn(),
ViewDataStep: jest.fn(),
};
});

View file

@ -21,7 +21,7 @@ import { FleetStatusProvider, ConfigContext } from '../../hooks';
import { useFleetServerInstructions } from '../../applications/fleet/sections/agents/agent_requirements_page';
import { AgentEnrollmentKeySelectionStep, AgentPolicySelectionStep } from './steps';
import { AgentEnrollmentKeySelectionStep, AgentPolicySelectionStep, ViewDataStep } from './steps';
import type { Props } from '.';
import { AgentEnrollmentFlyout } from '.';
@ -128,6 +128,46 @@ describe('<AgentEnrollmentFlyout />', () => {
expect(AgentEnrollmentKeySelectionStep).toHaveBeenCalled();
});
});
describe('"View data" extension point', () => {
it('calls the "View data" step when UI extension is provided', async () => {
jest.clearAllMocks();
await act(async () => {
testBed = await setup({
agentPolicies: [],
onClose: jest.fn(),
viewDataStepContent: <div />,
});
testBed.component.update();
});
const { exists, actions } = testBed;
expect(exists('agentEnrollmentFlyout')).toBe(true);
expect(ViewDataStep).toHaveBeenCalled();
jest.clearAllMocks();
actions.goToStandaloneTab();
expect(ViewDataStep).not.toHaveBeenCalled();
});
it('does not call the "View data" step when UI extension is not provided', async () => {
jest.clearAllMocks();
await act(async () => {
testBed = await setup({
agentPolicies: [],
onClose: jest.fn(),
viewDataStepContent: undefined,
});
testBed.component.update();
});
const { exists, actions } = testBed;
expect(exists('agentEnrollmentFlyout')).toBe(true);
expect(ViewDataStep).not.toHaveBeenCalled();
jest.clearAllMocks();
actions.goToStandaloneTab();
expect(ViewDataStep).not.toHaveBeenCalled();
});
});
});
describe('standalone instructions', () => {

View file

@ -42,6 +42,7 @@ export const AgentEnrollmentFlyout: React.FunctionComponent<Props> = ({
onClose,
agentPolicy,
agentPolicies,
viewDataStepContent,
}) => {
const [mode, setMode] = useState<'managed' | 'standalone'>('managed');
@ -109,9 +110,17 @@ export const AgentEnrollmentFlyout: React.FunctionComponent<Props> = ({
}
>
{fleetServerHosts.length === 0 && mode === 'managed' ? null : mode === 'managed' ? (
<ManagedInstructions agentPolicy={agentPolicy} agentPolicies={agentPolicies} />
<ManagedInstructions
agentPolicy={agentPolicy}
agentPolicies={agentPolicies}
viewDataStepContent={viewDataStepContent}
/>
) : (
<StandaloneInstructions agentPolicy={agentPolicy} agentPolicies={agentPolicies} />
<StandaloneInstructions
agentPolicy={agentPolicy}
agentPolicies={agentPolicies}
viewDataStepContent={viewDataStepContent}
/>
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>

View file

@ -21,7 +21,12 @@ import {
useFleetServerInstructions,
} from '../../applications/fleet/sections/agents/agent_requirements_page';
import { DownloadStep, AgentPolicySelectionStep, AgentEnrollmentKeySelectionStep } from './steps';
import {
DownloadStep,
AgentPolicySelectionStep,
AgentEnrollmentKeySelectionStep,
ViewDataStep,
} from './steps';
import type { BaseProps } from './types';
type Props = BaseProps;
@ -53,83 +58,91 @@ const FleetServerMissingRequirements = () => {
return <FleetServerRequirementPage />;
};
export const ManagedInstructions = React.memo<Props>(({ agentPolicy, agentPolicies }) => {
const fleetStatus = useFleetStatus();
export const ManagedInstructions = React.memo<Props>(
({ agentPolicy, agentPolicies, viewDataStepContent }) => {
const fleetStatus = useFleetStatus();
const [selectedAPIKeyId, setSelectedAPIKeyId] = useState<string | undefined>();
const [isFleetServerPolicySelected, setIsFleetServerPolicySelected] = useState<boolean>(false);
const [selectedAPIKeyId, setSelectedAPIKeyId] = useState<string | undefined>();
const [isFleetServerPolicySelected, setIsFleetServerPolicySelected] = useState<boolean>(false);
const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId);
const settings = useGetSettings();
const fleetServerInstructions = useFleetServerInstructions(apiKey?.data?.item?.policy_id);
const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId);
const settings = useGetSettings();
const fleetServerInstructions = useFleetServerInstructions(apiKey?.data?.item?.policy_id);
const steps = useMemo(() => {
const {
serviceToken,
getServiceToken,
isLoadingServiceToken,
installCommand,
platform,
setPlatform,
} = fleetServerInstructions;
const fleetServerHosts = settings.data?.item?.fleet_server_hosts || [];
const baseSteps: EuiContainedStepProps[] = [
DownloadStep(),
!agentPolicy
? AgentPolicySelectionStep({
agentPolicies,
setSelectedAPIKeyId,
setIsFleetServerPolicySelected,
})
: AgentEnrollmentKeySelectionStep({ agentPolicy, setSelectedAPIKeyId }),
];
if (isFleetServerPolicySelected) {
baseSteps.push(
...[
ServiceTokenStep({ serviceToken, getServiceToken, isLoadingServiceToken }),
FleetServerCommandStep({ serviceToken, installCommand, platform, setPlatform }),
]
);
} else {
baseSteps.push({
title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', {
defaultMessage: 'Enroll and start the Elastic Agent',
}),
children: selectedAPIKeyId && apiKey.data && (
<ManualInstructions apiKey={apiKey.data.item} fleetServerHosts={fleetServerHosts} />
),
});
}
return baseSteps;
}, [
agentPolicy,
agentPolicies,
selectedAPIKeyId,
apiKey.data,
isFleetServerPolicySelected,
settings.data?.item?.fleet_server_hosts,
fleetServerInstructions,
]);
const steps = useMemo(() => {
const {
serviceToken,
getServiceToken,
isLoadingServiceToken,
installCommand,
platform,
setPlatform,
} = fleetServerInstructions;
const fleetServerHosts = settings.data?.item?.fleet_server_hosts || [];
const baseSteps: EuiContainedStepProps[] = [
DownloadStep(),
!agentPolicy
? AgentPolicySelectionStep({
agentPolicies,
setSelectedAPIKeyId,
setIsFleetServerPolicySelected,
})
: AgentEnrollmentKeySelectionStep({ agentPolicy, setSelectedAPIKeyId }),
];
if (isFleetServerPolicySelected) {
baseSteps.push(
...[
ServiceTokenStep({ serviceToken, getServiceToken, isLoadingServiceToken }),
FleetServerCommandStep({ serviceToken, installCommand, platform, setPlatform }),
]
);
} else {
baseSteps.push({
title: i18n.translate('xpack.fleet.agentEnrollment.stepEnrollAndRunAgentTitle', {
defaultMessage: 'Enroll and start the Elastic Agent',
}),
children: selectedAPIKeyId && apiKey.data && (
<ManualInstructions apiKey={apiKey.data.item} fleetServerHosts={fleetServerHosts} />
),
});
}
return (
<>
{fleetStatus.isReady ? (
<>
<EuiText>
<FormattedMessage
id="xpack.fleet.agentEnrollment.managedDescription"
defaultMessage="Enroll an Elastic Agent in Fleet to automatically deploy updates and centrally manage the agent."
/>
</EuiText>
<EuiSpacer size="l" />
<EuiSteps steps={steps} />
</>
) : fleetStatus.missingRequirements?.length === 1 &&
fleetStatus.missingRequirements[0] === 'fleet_server' ? (
<FleetServerMissingRequirements />
) : (
<DefaultMissingRequirements />
)}
</>
);
});
if (viewDataStepContent) {
baseSteps.push(ViewDataStep(viewDataStepContent));
}
return baseSteps;
}, [
agentPolicy,
agentPolicies,
selectedAPIKeyId,
apiKey.data,
isFleetServerPolicySelected,
settings.data?.item?.fleet_server_hosts,
fleetServerInstructions,
viewDataStepContent,
]);
return (
<>
{fleetStatus.isReady ? (
<>
<EuiText>
<FormattedMessage
id="xpack.fleet.agentEnrollment.managedDescription"
defaultMessage="Enroll an Elastic Agent in Fleet to automatically deploy updates and centrally manage the agent."
/>
</EuiText>
<EuiSpacer size="l" />
<EuiSteps steps={steps} />
</>
) : fleetStatus.missingRequirements?.length === 1 &&
fleetStatus.missingRequirements[0] === 'fleet_server' ? (
<FleetServerMissingRequirements />
) : (
<DefaultMissingRequirements />
)}
</>
);
}
);

View file

@ -138,3 +138,16 @@ export const AgentEnrollmentKeySelectionStep = ({
),
};
};
/**
* Send users to assets installed by the package in Kibana so they can
* view their data.
*/
export const ViewDataStep = (content: JSX.Element) => {
return {
title: i18n.translate('xpack.fleet.agentEnrollment.stepViewDataTitle', {
defaultMessage: 'View your data',
}),
children: content,
};
};

View file

@ -9,12 +9,20 @@ import type { AgentPolicy } from '../../types';
export interface BaseProps {
/**
* The user selected policy to be used
* The user selected policy to be used. If this value is `undefined` a value must be provided for `agentPolicies`.
*/
agentPolicy?: AgentPolicy;
/**
* A selection of policies for the user to choose from, will be ignored if `agentPolicy` has been provided
* A selection of policies for the user to choose from, will be ignored if `agentPolicy` has been provided.
*
* If this value is `undefined` a value must be provided for `agentPolicy`.
*/
agentPolicies?: AgentPolicy[];
/**
* There is a step in the agent enrollment process that allows users to see the data from an integration represented in the UI
* in some way. This is an area for consumers to render a button and text explaining how data can be viewed.
*/
viewDataStepContent?: JSX.Element;
}

View file

@ -21,7 +21,8 @@ import { PackagePolicyDeleteProvider } from './package_policy_delete_provider';
export const PackagePolicyActionsMenu: React.FunctionComponent<{
agentPolicy: AgentPolicy;
packagePolicy: PackagePolicy;
}> = ({ agentPolicy, packagePolicy }) => {
viewDataStepContent?: JSX.Element;
}> = ({ agentPolicy, packagePolicy, viewDataStepContent }) => {
const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false);
const { getHref } = useLink();
const hasWriteCapabilities = useCapabilities().write;
@ -103,7 +104,11 @@ export const PackagePolicyActionsMenu: React.FunctionComponent<{
<>
{isEnrollmentFlyoutOpen && (
<EuiPortal>
<AgentEnrollmentFlyout agentPolicies={[agentPolicy]} onClose={onEnrollmentFlyoutClose} />
<AgentEnrollmentFlyout
agentPolicy={agentPolicy}
viewDataStepContent={viewDataStepContent}
onClose={onEnrollmentFlyoutClose}
/>
</EuiPortal>
)}
<ContextMenuActions items={menuItems} />

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { stringify } from 'query-string';
export type StaticPage =
| 'base'
| 'overview'
@ -19,6 +21,7 @@ export type StaticPage =
export type DynamicPage =
| 'integration_details_overview'
| 'integration_details_policies'
| 'integration_details_assets'
| 'integration_details_settings'
| 'integration_details_custom'
| 'integration_policy_edit'
@ -66,6 +69,7 @@ export const INTEGRATIONS_ROUTING_PATHS = {
integration_details: '/detail/:pkgkey/:panel?',
integration_details_overview: '/detail/:pkgkey/overview',
integration_details_policies: '/detail/:pkgkey/policies',
integration_details_assets: '/detail/:pkgkey/assets',
integration_details_settings: '/detail/:pkgkey/settings',
integration_details_custom: '/detail/:pkgkey/custom',
integration_policy_edit: '/edit-integration/:packagePolicyId',
@ -86,9 +90,13 @@ export const pagePathGetters: {
INTEGRATIONS_BASE_PATH,
`/detail/${pkgkey}/overview${integration ? `?integration=${integration}` : ''}`,
],
integration_details_policies: ({ pkgkey, integration }) => [
integration_details_policies: ({ pkgkey, integration, addAgentToPolicyId }) => {
const qs = stringify({ integration, addAgentToPolicyId });
return [INTEGRATIONS_BASE_PATH, `/detail/${pkgkey}/policies${qs ? `?${qs}` : ''}`];
},
integration_details_assets: ({ pkgkey, integration }) => [
INTEGRATIONS_BASE_PATH,
`/detail/${pkgkey}/policies${integration ? `?integration=${integration}` : ''}`,
`/detail/${pkgkey}/assets${integration ? `?integration=${integration}` : ''}`,
],
integration_details_settings: ({ pkgkey, integration }) => [
INTEGRATIONS_BASE_PATH,
@ -108,6 +116,7 @@ export const pagePathGetters: {
FLEET_BASE_PATH,
`/policies/${policyId}${tabId ? `/${tabId}` : ''}`,
],
// TODO: This might need to be removed because we do not have a way to pick an integration in line anymore
add_integration_from_policy: ({ policyId }) => [
FLEET_BASE_PATH,
`/policies/${policyId}/add-integration`,

View file

@ -11,7 +11,7 @@ export { useConfig, ConfigContext } from './use_config';
export { useKibanaVersion, KibanaVersionContext } from './use_kibana_version';
export { licenseService, useLicense } from './use_license';
export { useLink } from './use_link';
export { useKibanaLink } from './use_kibana_link';
export { useKibanaLink, getHrefToObjectInKibanaApp } from './use_kibana_link';
export { usePackageIconType, UsePackageIconType } from './use_package_icon_type';
export { usePagination, Pagination, PAGE_SIZE_OPTIONS } from './use_pagination';
export { useUrlPagination } from './use_url_pagination';

View file

@ -4,12 +4,62 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { HttpStart } from 'src/core/public';
import { KibanaAssetType } from '../types';
import { useStartServices } from './';
const KIBANA_BASE_PATH = '/app/kibana';
const getKibanaLink = (http: HttpStart, path: string) => {
return http.basePath.prepend(`${KIBANA_BASE_PATH}#${path}`);
};
/**
* TODO: This is a temporary solution for getting links to various assets. It is very risky because:
*
* 1. The plugin might not exist/be enabled
* 2. URLs and paths might not always be supported
*
* We should migrate to using the new URL service locators.
*
* @deprecated {@link Locators} from the new URL service need to be used instead.
*/
export const getHrefToObjectInKibanaApp = ({
type,
id,
http,
}: {
type: KibanaAssetType;
id: string;
http: HttpStart;
}): undefined | string => {
let kibanaAppPath: undefined | string;
switch (type) {
case KibanaAssetType.dashboard:
kibanaAppPath = `/dashboard/${id}`;
break;
case KibanaAssetType.search:
kibanaAppPath = `/discover/${id}`;
break;
case KibanaAssetType.visualization:
kibanaAppPath = `/visualize/edit/${id}`;
break;
default:
return undefined;
}
return getKibanaLink(http, kibanaAppPath);
};
/**
* TODO: This functionality needs to be replaced with use of the new URL service locators
*
* @deprecated {@link Locators} from the new URL service need to be used instead.
*/
export function useKibanaLink(path: string = '/') {
const core = useStartServices();
return core.http.basePath.prepend(`${KIBANA_BASE_PATH}#${path}`);
const { http } = useStartServices();
return getKibanaLink(http, path);
}