Skip to content

Commit

Permalink
[Ingest] Agent Config Details - Data sources list ui (#60429)
Browse files Browse the repository at this point in the history
* refactor `use_details_uri` hook and introduce `useAgentConfigLink`
* Refactor structure for datasources view
* Sync up table columns
* Added row actions to Datasources list
* Datasources table filters
* Support deleting datasource action
* Added PackageIcon to datasources list
  • Loading branch information
paul-tavares committed Mar 19, 2020
1 parent 5389e1a commit cba0af5
Show file tree
Hide file tree
Showing 14 changed files with 872 additions and 307 deletions.
4 changes: 4 additions & 0 deletions x-pack/plugins/ingest_manager/common/services/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export const datasourceRouteService = {
getUpdatePath: (datasourceId: string) => {
return DATASOURCE_API_ROUTES.UPDATE_PATTERN.replace('{datasourceId}', datasourceId);
},

getDeletePath: () => {
return DATASOURCE_API_ROUTES.DELETE_PATTERN;
},
};

export const agentConfigRouteService = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* 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, { useEffect, useMemo, useState } from 'react';
import { ICON_TYPES, EuiIcon, EuiIconProps } from '@elastic/eui';
import { PackageInfo, PackageListItem } from '../../../../common/types/models';
import { useLinks } from '../sections/epm/hooks';
import { epmRouteService } from '../../../../common/services';
import { sendRequest } from '../hooks/use_request';
import { GetInfoResponse } from '../types';
type Package = PackageInfo | PackageListItem;

const CACHED_ICONS = new Map<string, string>();

export const PackageIcon: React.FunctionComponent<{
packageName: string;
version?: string;
icons?: Package['icons'];
} & Omit<EuiIconProps, 'type'>> = ({ packageName, version, icons, ...euiIconProps }) => {
const iconType = usePackageIcon(packageName, version, icons);
return <EuiIcon size="s" type={iconType} {...euiIconProps} />;
};

const usePackageIcon = (packageName: string, version?: string, icons?: Package['icons']) => {
const { toImage } = useLinks();
const [iconType, setIconType] = useState<string>('');
const pkgKey = `${packageName}-${version ?? ''}`;

// Generates an icon path or Eui Icon name based on an icon list from the package
// or by using the package name against logo icons from Eui
const fromInput = useMemo(() => {
return (iconList?: Package['icons']) => {
const svgIcons = iconList?.filter(iconDef => iconDef.type === 'image/svg+xml');
const localIconSrc = Array.isArray(svgIcons) && svgIcons[0]?.src;
if (localIconSrc) {
CACHED_ICONS.set(pkgKey, toImage(localIconSrc));
setIconType(CACHED_ICONS.get(pkgKey) as string);
return;
}

const euiLogoIcon = ICON_TYPES.find(key => key.toLowerCase() === `logo${packageName}`);
if (euiLogoIcon) {
CACHED_ICONS.set(pkgKey, euiLogoIcon);
setIconType(euiLogoIcon);
return;
}

CACHED_ICONS.set(pkgKey, 'package');
setIconType('package');
};
}, [packageName, pkgKey, toImage]);

useEffect(() => {
if (CACHED_ICONS.has(pkgKey)) {
setIconType(CACHED_ICONS.get(pkgKey) as string);
return;
}

// Use API to see if package has icons defined
if (!icons && version !== undefined) {
fromPackageInfo(pkgKey)
.catch(() => undefined) // ignore API errors
.then(fromInput);
} else {
fromInput(icons);
}
}, [icons, toImage, packageName, version, fromInput, pkgKey]);

return iconType;
};

const fromPackageInfo = async (pkgKey: string) => {
const { data } = await sendRequest<GetInfoResponse>({
path: epmRouteService.getInfoPath(pkgKey),
method: 'get',
});
return data?.response?.icons;
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
import { sendRequest } from './use_request';
import { datasourceRouteService } from '../../services';
import { CreateDatasourceRequest, CreateDatasourceResponse } from '../../types';
import {
DeleteDatasourcesRequest,
DeleteDatasourcesResponse,
} from '../../../../../common/types/rest_spec';

export const sendCreateDatasource = (body: CreateDatasourceRequest['body']) => {
return sendRequest<CreateDatasourceResponse>({
Expand All @@ -14,3 +18,11 @@ export const sendCreateDatasource = (body: CreateDatasourceRequest['body']) => {
body: JSON.stringify(body),
});
};

export const sendDeleteDatasource = (body: DeleteDatasourcesRequest['body']) => {
return sendRequest<DeleteDatasourcesResponse>({
path: datasourceRouteService.getDeletePath(),
method: 'post',
body: JSON.stringify(body),
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import styled from 'styled-components';
import { EuiContextMenuItem } from '@elastic/eui';

export const DangerEuiContextMenuItem = styled(EuiContextMenuItem)`
color: ${props => props.theme.eui.textColors.danger};
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
/*
* 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, useMemo, useRef, useState } from 'react';
import { EuiCallOut, EuiConfirmModal, EuiOverlayMask, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { useCore, sendRequest, sendDeleteDatasource, useConfig } from '../../../hooks';
import { AGENT_API_ROUTES } from '../../../../../../common/constants';
import { AgentConfig } from '../../../../../../common/types/models';

interface Props {
agentConfig: AgentConfig;
children: (deleteDatasourcePrompt: DeleteAgentConfigDatasourcePrompt) => React.ReactElement;
}

export type DeleteAgentConfigDatasourcePrompt = (
datasourcesToDelete: string[],
onSuccess?: OnSuccessCallback
) => void;

type OnSuccessCallback = (datasourcesDeleted: string[]) => void;

export const DatasourceDeleteProvider: React.FunctionComponent<Props> = ({
agentConfig,
children,
}) => {
const { notifications } = useCore();
const {
fleet: { enabled: isFleetEnabled },
} = useConfig();
const [datasources, setDatasources] = useState<string[]>([]);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isLoadingAgentsCount, setIsLoadingAgentsCount] = useState<boolean>(false);
const [agentsCount, setAgentsCount] = useState<number>(0);
const [isLoading, setIsLoading] = useState<boolean>(false);
const onSuccessCallback = useRef<OnSuccessCallback | null>(null);

const fetchAgentsCount = useMemo(
() => async () => {
if (isLoadingAgentsCount || !isFleetEnabled) {
return;
}
setIsLoadingAgentsCount(true);
const { data } = await sendRequest<{ total: number }>({
path: AGENT_API_ROUTES.LIST_PATTERN,
method: 'get',
query: {
page: 1,
perPage: 1,
kuery: `agents.config_id : ${agentConfig.id}`,
},
});
setAgentsCount(data?.total || 0);
setIsLoadingAgentsCount(false);
},
[agentConfig.id, isFleetEnabled, isLoadingAgentsCount]
);

const deleteDatasourcesPrompt = useMemo(
(): DeleteAgentConfigDatasourcePrompt => (datasourcesToDelete, onSuccess = () => undefined) => {
if (!Array.isArray(datasourcesToDelete) || datasourcesToDelete.length === 0) {
throw new Error('No datasources specified for deletion');
}
setIsModalOpen(true);
setDatasources(datasourcesToDelete);
fetchAgentsCount();
onSuccessCallback.current = onSuccess;
},
[fetchAgentsCount]
);

const closeModal = useMemo(
() => () => {
setDatasources([]);
setIsLoading(false);
setIsLoadingAgentsCount(false);
setIsModalOpen(false);
},
[]
);

const deleteDatasources = useMemo(
() => async () => {
setIsLoading(true);

try {
const { data } = await sendDeleteDatasource({ datasourceIds: datasources });
const successfulResults = data?.filter(result => result.success) || [];
const failedResults = data?.filter(result => !result.success) || [];

if (successfulResults.length) {
const hasMultipleSuccesses = successfulResults.length > 1;
const successMessage = hasMultipleSuccesses
? i18n.translate(
'xpack.ingestManager.deleteDatasource.successMultipleNotificationTitle',
{
defaultMessage: 'Deleted {count} data sources',
values: { count: successfulResults.length },
}
)
: i18n.translate(
'xpack.ingestManager.deleteDatasource.successSingleNotificationTitle',
{
defaultMessage: "Deleted data source '{id}'",
values: { id: successfulResults[0].id },
}
);
notifications.toasts.addSuccess(successMessage);
}

if (failedResults.length) {
const hasMultipleFailures = failedResults.length > 1;
const failureMessage = hasMultipleFailures
? i18n.translate(
'xpack.ingestManager.deleteDatasource.failureMultipleNotificationTitle',
{
defaultMessage: 'Error deleting {count} data sources',
values: { count: failedResults.length },
}
)
: i18n.translate(
'xpack.ingestManager.deleteDatasource.failureSingleNotificationTitle',
{
defaultMessage: "Error deleting data source '{id}'",
values: { id: failedResults[0].id },
}
);
notifications.toasts.addDanger(failureMessage);
}

if (onSuccessCallback.current) {
onSuccessCallback.current(successfulResults.map(result => result.id));
}
} catch (e) {
notifications.toasts.addDanger(
i18n.translate('xpack.ingestManager.deleteDatasource.fatalErrorNotificationTitle', {
defaultMessage: 'Error deleting data source',
})
);
}
closeModal();
},
[closeModal, datasources, notifications.toasts]
);

const renderModal = () => {
if (!isModalOpen) {
return null;
}

return (
<EuiOverlayMask>
<EuiConfirmModal
title={
<FormattedMessage
id="xpack.ingestManager.deleteDatasource.confirmModal.deleteMultipleTitle"
defaultMessage="Delete {count, plural, one {data source} other {# data sources}}?"
values={{ count: datasources.length }}
/>
}
onCancel={closeModal}
onConfirm={deleteDatasources}
cancelButtonText={
<FormattedMessage
id="xpack.ingestManager.deleteDatasource.confirmModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
}
confirmButtonText={
isLoading || isLoadingAgentsCount ? (
<FormattedMessage
id="xpack.ingestManager.deleteDatasource.confirmModal.loadingButtonLabel"
defaultMessage="Loading…"
/>
) : (
<FormattedMessage
id="xpack.ingestManager.deleteDatasource.confirmModal.confirmButtonLabel"
defaultMessage="Delete {agentConfigsCount, plural, one {data source} other {data sources}}"
values={{
agentConfigsCount: datasources.length,
}}
/>
)
}
buttonColor="danger"
confirmButtonDisabled={isLoading || isLoadingAgentsCount}
>
{isLoadingAgentsCount ? (
<FormattedMessage
id="xpack.ingestManager.deleteDatasource.confirmModal.loadingAgentsCountMessage"
defaultMessage="Checking affected agents…"
/>
) : agentsCount ? (
<>
<EuiCallOut
color="danger"
title={
<FormattedMessage
id="xpack.ingestManager.deleteDatasource.confirmModal.affectedAgentsTitle"
defaultMessage="This action will affect {agentsCount} {agentsCount, plural, one {agent} other {agents}}."
values={{ agentsCount }}
/>
}
>
<FormattedMessage
id="xpack.ingestManager.deleteDatasource.confirmModal.affectedAgentsMessage"
defaultMessage="Fleet has detected that {agentConfigName} is already in use by some of your agents."
values={{
agentConfigName: <strong>{agentConfig.name}</strong>,
}}
/>
</EuiCallOut>
<EuiSpacer size="l" />
</>
) : null}
{!isLoadingAgentsCount && (
<FormattedMessage
id="xpack.ingestManager.deleteDatasource.confirmModal.generalMessage"
defaultMessage="This action can not be undone. Are you sure you wish to continue?"
/>
)}
</EuiConfirmModal>
</EuiOverlayMask>
);
};

return (
<Fragment>
{children(deleteDatasourcesPrompt)}
{renderModal()}
</Fragment>
);
};
Loading

0 comments on commit cba0af5

Please sign in to comment.