mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[7.12] [ML] Transforms: Fixes missing number of transform nodes and error reporting in stats bar. (#93956) (#95754)
- 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:
parent
25a0a12182
commit
894b883942
28 changed files with 368 additions and 72 deletions
|
@ -127,6 +127,7 @@ export class DocLinksService {
|
|||
kibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index.html`,
|
||||
elasticsearch: {
|
||||
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`,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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: any
|
||||
): arg is GetTransformNodesResponseSchema => {
|
||||
return (
|
||||
isPopulatedObject(arg) && {}.hasOwnProperty.call(arg, 'count') && typeof arg.count === 'number'
|
||||
);
|
||||
};
|
||||
|
||||
export const isGetTransformsResponseSchema = (arg: any): arg is GetTransformsResponseSchema => {
|
||||
return isGenericResponseSchema<GetTransformsResponseSchema>(arg);
|
||||
};
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -23,6 +23,7 @@ describe('Transform: Transform List Actions <StartAction />', () => {
|
|||
const props: StartActionNameProps = {
|
||||
forceDisable: false,
|
||||
items: [item],
|
||||
transformNodes: 1,
|
||||
};
|
||||
|
||||
const wrapper = shallow(<StartActionName {...props} />);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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)));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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'));
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue