[Transform] Add alerting rules management to Transform UI (#115363)

* transform alert flyout

* fetch alerting rules

* show alerting rules indicators

* filter continuous transforms

* add alert rules to the expanded row

* edit alert rule from the list

* fix ts issues

* fix types

* update texts

* refactor using context, wip create alert from the list

* update unit test

* fix ts issue

* privilege check
This commit is contained in:
Dima Arnautov 2021-10-19 17:15:52 +02:00 committed by GitHub
parent b306f8e2c3
commit 2aaa515bbc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 539 additions and 74 deletions

View file

@ -12,7 +12,7 @@ import type { ES_FIELD_TYPES } from '../../../../../src/plugins/data/common';
import type { Dictionary } from '../types/common';
import type { PivotAggDict } from '../types/pivot_aggs';
import type { PivotGroupByDict } from '../types/pivot_group_by';
import type { TransformId, TransformPivotConfig } from '../types/transform';
import type { TransformId, TransformConfigUnion } from '../types/transform';
import { transformStateSchema, runtimeMappingsSchema } from './common';
@ -33,7 +33,7 @@ export type GetTransformsRequestSchema = TypeOf<typeof getTransformsRequestSchem
export interface GetTransformsResponseSchema {
count: number;
transforms: TransformPivotConfig[];
transforms: TransformConfigUnion[];
}
// schemas shared by parts of the preview, create and update endpoint

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { AlertTypeParams } from '../../../alerting/common';
import type { Alert, AlertTypeParams } from '../../../alerting/common';
export type TransformHealthRuleParams = {
includeTransforms?: string[];
@ -20,3 +20,5 @@ export type TransformHealthRuleParams = {
export type TransformHealthRuleTestsConfig = TransformHealthRuleParams['testsConfig'];
export type TransformHealthTests = keyof Exclude<TransformHealthRuleTestsConfig, null | undefined>;
export type TransformHealthAlertRule = Omit<Alert<TransformHealthRuleParams>, 'apiKey'>;

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types';
import type { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types';
import type { LatestFunctionConfig, PutTransformsRequestSchema } from '../api_schemas/transforms';
import { isPopulatedObject } from '../shared_imports';
import { PivotGroupByDict } from './pivot_group_by';
import { PivotAggDict } from './pivot_aggs';
import type { PivotGroupByDict } from './pivot_group_by';
import type { PivotAggDict } from './pivot_aggs';
import type { TransformHealthAlertRule } from './alerting';
export type IndexName = string;
export type IndexPattern = string;
@ -22,6 +23,7 @@ export type TransformBaseConfig = PutTransformsRequestSchema & {
id: TransformId;
create_time?: number;
version?: string;
alerting_rules?: TransformHealthAlertRule[];
};
export interface PivotConfigDefinition {
@ -45,6 +47,11 @@ export type TransformLatestConfig = Omit<TransformBaseConfig, 'pivot'> & {
export type TransformConfigUnion = TransformPivotConfig | TransformLatestConfig;
export type ContinuousTransform = Omit<TransformConfigUnion, 'sync'> &
Required<{
sync: TransformConfigUnion['sync'];
}>;
export function isPivotTransform(transform: unknown): transform is TransformPivotConfig {
return isPopulatedObject(transform, ['pivot']);
}
@ -53,6 +60,10 @@ export function isLatestTransform(transform: unknown): transform is TransformLat
return isPopulatedObject(transform, ['latest']);
}
export function isContinuousTransform(transform: unknown): transform is ContinuousTransform {
return isPopulatedObject(transform, ['sync']);
}
export interface LatestFunctionConfigUI {
unique_key: Array<EuiComboBoxOptionOption<string>> | undefined;
sort: EuiComboBoxOptionOption<string> | undefined;

View file

@ -0,0 +1,127 @@
/*
* 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 React, { createContext, FC, useContext, useMemo } from 'react';
import { memoize } from 'lodash';
import { BehaviorSubject, Observable } from 'rxjs';
import { pluck } from 'rxjs/operators';
import useObservable from 'react-use/lib/useObservable';
import { useAppDependencies } from '../app/app_dependencies';
import { TransformHealthAlertRule, TransformHealthRuleParams } from '../../common/types/alerting';
import { TRANSFORM_RULE_TYPE } from '../../common';
interface TransformAlertFlyoutProps {
initialAlert?: TransformHealthAlertRule | null;
ruleParams?: TransformHealthRuleParams | null;
onSave?: () => void;
onCloseFlyout: () => void;
}
export const TransformAlertFlyout: FC<TransformAlertFlyoutProps> = ({
initialAlert,
ruleParams,
onCloseFlyout,
onSave,
}) => {
const { triggersActionsUi } = useAppDependencies();
const AlertFlyout = useMemo(() => {
if (!triggersActionsUi) return;
const commonProps = {
onClose: () => {
onCloseFlyout();
},
onSave: async () => {
if (onSave) {
onSave();
}
},
};
if (initialAlert) {
return triggersActionsUi.getEditAlertFlyout({
...commonProps,
initialAlert,
});
}
return triggersActionsUi.getAddAlertFlyout({
...commonProps,
consumer: 'stackAlerts',
canChangeTrigger: false,
alertTypeId: TRANSFORM_RULE_TYPE.TRANSFORM_HEALTH,
metadata: {},
initialValues: {
params: ruleParams!,
},
});
// deps on id to avoid re-rendering on auto-refresh
}, [triggersActionsUi, initialAlert, ruleParams, onCloseFlyout, onSave]);
return <>{AlertFlyout}</>;
};
interface AlertRulesManage {
editAlertRule$: Observable<TransformHealthAlertRule | null>;
createAlertRule$: Observable<TransformHealthRuleParams | null>;
setEditAlertRule: (alertRule: TransformHealthAlertRule) => void;
setCreateAlertRule: (transformId: string) => void;
hideAlertFlyout: () => void;
}
export const getAlertRuleManageContext = memoize(function (): AlertRulesManage {
const ruleState$ = new BehaviorSubject<{
editAlertRule: null | TransformHealthAlertRule;
createAlertRule: null | TransformHealthRuleParams;
}>({
editAlertRule: null,
createAlertRule: null,
});
return {
editAlertRule$: ruleState$.pipe(pluck('editAlertRule')),
createAlertRule$: ruleState$.pipe(pluck('createAlertRule')),
setEditAlertRule: (initialRule) => {
ruleState$.next({
createAlertRule: null,
editAlertRule: initialRule,
});
},
setCreateAlertRule: (transformId: string) => {
ruleState$.next({
createAlertRule: { includeTransforms: [transformId] },
editAlertRule: null,
});
},
hideAlertFlyout: () => {
ruleState$.next({
createAlertRule: null,
editAlertRule: null,
});
},
};
});
export const AlertRulesManageContext = createContext<AlertRulesManage>(getAlertRuleManageContext());
export function useAlertRuleFlyout(): AlertRulesManage {
return useContext(AlertRulesManageContext);
}
export const TransformAlertFlyoutWrapper = () => {
const { editAlertRule$, createAlertRule$, hideAlertFlyout } = useAlertRuleFlyout();
const editAlertRule = useObservable(editAlertRule$);
const createAlertRule = useObservable(createAlertRule$);
return editAlertRule || createAlertRule ? (
<TransformAlertFlyout
initialAlert={editAlertRule}
ruleParams={createAlertRule!}
onCloseFlyout={hideAlertFlyout}
/>
) : null;
};

View file

@ -19,6 +19,7 @@ import { Storage } from '../../../../../../src/plugins/kibana_utils/public';
import type { AppDependencies } from '../app_dependencies';
import { MlSharedContext } from './shared_context';
import type { GetMlSharedImportsReturnType } from '../../shared_imports';
import type { TriggersAndActionsUIPublicPluginStart } from '../../../../triggers_actions_ui/public';
const coreSetup = coreMock.createSetup();
const coreStart = coreMock.createStart();
@ -43,6 +44,7 @@ const appDependencies: AppDependencies = {
savedObjectsPlugin: savedObjectsPluginMock.createStartContract(),
share: { urlGenerators: { getUrlGenerator: jest.fn() } } as unknown as SharePluginStart,
ml: {} as GetMlSharedImportsReturnType,
triggersActionsUi: {} as jest.Mocked<TriggersAndActionsUIPublicPluginStart>,
};
export const useAppDependencies = () => {

View file

@ -16,6 +16,7 @@ import { useKibana } from '../../../../../src/plugins/kibana_react/public';
import type { Storage } from '../../../../../src/plugins/kibana_utils/public';
import type { GetMlSharedImportsReturnType } from '../shared_imports';
import type { TriggersAndActionsUIPublicPluginStart } from '../../../triggers_actions_ui/public';
export interface AppDependencies {
application: CoreStart['application'];
@ -34,6 +35,7 @@ export interface AppDependencies {
share: SharePluginStart;
ml: GetMlSharedImportsReturnType;
spaces?: SpacesPluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
}
export const useAppDependencies = () => {

View file

@ -5,10 +5,10 @@
* 2.0.
*/
import { EuiTableActionsColumnType } from '@elastic/eui';
import { TransformConfigUnion, TransformId } from '../../../common/types/transform';
import { TransformStats } from '../../../common/types/transform_stats';
import type { EuiTableActionsColumnType } from '@elastic/eui';
import type { TransformConfigUnion, TransformId } from '../../../common/types/transform';
import type { TransformStats } from '../../../common/types/transform_stats';
import type { TransformHealthAlertRule } from '../../../common/types/alerting';
// Used to pass on attribute names to table columns
export enum TRANSFORM_LIST_COLUMN {
@ -21,6 +21,7 @@ export interface TransformListRow {
config: TransformConfigUnion;
mode?: string; // added property on client side to allow filtering by this field
stats: TransformStats;
alerting_rules?: TransformHealthAlertRule[];
}
// The single Action type is not exported as is

View file

@ -87,6 +87,7 @@ export const useGetTransforms = (
mode:
typeof config.sync !== 'undefined' ? TRANSFORM_MODE.CONTINUOUS : TRANSFORM_MODE.BATCH,
stats,
alerting_rules: config.alerting_rules,
});
return reducedtableRows;
}, [] as TransformListRow[]);

View file

@ -20,12 +20,14 @@ interface Authorization {
capabilities: Capabilities;
}
const initialCapabalities: Capabilities = {
const initialCapabilities: Capabilities = {
canGetTransform: false,
canDeleteTransform: false,
canPreviewTransform: false,
canCreateTransform: false,
canStartStopTransform: false,
canCreateTransformAlerts: false,
canUseTransformAlerts: false,
};
const initialValue: Authorization = {
@ -35,7 +37,7 @@ const initialValue: Authorization = {
hasAllPrivileges: false,
missingPrivileges: {},
},
capabilities: initialCapabalities,
capabilities: initialCapabilities,
};
export const AuthorizationContext = createContext<Authorization>({ ...initialValue });
@ -58,7 +60,7 @@ export const AuthorizationProvider = ({ privilegesEndpoint, children }: Props) =
const value = {
isLoading,
privileges: isLoading ? { ...initialValue.privileges } : privilegesData,
capabilities: { ...initialCapabalities },
capabilities: { ...initialCapabilities },
apiError: error ? (error as Error) : null,
};
@ -85,6 +87,10 @@ export const AuthorizationProvider = ({ privilegesEndpoint, children }: Props) =
hasPrivilege(['cluster', 'cluster:admin/transform/start_task']) &&
hasPrivilege(['cluster', 'cluster:admin/transform/stop']);
value.capabilities.canCreateTransformAlerts = value.capabilities.canCreateTransform;
value.capabilities.canUseTransformAlerts = value.capabilities.canGetTransform;
return (
<AuthorizationContext.Provider value={{ ...value }}>{children}</AuthorizationContext.Provider>
);

View file

@ -16,6 +16,8 @@ export interface Capabilities {
canPreviewTransform: boolean;
canCreateTransform: boolean;
canStartStopTransform: boolean;
canCreateTransformAlerts: boolean;
canUseTransformAlerts: boolean;
}
export type Privilege = [string, string];
@ -67,6 +69,14 @@ export function createCapabilityFailureMessage(
defaultMessage: 'You do not have permission to create transforms.',
});
break;
case 'canCreateTransformAlerts':
message = i18n.translate(
'xpack.transform.capability.noPermission.canCreateTransformAlertsTooltip',
{
defaultMessage: 'You do not have permission to create transform alert rules.',
}
);
break;
case 'canStartStopTransform':
message = i18n.translate(
'xpack.transform.capability.noPermission.startOrStopTransformTooltip',

View file

@ -29,7 +29,7 @@ export async function mountManagementSection(
const startServices = await getStartServices();
const [core, plugins] = startServices;
const { application, chrome, docLinks, i18n, overlays, savedObjects, uiSettings } = core;
const { data, share, spaces } = plugins;
const { data, share, spaces, triggersActionsUi } = plugins;
const { docTitle } = chrome;
// Initialize services
@ -55,6 +55,7 @@ export async function mountManagementSection(
share,
spaces,
ml: await getMlSharedImports(),
triggersActionsUi,
};
const unmountAppCallback = renderApp(element, appDependencies);

View file

@ -21,7 +21,7 @@ import {
} from '@elastic/eui';
import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants';
import { TransformPivotConfig } from '../../../../common/types/transform';
import { TransformConfigUnion } from '../../../../common/types/transform';
import { isHttpFetchError } from '../../common/request';
import { useApi } from '../../hooks/use_api';
@ -50,7 +50,7 @@ export const CloneTransformSection: FC<Props> = ({ match, location }) => {
const transformId = match.params.transformId;
const [transformConfig, setTransformConfig] = useState<TransformPivotConfig>();
const [transformConfig, setTransformConfig] = useState<TransformConfigUnion>();
const [errorMessage, setErrorMessage] = useState<string>();
const [isInitialized, setIsInitialized] = useState(false);
const { error: searchItemsError, searchItems, setSavedObjectId } = useSearchItems(undefined);

View file

@ -23,6 +23,7 @@ import {
EuiText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public';
import {
@ -52,7 +53,8 @@ import {
} from '../../../../../../common/api_schemas/transforms';
import type { RuntimeField } from '../../../../../../../../../src/plugins/data/common';
import { isPopulatedObject } from '../../../../../../common/shared_imports';
import { isLatestTransform } from '../../../../../../common/types/transform';
import { isContinuousTransform, isLatestTransform } from '../../../../../../common/types/transform';
import { TransformAlertFlyout } from '../../../../../alerting/transform_alerting_flyout';
export interface StepDetailsExposedState {
created: boolean;
@ -86,6 +88,7 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
const [loading, setLoading] = useState(false);
const [created, setCreated] = useState(defaults.created);
const [started, setStarted] = useState(defaults.started);
const [alertFlyoutVisible, setAlertFlyoutVisible] = useState(false);
const [indexPatternId, setIndexPatternId] = useState(defaults.indexPatternId);
const [progressPercentComplete, setProgressPercentComplete] = useState<undefined | number>(
undefined
@ -398,6 +401,31 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
</EuiFlexItem>
</EuiFlexGroup>
)}
{isContinuousTransform(transformConfig) && created ? (
<EuiFlexGroup alignItems="center" style={FLEX_GROUP_STYLE}>
<EuiFlexItem grow={false} style={FLEX_ITEM_STYLE}>
<EuiButton
fill
isDisabled={loading}
onClick={setAlertFlyoutVisible.bind(null, true)}
data-test-subj="transformWizardCreateAlertButton"
>
<FormattedMessage
id="xpack.transform.stepCreateForm.createAlertRuleButton"
defaultMessage="Create alert rule"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="s">
{i18n.translate('xpack.transform.stepCreateForm.createAlertRuleDescription', {
defaultMessage:
'Opens a wizard to create an alert rule for monitoring transform health.',
})}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
) : null}
<EuiFlexGroup alignItems="center" style={FLEX_GROUP_STYLE}>
<EuiFlexItem grow={false} style={FLEX_ITEM_STYLE}>
<EuiButton
@ -414,7 +442,7 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
<EuiText color="subdued" size="s">
{i18n.translate('xpack.transform.stepCreateForm.createTransformDescription', {
defaultMessage:
'Create the transform without starting it. You will be able to start the transform later by returning to the transforms list.',
'Creates the transform without starting it. You will be able to start the transform later by returning to the transforms list.',
})}
</EuiText>
</EuiFlexItem>
@ -535,6 +563,12 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
</Fragment>
)}
</EuiForm>
{alertFlyoutVisible ? (
<TransformAlertFlyout
ruleParams={{ includeTransforms: [transformId] }}
onCloseFlyout={setAlertFlyoutVisible.bind(null, false)}
/>
) : null}
</div>
);
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { TransformId, TransformPivotConfig } from '../../../../../../common/types/transform';
import type { TransformConfigUnion, TransformId } from '../../../../../../common/types/transform';
export type EsIndexName = string;
export type IndexPatternTitle = string;
@ -55,7 +55,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState {
export function applyTransformConfigToDetailsState(
state: StepDetailsExposedState,
transformConfig?: TransformPivotConfig
transformConfig?: TransformConfigUnion
): StepDetailsExposedState {
// apply the transform configuration to wizard DETAILS state
if (transformConfig !== undefined) {

View file

@ -29,7 +29,7 @@ import {
isEsIndices,
isPostTransformsPreviewResponseSchema,
} from '../../../../../../common/api_schemas/type_guards';
import { TransformId, TransformPivotConfig } from '../../../../../../common/types/transform';
import { TransformId } from '../../../../../../common/types/transform';
import { isValidIndexName } from '../../../../../../common/utils/es_utils';
import { getErrorMessage } from '../../../../../../common/utils/errors';
@ -158,7 +158,7 @@ export const StepDetailsForm: FC<StepDetailsFormProps> = React.memo(
),
});
} else {
setTransformIds(resp.transforms.map((transform: TransformPivotConfig) => transform.id));
setTransformIds(resp.transforms.map((transform) => transform.id));
}
const indices = await api.getEsIndices();

View file

@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n';
import { EuiSteps, EuiStepStatus } from '@elastic/eui';
import { TransformPivotConfig } from '../../../../../../common/types/transform';
import type { TransformConfigUnion } from '../../../../../../common/types/transform';
import { getCreateTransformRequestBody } from '../../../../common';
import { SearchItems } from '../../../../hooks/use_search_items';
@ -81,7 +81,7 @@ const StepDefine: FC<DefinePivotStepProps> = ({
};
interface WizardProps {
cloneConfig?: TransformPivotConfig;
cloneConfig?: TransformConfigUnion;
searchItems: SearchItems;
}

View file

@ -0,0 +1,38 @@
/*
* 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 React, { FC } from 'react';
import { EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { createCapabilityFailureMessage } from '../../../../lib/authorization';
interface CreateAlertRuleActionProps {
disabled: boolean;
}
export const crateAlertRuleActionNameText = i18n.translate(
'xpack.transform.transformList.createAlertRuleNameText',
{
defaultMessage: 'Create alert rule',
}
);
export const CreateAlertRuleActionName: FC<CreateAlertRuleActionProps> = ({ disabled }) => {
if (disabled) {
return (
<EuiToolTip
position="top"
content={createCapabilityFailureMessage('canCreateTransformAlerts')}
>
<>{crateAlertRuleActionNameText}</>
</EuiToolTip>
);
}
return <>{crateAlertRuleActionNameText}</>;
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { useCreateAlertRuleAction } from './use_create_alert_rule_action';

View file

@ -0,0 +1,47 @@
/*
* 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 React, { useCallback, useContext, useMemo } from 'react';
import { AuthorizationContext } from '../../../../lib/authorization';
import { TransformListAction, TransformListRow } from '../../../../common';
import {
crateAlertRuleActionNameText,
CreateAlertRuleActionName,
} from './create_alert_rule_action_name';
import { useAlertRuleFlyout } from '../../../../../alerting/transform_alerting_flyout';
import { isContinuousTransform } from '../../../../../../common/types/transform';
export type CreateAlertRuleAction = ReturnType<typeof useCreateAlertRuleAction>;
export const useCreateAlertRuleAction = (forceDisable: boolean) => {
const { canCreateTransformAlerts } = useContext(AuthorizationContext).capabilities;
const { setCreateAlertRule } = useAlertRuleFlyout();
const clickHandler = useCallback(
(item: TransformListRow) => {
setCreateAlertRule(item.id);
},
[setCreateAlertRule]
);
const action: TransformListAction = useMemo(
() => ({
name: (item: TransformListRow) => (
<CreateAlertRuleActionName disabled={!canCreateTransformAlerts} />
),
available: (item: TransformListRow) => isContinuousTransform(item.config),
enabled: () => canCreateTransformAlerts && !forceDisable,
description: crateAlertRuleActionNameText,
type: 'icon',
icon: 'bell',
onClick: clickHandler,
'data-test-subj': 'transformActionCreateAlertRule',
}),
[canCreateTransformAlerts, forceDisable, clickHandler]
);
return { action };
};

View file

@ -22,6 +22,7 @@ import { getMlSharedImports } from '../../../../../shared_imports';
// FLAKY https://github.com/elastic/kibana/issues/112922
describe.skip('Transform: Transform List <ExpandedRow />', () => {
const onAlertEdit = jest.fn();
// Set timezone to US/Eastern for consistent test results.
beforeEach(() => {
moment.tz.setDefault('US/Eastern');
@ -38,7 +39,7 @@ describe.skip('Transform: Transform List <ExpandedRow />', () => {
render(
<MlSharedContext.Provider value={mlShared}>
<ExpandedRow item={item} />
<ExpandedRow item={item} onAlertEdit={onAlertEdit} />
</MlSharedContext.Provider>
);

View file

@ -7,17 +7,18 @@
import React, { FC } from 'react';
import { EuiTabbedContent } from '@elastic/eui';
import { EuiButtonEmpty, EuiTabbedContent } from '@elastic/eui';
import { Optional } from '@kbn/utility-types';
import { i18n } from '@kbn/i18n';
import moment from 'moment-timezone';
import { TransformListRow } from '../../../../common';
import { useAppDependencies } from '../../../../app_dependencies';
import { ExpandedRowDetailsPane, SectionConfig } from './expanded_row_details_pane';
import { ExpandedRowDetailsPane, SectionConfig, SectionItem } from './expanded_row_details_pane';
import { ExpandedRowJsonPane } from './expanded_row_json_pane';
import { ExpandedRowMessagesPane } from './expanded_row_messages_pane';
import { ExpandedRowPreviewPane } from './expanded_row_preview_pane';
import { TransformHealthAlertRule } from '../../../../../../common/types/alerting';
function getItemDescription(value: any) {
if (typeof value === 'object') {
@ -44,18 +45,16 @@ export function stringHash(str: string): number {
return hash < 0 ? hash * -2 : hash;
}
interface Item {
title: string;
description: any;
}
type Item = SectionItem;
interface Props {
item: TransformListRow;
onAlertEdit: (alertRule: TransformHealthAlertRule) => void;
}
type StateValues = Optional<TransformListRow['stats'], 'stats' | 'checkpointing'>;
export const ExpandedRow: FC<Props> = ({ item }) => {
export const ExpandedRow: FC<Props> = ({ item, onAlertEdit }) => {
const {
ml: { formatHumanReadableDateTimeSeconds },
} = useAppDependencies();
@ -166,12 +165,40 @@ export const ExpandedRow: FC<Props> = ({ item }) => {
}
}
const alertRuleItems: Item[] | undefined = item.alerting_rules?.map((rule) => {
return {
title: (
<EuiButtonEmpty
iconType={'documentEdit'}
iconSide={'left'}
onClick={() => {
onAlertEdit(rule);
}}
flush="left"
size={'xs'}
iconSize={'s'}
>
{rule.name}
</EuiButtonEmpty>
),
description: rule.executionStatus.status,
};
});
const checkpointing: SectionConfig = {
title: 'Checkpointing',
items: checkpointingItems,
position: 'right',
};
const alertingRules: SectionConfig = {
title: i18n.translate('xpack.transform.transformList.transformDetails.alertRulesTitle', {
defaultMessage: 'Alert rules',
}),
items: alertRuleItems!,
position: 'right',
};
const stats: SectionConfig = {
title: 'Stats',
items: Object.entries(item.stats.stats).map((s) => {
@ -192,7 +219,16 @@ export const ExpandedRow: FC<Props> = ({ item }) => {
defaultMessage: 'Details',
}
),
content: <ExpandedRowDetailsPane sections={[general, state, checkpointing]} />,
content: (
<ExpandedRowDetailsPane
sections={[
general,
state,
checkpointing,
...(alertingRules.items ? [alertingRules] : []),
]}
/>
),
},
{
id: `transform-stats-tab-${tabId}`,

View file

@ -17,9 +17,10 @@ import {
} from '@elastic/eui';
export interface SectionItem {
title: string;
description: string;
title: string | JSX.Element;
description: string | number | JSX.Element;
}
export interface SectionConfig {
title: string;
position: 'left' | 'right';

View file

@ -50,15 +50,18 @@ import { useColumns } from './use_columns';
import { ExpandedRow } from './expanded_row';
import { transformFilters, filterTransforms } from './transform_search_bar_filters';
import { useTableSettings } from './use_table_settings';
import { useAlertRuleFlyout } from '../../../../../alerting/transform_alerting_flyout';
import { TransformHealthAlertRule } from '../../../../../../common/types/alerting';
function getItemIdToExpandedRowMap(
itemIds: TransformId[],
transforms: TransformListRow[]
transforms: TransformListRow[],
onAlertEdit: (alertRule: TransformHealthAlertRule) => void
): ItemIdToExpandedRowMap {
return itemIds.reduce((m: ItemIdToExpandedRowMap, transformId: TransformId) => {
const item = transforms.find((transform) => transform.config.id === transformId);
if (item !== undefined) {
m[transformId] = <ExpandedRow item={item} />;
m[transformId] = <ExpandedRow item={item} onAlertEdit={onAlertEdit} />;
}
return m;
}, {} as ItemIdToExpandedRowMap);
@ -79,6 +82,7 @@ export const TransformList: FC<TransformListProps> = ({
}) => {
const [isLoading, setIsLoading] = useState(false);
const { refresh } = useRefreshTransformList({ isLoading: setIsLoading });
const { setEditAlertRule } = useAlertRuleFlyout();
const [filterActive, setFilterActive] = useState(false);
@ -171,7 +175,11 @@ export const TransformList: FC<TransformListProps> = ({
);
}
const itemIdToExpandedRowMap = getItemIdToExpandedRowMap(expandedRowItemIds, transforms);
const itemIdToExpandedRowMap = getItemIdToExpandedRowMap(
expandedRowItemIds,
transforms,
setEditAlertRule
);
const bulkActionMenuItems = [
<div key="startAction" className="transform__BulkActionItem">

View file

@ -27,6 +27,7 @@ describe('Transform: Transform List Actions', () => {
// in the runtime result here anyway.
expect(actions.map((a: any) => a['data-test-subj'])).toStrictEqual([
'transformActionDiscover',
'transformActionCreateAlertRule',
'transformActionStart',
'transformActionStop',
'transformActionEdit',

View file

@ -18,6 +18,7 @@ import { EditTransformFlyout } from '../edit_transform_flyout';
import { useEditAction } from '../action_edit';
import { useStartAction, StartActionModal } from '../action_start';
import { useStopAction } from '../action_stop';
import { useCreateAlertRuleAction } from '../action_create_alert';
export const useActions = ({
forceDisable,
@ -35,6 +36,7 @@ export const useActions = ({
const editAction = useEditAction(forceDisable, transformNodes);
const startAction = useStartAction(forceDisable, transformNodes);
const stopAction = useStopAction(forceDisable);
const createAlertRuleAction = useCreateAlertRuleAction(forceDisable);
return {
modals: (
@ -52,6 +54,7 @@ export const useActions = ({
),
actions: [
discoverAction.action,
createAlertRuleAction.action,
startAction.action,
stopAction.action,
editAction.action,

View file

@ -20,14 +20,15 @@ describe('Transform: Job List Columns', () => {
const columns: ReturnType<typeof useColumns>['columns'] = result.current.columns;
expect(columns).toHaveLength(8);
expect(columns).toHaveLength(9);
expect(columns[0].isExpander).toBeTruthy();
expect(columns[1].name).toBe('ID');
expect(columns[2].name).toBe('Description');
expect(columns[3].name).toBe('Type');
expect(columns[4].name).toBe('Status');
expect(columns[5].name).toBe('Mode');
expect(columns[6].name).toBe('Progress');
expect(columns[7].name).toBe('Actions');
expect(columns[2].id).toBe('alertRule');
expect(columns[3].name).toBe('Description');
expect(columns[4].name).toBe('Type');
expect(columns[5].name).toBe('Status');
expect(columns[6].name).toBe('Mode');
expect(columns[7].name).toBe('Progress');
expect(columns[8].name).toBe('Actions');
});
});

View file

@ -21,6 +21,7 @@ import {
EuiText,
EuiToolTip,
RIGHT_ALIGNMENT,
EuiIcon,
} from '@elastic/eui';
import {
@ -95,6 +96,7 @@ export const useColumns = (
const columns: [
EuiTableComputedColumnType<TransformListRow>,
EuiTableFieldDataColumnType<TransformListRow>,
EuiTableComputedColumnType<TransformListRow>,
EuiTableFieldDataColumnType<TransformListRow>,
EuiTableComputedColumnType<TransformListRow>,
EuiTableComputedColumnType<TransformListRow>,
@ -143,6 +145,38 @@ export const useColumns = (
truncateText: true,
scope: 'row',
},
{
id: 'alertRule',
name: (
<EuiScreenReaderOnly>
<p>
<FormattedMessage
id="xpack.transform.transformList.alertingRules.screenReaderDescription"
defaultMessage="This column displays an icon when there are alert rules associated with a transform"
/>
</p>
</EuiScreenReaderOnly>
),
width: '30px',
render: (item) => {
return Array.isArray(item.alerting_rules) ? (
<EuiToolTip
position="bottom"
content={
<FormattedMessage
id="xpack.transform.transformList.alertingRules.tooltipContent"
defaultMessage="Transform has {rulesCount} associated alert {rulesCount, plural, one { rule} other { rules}}"
values={{ rulesCount: item.alerting_rules.length }}
/>
}
>
<EuiIcon type="bell" />
</EuiToolTip>
) : (
<span />
);
},
},
{
field: TRANSFORM_LIST_COLUMN.DESCRIPTION,
'data-test-subj': 'transformListColumnDescription',

View file

@ -35,6 +35,11 @@ import { useRefreshInterval } from './components/transform_list/use_refresh_inte
import { SearchSelection } from './components/search_selection';
import { TransformList } from './components/transform_list';
import { TransformStatsBar } from './components/transform_list/transforms_stats_bar';
import {
AlertRulesManageContext,
getAlertRuleManageContext,
TransformAlertFlyoutWrapper,
} from '../../../alerting/transform_alerting_flyout';
export const TransformManagement: FC = () => {
const { esTransform } = useDocumentationLinks();
@ -149,12 +154,15 @@ export const TransformManagement: FC = () => {
</EuiFlexGroup>
)}
{typeof errorMessage === 'undefined' && (
<TransformList
onCreateTransform={onOpenModal}
transformNodes={transformNodes}
transforms={transforms}
transformsLoading={transformsLoading}
/>
<AlertRulesManageContext.Provider value={getAlertRuleManageContext()}>
<TransformList
onCreateTransform={onOpenModal}
transformNodes={transformNodes}
transforms={transforms}
transformsLoading={transformsLoading}
/>
<TransformAlertFlyoutWrapper />
</AlertRulesManageContext.Provider>
)}
</>
)}

View file

@ -16,7 +16,7 @@ import type { SharePluginStart } from 'src/plugins/share/public';
import type { SpacesApi } from '../../spaces/public';
import { registerFeature } from './register_feature';
import type { PluginSetupContract as AlertingSetup } from '../../alerting/public';
import type { TriggersAndActionsUIPublicPluginSetup } from '../../triggers_actions_ui/public';
import type { TriggersAndActionsUIPublicPluginStart } from '../../triggers_actions_ui/public';
import { getTransformHealthRuleType } from './alerting';
export interface PluginsDependencies {
@ -27,7 +27,7 @@ export interface PluginsDependencies {
share: SharePluginStart;
spaces?: SpacesApi;
alerting?: AlertingSetup;
triggersActionsUi?: TriggersAndActionsUIPublicPluginSetup;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
}
export class TransformUiPlugin {

View file

@ -8,16 +8,21 @@
import { ElasticsearchClient } from 'kibana/server';
import { i18n } from '@kbn/i18n';
import type { Transform as EsTransform } from '@elastic/elasticsearch/api/types';
import { keyBy } from 'lodash';
import { TransformHealthRuleParams } from './schema';
import {
ALL_TRANSFORMS_SELECTION,
TRANSFORM_HEALTH_CHECK_NAMES,
TRANSFORM_RULE_TYPE,
} from '../../../../common/constants';
import { getResultTestConfig } from '../../../../common/utils/alerts';
import {
NotStartedTransformResponse,
TransformHealthAlertContext,
} from './register_transform_health_rule_type';
import type { RulesClient } from '../../../../../alerting/server';
import type { TransformHealthAlertRule } from '../../../../common/types/alerting';
import { isContinuousTransform } from '../../../../common/types/transform';
interface TestResult {
name: string;
@ -27,37 +32,48 @@ interface TestResult {
// @ts-ignore FIXME update types in the elasticsearch client
type Transform = EsTransform & { id: string; description?: string; sync: object };
export function transformHealthServiceProvider(esClient: ElasticsearchClient) {
type TransformWithAlertingRules = Transform & { alerting_rules: TransformHealthAlertRule[] };
export function transformHealthServiceProvider(
esClient: ElasticsearchClient,
rulesClient?: RulesClient
) {
const transformsDict = new Map<string, Transform>();
/**
* Resolves result transform selection.
* @param includeTransforms
* @param excludeTransforms
* @param skipIDsCheck
*/
const getResultsTransformIds = async (
includeTransforms: string[],
excludeTransforms: string[] | null
excludeTransforms: string[] | null,
skipIDsCheck = false
): Promise<string[]> => {
const includeAll = includeTransforms.some((id) => id === ALL_TRANSFORMS_SELECTION);
// Fetch transforms to make sure assigned transforms exists.
const transformsResponse = (
await esClient.transform.getTransform({
...(includeAll ? {} : { transform_id: includeTransforms.join(',') }),
allow_no_match: true,
size: 1000,
})
).body.transforms as Transform[];
let resultTransformIds: string[] = [];
transformsResponse.forEach((t) => {
transformsDict.set(t.id, t);
if (t.sync) {
resultTransformIds.push(t.id);
}
});
if (skipIDsCheck) {
resultTransformIds = includeTransforms;
} else {
// Fetch transforms to make sure assigned transforms exists.
const transformsResponse = (
await esClient.transform.getTransform({
...(includeAll ? {} : { transform_id: includeTransforms.join(',') }),
allow_no_match: true,
size: 1000,
})
).body.transforms as Transform[];
transformsResponse.forEach((t) => {
transformsDict.set(t.id, t);
if (t.sync) {
resultTransformIds.push(t.id);
}
});
}
if (excludeTransforms && excludeTransforms.length > 0) {
const excludeIdsSet = new Set(excludeTransforms);
@ -129,6 +145,53 @@ export function transformHealthServiceProvider(esClient: ElasticsearchClient) {
return result;
},
/**
* Updates transform list with associated alerting rules.
*/
async populateTransformsWithAssignedRules(
transforms: Transform[]
): Promise<TransformWithAlertingRules[]> {
const newList = transforms.filter(isContinuousTransform) as TransformWithAlertingRules[];
if (!rulesClient) {
throw new Error('Rules client is missing');
}
const transformMap = keyBy(newList, 'id');
const transformAlertingRules = await rulesClient.find<TransformHealthRuleParams>({
options: {
perPage: 1000,
filter: `alert.attributes.alertTypeId:${TRANSFORM_RULE_TYPE.TRANSFORM_HEALTH}`,
},
});
for (const ruleInstance of transformAlertingRules.data) {
// Retrieve result transform IDs
const resultTransformIds: string[] = await getResultsTransformIds(
ruleInstance.params.includeTransforms.includes(ALL_TRANSFORMS_SELECTION)
? Object.keys(transformMap)
: ruleInstance.params.includeTransforms,
ruleInstance.params.excludeTransforms,
true
);
resultTransformIds.forEach((transformId) => {
const transformRef = transformMap[transformId] as TransformWithAlertingRules;
if (transformRef) {
if (Array.isArray(transformRef.alerting_rules)) {
transformRef.alerting_rules.push(ruleInstance);
} else {
transformRef.alerting_rules = [ruleInstance];
}
}
});
}
return newList;
},
};
}

View file

@ -63,6 +63,7 @@ import { registerTransformNodesRoutes } from './transforms_nodes';
import { IIndexPattern } from '../../../../../../src/plugins/data/common';
import { isLatestTransform } from '../../../common/types/transform';
import { isKeywordDuplicate } from '../../../common/utils/field_utils';
import { transformHealthServiceProvider } from '../../lib/alerting/transform_health_rule_type/transform_health_service';
enum TRANSFORM_ACTIONS {
STOP = 'stop',
@ -90,6 +91,17 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) {
size: 1000,
...req.params,
});
if (ctx.alerting) {
const transformHealthService = transformHealthServiceProvider(
ctx.core.elasticsearch.client.asCurrentUser,
ctx.alerting.getRulesClient()
);
// @ts-ignore
await transformHealthService.populateTransformsWithAssignedRules(body.transforms);
}
return res.ok({ body });
} catch (e) {
return res.customError(wrapError(wrapEsError(e)));

View file

@ -15,6 +15,7 @@ import {
} from 'kibana/server';
import { LicensingPluginSetup, LicenseType } from '../../../licensing/server';
import type { AlertingApiRequestHandlerContext } from '../../../alerting/server';
export interface LicenseStatus {
isValid: boolean;
@ -28,6 +29,10 @@ interface SetupSettings {
defaultErrorMessage: string;
}
type TransformRequestHandlerContext = RequestHandlerContext & {
alerting?: AlertingApiRequestHandlerContext;
};
export class License {
private licenseStatus: LicenseStatus = {
isValid: false,
@ -64,7 +69,9 @@ export class License {
});
}
guardApiRoute<Params, Query, Body>(handler: RequestHandler<Params, Query, Body>) {
guardApiRoute<Params, Query, Body>(
handler: RequestHandler<Params, Query, Body, TransformRequestHandlerContext>
) {
const license = this;
return function licenseCheck(

View file

@ -18,7 +18,7 @@ import {
IndexedHostsAndAlertsResponse,
indexHostsAndAlerts,
} from '../../../plugins/security_solution/common/endpoint/index_data';
import { TransformPivotConfig } from '../../../plugins/transform/common/types/transform';
import { TransformConfigUnion } from '../../../plugins/transform/common/types/transform';
import { GetTransformsResponseSchema } from '../../../plugins/transform/common/api_schemas/transforms';
import { catchAndWrapError } from '../../../plugins/security_solution/server/endpoint/utils';
import { installOrUpgradeEndpointFleetPackage } from '../../../plugins/security_solution/common/endpoint/data_loaders/setup_fleet_for_endpoint';
@ -38,9 +38,9 @@ export class EndpointTestResources extends FtrService {
*
* @param [endpointPackageVersion] if set, it will be used to get the specific transform this this package version. Else just returns first one found
*/
async getTransform(endpointPackageVersion?: string): Promise<TransformPivotConfig> {
async getTransform(endpointPackageVersion?: string): Promise<TransformConfigUnion> {
const transformId = this.generateTransformId(endpointPackageVersion);
let transform: TransformPivotConfig | undefined;
let transform: TransformConfigUnion | undefined;
if (endpointPackageVersion) {
await this.transform.api.waitForTransformToExist(transformId);