[IM] Index Templates UI list view (#39922) (#41451)

This commit is contained in:
Alison Goryachev 2019-07-18 13:57:40 -04:00 committed by GitHub
parent 1616ba5ffc
commit 4dcc99fe34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 1897 additions and 118 deletions

View file

@ -0,0 +1,100 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import {
registerTestBed,
TestBed,
TestBedConfig,
findTestSubject,
} from '../../../../../../test_utils';
import { IndexManagementHome } from '../../../public/sections/home';
import { BASE_PATH } from '../../../common/constants';
import { indexManagementStore } from '../../../public/store';
const testBedConfig: TestBedConfig = {
store: indexManagementStore,
memoryRouter: {
initialEntries: [`${BASE_PATH}indices`],
componentRoutePath: `${BASE_PATH}:section(indices|templates)`,
},
doMountAsync: true,
};
const initTestBed = registerTestBed(IndexManagementHome, testBedConfig);
export interface IdxMgmtHomeTestBed extends TestBed<IdxMgmtTestSubjects> {
actions: {
selectTab: (tab: 'indices' | 'index templates') => void;
clickReloadButton: () => void;
clickTemplateActionAt: (index: number, action: 'delete') => void;
};
}
export const setup = async (): Promise<IdxMgmtHomeTestBed> => {
const testBed = await initTestBed();
/**
* User Actions
*/
const selectTab = (tab: 'indices' | 'index templates') => {
const tabs = ['indices', 'index templates'];
testBed
.find('tab')
.at(tabs.indexOf(tab))
.simulate('click');
};
const clickReloadButton = () => {
const { find } = testBed;
find('reloadButton').simulate('click');
};
const clickTemplateActionAt = async (index: number, action: 'delete') => {
const { component, table } = testBed;
const { rows } = table.getMetaData('templatesTable');
const currentRow = rows[index];
const lastColumn = currentRow.columns[currentRow.columns.length - 1].reactWrapper;
const button = findTestSubject(lastColumn, `${action}TemplateButton`);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
button.simulate('click');
component.update();
});
};
return {
...testBed,
actions: {
selectTab,
clickReloadButton,
clickTemplateActionAt,
},
};
};
type IdxMgmtTestSubjects = TestSubjects;
export type TestSubjects =
| 'appTitle'
| 'cell'
| 'deleteSystemTemplateCallOut'
| 'deleteTemplateButton'
| 'deleteTemplatesButton'
| 'deleteTemplatesConfirmation'
| 'documentationLink'
| 'emptyPrompt'
| 'indicesList'
| 'reloadButton'
| 'row'
| 'sectionLoading'
| 'systemTemplatesSwitch'
| 'tab'
| 'templatesList'
| 'templatesTable';

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import sinon, { SinonFakeServer } from 'sinon';
const API_PATH = '/api/index_management';
type HttpResponse = Record<string, any> | any[];
// Register helpers to mock HTTP Requests
const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
const setLoadTemplatesResponse = (response: HttpResponse = []) => {
server.respondWith('GET', `${API_PATH}/templates`, [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify(response),
]);
};
const setLoadIndicesResponse = (response: HttpResponse = []) => {
server.respondWith('GET', `${API_PATH}/indices`, [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify(response),
]);
};
const setDeleteTemplateResponse = (response: HttpResponse = []) => {
server.respondWith('DELETE', `${API_PATH}/templates`, [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify(response),
]);
};
return {
setLoadTemplatesResponse,
setLoadIndicesResponse,
setDeleteTemplateResponse,
};
};
export const init = () => {
const server = sinon.fakeServer.create();
server.respondImmediately = true;
// Define default response for unhandled requests.
// We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry,
// and we can mock them all with a 200 instead of mocking each one individually.
server.respondWith([200, {}, 'DefaultResponse']);
const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server);
return {
server,
httpRequestsMockHelpers,
};
};

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { setup as homeSetup } from './home.helpers';
export { nextTick, getRandomString, findTestSubject, TestBed } from '../../../../../../test_utils';
export { setupEnvironment } from './setup_environment';
export const pageHelpers = {
home: { setup: homeSetup },
};

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import axios from 'axios';
import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import { init as initHttpRequests } from './http_requests';
import { setHttpClient } from '../../../public/services/api';
const mockHttpClient = axios.create({ adapter: axiosXhrAdapter });
export const setupEnvironment = () => {
const { server, httpRequestsMockHelpers } = initHttpRequests();
// @ts-ignore
setHttpClient(mockHttpClient);
return {
server,
httpRequestsMockHelpers,
};
};

View file

