mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Fleet] Add callout for granular privileges callout (#184026)
This commit is contained in:
parent
4785ae6b12
commit
42afaa38a2
11 changed files with 159 additions and 87 deletions
|
@ -858,6 +858,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D
|
|||
remoteESOoutput: `${FLEET_DOCS}monitor-elastic-agent.html#external-elasticsearch-monitoring`,
|
||||
performancePresets: `${FLEET_DOCS}es-output-settings.html#es-output-settings-performance-tuning-settings`,
|
||||
scalingKubernetesResourcesAndLimits: `${FLEET_DOCS}scaling-on-kubernetes.html#_specifying_resources_and_limits_in_agent_manifests`,
|
||||
roleAndPrivileges: `${FLEET_DOCS}fleet-roles-and-privileges.html`,
|
||||
},
|
||||
ecs: {
|
||||
guide: `${ELASTIC_WEBSITE_URL}guide/en/ecs/${ECS_VERSION}/index.html`,
|
||||
|
|
|
@ -551,6 +551,7 @@ export interface DocLinks {
|
|||
remoteESOoutput: string;
|
||||
performancePresets: string;
|
||||
scalingKubernetesResourcesAndLimits: string;
|
||||
roleAndPrivileges: string;
|
||||
}>;
|
||||
readonly ecs: {
|
||||
readonly guide: string;
|
||||
|
|
|
@ -7,9 +7,11 @@
|
|||
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { EuiCallOut, EuiLink } from '@elastic/eui';
|
||||
|
||||
import { useDismissableTour } from '../../../../hooks/use_dismissable_tour';
|
||||
import type { Section } from '../../sections';
|
||||
import { useLink, useConfig, useAuthz } from '../../hooks';
|
||||
import { useLink, useConfig, useAuthz, useStartServices } from '../../hooks';
|
||||
import { WithHeaderLayout } from '../../../../layouts';
|
||||
|
||||
import { ExperimentalFeaturesService } from '../../services';
|
||||
|
@ -30,7 +32,10 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({
|
|||
const { getHref } = useLink();
|
||||
const { agents } = useConfig();
|
||||
const authz = useAuthz();
|
||||
const { agentTamperProtectionEnabled } = ExperimentalFeaturesService.get();
|
||||
const { agentTamperProtectionEnabled, subfeaturePrivileges } = ExperimentalFeaturesService.get();
|
||||
|
||||
const { docLinks } = useStartServices();
|
||||
const granularPrivilegesCallout = useDismissableTour('GRANULAR_PRIVILEGES');
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
|
@ -109,8 +114,37 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({
|
|||
.map(({ isHidden, ...tab }) => tab);
|
||||
|
||||
return (
|
||||
<WithHeaderLayout leftColumn={<DefaultPageTitle />} rightColumn={rightColumn} tabs={tabs}>
|
||||
{children}
|
||||
</WithHeaderLayout>
|
||||
<>
|
||||
{!subfeaturePrivileges || !authz.fleet.all || granularPrivilegesCallout.isHidden ? null : (
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
iconType="cheer"
|
||||
onDismiss={granularPrivilegesCallout.dismiss}
|
||||
title={
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.granularPrivileges.callOutContent"
|
||||
defaultMessage="We've added new privileges that let you define more granularly who can view or edit Fleet agents, policies, and settings. {learnMoreLink}"
|
||||
values={{
|
||||
learnMoreLink: (
|
||||
<EuiLink href={docLinks.links.fleet.roleAndPrivileges} external>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="xpack.fleet.granularPrivileges.learnMoreLinkText"
|
||||
defaultMessage="Learn more."
|
||||
/>
|
||||
</strong>
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<WithHeaderLayout leftColumn={<DefaultPageTitle />} rightColumn={rightColumn} tabs={tabs}>
|
||||
{children}
|
||||
</WithHeaderLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,7 +13,7 @@ import { AgentStatusFilter } from './agent_status_filter';
|
|||
const PARTIAL_TOUR_TEXT = 'Some agents have become inactive and have been hidden';
|
||||
const mockStorage: Record<any, any> = {};
|
||||
|
||||
jest.mock('../../../../../../hooks', () => {
|
||||
jest.mock('../../../../../../hooks/use_core', () => {
|
||||
return {
|
||||
useStartServices: jest.fn(() => ({
|
||||
uiSettings: {
|
||||
|
@ -81,14 +81,13 @@ describe('AgentStatusFilter', () => {
|
|||
it('Should not show tour if previously been dismissed', async () => {
|
||||
mockStorage['fleet.inactiveAgentsTour'] = { active: false };
|
||||
|
||||
const { getByText } = renderComponent({
|
||||
const { queryByText } = renderComponent({
|
||||
selectedStatus: [],
|
||||
onSelectedStatusChange: () => {},
|
||||
totalInactiveAgents: 999,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
expect(getByText(PARTIAL_TOUR_TEXT, { exact: false })).not.toBeVisible();
|
||||
expect(queryByText(PARTIAL_TOUR_TEXT, { exact: false })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -16,12 +16,15 @@ import {
|
|||
EuiTourStep,
|
||||
EuiLink,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { useInactiveAgentsCalloutHasBeenDismissed, useLastSeenInactiveAgentsCount } from '../hooks';
|
||||
import { useDismissableTour } from '../../../../../../hooks/use_dismissable_tour';
|
||||
|
||||
import { useLastSeenInactiveAgentsCount } from '../hooks';
|
||||
|
||||
const statusFilters = [
|
||||
{
|
||||
|
@ -122,8 +125,8 @@ export const AgentStatusFilter: React.FC<{
|
|||
} = props;
|
||||
const [lastSeenInactiveAgentsCount, setLastSeenInactiveAgentsCount] =
|
||||
useLastSeenInactiveAgentsCount();
|
||||
const [inactiveAgentsCalloutHasBeenDismissed, setInactiveAgentsCalloutHasBeenDismissed] =
|
||||
useInactiveAgentsCalloutHasBeenDismissed();
|
||||
const { isHidden: inactiveAgentsCalloutHasBeenDismissed, dismiss: dismissInactiveAgentsCallout } =
|
||||
useDismissableTour('INACTIVE_AGENTS');
|
||||
|
||||
const newlyInactiveAgentsCount = useMemo(() => {
|
||||
const newVal = totalInactiveAgents - lastSeenInactiveAgentsCount;
|
||||
|
@ -159,7 +162,7 @@ export const AgentStatusFilter: React.FC<{
|
|||
|
||||
const updateIsStatusFilterOpen = (isOpen: boolean) => {
|
||||
if (isOpen && newlyInactiveAgentsCount > 0 && !inactiveAgentsCalloutHasBeenDismissed) {
|
||||
setInactiveAgentsCalloutHasBeenDismissed(true);
|
||||
dismissInactiveAgentsCallout();
|
||||
}
|
||||
|
||||
setIsStatusFilterOpen(isOpen);
|
||||
|
@ -206,7 +209,7 @@ export const AgentStatusFilter: React.FC<{
|
|||
return (
|
||||
<InactiveAgentsTourStep
|
||||
isOpen={newlyInactiveAgentsCount > 0 && !inactiveAgentsCalloutHasBeenDismissed}
|
||||
setInactiveAgentsCalloutHasBeenDismissed={setInactiveAgentsCalloutHasBeenDismissed}
|
||||
setInactiveAgentsCalloutHasBeenDismissed={dismissInactiveAgentsCallout}
|
||||
>
|
||||
<EuiPopover
|
||||
ownFocus
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
export { useUpdateTags } from './use_update_tags';
|
||||
export { useActionStatus } from './use_action_status';
|
||||
export { useLastSeenInactiveAgentsCount } from './use_last_seen_inactive_agents_count';
|
||||
export { useInactiveAgentsCalloutHasBeenDismissed } from './use_inactive_agents_callout_has_been_dismissed';
|
||||
export { useAgentSoftLimit } from './use_agent_soft_limit';
|
||||
export { useMissingEncryptionKeyCallout } from './use_missing_encryption_key_callout';
|
||||
export { useFetchAgentsData } from './use_fetch_agents_data';
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
* 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 { useState, useEffect } from 'react';
|
||||
|
||||
import type { TOUR_STORAGE_CONFIG } from '../../../../../../constants';
|
||||
import { TOUR_STORAGE_KEYS } from '../../../../../../constants';
|
||||
import { useStartServices } from '../../../../../../hooks';
|
||||
|
||||
export const useInactiveAgentsCalloutHasBeenDismissed = (): [boolean, (val: boolean) => void] => {
|
||||
const { uiSettings, storage } = useStartServices();
|
||||
|
||||
const [inactiveAgentsCalloutHasBeenDismissed, setInactiveAgentsCalloutHasBeenDismissed] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setInactiveAgentsCalloutHasBeenDismissed(
|
||||
uiSettings.get('hideAnnouncements', false) ||
|
||||
(
|
||||
storage.get(TOUR_STORAGE_KEYS.INACTIVE_AGENTS) as
|
||||
| TOUR_STORAGE_CONFIG['INACTIVE_AGENTS']
|
||||
| undefined
|
||||
)?.active === false
|
||||
);
|
||||
}, [storage, uiSettings]);
|
||||
|
||||
const updateInactiveAgentsCalloutHasBeenDismissed = (newValue: boolean) => {
|
||||
storage.set(TOUR_STORAGE_KEYS.INACTIVE_AGENTS, {
|
||||
active: false,
|
||||
} as TOUR_STORAGE_CONFIG['INACTIVE_AGENTS']);
|
||||
setInactiveAgentsCalloutHasBeenDismissed(newValue);
|
||||
};
|
||||
|
||||
return [inactiveAgentsCalloutHasBeenDismissed, updateInactiveAgentsCalloutHasBeenDismissed];
|
||||
};
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
|
@ -15,9 +15,8 @@ import { useTheme } from 'styled-components';
|
|||
|
||||
import type { EuiTheme } from '@kbn/kibana-react-plugin/common';
|
||||
|
||||
import type { TOUR_STORAGE_CONFIG } from '../constants';
|
||||
import { TOUR_STORAGE_KEYS } from '../constants';
|
||||
import { useStartServices } from '../hooks';
|
||||
import { useDismissableTour } from '../hooks/use_dismissable_tour';
|
||||
|
||||
export const AddAgentHelpPopover = ({
|
||||
button,
|
||||
|
@ -30,31 +29,16 @@ export const AddAgentHelpPopover = ({
|
|||
offset?: number;
|
||||
closePopover: NoArgCallback<void>;
|
||||
}) => {
|
||||
const { docLinks, uiSettings, storage } = useStartServices();
|
||||
const { docLinks } = useStartServices();
|
||||
const theme = useTheme() as EuiTheme;
|
||||
const optionalProps: { offset?: number } = {};
|
||||
const hideAddAgentTour: boolean = useMemo(() => {
|
||||
return (
|
||||
uiSettings.get('hideAnnouncements', false) ||
|
||||
(
|
||||
storage.get(TOUR_STORAGE_KEYS.ADD_AGENT_POPOVER) as
|
||||
| TOUR_STORAGE_CONFIG['ADD_AGENT_POPOVER']
|
||||
| undefined
|
||||
)?.active === false
|
||||
);
|
||||
}, [storage, uiSettings]);
|
||||
|
||||
const onFinish = () => {
|
||||
storage.set(TOUR_STORAGE_KEYS.ADD_AGENT_POPOVER, {
|
||||
active: false,
|
||||
} as TOUR_STORAGE_CONFIG['ADD_AGENT_POPOVER']);
|
||||
};
|
||||
const addAgentTour = useDismissableTour('ADD_AGENT_POPOVER');
|
||||
|
||||
if (offset !== undefined) {
|
||||
optionalProps.offset = offset; // offset being present in props sets it to 0 so only add if specified
|
||||
}
|
||||
|
||||
return hideAddAgentTour ? (
|
||||
return addAgentTour.isHidden ? (
|
||||
button
|
||||
) : (
|
||||
<EuiTourStep
|
||||
|
@ -81,7 +65,7 @@ export const AddAgentHelpPopover = ({
|
|||
zIndex={theme.eui.euiZLevel1 - 1} // put popover behind any modals that happen to be open
|
||||
isStepOpen={isOpen}
|
||||
minWidth={300}
|
||||
onFinish={onFinish}
|
||||
onFinish={addAgentTour.dismiss}
|
||||
step={1}
|
||||
stepsTotal={1}
|
||||
title={
|
||||
|
@ -96,7 +80,7 @@ export const AddAgentHelpPopover = ({
|
|||
footerAction={
|
||||
<EuiLink
|
||||
onClick={() => {
|
||||
onFinish();
|
||||
addAgentTour.dismiss();
|
||||
closePopover();
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -48,16 +48,15 @@ export const TOUR_STORAGE_KEYS = {
|
|||
AGENT_ACTIVITY: 'fleet.agentActivityTour',
|
||||
ADD_AGENT_POPOVER: 'fleet.addAgentPopoverTour',
|
||||
INACTIVE_AGENTS: 'fleet.inactiveAgentsTour',
|
||||
GRANULAR_PRIVILEGES: 'fleet.granularPrivileges',
|
||||
};
|
||||
|
||||
export interface TOUR_STORAGE_CONFIG {
|
||||
AGENT_ACTIVITY: {
|
||||
active: boolean;
|
||||
};
|
||||
ADD_AGENT_POPOVER: {
|
||||
active: boolean;
|
||||
};
|
||||
INACTIVE_AGENTS: {
|
||||
active: boolean;
|
||||
};
|
||||
export interface TourConfig {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export type TourKey = keyof typeof TOUR_STORAGE_KEYS;
|
||||
|
||||
export type TOUR_STORAGE_CONFIG = {
|
||||
[k in TourKey]: TourConfig;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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 { renderHook, act } from '@testing-library/react-hooks';
|
||||
|
||||
import { TOUR_STORAGE_KEYS } from '../constants';
|
||||
import { createStartServices } from '../mock';
|
||||
|
||||
import { useStartServices } from './use_core';
|
||||
import { useDismissableTour } from './use_dismissable_tour';
|
||||
|
||||
jest.mock('./use_core');
|
||||
|
||||
describe('useDismissableTour', () => {
|
||||
let startServices: ReturnType<typeof createStartServices>;
|
||||
beforeEach(() => {
|
||||
startServices = createStartServices('/app/fleet');
|
||||
jest.mocked(useStartServices).mockReturnValue(startServices);
|
||||
});
|
||||
it('should display the tour by default', () => {
|
||||
const res = renderHook(() => useDismissableTour('GRANULAR_PRIVILEGES'));
|
||||
|
||||
expect(res.result.current.isHidden).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow to dismiss the tour', () => {
|
||||
const res = renderHook(() => useDismissableTour('GRANULAR_PRIVILEGES'));
|
||||
expect(res.result.current.isHidden).toBe(false);
|
||||
|
||||
act(() => res.result.current.dismiss());
|
||||
|
||||
expect(res.result.current.isHidden).toBe(true);
|
||||
const storageValue = startServices.storage.get(TOUR_STORAGE_KEYS.GRANULAR_PRIVILEGES);
|
||||
expect(storageValue).toBeDefined();
|
||||
expect(storageValue.active).toEqual(false);
|
||||
});
|
||||
|
||||
it('should not display the tour if hideAnnouncements:true', () => {
|
||||
jest.mocked(startServices.uiSettings.get).mockReturnValue(true);
|
||||
const res = renderHook(() => useDismissableTour('GRANULAR_PRIVILEGES'));
|
||||
|
||||
expect(res.result.current.isHidden).toBe(true);
|
||||
});
|
||||
|
||||
it('should not display the tour if it is already dismissed', () => {
|
||||
startServices.storage.set(TOUR_STORAGE_KEYS.GRANULAR_PRIVILEGES, {
|
||||
active: false,
|
||||
});
|
||||
const res = renderHook(() => useDismissableTour('GRANULAR_PRIVILEGES'));
|
||||
|
||||
expect(res.result.current.isHidden).toBe(true);
|
||||
});
|
||||
});
|
34
x-pack/plugins/fleet/public/hooks/use_dismissable_tour.ts
Normal file
34
x-pack/plugins/fleet/public/hooks/use_dismissable_tour.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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 TourKey, type TourConfig, TOUR_STORAGE_KEYS } from '../constants';
|
||||
|
||||
import { useStartServices } from './use_core';
|
||||
|
||||
export function useDismissableTour(tourKey: TourKey) {
|
||||
const { storage, uiSettings } = useStartServices();
|
||||
|
||||
const defaultValue =
|
||||
uiSettings.get('hideAnnouncements', false) ||
|
||||
(storage.get(TOUR_STORAGE_KEYS[tourKey]) as TourConfig | undefined)?.active === false;
|
||||
|
||||
const [isHidden, setIsHidden] = React.useState(defaultValue);
|
||||
|
||||
const dismiss = React.useCallback(() => {
|
||||
setIsHidden(true);
|
||||
storage.set(TOUR_STORAGE_KEYS[tourKey], {
|
||||
active: false,
|
||||
});
|
||||
}, [tourKey, storage]);
|
||||
|
||||
return {
|
||||
isHidden,
|
||||
dismiss,
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue