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);
};