@ -0,0 +1,304 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { act } from 'react-dom/test-utils';
import * as fixtures from '../../test/fixtures';
import {
setupEnvironment,
pageHelpers,
nextTick,
getRandomString,
findTestSubject,
} from './helpers';
import { IdxMgmtHomeTestBed } from './helpers/home.helpers';
const API_PATH = '/api/index_management';
const { setup } = pageHelpers.home;
const removeWhiteSpaceOnArrayValues = (array: any[]) =>
array.map(value => {
if (!value.trim) {
return value;
}
return value.trim();
});
// We need to skip the tests until react 16.9.0 is released
// which supports asynchronous code inside act()
describe.skip('<IndexManagementHome />', () => {
const { server, httpRequestsMockHelpers } = setupEnvironment();
let testBed: IdxMgmtHomeTestBed;
afterAll(() => {
server.restore();
});
describe('on component mount', async () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadIndicesResponse([]);
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
const { component } = testBed;
await nextTick();
component.update();
});
});
test('should set the correct app title', () => {
const { exists, find } = testBed;
expect(exists('appTitle')).toBe(true);
expect(find('appTitle').text()).toEqual('Index Management');
});
test('should have a link to the documentation', () => {
const { exists, find } = testBed;
expect(exists('documentationLink')).toBe(true);
expect(find('documentationLink').text()).toBe('Index Management docs');
});
describe('tabs', () => {
test('should have 2 tabs', () => {
const { find } = testBed;
expect(find('tab').length).toBe(2);
expect(find('tab').map(t => t.text())).toEqual(['Indices', 'Index Templates']);
});
test('should navigate to Index Templates tab', async () => {
const { exists, actions, component } = testBed;
expect(exists('indicesList')).toBe(true);
expect(exists('templatesList')).toBe(false);
httpRequestsMockHelpers.setLoadTemplatesResponse([]);
actions.selectTab('index templates');
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
component.update();
});
expect(exists('indicesList')).toBe(false);
expect(exists('templatesList')).toBe(true);
});
});
describe('index templates', () => {
describe('when there are no index templates', () => {
beforeEach(async () => {
const { actions, component } = testBed;
httpRequestsMockHelpers.setLoadTemplatesResponse([]);
actions.selectTab('index templates');
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
component.update();
});
});
test('should display an empty prompt', async () => {
const { exists } = testBed;
expect(exists('sectionLoading')).toBe(false);
expect(exists('emptyPrompt')).toBe(true);
});
});
describe('when there are index templates', () => {
const template1 = fixtures.getTemplate({
name: `a${getRandomString()}`,
indexPatterns: ['template1Pattern1*', 'template1Pattern2'],
settings: {
index: {
number_of_shards: '1',
lifecycle: {
name: 'my_ilm_policy',
},
},
},
});
const template2 = fixtures.getTemplate({
name: `b${getRandomString()}`,
indexPatterns: ['template2Pattern1*'],
});
const template3 = fixtures.getTemplate({
name: `.c${getRandomString()}`, // mock system template
indexPatterns: ['template3Pattern1*', 'template3Pattern2', 'template3Pattern3'],
});
const templates = [template1, template2, template3];
beforeEach(async () => {
const { actions, component } = testBed;
httpRequestsMockHelpers.setLoadTemplatesResponse(templates);
actions.selectTab('index templates');
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
component.update();
});
});
test('should list them in the table', async () => {
const { table } = testBed;
const { tableCellsValues } = table.getMetaData('templatesTable');
tableCellsValues.forEach((row, i) => {
const template = templates[i];
const { name, indexPatterns, order, settings } = template;
const ilmPolicyName =
settings && settings.index && settings.index.lifecycle
? settings.index.lifecycle.name
: '';
expect(removeWhiteSpaceOnArrayValues(row)).toEqual([
'',
name,
indexPatterns.join(', '),
ilmPolicyName,
order.toString(),
'',
'',
'',
'',
]);
});
});
test('should have a button to reload the index templates', async () => {
const { component, exists, actions } = testBed;
const totalRequests = server.requests.length;
expect(exists('reloadButton')).toBe(true);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickReloadButton();
await nextTick();
component.update();
});
expect(server.requests.length).toBe(totalRequests + 1);
expect(server.requests[server.requests.length - 1].url).toBe(`${API_PATH}/templates`);
});
test('should have a switch to view system templates', async () => {
const { table, exists, component, form } = testBed;
const { rows } = table.getMetaData('templatesTable');
expect(rows.length).toEqual(
templates.filter(template => !template.name.startsWith('.')).length
);
expect(exists('systemTemplatesSwitch')).toBe(true);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
form.toggleEuiSwitch('systemTemplatesSwitch');
await nextTick();
component.update();
});
const { rows: updatedRows } = table.getMetaData('templatesTable');
expect(updatedRows.length).toEqual(templates.length);
});
describe('delete index template', () => {
test('should have action buttons on each row to delete an index template', () => {
const { table } = testBed;
const { rows } = table.getMetaData('templatesTable');
const lastColumn = rows[0].columns[rows[0].columns.length - 1].reactWrapper;
expect(findTestSubject(lastColumn, 'deleteTemplateButton').length).toBe(1);
});
test('should show a confirmation when clicking the delete template button', async () => {
const { actions } = testBed;
await actions.clickTemplateActionAt(0, 'delete');
// We need to read the document "body" as the modal is added there and not inside
// the component DOM tree.
expect(
document.body.querySelector('[data-test-subj="deleteTemplatesConfirmation"]')
).not.toBe(null);
expect(
document.body.querySelector('[data-test-subj="deleteTemplatesConfirmation"]')!
.textContent
).toContain('Delete template');
});
test('should show a warning message when attempting to delete a system template', async () => {
const { component, form, actions } = testBed;
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
form.toggleEuiSwitch('systemTemplatesSwitch');
await nextTick();
component.update();
});
await actions.clickTemplateActionAt(0, 'delete');
expect(
document.body.querySelector('[data-test-subj="deleteSystemTemplateCallOut"]')
).not.toBe(null);
});
test('should send the correct HTTP request to delete an index template', async () => {
const { component, actions, table } = testBed;
const { rows } = table.getMetaData('templatesTable');
const watchId = rows[0].columns[2].value;
await actions.clickTemplateActionAt(0, 'delete');
const modal = document.body.querySelector(
'[data-test-subj="deleteTemplatesConfirmation"]'
);
const confirmButton: HTMLButtonElement | null = modal!.querySelector(
'[data-test-subj="confirmModalConfirmButton"]'
);
httpRequestsMockHelpers.setDeleteTemplateResponse({
results: {
successes: [watchId],
errors: [],
},
});
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
confirmButton!.click();
await nextTick();
component.update();
});
const latestRequest = server.requests[server.requests.length - 1];
expect(latestRequest.method).toBe('DELETE');
expect(latestRequest.url).toBe(`${API_PATH}/templates/${template1.name}`);
});
});
});
});
});
});

View file

@ -37,4 +37,7 @@ export {
UIM_DETAIL_PANEL_MAPPING_TAB,
UIM_DETAIL_PANEL_STATS_TAB,
UIM_DETAIL_PANEL_EDIT_SETTINGS_TAB,
UIM_TEMPLATE_LIST_LOAD,
UIM_TEMPLATE_DELETE,
UIM_TEMPLATE_DELETE_MANY,
} from './ui_metric';

View file

@ -4,13 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { i18n } from '@kbn/i18n';
import { LICENSE_TYPE_BASIC } from '../../../../common/constants';
export const PLUGIN = {
ID: 'index_management',
NAME: i18n.translate('xpack.idxMgmt.appTitle', {
defaultMessage: 'Index Management'
defaultMessage: 'Index Management',
}),
MINIMUM_LICENSE_REQUIRED: LICENSE_TYPE_BASIC,
};

View file

@ -33,3 +33,6 @@ export const UIM_DETAIL_PANEL_MAPPING_TAB = 'detail_panel_mapping_tab';
export const UIM_DETAIL_PANEL_SETTINGS_TAB = 'detail_panel_settings_tab';
export const UIM_DETAIL_PANEL_STATS_TAB = 'detail_panel_stats_tab';
export const UIM_DETAIL_PANEL_SUMMARY_TAB = 'detail_panel_summary_tab';
export const UIM_TEMPLATE_LIST_LOAD = 'template_list_load';
export const UIM_TEMPLATE_DELETE = 'template_delete';
export const UIM_TEMPLATE_DELETE_MANY = 'template_delete_many';

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { IndexList } from './index_list';
export * from './templates';

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface Template {
name: string;
version: number;
order: number;
indexPatterns: string[];
settings?: {
index?: {
[key: string]: any;
lifecycle?: {
name: string;
};
};
};
aliases?: object;
mappings?: object;
}

View file

