diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx index da57fd466ffe17..7bdfaf87a2b2f2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.test.tsx @@ -5,28 +5,67 @@ * 2.0. */ +import { setMockValues, setMockActions, rerender } from '../../../__mocks__'; +import '../../../__mocks__/shallow_useeffect.mock'; + import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, ShallowWrapper } from 'enzyme'; import { EuiPageHeader } from '@elastic/eui'; +import { Loading } from '../../../shared/loading'; import { LogRetentionCallout, LogRetentionTooltip } from '../log_retention'; import { ApiLogs } from './'; describe('ApiLogs', () => { + const values = { + dataLoading: false, + apiLogs: [], + meta: { page: { current: 1 } }, + }; + const actions = { + fetchApiLogs: jest.fn(), + pollForApiLogs: jest.fn(), + }; + + let wrapper: ShallowWrapper; + beforeEach(() => { jest.clearAllMocks(); + setMockValues(values); + setMockActions(actions); + wrapper = shallow(); }); it('renders', () => { - const wrapper = shallow(); - expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('API Logs'); // TODO: Check for ApiLogsTable + NewApiEventsPrompt when those get added expect(wrapper.find(LogRetentionCallout).prop('type')).toEqual('api'); expect(wrapper.find(LogRetentionTooltip).prop('type')).toEqual('api'); }); + + it('renders a loading screen', () => { + setMockValues({ ...values, dataLoading: true, apiLogs: [] }); + rerender(wrapper); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + describe('effects', () => { + it('calls a manual fetchApiLogs on page load and pagination', () => { + expect(actions.fetchApiLogs).toHaveBeenCalledTimes(1); + + setMockValues({ ...values, meta: { page: { current: 2 } } }); + rerender(wrapper); + + expect(actions.fetchApiLogs).toHaveBeenCalledTimes(2); + }); + + it('starts pollForApiLogs on page load', () => { + expect(actions.pollForApiLogs).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx index 7e3fadb44fc7a9..2ffc9ea303b5c9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs.tsx @@ -5,22 +5,40 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; + +import { useValues, useActions } from 'kea'; import { EuiPageHeader, EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FlashMessages } from '../../../shared/flash_messages'; import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome'; import { BreadcrumbTrail } from '../../../shared/kibana_chrome/generate_breadcrumbs'; +import { Loading } from '../../../shared/loading'; import { LogRetentionCallout, LogRetentionTooltip, LogRetentionOptions } from '../log_retention'; import { API_LOGS_TITLE, RECENT_API_EVENTS } from './constants'; +import { ApiLogsLogic } from './'; + interface Props { engineBreadcrumb: BreadcrumbTrail; } export const ApiLogs: React.FC = ({ engineBreadcrumb }) => { + const { dataLoading, apiLogs, meta } = useValues(ApiLogsLogic); + const { fetchApiLogs, pollForApiLogs } = useActions(ApiLogsLogic); + + useEffect(() => { + fetchApiLogs(); + }, [meta.page.current]); + + useEffect(() => { + pollForApiLogs(); + }, []); + + if (dataLoading && !apiLogs.length) return ; + return ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs_logic.test.ts new file mode 100644 index 00000000000000..e7f3124a48e8c5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs_logic.test.ts @@ -0,0 +1,308 @@ +/* + * 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 { LogicMounter, mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__'; +import '../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { DEFAULT_META } from '../../../shared/constants'; + +import { POLLING_ERROR_MESSAGE } from './constants'; + +import { ApiLogsLogic } from './'; + +describe('ApiLogsLogic', () => { + const { mount, unmount } = new LogicMounter(ApiLogsLogic); + const { http } = mockHttpValues; + const { flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers; + + const DEFAULT_VALUES = { + dataLoading: true, + apiLogs: [], + meta: DEFAULT_META, + hasNewData: false, + polledData: {}, + intervalId: null, + }; + + const MOCK_API_RESPONSE = { + results: [ + { + timestamp: '1970-01-01T12:00:00.000Z', + http_method: 'POST', + status: 200, + user_agent: 'some browser agent string', + full_request_path: '/api/as/v1/engines/national-parks-demo/search.json', + request_body: '{"someMockRequest":"hello"}', + response_body: '{"someMockResponse":"world"}', + }, + ], + meta: { + page: { + current: 1, + total_pages: 10, + total_results: 100, + size: 10, + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(ApiLogsLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onPollStart', () => { + it('sets intervalId state', () => { + mount(); + ApiLogsLogic.actions.onPollStart(123); + + expect(ApiLogsLogic.values).toEqual({ + ...DEFAULT_VALUES, + intervalId: 123, + }); + }); + }); + + describe('storePolledData', () => { + it('sets hasNewData to true & polledData state', () => { + mount({ hasNewData: false }); + ApiLogsLogic.actions.storePolledData(MOCK_API_RESPONSE); + + expect(ApiLogsLogic.values).toEqual({ + ...DEFAULT_VALUES, + hasNewData: true, + polledData: MOCK_API_RESPONSE, + }); + }); + }); + + describe('updateView', () => { + it('sets dataLoading & hasNewData to false, sets apiLogs & meta state', () => { + mount({ dataLoading: true, hasNewData: true }); + ApiLogsLogic.actions.updateView(MOCK_API_RESPONSE); + + expect(ApiLogsLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: false, + hasNewData: false, + apiLogs: MOCK_API_RESPONSE.results, + meta: MOCK_API_RESPONSE.meta, + }); + }); + }); + + describe('onPaginate', () => { + it('sets dataLoading to true & sets meta state', () => { + mount({ dataLoading: false }); + ApiLogsLogic.actions.onPaginate(5); + + expect(ApiLogsLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: true, + meta: { + ...DEFAULT_META, + page: { + ...DEFAULT_META.page, + current: 5, + }, + }, + }); + }); + }); + }); + + describe('listeners', () => { + describe('pollForApiLogs', () => { + jest.useFakeTimers(); + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + it('starts a poll that calls fetchApiLogs at set intervals', () => { + mount(); + jest.spyOn(ApiLogsLogic.actions, 'onPollStart'); + jest.spyOn(ApiLogsLogic.actions, 'fetchApiLogs'); + + ApiLogsLogic.actions.pollForApiLogs(); + expect(setIntervalSpy).toHaveBeenCalled(); + expect(ApiLogsLogic.actions.onPollStart).toHaveBeenCalled(); + + jest.advanceTimersByTime(5000); + expect(ApiLogsLogic.actions.fetchApiLogs).toHaveBeenCalledWith({ isPoll: true }); + }); + + it('does not create new polls if one already exists', () => { + mount({ intervalId: 123 }); + ApiLogsLogic.actions.pollForApiLogs(); + expect(setIntervalSpy).not.toHaveBeenCalled(); + }); + + afterAll(() => jest.useRealTimers); + }); + + describe('fetchApiLogs', () => { + const mockDate = jest + .spyOn(global.Date, 'now') + .mockImplementation(() => new Date('1970-01-02').valueOf()); + + afterAll(() => mockDate.mockRestore()); + + it('should make an API call', () => { + mount(); + + ApiLogsLogic.actions.fetchApiLogs(); + + expect(http.get).toHaveBeenCalledWith('/api/app_search/engines/some-engine/api_logs', { + query: { + 'page[current]': 1, + 'filters[date][from]': '1970-01-01T00:00:00.000Z', + 'filters[date][to]': '1970-01-02T00:00:00.000Z', + sort_direction: 'desc', + }, + }); + }); + + describe('manual fetch (page load & pagination)', () => { + it('updates the view immediately with the returned data', async () => { + http.get.mockReturnValueOnce(Promise.resolve(MOCK_API_RESPONSE)); + mount(); + jest.spyOn(ApiLogsLogic.actions, 'updateView'); + + ApiLogsLogic.actions.fetchApiLogs(); + await nextTick(); + + expect(ApiLogsLogic.actions.updateView).toHaveBeenCalledWith(MOCK_API_RESPONSE); + }); + + it('handles API errors', async () => { + http.get.mockReturnValueOnce(Promise.reject('error')); + mount(); + + ApiLogsLogic.actions.fetchApiLogs(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + + describe('poll fetch (interval)', () => { + it('does not automatically update the view', async () => { + http.get.mockReturnValueOnce(Promise.resolve(MOCK_API_RESPONSE)); + mount({ dataLoading: false }); + jest.spyOn(ApiLogsLogic.actions, 'onPollInterval'); + + ApiLogsLogic.actions.fetchApiLogs({ isPoll: true }); + await nextTick(); + + expect(ApiLogsLogic.actions.onPollInterval).toHaveBeenCalledWith(MOCK_API_RESPONSE); + }); + + it('sets a custom error message on poll error', async () => { + http.get.mockReturnValueOnce(Promise.reject('error')); + mount({ dataLoading: false }); + + ApiLogsLogic.actions.fetchApiLogs({ isPoll: true }); + await nextTick(); + + expect(setErrorMessage).toHaveBeenCalledWith(POLLING_ERROR_MESSAGE); + }); + }); + + describe('when a manual fetch and a poll fetch occur at the same time', () => { + it('should short-circuit polls in favor of manual fetches', async () => { + // dataLoading is the signal we're using to check for a manual fetch + mount({ dataLoading: true }); + jest.spyOn(ApiLogsLogic.actions, 'onPollInterval'); + + ApiLogsLogic.actions.fetchApiLogs({ isPoll: true }); + await nextTick(); + + expect(http.get).not.toHaveBeenCalled(); + expect(ApiLogsLogic.actions.onPollInterval).not.toHaveBeenCalled(); + }); + }); + }); + + describe('onPollInterval', () => { + describe('when API logs are empty and new polled data comes in', () => { + it('updates the view immediately with the returned data (no manual action required)', () => { + mount({ meta: { page: { total_results: 0 } } }); + jest.spyOn(ApiLogsLogic.actions, 'updateView'); + + ApiLogsLogic.actions.onPollInterval(MOCK_API_RESPONSE); + + expect(ApiLogsLogic.actions.updateView).toHaveBeenCalledWith(MOCK_API_RESPONSE); + }); + }); + + describe('when previous API logs already exist on the page', () => { + describe('when new data is returned', () => { + it('stores the new polled data', () => { + mount({ meta: { page: { total_results: 1 } } }); + jest.spyOn(ApiLogsLogic.actions, 'storePolledData'); + + ApiLogsLogic.actions.onPollInterval(MOCK_API_RESPONSE); + + expect(ApiLogsLogic.actions.storePolledData).toHaveBeenCalledWith(MOCK_API_RESPONSE); + }); + }); + + describe('when the same data is returned', () => { + it('does nothing', () => { + mount({ meta: { page: { total_results: 100 } } }); + jest.spyOn(ApiLogsLogic.actions, 'updateView'); + jest.spyOn(ApiLogsLogic.actions, 'storePolledData'); + + ApiLogsLogic.actions.onPollInterval(MOCK_API_RESPONSE); + + expect(ApiLogsLogic.actions.updateView).not.toHaveBeenCalled(); + expect(ApiLogsLogic.actions.storePolledData).not.toHaveBeenCalled(); + }); + }); + }); + }); + + describe('onUserRefresh', () => { + it('updates the apiLogs data with the stored polled data', () => { + mount({ apiLogs: [], polledData: MOCK_API_RESPONSE }); + + ApiLogsLogic.actions.onUserRefresh(); + + expect(ApiLogsLogic.values).toEqual({ + ...DEFAULT_VALUES, + apiLogs: MOCK_API_RESPONSE.results, + meta: MOCK_API_RESPONSE.meta, + polledData: MOCK_API_RESPONSE, + dataLoading: false, + }); + }); + }); + }); + + describe('events', () => { + describe('unmount', () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + it('clears the poll interval', () => { + mount({ intervalId: 123 }); + unmount(); + expect(clearIntervalSpy).toHaveBeenCalledWith(123); + }); + + it('does not clearInterval if a poll has not been started', () => { + mount({ intervalId: null }); + unmount(); + expect(clearIntervalSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs_logic.ts new file mode 100644 index 00000000000000..2a2f55a0c80331 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/api_logs_logic.ts @@ -0,0 +1,156 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { DEFAULT_META } from '../../../shared/constants'; +import { flashAPIErrors, setErrorMessage } from '../../../shared/flash_messages'; +import { HttpLogic } from '../../../shared/http'; +import { updateMetaPageIndex } from '../../../shared/table_pagination'; +import { EngineLogic } from '../engine'; + +import { POLLING_DURATION, POLLING_ERROR_MESSAGE } from './constants'; +import { ApiLogsData, ApiLog } from './types'; +import { getDateString } from './utils'; + +interface ApiLogsValues { + dataLoading: boolean; + apiLogs: ApiLog[]; + meta: ApiLogsData['meta']; + hasNewData: boolean; + polledData: ApiLogsData; + intervalId: number | null; +} + +interface ApiLogsActions { + fetchApiLogs(options?: { isPoll: boolean }): { isPoll: boolean }; + pollForApiLogs(): void; + onPollStart(intervalId: number): { intervalId: number }; + onPollInterval(data: ApiLogsData): ApiLogsData; + storePolledData(data: ApiLogsData): ApiLogsData; + updateView(data: ApiLogsData): ApiLogsData; + onUserRefresh(): void; + onPaginate(newPageIndex: number): { newPageIndex: number }; +} + +export const ApiLogsLogic = kea>({ + path: ['enterprise_search', 'app_search', 'api_logs_logic'], + actions: () => ({ + fetchApiLogs: ({ isPoll } = { isPoll: false }) => ({ isPoll }), + pollForApiLogs: true, + onPollStart: (intervalId) => ({ intervalId }), + onPollInterval: ({ results, meta }) => ({ results, meta }), + storePolledData: ({ results, meta }) => ({ results, meta }), + updateView: ({ results, meta }) => ({ results, meta }), + onUserRefresh: true, + onPaginate: (newPageIndex) => ({ newPageIndex }), + }), + reducers: () => ({ + dataLoading: [ + true, + { + updateView: () => false, + onPaginate: () => true, + }, + ], + apiLogs: [ + [], + { + updateView: (_, { results }) => results, + }, + ], + meta: [ + DEFAULT_META, + { + updateView: (_, { meta }) => meta, + onPaginate: (state, { newPageIndex }) => updateMetaPageIndex(state, newPageIndex), + }, + ], + hasNewData: [ + false, + { + storePolledData: () => true, + updateView: () => false, + }, + ], + polledData: [ + {} as ApiLogsData, + { + storePolledData: (_, data) => data, + }, + ], + intervalId: [ + null, + { + onPollStart: (_, { intervalId }) => intervalId, + }, + ], + }), + listeners: ({ actions, values }) => ({ + pollForApiLogs: () => { + if (values.intervalId) return; // Ensure we only have one poll at a time + + const id = window.setInterval(() => actions.fetchApiLogs({ isPoll: true }), POLLING_DURATION); + actions.onPollStart(id); + }, + fetchApiLogs: async ({ isPoll }) => { + if (isPoll && values.dataLoading) return; // Manual fetches (i.e. user pagination) should override polling + + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.get(`/api/app_search/engines/${engineName}/api_logs`, { + query: { + 'page[current]': values.meta.page.current, + 'filters[date][from]': getDateString(-1), + 'filters[date][to]': getDateString(), + sort_direction: 'desc', + }, + }); + + // Manual fetches (e.g. page load, user pagination) should update the view immediately, + // while polls are stored in-state until the user manually triggers the 'Refresh' action + if (isPoll) { + actions.onPollInterval(response); + } else { + actions.updateView(response); + } + } catch (e) { + if (isPoll) { + // If polling fails, it will typically be due due to http connection - + // we should send a more human-readable message if so + setErrorMessage(POLLING_ERROR_MESSAGE); + } else { + flashAPIErrors(e); + } + } + }, + onPollInterval: (data, breakpoint) => { + breakpoint(); // Prevents errors if logic unmounts while fetching + + const previousResults = values.meta.page.total_results; + const newResults = data.meta.page.total_results; + const isEmpty = previousResults === 0; + const hasNewData = previousResults !== newResults; + + if (isEmpty && hasNewData) { + actions.updateView(data); // Empty logs should automatically update with new data without a manual action + } else if (hasNewData) { + actions.storePolledData(data); // Otherwise, store any new data until the user manually refreshes the table + } + }, + onUserRefresh: () => { + actions.updateView(values.polledData); + }, + }), + events: ({ values }) => ({ + beforeUnmount() { + if (values.intervalId !== null) clearInterval(values.intervalId); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/constants.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/constants.ts index 1620b0a953d465..9f64ec44e8b134 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/constants.ts @@ -16,3 +16,13 @@ export const RECENT_API_EVENTS = i18n.translate( 'xpack.enterpriseSearch.appSearch.engine.apiLogs.recent', { defaultMessage: 'Recent API events' } ); + +export const POLLING_DURATION = 5000; + +export const POLLING_ERROR_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.apiLogs.pollingErrorMessage', + { + defaultMessage: + 'Could not automatically refresh API logs data. Please check your connection or manually refresh the page.', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/index.ts index 104ae03b892203..dc05fe3de0d5c8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/index.ts @@ -7,3 +7,4 @@ export { API_LOGS_TITLE } from './constants'; export { ApiLogs } from './api_logs'; +export { ApiLogsLogic } from './api_logs_logic'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/types.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/types.ts new file mode 100644 index 00000000000000..05c0d11d032402 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/types.ts @@ -0,0 +1,27 @@ +/* + * 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 { Meta } from '../../../../../common/types'; + +export interface ApiLog { + timestamp: string; // Date ISO string + status: number; + http_method: string; + full_request_path: string; + user_agent: string; + request_body: string; // JSON string + response_body: string; // JSON string + // NOTE: The API also sends us back `path: null`, but we don't appear to be + // using it anywhere, so I've opted not to list it in our types +} + +export interface ApiLogsData { + results: ApiLog[]; + meta: Meta; + // NOTE: The API sends us back even more `meta` data than the normal (sort_direction, filters, query), + // but we currently don't use that data in our front-end code, so I'm opting not to list them in our types +} diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/utils.test.ts new file mode 100644 index 00000000000000..53c210d5952916 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/utils.test.ts @@ -0,0 +1,25 @@ +/* + * 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 { getDateString } from './utils'; + +describe('getDateString', () => { + const mockDate = jest + .spyOn(global.Date, 'now') + .mockImplementation(() => new Date('1970-01-02').valueOf()); + + it('gets the current date in ISO format', () => { + expect(getDateString()).toEqual('1970-01-02T00:00:00.000Z'); + }); + + it('allows passing a number of days to offset the timestamp by', () => { + expect(getDateString(-1)).toEqual('1970-01-01T00:00:00.000Z'); + expect(getDateString(10)).toEqual('1970-01-12T00:00:00.000Z'); + }); + + afterAll(() => mockDate.mockRestore()); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/utils.ts new file mode 100644 index 00000000000000..4e2dfc2cf701af --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/api_logs/utils.ts @@ -0,0 +1,12 @@ +/* + * 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 const getDateString = (offSetDays?: number) => { + const date = new Date(Date.now()); + if (offSetDays) date.setDate(date.getDate() + offSetDays); + return date.toISOString(); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.test.tsx index ceda3ab92f589b..44e125221f6742 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.test.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import { setMockActions } from '../../../../__mocks__'; +import '../../../../__mocks__/shallow_useeffect.mock'; import '../../../__mocks__/engine_logic.mock'; import React from 'react'; @@ -14,10 +16,16 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { RecentApiLogs } from './recent_api_logs'; describe('RecentApiLogs', () => { + const actions = { + fetchApiLogs: jest.fn(), + pollForApiLogs: jest.fn(), + }; + let wrapper: ShallowWrapper; beforeAll(() => { jest.clearAllMocks(); + setMockActions(actions); wrapper = shallow(); }); @@ -25,4 +33,9 @@ describe('RecentApiLogs', () => { expect(wrapper.prop('title')).toEqual(

Recent API events

); // TODO: expect(wrapper.find(ApiLogsTable)).toHaveLength(1) }); + + it('calls fetchApiLogs on page load and starts pollForApiLogs', () => { + expect(actions.fetchApiLogs).toHaveBeenCalledTimes(1); + expect(actions.pollForApiLogs).toHaveBeenCalledTimes(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.tsx index 1c7f43a5925362..e77a4ad7b03487 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/components/recent_api_logs.tsx @@ -5,10 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; + +import { useActions } from 'kea'; import { EuiButtonEmptyTo } from '../../../../shared/react_router_helpers'; import { ENGINE_API_LOGS_PATH } from '../../../routes'; +import { ApiLogsLogic } from '../../api_logs'; import { RECENT_API_EVENTS } from '../../api_logs/constants'; import { DataPanel } from '../../data_panel'; import { generateEnginePath } from '../../engine'; @@ -16,6 +19,13 @@ import { generateEnginePath } from '../../engine'; import { VIEW_API_LOGS } from '../constants'; export const RecentApiLogs: React.FC = () => { + const { fetchApiLogs, pollForApiLogs } = useActions(ApiLogsLogic); + + useEffect(() => { + fetchApiLogs(); + pollForApiLogs(); + }, []); + return ( {RECENT_API_EVENTS}} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/api_logs.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/api_logs.test.ts new file mode 100644 index 00000000000000..3152b371c2fbb0 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/api_logs.test.ts @@ -0,0 +1,54 @@ +/* + * 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 { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerApiLogsRoutes } from './api_logs'; + +describe('API logs routes', () => { + describe('GET /api/app_search/engines/{engineName}/api_logs', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'get', + path: '/api/app_search/engines/{engineName}/api_logs', + }); + + registerApiLogsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request handler', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/as/engines/:engineName/api_logs/collection', + }); + }); + + describe('validates', () => { + it('with required query params', () => { + const request = { + query: { + 'filters[date][from]': '1970-01-01T12:00:00.000Z', + 'filters[date][to]': '1970-01-02T12:00:00.000Z', + 'page[current]': 1, + sort_direction: 'desc', + }, + }; + mockRouter.shouldValidate(request); + }); + + it('missing params', () => { + const request = { query: {} }; + mockRouter.shouldThrow(request); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/api_logs.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/api_logs.ts new file mode 100644 index 00000000000000..d57ecb29294beb --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/api_logs.ts @@ -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 { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerApiLogsRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/app_search/engines/{engineName}/api_logs', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + query: schema.object({ + 'filters[date][from]': schema.string(), // Date string, expected format: ISO string + 'filters[date][to]': schema.string(), // Date string, expected format: ISO string + 'page[current]': schema.number(), + sort_direction: schema.string(), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/as/engines/:engineName/api_logs/collection', + }) + ); +} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index 3c8501ec15b3d5..1d48614e733741 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -8,6 +8,7 @@ import { RouteDependencies } from '../../plugin'; import { registerAnalyticsRoutes } from './analytics'; +import { registerApiLogsRoutes } from './api_logs'; import { registerCredentialsRoutes } from './credentials'; import { registerCurationsRoutes } from './curations'; import { registerDocumentsRoutes, registerDocumentRoutes } from './documents'; @@ -29,5 +30,6 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerSearchSettingsRoutes(dependencies); registerRoleMappingsRoutes(dependencies); registerResultSettingsRoutes(dependencies); + registerApiLogsRoutes(dependencies); registerOnboardingRoutes(dependencies); };