[App Search] Add AnalyticsChart component to EnginesOverview (#87752)

* Set up Kibana charts plugin dependency

- required for using shared colors/themes/etc.
- see https://github.com/elastic/kibana/tree/master/src/plugins/charts

* Add reusable AnalyticsChart component

+ util for converting data from our server API to data that Elastic Charts can use

* Update EngineOverview to use AnalyticsChart

+ remove now-unnecessary endDate value (we don't really need it just IMO)

* [PR feedback] Return type

* [Self feedback] naming - remove pluralization
This commit is contained in:
Constance 2021-01-12 11:43:01 -08:00 committed by GitHub
parent a1c2422261
commit 04739d851c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 229 additions and 34 deletions

View file

@ -2,7 +2,7 @@
"id": "enterpriseSearch", "id": "enterpriseSearch",
"version": "kibana", "version": "kibana",
"kibanaVersion": "kibana", "kibanaVersion": "kibana",
"requiredPlugins": ["features", "licensing"], "requiredPlugins": ["features", "licensing", "charts"],
"configPath": ["enterpriseSearch"], "configPath": ["enterpriseSearch"],
"optionalPlugins": ["usageCollection", "security", "home", "spaces", "cloud"], "optionalPlugins": ["usageCollection", "security", "home", "spaces", "cloud"],
"server": true, "server": true,

View file

@ -4,15 +4,17 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { chartPluginMock } from '../../../../../../src/plugins/charts/public/mocks';
import { mockHistory } from './'; import { mockHistory } from './';
export const mockKibanaValues = { export const mockKibanaValues = {
config: { host: 'http://localhost:3002' }, config: { host: 'http://localhost:3002' },
history: mockHistory, charts: chartPluginMock.createStartContract(),
cloud: { cloud: {
isCloudEnabled: false, isCloudEnabled: false,
cloudDeploymentUrl: 'https://cloud.elastic.co/deployments/some-id', cloudDeploymentUrl: 'https://cloud.elastic.co/deployments/some-id',
}, },
history: mockHistory,
navigateToUrl: jest.fn(), navigateToUrl: jest.fn(),
setBreadcrumbs: jest.fn(), setBreadcrumbs: jest.fn(),
setDocTitle: jest.fn(), setDocTitle: jest.fn(),

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { mockKibanaValues } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { Chart, Settings, LineSeries, Axis } from '@elastic/charts';
import { AnalyticsChart } from './';
describe('AnalyticsChart', () => {
const MOCK_DATA = [
{ x: '1970-01-01', y: 0 },
{ x: '1970-01-02', y: 1 },
{ x: '1970-01-03', y: 5 },
{ x: '1970-01-04', y: 50 },
{ x: '1970-01-05', y: 25 },
];
beforeAll(() => {
jest.clearAllMocks();
});
it('renders an Elastic line chart', () => {
const wrapper = shallow(
<AnalyticsChart height={300} lines={[{ id: 'test', data: MOCK_DATA }]} />
);
expect(wrapper.find(Chart).prop('size')).toEqual({ height: 300 });
expect(wrapper.find(Axis)).toHaveLength(2);
expect(mockKibanaValues.charts.theme.useChartsTheme).toHaveBeenCalled();
expect(mockKibanaValues.charts.theme.useChartsBaseTheme).toHaveBeenCalled();
expect(wrapper.find(LineSeries)).toHaveLength(1);
expect(wrapper.find(LineSeries).prop('id')).toEqual('test');
expect(wrapper.find(LineSeries).prop('data')).toEqual(MOCK_DATA);
});
it('renders multiple lines', () => {
const wrapper = shallow(
<AnalyticsChart
lines={[
{ id: 'line 1', data: MOCK_DATA },
{ id: 'line 2', data: MOCK_DATA },
{ id: 'line 3', data: MOCK_DATA },
]}
/>
);
expect(wrapper.find(LineSeries)).toHaveLength(3);
});
it('formats x-axis dates correctly', () => {
const wrapper = shallow(<AnalyticsChart lines={[{ id: 'test', data: MOCK_DATA }]} />);
const dateFormatter: Function = wrapper.find('#bottom-axis').prop('tickFormat');
expect(dateFormatter('1970-02-28')).toEqual('2/28');
});
it('formats tooltip dates correctly', () => {
const wrapper = shallow(<AnalyticsChart lines={[{ id: 'test', data: MOCK_DATA }]} />);
const dateFormatter: Function = (wrapper.find(Settings).prop('tooltip') as any).headerFormatter;
expect(dateFormatter({ value: '1970-12-03' })).toEqual('December 3, 1970');
});
});

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { useValues } from 'kea';
import moment from 'moment';
import { Chart, Settings, LineSeries, CurveType, Axis } from '@elastic/charts';
import { KibanaLogic } from '../../../../shared/kibana';
import { X_AXIS_DATE_FORMAT, TOOLTIP_DATE_FORMAT } from '../constants';
interface ChartPoint {
x: string; // Date string
y: number; // # of clicks, queries, etc.
}
export type ChartData = ChartPoint[];
interface Props {
height?: number;
lines: Array<{
id: string;
data: ChartData;
}>;
}
export const AnalyticsChart: React.FC<Props> = ({ height = 300, lines }) => {
const { charts } = useValues(KibanaLogic);
return (
<Chart size={{ height }}>
<Settings
theme={charts.theme.useChartsTheme()}
baseTheme={charts.theme.useChartsBaseTheme()}
tooltip={{
headerFormatter: (tooltip) => moment(tooltip.value).format(TOOLTIP_DATE_FORMAT),
}}
/>
{lines.map(({ id, data }) => (
<LineSeries
key={id}
id={id}
data={data}
xAccessor={'x'}
yAccessors={['y']}
curve={CurveType.CURVE_MONOTONE_X}
/>
))}
<Axis
id="bottom-axis"
position="bottom"
tickFormat={(d) => moment(d).format(X_AXIS_DATE_FORMAT)}
showGridLines
/>
<Axis id="left-axis" position="left" ticks={4} showGridLines />
</Chart>
);
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
export { AnalyticsChart } from './analytics_chart';

View file

@ -30,3 +30,8 @@ export const TOTAL_CLICKS = i18n.translate(
'xpack.enterpriseSearch.appSearch.engine.analytics.totalClicks', 'xpack.enterpriseSearch.appSearch.engine.analytics.totalClicks',
{ defaultMessage: 'Total clicks' } { defaultMessage: 'Total clicks' }
); );
// Moment date format conversions
export const SERVER_DATE_FORMAT = 'YYYY-MM-DD';
export const TOOLTIP_DATE_FORMAT = 'MMMM D, YYYY';
export const X_AXIS_DATE_FORMAT = 'M/D';

View file

@ -5,3 +5,5 @@
*/ */
export { ANALYTICS_TITLE } from './constants'; export { ANALYTICS_TITLE } from './constants';
export { AnalyticsChart } from './components';
export { convertToChartData } from './utils';

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { convertToChartData } from './utils';
describe('convertToChartData', () => {
it('converts server-side analytics data into an array of objects that Elastic Charts can consume', () => {
expect(
convertToChartData({
startDate: '1970-01-01',
data: [0, 1, 5, 50, 25],
})
).toEqual([
{ x: '1970-01-01', y: 0 },
{ x: '1970-01-02', y: 1 },
{ x: '1970-01-03', y: 5 },
{ x: '1970-01-04', y: 50 },
{ x: '1970-01-05', y: 25 },
]);
});
});

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import moment from 'moment';
import { SERVER_DATE_FORMAT } from './constants';
import { ChartData } from './components/analytics_chart';
interface ConvertToChartData {
data: number[];
startDate: string;
}
export const convertToChartData = ({ data, startDate }: ConvertToChartData): ChartData => {
const date = moment(startDate, SERVER_DATE_FORMAT);
return data.map((y, index) => ({
x: moment(date).add(index, 'days').format(SERVER_DATE_FORMAT),
y,
}));
};

View file

@ -10,6 +10,7 @@ import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme'; import { shallow, ShallowWrapper } from 'enzyme';
import { EuiButtonTo } from '../../../../shared/react_router_helpers'; import { EuiButtonTo } from '../../../../shared/react_router_helpers';
import { AnalyticsChart } from '../../analytics';
import { TotalCharts } from './total_charts'; import { TotalCharts } from './total_charts';
@ -21,7 +22,6 @@ describe('TotalCharts', () => {
setMockValues({ setMockValues({
engineName: 'some-engine', engineName: 'some-engine',
startDate: '1970-01-01', startDate: '1970-01-01',
endDate: '1970-01-08',
queriesPerDay: [0, 1, 2, 3, 5, 10, 50], queriesPerDay: [0, 1, 2, 3, 5, 10, 50],
operationsPerDay: [0, 0, 0, 0, 0, 0, 0], operationsPerDay: [0, 0, 0, 0, 0, 0, 0],
}); });
@ -33,7 +33,7 @@ describe('TotalCharts', () => {
expect(chart.find('h2').text()).toEqual('Total queries'); expect(chart.find('h2').text()).toEqual('Total queries');
expect(chart.find(EuiButtonTo).prop('to')).toEqual('/engines/some-engine/analytics'); expect(chart.find(EuiButtonTo).prop('to')).toEqual('/engines/some-engine/analytics');
// TODO: find chart component expect(chart.find(AnalyticsChart)).toHaveLength(1);
}); });
it('renders the total API operations chart', () => { it('renders the total API operations chart', () => {
@ -41,6 +41,6 @@ describe('TotalCharts', () => {
expect(chart.find('h2').text()).toEqual('Total API operations'); expect(chart.find('h2').text()).toEqual('Total API operations');
expect(chart.find(EuiButtonTo).prop('to')).toEqual('/engines/some-engine/api-logs'); expect(chart.find(EuiButtonTo).prop('to')).toEqual('/engines/some-engine/api-logs');
// TODO: find chart component expect(chart.find(AnalyticsChart)).toHaveLength(1);
}); });
}); });

View file

@ -24,6 +24,8 @@ import { ENGINE_ANALYTICS_PATH, ENGINE_API_LOGS_PATH, getEngineRoute } from '../
import { TOTAL_QUERIES, TOTAL_API_OPERATIONS } from '../../analytics/constants'; import { TOTAL_QUERIES, TOTAL_API_OPERATIONS } from '../../analytics/constants';
import { VIEW_ANALYTICS, VIEW_API_LOGS, LAST_7_DAYS } from '../constants'; import { VIEW_ANALYTICS, VIEW_API_LOGS, LAST_7_DAYS } from '../constants';
import { AnalyticsChart, convertToChartData } from '../../analytics';
import { EngineLogic } from '../../engine'; import { EngineLogic } from '../../engine';
import { EngineOverviewLogic } from '../'; import { EngineOverviewLogic } from '../';
@ -31,12 +33,7 @@ export const TotalCharts: React.FC = () => {
const { engineName } = useValues(EngineLogic); const { engineName } = useValues(EngineLogic);
const engineRoute = getEngineRoute(engineName); const engineRoute = getEngineRoute(engineName);
const { const { startDate, queriesPerDay, operationsPerDay } = useValues(EngineOverviewLogic);
// startDate,
// endDate,
// queriesPerDay,
// operationsPerDay,
} = useValues(EngineOverviewLogic);
return ( return (
<EuiFlexGroup> <EuiFlexGroup>
@ -58,12 +55,14 @@ export const TotalCharts: React.FC = () => {
</EuiPageContentHeaderSection> </EuiPageContentHeaderSection>
</EuiPageContentHeader> </EuiPageContentHeader>
<EuiPageContentBody> <EuiPageContentBody>
TODO: Analytics chart <AnalyticsChart
{/* <EngineAnalytics lines={[
data={[queriesPerDay]} {
startDate={new Date(startDate)} id: TOTAL_QUERIES,
endDate={new Date(endDate)} data: convertToChartData({ startDate, data: queriesPerDay }),
/> */} },
]}
/>
</EuiPageContentBody> </EuiPageContentBody>
</EuiPageContent> </EuiPageContent>
</EuiFlexItem> </EuiFlexItem>
@ -85,12 +84,14 @@ export const TotalCharts: React.FC = () => {
</EuiPageContentHeaderSection> </EuiPageContentHeaderSection>
</EuiPageContentHeader> </EuiPageContentHeader>
<EuiPageContentBody> <EuiPageContentBody>
TODO: API Logs chart <AnalyticsChart
{/* <EngineAnalytics lines={[
data={[operationsPerDay]} {
startDate={new Date(startDate)} id: TOTAL_API_OPERATIONS,
endDate={new Date(endDate)} data: convertToChartData({ startDate, data: operationsPerDay }),
/> */} },
]}
/>
</EuiPageContentBody> </EuiPageContentBody>
</EuiPageContent> </EuiPageContent>
</EuiFlexItem> </EuiFlexItem>

View file

@ -28,7 +28,6 @@ describe('EngineOverviewLogic', () => {
apiLogsUnavailable: true, apiLogsUnavailable: true,
documentCount: 10, documentCount: 10,
startDate: '1970-01-30', startDate: '1970-01-30',
endDate: '1970-01-31',
operationsPerDay: [0, 0, 0, 0, 0, 0, 0], operationsPerDay: [0, 0, 0, 0, 0, 0, 0],
queriesPerDay: [0, 0, 0, 0, 0, 25, 50], queriesPerDay: [0, 0, 0, 0, 0, 25, 50],
totalClicks: 50, totalClicks: 50,
@ -40,7 +39,6 @@ describe('EngineOverviewLogic', () => {
apiLogsUnavailable: false, apiLogsUnavailable: false,
documentCount: 0, documentCount: 0,
startDate: '', startDate: '',
endDate: '',
operationsPerDay: [], operationsPerDay: [],
queriesPerDay: [], queriesPerDay: [],
totalClicks: 0, totalClicks: 0,

View file

@ -16,7 +16,6 @@ interface EngineOverviewApiData {
apiLogsUnavailable: boolean; apiLogsUnavailable: boolean;
documentCount: number; documentCount: number;
startDate: string; startDate: string;
endDate: string;
operationsPerDay: number[]; operationsPerDay: number[];
queriesPerDay: number[]; queriesPerDay: number[];
totalClicks: number; totalClicks: number;
@ -61,12 +60,6 @@ export const EngineOverviewLogic = kea<MakeLogicType<EngineOverviewValues, Engin
setPolledData: (_, { startDate }) => startDate, setPolledData: (_, { startDate }) => startDate,
}, },
], ],
endDate: [
'',
{
setPolledData: (_, { endDate }) => endDate,
},
],
queriesPerDay: [ queriesPerDay: [
[], [],
{ {

View file

@ -9,6 +9,7 @@ import { getContext } from 'kea';
import { coreMock } from 'src/core/public/mocks'; import { coreMock } from 'src/core/public/mocks';
import { licensingMock } from '../../../licensing/public/mocks'; import { licensingMock } from '../../../licensing/public/mocks';
import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks';
import { renderApp, renderHeaderActions } from './'; import { renderApp, renderHeaderActions } from './';
import { EnterpriseSearch } from './enterprise_search'; import { EnterpriseSearch } from './enterprise_search';
@ -20,7 +21,10 @@ describe('renderApp', () => {
const kibanaDeps = { const kibanaDeps = {
params: coreMock.createAppMountParamters(), params: coreMock.createAppMountParamters(),
core: coreMock.createStart(), core: coreMock.createStart(),
plugins: { licensing: licensingMock.createStart() }, plugins: {
licensing: licensingMock.createStart(),
charts: chartPluginMock.createStartContract(),
},
} as any; } as any;
const pluginData = { const pluginData = {
config: {}, config: {},

View file

@ -41,6 +41,7 @@ export const renderApp = (
const unmountKibanaLogic = mountKibanaLogic({ const unmountKibanaLogic = mountKibanaLogic({
config, config,
charts: plugins.charts,
cloud: plugins.cloud || {}, cloud: plugins.cloud || {},
history: params.history, history: params.history,
navigateToUrl: core.application.navigateToUrl, navigateToUrl: core.application.navigateToUrl,

View file

@ -9,6 +9,7 @@ import { kea, MakeLogicType } from 'kea';
import { FC } from 'react'; import { FC } from 'react';
import { History } from 'history'; import { History } from 'history';
import { ApplicationStart, ChromeBreadcrumb } from 'src/core/public'; import { ApplicationStart, ChromeBreadcrumb } from 'src/core/public';
import { ChartsPluginStart } from 'src/plugins/charts/public';
import { CloudSetup } from '../../../../../cloud/public'; import { CloudSetup } from '../../../../../cloud/public';
import { HttpLogic } from '../http'; import { HttpLogic } from '../http';
@ -18,6 +19,7 @@ interface KibanaLogicProps {
config: { host?: string }; config: { host?: string };
history: History; history: History;
cloud: Partial<CloudSetup>; cloud: Partial<CloudSetup>;
charts: ChartsPluginStart;
navigateToUrl: ApplicationStart['navigateToUrl']; navigateToUrl: ApplicationStart['navigateToUrl'];
setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void; setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void;
setDocTitle(title: string): void; setDocTitle(title: string): void;
@ -31,8 +33,9 @@ export const KibanaLogic = kea<MakeLogicType<KibanaValues>>({
path: ['enterprise_search', 'kibana_logic'], path: ['enterprise_search', 'kibana_logic'],
reducers: ({ props }) => ({ reducers: ({ props }) => ({
config: [props.config || {}, {}], config: [props.config || {}, {}],
history: [props.history, {}], charts: [props.charts, {}],
cloud: [props.cloud || {}, {}], cloud: [props.cloud || {}, {}],
history: [props.history, {}],
navigateToUrl: [ navigateToUrl: [
(url: string, options?: CreateHrefOptions) => { (url: string, options?: CreateHrefOptions) => {
const deps = { history: props.history, http: HttpLogic.values.http }; const deps = { history: props.history, http: HttpLogic.values.http };

View file

@ -18,6 +18,7 @@ import {
} from '../../../../src/plugins/home/public'; } from '../../../../src/plugins/home/public';
import { CloudSetup } from '../../cloud/public'; import { CloudSetup } from '../../cloud/public';
import { LicensingPluginStart } from '../../licensing/public'; import { LicensingPluginStart } from '../../licensing/public';
import { ChartsPluginStart } from '../../../../src/plugins/charts/public';
import { import {
APP_SEARCH_PLUGIN, APP_SEARCH_PLUGIN,
@ -41,6 +42,7 @@ interface PluginsSetup {
export interface PluginsStart { export interface PluginsStart {
cloud?: CloudSetup; cloud?: CloudSetup;
licensing: LicensingPluginStart; licensing: LicensingPluginStart;
charts: ChartsPluginStart;
} }
export class EnterpriseSearchPlugin implements Plugin { export class EnterpriseSearchPlugin implements Plugin {