@ -7,6 +7,7 @@
import { resolve } from 'path';
import { createRouter } from '../../server/lib/create_router';
import { registerIndicesRoutes } from './server/routes/api/indices';
import { registerTemplatesRoutes } from './server/routes/api/templates';
import { registerMappingRoute } from './server/routes/api/mapping';
import { registerSettingsRoutes } from './server/routes/api/settings';
import { registerStatsRoute } from './server/routes/api/stats';
@ -31,6 +32,7 @@ export function indexManagement(kibana) {
server.expose('addIndexManagementDataEnricher', addIndexManagementDataEnricher);
registerLicenseChecker(server, PLUGIN.ID, PLUGIN.NAME, PLUGIN.MINIMUM_LICENSE_REQUIRED);
registerIndicesRoutes(router);
registerTemplatesRoutes(router);
registerSettingsRoutes(router);
registerStatsRoute(router);
registerMappingRoute(router);

View file

@ -7,7 +7,7 @@
import React, { useEffect } from 'react';
import { HashRouter, Switch, Route, Redirect } from 'react-router-dom';
import { BASE_PATH, UIM_APP_LOAD } from '../common/constants';
import { IndexList } from './sections/index_list';
import { IndexManagementHome } from './sections/home';
import { trackUiMetric } from './services';
export const App = () => {
@ -23,8 +23,7 @@ export const App = () => {
// Exoprt this so we can test it with a different router.
export const AppWithoutRouter = () => (
<Switch>
<Redirect exact from={`${BASE_PATH}`} to={`${BASE_PATH}indices`}/>
<Route exact path={`${BASE_PATH}indices`} component={IndexList} />
<Route path={`${BASE_PATH}indices/filter/:filter?`} component={IndexList}/>
<Route path={`${BASE_PATH}:section(indices|templates)`} component={IndexManagementHome} />
<Redirect from={`${BASE_PATH}`} to={`${BASE_PATH}indices`}/>
</Switch>
);

View file

@ -0,0 +1,168 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiConfirmModal, EuiOverlayMask, EuiCallOut, EuiCheckbox } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { Fragment, useState } from 'react';
import { toastNotifications } from 'ui/notify';
import { deleteTemplates } from '../services/api';
import { Template } from '../../common/types';
export const DeleteTemplatesModal = ({
templatesToDelete,
callback,
}: {
templatesToDelete: Array<Template['name']>;
callback: (data?: { hasDeletedTemplates: boolean }) => void;
}) => {
const numTemplatesToDelete = templatesToDelete.length;
if (!numTemplatesToDelete) {
return null;
}
const hasSystemTemplate = Boolean(
templatesToDelete.find(templateName => templateName.startsWith('.'))
);
const handleDeleteTemplates = () => {
deleteTemplates(templatesToDelete).then(({ data: { templatesDeleted, errors }, error }) => {
const hasDeletedTemplates = templatesDeleted && templatesDeleted.length;
if (hasDeletedTemplates) {
const successMessage = i18n.translate(
'xpack.idxMgmt.deleteTemplatesModal.successNotificationMessageText',
{
defaultMessage: 'Deleted {numSuccesses, plural, one {# template} other {# templates}}',
values: { numSuccesses: templatesDeleted.length },
}
);
callback({ hasDeletedTemplates });
toastNotifications.addSuccess(successMessage);
}
if (error || (errors && errors.length)) {
const hasMultipleErrors =
(errors && errors.length > 1) || (error && templatesToDelete.length > 1);
const errorMessage = hasMultipleErrors
? i18n.translate(
'xpack.idxMgmt.deleteTemplatesModal.multipleErrorsNotificationMessageText',
{
defaultMessage: 'Error deleting {count} templates',
values: {
count: (errors && errors.length) || templatesToDelete.length,
},
}
)
: i18n.translate('xpack.idxMgmt.deleteTemplatesModal.errorNotificationMessageText', {
defaultMessage: "Error deleting template '{name}'",
values: { name: (errors && errors[0].name) || templatesToDelete[0] },
});
toastNotifications.addDanger(errorMessage);
}
});
};
const handleOnCancel = () => {
setIsDeleteConfirmed(false);
callback();
};
const [isDeleteConfirmed, setIsDeleteConfirmed] = useState<boolean>(false);
return (
<EuiOverlayMask>
<EuiConfirmModal
buttonColor="danger"
data-test-subj="deleteTemplatesConfirmation"
title={
<FormattedMessage
id="xpack.idxMgmt.deleteTemplatesModal.modalTitleText"
defaultMessage="Delete {numTemplatesToDelete, plural, one {template} other {# templates}}"
values={{ numTemplatesToDelete }}
/>
}
onCancel={handleOnCancel}
onConfirm={handleDeleteTemplates}
cancelButtonText={
<FormattedMessage
id="xpack.idxMgmt.deleteTemplatesModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
}
confirmButtonText={
<FormattedMessage
id="xpack.idxMgmt.deleteTemplatesModal.confirmButtonLabel"
defaultMessage="Delete {numTemplatesToDelete, plural, one {template} other {templates} }"
values={{ numTemplatesToDelete }}
/>
}
confirmButtonDisabled={hasSystemTemplate ? !isDeleteConfirmed : false}
>
<Fragment>
<p>
<FormattedMessage
id="xpack.idxMgmt.deleteTemplatesModal.deleteDescription"
defaultMessage="You are about to delete {numTemplatesToDelete, plural, one {this template} other {these templates} }:"
values={{ numTemplatesToDelete }}
/>
</p>
<ul>
{templatesToDelete.map(template => (
<li key={template}>
{template}
{template.startsWith('.') ? (
<Fragment>
{' '}
<FormattedMessage
id="xpack.idxMgmt.deleteTemplatesModal.systemTemplateLabel"
defaultMessage="(System template)"
/>
</Fragment>
) : null}
</li>
))}
</ul>
{hasSystemTemplate && (
<EuiCallOut
title={
<FormattedMessage
id="xpack.idxMgmt.deleteTemplatesModal.proceedWithCautionCallOutTitle"
defaultMessage="Deleting a system template can break Kibana"
/>
}
color="danger"
iconType="alert"
data-test-subj="deleteSystemTemplateCallOut"
>
<p>
<FormattedMessage
id="xpack.idxMgmt.deleteTemplatesModal.proceedWithCautionCallOutDescription"
defaultMessage="System templates are critical for internal operations.
Deleting a template cannot be undone."
/>
</p>
<EuiCheckbox
id="confirmDeleteTemplatesCheckbox"
label={
<FormattedMessage
id="xpack.idxMgmt.deleteTemplatesModal.confirmDeleteCheckboxLabel"
defaultMessage="I understand the consequences of deleting a system template"
/>
}
checked={isDeleteConfirmed}
onChange={e => setIsDeleteConfirmed(e.target.checked)}
/>
</EuiCallOut>
)}
</Fragment>
</EuiConfirmModal>
</EuiOverlayMask>
);
};

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { SectionError } from './section_error';
export { SectionLoading } from './section_loading';
export { NoMatch } from './no_match';
export { PageErrorForbidden } from './page_error';
export { DeleteTemplatesModal } from './delete_templates_modal';

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import React, { Fragment } from 'react';
interface Props {
title: React.ReactNode;
error: {
data: {
error: string;
cause?: string[];
message?: string;
};
};
}
export const SectionError: React.FunctionComponent<Props> = ({ title, error, ...rest }) => {
const {
error: errorString,
cause, // wrapEsError() on the server adds a "cause" array
message,
} = error.data;
return (
<EuiCallOut title={title} color="danger" iconType="alert" {...rest}>
<div>{message || errorString}</div>
{cause && (
<Fragment>
<EuiSpacer size="m" />
<ul>
{cause.map((causeMsg, i) => (
<li key={i}>{causeMsg}</li>
))}
</ul>
</Fragment>
)}
</EuiCallOut>
);
};

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui';
interface Props {
children: React.ReactNode;
}
export const SectionLoading: React.FunctionComponent<Props> = ({ children }) => {
return (
<EuiEmptyPrompt
title={<EuiLoadingSpinner size="xl" />}
body={<EuiText color="subdued">{children}</EuiText>}
data-test-subj="sectionLoading"
/>
);
};

View file

@ -6,5 +6,10 @@
import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links';
const esBase = `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}`;
const base = `${ELASTIC_WEBSITE_URL}guide/en`;
const esBase = `${base}/elasticsearch/reference/${DOC_LINK_VERSION}`;
const kibanaBase = `${base}/kibana/${DOC_LINK_VERSION}`;
export const settingsDocumentationLink = `${esBase}/index-modules.html#index-modules-settings`;
export const idxMgmtDocumentationLink = `${kibanaBase}/managing-indices.html`;

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { Route, RouteComponentProps, Switch } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiPageBody,
EuiPageContent,
EuiSpacer,
EuiTab,
EuiTabs,
EuiTitle,
} from '@elastic/eui';
import { BASE_PATH } from '../../../common/constants';
import { idxMgmtDocumentationLink } from '../../lib/documentation_links';
import { IndexList } from './index_list';
import { TemplatesList } from './templates_list';
type Section = 'indices' | 'templates';
interface MatchParams {
section: Section;
}
export const IndexManagementHome: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { section },
},
history,
}) => {
const tabs = [
{
id: 'indices' as Section,
name: <FormattedMessage id="xpack.idxMgmt.home.indicesTabTitle" defaultMessage="Indices" />,
},
{
id: 'templates' as Section,
name: (
<FormattedMessage
id="xpack.idxMgmt.home.indexTemplatesTabTitle"
defaultMessage="Index Templates"
/>
),
},
];
const onSectionChange = (newSection: Section) => {
history.push(`${BASE_PATH}${newSection}`);
};
return (
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="l">
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={true}>
<h1 data-test-subj="appTitle">
<FormattedMessage
id="xpack.idxMgmt.home.appTitle"
defaultMessage="Index Management"
/>
</h1>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
href={idxMgmtDocumentationLink}
target="_blank"
iconType="help"
data-test-subj="documentationLink"
>
<FormattedMessage
id="xpack.idxMgmt.home.idxMgmtDocsLinkText"
defaultMessage="Index Management docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiTitle>
<EuiSpacer size="m" />
<EuiTabs>
{tabs.map(tab => (
<EuiTab
onClick={() => onSectionChange(tab.id)}
isSelected={tab.id === section}
key={tab.id}
data-test-subj="tab"
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
<EuiSpacer size="m" />
<Switch>
<Route exact path={`${BASE_PATH}indices`} component={IndexList} />
<Route exact path={`${BASE_PATH}indices/filter/:filter?`} component={IndexList} />
<Route exact path={`${BASE_PATH}templates`} component={TemplatesList} />
</Switch>
</EuiPageContent>
</EuiPageBody>
);
};

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { NoMatch } from './components/no_match';
export { IndexManagementHome } from './home';

View file

@ -29,7 +29,7 @@ import {
TAB_STATS,
TAB_EDIT_SETTINGS,
} from '../../../../constants';
import { IndexActionsContextMenu } from '../../components';
import { IndexActionsContextMenu } from '../index_actions_context_menu';
import { ShowJson } from './show_json';
import { Summary } from './summary';
import { EditSettingsJson } from './edit_settings_json';

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { PageErrorForbidden } from './components';
export * from './index_list';

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { IndexList } from './components/index_list';
export declare function IndexList(match: any): any;

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { DetailPanel } from './detail_panel';
import { IndexTable } from './index_table';
export function IndexList({
match: {
params: {
filter
}
}
}) {
return (
<div className="im-snapshotTestSubject" data-test-subj="indicesList" >
<IndexTable filterFromURI={filter}/>
<DetailPanel />
</div>
);
}

View file

@ -30,9 +30,8 @@ import {
EuiTableRow,
EuiTableRowCell,
EuiTableRowCellCheckbox,
EuiTitle,
EuiText,
EuiPageContent,
EuiTitle,
} from '@elastic/eui';
import { UIM_SHOW_DETAILS_CLICK } from '../../../../../common/constants';
@ -44,9 +43,8 @@ import {
getToggleExtensions,
} from '../../../../index_management_extensions';
import { renderBadges } from '../../../../lib/render_badges';
import { NoMatch } from '../../../no_match';
import { PageErrorForbidden } from '../../../page_error';
import { IndexActionsContextMenu } from '../../components';
import { NoMatch, PageErrorForbidden } from '../../../../components';
import { IndexActionsContextMenu } from '../index_actions_context_menu';
const HEADERS = {
name: i18n.translate('xpack.idxMgmt.indexTable.headers.nameHeader', {
@ -410,26 +408,17 @@ export class IndexTable extends Component {
}
return (
<EuiPageContent>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexEnd">
<EuiFlexItem grow={false}>
<EuiTitle size="m">
<h1 data-test-subj="sectionHeading">
<Fragment>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={true}>
<EuiTitle size="s">
<EuiText color="subdued">
<FormattedMessage
id="xpack.idxMgmt.indexTable.sectionHeading"
defaultMessage="Index Management"
/>
</h1>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
id="xpack.idxMgmt.indexTable.sectionDescription"
id="xpack.idxMgmt.home.idxMgmtDescription"
defaultMessage="Update your Elasticsearch indices individually or in bulk."
/>
</p>
</EuiText>
</EuiText>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{((indicesLoading && allIndices.length === 0) || indicesError) ? null : (
@ -454,7 +443,7 @@ export class IndexTable extends Component {
)}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiSpacer size="l" />
{this.renderBanners()}
{indicesError && this.renderError()}
<EuiFlexGroup gutterSize="l" alignItems="center">
@ -537,7 +526,7 @@ export class IndexTable extends Component {
)}
<EuiSpacer size="m" />
{indices.length > 0 ? this.renderPager() : null}
</EuiPageContent>
</Fragment>
);
}
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { TemplatesList } from './templates_list';

View file

@ -0,0 +1,119 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useState, useEffect, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiEmptyPrompt,
EuiSpacer,
EuiTitle,
EuiText,
EuiSwitch,
EuiFlexItem,
EuiFlexGroup,
} from '@elastic/eui';
import { SectionError, SectionLoading } from '../../../components';
import { TemplatesTable } from './templates_table';
import { loadIndexTemplates } from '../../../services/api';
import { Template } from '../../../../common/types';
import { trackUiMetric } from '../../../services/track_ui_metric';
import { UIM_TEMPLATE_LIST_LOAD } from '../../../../common/constants';
export const TemplatesList: React.FunctionComponent = () => {
const { error, isLoading, data: templates, createRequest: reload } = loadIndexTemplates();
let content;
const [showSystemTemplates, setShowSystemTemplates] = useState<boolean>(false);
// Filter out system index templates
const filteredTemplates = useMemo(
() =>
templates ? templates.filter((template: Template) => !template.name.startsWith('.')) : [],
[templates]
);
// Track component loaded
useEffect(() => {
trackUiMetric(UIM_TEMPLATE_LIST_LOAD);
}, []);
if (isLoading) {
content = (
<SectionLoading>
<FormattedMessage
id="xpack.idxMgmt.indexTemplatesList.loadingIndexTemplatesDescription"
defaultMessage="Loading index templates…"
/>
</SectionLoading>
);
} else if (error) {
content = (
<SectionError
title={
<FormattedMessage
id="xpack.idxMgmt.indexTemplatesList.loadingIndexTemplatesErrorMessage"
defaultMessage="Error loading index templates"
/>
}
error={error}
/>
);
} else if (Array.isArray(templates) && templates.length === 0) {
content = (
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1 data-test-subj="title">
<FormattedMessage
id="xpack.idxMgmt.indexTemplatesList.emptyPrompt.noIndexTemplatesTitle"
defaultMessage="You don't have any index templates yet"
/>
</h1>
}
data-test-subj="emptyPrompt"
/>
);
} else if (Array.isArray(templates) && templates.length > 0) {
content = (
<Fragment>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={true}>
<EuiTitle size="s">
<EuiText color="subdued">
<FormattedMessage
id="xpack.idxMgmt.home.indexTemplatesDescription"
defaultMessage="Use index templates to automatically apply mappings and other properties to new indices."
/>
</EuiText>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSwitch
id="checkboxShowSystemIndexTemplates"
data-test-subj="systemTemplatesSwitch"
checked={showSystemTemplates}
onChange={event => setShowSystemTemplates(event.target.checked)}
label={
<FormattedMessage
id="xpack.idxMgmt.indexTemplatesTable.systemIndexTemplatesSwitchLabel"
defaultMessage="Include system index templates"
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<TemplatesTable
templates={showSystemTemplates ? templates : filteredTemplates}
reload={reload}
/>
</Fragment>
);
}
return <section data-test-subj="templatesList">{content}</section>;
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { TemplatesTable } from './templates_table';

View file

@ -0,0 +1,243 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, Fragment } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiInMemoryTable, EuiIcon, EuiButton, EuiToolTip, EuiButtonIcon } from '@elastic/eui';
import { Template } from '../../../../../common/types';
import { DeleteTemplatesModal } from '../../../../components';
interface Props {
templates: Template[];
reload: () => Promise<void>;
}
const Checkmark = ({ tableCellData }: { tableCellData: object }) => {
const isChecked = Object.entries(tableCellData).length > 0;
return isChecked ? <EuiIcon type="check" /> : null;
};
export const TemplatesTable: React.FunctionComponent<Props> = ({ templates, reload }) => {
const [selection, setSelection] = useState<Template[]>([]);
const [templatesToDelete, setTemplatesToDelete] = useState<Array<Template['name']>>([]);
const columns = [
{
field: 'name',
name: i18n.translate('xpack.idxMgmt.templatesList.table.nameColumnTitle', {
defaultMessage: 'Name',
}),
truncateText: true,
sortable: true,
},
{
field: 'indexPatterns',
name: i18n.translate('xpack.idxMgmt.templatesList.table.indexPatternsColumnTitle', {
defaultMessage: 'Index patterns',
}),
truncateText: true,
sortable: true,
render: (indexPatterns: string[]) => <strong>{indexPatterns.join(', ')}</strong>,
},
{
field: 'settings',
name: i18n.translate('xpack.idxMgmt.templatesList.table.ilmPolicyColumnTitle', {
defaultMessage: 'ILM policy',
}),
truncateText: true,
sortable: true,
render: (settings?: {
index: {
lifecycle: {
name: string;
};
};
}) => {
if (settings && settings.index && settings.index.lifecycle) {
return settings.index.lifecycle.name;
}
return null;
},
},
{
field: 'order',
name: i18n.translate('xpack.idxMgmt.templatesList.table.orderColumnTitle', {
defaultMessage: 'Order',
}),
truncateText: true,
sortable: true,
},
{
field: 'mappings',
name: i18n.translate('xpack.idxMgmt.templatesList.table.mappingsColumnTitle', {
defaultMessage: 'Mappings',
}),
truncateText: true,
sortable: true,
width: '100px',
render: (mappings: object) => <Checkmark tableCellData={mappings} />,
},
{
field: 'settings',
name: i18n.translate('xpack.idxMgmt.templatesList.table.settingsColumnTitle', {
defaultMessage: 'Settings',
}),
truncateText: true,
sortable: true,
width: '100px',
render: (settings: object) => <Checkmark tableCellData={settings} />,
},
{
field: 'aliases',
name: i18n.translate('xpack.idxMgmt.templatesList.table.aliasesColumnTitle', {
defaultMessage: 'Aliases',
}),
truncateText: true,
sortable: true,
width: '100px',
render: (aliases: object) => {
return <Checkmark tableCellData={aliases} />;
},
},
{
name: i18n.translate('xpack.idxMgmt.templatesList.table.actionColumnTitle', {
defaultMessage: 'Actions',
}),
width: '75px',
actions: [
{
render: (template: Template) => {
const { name } = template;
return (
<EuiToolTip
content={i18n.translate(
'xpack.idxMgmt.templatesList.table.actionDeleteTooltipLabel',
{
defaultMessage: 'Delete',
}
)}
delay="long"
>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.idxMgmt.templatesList.table.actionDeleteAriaLabel',
{
defaultMessage: "Delete template '{name}'",
values: { name },
}
)}
iconType="trash"
color="danger"
onClick={() => {
setTemplatesToDelete([name]);
}}
data-test-subj="deleteTemplateButton"
/>
</EuiToolTip>
);
},
},
],
},
];
const pagination = {
initialPageSize: 20,
pageSizeOptions: [10, 20, 50],
};
const sorting = {
sort: {
field: 'name',
direction: 'asc',
},
};
const selectionConfig = {
onSelectionChange: setSelection,
};
const searchConfig = {
box: {
incremental: true,
},
filters: [
{
type: 'is',
field: 'settings.index.lifecycle.name',
name: i18n.translate('xpack.idxMgmt.templatesList.table.ilmPolicyFilterLabel', {
defaultMessage: 'ILM policy',
}),
},
],
toolsLeft: selection.length && (
<EuiButton
data-test-subj="deleteTemplatesButton"
onClick={() => setTemplatesToDelete(selection.map((selected: Template) => selected.name))}
color="danger"
>
<FormattedMessage
id="xpack.idxMgmt.templatesList.table.deleteTemplatesButtonLabel"
defaultMessage="Delete {count, plural, one {template} other {templates} }"
values={{ count: selection.length }}
/>
</EuiButton>
),
toolsRight: (
<EuiButton
color="secondary"
iconType="refresh"
onClick={reload}
data-test-subj="reloadButton"
>
<FormattedMessage
id="xpack.idxMgmt.templatesList.table.reloadTemplatesButtonLabel"
defaultMessage="Reload"
/>
</EuiButton>
),
};
return (
<Fragment>
<DeleteTemplatesModal
callback={data => {
if (data && data.hasDeletedTemplates) {
reload();
}
setTemplatesToDelete([]);
}}
templatesToDelete={templatesToDelete}
/>
<EuiInMemoryTable
items={templates}
itemId="name"
columns={columns}
search={searchConfig}
sorting={sorting}
isSelectable={true}
selection={selectionConfig}
pagination={pagination}
rowProps={() => ({
'data-test-subj': 'row',
})}
cellProps={() => ({
'data-test-subj': 'cell',
})}
data-test-subj="templatesTable"
message={
<FormattedMessage
id="xpack.idxMgmt.templatesList.table.noIndexTemplatesMessage"
defaultMessage="No templates found"
/>
}
/>
</Fragment>
);
};

View file

@ -1,10 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { IndexActionsContextMenu } from './index_actions_context_menu';
export { IndexList } from './index_list';
export { DetailPanel } from './detail_panel';
export { IndexTable } from './index_table';

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
DetailPanel,
IndexTable,
} from '../../components';
export class IndexList extends React.PureComponent {
render() {
const {
match: {
params: {
filter
}
},
} = this.props;
return (
<div className="im-snapshotTestSubject">
<IndexTable filterFromURI={filter}/>
<DetailPanel />
</div>
);
}
}

