[App Search] API logs: Server route + ApiLogsLogic + useEffects (#95732)
* Set up API route * Set up API types * Set up date util needed by filters dates * Add ApiLogsLogic * Update ApiLogs and EngineOverview views with polling behavior * Add API type notes - maybe serves as a TODO to clean up our API data some day
This commit is contained in:
parent
738425932f
commit
53d4fa7052
|
@ -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(<ApiLogs engineBreadcrumb={['some engine']} />);
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
const wrapper = shallow(<ApiLogs engineBreadcrumb={['some engine']} />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<Props> = ({ engineBreadcrumb }) => {
|
||||
const { dataLoading, apiLogs, meta } = useValues(ApiLogsLogic);
|
||||
const { fetchApiLogs, pollForApiLogs } = useActions(ApiLogsLogic);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApiLogs();
|
||||
}, [meta.page.current]);
|
||||
|
||||
useEffect(() => {
|
||||
pollForApiLogs();
|
||||
}, []);
|
||||
|
||||
if (dataLoading && !apiLogs.length) return <Loading />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SetPageChrome trail={[...engineBreadcrumb, API_LOGS_TITLE]} />
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<MakeLogicType<ApiLogsValues, ApiLogsActions>>({
|
||||
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);
|
||||
},
|
||||
}),
|
||||
});
|
|
@ -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.',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -7,3 +7,4 @@
|
|||
|
||||
export { API_LOGS_TITLE } from './constants';
|
||||
export { ApiLogs } from './api_logs';
|
||||
export { ApiLogsLogic } from './api_logs_logic';
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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());
|
||||
});
|
|
@ -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();
|
||||
};
|
|
@ -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(<RecentApiLogs />);
|
||||
});
|
||||
|
||||
|
@ -25,4 +33,9 @@ describe('RecentApiLogs', () => {
|
|||
expect(wrapper.prop('title')).toEqual(<h2>Recent API events</h2>);
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 (
|
||||
<DataPanel
|
||||
title={<h2>{RECENT_API_EVENTS}</h2>}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue