Add delete data stream action and detail panel (#68919)

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
CJ Cenizal 2020-06-24 19:30:12 -07:00 committed by GitHub
parent bcc62095f0
commit b48c8bf355
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 860 additions and 183 deletions

View file

@ -35,6 +35,22 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
]);
};
const setLoadDataStreamResponse = (response: HttpResponse = []) => {
server.respondWith('GET', `${API_BASE_PATH}/data_streams/:id`, [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify(response),
]);
};
const setDeleteDataStreamResponse = (response: HttpResponse = []) => {
server.respondWith('POST', `${API_BASE_PATH}/delete_data_streams`, [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify(response),
]);
};
const setDeleteTemplateResponse = (response: HttpResponse = []) => {
server.respondWith('POST', `${API_BASE_PATH}/delete_index_templates`, [
200,
@ -80,6 +96,8 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
setLoadTemplatesResponse,
setLoadIndicesResponse,
setLoadDataStreamsResponse,
setLoadDataStreamResponse,
setDeleteDataStreamResponse,
setDeleteTemplateResponse,
setLoadTemplateResponse,
setCreateTemplateResponse,

View file

@ -8,6 +8,7 @@
import React from 'react';
import axios from 'axios';
import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import { merge } from 'lodash';
import {
notificationServiceMock,
@ -33,7 +34,7 @@ export const services = {
services.uiMetricService.setup({ reportUiStats() {} } as any);
setExtensionsService(services.extensionsService);
setUiMetricService(services.uiMetricService);
const appDependencies = { services, core: {}, plugins: {} } as any;
const appDependencies = { services, core: { getUrlForApp: () => {} }, plugins: {} } as any;
export const setupEnvironment = () => {
// Mock initialization of services
@ -51,8 +52,13 @@ export const setupEnvironment = () => {
};
};
export const WithAppDependencies = (Comp: any) => (props: any) => (
<AppContextProvider value={appDependencies}>
<Comp {...props} />
</AppContextProvider>
);
export const WithAppDependencies = (Comp: any, overridingDependencies: any = {}) => (
props: any
) => {
const mergedDependencies = merge({}, appDependencies, overridingDependencies);
return (
<AppContextProvider value={mergedDependencies}>
<Comp {...props} />
</AppContextProvider>
);
};

View file

@ -5,6 +5,7 @@
*/
import { act } from 'react-dom/test-utils';
import { ReactWrapper } from 'enzyme';
import {
registerTestBed,
@ -17,27 +18,38 @@ import { IndexManagementHome } from '../../../public/application/sections/home';
import { indexManagementStore } from '../../../public/application/store'; // eslint-disable-line @kbn/eslint/no-restricted-paths
import { WithAppDependencies, services, TestSubjects } from '../helpers';
const testBedConfig: TestBedConfig = {
store: () => indexManagementStore(services as any),
memoryRouter: {
initialEntries: [`/indices`],
componentRoutePath: `/:section(indices|data_streams|templates)`,
},
doMountAsync: true,
};
const initTestBed = registerTestBed(WithAppDependencies(IndexManagementHome), testBedConfig);
export interface DataStreamsTabTestBed extends TestBed<TestSubjects> {
actions: {
goToDataStreamsList: () => void;
clickEmptyPromptIndexTemplateLink: () => void;
clickReloadButton: () => void;
clickNameAt: (index: number) => void;
clickIndicesAt: (index: number) => void;
clickDeletActionAt: (index: number) => void;
clickConfirmDelete: () => void;
clickDeletDataStreamButton: () => void;
};
findDeleteActionAt: (index: number) => ReactWrapper;
findDeleteConfirmationModal: () => ReactWrapper;
findDetailPanel: () => ReactWrapper;
findDetailPanelTitle: () => string;
findEmptyPromptIndexTemplateLink: () => ReactWrapper;
}
export const setup = async (): Promise<DataStreamsTabTestBed> => {
export const setup = async (overridingDependencies: any = {}): Promise<DataStreamsTabTestBed> => {
const testBedConfig: TestBedConfig = {
store: () => indexManagementStore(services as any),
memoryRouter: {
initialEntries: [`/indices`],
componentRoutePath: `/:section(indices|data_streams|templates)`,
},
doMountAsync: true,
};
const initTestBed = registerTestBed(
WithAppDependencies(IndexManagementHome, overridingDependencies),
testBedConfig
);
const testBed = await initTestBed();
/**
@ -48,15 +60,17 @@ export const setup = async (): Promise<DataStreamsTabTestBed> => {
testBed.find('data_streamsTab').simulate('click');
};
const clickEmptyPromptIndexTemplateLink = async () => {
const { find, component, router } = testBed;
const findEmptyPromptIndexTemplateLink = () => {
const { find } = testBed;
const templateLink = find('dataStreamsEmptyPromptTemplateLink');
return templateLink;
};
const clickEmptyPromptIndexTemplateLink = async () => {
const { component, router } = testBed;
await act(async () => {
router.navigateTo(templateLink.props().href!);
router.navigateTo(findEmptyPromptIndexTemplateLink().props().href!);
});
component.update();
};
@ -65,10 +79,15 @@ export const setup = async (): Promise<DataStreamsTabTestBed> => {
find('reloadButton').simulate('click');
};
const clickIndicesAt = async (index: number) => {
const { component, table, router } = testBed;
const findTestSubjectAt = (testSubject: string, index: number) => {
const { table } = testBed;
const { rows } = table.getMetaData('dataStreamTable');
const indicesLink = findTestSubject(rows[index].reactWrapper, 'indicesLink');
return findTestSubject(rows[index].reactWrapper, testSubject);
};
const clickIndicesAt = async (index: number) => {
const { component, router } = testBed;
const indicesLink = findTestSubjectAt('indicesLink', index);
await act(async () => {
router.navigateTo(indicesLink.props().href!);
@ -77,14 +96,71 @@ export const setup = async (): Promise<DataStreamsTabTestBed> => {
component.update();
};
const clickNameAt = async (index: number) => {
const { component, router } = testBed;
const nameLink = findTestSubjectAt('nameLink', index);
await act(async () => {
router.navigateTo(nameLink.props().href!);
});
component.update();
};
const findDeleteActionAt = findTestSubjectAt.bind(null, 'deleteDataStream');
const clickDeletActionAt = (index: number) => {
findDeleteActionAt(index).simulate('click');
};
const findDeleteConfirmationModal = () => {
const { find } = testBed;
return find('deleteDataStreamsConfirmation');
};
const clickConfirmDelete = async () => {
const modal = document.body.querySelector('[data-test-subj="deleteDataStreamsConfirmation"]');
const confirmButton: HTMLButtonElement | null = modal!.querySelector(
'[data-test-subj="confirmModalConfirmButton"]'
);
await act(async () => {
confirmButton!.click();
});
};
const clickDeletDataStreamButton = () => {
const { find } = testBed;
find('deleteDataStreamButton').simulate('click');
};
const findDetailPanel = () => {
const { find } = testBed;
return find('dataStreamDetailPanel');
};
const findDetailPanelTitle = () => {
const { find } = testBed;
return find('dataStreamDetailPanelTitle').text();
};
return {
...testBed,
actions: {
goToDataStreamsList,
clickEmptyPromptIndexTemplateLink,
clickReloadButton,
clickNameAt,
clickIndicesAt,
clickDeletActionAt,
clickConfirmDelete,
clickDeletDataStreamButton,
},
findDeleteActionAt,
findDeleteConfirmationModal,
findDetailPanel,
findDetailPanelTitle,
findEmptyPromptIndexTemplateLink,
};
};

View file

@ -19,61 +19,38 @@ describe('Data Streams tab', () => {
server.restore();
});
beforeEach(async () => {
httpRequestsMockHelpers.setLoadIndicesResponse([
{
health: '',
status: '',
primary: '',
replica: '',
documents: '',
documents_deleted: '',
size: '',
primary_size: '',
name: 'data-stream-index',
data_stream: 'dataStream1',
},
{
health: 'green',
status: 'open',
primary: 1,
replica: 1,
documents: 10000,
documents_deleted: 100,
size: '156kb',
primary_size: '156kb',
name: 'non-data-stream-index',
},
]);
await act(async () => {
testBed = await setup();
});
});
describe('when there are no data streams', () => {
beforeEach(async () => {
const { actions, component } = testBed;
httpRequestsMockHelpers.setLoadIndicesResponse([]);
httpRequestsMockHelpers.setLoadDataStreamsResponse([]);
httpRequestsMockHelpers.setLoadTemplatesResponse({ templates: [], legacyTemplates: [] });
await act(async () => {
actions.goToDataStreamsList();
});
component.update();
});
test('displays an empty prompt', async () => {
const { exists } = testBed;
testBed = await setup();
await act(async () => {
testBed.actions.goToDataStreamsList();
});
const { exists, component } = testBed;
component.update();
expect(exists('sectionLoading')).toBe(false);
expect(exists('emptyPrompt')).toBe(true);
});
test('goes to index templates tab when "Get started" link is clicked', async () => {
const { actions, exists } = testBed;
test('when Ingest Manager is disabled, goes to index templates tab when "Get started" link is clicked', async () => {
testBed = await setup({
plugins: {},
});
await act(async () => {
testBed.actions.goToDataStreamsList();
});
const { actions, exists, component } = testBed;
component.update();
await act(async () => {
actions.clickEmptyPromptIndexTemplateLink();
@ -81,32 +58,77 @@ describe('Data Streams tab', () => {
expect(exists('templateList')).toBe(true);
});
test('when Ingest Manager is enabled, links to Ingest Manager', async () => {
testBed = await setup({
plugins: { ingestManager: { hi: 'ok' } },
});
await act(async () => {
testBed.actions.goToDataStreamsList();
});
const { findEmptyPromptIndexTemplateLink, component } = testBed;
component.update();
// Assert against the text because the href won't be available, due to dependency upon our core mock.
expect(findEmptyPromptIndexTemplateLink().text()).toBe('Ingest Manager');
});
});
describe('when there are data streams', () => {
beforeEach(async () => {
const { actions, component } = testBed;
httpRequestsMockHelpers.setLoadIndicesResponse([
{
health: '',
status: '',
primary: '',
replica: '',
documents: '',
documents_deleted: '',
size: '',
primary_size: '',
name: 'data-stream-index',
data_stream: 'dataStream1',
},
{
health: 'green',
status: 'open',
primary: 1,
replica: 1,
documents: 10000,
documents_deleted: 100,
size: '156kb',
primary_size: '156kb',
name: 'non-data-stream-index',
},
]);
const dataStreamForDetailPanel = createDataStreamPayload('dataStream1');
httpRequestsMockHelpers.setLoadDataStreamsResponse([
createDataStreamPayload('dataStream1'),
dataStreamForDetailPanel,
createDataStreamPayload('dataStream2'),
]);
httpRequestsMockHelpers.setLoadDataStreamResponse(dataStreamForDetailPanel);
testBed = await setup();
await act(async () => {
actions.goToDataStreamsList();
testBed.actions.goToDataStreamsList();
});
component.update();
testBed.component.update();
});
test('lists them in the table', async () => {
const { table } = testBed;
const { tableCellsValues } = table.getMetaData('dataStreamTable');
expect(tableCellsValues).toEqual([
['dataStream1', '1', '@timestamp', '1'],
['dataStream2', '1', '@timestamp', '1'],
['', 'dataStream1', '1', ''],
['', 'dataStream2', '1', ''],
]);
});
@ -126,12 +148,90 @@ describe('Data Streams tab', () => {
test('clicking the indices count navigates to the backing indices', async () => {
const { table, actions } = testBed;
await actions.clickIndicesAt(0);
expect(table.getMetaData('indexTable').tableCellsValues).toEqual([
['', '', '', '', '', '', '', 'dataStream1'],
]);
});
describe('row actions', () => {
test('can delete', () => {
const { findDeleteActionAt } = testBed;
const deleteAction = findDeleteActionAt(0);
expect(deleteAction.length).toBe(1);
});
});
describe('deleting a data stream', () => {
test('shows a confirmation modal', async () => {
const {
actions: { clickDeletActionAt },
findDeleteConfirmationModal,
} = testBed;
clickDeletActionAt(0);
const confirmationModal = findDeleteConfirmationModal();
expect(confirmationModal).toBeDefined();
});
test('sends a request to the Delete API', async () => {
const {
actions: { clickDeletActionAt, clickConfirmDelete },
} = testBed;
clickDeletActionAt(0);
httpRequestsMockHelpers.setDeleteDataStreamResponse({
results: {
dataStreamsDeleted: ['dataStream1'],
errors: [],
},
});
await clickConfirmDelete();
const { method, url, requestBody } = server.requests[server.requests.length - 1];
expect(method).toBe('POST');
expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`);
expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({
dataStreams: ['dataStream1'],
});
});
});
describe('detail panel', () => {
test('opens when the data stream name in the table is clicked', async () => {
const { actions, findDetailPanel, findDetailPanelTitle } = testBed;
await actions.clickNameAt(0);
expect(findDetailPanel().length).toBe(1);
expect(findDetailPanelTitle()).toBe('dataStream1');
});
test('deletes the data stream when delete button is clicked', async () => {
const {
actions: { clickNameAt, clickDeletDataStreamButton, clickConfirmDelete },
} = testBed;
await clickNameAt(0);
clickDeletDataStreamButton();
httpRequestsMockHelpers.setDeleteDataStreamResponse({
results: {
dataStreamsDeleted: ['dataStream1'],
errors: [],
},
});
await clickConfirmDelete();
const { method, url, requestBody } = server.requests[server.requests.length - 1];
expect(method).toBe('POST');
expect(url).toBe(`${API_BASE_PATH}/delete_data_streams`);
expect(JSON.parse(JSON.parse(requestBody).body)).toEqual({
dataStreams: ['dataStream1'],
});
});
});
});
});

View file

@ -5,6 +5,7 @@
*/
import { act } from 'react-dom/test-utils';
import { ReactWrapper } from 'enzyme';
import {
registerTestBed,
@ -34,6 +35,8 @@ export interface IndicesTestBed extends TestBed<TestSubjects> {
clickIncludeHiddenIndicesToggle: () => void;
clickDataStreamAt: (index: number) => void;
};
findDataStreamDetailPanel: () => ReactWrapper;
findDataStreamDetailPanelTitle: () => string;
}
export const setup = async (): Promise<IndicesTestBed> => {
@ -77,6 +80,16 @@ export const setup = async (): Promise<IndicesTestBed> => {
component.update();
};
const findDataStreamDetailPanel = () => {
const { find } = testBed;
return find('dataStreamDetailPanel');
};
const findDataStreamDetailPanelTitle = () => {
const { find } = testBed;
return find('dataStreamDetailPanelTitle').text();
};
return {
...testBed,
actions: {
@ -85,5 +98,7 @@ export const setup = async (): Promise<IndicesTestBed> => {
clickIncludeHiddenIndicesToggle,
clickDataStreamAt,
},
findDataStreamDetailPanel,
findDataStreamDetailPanelTitle,
};
};

View file

@ -70,10 +70,10 @@ describe('<IndexManagementHome />', () => {
},
]);
httpRequestsMockHelpers.setLoadDataStreamsResponse([
createDataStreamPayload('dataStream1'),
createDataStreamPayload('dataStream2'),
]);
// The detail panel should still appear even if there are no data streams.
httpRequestsMockHelpers.setLoadDataStreamsResponse([]);
httpRequestsMockHelpers.setLoadDataStreamResponse(createDataStreamPayload('dataStream1'));
testBed = await setup();
@ -86,13 +86,16 @@ describe('<IndexManagementHome />', () => {
});
test('navigates to the data stream in the Data Streams tab', async () => {
const { table, actions } = testBed;
const {
findDataStreamDetailPanel,
findDataStreamDetailPanelTitle,
actions: { clickDataStreamAt },
} = testBed;
await actions.clickDataStreamAt(0);
await clickDataStreamAt(0);
expect(table.getMetaData('dataStreamTable').tableCellsValues).toEqual([
['dataStream1', '1', '@timestamp', '1'],
]);
expect(findDataStreamDetailPanel().length).toBe(1);
expect(findDataStreamDetailPanelTitle()).toBe('dataStream1');
});
});

View file

@ -6,8 +6,10 @@
import { DataStream, DataStreamFromEs } from '../types';
export function deserializeDataStreamList(dataStreamsFromEs: DataStreamFromEs[]): DataStream[] {
return dataStreamsFromEs.map(({ name, timestamp_field, indices, generation }) => ({
export function deserializeDataStream(dataStreamFromEs: DataStreamFromEs): DataStream {
const { name, timestamp_field, indices, generation } = dataStreamFromEs;
return {
name,
timeStampField: timestamp_field,
indices: indices.map(
@ -17,5 +19,9 @@ export function deserializeDataStreamList(dataStreamsFromEs: DataStreamFromEs[])
})
),
generation,
}));
};
}
export function deserializeDataStreamList(dataStreamsFromEs: DataStreamFromEs[]): DataStream[] {
return dataStreamsFromEs.map((dataStream) => deserializeDataStream(dataStream));
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { deserializeDataStreamList } from './data_stream_serialization';
export { deserializeDataStream, deserializeDataStreamList } from './data_stream_serialization';
export {
deserializeLegacyTemplateList,

View file

@ -10,7 +10,8 @@
],
"optionalPlugins": [
"security",
"usageCollection"
"usageCollection",
"ingestManager"
],
"configPath": ["xpack", "index_management"]
}

View file

@ -6,9 +6,10 @@
import React, { createContext, useContext } from 'react';
import { ScopedHistory } from 'kibana/public';
import { CoreStart } from '../../../../../src/core/public';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/public';
import { UsageCollectionSetup } from '../../../../../src/plugins/usage_collection/public';
import { CoreStart } from '../../../../../src/core/public';
import { IngestManagerSetup } from '../../../ingest_manager/public';
import { IndexMgmtMetricsType } from '../types';
import { UiMetricService, NotificationService, HttpService } from './services';
import { ExtensionsService } from '../services';
@ -22,6 +23,7 @@ export interface AppDependencies {
};
plugins: {
usageCollection: UsageCollectionSetup;
ingestManager?: IngestManagerSetup;
};
services: {
uiMetricService: UiMetricService<IndexMgmtMetricsType>;

View file

@ -8,6 +8,7 @@ import { CoreSetup } from 'src/core/public';
import { ManagementAppMountParams } from 'src/plugins/management/public/';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/public';
import { IngestManagerSetup } from '../../../ingest_manager/public';
import { ExtensionsService } from '../services';
import { IndexMgmtMetricsType } from '../types';
import { AppDependencies } from './app_context';
@ -28,7 +29,8 @@ export async function mountManagementSection(
coreSetup: CoreSetup,
usageCollection: UsageCollectionSetup,
services: InternalServices,
params: ManagementAppMountParams
params: ManagementAppMountParams,
ingestManager?: IngestManagerSetup
) {
const { element, setBreadcrumbs, history } = params;
const [core] = await coreSetup.getStartServices();
@ -44,6 +46,7 @@ export async function mountManagementSection(
},
plugins: {
usageCollection,
ingestManager,
},
services,
history,

View file

@ -4,9 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiFlyout,
EuiFlyoutHeader,
EuiTitle,
@ -15,14 +16,18 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
} from '@elastic/eui';
import { SectionLoading, SectionError, Error } from '../../../../components';
import { useLoadDataStream } from '../../../../services/api';
import { DeleteDataStreamConfirmationModal } from '../delete_data_stream_confirmation_modal';
interface Props {
dataStreamName: string;
onClose: () => void;
onClose: (shouldReload?: boolean) => void;
}
/**
@ -36,6 +41,8 @@ export const DataStreamDetailPanel: React.FunctionComponent<Props> = ({
}) => {
const { error, data: dataStream, isLoading } = useLoadDataStream(dataStreamName);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
let content;
if (isLoading) {
@ -61,44 +68,97 @@ export const DataStreamDetailPanel: React.FunctionComponent<Props> = ({
/>
);
} else if (dataStream) {
content = <Fragment>{JSON.stringify(dataStream)}</Fragment>;
const { timeStampField, generation } = dataStream;
content = (
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.dataStreamDetailPanel.timestampFieldTitle"
defaultMessage="Timestamp field"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{timeStampField.name}</EuiDescriptionListDescription>
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.idxMgmt.dataStreamDetailPanel.generationTitle"
defaultMessage="Generation"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{generation}</EuiDescriptionListDescription>
</EuiDescriptionList>
);
}
return (
<EuiFlyout
onClose={onClose}
data-test-subj="dataStreamDetailPanel"
aria-labelledby="dataStreamDetailPanelTitle"
size="m"
maxWidth={500}
>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id="dataStreamDetailPanelTitle" data-test-subj="title">
{dataStreamName}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<>
{isDeleting ? (
<DeleteDataStreamConfirmationModal
onClose={(data) => {
if (data && data.hasDeletedDataStreams) {
onClose(true);
} else {
setIsDeleting(false);
}
}}
dataStreams={[dataStreamName]}
/>
) : null}
<EuiFlyoutBody data-test-subj="content">{content}</EuiFlyoutBody>
<EuiFlyout
onClose={onClose}
data-test-subj="dataStreamDetailPanel"
aria-labelledby="dataStreamDetailPanelTitle"
size="m"
maxWidth={500}
>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id="dataStreamDetailPanelTitle" data-test-subj="dataStreamDetailPanelTitle">
{dataStreamName}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={onClose}
data-test-subj="closeDetailsButton"
>
<FormattedMessage
id="xpack.idxMgmt.dataStreamDetailPanel.closeButtonLabel"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
<EuiFlyoutBody data-test-subj="content">{content}</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={() => onClose()}
data-test-subj="closeDetailsButton"
>
<FormattedMessage
id="xpack.idxMgmt.dataStreamDetailPanel.closeButtonLabel"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
{!isLoading && !error ? (
<EuiFlexItem grow={false}>
<EuiButton
color="danger"
iconType="trash"
onClick={() => setIsDeleting(true)}
data-test-subj="deleteDataStreamButton"
>
<FormattedMessage
id="xpack.idxMgmt.dataStreamDetailPanel.deleteButtonLabel"
defaultMessage="Delete data stream"
/>
</EuiButton>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
</>
);
};

View file

@ -12,9 +12,13 @@ import { EuiTitle, EuiText, EuiSpacer, EuiEmptyPrompt, EuiLink } from '@elastic/
import { ScopedHistory } from 'kibana/public';
import { reactRouterNavigate } from '../../../../shared_imports';
import { useAppContext } from '../../../app_context';
import { SectionError, SectionLoading, Error } from '../../../components';
import { useLoadDataStreams } from '../../../services/api';
import { decodePathFromReactRouter } from '../../../services/routing';
import { Section } from '../../home';
import { DataStreamTable } from './data_stream_table';
import { DataStreamDetailPanel } from './data_stream_detail_panel';
interface MatchParams {
dataStreamName?: string;
@ -26,6 +30,11 @@ export const DataStreamList: React.FunctionComponent<RouteComponentProps<MatchPa
},
history,
}) => {
const {
core: { getUrlForApp },
plugins: { ingestManager },
} = useAppContext();
const { error, isLoading, data: dataStreams, sendRequest: reload } = useLoadDataStreams();
let content;
@ -67,22 +76,52 @@ export const DataStreamList: React.FunctionComponent<RouteComponentProps<MatchPa
<p>
<FormattedMessage
id="xpack.idxMgmt.dataStreamList.emptyPrompt.noDataStreamsDescription"
defaultMessage="Data streams represent the latest data in a rollover series. Get started with data streams by creating a {link}."
values={{
link: (
<EuiLink
data-test-subj="dataStreamsEmptyPromptTemplateLink"
{...reactRouterNavigate(history, {
pathname: '/templates',
})}
>
{i18n.translate('xpack.idxMgmt.dataStreamList.emptyPrompt.getStartedLink', {
defaultMessage: 'composable index template',
})}
</EuiLink>
),
}}
defaultMessage="Data streams represent collections of time series indices."
/>
{' ' /* We need this space to separate these two sentences. */}
{ingestManager ? (
<FormattedMessage
id="xpack.idxMgmt.dataStreamList.emptyPrompt.noDataStreamsCtaIngestManagerMessage"
defaultMessage="Get started with data streams in {link}."
values={{
link: (
<EuiLink
data-test-subj="dataStreamsEmptyPromptTemplateLink"
href={getUrlForApp('ingestManager')}
>
{i18n.translate(
'xpack.idxMgmt.dataStreamList.emptyPrompt.noDataStreamsCtaIngestManagerLink',
{
defaultMessage: 'Ingest Manager',
}
)}
</EuiLink>
),
}}
/>
) : (
<FormattedMessage
id="xpack.idxMgmt.dataStreamList.emptyPrompt.noDataStreamsCtaIndexTemplateMessage"
defaultMessage="Get started with data streams by creating a {link}."
values={{
link: (
<EuiLink
data-test-subj="dataStreamsEmptyPromptTemplateLink"
{...reactRouterNavigate(history, {
pathname: '/templates',
})}
>
{i18n.translate(
'xpack.idxMgmt.dataStreamList.emptyPrompt.noDataStreamsCtaIndexTemplateLink',
{
defaultMessage: 'composable index template',
}
)}
</EuiLink>
),
}}
/>
)}
</p>
}
data-test-subj="emptyPrompt"
@ -104,24 +143,38 @@ export const DataStreamList: React.FunctionComponent<RouteComponentProps<MatchPa
<EuiSpacer size="l" />
<DataStreamTable
filters={dataStreamName !== undefined ? `name=${dataStreamName}` : ''}
filters={
dataStreamName !== undefined ? `name=${decodePathFromReactRouter(dataStreamName)}` : ''
}
dataStreams={dataStreams}
reload={reload}
history={history as ScopedHistory}
/>
{/* TODO: Implement this once we have something to put in here, e.g. storage size, docs count */}
{/* dataStreamName && (
<DataStreamDetailPanel
dataStreamName={decodePathFromReactRouter(dataStreamName)}
onClose={() => {
history.push('/data_streams');
}}
/>
)*/}
</>
);
}
return <div data-test-subj="dataStreamList">{content}</div>;
return (
<div data-test-subj="dataStreamList">
{content}
{/*
If the user has been deep-linked, they'll expect to see the detail panel because it reflects
the URL state, even if there are no data streams or if there was an error loading them.
*/}
{dataStreamName && (
<DataStreamDetailPanel
dataStreamName={decodePathFromReactRouter(dataStreamName)}
onClose={(shouldReload?: boolean) => {
history.push(`/${Section.DataStreams}`);
// If the data stream was deleted, we need to refresh the list.
if (shouldReload) {
reload();
}
}}
/>
)}
</div>
);
};

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiInMemoryTable, EuiBasicTableColumn, EuiButton, EuiLink } from '@elastic/eui';
@ -13,6 +13,8 @@ import { ScopedHistory } from 'kibana/public';
import { DataStream } from '../../../../../../common/types';
import { reactRouterNavigate } from '../../../../../shared_imports';
import { encodePathForReactRouter } from '../../../../services/routing';
import { Section } from '../../../home';
import { DeleteDataStreamConfirmationModal } from '../delete_data_stream_confirmation_modal';
interface Props {
dataStreams?: DataStream[];
@ -27,6 +29,9 @@ export const DataStreamTable: React.FunctionComponent<Props> = ({
history,
filters,
}) => {
const [selection, setSelection] = useState<DataStream[]>([]);
const [dataStreamsToDelete, setDataStreamsToDelete] = useState<string[]>([]);
const columns: Array<EuiBasicTableColumn<DataStream>> = [
{
field: 'name',
@ -35,7 +40,19 @@ export const DataStreamTable: React.FunctionComponent<Props> = ({
}),
truncateText: true,
sortable: true,
// TODO: Render as a link to open the detail panel
render: (name: DataStream['name'], item: DataStream) => {
return (
/* eslint-disable-next-line @elastic/eui/href-or-on-click */
<EuiLink
data-test-subj="nameLink"
{...reactRouterNavigate(history, {
pathname: `/${Section.DataStreams}/${encodePathForReactRouter(name)}`,
})}
>
{name}
</EuiLink>
);
},
},
{
field: 'indices',
@ -59,20 +76,27 @@ export const DataStreamTable: React.FunctionComponent<Props> = ({
),
},
{
field: 'timeStampField.name',
name: i18n.translate('xpack.idxMgmt.dataStreamList.table.timeStampFieldColumnTitle', {
defaultMessage: 'Timestamp field',
name: i18n.translate('xpack.idxMgmt.dataStreamList.table.actionColumnTitle', {
defaultMessage: 'Actions',
}),
truncateText: true,
sortable: true,
},
{
field: 'generation',
name: i18n.translate('xpack.idxMgmt.dataStreamList.table.generationFieldColumnTitle', {
defaultMessage: 'Generation',
}),
truncateText: true,
sortable: true,
actions: [
{
name: i18n.translate('xpack.idxMgmt.dataStreamList.table.actionDeleteText', {
defaultMessage: 'Delete',
}),
description: i18n.translate('xpack.idxMgmt.dataStreamList.table.actionDeleteDecription', {
defaultMessage: 'Delete this data stream',
}),
icon: 'trash',
color: 'danger',
type: 'icon',
onClick: ({ name }: DataStream) => {
setDataStreamsToDelete([name]);
},
isPrimary: true,
'data-test-subj': 'deleteDataStream',
},
],
},
];
@ -88,12 +112,29 @@ export const DataStreamTable: React.FunctionComponent<Props> = ({
},
} as const;
const selectionConfig = {
onSelectionChange: setSelection,
};
const searchConfig = {
query: filters,
box: {
incremental: true,
},
toolsLeft: undefined /* TODO: Actions menu */,
toolsLeft:
selection.length > 0 ? (
<EuiButton
data-test-subj="deletDataStreamsButton"
onClick={() => setDataStreamsToDelete(selection.map(({ name }: DataStream) => name))}
color="danger"
>
<FormattedMessage
id="xpack.idxMgmt.dataStreamList.table.deleteDataStreamsButtonLabel"
defaultMessage="Delete {count, plural, one {data stream} other {data streams} }"
values={{ count: selection.length }}
/>
</EuiButton>
) : undefined,
toolsRight: [
<EuiButton
color="secondary"
@ -112,6 +153,18 @@ export const DataStreamTable: React.FunctionComponent<Props> = ({
return (
<>
{dataStreamsToDelete && dataStreamsToDelete.length > 0 ? (
<DeleteDataStreamConfirmationModal
onClose={(data) => {
if (data && data.hasDeletedDataStreams) {
reload();
} else {
setDataStreamsToDelete([]);
}
}}
dataStreams={dataStreamsToDelete}
/>
) : null}
<EuiInMemoryTable
items={dataStreams || []}
itemId="name"
@ -119,6 +172,7 @@ export const DataStreamTable: React.FunctionComponent<Props> = ({
search={searchConfig}
sorting={sorting}
isSelectable={true}
selection={selectionConfig}
pagination={pagination}
rowProps={() => ({
'data-test-subj': 'row',

View file

@ -0,0 +1,149 @@
/*
* 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 } from 'react';
import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { deleteDataStreams } from '../../../../services/api';
import { notificationService } from '../../../../services/notification';
interface Props {
dataStreams: string[];
onClose: (data?: { hasDeletedDataStreams: boolean }) => void;
}
export const DeleteDataStreamConfirmationModal: React.FunctionComponent<Props> = ({
dataStreams,
onClose,
}: {
dataStreams: string[];
onClose: (data?: { hasDeletedDataStreams: boolean }) => void;
}) => {
const dataStreamsCount = dataStreams.length;
const handleDeleteDataStreams = () => {
deleteDataStreams(dataStreams).then(({ data: { dataStreamsDeleted, errors }, error }) => {
const hasDeletedDataStreams = dataStreamsDeleted && dataStreamsDeleted.length;
if (hasDeletedDataStreams) {
const successMessage =
dataStreamsDeleted.length === 1
? i18n.translate(
'xpack.idxMgmt.deleteDataStreamsConfirmationModal.successDeleteSingleNotificationMessageText',
{
defaultMessage: "Deleted data stream '{dataStreamName}'",
values: { dataStreamName: dataStreams[0] },
}
)
: i18n.translate(
'xpack.idxMgmt.deleteDataStreamsConfirmationModal.successDeleteMultipleNotificationMessageText',
{
defaultMessage:
'Deleted {numSuccesses, plural, one {# data stream} other {# data streams}}',
values: { numSuccesses: dataStreamsDeleted.length },
}
);
onClose({ hasDeletedDataStreams });
notificationService.showSuccessToast(successMessage);
}
if (error || (errors && errors.length)) {
const hasMultipleErrors =
(errors && errors.length > 1) || (error && dataStreams.length > 1);
const errorMessage = hasMultipleErrors
? i18n.translate(
'xpack.idxMgmt.deleteDataStreamsConfirmationModal.multipleErrorsNotificationMessageText',
{
defaultMessage: 'Error deleting {count} data streams',
values: {
count: (errors && errors.length) || dataStreams.length,
},
}
)
: i18n.translate(
'xpack.idxMgmt.deleteDataStreamsConfirmationModal.errorNotificationMessageText',
{
defaultMessage: "Error deleting data stream '{name}'",
values: { name: (errors && errors[0].name) || dataStreams[0] },
}
);
notificationService.showDangerToast(errorMessage);
}
});
};
return (
<EuiOverlayMask>
<EuiConfirmModal
buttonColor="danger"
data-test-subj="deleteDataStreamsConfirmation"
title={
<FormattedMessage
id="xpack.idxMgmt.deleteDataStreamsConfirmationModal.modalTitleText"
defaultMessage="Delete {dataStreamsCount, plural, one {data stream} other {# data streams}}"
values={{ dataStreamsCount }}
/>
}
onCancel={() => onClose()}
onConfirm={handleDeleteDataStreams}
cancelButtonText={
<FormattedMessage
id="xpack.idxMgmt.deleteDataStreamsConfirmationModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
}
confirmButtonText={
<FormattedMessage
id="xpack.idxMgmt.deleteDataStreamsConfirmationModal.confirmButtonLabel"
defaultMessage="Delete {dataStreamsCount, plural, one {data stream} other {data streams} }"
values={{ dataStreamsCount }}
/>
}
>
<Fragment>
<EuiCallOut
title={
<FormattedMessage
id="xpack.idxMgmt.deleteDataStreamsConfirmationModal.warningTitle"
defaultMessage="Deleting data streams also deletes indices"
/>
}
color="danger"
iconType="alert"
>
<p>
<FormattedMessage
id="xpack.idxMgmt.deleteDataStreamsConfirmationModal.warningMessage"
defaultMessage="Data streams are collections of time series indices. Deleting a data stream will also delete its indices."
/>
</p>
</EuiCallOut>
<EuiSpacer />
<p>
<FormattedMessage
id="xpack.idxMgmt.deleteDataStreamsConfirmationModal.deleteDescription"
defaultMessage="You are about to delete {dataStreamsCount, plural, one {this data stream} other {these data streams} }:"
values={{ dataStreamsCount }}
/>
</p>
<ul>
{dataStreams.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</Fragment>
</EuiConfirmModal>
</EuiOverlayMask>
);
};

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 { DeleteDataStreamConfirmationModal } from './delete_data_stream_confirmation_modal';

View file

@ -53,14 +53,21 @@ export function useLoadDataStreams() {
});
}
// TODO: Implement this API endpoint once we have content to surface in the detail panel.
export function useLoadDataStream(name: string) {
return useRequest<DataStream[]>({
path: `${API_BASE_PATH}/data_stream/${encodeURIComponent(name)}`,
return useRequest<DataStream>({
path: `${API_BASE_PATH}/data_streams/${encodeURIComponent(name)}`,
method: 'get',
});
}
export async function deleteDataStreams(dataStreams: string[]) {
return sendRequest({
path: `${API_BASE_PATH}/delete_data_streams`,
method: 'post',
body: { dataStreams },
});
}
export async function loadIndices() {
const response = await httpService.httpClient.get(`${API_BASE_PATH}/indices`);
return response.data ? response.data : response;

View file

@ -8,6 +8,8 @@ import { i18n } from '@kbn/i18n';
import { CoreSetup } from '../../../../src/core/public';
import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public';
import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public';
import { IngestManagerSetup } from '../../ingest_manager/public';
import { UIM_APP_NAME, PLUGIN } from '../common/constants';
import { httpService } from './application/services/http';
@ -25,6 +27,7 @@ export interface IndexManagementPluginSetup {
}
interface PluginsDependencies {
ingestManager?: IngestManagerSetup;
usageCollection: UsageCollectionSetup;
management: ManagementSetup;
}
@ -42,7 +45,7 @@ export class IndexMgmtUIPlugin {
public setup(coreSetup: CoreSetup, plugins: PluginsDependencies): IndexManagementPluginSetup {
const { http, notifications } = coreSetup;
const { usageCollection, management } = plugins;
const { ingestManager, usageCollection, management } = plugins;
httpService.setup(http);
notificationService.setup(notifications);
@ -60,7 +63,7 @@ export class IndexMgmtUIPlugin {
uiMetricService: this.uiMetricService,
extensionsService: this.extensionsService,
};
return mountManagementSection(coreSetup, usageCollection, services, params);
return mountManagementSection(coreSetup, usageCollection, services, params, ingestManager);
},
});

View file

@ -20,6 +20,20 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any)
method: 'GET',
});
dataManagement.getDataStream = ca({
urls: [
{
fmt: '/_data_stream/<%=name%>',
req: {
name: {
type: 'string',
},
},
},
],
method: 'GET',
});
// We don't allow the user to create a data stream in the UI or API. We're just adding this here
// to enable the API integration tests.
dataManagement.createDataStream = ca({

View file

@ -6,8 +6,11 @@
import { RouteDependencies } from '../../../types';
import { registerGetAllRoute } from './register_get_route';
import { registerGetOneRoute, registerGetAllRoute } from './register_get_route';
import { registerDeleteRoute } from './register_delete_route';
export function registerDataStreamRoutes(dependencies: RouteDependencies) {
registerGetOneRoute(dependencies);
registerGetAllRoute(dependencies);
registerDeleteRoute(dependencies);
}

View file

@ -0,0 +1,52 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '../index';
import { wrapEsError } from '../../helpers';
const bodySchema = schema.object({
dataStreams: schema.arrayOf(schema.string()),
});
export function registerDeleteRoute({ router, license }: RouteDependencies) {
router.post(
{
path: addBasePath('/delete_data_streams'),
validate: { body: bodySchema },
},
license.guardApiRoute(async (ctx, req, res) => {
const { callAsCurrentUser } = ctx.dataManagement!.client;
const { dataStreams } = req.body as TypeOf<typeof bodySchema>;
const response: { dataStreamsDeleted: string[]; errors: any[] } = {
dataStreamsDeleted: [],
errors: [],
};
await Promise.all(
dataStreams.map(async (name: string) => {
try {
await callAsCurrentUser('dataManagement.deleteDataStream', {
name,
});
return response.dataStreamsDeleted.push(name);
} catch (e) {
return response.errors.push({
name,
error: wrapEsError(e),
});
}
})
);
return res.ok({ body: response });
})
);
}

View file

@ -4,7 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { deserializeDataStreamList } from '../../../../common/lib';
import { schema, TypeOf } from '@kbn/config-schema';
import { deserializeDataStream, deserializeDataStreamList } from '../../../../common/lib';
import { RouteDependencies } from '../../../types';
import { addBasePath } from '../index';
@ -32,3 +34,40 @@ export function registerGetAllRoute({ router, license, lib: { isEsError } }: Rou
})
);
}
export function registerGetOneRoute({ router, license, lib: { isEsError } }: RouteDependencies) {
const paramsSchema = schema.object({
name: schema.string(),
});
router.get(
{
path: addBasePath('/data_streams/{name}'),
validate: { params: paramsSchema },
},
license.guardApiRoute(async (ctx, req, res) => {
const { name } = req.params as TypeOf<typeof paramsSchema>;
const { callAsCurrentUser } = ctx.dataManagement!.client;
try {
const dataStream = await callAsCurrentUser('dataManagement.getDataStream', { name });
if (dataStream[0]) {
const body = deserializeDataStream(dataStream[0]);
return res.ok({ body });
}
return res.notFound();
} catch (e) {
if (isEsError(e)) {
return res.customError({
statusCode: e.statusCode,
body: e,
});
}
// Case: default
return res.internalError({ body: e });
}
})
);
}

View file

@ -6,7 +6,7 @@
import { PluginInitializerContext } from 'src/core/public';
import { IngestManagerPlugin } from './plugin';
export { IngestManagerStart } from './plugin';
export { IngestManagerSetup, IngestManagerStart } from './plugin';
export const plugin = (initializerContext: PluginInitializerContext) => {
return new IngestManagerPlugin(initializerContext);

View file

@ -22,7 +22,11 @@ import { registerDatasource } from './applications/ingest_manager/sections/agent
export { IngestManagerConfigType } from '../common/types';
export type IngestManagerSetup = void;
// We need to provide an object instead of void so that dependent plugins know when Ingest Manager
// is disabled.
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface IngestManagerSetup {}
/**
* Describes public IngestManager plugin contract returned at the `start` stage.
*/
@ -72,6 +76,8 @@ export class IngestManagerPlugin
};
},
});
return {};
}
public async start(core: CoreStart): Promise<IngestManagerStart> {