View file

@ -26,19 +26,19 @@ import {
UIM_INDEX_REFRESH_MANY,
UIM_INDEX_UNFREEZE,
UIM_INDEX_UNFREEZE_MANY,
UIM_TEMPLATE_DELETE,
UIM_TEMPLATE_DELETE_MANY,
} from '../../common/constants';
import {
TAB_SETTINGS,
TAB_MAPPING,
TAB_STATS,
} from '../constants';
import { TAB_SETTINGS, TAB_MAPPING, TAB_STATS } from '../constants';
import { trackUiMetric } from './track_ui_metric';
import { useRequest, sendRequest } from './use_request';
import { Template } from '../../common/types';
let httpClient;
let httpClient: ng.IHttpService;
export const setHttpClient = (client) => {
export const setHttpClient = (client: ng.IHttpService) => {
httpClient = client;
};
@ -53,17 +53,17 @@ export async function loadIndices() {
return response.data;
}
export async function reloadIndices(indexNames) {
export async function reloadIndices(indexNames: string[]) {
const body = {
indexNames
indexNames,
};
const response = await httpClient.post(`${apiPrefix}/indices/reload`, body);
return response.data;
}
export async function closeIndices(indices) {
export async function closeIndices(indices: string[]) {
const body = {
indices
indices,
};
const response = await httpClient.post(`${apiPrefix}/indices/close`, body);
// Only track successful requests.
@ -72,9 +72,9 @@ export async function closeIndices(indices) {
return response.data;
}
export async function deleteIndices(indices) {
export async function deleteIndices(indices: string[]) {
const body = {
indices
indices,
};
const response = await httpClient.post(`${apiPrefix}/indices/delete`, body);
// Only track successful requests.
@ -83,9 +83,9 @@ export async function deleteIndices(indices) {
return response.data;
}
export async function openIndices(indices) {
export async function openIndices(indices: string[]) {
const body = {
indices
indices,
};
const response = await httpClient.post(`${apiPrefix}/indices/open`, body);
// Only track successful requests.
@ -94,9 +94,9 @@ export async function openIndices(indices) {
return response.data;
}
export async function refreshIndices(indices) {
export async function refreshIndices(indices: string[]) {
const body = {
indices
indices,
};
const response = await httpClient.post(`${apiPrefix}/indices/refresh`, body);
// Only track successful requests.
@ -105,9 +105,9 @@ export async function refreshIndices(indices) {
return response.data;
}
export async function flushIndices(indices) {
export async function flushIndices(indices: string[]) {
const body = {
indices
indices,
};
const response = await httpClient.post(`${apiPrefix}/indices/flush`, body);
// Only track successful requests.
@ -116,10 +116,10 @@ export async function flushIndices(indices) {
return response.data;
}
export async function forcemergeIndices(indices, maxNumSegments) {
export async function forcemergeIndices(indices: string[], maxNumSegments: string) {
const body = {
indices,
maxNumSegments
maxNumSegments,
};
const response = await httpClient.post(`${apiPrefix}/indices/forcemerge`, body);
// Only track successful requests.
@ -128,9 +128,9 @@ export async function forcemergeIndices(indices, maxNumSegments) {
return response.data;
}
export async function clearCacheIndices(indices) {
export async function clearCacheIndices(indices: string[]) {
const body = {
indices
indices,
};
const response = await httpClient.post(`${apiPrefix}/indices/clear_cache`, body);
// Only track successful requests.
@ -138,9 +138,9 @@ export async function clearCacheIndices(indices) {
trackUiMetric(actionType);
return response.data;
}
export async function freezeIndices(indices) {
export async function freezeIndices(indices: string[]) {
const body = {
indices
indices,
};
const response = await httpClient.post(`${apiPrefix}/indices/freeze`, body);
// Only track successful requests.
@ -148,9 +148,9 @@ export async function freezeIndices(indices) {
trackUiMetric(actionType);
return response.data;
}
export async function unfreezeIndices(indices) {
export async function unfreezeIndices(indices: string[]) {
const body = {
indices
indices,
};
const response = await httpClient.post(`${apiPrefix}/indices/unfreeze`, body);
// Only track successful requests.
@ -159,29 +159,29 @@ export async function unfreezeIndices(indices) {
return response.data;
}
export async function loadIndexSettings(indexName) {
export async function loadIndexSettings(indexName: string) {
const response = await httpClient.get(`${apiPrefix}/settings/${indexName}`);
return response.data;
}
export async function updateIndexSettings(indexName, settings) {
export async function updateIndexSettings(indexName: string, settings: object) {
const response = await httpClient.put(`${apiPrefix}/settings/${indexName}`, settings);
// Only track successful requests.
trackUiMetric(UIM_UPDATE_SETTINGS);
return response;
}
export async function loadIndexStats(indexName) {
export async function loadIndexStats(indexName: string) {
const response = await httpClient.get(`${apiPrefix}/stats/${indexName}`);
return response.data;
}
export async function loadIndexMapping(indexName) {
export async function loadIndexMapping(indexName: string) {
const response = await httpClient.get(`${apiPrefix}/mapping/${indexName}`);
return response.data;
}
export async function loadIndexData(type, indexName) {
export async function loadIndexData(type: string, indexName: string) {
switch (type) {
case TAB_MAPPING:
return loadIndexMapping(indexName);
@ -193,3 +193,20 @@ export async function loadIndexData(type, indexName) {
return loadIndexStats(indexName);
}
}
export function loadIndexTemplates() {
return useRequest({
path: `${apiPrefix}/templates`,
method: 'get',
});
}
export const deleteTemplates = async (names: Array<Template['name']>) => {
const uimActionType = names.length > 1 ? UIM_TEMPLATE_DELETE_MANY : UIM_TEMPLATE_DELETE;
return sendRequest({
path: `${apiPrefix}/templates/${names.map(name => encodeURIComponent(name)).join(',')}`,
method: 'delete',
uimActionType,
});
};

View file

@ -4,7 +4,25 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './api';
export {
loadIndices,
reloadIndices,
closeIndices,
deleteIndices,
openIndices,
refreshIndices,
flushIndices,
forcemergeIndices,
clearCacheIndices,
freezeIndices,
unfreezeIndices,
loadIndexSettings,
updateIndexSettings,
loadIndexStats,
loadIndexMapping,
loadIndexData,
loadIndexTemplates,
} from './api';
export { sortTable } from './sort_table';
export { filterItems } from './filter_items';
export { healthToColor } from './health_to_color';

View file

@ -7,6 +7,6 @@
import { trackUiMetric as track } from '../../../../../../src/legacy/core_plugins/ui_metric/public';
import { UIM_APP_NAME } from '../../common/constants';
export function trackUiMetric(metricType) {
export function trackUiMetric(metricType: string) {
track(UIM_APP_NAME, metricType);
}

View file

@ -0,0 +1,131 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState } from 'react';
import { getHttpClient } from './api';
import { trackUiMetric } from './track_ui_metric';
interface SendRequest {
path?: string;
method: string;
body?: any;
uimActionType?: string;
}
interface SendRequestResponse {
data: any;
error: Error;
}
export const sendRequest = async ({
path,
method,
body,
uimActionType,
}: SendRequest): Promise<Partial<SendRequestResponse>> => {
try {
const response = await (getHttpClient() as any)[method](path, body);
if (typeof response.data === 'undefined') {
throw new Error(response.statusText);
}
// Track successful request
if (uimActionType) {
trackUiMetric(uimActionType);
}
return {
data: response.data,
};
} catch (e) {
return {
error: e.response ? e.response : e,
};
}
};
interface UseRequest extends SendRequest {
interval?: number;
initialData?: any;
processData?: any;
}
export const useRequest = ({
path,
method,
body,
interval,
initialData,
processData,
}: UseRequest) => {
const [error, setError] = useState<null | any>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [data, setData] = useState<any>(initialData);
// Tied to every render and bound to each request.
let isOutdatedRequest = false;
const createRequest = async (isInitialRequest = true) => {
// Set a neutral state for a non-request.
if (!path) {
setError(null);
setData(initialData);
setIsLoading(false);
return;
}
setError(null);
// Only set loading state to true and initial data on the first request
if (isInitialRequest) {
setIsLoading(true);
setData(initialData);
}
const { data: responseData, error: responseError } = await sendRequest({
path,
method,
body,
});
// Don't update state if an outdated request has resolved.
if (isOutdatedRequest) {
return;
}
setError(responseError);
setData(processData && responseData ? processData(responseData) : responseData);
setIsLoading(false);
};
useEffect(() => {
function cancelOutdatedRequest() {
isOutdatedRequest = true;
}
createRequest();
if (interval) {
const intervalRequest = setInterval(createRequest.bind(null, false), interval);
return () => {
cancelOutdatedRequest();
clearInterval(intervalRequest);
};
}
// Called when a new render will trigger this effect.
return cancelOutdatedRequest;
}, [path]);
return {
error,
isLoading,
data,
createRequest,
};
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export declare function indexManagementStore(): any;

View file

@ -11,7 +11,7 @@ import { defaultTableState } from './reducers/table_state';
import { indexManagement } from './reducers/';
export const indexManagementStore = () => {
export function indexManagementStore() {
const toggleNameToVisibleMap = {};
getToggleExtensions().forEach((toggleExtension) => {
toggleNameToVisibleMap[toggleExtension.name] = false;
@ -25,4 +25,4 @@ export const indexManagementStore = () => {
initialState,
compose(...enhancers)
);
};
}

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const fetchTemplates = async (callWithRequest: any) => {
const indexTemplatesByName = await callWithRequest('indices.getTemplate');
const indexTemplateNames = Object.keys(indexTemplatesByName);
const indexTemplates = indexTemplateNames.map(name => {
const {
version,
order,
index_patterns: indexPatterns = [],
settings = {},
aliases = {},
mappings = {},
} = indexTemplatesByName[name];
return {
name,
version,
order,
indexPatterns: indexPatterns.sort(),
settings,
aliases,
mappings,
};
});
return indexTemplates;
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { registerTemplatesRoutes } from './register_templates_routes';

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import {
Router,
RouterRouteHandler,
wrapCustomError,
} from '../../../../../../server/lib/create_router';
import { Template } from '../../../../common/types';
const handler: RouterRouteHandler = async (req, callWithRequest) => {
const {
name = '',
order,
version,
settings = {},
mappings = {},
aliases = {},
indexPatterns = [],
} = req.payload as Template;
const conflictError = wrapCustomError(
new Error(
i18n.translate('xpack.idxMgmt.createRoute.duplicateTemplateIdErrorMessage', {
defaultMessage: "There is already a template with name '{name}'.",
values: {
name,
},
})
),
409
);
// Check that template with the same name doesn't already exist
try {
const templateExists = await callWithRequest('indices.existsTemplate', { name });
if (templateExists) {
throw conflictError;
}
} catch (e) {
// Rethrow conflict error but silently swallow all others
if (e === conflictError) {
throw e;
}
}
// Otherwise create new index template
return await callWithRequest('indices.putTemplate', {
name,
order,
body: {
index_patterns: indexPatterns,
version,
settings,
mappings,
aliases,
},
});
};
export function registerCreateRoute(router: Router) {
router.put('templates', handler);
}

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
Router,
RouterRouteHandler,
wrapEsError,
} from '../../../../../../server/lib/create_router';
import { Template } from '../../../../common/types';
const handler: RouterRouteHandler = async (req, callWithRequest) => {
const { names } = req.params;
const templateNames = names.split(',');
const response: { templatesDeleted: Array<Template['name']>; errors: any[] } = {
templatesDeleted: [],
errors: [],
};
await Promise.all(
templateNames.map(async name => {
try {
await callWithRequest('indices.deleteTemplate', { name });
return response.templatesDeleted.push(name);
} catch (e) {
return response.errors.push({
name,
error: wrapEsError(e),
});
}
})
);
return response;
};
export function registerDeleteRoute(router: Router) {
router.delete('templates/{names}', handler);
}

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Router, RouterRouteHandler } from '../../../../../../server/lib/create_router';
import { fetchTemplates } from '../../../lib/fetch_templates';
const handler: RouterRouteHandler = async (_req, callWithRequest) => {
return fetchTemplates(callWithRequest);
};
export function registerListRoute(router: Router) {
router.get('templates', handler);
}

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Router } from '../../../../../../server/lib/create_router';
import { registerListRoute } from './register_list_route';
import { registerDeleteRoute } from './register_delete_route';
import { registerCreateRoute } from './register_create_route';
export function registerTemplatesRoutes(router: Router) {
registerListRoute(router);
registerDeleteRoute(router);
registerCreateRoute(router);
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './template';

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getRandomString, getRandomNumber } from '../../../../../test_utils';
import { Template } from '../../common/types';
export const getTemplate = ({
name = getRandomString(),
version = getRandomNumber(),
order = getRandomNumber(),
indexPatterns = [],
settings = {},
aliases = {},
mappings = {},
}: Partial<Template> = {}): Template => ({
name,
version,
order,
indexPatterns,
settings,
aliases,
mappings,
});

View file

@ -4883,8 +4883,6 @@
"xpack.idxMgmt.indexTable.headers.storageSizeHeader": "ストレージサイズ",
"xpack.idxMgmt.indexTable.invalidSearchErrorMessage": "無効な検索: {errorMessage}",
"xpack.idxMgmt.indexTable.reloadIndicesButton": "インデックスを再読み込み",
"xpack.idxMgmt.indexTable.sectionDescription": "Elasticsearch インデックスを個々に、または一斉に更新します",
"xpack.idxMgmt.indexTable.sectionHeading": "インデックス管理",
"xpack.idxMgmt.indexTable.serverErrorTitle": "インデックスの読み込み中にエラーが発生",
"xpack.idxMgmt.indexTable.systemIndicesSearchIndicesAriaLabel": "インデックスの検索",
"xpack.idxMgmt.indexTable.systemIndicesSearchInputPlaceholder": "検索",

View file

@ -4884,8 +4884,6 @@
"xpack.idxMgmt.indexTable.headers.storageSizeHeader": "存储大小",
"xpack.idxMgmt.indexTable.invalidSearchErrorMessage": "无效搜索:{errorMessage}",
"xpack.idxMgmt.indexTable.reloadIndicesButton": "重载索引",
"xpack.idxMgmt.indexTable.sectionDescription": "单个或批量更新您的 Elasticsearch 索引。",
"xpack.idxMgmt.indexTable.sectionHeading": "索引管理",
"xpack.idxMgmt.indexTable.serverErrorTitle": "加载索引时出错",
"xpack.idxMgmt.indexTable.systemIndicesSearchIndicesAriaLabel": "搜索索引",
"xpack.idxMgmt.indexTable.systemIndicesSearchInputPlaceholder": "搜索",

View file

@ -5,3 +5,5 @@
*/
export const API_BASE_PATH = '/api/index_management';
export const INDEX_PATTERNS = ['test*'];

View file

@ -10,5 +10,6 @@ export default function ({ loadTestFile }) {
loadTestFile(require.resolve('./mapping'));
loadTestFile(require.resolve('./settings'));
loadTestFile(require.resolve('./stats'));
loadTestFile(require.resolve('./templates'));
});
}

View file

@ -39,6 +39,10 @@ export const initElasticsearchHelpers = (es) => {
deleteAllIndices()
);
const catTemplate = name => (
es.cat.templates({ name, format: 'json' })
);
return ({
createIndex,
deleteIndex,
@ -46,5 +50,6 @@ export const initElasticsearchHelpers = (es) => {
catIndex,
indexStats,
cleanUp,
catTemplate,
});
};

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { API_BASE_PATH, INDEX_PATTERNS } from './constants';
export const registerHelpers = ({ supertest }) => {
const list = () => supertest.get(`${API_BASE_PATH}/templates`);
const getTemplatePayload = name => ({
name,
order: 1,
indexPatterns: INDEX_PATTERNS,
version: 1,
settings: {
number_of_shards: 1
},
mappings: {
_source: {
enabled: false
},
properties: {
host_name: {
type: 'keyword'
},
created_at: {
type: 'date',
format: 'EEE MMM dd HH:mm:ss Z yyyy'
}
}
},
aliases: {
alias1: {}
}
});
const createTemplate = payload =>
supertest
.put(`${API_BASE_PATH}/templates`)
.set('kbn-xsrf', 'xxx')
.send(payload);
const deleteTemplates = templatesToDelete =>
supertest
.delete(`${API_BASE_PATH}/templates/${templatesToDelete.map(template => encodeURIComponent(template)).join(',')}`)
.set('kbn-xsrf', 'xxx');
return {
list,
getTemplatePayload,
createTemplate,
deleteTemplates,
};
};

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { initElasticsearchHelpers, getRandomString } from './lib';
import { registerHelpers } from './templates.helpers';
export default function ({ getService }) {
const supertest = getService('supertest');
const es = getService('es');
const {
cleanUp: cleanUpEsResources,
catTemplate,
} = initElasticsearchHelpers(es);
const {
list,
createTemplate,
getTemplatePayload,
deleteTemplates,
} = registerHelpers({ supertest });
describe('index templates', () => {
after(() => Promise.all([cleanUpEsResources()]));
describe('list', function () {
it('should list all the index templates with the expected properties', async function () {
const { body } = await list().expect(200);
const expectedKeys = ['name', 'indexPatterns', 'settings', 'aliases', 'mappings'];
expectedKeys.forEach(key => expect(Object.keys(body[0]).includes(key)).to.be(true));
});
});
describe('create', () => {
it('should create an index template', async () => {
const templateName = `template-${getRandomString()}`;
const payload = getTemplatePayload(templateName);
await createTemplate(payload).expect(200);
});
it('should throw a 409 conflict when trying to create 2 templates with the same name', async () => {
const templateName = `template-${getRandomString()}`;
const payload = getTemplatePayload(templateName);
await createTemplate(payload);
await createTemplate(payload).expect(409);
});
it('should handle ES errors', async () => {
const templateName = `template-${getRandomString()}`;
const payload = getTemplatePayload(templateName);
delete payload.indexPatterns; // index patterns are required
const { body } = await createTemplate(payload);
expect(body.message).to.contain('index patterns are missing');
});
});
describe('delete', () => {
it('should delete an index template', async () => {
const templateName = `template-${getRandomString()}`;
const payload = getTemplatePayload(templateName);
await createTemplate(payload).expect(200);
let catTemplateResponse = await catTemplate(templateName);
expect(catTemplateResponse.find(template => template.name === payload.name).name).to.equal(templateName);
const { body } = await deleteTemplates([templateName]).expect(200);
expect(body.errors).to.be.empty;
expect(body.templatesDeleted[0]).to.equal(templateName);
catTemplateResponse = await catTemplate(templateName);
expect(catTemplateResponse.find(template => template.name === payload.name)).to.equal(undefined);
});
});
});
}

View file

@ -13,7 +13,7 @@ export const IndexManagementPageProvider = ({
return {
async sectionHeadingText() {
return await testSubjects.getVisibleText('sectionHeading');
return await testSubjects.getVisibleText('appTitle');
},
async reloadIndicesButton() {
return await testSubjects.find('reloadIndicesButton');