[App Search] API Logs: Add ApiLogsTable and NewApiEventsPrompt components (#96008)

* Set up getStatusColor util for upcoming EuiHealth components

* Add ApiLogsTable component

* Add NewApiEventsPrompt component

* Update ApiLogs view with new components

+ add EuiPageContent wrapper (missed this originally)

* Update EngineOverview with new components

* PR feedback: Comments + mock FormattedRelative

* Fix type error
This commit is contained in:
Constance 2021-04-02 05:49:12 -07:00 committed by GitHub
parent bffded3ca7
commit 7db838e0b8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 422 additions and 22 deletions

View file

@ -17,6 +17,8 @@ import { EuiPageHeader } from '@elastic/eui';
import { Loading } from '../../../shared/loading';
import { LogRetentionCallout, LogRetentionTooltip } from '../log_retention';
import { ApiLogsTable, NewApiEventsPrompt } from './components';
import { ApiLogs } from './';
describe('ApiLogs', () => {
@ -41,7 +43,8 @@ describe('ApiLogs', () => {
it('renders', () => {
expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('API Logs');
// TODO: Check for ApiLogsTable + NewApiEventsPrompt when those get added
expect(wrapper.find(ApiLogsTable)).toHaveLength(1);
expect(wrapper.find(NewApiEventsPrompt)).toHaveLength(1);
expect(wrapper.find(LogRetentionCallout).prop('type')).toEqual('api');
expect(wrapper.find(LogRetentionTooltip).prop('type')).toEqual('api');

View file

@ -9,7 +9,15 @@ import React, { useEffect } from 'react';
import { useValues, useActions } from 'kea';
import { EuiPageHeader, EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import {
EuiPageHeader,
EuiTitle,
EuiPageContent,
EuiPageContentBody,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
} from '@elastic/eui';
import { FlashMessages } from '../../../shared/flash_messages';
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
@ -18,6 +26,7 @@ import { Loading } from '../../../shared/loading';
import { LogRetentionCallout, LogRetentionTooltip, LogRetentionOptions } from '../log_retention';
import { ApiLogsTable, NewApiEventsPrompt } from './components';
import { API_LOGS_TITLE, RECENT_API_EVENTS } from './constants';
import { ApiLogsLogic } from './';
@ -47,19 +56,27 @@ export const ApiLogs: React.FC<Props> = ({ engineBreadcrumb }) => {
<FlashMessages />
<LogRetentionCallout type={LogRetentionOptions.API} />
<EuiFlexGroup gutterSize="m" alignItems="center" responsive={false} wrap>
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h2>{RECENT_API_EVENTS}</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LogRetentionTooltip type={LogRetentionOptions.API} />
</EuiFlexItem>
<EuiFlexItem grow={false}>{/* TODO: NewApiEventsPrompt */}</EuiFlexItem>
</EuiFlexGroup>
<EuiPageContent hasBorder>
<EuiPageContentBody>
<EuiFlexGroup gutterSize="m" alignItems="center" responsive={false} wrap>
<EuiFlexItem grow={false}>
<EuiTitle size="s">
<h2>{RECENT_API_EVENTS}</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<LogRetentionTooltip type={LogRetentionOptions.API} />
</EuiFlexItem>
<EuiFlexItem />
<EuiFlexItem grow={false}>
<NewApiEventsPrompt />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
{/* TODO: ApiLogsTable */}
<ApiLogsTable hasPagination />
</EuiPageContentBody>
</EuiPageContent>
</>
);
};

View file

@ -0,0 +1,5 @@
.apiLogDetailButton {
// More closely mimics the regular line height of an EuiLink /
// compresses table rows back to the standard height
height: $euiSizeL !important;
}

View file

@ -0,0 +1,113 @@
/*
* 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 { setMockValues, setMockActions, mountWithIntl } from '../../../../__mocks__';
// NOTE: We're mocking FormattedRelative here because it (currently) has
// console warn issues, and it allows us to skip mocking dates
jest.mock('@kbn/i18n/react', () => ({
...(jest.requireActual('@kbn/i18n/react') as object),
FormattedRelative: jest.fn(() => '20 hours ago'),
}));
import React from 'react';
import { shallow } from 'enzyme';
import { EuiBasicTable, EuiBadge, EuiHealth, EuiButtonEmpty, EuiEmptyPrompt } from '@elastic/eui';
import { DEFAULT_META } from '../../../../shared/constants';
import { ApiLogsTable } from './';
describe('ApiLogsTable', () => {
const apiLogs = [
{
timestamp: '1970-01-01T00:00:00.000Z',
status: 404,
http_method: 'GET',
full_request_path: '/api/as/v1/test',
},
{
timestamp: '1970-01-01T00:00:00.000Z',
status: 500,
http_method: 'DELETE',
full_request_path: '/api/as/v1/test',
},
{
timestamp: '1970-01-01T00:00:00.000Z',
status: 200,
http_method: 'POST',
full_request_path: '/api/as/v1/engines/some-engine/search',
},
];
const values = {
dataLoading: false,
apiLogs,
meta: DEFAULT_META,
};
const actions = {
onPaginate: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions(actions);
});
it('renders', () => {
const wrapper = mountWithIntl(<ApiLogsTable />);
const tableContent = wrapper.find(EuiBasicTable).text();
expect(tableContent).toContain('Method');
expect(tableContent).toContain('GET');
expect(tableContent).toContain('DELETE');
expect(tableContent).toContain('POST');
expect(wrapper.find(EuiBadge)).toHaveLength(3);
expect(tableContent).toContain('Time');
expect(tableContent).toContain('20 hours ago');
expect(tableContent).toContain('Endpoint');
expect(tableContent).toContain('/api/as/v1/test');
expect(tableContent).toContain('/api/as/v1/engines/some-engine/search');
expect(tableContent).toContain('Status');
expect(tableContent).toContain('404');
expect(tableContent).toContain('500');
expect(tableContent).toContain('200');
expect(wrapper.find(EuiHealth)).toHaveLength(3);
expect(wrapper.find(EuiButtonEmpty)).toHaveLength(3);
wrapper.find('[data-test-subj="ApiLogsTableDetailsButton"]').first().simulate('click');
// TODO: API log details flyout
});
it('renders an empty prompt if no items are passed', () => {
setMockValues({ ...values, apiLogs: [] });
const wrapper = mountWithIntl(<ApiLogsTable />);
const promptContent = wrapper.find(EuiEmptyPrompt).text();
expect(promptContent).toContain('No recent logs');
});
describe('hasPagination', () => {
it('does not render with pagination by default', () => {
const wrapper = shallow(<ApiLogsTable />);
expect(wrapper.find(EuiBasicTable).prop('pagination')).toBeFalsy();
});
it('renders pagination if hasPagination is true', () => {
const wrapper = shallow(<ApiLogsTable hasPagination />);
expect(wrapper.find(EuiBasicTable).prop('pagination')).toBeTruthy();
});
});
});

View file

@ -0,0 +1,132 @@
/*
* 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 { useValues, useActions } from 'kea';
import {
EuiBasicTable,
EuiBasicTableColumn,
EuiBadge,
EuiHealth,
EuiButtonEmpty,
EuiEmptyPrompt,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedRelative } from '@kbn/i18n/react';
import { convertMetaToPagination, handlePageChange } from '../../../../shared/table_pagination';
import { ApiLogsLogic } from '../index';
import { ApiLog } from '../types';
import { getStatusColor } from '../utils';
import './api_logs_table.scss';
interface Props {
hasPagination?: boolean;
}
export const ApiLogsTable: React.FC<Props> = ({ hasPagination }) => {
const { dataLoading, apiLogs, meta } = useValues(ApiLogsLogic);
const { onPaginate } = useActions(ApiLogsLogic);
const columns: Array<EuiBasicTableColumn<ApiLog>> = [
{
field: 'http_method',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.methodTableHeading', {
defaultMessage: 'Method',
}),
width: '100px',
render: (method: string) => <EuiBadge color="primary">{method}</EuiBadge>,
},
{
field: 'timestamp',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.timeTableHeading', {
defaultMessage: 'Time',
}),
width: '20%',
render: (dateString: string) => <FormattedRelative value={new Date(dateString)} />,
},
{
field: 'full_request_path',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.endpointTableHeading', {
defaultMessage: 'Endpoint',
}),
width: '50%',
truncateText: true,
mobileOptions: {
// @ts-ignore - EUI's typing is incorrect here
width: '100%',
},
},
{
field: 'status',
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.statusTableHeading', {
defaultMessage: 'Status',
}),
dataType: 'number',
width: '100px',
render: (status: number) => <EuiHealth color={getStatusColor(status)}>{status}</EuiHealth>,
},
{
width: '100px',
align: 'right',
render: (apiLog: ApiLog) => (
<EuiButtonEmpty
size="s"
className="apiLogDetailButton"
data-test-subj="ApiLogsTableDetailsButton"
// TODO: flyout onclick
>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.detailsButtonLabel', {
defaultMessage: 'Details',
})}
</EuiButtonEmpty>
),
},
];
const paginationProps = hasPagination
? {
pagination: {
...convertMetaToPagination(meta),
hidePerPageOptions: true,
},
onChange: handlePageChange(onPaginate),
}
: {};
return (
<EuiBasicTable
columns={columns}
items={apiLogs}
responsive
loading={dataLoading}
noItemsMessage={
<EuiEmptyPrompt
iconType="clock"
title={
<h3>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyTitle', {
defaultMessage: 'No recent logs',
})}
</h3>
}
body={
<p>
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyDescription', {
defaultMessage: "Check back after you've performed some API calls.",
})}
</p>
}
/>
}
{...paginationProps}
/>
);
};

View file

@ -0,0 +1,9 @@
/*
* 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 { ApiLogsTable } from './api_logs_table';
export { NewApiEventsPrompt } from './new_api_events_prompt';

View file

@ -0,0 +1,6 @@
.newApiEventsPrompt {
padding: $euiSizeXS;
padding-left: $euiSizeS;
display: flex;
align-items: center;
}

View file

@ -0,0 +1,51 @@
/*
* 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 { setMockValues, setMockActions } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiButtonEmpty } from '@elastic/eui';
import { NewApiEventsPrompt } from './';
describe('NewApiEventsPrompt', () => {
const values = {
hasNewData: true,
};
const actions = {
onUserRefresh: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
setMockValues(values);
setMockActions(actions);
});
it('renders', () => {
const wrapper = shallow(<NewApiEventsPrompt />);
expect(wrapper.isEmptyRender()).toBe(false);
});
it('does not render if no new data has been polled', () => {
setMockValues({ ...values, hasNewData: false });
const wrapper = shallow(<NewApiEventsPrompt />);
expect(wrapper.isEmptyRender()).toBe(true);
});
it('calls onUserRefresh', () => {
const wrapper = shallow(<NewApiEventsPrompt />);
wrapper.find(EuiButtonEmpty).simulate('click');
expect(actions.onUserRefresh).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,35 @@
/*
* 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 { useValues, useActions } from 'kea';
import { EuiPanel, EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ApiLogsLogic } from '../';
import './new_api_events_prompt.scss';
export const NewApiEventsPrompt: React.FC = () => {
const { hasNewData } = useValues(ApiLogsLogic);
const { onUserRefresh } = useActions(ApiLogsLogic);
return hasNewData ? (
<EuiPanel color="subdued" hasShadow={false} paddingSize="s" className="newApiEventsPrompt">
{i18n.translate('xpack.enterpriseSearch.appSearch.engines.apiLogs.newEventsMessage', {
defaultMessage: 'New events have been logged.',
})}
<EuiButtonEmpty iconType="refresh" size="xs" onClick={onUserRefresh}>
{i18n.translate('xpack.enterpriseSearch.appSearch.engines.apiLogs.newEventsButtonLabel', {
defaultMessage: 'Refresh',
})}
</EuiButtonEmpty>
</EuiPanel>
) : null;
};

View file

@ -6,5 +6,6 @@
*/
export { API_LOGS_TITLE } from './constants';
export { ApiLogsTable, NewApiEventsPrompt } from './components';
export { ApiLogs } from './api_logs';
export { ApiLogsLogic } from './api_logs_logic';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { getDateString } from './utils';
import { getDateString, getStatusColor } from './utils';
describe('getDateString', () => {
const mockDate = jest
@ -23,3 +23,12 @@ describe('getDateString', () => {
afterAll(() => mockDate.mockRestore());
});
describe('getStatusColor', () => {
it('returns a valid EUI badge color based on the status code', () => {
expect(getStatusColor(200)).toEqual('secondary');
expect(getStatusColor(301)).toEqual('primary');
expect(getStatusColor(404)).toEqual('warning');
expect(getStatusColor(503)).toEqual('danger');
});
});

View file

@ -10,3 +10,12 @@ export const getDateString = (offSetDays?: number) => {
if (offSetDays) date.setDate(date.getDate() + offSetDays);
return date.toISOString();
};
export const getStatusColor = (status: number) => {
let color = '';
if (status >= 100 && status < 300) color = 'secondary';
if (status >= 300 && status < 400) color = 'primary';
if (status >= 400 && status < 500) color = 'warning';
if (status >= 500) color = 'danger';
return color;
};

View file

@ -13,6 +13,8 @@ import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { ApiLogsTable } from '../../api_logs';
import { RecentApiLogs } from './recent_api_logs';
describe('RecentApiLogs', () => {
@ -31,7 +33,7 @@ describe('RecentApiLogs', () => {
it('renders the recent API logs table', () => {
expect(wrapper.prop('title')).toEqual(<h2>Recent API events</h2>);
// TODO: expect(wrapper.find(ApiLogsTable)).toHaveLength(1)
expect(wrapper.find(ApiLogsTable)).toHaveLength(1);
});
it('calls fetchApiLogs on page load and starts pollForApiLogs', () => {

View file

@ -9,9 +9,11 @@ import React, { useEffect } from 'react';
import { useActions } from 'kea';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiButtonEmptyTo } from '../../../../shared/react_router_helpers';
import { ENGINE_API_LOGS_PATH } from '../../../routes';
import { ApiLogsLogic } from '../../api_logs';
import { ApiLogsLogic, ApiLogsTable, NewApiEventsPrompt } from '../../api_logs';
import { RECENT_API_EVENTS } from '../../api_logs/constants';
import { DataPanel } from '../../data_panel';
import { generateEnginePath } from '../../engine';
@ -30,14 +32,20 @@ export const RecentApiLogs: React.FC = () => {
<DataPanel
title={<h2>{RECENT_API_EVENTS}</h2>}
action={
<EuiButtonEmptyTo iconType="eye" to={generateEnginePath(ENGINE_API_LOGS_PATH)} size="s">
{VIEW_API_LOGS}
</EuiButtonEmptyTo>
<EuiFlexGroup alignItems="center" justifyContent="flexEnd" responsive={false} wrap>
<EuiFlexItem grow={false}>
<NewApiEventsPrompt />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmptyTo iconType="eye" to={generateEnginePath(ENGINE_API_LOGS_PATH)} size="s">
{VIEW_API_LOGS}
</EuiButtonEmptyTo>
</EuiFlexItem>
</EuiFlexGroup>
}
hasBorder
>
TODO: API Logs Table
{/* <ApiLogsTable hidePagination={true} /> */}
<ApiLogsTable />
</DataPanel>
);
};