Skip to content
1 change: 1 addition & 0 deletions packages/kbn-doc-links/src/get_doc_links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => {
},
apis: {
bulkIndexAlias: `${ELASTICSEARCH_DOCS}indices-aliases.html`,
indexStats: `${ELASTICSEARCH_DOCS}indices-stats.html`,
byteSizeUnits: `${ELASTICSEARCH_DOCS}api-conventions.html#byte-units`,
createAutoFollowPattern: `${ELASTICSEARCH_DOCS}ccr-put-auto-follow-pattern.html`,
createFollower: `${ELASTICSEARCH_DOCS}ccr-put-follow.html`,
Expand Down
1 change: 1 addition & 0 deletions packages/kbn-doc-links/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ export interface DocLinks {
readonly visualize: Record<string, string>;
readonly apis: Readonly<{
bulkIndexAlias: string;
indexStats: string;
byteSizeUnits: string;
createAutoFollowPattern: string;
createFollower: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,11 @@ const registerHttpRequestMockHelpers = (
error?: ResponseError
) => mockResponse('GET', `${API_BASE_PATH}/mapping/${indexName}`, response, error);

const setLoadIndexStatsResponse = (response?: HttpResponse, error?: ResponseError) =>
mockResponse('GET', `${API_BASE_PATH}/stats/:name`, response, error);
const setLoadIndexStatsResponse = (
indexName: string,
response?: HttpResponse,
error?: ResponseError
) => mockResponse('GET', `${API_BASE_PATH}/stats/${indexName}`, response, error);

const setUpdateIndexSettingsResponse = (
indexName: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ export interface IndexDetailsPageTestBed extends TestBed {
isDisplayed: () => boolean;
clickReloadButton: () => Promise<void>;
};
stats: {
getCodeBlockContent: () => string;
getDocsLinkHref: () => string;
isErrorDisplayed: () => boolean;
clickErrorReloadButton: () => Promise<void>;
indexStatsTabExists: () => boolean;
isWarningDisplayed: () => boolean;
};
};
}

Expand Down Expand Up @@ -159,6 +167,30 @@ export const setup = async (
component.update();
},
};

const stats = {
indexStatsTabExists: () => {
return exists('indexDetailsTab-stats');
},
getCodeBlockContent: () => {
return find('indexDetailsStatsCodeBlock').text();
},
getDocsLinkHref: () => {
return find('indexDetailsStatsDocsLink').prop('href');
},
isErrorDisplayed: () => {
return exists('indexDetailsStatsError');
},
isWarningDisplayed: () => {
return exists('indexStatsNotAvailableWarning');
},
clickErrorReloadButton: async () => {
await act(async () => {
find('reloadIndexStatsButton').simulate('click');
});
component.update();
},
};
return {
...testBed,
routerMock,
Expand All @@ -171,6 +203,7 @@ export const setup = async (
discoverLinkExists,
contextMenu,
errorSection,
stats,
},
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { setupEnvironment } from '../helpers';
import { IndexDetailsPageTestBed, setup } from './index_details_page.helpers';
import { act } from 'react-dom/test-utils';
import { IndexDetailsSection } from '../../../public/application/sections/home/index_list/details_page';
import { testIndexMappings, testIndexMock, testIndexName } from './mocks';
import { testIndexMappings, testIndexMock, testIndexName, testIndexStats } from './mocks';
import { API_BASE_PATH, INTERNAL_API_BASE_PATH } from '../../../common';

describe('<IndexDetailsPage />', () => {
Expand All @@ -22,6 +22,7 @@ describe('<IndexDetailsPage />', () => {
({ httpSetup, httpRequestsMockHelpers } = mockEnvironment);
// testIndexName is configured in initialEntries of the memory router
httpRequestsMockHelpers.setLoadIndexDetailsResponse(testIndexName, testIndexMock);
httpRequestsMockHelpers.setLoadIndexStatsResponse(testIndexName, testIndexStats);
httpRequestsMockHelpers.setLoadIndexMappingResponse(testIndexName, testIndexMappings);

await act(async () => {
Expand Down Expand Up @@ -61,6 +62,91 @@ describe('<IndexDetailsPage />', () => {
});
});

describe('Stats tab', () => {
it('loads index stats from the API', async () => {
await testBed.actions.clickIndexDetailsTab(IndexDetailsSection.Stats);
expect(httpSetup.get).toHaveBeenLastCalledWith(`${API_BASE_PATH}/stats/${testIndexName}`, {
asSystemRequest: undefined,
body: undefined,
query: undefined,
version: undefined,
});
});

it('renders index stats', async () => {
await testBed.actions.clickIndexDetailsTab(IndexDetailsSection.Stats);
const tabContent = testBed.actions.stats.getCodeBlockContent();
expect(tabContent).toEqual(JSON.stringify(testIndexStats, null, 2));
});

it('sets the docs link href from the documenation service', async () => {
await testBed.actions.clickIndexDetailsTab(IndexDetailsSection.Stats);
const docsLinkHref = testBed.actions.stats.getDocsLinkHref();
// the url from the mocked docs mock
expect(docsLinkHref).toEqual(
'https://www.elastic.co/guide/en/elasticsearch/reference/mocked-test-branch/indices-stats.html'
);
});

it('renders a warning message if an index is not open', async () => {
const testIndexMockWithClosedStatus = {
...testIndexMock,
status: 'closed',
};

httpRequestsMockHelpers.setLoadIndexDetailsResponse(
testIndexName,
testIndexMockWithClosedStatus
);

await act(async () => {
testBed = await setup(httpSetup);
});
testBed.component.update();

await testBed.actions.clickIndexDetailsTab(IndexDetailsSection.Stats);
expect(testBed.actions.stats.isWarningDisplayed()).toBe(true);
});

it('hides index stats tab if enableIndexStats===false', async () => {
await act(async () => {
testBed = await setup(httpSetup, {
config: { enableIndexStats: false },
});
});
testBed.component.update();

expect(testBed.actions.stats.indexStatsTabExists()).toBe(false);
});

describe('Error handling', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadIndexStatsResponse(testIndexName, undefined, {
statusCode: 500,
message: 'Error',
});
await act(async () => {
testBed = await setup(httpSetup);
});

testBed.component.update();
await testBed.actions.clickIndexDetailsTab(IndexDetailsSection.Stats);
});

it('there is an error prompt', async () => {
expect(testBed.actions.stats.isErrorDisplayed()).toBe(true);
});

it('resends a request when reload button is clicked', async () => {
// already sent 3 requests while setting up the component
const numberOfRequests = 3;
expect(httpSetup.get).toHaveBeenCalledTimes(numberOfRequests);
await testBed.actions.stats.clickErrorReloadButton();
expect(httpSetup.get).toHaveBeenCalledTimes(numberOfRequests + 1);
});
});
});

it('loads index details from the API', async () => {
expect(httpSetup.get).toHaveBeenLastCalledWith(
`${INTERNAL_API_BASE_PATH}/indices/${testIndexName}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,29 @@ export const testIndexMappings = {
},
},
};

// Mocking partial index stats response
export const testIndexStats = {
_shards: {
total: 1,
successful: 1,
failed: 0,
},
stats: {
uuid: 'tQ-n6sriQzC84xn58VYONQ',
health: 'green',
status: 'open',
primaries: {
docs: {
count: 1000,
deleted: 0,
},
},
total: {
docs: {
count: 1000,
deleted: 0,
},
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { css } from '@emotion/react';
import { Redirect, RouteComponentProps } from 'react-router-dom';
import { Route, Routes } from '@kbn/shared-ux-router';
import { FormattedMessage } from '@kbn/i18n-react';
Expand All @@ -18,13 +19,15 @@ import {
} from '@elastic/eui';
import { SectionLoading } from '@kbn/es-ui-shared-plugin/public';

import { css } from '@emotion/react';
import { Index } from '../../../../../../common';
import { INDEX_OPEN } from '../../../../../../common/constants';
import { loadIndex } from '../../../../services';
import { useAppContext } from '../../../../app_context';
import { DiscoverLink } from '../../../../lib/discover_link';
import { Section } from '../../home';
import { DetailsPageError } from './details_page_error';
import { ManageIndexButton } from './manage_index_button';
import { DetailsPageStats } from './details_page_stats';
import { DetailsPageMappings } from './details_page_mappings';

export enum IndexDetailsSection {
Expand All @@ -33,8 +36,9 @@ export enum IndexDetailsSection {
Mappings = 'mappings',
Settings = 'settings',
Pipelines = 'pipelines',
Stats = 'stats',
}
const tabs = [
const defaultTabs = [
{
id: IndexDetailsSection.Overview,
name: (
Expand Down Expand Up @@ -66,6 +70,12 @@ const tabs = [
),
},
];

const statsTab = {
id: IndexDetailsSection.Stats,
name: <FormattedMessage id="xpack.idxMgmt.indexDetails.statsTitle" defaultMessage="Stats" />,
Copy link
Contributor

@gchaps gchaps Aug 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Elsewhere we spell out statistics. For example, Field Statistics in Discover, and the Statistics tab in the Inspector. Should we spell it out for this tab as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I agree we should be consistent if "Statistics" is used elsewhere in Kibana.

This PR is part of a larger effort to make UX improvements to Index Management. I'm going to hold off on making this change here, as @yuliacech had planned to do an overall copy review with the docs team once the initial work is complete.

};

export const DetailsPage: React.FunctionComponent<
RouteComponentProps<{ indexName: string; indexDetailsSection: IndexDetailsSection }>
> = ({
Expand All @@ -74,6 +84,7 @@ export const DetailsPage: React.FunctionComponent<
},
history,
}) => {
const { config } = useAppContext();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState();
const [index, setIndex] = useState<Index | null>();
Expand Down Expand Up @@ -107,14 +118,16 @@ export const DetailsPage: React.FunctionComponent<
}, [history]);

const headerTabs = useMemo<EuiPageHeaderProps['tabs']>(() => {
return tabs.map((tab) => ({
const visibleTabs = config.enableIndexStats ? [...defaultTabs, statsTab] : defaultTabs;

return visibleTabs.map((tab) => ({
onClick: () => onSectionChange(tab.id),
isSelected: tab.id === indexDetailsSection,
key: tab.id,
'data-test-subj': `indexDetailsTab-${tab.id}`,
label: tab.name,
}));
}, [indexDetailsSection, onSectionChange]);
}, [indexDetailsSection, onSectionChange, config]);

if (isLoading && !index) {
return (
Expand All @@ -129,7 +142,6 @@ export const DetailsPage: React.FunctionComponent<
if (error || !index) {
return <DetailsPageError indexName={indexName} resendRequest={fetchIndexDetails} />;
}

return (
<>
<EuiPageSection paddingSize="none">
Expand Down Expand Up @@ -193,6 +205,14 @@ export const DetailsPage: React.FunctionComponent<
path={`/${Section.Indices}/${indexName}/${IndexDetailsSection.Pipelines}`}
render={() => <div>Pipelines</div>}
/>
{config.enableIndexStats && (
<Route
path={`/${Section.Indices}/:indexName/${IndexDetailsSection.Stats}`}
render={(routerProps: RouteComponentProps<{ indexName: string }>) => (
<DetailsPageStats {...routerProps} isIndexOpen={index.status === INDEX_OPEN} />
)}
/>
)}
<Redirect
from={`/${Section.Indices}/${indexName}`}
to={`/${Section.Indices}/${indexName}/${IndexDetailsSection.Overview}`}
Expand Down
Loading