Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Endpoint] Add link to Logs UI to the Host Details view #62852

Merged
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useNavigateToAppEventHandler } from '../hooks/use_navigate_to_app_event
export type LinkToAppProps = EuiLinkProps & {
/** the app id - normally the value of the `id` in that plugin's `kibana.json` */
appId: string;
/** Any app specic path (route) */
/** Any app specific path (route) */
appPath?: string;
appState?: any;
onClick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResul
/**
* Mocked app root context renderer
*/
interface AppContextTestRender {
export interface AppContextTestRender {
store: ReturnType<typeof appStoreFactory>;
history: ReturnType<typeof createMemoryHistory>;
coreStart: ReturnType<typeof coreMock.createStart>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { HostResultList, HostStatus } from '../../../../../common/types';
import { HostInfo, HostResultList, HostStatus } from '../../../../../common/types';
import { EndpointDocGenerator } from '../../../../../common/generate_data';

export const mockHostResultList: (options?: {
Expand Down Expand Up @@ -40,3 +40,14 @@ export const mockHostResultList: (options?: {
};
return mock;
};

/**
* returns a mocked API response for retrieving a single host metadata
*/
export const mockHostDetailsApiResult = (): HostInfo => {
const generator = new EndpointDocGenerator('seed');
return {
metadata: generator.generateHostMetadata(),
host_status: HostStatus.ERROR,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { useHostListSelector } from './hooks';
import { urlFromQueryParams } from './url_from_query_params';
import { FormattedDateAndTime } from '../formatted_date_time';
import { uiQueryParams, detailsData, detailsError } from './../../store/hosts/selectors';
import { LinkToApp } from '../../components/link_to_app';

const HostIds = styled(EuiListGroupItem)`
margin-top: 0;
Expand All @@ -37,6 +38,7 @@ const HostIds = styled(EuiListGroupItem)`
`;

const HostDetails = memo(({ details }: { details: HostMetadata }) => {
const { appId, appPath, url } = useHostLogsUrl(details.host.id);
const detailsResultsUpper = useMemo(() => {
return [
{
Expand Down Expand Up @@ -113,6 +115,20 @@ const HostDetails = memo(({ details }: { details: HostMetadata }) => {
listItems={detailsResultsLower}
data-test-subj="hostDetailsLowerList"
/>
<EuiHorizontalRule margin="s" />
<p>
<LinkToApp
appId={appId}
appPath={appPath}
href={url}
data-test-subj="hostDetailsLinkToLogs"
>
<FormattedMessage
id="xpack.endpoint.host.details.linkToLogsTitle"
defaultMessage="Endpoint Logs"
/>
</LinkToApp>
</p>
</>
);
});
Expand Down Expand Up @@ -170,3 +186,15 @@ export const HostDetailsFlyout = () => {
</EuiFlyout>
);
};

const useHostLogsUrl = (hostId: string): { url: string; appId: string; appPath: string } => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created separate hook case we would like to later use it in other areas of the UI. Would be easier to extract out.

const { services } = useKibana();
return useMemo(() => {
const appPath = `/stream?logFilter=(expression:'host.id:${hostId}',kind:kuery)`;
return {
url: `${services.application.getUrlForApp('logs')}${appPath}`,
appId: 'logs',
appPath,
};
}, [hostId, services.application]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,26 @@

import React from 'react';
import * as reactTestingLibrary from '@testing-library/react';
import { Provider } from 'react-redux';
import { I18nProvider } from '@kbn/i18n/react';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why remove the i18n bits? ...cause its a test file and it isn't being tested / translated yet?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @EricDavisX . It's removed explicitly from here, but still used via createAppRootMockRender().
In my prior PR I pushed through a custom test renderer that add a wrapper around the component being tested that includes all of the context providers that the app uses at runtime.
See:

  • x-pack/plugins/endpoint/public/applications/endpoint/mocks/app_context_render.tsx:44, And
  • x-pack/plugins/endpoint/public/applications/endpoint/view/app_root_provider.tsx:23

import { EuiThemeProvider } from '../../../../../../../legacy/common/eui_styled_components';
import { appStoreFactory } from '../../store';
import { RouteCapture } from '../route_capture';
import { createMemoryHistory, MemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { fireEvent } from '@testing-library/react';
import { AppAction } from '../../types';
import { HostList } from './index';
import { mockHostResultList } from '../../store/hosts/mock_host_result_list';
import {
mockHostDetailsApiResult,
mockHostResultList,
} from '../../store/hosts/mock_host_result_list';
import { AppContextTestRender, createAppRootMockRenderer } from '../../mocks';
import { HostInfo } from '../../../../../common/types';

describe('when on the hosts page', () => {
let render: () => reactTestingLibrary.RenderResult;
let history: MemoryHistory<never>;
let store: ReturnType<typeof appStoreFactory>;
let render: () => ReturnType<AppContextTestRender['render']>;
let history: AppContextTestRender['history'];
let store: AppContextTestRender['store'];
let coreStart: AppContextTestRender['coreStart'];

beforeEach(async () => {
history = createMemoryHistory<never>();
store = appStoreFactory();
render = () => {
return reactTestingLibrary.render(
<Provider store={store}>
<I18nProvider>
<EuiThemeProvider>
<Router history={history}>
<RouteCapture>
<HostList />
</RouteCapture>
</Router>
</EuiThemeProvider>
</I18nProvider>
</Provider>
);
};
const mockedContext = createAppRootMockRenderer();
({ history, store, coreStart } = mockedContext);
render = () => mockedContext.render(<HostList />);
});

it('should show a table', async () => {
Expand All @@ -56,7 +42,7 @@ describe('when on the hosts page', () => {
expect(e).not.toBeNull();
});
});
describe('when data loads', () => {
describe('when list data loads', () => {
beforeEach(() => {
reactTestingLibrary.act(() => {
const action: AppAction = {
Expand All @@ -76,6 +62,16 @@ describe('when on the hosts page', () => {
describe('when the user clicks the hostname in the table', () => {
let renderResult: reactTestingLibrary.RenderResult;
beforeEach(async () => {
const hostDetailsApiResponse = mockHostDetailsApiResult();

coreStart.http.get.mockReturnValue(Promise.resolve(hostDetailsApiResponse));
reactTestingLibrary.act(() => {
store.dispatch({
type: 'serverReturnedHostDetails',
payload: hostDetailsApiResponse,
});
});

renderResult = render();
const detailsLink = await renderResult.findByTestId('hostnameCellLink');
if (detailsLink) {
Expand All @@ -93,19 +89,71 @@ describe('when on the hosts page', () => {
});

describe('when there is a selected host in the url', () => {
let hostDetails: HostInfo;
beforeEach(() => {
const {
host_status,
metadata: { host, ...details },
} = mockHostDetailsApiResult();
hostDetails = {
host_status,
metadata: {
...details,
host: {
...host,
id: '1',
},
},
};

coreStart.http.get.mockReturnValue(Promise.resolve(hostDetails));
coreStart.application.getUrlForApp.mockReturnValue('/app/logs');

reactTestingLibrary.act(() => {
history.push({
...history.location,
search: '?selected_host=1',
});
});
reactTestingLibrary.act(() => {
store.dispatch({
type: 'serverReturnedHostDetails',
payload: hostDetails,
});
});
});
afterEach(() => {
jest.clearAllMocks();
});

it('should show the flyout', () => {
const renderResult = render();
return renderResult.findByTestId('hostDetailsFlyout').then(flyout => {
expect(flyout).not.toBeNull();
});
});
it('should include the link to logs', async () => {
const renderResult = render();
const linkToLogs = await renderResult.findByTestId('hostDetailsLinkToLogs');
expect(linkToLogs).not.toBeNull();
expect(linkToLogs.textContent).toEqual('Endpoint Logs');
expect(linkToLogs.getAttribute('href')).toEqual(
"/app/logs/stream?logFilter=(expression:'host.id:1',kind:kuery)"
);
});
describe('when link to logs is clicked', () => {
beforeEach(async () => {
const renderResult = render();
const linkToLogs = await renderResult.findByTestId('hostDetailsLinkToLogs');
reactTestingLibrary.act(() => {
fireEvent.click(linkToLogs);
});
});

it('should navigate to logs without full page refresh', async () => {
// FIXME: this is not working :(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI - I will remove this in next PR.

expect(coreStart.application.navigateToApp.mock.calls).toHaveLength(1);
});
});
});
});