mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[App Search] API Logs: Add ApiLogsTable and NewApiEventsPrompt components (#96008)
* Set up getStatusColor util for upcoming EuiHealth components * Add ApiLogsTable component * Add NewApiEventsPrompt component * Update ApiLogs view with new components + add EuiPageContent wrapper (missed this originally) * Update EngineOverview with new components * PR feedback: Comments + mock FormattedRelative * Fix type error
This commit is contained in:
parent
bffded3ca7
commit
7db838e0b8
14 changed files with 422 additions and 22 deletions
|
@ -17,6 +17,8 @@ import { EuiPageHeader } from '@elastic/eui';
|
|||
import { Loading } from '../../../shared/loading';
|
||||
import { LogRetentionCallout, LogRetentionTooltip } from '../log_retention';
|
||||
|
||||
import { ApiLogsTable, NewApiEventsPrompt } from './components';
|
||||
|
||||
import { ApiLogs } from './';
|
||||
|
||||
describe('ApiLogs', () => {
|
||||
|
@ -41,7 +43,8 @@ describe('ApiLogs', () => {
|
|||
|
||||
it('renders', () => {
|
||||
expect(wrapper.find(EuiPageHeader).prop('pageTitle')).toEqual('API Logs');
|
||||
// TODO: Check for ApiLogsTable + NewApiEventsPrompt when those get added
|
||||
expect(wrapper.find(ApiLogsTable)).toHaveLength(1);
|
||||
expect(wrapper.find(NewApiEventsPrompt)).toHaveLength(1);
|
||||
|
||||
expect(wrapper.find(LogRetentionCallout).prop('type')).toEqual('api');
|
||||
expect(wrapper.find(LogRetentionTooltip).prop('type')).toEqual('api');
|
||||
|
|
|
@ -9,7 +9,15 @@ import React, { useEffect } from 'react';
|
|||
|
||||
import { useValues, useActions } from 'kea';
|
||||
|
||||
import { EuiPageHeader, EuiTitle, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import {
|
||||
EuiPageHeader,
|
||||
EuiTitle,
|
||||
EuiPageContent,
|
||||
EuiPageContentBody,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { FlashMessages } from '../../../shared/flash_messages';
|
||||
import { SetAppSearchChrome as SetPageChrome } from '../../../shared/kibana_chrome';
|
||||
|
@ -18,6 +26,7 @@ import { Loading } from '../../../shared/loading';
|
|||
|
||||
import { LogRetentionCallout, LogRetentionTooltip, LogRetentionOptions } from '../log_retention';
|
||||
|
||||
import { ApiLogsTable, NewApiEventsPrompt } from './components';
|
||||
import { API_LOGS_TITLE, RECENT_API_EVENTS } from './constants';
|
||||
|
||||
import { ApiLogsLogic } from './';
|
||||
|
@ -47,19 +56,27 @@ export const ApiLogs: React.FC<Props> = ({ engineBreadcrumb }) => {
|
|||
<FlashMessages />
|
||||
<LogRetentionCallout type={LogRetentionOptions.API} />
|
||||
|
||||
<EuiFlexGroup gutterSize="m" alignItems="center" responsive={false} wrap>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="s">
|
||||
<h2>{RECENT_API_EVENTS}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<LogRetentionTooltip type={LogRetentionOptions.API} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{/* TODO: NewApiEventsPrompt */}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiPageContent hasBorder>
|
||||
<EuiPageContentBody>
|
||||
<EuiFlexGroup gutterSize="m" alignItems="center" responsive={false} wrap>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="s">
|
||||
<h2>{RECENT_API_EVENTS}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<LogRetentionTooltip type={LogRetentionOptions.API} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem />
|
||||
<EuiFlexItem grow={false}>
|
||||
<NewApiEventsPrompt />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
{/* TODO: ApiLogsTable */}
|
||||
<ApiLogsTable hasPagination />
|
||||
</EuiPageContentBody>
|
||||
</EuiPageContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
.apiLogDetailButton {
|
||||
// More closely mimics the regular line height of an EuiLink /
|
||||
// compresses table rows back to the standard height
|
||||
height: $euiSizeL !important;
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { setMockValues, setMockActions, mountWithIntl } from '../../../../__mocks__';
|
||||
|
||||
// NOTE: We're mocking FormattedRelative here because it (currently) has
|
||||
// console warn issues, and it allows us to skip mocking dates
|
||||
jest.mock('@kbn/i18n/react', () => ({
|
||||
...(jest.requireActual('@kbn/i18n/react') as object),
|
||||
FormattedRelative: jest.fn(() => '20 hours ago'),
|
||||
}));
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { EuiBasicTable, EuiBadge, EuiHealth, EuiButtonEmpty, EuiEmptyPrompt } from '@elastic/eui';
|
||||
|
||||
import { DEFAULT_META } from '../../../../shared/constants';
|
||||
|
||||
import { ApiLogsTable } from './';
|
||||
|
||||
describe('ApiLogsTable', () => {
|
||||
const apiLogs = [
|
||||
{
|
||||
timestamp: '1970-01-01T00:00:00.000Z',
|
||||
status: 404,
|
||||
http_method: 'GET',
|
||||
full_request_path: '/api/as/v1/test',
|
||||
},
|
||||
{
|
||||
timestamp: '1970-01-01T00:00:00.000Z',
|
||||
status: 500,
|
||||
http_method: 'DELETE',
|
||||
full_request_path: '/api/as/v1/test',
|
||||
},
|
||||
{
|
||||
timestamp: '1970-01-01T00:00:00.000Z',
|
||||
status: 200,
|
||||
http_method: 'POST',
|
||||
full_request_path: '/api/as/v1/engines/some-engine/search',
|
||||
},
|
||||
];
|
||||
|
||||
const values = {
|
||||
dataLoading: false,
|
||||
apiLogs,
|
||||
meta: DEFAULT_META,
|
||||
};
|
||||
const actions = {
|
||||
onPaginate: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setMockValues(values);
|
||||
setMockActions(actions);
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
const wrapper = mountWithIntl(<ApiLogsTable />);
|
||||
const tableContent = wrapper.find(EuiBasicTable).text();
|
||||
|
||||
expect(tableContent).toContain('Method');
|
||||
expect(tableContent).toContain('GET');
|
||||
expect(tableContent).toContain('DELETE');
|
||||
expect(tableContent).toContain('POST');
|
||||
expect(wrapper.find(EuiBadge)).toHaveLength(3);
|
||||
|
||||
expect(tableContent).toContain('Time');
|
||||
expect(tableContent).toContain('20 hours ago');
|
||||
|
||||
expect(tableContent).toContain('Endpoint');
|
||||
expect(tableContent).toContain('/api/as/v1/test');
|
||||
expect(tableContent).toContain('/api/as/v1/engines/some-engine/search');
|
||||
|
||||
expect(tableContent).toContain('Status');
|
||||
expect(tableContent).toContain('404');
|
||||
expect(tableContent).toContain('500');
|
||||
expect(tableContent).toContain('200');
|
||||
expect(wrapper.find(EuiHealth)).toHaveLength(3);
|
||||
|
||||
expect(wrapper.find(EuiButtonEmpty)).toHaveLength(3);
|
||||
wrapper.find('[data-test-subj="ApiLogsTableDetailsButton"]').first().simulate('click');
|
||||
// TODO: API log details flyout
|
||||
});
|
||||
|
||||
it('renders an empty prompt if no items are passed', () => {
|
||||
setMockValues({ ...values, apiLogs: [] });
|
||||
const wrapper = mountWithIntl(<ApiLogsTable />);
|
||||
const promptContent = wrapper.find(EuiEmptyPrompt).text();
|
||||
|
||||
expect(promptContent).toContain('No recent logs');
|
||||
});
|
||||
|
||||
describe('hasPagination', () => {
|
||||
it('does not render with pagination by default', () => {
|
||||
const wrapper = shallow(<ApiLogsTable />);
|
||||
|
||||
expect(wrapper.find(EuiBasicTable).prop('pagination')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('renders pagination if hasPagination is true', () => {
|
||||
const wrapper = shallow(<ApiLogsTable hasPagination />);
|
||||
|
||||
expect(wrapper.find(EuiBasicTable).prop('pagination')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useValues, useActions } from 'kea';
|
||||
|
||||
import {
|
||||
EuiBasicTable,
|
||||
EuiBasicTableColumn,
|
||||
EuiBadge,
|
||||
EuiHealth,
|
||||
EuiButtonEmpty,
|
||||
EuiEmptyPrompt,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedRelative } from '@kbn/i18n/react';
|
||||
|
||||
import { convertMetaToPagination, handlePageChange } from '../../../../shared/table_pagination';
|
||||
|
||||
import { ApiLogsLogic } from '../index';
|
||||
import { ApiLog } from '../types';
|
||||
import { getStatusColor } from '../utils';
|
||||
|
||||
import './api_logs_table.scss';
|
||||
|
||||
interface Props {
|
||||
hasPagination?: boolean;
|
||||
}
|
||||
export const ApiLogsTable: React.FC<Props> = ({ hasPagination }) => {
|
||||
const { dataLoading, apiLogs, meta } = useValues(ApiLogsLogic);
|
||||
const { onPaginate } = useActions(ApiLogsLogic);
|
||||
|
||||
const columns: Array<EuiBasicTableColumn<ApiLog>> = [
|
||||
{
|
||||
field: 'http_method',
|
||||
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.methodTableHeading', {
|
||||
defaultMessage: 'Method',
|
||||
}),
|
||||
width: '100px',
|
||||
render: (method: string) => <EuiBadge color="primary">{method}</EuiBadge>,
|
||||
},
|
||||
{
|
||||
field: 'timestamp',
|
||||
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.timeTableHeading', {
|
||||
defaultMessage: 'Time',
|
||||
}),
|
||||
width: '20%',
|
||||
render: (dateString: string) => <FormattedRelative value={new Date(dateString)} />,
|
||||
},
|
||||
{
|
||||
field: 'full_request_path',
|
||||
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.endpointTableHeading', {
|
||||
defaultMessage: 'Endpoint',
|
||||
}),
|
||||
width: '50%',
|
||||
truncateText: true,
|
||||
mobileOptions: {
|
||||
// @ts-ignore - EUI's typing is incorrect here
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
name: i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.statusTableHeading', {
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
dataType: 'number',
|
||||
width: '100px',
|
||||
render: (status: number) => <EuiHealth color={getStatusColor(status)}>{status}</EuiHealth>,
|
||||
},
|
||||
{
|
||||
width: '100px',
|
||||
align: 'right',
|
||||
render: (apiLog: ApiLog) => (
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
className="apiLogDetailButton"
|
||||
data-test-subj="ApiLogsTableDetailsButton"
|
||||
// TODO: flyout onclick
|
||||
>
|
||||
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.detailsButtonLabel', {
|
||||
defaultMessage: 'Details',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const paginationProps = hasPagination
|
||||
? {
|
||||
pagination: {
|
||||
...convertMetaToPagination(meta),
|
||||
hidePerPageOptions: true,
|
||||
},
|
||||
onChange: handlePageChange(onPaginate),
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<EuiBasicTable
|
||||
columns={columns}
|
||||
items={apiLogs}
|
||||
responsive
|
||||
loading={dataLoading}
|
||||
noItemsMessage={
|
||||
<EuiEmptyPrompt
|
||||
iconType="clock"
|
||||
title={
|
||||
<h3>
|
||||
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyTitle', {
|
||||
defaultMessage: 'No recent logs',
|
||||
})}
|
||||
</h3>
|
||||
}
|
||||
body={
|
||||
<p>
|
||||
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.apiLogs.emptyDescription', {
|
||||
defaultMessage: "Check back after you've performed some API calls.",
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
}
|
||||
{...paginationProps}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { ApiLogsTable } from './api_logs_table';
|
||||
export { NewApiEventsPrompt } from './new_api_events_prompt';
|
|
@ -0,0 +1,6 @@
|
|||
.newApiEventsPrompt {
|
||||
padding: $euiSizeXS;
|
||||
padding-left: $euiSizeS;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { setMockValues, setMockActions } from '../../../../__mocks__';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { EuiButtonEmpty } from '@elastic/eui';
|
||||
|
||||
import { NewApiEventsPrompt } from './';
|
||||
|
||||
describe('NewApiEventsPrompt', () => {
|
||||
const values = {
|
||||
hasNewData: true,
|
||||
};
|
||||
const actions = {
|
||||
onUserRefresh: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setMockValues(values);
|
||||
setMockActions(actions);
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
const wrapper = shallow(<NewApiEventsPrompt />);
|
||||
|
||||
expect(wrapper.isEmptyRender()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render if no new data has been polled', () => {
|
||||
setMockValues({ ...values, hasNewData: false });
|
||||
const wrapper = shallow(<NewApiEventsPrompt />);
|
||||
|
||||
expect(wrapper.isEmptyRender()).toBe(true);
|
||||
});
|
||||
|
||||
it('calls onUserRefresh', () => {
|
||||
const wrapper = shallow(<NewApiEventsPrompt />);
|
||||
|
||||
wrapper.find(EuiButtonEmpty).simulate('click');
|
||||
expect(actions.onUserRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useValues, useActions } from 'kea';
|
||||
|
||||
import { EuiPanel, EuiButtonEmpty } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { ApiLogsLogic } from '../';
|
||||
|
||||
import './new_api_events_prompt.scss';
|
||||
|
||||
export const NewApiEventsPrompt: React.FC = () => {
|
||||
const { hasNewData } = useValues(ApiLogsLogic);
|
||||
const { onUserRefresh } = useActions(ApiLogsLogic);
|
||||
|
||||
return hasNewData ? (
|
||||
<EuiPanel color="subdued" hasShadow={false} paddingSize="s" className="newApiEventsPrompt">
|
||||
{i18n.translate('xpack.enterpriseSearch.appSearch.engines.apiLogs.newEventsMessage', {
|
||||
defaultMessage: 'New events have been logged.',
|
||||
})}
|
||||
<EuiButtonEmpty iconType="refresh" size="xs" onClick={onUserRefresh}>
|
||||
{i18n.translate('xpack.enterpriseSearch.appSearch.engines.apiLogs.newEventsButtonLabel', {
|
||||
defaultMessage: 'Refresh',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiPanel>
|
||||
) : null;
|
||||
};
|
|
@ -6,5 +6,6 @@
|
|||
*/
|
||||
|
||||
export { API_LOGS_TITLE } from './constants';
|
||||
export { ApiLogsTable, NewApiEventsPrompt } from './components';
|
||||
export { ApiLogs } from './api_logs';
|
||||
export { ApiLogsLogic } from './api_logs_logic';
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { getDateString } from './utils';
|
||||
import { getDateString, getStatusColor } from './utils';
|
||||
|
||||
describe('getDateString', () => {
|
||||
const mockDate = jest
|
||||
|
@ -23,3 +23,12 @@ describe('getDateString', () => {
|
|||
|
||||
afterAll(() => mockDate.mockRestore());
|
||||
});
|
||||
|
||||
describe('getStatusColor', () => {
|
||||
it('returns a valid EUI badge color based on the status code', () => {
|
||||
expect(getStatusColor(200)).toEqual('secondary');
|
||||
expect(getStatusColor(301)).toEqual('primary');
|
||||
expect(getStatusColor(404)).toEqual('warning');
|
||||
expect(getStatusColor(503)).toEqual('danger');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,3 +10,12 @@ export const getDateString = (offSetDays?: number) => {
|
|||
if (offSetDays) date.setDate(date.getDate() + offSetDays);
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
export const getStatusColor = (status: number) => {
|
||||
let color = '';
|
||||
if (status >= 100 && status < 300) color = 'secondary';
|
||||
if (status >= 300 && status < 400) color = 'primary';
|
||||
if (status >= 400 && status < 500) color = 'warning';
|
||||
if (status >= 500) color = 'danger';
|
||||
return color;
|
||||
};
|
||||
|
|
|
@ -13,6 +13,8 @@ import React from 'react';
|
|||
|
||||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
|
||||
import { ApiLogsTable } from '../../api_logs';
|
||||
|
||||
import { RecentApiLogs } from './recent_api_logs';
|
||||
|
||||
describe('RecentApiLogs', () => {
|
||||
|
@ -31,7 +33,7 @@ describe('RecentApiLogs', () => {
|
|||
|
||||
it('renders the recent API logs table', () => {
|
||||
expect(wrapper.prop('title')).toEqual(<h2>Recent API events</h2>);
|
||||
// TODO: expect(wrapper.find(ApiLogsTable)).toHaveLength(1)
|
||||
expect(wrapper.find(ApiLogsTable)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('calls fetchApiLogs on page load and starts pollForApiLogs', () => {
|
||||
|
|
|
@ -9,9 +9,11 @@ import React, { useEffect } from 'react';
|
|||
|
||||
import { useActions } from 'kea';
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
|
||||
import { EuiButtonEmptyTo } from '../../../../shared/react_router_helpers';
|
||||
import { ENGINE_API_LOGS_PATH } from '../../../routes';
|
||||
import { ApiLogsLogic } from '../../api_logs';
|
||||
import { ApiLogsLogic, ApiLogsTable, NewApiEventsPrompt } from '../../api_logs';
|
||||
import { RECENT_API_EVENTS } from '../../api_logs/constants';
|
||||
import { DataPanel } from '../../data_panel';
|
||||
import { generateEnginePath } from '../../engine';
|
||||
|
@ -30,14 +32,20 @@ export const RecentApiLogs: React.FC = () => {
|
|||
<DataPanel
|
||||
title={<h2>{RECENT_API_EVENTS}</h2>}
|
||||
action={
|
||||
<EuiButtonEmptyTo iconType="eye" to={generateEnginePath(ENGINE_API_LOGS_PATH)} size="s">
|
||||
{VIEW_API_LOGS}
|
||||
</EuiButtonEmptyTo>
|
||||
<EuiFlexGroup alignItems="center" justifyContent="flexEnd" responsive={false} wrap>
|
||||
<EuiFlexItem grow={false}>
|
||||
<NewApiEventsPrompt />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmptyTo iconType="eye" to={generateEnginePath(ENGINE_API_LOGS_PATH)} size="s">
|
||||
{VIEW_API_LOGS}
|
||||
</EuiButtonEmptyTo>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
hasBorder
|
||||
>
|
||||
TODO: API Logs Table
|
||||
{/* <ApiLogsTable hidePagination={true} /> */}
|
||||
<ApiLogsTable />
|
||||
</DataPanel>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue