[Alerting] Adds navigation by consumer and alert type to alerting (#58997) (#60605)

Adds Navigation APIs to Alerting.

Parts to this PR:

Adds a client side (Public) plugin to Alerting, including two APIs: registerNavigation & registerDefaultNavigation. These allow a plugin to register navigation handlers for any alerts which it is the consumer of- one for specific AlertTypes and one for a default handler for all AlertTypes created by the plugin.
The Alert Details page now uses these navigation handlers for the View In App button. If there's an AlertType specific handler it uses that, otherwise it uses a default one and if the consumer has not registered a handler - it remains disabled.
A generic Alerting Example plugin that demonstrates usage of these APIs including two AlertTypes - one that always fires, and another that checks how many people are in Outer Space and allows you to trigger based on that. 😉 To enable the plugin run yarn start --ssl --run-examples
This commit is contained in:
Gidi Meir Morris 2020-03-19 13:05:44 +00:00 committed by GitHub
parent a8c61473e5
commit 08384027cb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 2545 additions and 56 deletions

View file

@ -0,0 +1,5 @@
## Alerting Example
This example plugin shows you how to create a custom Alert Type, create alerts based on that type and corresponding UI for viewing the details of all the alerts within the custom plugin.
To run this example, use the command `yarn start --run-examples`.

View file

@ -0,0 +1,34 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const ALERTING_EXAMPLE_APP_ID = 'AlertingExample';
// always firing
export const DEFAULT_INSTANCES_TO_GENERATE = 5;
// Astros
export enum Craft {
OuterSpace = 'Outer Space',
ISS = 'ISS',
}
export enum Operator {
AreAbove = 'Are above',
AreBelow = 'Are below',
AreExactly = 'Are exactly',
}

View file

@ -0,0 +1,10 @@
{
"id": "alertingExample",
"version": "0.0.1",
"kibanaVersion": "kibana",
"configPath": ["alerting_example"],
"server": true,
"ui": true,
"requiredPlugins": ["triggers_actions_ui", "charts", "data", "alerting", "actions"],
"optionalPlugins": []
}

View file

@ -0,0 +1,17 @@
{
"name": "alerting_example",
"version": "1.0.0",
"main": "target/examples/alerting_example",
"kibana": {
"version": "kibana",
"templateVersion": "1.0.0"
},
"license": "Apache-2.0",
"scripts": {
"kbn": "node ../../scripts/kbn.js",
"build": "rm -rf './target' && tsc"
},
"devDependencies": {
"typescript": "3.7.2"
}
}

View file

@ -0,0 +1,82 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Fragment } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiFieldNumber, EuiFormRow } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AlertTypeModel } from '../../../../x-pack/plugins/triggers_actions_ui/public';
import { DEFAULT_INSTANCES_TO_GENERATE } from '../../common/constants';
interface AlwaysFiringParamsProps {
alertParams: { instances?: number };
setAlertParams: (property: string, value: any) => void;
errors: { [key: string]: string[] };
}
export function getAlertType(): AlertTypeModel {
return {
id: 'example.always-firing',
name: 'Always Fires',
iconClass: 'bolt',
alertParamsExpression: AlwaysFiringExpression,
validate: (alertParams: AlwaysFiringParamsProps['alertParams']) => {
const { instances } = alertParams;
const validationResult = {
errors: {
instances: new Array<string>(),
},
};
if (instances && instances < 0) {
validationResult.errors.instances.push(
i18n.translate('AlertingExample.addAlert.error.invalidRandomInstances', {
defaultMessage: 'instances must be equal or greater than zero.',
})
);
}
return validationResult;
},
};
}
export const AlwaysFiringExpression: React.FunctionComponent<AlwaysFiringParamsProps> = ({
alertParams,
setAlertParams,
}) => {
const { instances = DEFAULT_INSTANCES_TO_GENERATE } = alertParams;
return (
<Fragment>
<EuiFlexGroup gutterSize="s" wrap direction="column">
<EuiFlexItem grow={true}>
<EuiFormRow
label="Random Instances to generate"
helpText="How many randomly generated Alert Instances do you wish to activate on each alert run?"
>
<EuiFieldNumber
name="instances"
value={instances}
onChange={event => {
setAlertParams('instances', event.target.valueAsNumber);
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
};

View file

@ -0,0 +1,277 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState, useEffect, Fragment } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiFieldNumber,
EuiPopoverTitle,
EuiSelect,
EuiCallOut,
EuiExpression,
EuiTextColor,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { flatten } from 'lodash';
import { ALERTING_EXAMPLE_APP_ID, Craft, Operator } from '../../common/constants';
import { SanitizedAlert } from '../../../../x-pack/plugins/alerting/common';
import { PluginSetupContract as AlertingSetup } from '../../../../x-pack/plugins/alerting/public';
import { AlertTypeModel } from '../../../../x-pack/plugins/triggers_actions_ui/public';
export function registerNavigation(alerting: AlertingSetup) {
alerting.registerNavigation(
ALERTING_EXAMPLE_APP_ID,
'example.people-in-space',
(alert: SanitizedAlert) => `/astros/${alert.id}`
);
}
interface PeopleinSpaceParamsProps {
alertParams: { outerSpaceCapacity?: number; craft?: string; op?: string };
setAlertParams: (property: string, value: any) => void;
errors: { [key: string]: string[] };
}
function isValueInEnum(enumeratin: Record<string, any>, value: any): boolean {
return !!Object.values(enumeratin).find(enumVal => enumVal === value);
}
export function getAlertType(): AlertTypeModel {
return {
id: 'example.people-in-space',
name: 'People Are In Space Right Now',
iconClass: 'globe',
alertParamsExpression: PeopleinSpaceExpression,
validate: (alertParams: PeopleinSpaceParamsProps['alertParams']) => {
const { outerSpaceCapacity, craft, op } = alertParams;
const validationResult = {
errors: {
outerSpaceCapacity: new Array<string>(),
craft: new Array<string>(),
},
};
if (!isValueInEnum(Craft, craft)) {
validationResult.errors.craft.push(
i18n.translate('AlertingExample.addAlert.error.invalidCraft', {
defaultMessage: 'You must choose one of the following Craft: {crafts}',
values: {
crafts: Object.values(Craft).join(', '),
},
})
);
}
if (!(typeof outerSpaceCapacity === 'number' && outerSpaceCapacity >= 0)) {
validationResult.errors.outerSpaceCapacity.push(
i18n.translate('AlertingExample.addAlert.error.invalidOuterSpaceCapacity', {
defaultMessage: 'outerSpaceCapacity must be a number greater than or equal to zero.',
})
);
}
if (!isValueInEnum(Operator, op)) {
validationResult.errors.outerSpaceCapacity.push(
i18n.translate('AlertingExample.addAlert.error.invalidCraft', {
defaultMessage: 'You must choose one of the following Operator: {crafts}',
values: {
crafts: Object.values(Operator).join(', '),
},
})
);
}
return validationResult;
},
};
}
export const PeopleinSpaceExpression: React.FunctionComponent<PeopleinSpaceParamsProps> = ({
alertParams,
setAlertParams,
errors,
}) => {
const { outerSpaceCapacity = 0, craft = Craft.OuterSpace, op = Operator.AreAbove } = alertParams;
// store defaults
useEffect(() => {
if (outerSpaceCapacity !== alertParams.outerSpaceCapacity) {
setAlertParams('outerSpaceCapacity', outerSpaceCapacity);
}
if (craft !== alertParams.craft) {
setAlertParams('craft', craft);
}
if (op !== alertParams.op) {
setAlertParams('op', op);
}
}, [alertParams, craft, op, outerSpaceCapacity, setAlertParams]);
const [craftTrigger, setCraftTrigger] = useState<{ craft: string; isOpen: boolean }>({
craft,
isOpen: false,
});
const [outerSpaceCapacityTrigger, setOuterSpaceCapacity] = useState<{
outerSpaceCapacity: number;
op: string;
isOpen: boolean;
}>({
outerSpaceCapacity,
op,
isOpen: false,
});
const errorsCallout = flatten(
Object.entries(errors).map(([field, errs]: [string, string[]]) =>
errs.map(e => (
<p>
<EuiTextColor color="accent">{field}:</EuiTextColor>`: ${errs}`
</p>
))
)
);
return (
<Fragment>
{errorsCallout.length ? (
<EuiCallOut title="Sorry, there was an error" color="danger" iconType="alert">
{errorsCallout}
</EuiCallOut>
) : (
<Fragment />
)}
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiPopover
id="craft"
button={
<EuiExpression
description="When the People in"
value={craftTrigger.craft}
isActive={craftTrigger.isOpen}
onClick={() => {
setCraftTrigger({
...craftTrigger,
isOpen: true,
});
}}
/>
}
isOpen={craftTrigger.isOpen}
closePopover={() => {
setCraftTrigger({
...craftTrigger,
isOpen: false,
});
}}
ownFocus
panelPaddingSize="s"
anchorPosition="downLeft"
>
<div style={{ zIndex: 200 }}>
<EuiPopoverTitle>When the People in</EuiPopoverTitle>
<EuiSelect
compressed
value={craftTrigger.craft}
onChange={event => {
setAlertParams('craft', event.target.value);
setCraftTrigger({
craft: event.target.value,
isOpen: false,
});
}}
options={[
{ value: Craft.OuterSpace, text: 'Outer Space' },
{ value: Craft.ISS, text: 'the International Space Station' },
]}
/>
</div>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
id="outerSpaceCapacity"
button={
<EuiExpression
description={outerSpaceCapacityTrigger.op}
value={outerSpaceCapacityTrigger.outerSpaceCapacity}
isActive={outerSpaceCapacityTrigger.isOpen}
onClick={() => {
setOuterSpaceCapacity({
...outerSpaceCapacityTrigger,
isOpen: true,
});
}}
/>
}
isOpen={outerSpaceCapacityTrigger.isOpen}
closePopover={() => {
setOuterSpaceCapacity({
...outerSpaceCapacityTrigger,
isOpen: false,
});
}}
ownFocus
panelPaddingSize="s"
anchorPosition="downLeft"
>
<div style={{ zIndex: 200 }}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false} style={{ width: 150 }}>
<EuiSelect
compressed
value={outerSpaceCapacityTrigger.op}
onChange={event => {
setAlertParams('op', event.target.value);
setOuterSpaceCapacity({
...outerSpaceCapacityTrigger,
op: event.target.value,
isOpen: false,
});
}}
options={[
{ value: Operator.AreAbove, text: 'Are above' },
{ value: Operator.AreBelow, text: 'Are below' },
{ value: Operator.AreExactly, text: 'Are exactly' },
]}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: 100 }}>
<EuiFieldNumber
compressed
value={outerSpaceCapacityTrigger.outerSpaceCapacity}
onChange={event => {
setAlertParams('outerSpaceCapacity', event.target.valueAsNumber);
setOuterSpaceCapacity({
...outerSpaceCapacityTrigger,
outerSpaceCapacity: event.target.valueAsNumber,
isOpen: false,
});
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
};

View file

@ -0,0 +1,33 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { registerNavigation as registerPeopleInSpaceNavigation } from './astros';
import { ALERTING_EXAMPLE_APP_ID } from '../../common/constants';
import { SanitizedAlert } from '../../../../x-pack/plugins/alerting/common';
import { PluginSetupContract as AlertingSetup } from '../../../../x-pack/plugins/alerting/public';
export function registerNavigation(alerting: AlertingSetup) {
// register default navigation
alerting.registerDefaultNavigation(
ALERTING_EXAMPLE_APP_ID,
(alert: SanitizedAlert) => `/alert/${alert.id}`
);
registerPeopleInSpaceNavigation(alerting);
}

View file

@ -0,0 +1,108 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route, RouteComponentProps } from 'react-router-dom';
import { EuiPage } from '@elastic/eui';
import {
AppMountParameters,
CoreStart,
IUiSettingsClient,
ToastsSetup,
} from '../../../src/core/public';
import { DataPublicPluginStart } from '../../../src/plugins/data/public';
import { ChartsPluginStart } from '../../../src/plugins/charts/public';
import { Page } from './components/page';
import { DocumentationPage } from './components/documentation';
import { ViewAlertPage } from './components/view_alert';
import { TriggersAndActionsUIPublicPluginStart } from '../../../x-pack/plugins/triggers_actions_ui/public';
import { AlertingExamplePublicStartDeps } from './plugin';
import { ViewPeopleInSpaceAlertPage } from './components/view_astros_alert';
export interface AlertingExampleComponentParams {
application: CoreStart['application'];
http: CoreStart['http'];
basename: string;
triggers_actions_ui: TriggersAndActionsUIPublicPluginStart;
data: DataPublicPluginStart;
charts: ChartsPluginStart;
uiSettings: IUiSettingsClient;
toastNotifications: ToastsSetup;
}
const AlertingExampleApp = (deps: AlertingExampleComponentParams) => {
const { basename, http } = deps;
return (
<Router basename={basename}>
<EuiPage>
<Route
path={`/`}
exact={true}
render={() => (
<Page title={`Home`} isHome={true}>
<DocumentationPage {...deps} />
</Page>
)}
/>
<Route
path={`/alert/:id`}
render={(props: RouteComponentProps<{ id: string }>) => {
return (
<Page title={`View Alert`} crumb={`View Alert ${props.match.params.id}`}>
<ViewAlertPage http={http} id={props.match.params.id} />
</Page>
);
}}
/>
<Route
path={`/astros/:id`}
render={(props: RouteComponentProps<{ id: string }>) => {
return (
<Page title={`View People In Space Alert`} crumb={`Astros ${props.match.params.id}`}>
<ViewPeopleInSpaceAlertPage http={http} id={props.match.params.id} />
</Page>
);
}}
/>
</EuiPage>
</Router>
);
};
export const renderApp = (
{ application, notifications, http, uiSettings }: CoreStart,
deps: AlertingExamplePublicStartDeps,
{ appBasePath, element }: AppMountParameters
) => {
ReactDOM.render(
<AlertingExampleApp
basename={appBasePath}
application={application}
toastNotifications={notifications.toasts}
http={http}
uiSettings={uiSettings}
{...deps}
/>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -0,0 +1,72 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState } from 'react';
import { EuiIcon, EuiFlexItem, EuiCard, EuiFlexGroup } from '@elastic/eui';
import {
AlertsContextProvider,
AlertAdd,
} from '../../../../x-pack/plugins/triggers_actions_ui/public';
import { AlertingExampleComponentParams } from '../application';
import { ALERTING_EXAMPLE_APP_ID } from '../../common/constants';
export const CreateAlert = ({
http,
triggers_actions_ui,
charts,
uiSettings,
data,
toastNotifications,
}: AlertingExampleComponentParams) => {
const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState<boolean>(false);
return (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiCard
icon={<EuiIcon size="xxl" type={`bell`} />}
title={`Create Alert`}
description="Create an new Alert based on one of our example Alert Types ."
onClick={() => setAlertFlyoutVisibility(true)}
/>
</EuiFlexItem>
<EuiFlexItem>
<AlertsContextProvider
value={{
http,
actionTypeRegistry: triggers_actions_ui.actionTypeRegistry,
alertTypeRegistry: triggers_actions_ui.alertTypeRegistry,
toastNotifications,
uiSettings,
charts,
dataFieldsFormats: data.fieldFormats,
}}
>
<AlertAdd
consumer={ALERTING_EXAMPLE_APP_ID}
addFlyoutVisible={alertFlyoutVisible}
setAddFlyoutVisibility={setAlertFlyoutVisibility}
/>
</AlertsContextProvider>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,67 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import {
EuiText,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiPageHeader,
EuiPageHeaderSection,
EuiTitle,
EuiSpacer,
} from '@elastic/eui';
import { CreateAlert } from './create_alert';
import { AlertingExampleComponentParams } from '../application';
export const DocumentationPage = (deps: AlertingExampleComponentParams) => (
<EuiPageBody>
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>Welcome to the Alerting plugin example</h1>
</EuiTitle>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle>
<h2>Documentation links</h2>
</EuiTitle>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>
<EuiText>
<h2>Plugin Structure</h2>
<p>
This example solution has both `server` and a `public` plugins. The `server` handles
registration of example the AlertTypes, while the `public` handles creation of, and
navigation for, these alert types.
</p>
</EuiText>
<EuiSpacer />
<CreateAlert {...deps} />
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
);

View file

@ -0,0 +1,74 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import {
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiPageHeaderSection,
EuiTitle,
EuiBreadcrumbs,
EuiSpacer,
} from '@elastic/eui';
type PageProps = RouteComponentProps & {
title: string;
children: React.ReactNode;
crumb?: string;
isHome?: boolean;
};
export const Page = withRouter(({ title, crumb, children, isHome = false, history }: PageProps) => {
const breadcrumbs: Array<{
text: string;
onClick?: () => void;
}> = [
{
text: crumb ?? title,
},
];
if (!isHome) {
breadcrumbs.splice(0, 0, {
text: 'Home',
onClick: () => {
history.push(`/`);
},
});
}
return (
<EuiPageBody data-test-subj="searchTestPage">
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle size="l">
<h1>{title}</h1>
</EuiTitle>
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiBreadcrumbs responsive={false} breadcrumbs={breadcrumbs} />
<EuiSpacer />
<EuiPageContent>
<EuiPageContentBody>{children}</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
);
});

View file

@ -0,0 +1,116 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState, useEffect, Fragment } from 'react';
import {
EuiText,
EuiLoadingKibana,
EuiCallOut,
EuiTextColor,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiCodeBlock,
EuiSpacer,
} from '@elastic/eui';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { CoreStart } from 'kibana/public';
import { isEmpty } from 'lodash';
import { Alert, AlertTaskState } from '../../../../x-pack/plugins/alerting/common';
import { ALERTING_EXAMPLE_APP_ID } from '../../common/constants';
type Props = RouteComponentProps & {
http: CoreStart['http'];
id: string;
};
export const ViewAlertPage = withRouter(({ http, id }: Props) => {
const [alert, setAlert] = useState<Alert | null>(null);
const [alertState, setAlertState] = useState<AlertTaskState | null>(null);
useEffect(() => {
if (!alert) {
http.get(`/api/alert/${id}`).then(setAlert);
}
if (!alertState) {
http.get(`/api/alert/${id}/state`).then(setAlertState);
}
}, [alert, alertState, http, id]);
return alert && alertState ? (
<Fragment>
<EuiCallOut title={`Alert "${alert.name}"`} iconType="search">
<p>
This is a generic view for all Alerts created by the
<EuiTextColor color="accent"> {ALERTING_EXAMPLE_APP_ID} </EuiTextColor>
plugin.
</p>
<p>
You are now viewing the <EuiTextColor color="accent">{`${alert.name}`} </EuiTextColor>
Alert, whose ID is <EuiTextColor color="accent">{`${alert.id}`}</EuiTextColor>.
</p>
<p>
Its AlertType is <EuiTextColor color="accent">{`${alert.alertTypeId}`}</EuiTextColor> and
its scheduled to run at an interval of
<EuiTextColor color="accent"> {`${alert.schedule.interval}`}</EuiTextColor>.
</p>
</EuiCallOut>
<EuiSpacer size="l" />
<EuiText>
<h2>Alert Instances</h2>
</EuiText>
{isEmpty(alertState.alertInstances) ? (
<EuiCallOut title="No Alert Instances!" color="warning" iconType="help">
<p>This Alert doesn&apos;t have any active alert instances at the moment.</p>
</EuiCallOut>
) : (
<Fragment>
<EuiCallOut title="Active State" color="success" iconType="user">
<p>
Bellow are the active Alert Instances which were activated on the alerts last run.
<br />
For each instance id you can see its current state in JSON format.
</p>
</EuiCallOut>
<EuiSpacer size="l" />
<EuiDescriptionList compressed>
{Object.entries(alertState.alertInstances ?? {}).map(([instance, { state }]) => (
<Fragment>
<EuiDescriptionListTitle>{instance}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<EuiCodeBlock
language="json"
fontSize="m"
paddingSize="m"
color="dark"
overflowHeight={300}
isCopyable
>
{`${JSON.stringify(state)}`}
</EuiCodeBlock>
</EuiDescriptionListDescription>
</Fragment>
))}
</EuiDescriptionList>
</Fragment>
)}
</Fragment>
) : (
<EuiLoadingKibana size="xl" />
);
});

View file

@ -0,0 +1,123 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState, useEffect, Fragment } from 'react';
import {
EuiText,
EuiLoadingKibana,
EuiCallOut,
EuiTextColor,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiStat,
} from '@elastic/eui';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { CoreStart } from 'kibana/public';
import { isEmpty } from 'lodash';
import { Alert, AlertTaskState } from '../../../../x-pack/plugins/alerting/common';
import { ALERTING_EXAMPLE_APP_ID } from '../../common/constants';
type Props = RouteComponentProps & {
http: CoreStart['http'];
id: string;
};
function hasCraft(state: any): state is { craft: string } {
return state && state.craft;
}
export const ViewPeopleInSpaceAlertPage = withRouter(({ http, id }: Props) => {
const [alert, setAlert] = useState<Alert | null>(null);
const [alertState, setAlertState] = useState<AlertTaskState | null>(null);
useEffect(() => {
if (!alert) {
http.get(`/api/alert/${id}`).then(setAlert);
}
if (!alertState) {
http.get(`/api/alert/${id}/state`).then(setAlertState);
}
}, [alert, alertState, http, id]);
return alert && alertState ? (
<Fragment>
<EuiCallOut title={`Alert "${alert.name}"`} iconType="search">
<p>
This is a specific view for all
<EuiTextColor color="accent"> example.people-in-space </EuiTextColor> Alerts created by
the
<EuiTextColor color="accent"> {ALERTING_EXAMPLE_APP_ID} </EuiTextColor>
plugin.
</p>
</EuiCallOut>
<EuiSpacer size="l" />
<EuiText>
<h2>Alert Instances</h2>
</EuiText>
{isEmpty(alertState.alertInstances) ? (
<EuiCallOut title="No Alert Instances!" color="warning" iconType="help">
<p>
The people in {alert.params.craft} at the moment <b>are not</b> {alert.params.op}{' '}
{alert.params.outerSpaceCapacity}
</p>
</EuiCallOut>
) : (
<Fragment>
<EuiCallOut title="Active State" color="success" iconType="user">
<p>
The alert has been triggered because the people in {alert.params.craft} at the moment{' '}
{alert.params.op} {alert.params.outerSpaceCapacity}
</p>
</EuiCallOut>
<EuiSpacer size="l" />
<div>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiStat
title={Object.keys(alertState.alertInstances ?? {}).length}
description={`People in ${alert.params.craft}`}
titleColor="primary"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionList compressed>
{Object.entries(alertState.alertInstances ?? {}).map(
([instance, { state }], index) => (
<Fragment key={index}>
<EuiDescriptionListTitle>{instance}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{hasCraft(state) ? state.craft : 'Unknown Craft'}
</EuiDescriptionListDescription>
</Fragment>
)
)}
</EuiDescriptionList>
</EuiFlexItem>
</EuiFlexGroup>
</div>
</Fragment>
)}
</Fragment>
) : (
<EuiLoadingKibana size="xl" />
);
});

View file

@ -0,0 +1,22 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { AlertingExamplePlugin } from './plugin';
export const plugin = () => new AlertingExamplePlugin();

View file

@ -0,0 +1,70 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Plugin, CoreSetup, AppMountParameters, CoreStart } from 'kibana/public';
import { PluginSetupContract as AlertingSetup } from '../../../x-pack/plugins/alerting/public';
import { ChartsPluginStart } from '../../../src/plugins/charts/public';
import { TriggersAndActionsUIPublicPluginSetup } from '../../../x-pack/plugins/triggers_actions_ui/public';
import { DataPublicPluginStart } from '../../../src/plugins/data/public';
import { getAlertType as getAlwaysFiringAlertType } from './alert_types/always_firing';
import { getAlertType as getPeopleInSpaceAlertType } from './alert_types/astros';
import { registerNavigation } from './alert_types';
export type Setup = void;
export type Start = void;
export interface AlertingExamplePublicSetupDeps {
alerting: AlertingSetup;
triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup;
}
export interface AlertingExamplePublicStartDeps {
alerting: AlertingSetup;
triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup;
charts: ChartsPluginStart;
data: DataPublicPluginStart;
}
export class AlertingExamplePlugin implements Plugin<Setup, Start, AlertingExamplePublicSetupDeps> {
public setup(
core: CoreSetup<AlertingExamplePublicStartDeps>,
{ alerting, triggers_actions_ui }: AlertingExamplePublicSetupDeps
) {
core.application.register({
id: 'AlertingExample',
title: 'Alerting Example',
async mount(params: AppMountParameters) {
const [coreStart, depsStart]: [
CoreStart,
AlertingExamplePublicStartDeps
] = await core.getStartServices();
const { renderApp } = await import('./application');
return renderApp(coreStart, depsStart, params);
},
});
triggers_actions_ui.alertTypeRegistry.register(getAlwaysFiringAlertType());
triggers_actions_ui.alertTypeRegistry.register(getPeopleInSpaceAlertType());
registerNavigation(alerting);
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,46 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import uuid from 'uuid';
import { range } from 'lodash';
import { AlertType } from '../../../../x-pack/plugins/alerting/server';
import { DEFAULT_INSTANCES_TO_GENERATE } from '../../common/constants';
export const alertType: AlertType = {
id: 'example.always-firing',
name: 'Always firing',
actionGroups: [{ id: 'default', name: 'default' }],
defaultActionGroupId: 'default',
async executor({ services, params: { instances = DEFAULT_INSTANCES_TO_GENERATE }, state }) {
const count = (state.count ?? 0) + 1;
range(instances)
.map(() => ({ id: uuid.v4() }))
.forEach((instance: { id: string }) => {
services
.alertInstanceFactory(instance.id)
.replaceState({ triggerdOnCycle: count })
.scheduleActions('default');
});
return {
count,
};
},
};

View file

@ -0,0 +1,82 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import axios from 'axios';
import { AlertType } from '../../../../x-pack/plugins/alerting/server';
import { Operator, Craft } from '../../common/constants';
interface PeopleInSpace {
people: Array<{
craft: string;
name: string;
}>;
number: number;
}
function getOperator(op: string) {
switch (op) {
case Operator.AreAbove:
return (left: number, right: number) => left > right;
case Operator.AreBelow:
return (left: number, right: number) => left < right;
case Operator.AreExactly:
return (left: number, right: number) => left === right;
default:
return () => {
throw new Error(
`Invalid Operator "${op}" [${Operator.AreAbove},${Operator.AreBelow},${Operator.AreExactly}]`
);
};
}
}
function getCraftFilter(craft: string) {
return (person: { craft: string; name: string }) =>
craft === Craft.OuterSpace ? true : craft === person.craft;
}
export const alertType: AlertType = {
id: 'example.people-in-space',
name: 'People In Space Right Now',
actionGroups: [{ id: 'default', name: 'default' }],
defaultActionGroupId: 'default',
async executor({ services, params }) {
const { outerSpaceCapacity, craft: craftToTriggerBy, op } = params;
const response = await axios.get<PeopleInSpace>('http://api.open-notify.org/astros.json');
const {
data: { number: peopleInSpace, people = [] },
} = response;
const peopleInCraft = people.filter(getCraftFilter(craftToTriggerBy));
if (getOperator(op)(peopleInCraft.length, outerSpaceCapacity)) {
peopleInCraft.forEach(({ craft, name }) => {
services
.alertInstanceFactory(name)
.replaceState({ craft })
.scheduleActions('default');
});
}
return {
peopleInSpace,
};
},
};

View file

@ -0,0 +1,23 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { PluginInitializer } from 'kibana/server';
import { AlertingExamplePlugin } from './plugin';
export const plugin: PluginInitializer<void, void> = () => new AlertingExamplePlugin();

View file

@ -0,0 +1,39 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Plugin, CoreSetup } from 'kibana/server';
import { PluginSetupContract as AlertingSetup } from '../../../x-pack/plugins/alerting/server';
import { alertType as alwaysFiringAlert } from './alert_types/always_firing';
import { alertType as peopleInSpaceAlert } from './alert_types/astros';
// this plugin's dependendencies
export interface AlertingExampleDeps {
alerting: AlertingSetup;
}
export class AlertingExamplePlugin implements Plugin<void, void, AlertingExampleDeps> {
public setup(core: CoreSetup, { alerting }: AlertingExampleDeps) {
alerting.registerType(alwaysFiringAlert);
alerting.registerType(peopleInSpaceAlert);
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./target",
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"server/**/*.ts",
"common/**/*.ts",
"../../typings/**/*",
],
"exclude": []
}

View file

@ -101,7 +101,8 @@
"x-pack/legacy/plugins/*",
"examples/*",
"test/plugin_functional/plugins/*",
"test/interpreter_functional/plugins/*"
"test/interpreter_functional/plugins/*",
"x-pack/test/functional_with_es_ssl/fixtures/plugins/*"
],
"nohoist": [
"**/@types/*",

View file

@ -56996,6 +56996,7 @@ function getProjectPaths({
projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack'));
projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack/plugins/*'));
projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack/legacy/plugins/*'));
projectPaths.push(Object(path__WEBPACK_IMPORTED_MODULE_0__["resolve"])(rootPath, 'x-pack/test/functional_with_es_ssl/fixtures/plugins/*'));
}
if (!skipKibanaPlugins) {

View file

@ -48,6 +48,7 @@ export function getProjectPaths({ rootPath, ossOnly, skipKibanaPlugins }: Option
projectPaths.push(resolve(rootPath, 'x-pack'));
projectPaths.push(resolve(rootPath, 'x-pack/plugins/*'));
projectPaths.push(resolve(rootPath, 'x-pack/legacy/plugins/*'));
projectPaths.push(resolve(rootPath, 'x-pack/test/functional_with_es_ssl/fixtures/plugins/*'));
}
if (!skipKibanaPlugins) {

View file

@ -6,6 +6,7 @@ source src/dev/ci_setup/setup_env.sh
echo " -> building kibana platform plugins"
node scripts/build_kibana_platform_plugins \
--scan-dir "$XPACK_DIR/test/plugin_functional/plugins" \
--scan-dir "$XPACK_DIR/test/functional_with_es_ssl/fixtures/plugins" \
--verbose;
# doesn't persist, also set in kibanaPipeline.groovy

View file

@ -5,3 +5,5 @@
*/
export * from './types';
export const BASE_ACTION_API_PATH = '/api/action';

View file

@ -18,6 +18,7 @@ Table of Contents
- [Methods](#methods)
- [Executor](#executor)
- [Example](#example)
- [Alert Navigation](#alert-navigation)
- [RESTful API](#restful-api)
- [`POST /api/alert`: Create alert](#post-apialert-create-alert)
- [`DELETE /api/alert/{id}`: Delete alert](#delete-apialertid-delete-alert)
@ -268,6 +269,61 @@ server.newPlatform.setup.plugins.alerting.registerType({
});
```
## Alert Navigation
When registering an Alert Type, you'll likely want to provide a way of viewing alerts of that type within your own plugin, or perhaps you want to provide a view for all alerts created from within your solution within your own UI.
In order for the Alerting framework to know that your plugin has its own internal view for displaying an alert, you must resigter a navigation handler within the framework.
A navigation handler is nothing more than a function that receives an Alert and its corresponding AlertType, and is expected to then return the path *within your plugin* which knows how to display this alert.
The signature of such a handler is:
```
type AlertNavigationHandler = (
alert: SanitizedAlert,
alertType: AlertType
) => string;
```
There are two ways to register this handler.
By specifying _alerting_ as a dependency of your *public* (client side) plugin, you'll gain access to two apis: _alerting.registerNavigation_ and _alerting.registerDefaultNavigation_.
### registerNavigation
The _registerNavigation_ api allows you to register a handler for a specific alert type within your solution:
```
alerting.registerNavigation(
'my-application-id',
'my-application-id.my-alert-type',
(alert: SanitizedAlert, alertType: AlertType) => `/my-unique-alert/${alert.id}`
);
```
This tells the Alerting framework that, given an alert of the AlertType whose ID is `my-application-id.my-unique-alert-type`, if that Alert's `consumer` value (which is set when the alert is created by your plugin) is your application (whose id is `my-application-id`), then it will navigate to your application using the path `/my-unique-alert/${the id of the alert}`.
The navigation is handled using the `navigateToApp` api, meaning that the path will be automatically picked up by your `react-router-dom` **Route** component, so all you have top do is configure a Route that handles the path `/my-unique-alert/:id`.
You can look at the `alerting-example` plugin to see an example of using this API, which is enabled using the `--run-examples` flag when you run `yarn start`.
### registerDefaultNavigation
The _registerDefaultNavigation_ api allows you to register a handler for any alert type within your solution:
```
alerting.registerDefaultNavigation(
'my-application-id',
(alert: SanitizedAlert, alertType: AlertType) => `/my-other-alerts/${alert.id}`
);
```
This tells the Alerting framework that, given any alert whose `consumer` value is your application, as long as then it will navigate to your application using the path `/my-other-alerts/${the id of the alert}`.
### balancing both APIs side by side
As we mentioned, using `registerDefaultNavigation` will tell the Alerting Framework that your application can handle any type of Alert we throw at it, as long as your application created it, using the handler you provide it.
The only case in which this handler will not be used to evaluate the navigation for an alert (assuming your application is the `consumer`) is if you have also used `registerNavigation` api, along side your `registerDefaultNavigation` usage, to handle that alert's specific AlertType.
You can use the `registerNavigation` api to specify as many AlertType specific handlers as you like, but you can only use it once per AlertType as we wouldn't know which handler to use if you specified two for the same AlertType. For the same reason, you can only use `registerDefaultNavigation` once per plugin, as it covers all cases for your specific plugin.
## RESTful API
Using an alert type requires you to create an alert that will contain parameters and actions for a given alert type. See below for CRUD operations using the API.
@ -480,4 +536,3 @@ The templating system will take the alert and alert type as described above and
```
There are limitations that we are aware of using only templates, and we are gathering feedback and use cases for these. (for example passing an array of strings to an action).

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { JsonObject } from '../../infra/common/typed_json';
export interface AlertUrlNavigation {
path: string;
}
export interface AlertStateNavigation {
state: JsonObject;
}
export type AlertNavigation = AlertUrlNavigation | AlertStateNavigation;

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface AlertType {
id: string;
name: string;
actionGroups: ActionGroup[];
actionVariables: string[];
defaultActionGroupId: ActionGroup['id'];
}
export interface ActionGroup {
id: string;
name: string;
}

View file

@ -5,10 +5,9 @@
*/
export * from './alert';
export * from './alert_type';
export * from './alert_instance';
export * from './alert_task_instance';
export * from './alert_navigation';
export interface ActionGroup {
id: string;
name: string;
}
export const BASE_ALERT_API_PATH = '/api/alert';

View file

@ -1,10 +1,10 @@
{
"id": "alerting",
"server": true,
"ui": true,
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "alerting"],
"requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "actions"],
"optionalPlugins": ["usageCollection", "spaces", "security"],
"ui": false
}
"optionalPlugins": ["usageCollection", "spaces", "security"]
}

View file

@ -0,0 +1,176 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertType } from '../common';
import { httpServiceMock } from '../../../../src/core/public/mocks';
import { loadAlert, loadAlertState, loadAlertType, loadAlertTypes } from './alert_api';
import uuid from 'uuid';
const http = httpServiceMock.createStartContract();
beforeEach(() => jest.resetAllMocks());
describe('loadAlertTypes', () => {
test('should call get alert types API', async () => {
const resolvedValue: AlertType[] = [
{
id: 'test',
name: 'Test',
actionVariables: ['var1'],
actionGroups: [{ id: 'default', name: 'Default' }],
defaultActionGroupId: 'default',
},
];
http.get.mockResolvedValueOnce(resolvedValue);
const result = await loadAlertTypes({ http });
expect(result).toEqual(resolvedValue);
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alert/types",
]
`);
});
});
describe('loadAlertType', () => {
test('should call get alert types API', async () => {
const alertType: AlertType = {
id: 'test',
name: 'Test',
actionVariables: ['var1'],
actionGroups: [{ id: 'default', name: 'Default' }],
defaultActionGroupId: 'default',
};
http.get.mockResolvedValueOnce([alertType]);
await loadAlertType({ http, id: alertType.id });
expect(http.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"/api/alert/types",
]
`);
});
test('should find the required alertType', async () => {
const alertType: AlertType = {
id: 'test-another',
name: 'Test Another',
actionVariables: [],
actionGroups: [{ id: 'default', name: 'Default' }],
defaultActionGroupId: 'default',
};
http.get.mockResolvedValueOnce([alertType]);
expect(await loadAlertType({ http, id: 'test-another' })).toEqual(alertType);
});
test('should throw if required alertType is missing', async () => {
http.get.mockResolvedValueOnce([
{
id: 'test-another',
name: 'Test Another',
actionVariables: [],
actionGroups: [{ id: 'default', name: 'Default' }],
defaultActionGroupId: 'default',
},
]);
expect(loadAlertType({ http, id: 'test' })).rejects.toMatchInlineSnapshot(
`[Error: Alert type "test" is not registered.]`
);
});
});
describe('loadAlert', () => {
test('should call get API with base parameters', async () => {
const alertId = uuid.v4();
const resolvedValue = {
id: alertId,
name: 'name',
tags: [],
enabled: true,
alertTypeId: '.noop',
schedule: { interval: '1s' },
actions: [],
params: {},
createdBy: null,
updatedBy: null,
throttle: null,
muteAll: false,
mutedInstanceIds: [],
};
http.get.mockResolvedValueOnce(resolvedValue);
expect(await loadAlert({ http, alertId })).toEqual(resolvedValue);
expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}`);
});
});
describe('loadAlertState', () => {
test('should call get API with base parameters', async () => {
const alertId = uuid.v4();
const resolvedValue = {
alertTypeState: {
some: 'value',
},
alertInstances: {
first_instance: {},
second_instance: {},
},
};
http.get.mockResolvedValueOnce(resolvedValue);
expect(await loadAlertState({ http, alertId })).toEqual(resolvedValue);
expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}/state`);
});
test('should parse AlertInstances', async () => {
const alertId = uuid.v4();
const resolvedValue = {
alertTypeState: {
some: 'value',
},
alertInstances: {
first_instance: {
state: {},
meta: {
lastScheduledActions: {
group: 'first_group',
date: '2020-02-09T23:15:41.941Z',
},
},
},
},
};
http.get.mockResolvedValueOnce(resolvedValue);
expect(await loadAlertState({ http, alertId })).toEqual({
...resolvedValue,
alertInstances: {
first_instance: {
state: {},
meta: {
lastScheduledActions: {
group: 'first_group',
date: new Date('2020-02-09T23:15:41.941Z'),
},
},
},
},
});
expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}/state`);
});
test('should handle empty response from api', async () => {
const alertId = uuid.v4();
http.get.mockResolvedValueOnce('');
expect(await loadAlertState({ http, alertId })).toEqual({});
expect(http.get).toHaveBeenCalledWith(`/api/alert/${alertId}/state`);
});
});

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { HttpSetup } from 'kibana/public';
import * as t from 'io-ts';
import { pipe } from 'fp-ts/lib/pipeable';
import { fold } from 'fp-ts/lib/Either';
import { findFirst } from 'fp-ts/lib/Array';
import { isNone } from 'fp-ts/lib/Option';
import { i18n } from '@kbn/i18n';
import { BASE_ALERT_API_PATH, alertStateSchema } from '../common';
import { Alert, AlertType, AlertTaskState } from '../common';
export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise<AlertType[]> {
return await http.get(`${BASE_ALERT_API_PATH}/types`);
}
export async function loadAlertType({
http,
id,
}: {
http: HttpSetup;
id: AlertType['id'];
}): Promise<AlertType> {
const maybeAlertType = findFirst<AlertType>(type => type.id === id)(
await http.get(`${BASE_ALERT_API_PATH}/types`)
);
if (isNone(maybeAlertType)) {
throw new Error(
i18n.translate('xpack.alerting.loadAlertType.missingAlertTypeError', {
defaultMessage: 'Alert type "{id}" is not registered.',
values: {
id,
},
})
);
}
return maybeAlertType.value;
}
export async function loadAlert({
http,
alertId,
}: {
http: HttpSetup;
alertId: string;
}): Promise<Alert> {
return await http.get(`${BASE_ALERT_API_PATH}/${alertId}`);
}
type EmptyHttpResponse = '';
export async function loadAlertState({
http,
alertId,
}: {
http: HttpSetup;
alertId: string;
}): Promise<AlertTaskState> {
return await http
.get(`${BASE_ALERT_API_PATH}/${alertId}/state`)
.then((state: AlertTaskState | EmptyHttpResponse) => (state ? state : {}))
.then((state: AlertTaskState) => {
return pipe(
alertStateSchema.decode(state),
fold((e: t.Errors) => {
throw new Error(`Alert "${alertId}" has invalid state`);
}, t.identity)
);
});
}

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertNavigationRegistry } from './alert_navigation_registry';
type Schema = PublicMethodsOf<AlertNavigationRegistry>;
const createAlertNavigationRegistryMock = () => {
const mocked: jest.Mocked<Schema> = {
has: jest.fn(),
hasDefaultHandler: jest.fn(),
hasTypedHandler: jest.fn(),
register: jest.fn(),
registerDefault: jest.fn(),
get: jest.fn(),
};
return mocked;
};
export const alertNavigationRegistryMock = {
create: createAlertNavigationRegistryMock,
};

View file

@ -0,0 +1,184 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertNavigationRegistry } from './alert_navigation_registry';
import { AlertType, SanitizedAlert } from '../../common';
import uuid from 'uuid';
beforeEach(() => jest.resetAllMocks());
const mockAlertType = (id: string): AlertType => ({
id,
name: id,
actionGroups: [],
actionVariables: [],
defaultActionGroupId: 'default',
});
describe('AlertNavigationRegistry', () => {
function handler(alert: SanitizedAlert, alertType: AlertType) {
return {};
}
describe('has()', () => {
test('returns false for unregistered consumer handlers', () => {
const registry = new AlertNavigationRegistry();
expect(registry.has('siem', mockAlertType(uuid.v4()))).toEqual(false);
});
test('returns false for unregistered alert types handlers', () => {
const registry = new AlertNavigationRegistry();
expect(registry.has('siem', mockAlertType('index_threshold'))).toEqual(false);
});
test('returns true for registered consumer & alert types handlers', () => {
const registry = new AlertNavigationRegistry();
const alertType = mockAlertType('index_threshold');
registry.register('siem', alertType, handler);
expect(registry.has('siem', alertType)).toEqual(true);
});
test('returns true for registered consumer with default handler', () => {
const registry = new AlertNavigationRegistry();
const alertType = mockAlertType('index_threshold');
registry.registerDefault('siem', handler);
expect(registry.has('siem', alertType)).toEqual(true);
});
});
describe('hasDefaultHandler()', () => {
test('returns false for unregistered consumer handlers', () => {
const registry = new AlertNavigationRegistry();
expect(registry.hasDefaultHandler('siem')).toEqual(false);
});
test('returns true for registered consumer handlers', () => {
const registry = new AlertNavigationRegistry();
registry.registerDefault('siem', handler);
expect(registry.hasDefaultHandler('siem')).toEqual(true);
});
});
describe('register()', () => {
test('registers a handler by consumer & Alert Type', () => {
const registry = new AlertNavigationRegistry();
const alertType = mockAlertType('index_threshold');
registry.register('siem', alertType, handler);
expect(registry.has('siem', alertType)).toEqual(true);
});
test('allows registeration of multiple handlers for the same consumer', () => {
const registry = new AlertNavigationRegistry();
const indexThresholdAlertType = mockAlertType('index_threshold');
registry.register('siem', indexThresholdAlertType, handler);
expect(registry.has('siem', indexThresholdAlertType)).toEqual(true);
const geoAlertType = mockAlertType('geogrid');
registry.register('siem', geoAlertType, handler);
expect(registry.has('siem', geoAlertType)).toEqual(true);
});
test('allows registeration of multiple handlers for the same Alert Type', () => {
const registry = new AlertNavigationRegistry();
const indexThresholdAlertType = mockAlertType('geogrid');
registry.register('siem', indexThresholdAlertType, handler);
expect(registry.has('siem', indexThresholdAlertType)).toEqual(true);
registry.register('apm', indexThresholdAlertType, handler);
expect(registry.has('apm', indexThresholdAlertType)).toEqual(true);
});
test('throws if an existing handler is registered', () => {
const registry = new AlertNavigationRegistry();
const alertType = mockAlertType('index_threshold');
registry.register('siem', alertType, handler);
expect(() => {
registry.register('siem', alertType, handler);
}).toThrowErrorMatchingInlineSnapshot(
`"Navigation for Alert type \\"index_threshold\\" within \\"siem\\" is already registered."`
);
});
});
describe('registerDefault()', () => {
test('registers a handler by consumer', () => {
const registry = new AlertNavigationRegistry();
registry.registerDefault('siem', handler);
expect(registry.hasDefaultHandler('siem')).toEqual(true);
});
test('allows registeration of default and typed handlers for the same consumer', () => {
const registry = new AlertNavigationRegistry();
registry.registerDefault('siem', handler);
expect(registry.hasDefaultHandler('siem')).toEqual(true);
const geoAlertType = mockAlertType('geogrid');
registry.register('siem', geoAlertType, handler);
expect(registry.has('siem', geoAlertType)).toEqual(true);
});
test('throws if an existing handler is registered', () => {
const registry = new AlertNavigationRegistry();
registry.registerDefault('siem', handler);
expect(() => {
registry.registerDefault('siem', handler);
}).toThrowErrorMatchingInlineSnapshot(
`"Default Navigation within \\"siem\\" is already registered."`
);
});
});
describe('get()', () => {
test('returns registered handlers by consumer & Alert Type', () => {
const registry = new AlertNavigationRegistry();
function indexThresholdHandler(alert: SanitizedAlert, alertType: AlertType) {
return {};
}
const indexThresholdAlertType = mockAlertType('indexThreshold');
registry.register('siem', indexThresholdAlertType, indexThresholdHandler);
expect(registry.get('siem', indexThresholdAlertType)).toEqual(indexThresholdHandler);
});
test('returns default handlers by consumer when there is no handler for requested alert type', () => {
const registry = new AlertNavigationRegistry();
function defaultHandler(alert: SanitizedAlert, alertType: AlertType) {
return {};
}
registry.registerDefault('siem', defaultHandler);
expect(registry.get('siem', mockAlertType('geogrid'))).toEqual(defaultHandler);
});
test('returns default handlers by consumer when there are other alert type handler', () => {
const registry = new AlertNavigationRegistry();
registry.register('siem', mockAlertType('indexThreshold'), () => ({}));
function defaultHandler(alert: SanitizedAlert, alertType: AlertType) {
return {};
}
registry.registerDefault('siem', defaultHandler);
expect(registry.get('siem', mockAlertType('geogrid'))).toEqual(defaultHandler);
});
test('throws if a handler isnt registered', () => {
const registry = new AlertNavigationRegistry();
const alertType = mockAlertType('index_threshold');
expect(() => registry.get('siem', alertType)).toThrowErrorMatchingInlineSnapshot(
`"Navigation for Alert type \\"index_threshold\\" within \\"siem\\" is not registered."`
);
});
});
});

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { i18n } from '@kbn/i18n';
import { AlertType } from '../../common';
import { AlertNavigationHandler } from './types';
const DEFAULT_HANDLER = Symbol('*');
export class AlertNavigationRegistry {
private readonly alertNavigations: Map<
string,
Map<string | symbol, AlertNavigationHandler>
> = new Map();
public has(consumer: string, alertType: AlertType) {
return this.hasTypedHandler(consumer, alertType) || this.hasDefaultHandler(consumer);
}
public hasTypedHandler(consumer: string, alertType: AlertType) {
return this.alertNavigations.get(consumer)?.has(alertType.id) ?? false;
}
public hasDefaultHandler(consumer: string) {
return this.alertNavigations.get(consumer)?.has(DEFAULT_HANDLER) ?? false;
}
private createConsumerNavigation(consumer: string) {
const consumerNavigations = new Map<string, AlertNavigationHandler>();
this.alertNavigations.set(consumer, consumerNavigations);
return consumerNavigations;
}
public registerDefault(consumer: string, handler: AlertNavigationHandler) {
if (this.hasDefaultHandler(consumer)) {
throw Boom.badRequest(
i18n.translate('xpack.alerting.alertNavigationRegistry.register.duplicateDefaultError', {
defaultMessage: 'Default Navigation within "{consumer}" is already registered.',
values: {
consumer,
},
})
);
}
const consumerNavigations =
this.alertNavigations.get(consumer) ?? this.createConsumerNavigation(consumer);
consumerNavigations.set(DEFAULT_HANDLER, handler);
}
public register(consumer: string, alertType: AlertType, handler: AlertNavigationHandler) {
if (this.hasTypedHandler(consumer, alertType)) {
throw Boom.badRequest(
i18n.translate('xpack.alerting.alertNavigationRegistry.register.duplicateNavigationError', {
defaultMessage:
'Navigation for Alert type "{alertType}" within "{consumer}" is already registered.',
values: {
alertType: alertType.id,
consumer,
},
})
);
}
const consumerNavigations =
this.alertNavigations.get(consumer) ?? this.createConsumerNavigation(consumer);
consumerNavigations.set(alertType.id, handler);
}
public get(consumer: string, alertType: AlertType): AlertNavigationHandler {
if (this.has(consumer, alertType)) {
const consumerHandlers = this.alertNavigations.get(consumer)!;
return (consumerHandlers.get(alertType.id) ?? consumerHandlers.get(DEFAULT_HANDLER))!;
}
throw Boom.badRequest(
i18n.translate('xpack.alerting.alertNavigationRegistry.get.missingNavigationError', {
defaultMessage:
'Navigation for Alert type "{alertType}" within "{consumer}" is not registered.',
values: {
alertType: alertType.id,
consumer,
},
})
);
}
}

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;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './types';
export * from './alert_navigation_registry';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { JsonObject } from '../../../infra/common/typed_json';
import { AlertType, SanitizedAlert } from '../../common';
export type AlertNavigationHandler = (
alert: SanitizedAlert,
alertType: AlertType
) => JsonObject | string;

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertingPublicPlugin } from './plugin';
export { PluginSetupContract, PluginStartContract } from './plugin';
export function plugin() {
return new AlertingPublicPlugin();
}

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertingPublicPlugin } from './plugin';
export type Setup = jest.Mocked<ReturnType<AlertingPublicPlugin['setup']>>;
export type Start = jest.Mocked<ReturnType<AlertingPublicPlugin['start']>>;
const createSetupContract = (): Setup => ({
registerNavigation: jest.fn(),
registerDefaultNavigation: jest.fn(),
});
const createStartContract = (): Start => ({
getNavigation: jest.fn(),
});
export const alertingPluginMock = {
createSetupContract,
createStartContract,
};

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CoreSetup, Plugin, CoreStart } from 'src/core/public';
import { AlertNavigationRegistry, AlertNavigationHandler } from './alert_navigation_registry';
import { loadAlert, loadAlertType } from './alert_api';
import { Alert, AlertNavigation } from '../common';
export interface PluginSetupContract {
registerNavigation: (
consumer: string,
alertType: string,
handler: AlertNavigationHandler
) => void;
registerDefaultNavigation: (consumer: string, handler: AlertNavigationHandler) => void;
}
export interface PluginStartContract {
getNavigation: (alertId: Alert['id']) => Promise<AlertNavigation | undefined>;
}
export class AlertingPublicPlugin implements Plugin<PluginSetupContract, PluginStartContract> {
private alertNavigationRegistry?: AlertNavigationRegistry;
public setup(core: CoreSetup) {
this.alertNavigationRegistry = new AlertNavigationRegistry();
const registerNavigation = async (
consumer: string,
alertType: string,
handler: AlertNavigationHandler
) =>
this.alertNavigationRegistry!.register(
consumer,
await loadAlertType({ http: core.http, id: alertType }),
handler
);
const registerDefaultNavigation = async (consumer: string, handler: AlertNavigationHandler) =>
this.alertNavigationRegistry!.registerDefault(consumer, handler);
return {
registerNavigation,
registerDefaultNavigation,
};
}
public start(core: CoreStart) {
return {
getNavigation: async (alertId: Alert['id']) => {
const alert = await loadAlert({ http: core.http, alertId });
const alertType = await loadAlertType({ http: core.http, id: alert.alertTypeId });
if (this.alertNavigationRegistry!.has(alert.consumer, alertType)) {
const navigationHandler = this.alertNavigationRegistry!.get(alert.consumer, alertType);
const state = navigationHandler(alert, alertType);
return typeof state === 'string' ? { path: state } : { state };
}
},
};
}
}

View file

@ -137,6 +137,7 @@ export class AlertingPlugin {
taskRunnerFactory: this.taskRunnerFactory,
});
this.alertTypeRegistry = alertTypeRegistry;
this.serverBasePath = core.http.basePath.serverBasePath;
const usageCollection = plugins.usageCollection;

View file

@ -1,8 +1,8 @@
{
"id": "triggers_actions_ui",
"version": "kibana",
"server": false,
"ui": true,
"optionalPlugins": ["alerting", "alertingBuiltins"],
"requiredPlugins": ["management", "charts", "data"]
}
"id": "triggers_actions_ui",
"version": "kibana",
"server": false,
"ui": true,
"optionalPlugins": ["alerting", "alertingBuiltins"],
"requiredPlugins": ["management", "charts", "data"]
}

View file

@ -13,6 +13,7 @@ import {
IUiSettingsClient,
ApplicationStart,
ChromeBreadcrumb,
CoreStart,
} from 'kibana/public';
import { BASE_PATH, Section, routeToAlertDetails } from './constants';
import { TriggersActionsUIHome } from './home';
@ -23,11 +24,14 @@ import { TypeRegistry } from './type_registry';
import { AlertDetailsRouteWithApi as AlertDetailsRoute } from './sections/alert_details/components/alert_details_route';
import { ChartsPluginStart } from '../../../../../src/plugins/charts/public';
import { DataPublicPluginStart } from '../../../../../src/plugins/data/public';
import { PluginStartContract as AlertingStart } from '../../../alerting/public';
export interface AppDeps {
dataPlugin: DataPublicPluginStart;
charts: ChartsPluginStart;
chrome: ChromeStart;
alerting?: AlertingStart;
navigateToApp: CoreStart['application']['navigateToApp'];
docLinks: DocLinksStart;
toastNotifications: ToastsSetup;
http: HttpSetup;

View file

@ -4,9 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { BASE_ALERT_API_PATH } from '../../../../alerting/common';
export { BASE_ACTION_API_PATH } from '../../../../actions/common';
export const BASE_PATH = '/management/kibana/triggersActions';
export const BASE_ACTION_API_PATH = '/api/action';
export const BASE_ALERT_API_PATH = '/api/alert';
export type Section = 'connectors' | 'alerts';

View file

@ -13,6 +13,7 @@ import { actionTypeRegistryMock } from '../../../action_type_registry.mock';
import { AppContextProvider } from '../../../app_context';
import { chartPluginMock } from '../../../../../../../../src/plugins/charts/public/mocks';
import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks';
import { alertingPluginMock } from '../../../../../../alerting/public/mocks';
jest.mock('../../../lib/action_connector_api', () => ({
loadAllActions: jest.fn(),
@ -49,7 +50,7 @@ describe('actions_connectors_list component empty', () => {
{
chrome,
docLinks,
application: { capabilities },
application: { capabilities, navigateToApp },
},
] = await mockes.getStartServices();
const deps = {
@ -57,9 +58,11 @@ describe('actions_connectors_list component empty', () => {
docLinks,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
alerting: alertingPluginMock.createStartContract(),
toastNotifications: mockes.notifications.toasts,
http: mockes.http,
uiSettings: mockes.uiSettings,
navigateToApp,
capabilities: {
...capabilities,
siem: {
@ -145,7 +148,7 @@ describe('actions_connectors_list component with items', () => {
{
chrome,
docLinks,
application: { capabilities },
application: { capabilities, navigateToApp },
},
] = await mockes.getStartServices();
const deps = {
@ -153,9 +156,11 @@ describe('actions_connectors_list component with items', () => {
docLinks,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
alerting: alertingPluginMock.createStartContract(),
toastNotifications: mockes.notifications.toasts,
http: mockes.http,
uiSettings: mockes.uiSettings,
navigateToApp,
capabilities: {
...capabilities,
siem: {
@ -228,7 +233,7 @@ describe('actions_connectors_list component empty with show only capability', ()
{
chrome,
docLinks,
application: { capabilities },
application: { capabilities, navigateToApp },
},
] = await mockes.getStartServices();
const deps = {
@ -236,9 +241,11 @@ describe('actions_connectors_list component empty with show only capability', ()
docLinks,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
alerting: alertingPluginMock.createStartContract(),
toastNotifications: mockes.notifications.toasts,
http: mockes.http,
uiSettings: mockes.uiSettings,
navigateToApp,
capabilities: {
...capabilities,
siem: {
@ -316,7 +323,7 @@ describe('actions_connectors_list with show only capability', () => {
{
chrome,
docLinks,
application: { capabilities },
application: { capabilities, navigateToApp },
},
] = await mockes.getStartServices();
const deps = {
@ -324,9 +331,11 @@ describe('actions_connectors_list with show only capability', () => {
docLinks,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
alerting: alertingPluginMock.createStartContract(),
toastNotifications: mockes.notifications.toasts,
http: mockes.http,
uiSettings: mockes.uiSettings,
navigateToApp,
capabilities: {
...capabilities,
siem: {

View file

@ -19,6 +19,7 @@ import {
import { times, random } from 'lodash';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { ViewInApp } from './view_in_app';
jest.mock('../../../app_context', () => ({
useAppDependencies: jest.fn(() => ({
@ -247,14 +248,7 @@ describe('alert_details', () => {
expect(
shallow(
<AlertDetails alert={alert} alertType={alertType} actionTypes={[]} {...mockAlertApis} />
).containsMatchingElement(
<EuiButtonEmpty disabled={true} iconType="popout">
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertDetails.viewAlertInAppButtonLabel"
defaultMessage="View in app"
/>
</EuiButtonEmpty>
)
).containsMatchingElement(<ViewInApp alert={alert} />)
).toBeTruthy();
});

View file

@ -33,6 +33,7 @@ import {
withBulkAlertOperations,
} from '../../common/components/with_bulk_alert_api_operations';
import { AlertInstancesRouteWithApi } from './alert_instances_route';
import { ViewInApp } from './view_in_app';
type AlertDetailsProps = {
alert: Alert;
@ -95,12 +96,7 @@ export const AlertDetails: React.FunctionComponent<AlertDetailsProps> = ({
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty disabled={true} iconType="popout">
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertDetails.viewAlertInAppButtonLabel"
defaultMessage="View in app"
/>
</EuiButtonEmpty>
<ViewInApp alert={alert} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty disabled={true} iconType="menuLeft">

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import uuid from 'uuid';
import { mount, ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { Alert } from '../../../../types';
import { ViewInApp } from './view_in_app';
import { useAppDependencies } from '../../../app_context';
jest.mock('../../../app_context', () => {
const alerting = {
getNavigation: jest.fn(async id => (id === 'alert-with-nav' ? { path: '/alert' } : undefined)),
};
const navigateToApp = jest.fn();
return {
useAppDependencies: jest.fn(() => ({
http: jest.fn(),
navigateToApp,
alerting,
legacy: {
capabilities: {
get: jest.fn(() => ({})),
},
},
})),
};
});
jest.mock('../../../lib/capabilities', () => ({
hasSaveAlertsCapability: jest.fn(() => true),
}));
describe('alert_details', () => {
describe('link to the app that created the alert', () => {
it('is disabled when there is no navigation', async () => {
const alert = mockAlert();
const { alerting } = useAppDependencies();
let component: ReactWrapper;
await act(async () => {
// use mount as we need useEffect to run
component = mount(<ViewInApp alert={alert} />);
await waitForUseEffect();
expect(component!.find('button').prop('disabled')).toBe(true);
expect(component!.text()).toBe('View in app');
expect(alerting!.getNavigation).toBeCalledWith(alert.id);
});
});
it('enabled when there is navigation', async () => {
const alert = mockAlert({ id: 'alert-with-nav', consumer: 'siem' });
const { navigateToApp } = useAppDependencies();
let component: ReactWrapper;
act(async () => {
// use mount as we need useEffect to run
component = mount(<ViewInApp alert={alert} />);
await waitForUseEffect();
expect(component!.find('button').prop('disabled')).toBe(undefined);
component!.find('button').prop('onClick')!({
currentTarget: {},
} as React.MouseEvent<{}, MouseEvent>);
expect(navigateToApp).toBeCalledWith('siem', '/alert');
});
});
});
});
function waitForUseEffect() {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
function mockAlert(overloads: Partial<Alert> = {}): Alert {
return {
id: uuid.v4(),
enabled: true,
name: `alert-${uuid.v4()}`,
tags: [],
alertTypeId: '.noop',
consumer: 'consumer',
schedule: { interval: '1m' },
actions: [],
params: {},
createdBy: null,
updatedBy: null,
createdAt: new Date(),
updatedAt: new Date(),
apiKeyOwner: null,
throttle: null,
muteAll: false,
mutedInstanceIds: [],
...overloads,
};
}

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, useEffect } from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { CoreStart } from 'kibana/public';
import { fromNullable, fold } from 'fp-ts/lib/Option';
import { pipe } from 'fp-ts/lib/pipeable';
import { useAppDependencies } from '../../../app_context';
import {
AlertNavigation,
AlertStateNavigation,
AlertUrlNavigation,
} from '../../../../../../alerting/common';
import { Alert } from '../../../../types';
export interface ViewInAppProps {
alert: Alert;
}
const NO_NAVIGATION = false;
type AlertNavigationLoadingState = AlertNavigation | false | null;
export const ViewInApp: React.FunctionComponent<ViewInAppProps> = ({ alert }) => {
const { navigateToApp, alerting: maybeAlerting } = useAppDependencies();
const [alertNavigation, setAlertNavigation] = useState<AlertNavigationLoadingState>(null);
useEffect(() => {
pipe(
fromNullable(maybeAlerting),
fold(
/**
* If the alerting plugin is disabled,
* navigation isn't supported
*/
() => setAlertNavigation(NO_NAVIGATION),
alerting =>
alerting
.getNavigation(alert.id)
.then(nav => (nav ? setAlertNavigation(nav) : setAlertNavigation(NO_NAVIGATION)))
.catch(() => {
setAlertNavigation(NO_NAVIGATION);
})
)
);
}, [alert.id, maybeAlerting]);
return (
<EuiButtonEmpty
data-test-subj="alertDetails-viewInApp"
isLoading={alertNavigation === null}
disabled={!hasNavigation(alertNavigation)}
iconType="popout"
{...getNavigationHandler(alertNavigation, alert, navigateToApp)}
>
<FormattedMessage
id="xpack.triggersActionsUI.sections.alertDetails.viewAlertInAppButtonLabel"
defaultMessage="View in app"
/>
</EuiButtonEmpty>
);
};
function hasNavigation(
alertNavigation: AlertNavigationLoadingState
): alertNavigation is AlertStateNavigation | AlertUrlNavigation {
return alertNavigation
? alertNavigation.hasOwnProperty('state') || alertNavigation.hasOwnProperty('path')
: NO_NAVIGATION;
}
function getNavigationHandler(
alertNavigation: AlertNavigationLoadingState,
alert: Alert,
navigateToApp: CoreStart['application']['navigateToApp']
): object {
return hasNavigation(alertNavigation)
? {
onClick: () => {
navigateToApp(alert.consumer, alertNavigation);
},
}
: {};
}

View file

@ -15,6 +15,7 @@ import { ValidationResult } from '../../../../types';
import { AppContextProvider } from '../../../app_context';
import { chartPluginMock } from '../../../../../../../../src/plugins/charts/public/mocks';
import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks';
import { alertingPluginMock } from '../../../../../../alerting/public/mocks';
jest.mock('../../../lib/action_connector_api', () => ({
loadActionTypes: jest.fn(),
@ -83,7 +84,7 @@ describe('alerts_list component empty', () => {
{
chrome,
docLinks,
application: { capabilities },
application: { capabilities, navigateToApp },
},
] = await mockes.getStartServices();
const deps = {
@ -91,9 +92,11 @@ describe('alerts_list component empty', () => {
docLinks,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
alerting: alertingPluginMock.createStartContract(),
toastNotifications: mockes.notifications.toasts,
http: mockes.http,
uiSettings: mockes.uiSettings,
navigateToApp,
capabilities: {
...capabilities,
siem: {
@ -204,7 +207,7 @@ describe('alerts_list component with items', () => {
{
chrome,
docLinks,
application: { capabilities },
application: { capabilities, navigateToApp },
},
] = await mockes.getStartServices();
const deps = {
@ -212,9 +215,11 @@ describe('alerts_list component with items', () => {
docLinks,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
alerting: alertingPluginMock.createStartContract(),
toastNotifications: mockes.notifications.toasts,
http: mockes.http,
uiSettings: mockes.uiSettings,
navigateToApp,
capabilities: {
...capabilities,
siem: {
@ -292,7 +297,7 @@ describe('alerts_list component empty with show only capability', () => {
{
chrome,
docLinks,
application: { capabilities },
application: { capabilities, navigateToApp },
},
] = await mockes.getStartServices();
const deps = {
@ -300,9 +305,11 @@ describe('alerts_list component empty with show only capability', () => {
docLinks,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
alerting: alertingPluginMock.createStartContract(),
toastNotifications: mockes.notifications.toasts,
http: mockes.http,
uiSettings: mockes.uiSettings,
navigateToApp,
capabilities: {
...capabilities,
siem: {
@ -409,7 +416,7 @@ describe('alerts_list with show only capability', () => {
{
chrome,
docLinks,
application: { capabilities },
application: { capabilities, navigateToApp },
},
] = await mockes.getStartServices();
const deps = {
@ -417,9 +424,11 @@ describe('alerts_list with show only capability', () => {
docLinks,
dataPlugin: dataPluginMock.createStartContract(),
charts: chartPluginMock.createStartContract(),
alerting: alertingPluginMock.createStartContract(),
toastNotifications: mockes.notifications.toasts,
http: mockes.http,
uiSettings: mockes.uiSettings,
navigateToApp,
capabilities: {
...capabilities,
siem: {

View file

@ -11,7 +11,7 @@ export { AlertsContextProvider } from './application/context/alerts_context';
export { ActionsConnectorsContextProvider } from './application/context/actions_connectors_context';
export { AlertAdd } from './application/sections/alert_form';
export { ActionForm } from './application/sections/action_connector_form';
export { AlertAction, Alert } from './types';
export { AlertAction, Alert, AlertTypeModel } from './types';
export {
ConnectorAddFlyout,
ConnectorEditFlyout,

View file

@ -15,6 +15,7 @@ import { TypeRegistry } from './application/type_registry';
import { ManagementStart } from '../../../../src/plugins/management/public';
import { boot } from './application/boot';
import { ChartsPluginStart } from '../../../../src/plugins/charts/public';
import { PluginStartContract as AlertingStart } from '../../alerting/public';
import { DataPublicPluginStart } from '../../../../src/plugins/data/public';
export interface TriggersAndActionsUIPublicPluginSetup {
@ -31,6 +32,8 @@ interface PluginsStart {
data: DataPublicPluginStart;
charts: ChartsPluginStart;
management: ManagementStart;
alerting?: AlertingStart;
navigateToApp: CoreStart['application']['navigateToApp'];
}
export class Plugin
@ -80,6 +83,7 @@ export class Plugin
boot({
dataPlugin: plugins.data,
charts: plugins.charts,
alerting: plugins.alerting,
element: params.element,
toastNotifications: core.notifications.toasts,
http: core.http,
@ -89,6 +93,7 @@ export class Plugin
savedObjects: core.savedObjects.client,
I18nContext: core.i18n.Context,
capabilities: core.application.capabilities,
navigateToApp: core.application.navigateToApp,
setBreadcrumbs: params.setBreadcrumbs,
actionTypeRegistry: this.actionTypeRegistry,
alertTypeRegistry: this.alertTypeRegistry,

View file

@ -148,6 +148,34 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
});
describe.skip('View In App', function() {
const testRunUuid = uuid.v4();
before(async () => {
await pageObjects.common.navigateToApp('triggersActions');
});
it('renders the alert details view in app button', async () => {
const alert = await alerting.alerts.createNoOp(`test-alert-${testRunUuid}`);
// refresh to see alert
await browser.refresh();
await pageObjects.header.waitUntilLoadingHasFinished();
// Verify content
await testSubjects.existOrFail('alertsList');
// click on first alert
await pageObjects.triggersActionsUI.clickOnAlertInAlertsList(alert.name);
expect(await pageObjects.alertDetailsUI.isViewInAppEnabled()).to.be(true);
await pageObjects.alertDetailsUI.clickViewInAppEnabled();
expect(await pageObjects.alertDetailsUI.getNoOpAppTitle()).to.be(`View Alert ${alert.id}`);
});
});
describe('Alert Instances', function() {
const testRunUuid = uuid.v4();
let alert: any;

View file

@ -0,0 +1,9 @@
{
"id": "alerting_fixture",
"version": "1.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack"],
"requiredPlugins": ["alerting"],
"server": true,
"ui": true
}

View file

@ -3,5 +3,13 @@
"version": "0.0.0",
"kibana": {
"version": "kibana"
},
"main": "target/test/functional_with_es_ssl/fixtures/plugins/alerts",
"scripts": {
"kbn": "node ../../../../../../scripts/kbn.js",
"build": "rm -rf './target' && tsc"
},
"devDependencies": {
"typescript": "3.7.2"
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route, RouteComponentProps } from 'react-router-dom';
import { EuiPage, EuiText } from '@elastic/eui';
import { AppMountParameters, CoreStart } from '../../../../../../../src/core/public';
export interface AlertingExampleComponentParams {
basename: string;
}
const AlertingExampleApp = (deps: AlertingExampleComponentParams) => {
const { basename } = deps;
return (
<Router basename={basename}>
<EuiPage>
<Route
path={`/alert/:id`}
render={(props: RouteComponentProps<{ id: string }>) => {
return (
<EuiText data-test-subj="noop-title">
<h2>View Alert {props.match.params.id}</h2>
</EuiText>
);
}}
/>
</EuiPage>
</Router>
);
};
export const renderApp = (
core: CoreStart,
deps: any,
{ appBasePath, element }: AppMountParameters
) => {
ReactDOM.render(<AlertingExampleApp basename={appBasePath} {...deps} />, element);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertingFixturePlugin } from './plugin';
export const plugin = () => new AlertingFixturePlugin();

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Plugin, CoreSetup, AppMountParameters } from 'kibana/public';
import { PluginSetupContract as AlertingSetup } from '../../../../../../plugins/alerting/public';
import { AlertType, SanitizedAlert } from '../../../../../../plugins/alerting/common';
export type Setup = void;
export type Start = void;
export interface AlertingExamplePublicSetupDeps {
alerting: AlertingSetup;
}
export class AlertingFixturePlugin implements Plugin<Setup, Start, AlertingExamplePublicSetupDeps> {
public setup(core: CoreSetup, { alerting }: AlertingExamplePublicSetupDeps) {
alerting.registerNavigation(
'consumer-noop',
'test.noop',
(alert: SanitizedAlert, alertType: AlertType) => `/alert/${alert.id}`
);
core.application.register({
id: 'consumer-noop',
title: 'No Op App',
async mount(params: AppMountParameters) {
const [coreStart, depsStart] = await core.getStartServices();
const { renderApp } = await import('./application');
return renderApp(coreStart, depsStart, params);
},
});
}
public start() {}
public stop() {}
}

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { PluginInitializer } from 'kibana/server';
import { AlertingFixturePlugin } from './plugin';
export const plugin: PluginInitializer<void, void> = () => new AlertingFixturePlugin();

View file

@ -4,21 +4,28 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertType } from '../../../../../plugins/alerting/server';
import { Plugin, CoreSetup } from 'kibana/server';
import {
PluginSetupContract as AlertingSetup,
AlertType,
} from '../../../../../../plugins/alerting/server';
// eslint-disable-next-line import/no-default-export
export default function(kibana: any) {
return new kibana.Plugin({
require: ['alerting'],
name: 'alerts',
init(server: any) {
createNoopAlertType(server.newPlatform.setup.plugins.alerting);
createAlwaysFiringAlertType(server.newPlatform.setup.plugins.alerting);
},
});
// this plugin's dependendencies
export interface AlertingExampleDeps {
alerting: AlertingSetup;
}
function createNoopAlertType(setupContract: any) {
export class AlertingFixturePlugin implements Plugin<void, void, AlertingExampleDeps> {
public setup(core: CoreSetup, { alerting }: AlertingExampleDeps) {
createNoopAlertType(alerting);
createAlwaysFiringAlertType(alerting);
}
public start() {}
public stop() {}
}
function createNoopAlertType(alerting: AlertingSetup) {
const noopAlertType: AlertType = {
id: 'test.noop',
name: 'Test: Noop',
@ -26,10 +33,10 @@ function createNoopAlertType(setupContract: any) {
defaultActionGroupId: 'default',
async executor() {},
};
setupContract.registerType(noopAlertType);
alerting.registerType(noopAlertType);
}
function createAlwaysFiringAlertType(setupContract: any) {
function createAlwaysFiringAlertType(alerting: AlertingSetup) {
// Alert types
const alwaysFiringAlertType: any = {
id: 'test.always-firing',
@ -54,5 +61,5 @@ function createAlwaysFiringAlertType(setupContract: any) {
};
},
};
setupContract.registerType(alwaysFiringAlertType);
alerting.registerType(alwaysFiringAlertType);
}

View file

@ -102,5 +102,16 @@ export function AlertDetailsPageProvider({ getService }: FtrProviderContext) {
const nextButton = await testSubjects.find(`pagination-button-next`);
nextButton.click();
},
async isViewInAppEnabled() {
const viewInAppButton = await testSubjects.find(`alertDetails-viewInApp`);
return (await viewInAppButton.getAttribute('disabled')) !== 'disabled';
},
async clickViewInAppEnabled() {
const viewInAppButton = await testSubjects.find(`alertDetails-viewInApp`);
return viewInAppButton.click();
},
async getNoOpAppTitle() {
return await testSubjects.getVisibleText('noop-title');
},
};
}

View file

@ -22,6 +22,31 @@ export class Alerts {
});
}
public async createNoOp(name: string) {
this.log.debug(`creating alert ${name}`);
const { data: alert, status, statusText } = await this.axios.post(`/api/alert`, {
enabled: true,
name,
tags: ['foo'],
alertTypeId: 'test.noop',
consumer: 'consumer-noop',
schedule: { interval: '1m' },
throttle: '1m',
actions: [],
params: {},
});
if (status !== 200) {
throw new Error(
`Expected status code of 200, received ${status} ${statusText}: ${util.inspect(alert)}`
);
}
this.log.debug(`created alert ${alert.id}`);
return alert;
}
public async createAlwaysFiringWithActions(
name: string,
actions: Array<{