[ML] Transforms: Fixes missing number of transform nodes and error reporting in stats bar. (#93956)

- Adds a Kibana API endpoint transforms/_nodes
- Adds number of nodes to the stats bar in the transforms list.
- Shows a callout when no transform nodes are available.
- Disable all actions except delete when no transform nodes are available.
- Disables the create button when no transform nodes are available.
This commit is contained in:
Walter Rafelsberger 2021-03-16 11:41:48 +01:00 committed by GitHub
parent d02169e4be
commit f3b74b457c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 368 additions and 72 deletions

View file

@ -129,6 +129,7 @@ export class DocLinksService {
elasticsearch: {
indexModules: `${ELASTICSEARCH_DOCS}index-modules.html`,
mapping: `${ELASTICSEARCH_DOCS}mapping.html`,
nodeRoles: `${ELASTICSEARCH_DOCS}modules-node.html#node-roles`,
remoteClusters: `${ELASTICSEARCH_DOCS}modules-remote-clusters.html`,
remoteClustersProxy: `${ELASTICSEARCH_DOCS}modules-remote-clusters.html#proxy-mode`,
remoteClusersProxySettings: `${ELASTICSEARCH_DOCS}modules-remote-clusters.html#remote-cluster-proxy-settings`,

View file

@ -16,6 +16,11 @@ import type { TransformId, TransformPivotConfig } from '../types/transform';
import { transformStateSchema, runtimeMappingsSchema } from './common';
// GET transform nodes
export interface GetTransformNodesResponseSchema {
count: number;
}
// GET transforms
export const getTransformsRequestSchema = schema.arrayOf(
schema.object({

View file

@ -19,6 +19,7 @@ import type { DeleteTransformsResponseSchema } from './delete_transforms';
import type { StartTransformsResponseSchema } from './start_transforms';
import type { StopTransformsResponseSchema } from './stop_transforms';
import type {
GetTransformNodesResponseSchema,
GetTransformsResponseSchema,
PostTransformsPreviewResponseSchema,
PutTransformsResponseSchema,
@ -35,6 +36,14 @@ const isGenericResponseSchema = <T>(arg: any): arg is T => {
);
};
export const isGetTransformNodesResponseSchema = (
arg: unknown
): arg is GetTransformNodesResponseSchema => {
return (
isPopulatedObject(arg) && {}.hasOwnProperty.call(arg, 'count') && typeof arg.count === 'number'
);
};
export const isGetTransformsResponseSchema = (arg: unknown): arg is GetTransformsResponseSchema => {
return isGenericResponseSchema<GetTransformsResponseSchema>(arg);
};

View file

@ -29,6 +29,7 @@ import type {
StopTransformsResponseSchema,
} from '../../../common/api_schemas/stop_transforms';
import type {
GetTransformNodesResponseSchema,
GetTransformsResponseSchema,
PostTransformsPreviewRequestSchema,
PostTransformsPreviewResponseSchema,
@ -66,6 +67,13 @@ export const useApi = () => {
return useMemo(
() => ({
async getTransformNodes(): Promise<GetTransformNodesResponseSchema | HttpFetchError> {
try {
return await http.get(`${API_BASE_PATH}transforms/_nodes`);
} catch (e) {
return e;
}
},
async getTransform(
transformId: TransformId
): Promise<GetTransformsResponseSchema | HttpFetchError> {

View file

@ -13,6 +13,7 @@ export const useDocumentationLinks = () => {
return {
esAggsCompositeMissingBucket: deps.docLinks.links.aggs.composite_missing_bucket,
esIndicesCreateIndex: deps.docLinks.links.apis.createIndex,
esNodeRoles: deps.docLinks.links.elasticsearch.nodeRoles,
esPluginDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`,
esQueryDsl: deps.docLinks.links.query.queryDsl,
esTransform: deps.docLinks.links.transforms.guide,

View file

@ -8,6 +8,7 @@
import { HttpFetchError } from 'src/core/public';
import {
isGetTransformNodesResponseSchema,
isGetTransformsResponseSchema,
isGetTransformsStatsResponseSchema,
} from '../../../common/api_schemas/type_guards';
@ -22,6 +23,7 @@ export type GetTransforms = (forceRefresh?: boolean) => void;
export const useGetTransforms = (
setTransforms: React.Dispatch<React.SetStateAction<TransformListRow[]>>,
setTransformNodes: React.Dispatch<React.SetStateAction<number>>,
setErrorMessage: React.Dispatch<React.SetStateAction<HttpFetchError | undefined>>,
setIsInitialized: React.Dispatch<React.SetStateAction<boolean>>,
blockRefresh: boolean
@ -40,17 +42,20 @@ export const useGetTransforms = (
}
const fetchOptions = { asSystemRequest: true };
const transformNodes = await api.getTransformNodes();
const transformConfigs = await api.getTransforms(fetchOptions);
const transformStats = await api.getTransformsStats(fetchOptions);
if (
!isGetTransformsResponseSchema(transformConfigs) ||
!isGetTransformsStatsResponseSchema(transformStats)
!isGetTransformsStatsResponseSchema(transformStats) ||
!isGetTransformNodesResponseSchema(transformNodes)
) {
// An error is followed immediately by setting the state to idle.
// This way we're able to treat ERROR as a one-time-event like REFRESH.
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.ERROR);
refreshTransformList$.next(REFRESH_TRANSFORM_LIST_STATE.IDLE);
setTransformNodes(0);
setTransforms([]);
setIsInitialized(true);
@ -86,6 +91,7 @@ export const useGetTransforms = (
return reducedtableRows;
}, [] as TransformListRow[]);
setTransformNodes(transformNodes.count);
setTransforms(tableRows);
setErrorMessage(undefined);
setIsInitialized(true);

View file

@ -58,7 +58,9 @@ export const hasPrivilegeFactory = (privileges: Privileges | undefined | null) =
// create the text for button's tooltips if the user
// doesn't have the permission to press that button
export function createCapabilityFailureMessage(capability: keyof Capabilities) {
export function createCapabilityFailureMessage(
capability: keyof Capabilities | 'noTransformNodes'
) {
let message = '';
switch (capability) {
@ -80,6 +82,12 @@ export function createCapabilityFailureMessage(capability: keyof Capabilities) {
defaultMessage: 'You do not have permission to delete transforms.',
});
break;
case 'noTransformNodes':
message = i18n.translate('xpack.transform.capability.noPermission.noTransformNodesTooltip', {
defaultMessage: 'There are no transform nodes available.',
});
break;
}
return i18n.translate('xpack.transform.capability.pleaseContactAdministratorTooltip', {

View file

@ -191,8 +191,7 @@ export const StepDefineForm: FC<StepDefineFormProps> = React.memo((props) => {
stepDefineForm.advancedPivotEditor.actions.setAdvancedPivotEditorApplyButtonEnabled(false);
};
const { esQueryDsl } = useDocumentationLinks();
const { esTransformPivot } = useDocumentationLinks();
const { esQueryDsl, esTransformPivot } = useDocumentationLinks();
const advancedEditorsSidebarWidth = '220px';

View file

@ -18,7 +18,7 @@ import { useAppDependencies, useToastNotifications } from '../../../../app_depen
import { cloneActionNameText, CloneActionName } from './clone_action_name';
export type CloneAction = ReturnType<typeof useCloneAction>;
export const useCloneAction = (forceDisable: boolean) => {
export const useCloneAction = (forceDisable: boolean, transformNodes: number) => {
const history = useHistory();
const appDeps = useAppDependencies();
const savedObjectsClient = appDeps.savedObjects.client;
@ -72,14 +72,14 @@ export const useCloneAction = (forceDisable: boolean) => {
const action: TransformListAction = useMemo(
() => ({
name: (item: TransformListRow) => <CloneActionName disabled={!canCreateTransform} />,
enabled: () => canCreateTransform && !forceDisable,
enabled: () => canCreateTransform && !forceDisable && transformNodes > 0,
description: cloneActionNameText,
icon: 'copy',
type: 'icon',
onClick: clickHandler,
'data-test-subj': 'transformActionClone',
}),
[canCreateTransform, forceDisable, clickHandler]
[canCreateTransform, forceDisable, clickHandler, transformNodes]
);
return { action };

View file

@ -14,7 +14,7 @@ import { AuthorizationContext } from '../../../../lib/authorization';
import { editActionNameText, EditActionName } from './edit_action_name';
export const useEditAction = (forceDisable: boolean) => {
export const useEditAction = (forceDisable: boolean, transformNodes: number) => {
const { canCreateTransform } = useContext(AuthorizationContext).capabilities;
const [config, setConfig] = useState<TransformConfigUnion>();
@ -28,14 +28,14 @@ export const useEditAction = (forceDisable: boolean) => {
const action: TransformListAction = useMemo(
() => ({
name: () => <EditActionName />,
enabled: () => canCreateTransform || !forceDisable,
enabled: () => canCreateTransform && !forceDisable && transformNodes > 0,
description: editActionNameText,
icon: 'pencil',
type: 'icon',
onClick: (item: TransformListRow) => showFlyout(item.config),
'data-test-subj': 'transformActionEdit',
}),
[canCreateTransform, forceDisable]
[canCreateTransform, forceDisable, transformNodes]
);
return {

View file

@ -23,6 +23,7 @@ describe('Transform: Transform List Actions <StartAction />', () => {
const props: StartActionNameProps = {
forceDisable: false,
items: [item],
transformNodes: 1,
};
const wrapper = shallow(<StartActionName {...props} />);

View file

@ -26,7 +26,8 @@ export const startActionNameText = i18n.translate(
export const isStartActionDisabled = (
items: TransformListRow[],
canStartStopTransform: boolean
canStartStopTransform: boolean,
transformNodes: number
) => {
// Disable start for batch transforms which have completed.
const completedBatchTransform = items.some((i: TransformListRow) => isCompletedBatchTransform(i));
@ -36,15 +37,24 @@ export const isStartActionDisabled = (
);
return (
!canStartStopTransform || completedBatchTransform || startedTransform || items.length === 0
!canStartStopTransform ||
completedBatchTransform ||
startedTransform ||
items.length === 0 ||
transformNodes === 0
);
};
export interface StartActionNameProps {
items: TransformListRow[];
forceDisable?: boolean;
transformNodes: number;
}
export const StartActionName: FC<StartActionNameProps> = ({ items, forceDisable }) => {
export const StartActionName: FC<StartActionNameProps> = ({
items,
forceDisable,
transformNodes,
}) => {
const { canStartStopTransform } = useContext(AuthorizationContext).capabilities;
const isBulkAction = items.length > 1;
@ -89,7 +99,7 @@ export const StartActionName: FC<StartActionNameProps> = ({ items, forceDisable
);
}
const actionIsDisabled = isStartActionDisabled(items, canStartStopTransform);
const actionIsDisabled = isStartActionDisabled(items, canStartStopTransform, transformNodes);
let content: string | undefined;
if (actionIsDisabled && items.length > 0) {

View file

@ -16,7 +16,7 @@ import { useStartTransforms } from '../../../../hooks';
import { isStartActionDisabled, startActionNameText, StartActionName } from './start_action_name';
export type StartAction = ReturnType<typeof useStartAction>;
export const useStartAction = (forceDisable: boolean) => {
export const useStartAction = (forceDisable: boolean, transformNodes: number) => {
const { canStartStopTransform } = useContext(AuthorizationContext).capabilities;
const startTransforms = useStartTransforms();
@ -43,17 +43,22 @@ export const useStartAction = (forceDisable: boolean) => {
const action: TransformListAction = useMemo(
() => ({
name: (item: TransformListRow) => (
<StartActionName items={[item]} forceDisable={forceDisable} />
<StartActionName
items={[item]}
forceDisable={forceDisable}
transformNodes={transformNodes}
/>
),
available: (item: TransformListRow) => item.stats.state === TRANSFORM_STATE.STOPPED,
enabled: (item: TransformListRow) => !isStartActionDisabled([item], canStartStopTransform),
enabled: (item: TransformListRow) =>
!isStartActionDisabled([item], canStartStopTransform, transformNodes),
description: startActionNameText,
icon: 'play',
type: 'icon',
onClick: (item: TransformListRow) => openModal([item]),
'data-test-subj': 'transformActionStart',
}),
[canStartStopTransform, forceDisable]
[canStartStopTransform, forceDisable, transformNodes]
);
return {

View file

@ -14,7 +14,7 @@ jest.mock('../../../../../shared_imports');
describe('Transform: Transform List <CreateTransformButton />', () => {
test('Minimal initialization', () => {
const wrapper = shallow(<CreateTransformButton onClick={jest.fn()} />);
const wrapper = shallow(<CreateTransformButton onClick={jest.fn()} transformNodes={1} />);
expect(wrapper).toMatchSnapshot();
});

View file

@ -18,15 +18,20 @@ import {
interface CreateTransformButtonProps {
onClick: MouseEventHandler<HTMLButtonElement>;
transformNodes: number;
}
export const CreateTransformButton: FC<CreateTransformButtonProps> = ({ onClick }) => {
export const CreateTransformButton: FC<CreateTransformButtonProps> = ({
onClick,
transformNodes,
}) => {
const { capabilities } = useContext(AuthorizationContext);
const disabled =
!capabilities.canCreateTransform ||
!capabilities.canPreviewTransform ||
!capabilities.canStartStopTransform;
!capabilities.canStartStopTransform ||
transformNodes === 0;
const createTransformButton = (
<EuiButton
@ -45,7 +50,12 @@ export const CreateTransformButton: FC<CreateTransformButtonProps> = ({ onClick
if (disabled) {
return (
<EuiToolTip position="top" content={createCapabilityFailureMessage('canCreateTransform')}>
<EuiToolTip
position="top"
content={createCapabilityFailureMessage(
transformNodes > 0 ? 'canCreateTransform' : 'noTransformNodes'
)}
>
{createTransformButton}
</EuiToolTip>
);

View file

@ -17,9 +17,8 @@ describe('Transform: Transform List <TransformList />', () => {
test('Minimal initialization', () => {
const wrapper = shallow(
<TransformList
errorMessage={undefined}
isInitialized={true}
onCreateTransform={jest.fn()}
transformNodes={1}
transforms={[]}
transformsLoading={false}
/>

View file

@ -12,7 +12,6 @@ import { i18n } from '@kbn/i18n';
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiCallOut,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
@ -62,18 +61,16 @@ function getItemIdToExpandedRowMap(
}, {} as ItemIdToExpandedRowMap);
}
interface Props {
errorMessage: any;
isInitialized: boolean;
interface TransformListProps {
onCreateTransform: MouseEventHandler<HTMLButtonElement>;
transformNodes: number;
transforms: TransformListRow[];
transformsLoading: boolean;
}
export const TransformList: FC<Props> = ({
errorMessage,
isInitialized,
export const TransformList: FC<TransformListProps> = ({
onCreateTransform,
transformNodes,
transforms,
transformsLoading,
}) => {
@ -86,7 +83,7 @@ export const TransformList: FC<Props> = ({
const [expandedRowItemIds, setExpandedRowItemIds] = useState<TransformId[]>([]);
const [transformSelection, setTransformSelection] = useState<TransformListRow[]>([]);
const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false);
const bulkStartAction = useStartAction(false);
const bulkStartAction = useStartAction(false, transformNodes);
const bulkDeleteAction = useDeleteAction(false);
const [searchError, setSearchError] = useState<any>(undefined);
@ -106,6 +103,7 @@ export const TransformList: FC<Props> = ({
const { columns, modals: singleActionModals } = useColumns(
expandedRowItemIds,
setExpandedRowItemIds,
transformNodes,
transformSelection
);
@ -131,26 +129,10 @@ export const TransformList: FC<Props> = ({
}
};
// Before the transforms have been loaded for the first time, display the loading indicator only.
// Otherwise a user would see 'No transforms found' during the initial loading.
if (!isInitialized) {
if (transforms.length === 0 && transformNodes === 0) {
return null;
}
if (typeof errorMessage !== 'undefined') {
return (
<EuiCallOut
title={i18n.translate('xpack.transform.list.errorPromptTitle', {
defaultMessage: 'An error occurred getting the transform list.',
})}
color="danger"
iconType="alert"
>
<pre>{JSON.stringify(errorMessage)}</pre>
</EuiCallOut>
);
}
if (transforms.length === 0) {
return (
<EuiEmptyPrompt
@ -182,7 +164,7 @@ export const TransformList: FC<Props> = ({
const bulkActionMenuItems = [
<div key="startAction" className="transform__BulkActionItem">
<EuiButtonEmpty onClick={() => bulkStartAction.openModal(transformSelection)}>
<StartActionName items={transformSelection} />
<StartActionName items={transformSelection} transformNodes={transformNodes} />
</EuiButtonEmpty>
</div>,
<div key="stopAction" className="transform__BulkActionItem">
@ -257,7 +239,7 @@ export const TransformList: FC<Props> = ({
<RefreshTransformListButton onClick={refresh} isLoading={isLoading} />
</EuiFlexItem>
<EuiFlexItem>
<CreateTransformButton onClick={onCreateTransform} />
<CreateTransformButton onClick={onCreateTransform} transformNodes={transformNodes} />
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -6,15 +6,21 @@
*/
import React, { FC } from 'react';
import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { TRANSFORM_MODE, TRANSFORM_STATE } from '../../../../../../common/constants';
import { TransformListRow } from '../../../../common';
import { useDocumentationLinks } from '../../../../hooks/use_documentation_links';
import { StatsBar, TransformStatsBarStats } from '../stats_bar';
function createTranformStats(transformsList: TransformListRow[]) {
function createTranformStats(transformNodes: number, transformsList: TransformListRow[]) {
const transformStats = {
total: {
label: i18n.translate('xpack.transform.statsBar.totalTransformsLabel', {
@ -51,6 +57,13 @@ function createTranformStats(transformsList: TransformListRow[]) {
value: 0,
show: true,
},
nodes: {
label: i18n.translate('xpack.transform.statsBar.transformNodesLabel', {
defaultMessage: 'Nodes',
}),
value: transformNodes,
show: true,
},
};
if (transformsList === undefined) {
@ -87,12 +100,57 @@ function createTranformStats(transformsList: TransformListRow[]) {
return transformStats;
}
interface Props {
interface TransformStatsBarProps {
transformNodes: number;
transformsList: TransformListRow[];
}
export const TransformStatsBar: FC<Props> = ({ transformsList }) => {
const transformStats: TransformStatsBarStats = createTranformStats(transformsList);
export const TransformStatsBar: FC<TransformStatsBarProps> = ({
transformNodes,
transformsList,
}) => {
const { esNodeRoles } = useDocumentationLinks();
return <StatsBar stats={transformStats} dataTestSub={'transformStatsBar'} />;
const transformStats: TransformStatsBarStats = createTranformStats(
transformNodes,
transformsList
);
return (
<>
<StatsBar stats={transformStats} dataTestSub={'transformStatsBar'} />
{transformNodes === 0 && (
<>
<EuiSpacer size="m" />
<EuiCallOut
title={
<FormattedMessage
id="xpack.transform.transformNodes.noTransformNodesCallOutTitle"
defaultMessage="There are no transform nodes available."
/>
}
color="warning"
iconType="alert"
>
<p>
<FormattedMessage
id="xpack.transform.transformNodes.noTransformNodesCallOutBody"
defaultMessage="You will not be able to create or run transforms. {learnMoreLink}"
values={{
learnMoreLink: (
<EuiLink href={esNodeRoles} target="_blank">
<FormattedMessage
id="xpack.transform.transformNodes.noTransformNodesLearnMoreLinkText"
defaultMessage="Learn more"
/>
</EuiLink>
),
}}
/>
</p>
</EuiCallOut>
</>
)}
</>
);
};

View file

@ -14,7 +14,7 @@ jest.mock('../../../../../app/app_dependencies');
describe('Transform: Transform List Actions', () => {
test('useActions()', () => {
const { result } = renderHook(() => useActions({ forceDisable: false }));
const { result } = renderHook(() => useActions({ forceDisable: false, transformNodes: 1 }));
const actions = result.current.actions;
// Using `any` for the callback. Somehow the EUI types don't pass

View file

@ -20,16 +20,18 @@ import { useStopAction } from '../action_stop';
export const useActions = ({
forceDisable,
transformNodes,
}: {
forceDisable: boolean;
transformNodes: number;
}): {
actions: EuiTableActionsColumnType<TransformListRow>['actions'];
modals: JSX.Element;
} => {
const cloneAction = useCloneAction(forceDisable);
const cloneAction = useCloneAction(forceDisable, transformNodes);
const deleteAction = useDeleteAction(forceDisable);
const editAction = useEditAction(forceDisable);
const startAction = useStartAction(forceDisable);
const editAction = useEditAction(forceDisable, transformNodes);
const startAction = useStartAction(forceDisable, transformNodes);
const stopAction = useStopAction(forceDisable);
return {

View file

@ -14,7 +14,7 @@ jest.mock('../../../../../app/app_dependencies');
describe('Transform: Job List Columns', () => {
test('useColumns()', () => {
const { result } = renderHook(() => useColumns([], () => {}, []));
const { result } = renderHook(() => useColumns([], () => {}, 1, []));
const columns: ReturnType<typeof useColumns>['columns'] = result.current.columns;
expect(columns).toHaveLength(7);

View file

@ -65,9 +65,13 @@ export const getTaskStateBadge = (
export const useColumns = (
expandedRowItemIds: TransformId[],
setExpandedRowItemIds: React.Dispatch<React.SetStateAction<TransformId[]>>,
transformNodes: number,
transformSelection: TransformListRow[]
) => {
const { actions, modals } = useActions({ forceDisable: transformSelection.length > 0 });
const { actions, modals } = useActions({
forceDisable: transformSelection.length > 0,
transformNodes,
});
function toggleDetails(item: TransformListRow) {
const index = expandedRowItemIds.indexOf(item.config.id);

View file

@ -7,12 +7,15 @@
import React, { FC, Fragment, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingContent,
EuiModal,
EuiPageContent,
EuiPageContentBody,
@ -42,10 +45,12 @@ export const TransformManagement: FC = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [blockRefresh, setBlockRefresh] = useState(false);
const [transforms, setTransforms] = useState<TransformListRow[]>([]);
const [transformNodes, setTransformNodes] = useState<number>(0);
const [errorMessage, setErrorMessage] = useState<any>(undefined);
const getTransforms = useGetTransforms(
setTransforms,
setTransformNodes,
setErrorMessage,
setIsInitialized,
blockRefresh
@ -111,15 +116,32 @@ export const TransformManagement: FC = () => {
</EuiTitle>
<EuiPageContentBody>
<EuiSpacer size="l" />
<TransformStatsBar transformsList={transforms} />
<EuiSpacer size="s" />
<TransformList
errorMessage={errorMessage}
isInitialized={isInitialized}
onCreateTransform={onOpenModal}
transforms={transforms}
transformsLoading={transformsLoading}
/>
{!isInitialized && <EuiLoadingContent lines={2} />}
{isInitialized && (
<>
<TransformStatsBar transformNodes={transformNodes} transformsList={transforms} />
<EuiSpacer size="s" />
{typeof errorMessage !== 'undefined' && (
<EuiCallOut
title={i18n.translate('xpack.transform.list.errorPromptTitle', {
defaultMessage: 'An error occurred getting the transform list.',
})}
color="danger"
iconType="alert"
>
<pre>{JSON.stringify(errorMessage)}</pre>
</EuiCallOut>
)}
{typeof errorMessage === 'undefined' && (
<TransformList
onCreateTransform={onOpenModal}
transformNodes={transformNodes}
transforms={transforms}
transformsLoading={transformsLoading}
/>
)}
</>
)}
</EuiPageContentBody>
</EuiPageContent>
{isSearchSelectionVisible && (

View file

@ -58,6 +58,7 @@ import { addBasePath } from '../index';
import { isRequestTimeout, fillResultsWithTimeouts, wrapError, wrapEsError } from './error_utils';
import { registerTransformsAuditMessagesRoutes } from './transforms_audit_messages';
import { registerTransformNodesRoutes } from './transforms_nodes';
import { IIndexPattern } from '../../../../../../src/plugins/data/common/index_patterns';
import { isLatestTransform } from '../../../common/types/transform';
@ -175,7 +176,6 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) {
}
})
);
registerTransformsAuditMessagesRoutes(routeDependencies);
/**
* @apiGroup Transforms
@ -389,6 +389,9 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) {
}
})
);
registerTransformsAuditMessagesRoutes(routeDependencies);
registerTransformNodesRoutes(routeDependencies);
}
async function getIndexPatternId(

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isNodes } from './transforms_nodes';
describe('Transform: Nodes API endpoint', () => {
test('isNodes()', () => {
expect(isNodes(undefined)).toBe(false);
expect(isNodes({})).toBe(false);
expect(isNodes({ nodeId: {} })).toBe(false);
expect(isNodes({ nodeId: { someAttribute: {} } })).toBe(false);
expect(isNodes({ nodeId: { attributes: {} } })).toBe(false);
expect(
isNodes({
nodeId1: { attributes: { someAttribute: true } },
nodeId2: { someAttribute: 'asdf' },
})
).toBe(false);
// Legacy format based on attributes should return false
expect(isNodes({ nodeId: { attributes: { someAttribute: true } } })).toBe(false);
expect(
isNodes({
nodeId1: { attributes: { someAttribute: true } },
nodeId2: { attributes: { 'transform.node': 'true' } },
})
).toBe(false);
// Current format based on roles should return true
expect(isNodes({ nodeId: { roles: ['master', 'transform'] } })).toBe(true);
expect(isNodes({ nodeId: { roles: ['transform'] } })).toBe(true);
expect(
isNodes({
nodeId1: { roles: ['master', 'data'] },
nodeId2: { roles: ['transform'] },
})
).toBe(true);
});
});

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isPopulatedObject } from '../../../common/utils/object_utils';
import { RouteDependencies } from '../../types';
import { addBasePath } from '../index';
import { wrapError, wrapEsError } from './error_utils';
const NODE_ROLES = 'roles';
interface NodesAttributes {
roles: string[];
}
type Nodes = Record<string, NodesAttributes>;
export const isNodes = (arg: unknown): arg is Nodes => {
return (
isPopulatedObject(arg) &&
Object.values(arg).every(
(node) =>
isPopulatedObject(node) &&
{}.hasOwnProperty.call(node, NODE_ROLES) &&
Array.isArray(node.roles)
)
);
};
export function registerTransformNodesRoutes({ router, license }: RouteDependencies) {
/**
* @apiGroup Transform Nodes
*
* @api {get} /api/transforms/_nodes Transform Nodes
* @apiName GetTransformNodes
* @apiDescription Get transform nodes
*/
router.get<undefined, undefined, undefined>(
{
path: addBasePath('transforms/_nodes'),
validate: false,
},
license.guardApiRoute<undefined, undefined, undefined>(async (ctx, req, res) => {
try {
const {
body: { nodes },
} = await ctx.core.elasticsearch.client.asInternalUser.nodes.info({
filter_path: `nodes.*.${NODE_ROLES}`,
});
let count = 0;
if (isNodes(nodes)) {
for (const { roles } of Object.values(nodes)) {
if (roles.includes('transform')) {
count++;
}
}
}
return res.ok({ body: { count } });
} catch (e) {
return res.customError(wrapError(wrapEsError(e)));
}
})
);
}

View file

@ -32,6 +32,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./start_transforms'));
loadTestFile(require.resolve('./stop_transforms'));
loadTestFile(require.resolve('./transforms'));
loadTestFile(require.resolve('./transforms_nodes'));
loadTestFile(require.resolve('./transforms_preview'));
loadTestFile(require.resolve('./transforms_stats'));
loadTestFile(require.resolve('./transforms_update'));

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import type { GetTransformNodesResponseSchema } from '../../../../plugins/transform/common/api_schemas/transforms';
import { isGetTransformNodesResponseSchema } from '../../../../plugins/transform/common/api_schemas/type_guards';
import { COMMON_REQUEST_HEADERS } from '../../../functional/services/ml/common_api';
import { USER } from '../../../functional/services/transform/security_common';
import { FtrProviderContext } from '../../ftr_provider_context';
export default ({ getService }: FtrProviderContext) => {
const supertest = getService('supertestWithoutAuth');
const transform = getService('transform');
const expected = {
apiTransformTransformsNodes: {
count: 1,
},
};
function assertTransformsNodesResponseBody(body: GetTransformNodesResponseSchema) {
expect(isGetTransformNodesResponseSchema(body)).to.eql(true);
expect(body.count).to.eql(expected.apiTransformTransformsNodes.count);
}
describe('/api/transform/transforms/_nodes', function () {
it('should return the number of available transform nodes', async () => {
const { body } = await supertest
.get('/api/transform/transforms/_nodes')
.auth(
USER.TRANSFORM_POWERUSER,
transform.securityCommon.getPasswordForUser(USER.TRANSFORM_POWERUSER)
)
.set(COMMON_REQUEST_HEADERS)
.send()
.expect(200);
assertTransformsNodesResponseBody(body);
});
});
};