feat(notifications): create notification endpoint overlay (#14693)

* chore: update swagger definitions

* feat(endpoints): introducer endpoint overlay

* wip: change endpoint type

* feat(endpoint): add inputs for supported endpoint types

* wip: connect create endpoint to API

* feat: implement create endpoint

* alerts(e2e): add createEndpoint command

* chore: update swagger
pull/14713/head
Andrew Watkins 2019-08-19 10:57:20 -07:00 committed by GitHub
parent 8ef3d9e94c
commit 286d57b0ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1076 additions and 54 deletions

View File

@ -9205,14 +9205,12 @@ components:
NotificationEndpoint:
oneOf:
- $ref: "#/components/schemas/SlackNotificationEndpoint"
- $ref: "#/components/schemas/SMTPNotificationEndpoint"
- $ref: "#/components/schemas/PagerDutyNotificationEndpoint"
- $ref: "#/components/schemas/WebhookNotificationEndpoint"
discriminator:
propertyName: type
mapping:
slack: "#/components/schemas/SlackNotificationEndpoint"
smtp: "#/components/schemas/SMTPNotificationEndpoint"
pagerduty: "#/components/schemas/PagerDutyNotificationEndpoint"
webhook: "#/components/schemas/WebhookNotificationEndpoint"
NotificationEndpoints:
@ -9225,6 +9223,7 @@ components:
$ref: "#/components/schemas/Links"
NotificationEndpointBase:
type: object
required: [type]
properties:
id:
type: string
@ -9254,30 +9253,54 @@ components:
$ref: "#/components/schemas/Labels"
type:
$ref: "#/components/schemas/NotificationEndpointType"
required: [type]
SlackNotificationEndpoint:
type: object
allOf:
- $ref: "#/components/schemas/NotificationEndpointBase"
- type: object
SMTPNotificationEndpoint:
type: object
allOf:
- $ref: "#/components/schemas/NotificationEndpointBase"
- type: object
required: [url, token]
properties:
url:
type: string
token:
type: string
PagerDutyNotificationEndpoint:
type: object
allOf:
- $ref: "#/components/schemas/NotificationEndpointBase"
- type: object
required: [url, routingKey]
properties:
url:
type: string
routingKey:
type: string
WebhookNotificationEndpoint:
type: object
allOf:
- $ref: "#/components/schemas/NotificationEndpointBase"
- type: object
required: [url, authmethod, method]
properties:
url:
type: string
username:
type: string
password:
type: string
token:
type: string
method:
type: string
enum: ['POST', 'GET', 'PUT']
authmethod:
type: string
enum: ['none', 'basic', 'bearer']
contentTemplate:
type: string
NotificationEndpointType:
type: string
enum: ['slack', smtp, 'pagerduty', 'webhook']
enum: ['slack', 'pagerduty', 'webhook']
securitySchemes:
BasicAuth:
type: http

View File

@ -13,7 +13,7 @@ describe('Dashboards', () => {
})
cy.fixture('routes').then(({orgs}) => {
cy.get<Organization>('@org').then(({id}) => {
cy.get('@org').then(({id}: Organization) => {
cy.visit(`${orgs}/${id}/dashboards`)
})
})
@ -27,7 +27,7 @@ describe('Dashboards', () => {
cy.getByTestID('add-resource-dropdown--new').click()
cy.fixture('routes').then(({orgs}) => {
cy.get<Organization>('@org').then(({id}) => {
cy.get('@org').then(({id}: Organization) => {
cy.visit(`${orgs}/${id}/dashboards`)
})
})
@ -41,7 +41,7 @@ describe('Dashboards', () => {
cy.getByTestID('add-resource-dropdown--new').click()
cy.fixture('routes').then(({orgs}) => {
cy.get<Organization>('@org').then(({id}) => {
cy.get('@org').then(({id}: Organization) => {
cy.visit(`${orgs}/${id}/dashboards`)
})
})
@ -51,7 +51,7 @@ describe('Dashboards', () => {
it.only('can create a dashboard from a Template', () => {
cy.getByTestID('dashboard-card').should('have.length', 0)
cy.get<Organization>('@org').then(({id}) => {
cy.get('@org').then(({id}: Organization) => {
cy.createDashboardTemplate(id)
})
@ -70,7 +70,7 @@ describe('Dashboards', () => {
describe('Dashboard List', () => {
beforeEach(() => {
cy.get<Organization>('@org').then(({id}) => {
cy.get('@org').then(({id}: Organization) => {
cy.createDashboard(id, dashboardName).then(({body}) => {
cy.createAndAddLabel('dashboards', id, body.id, newLabelName)
})
@ -81,7 +81,7 @@ describe('Dashboards', () => {
})
cy.fixture('routes').then(({orgs}) => {
cy.get<Organization>('@org').then(({id}) => {
cy.get('@org').then(({id}: Organization) => {
cy.visit(`${orgs}/${id}/dashboards`)
})
})
@ -152,7 +152,7 @@ describe('Dashboards', () => {
it('can add an existing label to a dashboard', () => {
const labelName = 'swogglez'
cy.get<Organization>('@org').then(({id}) => {
cy.get('@org').then(({id}: Organization) => {
cy.createLabel(labelName, id).then(() => {
cy.getByTestID(`inline-labels--add`)
.first()

View File

@ -0,0 +1,85 @@
describe('Notification Endpoints', () => {
beforeEach(() => {
cy.flush()
cy.signin().then(({body}) => {
const {
org: {id},
} = body
cy.wrap(body.org).as('org')
cy.fixture('routes').then(({orgs, alerting}) => {
cy.visit(`${orgs}/${id}${alerting}`)
})
})
})
it('can create a notification endpoint', () => {
const name = 'An Endpoint Has No Name'
const description =
'A minute, an hour, a month. Notification Endpoint is certain. The time is not.'
cy.getByTestID('alert-column--header create-endpoint').click()
cy.getByTestID('endpoint-name--input')
.clear()
.type(name)
.should('have.value', name)
cy.getByTestID('endpoint-description--textarea')
.clear()
.type(description)
.should('have.value', description)
cy.getByTestID('endpoint-change--dropdown')
.click()
.within(() => {
cy.getByTestID('endpoint--dropdown--button').within(() => {
cy.contains('Slack')
})
cy.getByTestID('endpoint--dropdown-item pagerduty').click()
cy.getByTestID('endpoint--dropdown--button').within(() => {
cy.contains('Pagerduty')
})
})
cy.getByTestID('pagerduty-url')
.clear()
.type('many-faced-god.gov')
.should('have.value', 'many-faced-god.gov')
cy.getByTestID('pagerduty-routing-key')
.type('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')
.should('have.value', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')
cy.getByTestID('endpoint-change--dropdown')
.click()
.within(() => {
cy.getByTestID('endpoint--dropdown--button').within(() => {
cy.contains('Pagerduty')
})
cy.getByTestID('endpoint--dropdown-item slack').click()
cy.getByTestID('endpoint--dropdown--button').within(() => {
cy.contains('Slack')
})
})
cy.getByTestID('slack-url')
.clear()
.type('slack.url.us')
.should('have.value', 'slack.url.us')
cy.getByTestID('slack-token')
.clear()
.type('another token')
.should('have.value', 'another token')
cy.getByTestID('endpoint-save--button').click()
cy.getByTestID(`endpoint-card ${name}`).should('exist')
cy.getByTestID('endpoint--overlay').should('not.be.visible')
})
})

View File

@ -22,6 +22,7 @@ import {
createDashboardTemplate,
writeData,
getByTestIDSubStr,
createEndpoint,
} from './support/commands'
declare global {
@ -49,6 +50,7 @@ declare global {
createTelegraf: typeof createTelegraf
createToken: typeof createToken
writeData: typeof writeData
createEndpoint: typeof createEndpoint
}
}
}

View File

@ -1,3 +1,5 @@
import {NotificationEndpoint} from '../../src/types'
export const signin = (): Cypress.Chainable<Cypress.Response> => {
return cy.fixture('user').then(({username, password}) => {
return cy.setupUser().then(body => {
@ -359,6 +361,27 @@ export const fluxEqual = (s1: string, s2: string): Cypress.Chainable => {
return cy.wrap(strip1 === strip2)
}
// notification endpoints
export const createEndpoint = (
name: string,
orgID: string
): Cypress.Chainable<Cypress.Response> => {
const endpoint: NotificationEndpoint = {
orgID,
name,
userID: '',
description: 'interrupt everyone at work',
status: 'active',
type: 'slack',
url: 'insert.slack.url.here',
}
return cy.request('POST', 'api/v2/notificationEndpoints', endpoint)
}
// notification endpoints
Cypress.Commands.add('createEndpoint', createEndpoint)
// assertions
Cypress.Commands.add('fluxEqual', fluxEqual)
@ -397,15 +420,15 @@ Cypress.Commands.add('flush', flush)
// tasks
Cypress.Commands.add('createTask', createTask)
//Tokems
// tokens
Cypress.Commands.add('createToken', createToken)
// variables
Cypress.Commands.add('createVariable', createVariable)
// Labels
// labels
Cypress.Commands.add('createLabel', createLabel)
Cypress.Commands.add('createAndAddLabel', createAndAddLabel)
//Test
// test
Cypress.Commands.add('writeData', writeData)

View File

@ -0,0 +1,65 @@
// Libraries
import {Dispatch} from 'react'
// Types
import {NotificationEndpoint, GetState} from 'src/types'
import {RemoteDataState} from '@influxdata/clockface'
// APIs
import * as api from 'src/client'
export type Action =
| {type: 'SET_ENDPOINT'; endpoint: NotificationEndpoint}
| {
type: 'SET_ALL_ENDPOINTS'
status: RemoteDataState
endpoints?: NotificationEndpoint[]
}
export const getEndpoints = () => async (
dispatch: Dispatch<Action>,
getState: GetState
) => {
try {
dispatch({
type: 'SET_ALL_ENDPOINTS',
status: RemoteDataState.Loading,
})
const {orgs} = getState()
const resp = await api.getNotificationEndpoints({
query: {orgID: orgs.org.id},
})
if (resp.status !== 200) {
throw new Error(resp.data.message)
}
dispatch({
type: 'SET_ALL_ENDPOINTS',
status: RemoteDataState.Done,
endpoints: resp.data.notificationEndpoints,
})
} catch (e) {
console.error(e)
dispatch({type: 'SET_ALL_ENDPOINTS', status: RemoteDataState.Error})
}
}
export const createEndpoint = (data: NotificationEndpoint) => async (
dispatch: Dispatch<Action>
) => {
const resp = await api.postNotificationEndpoint({data})
if (resp.status !== 201) {
throw new Error(resp.data.message)
}
const endpoint = (resp.data as unknown) as NotificationEndpoint
dispatch({
type: 'SET_ENDPOINT',
endpoint,
})
}

View File

@ -1,29 +1,38 @@
// Libraries
import React, {FunctionComponent} from 'react'
import React, {FC} from 'react'
import {connect} from 'react-redux'
import {withRouter, WithRouterProps} from 'react-router'
// Components
import {EmptyState, ComponentSize} from '@influxdata/clockface'
import EndpointCards from 'src/alerting/components/endpoints/EndpointCards'
import AlertsColumn from 'src/alerting/components/AlertsColumn'
import {AppState} from 'src/types'
interface StateProps {
endpoints: AppState['endpoints']['list']
}
type OwnProps = {}
type Props = OwnProps & WithRouterProps & StateProps
const EndpointsColumn: FC<Props> = ({router, params, endpoints}) => {
const handleOpenOverlay = () => {
const newRuleRoute = `/orgs/${params.orgID}/alerting/endpoints/new`
router.push(newRuleRoute)
}
const EndpointsColumn: FunctionComponent = () => {
return (
<AlertsColumn
title="Notification Endpoints"
testID="create-endpoint"
onCreate={() => {}}
onCreate={handleOpenOverlay}
>
<EmptyState size={ComponentSize.Small} className="alert-column--empty">
<EmptyState.Text
text="A Notification Endpoint stores the information to connect to a third party service that can receive notifications like Slack, PagerDuty, or an HTTP server"
highlightWords={['Notification', 'Endpoint']}
/>
<br />
<a href="#" target="_blank">
Documentation
</a>
</EmptyState>
<EndpointCards endpoints={endpoints} />
</AlertsColumn>
)
}
export default EndpointsColumn
const mstp = ({endpoints}: AppState) => {
return {endpoints: endpoints.list}
}
export default connect<StateProps>(mstp)(withRouter<OwnProps>(EndpointsColumn))

View File

@ -0,0 +1,72 @@
/* eslint no-console: 0 */
// Libraries
import React, {FC} from 'react'
// Components
import {SlideToggle, ComponentSize, ResourceCard} from '@influxdata/clockface'
import EndpointCardMenu from 'src/alerting/components/endpoints/EndpointCardMenu'
// Types
import {NotificationEndpoint} from 'src/types'
interface OwnProps {
endpoint: NotificationEndpoint
}
type Props = OwnProps
const EndpointCard: FC<Props> = ({endpoint}) => {
const {id, name, status} = endpoint
const handleUpdateName = () => console.trace('implement update endpoint name')
const handleClick = () => console.trace('implement click endpoint name')
const nameComponent = (
<ResourceCard.EditableName
key={id}
name={name}
onClick={handleClick}
onUpdate={handleUpdateName}
testID="endpoint-card--name"
inputTestID="endpoint-card--input"
buttonTestID="endpoint-card--name-button"
noNameString="Name this notification endpoint"
/>
)
const handleToggle = () => console.trace('implement toggle status')
const toggle = (
<SlideToggle
active={status === 'active'}
size={ComponentSize.ExtraSmall}
onChange={handleToggle}
testID="endpoint-card--slide-toggle"
/>
)
const handleDelete = () => console.trace('implement delete')
const handleExport = () => console.trace('implement export')
const handleClone = () => console.trace('implement delete')
const contextMenu = (
<EndpointCardMenu
onDelete={handleDelete}
onExport={handleExport}
onClone={handleClone}
/>
)
return (
<ResourceCard
key={id}
toggle={toggle}
name={nameComponent}
contextMenu={contextMenu}
disabled={status === 'inactive'}
metaData={[<>{endpoint.updatedAt}</>]}
testID={`endpoint-card ${name}`}
/>
)
}
export default EndpointCard

View File

@ -0,0 +1,38 @@
// Libraries
import React, {FC} from 'react'
// Components
import {Context, IconFont} from 'src/clockface'
import {ComponentColor} from '@influxdata/clockface'
interface Props {
onDelete: () => void
onClone: () => void
onExport: () => void
}
const EndpointCardContext: FC<Props> = ({onDelete, onClone, onExport}) => {
return (
<Context>
<Context.Menu icon={IconFont.CogThick}>
<Context.Item label="Export" action={onExport} />
</Context.Menu>
<Context.Menu icon={IconFont.Duplicate} color={ComponentColor.Secondary}>
<Context.Item label="Clone" action={onClone} />
</Context.Menu>
<Context.Menu
icon={IconFont.Trash}
color={ComponentColor.Danger}
testID="context-delete-menu"
>
<Context.Item
label="Delete"
action={onDelete}
testID="context-delete-task"
/>
</Context.Menu>
</Context>
)
}
export default EndpointCardContext

View File

@ -0,0 +1,42 @@
// Libraries
import React, {FC} from 'react'
// Components
import EndpointCard from 'src/alerting/components/endpoints/EndpointCard'
import {EmptyState, ResourceList, ComponentSize} from '@influxdata/clockface'
// Types
import {NotificationEndpoint} from 'src/types'
interface Props {
endpoints: NotificationEndpoint[]
}
const EndpointCards: FC<Props> = ({endpoints}) => {
const cards = endpoints.map(endpoint => (
<EndpointCard key={endpoint.id} endpoint={endpoint} />
))
return (
<ResourceList>
<ResourceList.Body emptyState={<EmptyEndpointList />}>
{cards}
</ResourceList.Body>
</ResourceList>
)
}
const EmptyEndpointList: FC = () => (
<EmptyState size={ComponentSize.Small} className="alert-column--empty">
<EmptyState.Text
text="A Notification Endpoint stores the information to connect to a third party service that can receive notifications like Slack, PagerDuty, or an HTTP server"
highlightWords={['Notification', 'Endpoint']}
/>
<br />
<a href="#" target="_blank">
Documentation
</a>
</EmptyState>
)
export default EndpointCards

View File

@ -0,0 +1,76 @@
// Libraries
import React, {FC, ChangeEvent} from 'react'
// Components
import EndpointOptionsSlack from './EndpointOptionsSlack'
import EndpointOptionsPagerDuty from './EndpointOptionsPagerDuty'
import EndpointOptionsWebhook from './EndpointOptionsWebhook'
// Types
import {
NotificationEndpoint,
SlackNotificationEndpoint,
PagerDutyNotificationEndpoint,
WebhookNotificationEndpoint,
} from 'src/types'
interface Props {
endpoint: NotificationEndpoint
onChange: (e: ChangeEvent<HTMLInputElement>) => void
}
const EndpointOptions: FC<Props> = ({endpoint, onChange}) => {
switch (endpoint.type) {
case 'slack': {
const {url, token} = endpoint as SlackNotificationEndpoint
return (
<EndpointOptionsSlack url={url} token={token} onChange={onChange} />
)
}
case 'pagerduty': {
const {url, routingKey} = endpoint as PagerDutyNotificationEndpoint
return (
<EndpointOptionsPagerDuty
url={url}
routingKey={routingKey}
onChange={onChange}
/>
)
}
case 'webhook': {
// TODO(watts): add webhook type to the `Destination` dropdown
// when webhooks are implemented in the backend.
const {
url,
token,
username,
password,
method,
authmethod,
contentTemplate,
} = endpoint as WebhookNotificationEndpoint
return (
<EndpointOptionsWebhook
url={url}
token={token}
username={username}
password={password}
method={method}
authmethod={authmethod}
contentTemplate={contentTemplate}
/>
)
}
default:
throw new Error(
`Unknown endpoint type for endpoint: ${JSON.stringify(
endpoint,
null,
2
)}`
)
}
}
export default EndpointOptions

View File

@ -0,0 +1,36 @@
// Libraries
import React, {FC, ChangeEvent} from 'react'
// Components
import {Input, FormElement} from '@influxdata/clockface'
interface Props {
url: string
routingKey: string
onChange: (e: ChangeEvent<HTMLInputElement>) => void
}
const EndpointOptionsPagerDuty: FC<Props> = ({url, routingKey, onChange}) => {
return (
<>
<FormElement label="URL">
<Input
name="url"
value={url}
testID="pagerduty-url"
onChange={onChange}
/>
</FormElement>
<FormElement label="Routing Key">
<Input
name="routingKey"
value={routingKey}
testID="pagerduty-routing-key"
onChange={onChange}
/>
</FormElement>
</>
)
}
export default EndpointOptionsPagerDuty

View File

@ -0,0 +1,31 @@
// Libraries
import React, {FC, ChangeEvent} from 'react'
// Components
import {Input, FormElement} from '@influxdata/clockface'
interface Props {
url: string
token: string
onChange: (e: ChangeEvent<HTMLInputElement>) => void
}
const EndpointOptionsSlack: FC<Props> = ({url, token, onChange}) => {
return (
<>
<FormElement label="URL">
<Input name="url" value={url} testID="slack-url" onChange={onChange} />
</FormElement>
<FormElement label="Token">
<Input
name="token"
value={token}
testID="slack-token"
onChange={onChange}
/>
</FormElement>
</>
)
}
export default EndpointOptionsSlack

View File

@ -0,0 +1,43 @@
// Libraries
import React, {FC} from 'react'
// Components
import {Input, FormElement} from '@influxdata/clockface'
// Types
import {WebhookNotificationEndpoint} from 'src/types'
interface Props {
url: string
token?: string
username?: string
password?: string
method?: WebhookNotificationEndpoint['method']
authmethod?: WebhookNotificationEndpoint['authmethod']
contentTemplate: string
}
const EndpointOptionsWebhook: FC<Props> = ({url, token}) => {
return (
<>
<FormElement label="URL">
<Input name="url" value={url} />
</FormElement>
<FormElement label="Token">
<Input name="token" value={token} />
</FormElement>
<FormElement label="username">
<Input name="username" value={token} />
</FormElement>
<FormElement label="password">
<Input name="password" value={token} />
</FormElement>
{/** add dropdowns for method and authmethod */}
<FormElement label="Content Template">
<Input name="contentTemplate" value={token} />
</FormElement>
</>
)
}
export default EndpointOptionsWebhook

View File

@ -0,0 +1,29 @@
// Types
import {NotificationEndpoint} from 'src/types'
export type Action =
| {type: 'UPDATE_ENDPOINT'; endpoint: NotificationEndpoint}
| {type: 'DELETE_ENDPOINT'; endpointID: string}
export type EndpointState = NotificationEndpoint
export const reducer = (state: EndpointState, action: Action) => {
switch (action.type) {
case 'UPDATE_ENDPOINT': {
const {endpoint} = action
return {...state, ...endpoint}
}
case 'DELETE_ENDPOINT': {
return state
}
default:
const neverAction: never = action
throw new Error(
`Unhandled action "${
(neverAction as any).type
}" in EndpointsOverlay.reducer.ts`
)
}
}

View File

@ -0,0 +1,9 @@
.endpoint-overlay-footer--error {
display: flex;
align-items: center;
justify-content: center;
color: $c-fire;
font-size: $ix-text-base;
font-weight: 600;
margin: $ix-marg-c 0;
}

View File

@ -0,0 +1,86 @@
// Libraries
import React, {FC, ChangeEvent} from 'react'
// Components
import {Grid, Form, Panel, Input, TextArea} from '@influxdata/clockface'
import EndpointOptions from 'src/alerting/components/endpoints/EndpointOptions'
import EndpointTypeDropdown from 'src/alerting/components/endpoints/EndpointTypeDropdown'
import EndpointOverlayFooter from 'src/alerting/components/endpoints/EndpointOverlayFooter'
// Hooks
import {useEndpointReducer} from './EndpointOverlayProvider'
// Types
import {NotificationEndpointType, NotificationEndpoint} from 'src/types'
interface Props {
onSave: (endpoint: NotificationEndpoint) => Promise<void>
saveButtonText: string
}
const EndpointOverlayContents: FC<Props> = ({onSave, saveButtonText}) => {
const [endpoint, dispatch] = useEndpointReducer()
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const {name, value} = e.target
dispatch({
type: 'UPDATE_ENDPOINT',
endpoint: {...endpoint, [name]: value},
})
}
const handleSelectType = (type: NotificationEndpointType) => {
dispatch({
type: 'UPDATE_ENDPOINT',
endpoint: {...endpoint, type},
})
}
return (
<Grid>
<Form>
<Grid.Row>
<Grid.Column>
<Panel>
<Panel.Body>
<Form.Element label="Name">
<Input
testID="endpoint-name--input"
placeholder="Name this endpoint"
value={endpoint.name}
name="name"
onChange={handleChange}
/>
</Form.Element>
<Form.Element label="Description">
<TextArea
className="endpoint-description--textarea"
testID="endpoint-description--textarea"
name="description"
placeholder="Optional"
value={endpoint.description}
onChange={handleChange}
/>
</Form.Element>
<Form.Element label="Destination">
<EndpointTypeDropdown
onSelectType={handleSelectType}
selectedType={endpoint.type}
/>
</Form.Element>
<EndpointOptions endpoint={endpoint} onChange={handleChange} />
<EndpointOverlayFooter
onSave={onSave}
saveButtonText={saveButtonText}
/>
</Panel.Body>
</Panel>
</Grid.Column>
</Grid.Row>
</Form>
</Grid>
)
}
export default EndpointOverlayContents

View File

@ -0,0 +1,74 @@
// Libraries
import React, {useState, FC} from 'react'
// Components
import {
Form,
Grid,
Button,
Columns,
ComponentColor,
ComponentStatus,
} from '@influxdata/clockface'
// Hooks
import {useEndpointState} from './EndpointOverlayProvider'
// Types
import {NotificationEndpoint, RemoteDataState} from 'src/types'
interface Props {
saveButtonText: string
onSave: (endpoint: NotificationEndpoint) => Promise<void>
}
const EndpointOverlayFooter: FC<Props> = ({saveButtonText, onSave}) => {
const endpoint = useEndpointState()
const [saveStatus, setSaveStatus] = useState(RemoteDataState.NotStarted)
const [errorMessage, setErrorMessage] = useState<string>(null)
const handleSave = async () => {
if (saveStatus === RemoteDataState.Loading) {
return
}
try {
setSaveStatus(RemoteDataState.Loading)
setErrorMessage(null)
await onSave(endpoint)
} catch (e) {
setSaveStatus(RemoteDataState.Error)
setErrorMessage(e.message)
}
}
const buttonStatus =
saveStatus === RemoteDataState.Loading
? ComponentStatus.Loading
: ComponentStatus.Default
return (
<>
<Grid.Row>
<Grid.Column widthXS={Columns.Twelve}>
{errorMessage && (
<div className="endpoint-overlay-footer--error">{errorMessage}</div>
)}
<Form.Footer className="endpoint-overlay-footer">
<Button
testID="endpoint-save--button"
onClick={handleSave}
text={saveButtonText}
status={buttonStatus}
color={ComponentColor.Primary}
/>
</Form.Footer>
</Grid.Column>
</Grid.Row>
</>
)
}
export default EndpointOverlayFooter

View File

@ -0,0 +1,55 @@
// Libraries
import React, {
FC,
Dispatch,
createContext,
useReducer,
useRef,
useContext,
} from 'react'
// Reducer
import {EndpointState, Action, reducer} from './EndpointOverlay.reducer'
const EndpointStateContext = createContext<EndpointState>(null)
const EndpointDispatchContext = createContext<Dispatch<Action>>(null)
export const EndpointOverlayProvider: FC<{initialState: EndpointState}> = ({
initialState,
children,
}) => {
const prevInitialStateRef = useRef(initialState)
const [state, dispatch] = useReducer(
(state: EndpointState, action: Action) => {
if (prevInitialStateRef.current !== initialState) {
prevInitialStateRef.current = initialState
return initialState
}
return reducer(state, action)
},
initialState
)
return (
<EndpointStateContext.Provider value={state}>
<EndpointDispatchContext.Provider value={dispatch}>
{children}
</EndpointDispatchContext.Provider>
</EndpointStateContext.Provider>
)
}
export const useEndpointState = (): EndpointState => {
return useContext(EndpointStateContext)
}
export const useEndpointDispatch = (): Dispatch<Action> => {
return useContext(EndpointDispatchContext)
}
export const useEndpointReducer = (): [EndpointState, Dispatch<Action>] => {
return [useEndpointState(), useEndpointDispatch()]
}

View File

@ -0,0 +1,71 @@
// Libraries
import React, {FC} from 'react'
// Components
import {Dropdown} from '@influxdata/clockface'
// Types
import {NotificationEndpointType} from 'src/types'
interface EndpointType {
id: NotificationEndpointType
type: NotificationEndpointType
name: string
}
interface Props {
selectedType: string
onSelectType: (type: NotificationEndpointType) => void
}
const types: EndpointType[] = [
{name: 'Slack', type: 'slack', id: 'slack'},
{name: 'Pagerduty', type: 'pagerduty', id: 'pagerduty'},
]
const EndpointTypeDropdown: FC<Props> = ({selectedType, onSelectType}) => {
const items = types.map(({id, type, name}) => (
<Dropdown.Item
key={id}
id={id}
value={id}
testID={`endpoint--dropdown-item ${type}`}
onClick={onSelectType}
>
{name}
</Dropdown.Item>
))
const selected = types.find(t => t.type === selectedType)
if (!selected) {
throw new Error(
'Incorrect endpoint type provided to <EndpointTypeDropdown/>'
)
}
const button = (active, onClick) => (
<Dropdown.Button
testID="endpoint--dropdown--button"
active={active}
onClick={onClick}
>
{selected.name}
</Dropdown.Button>
)
const menu = onCollapse => (
<Dropdown.Menu onCollapse={onCollapse}>{items}</Dropdown.Menu>
)
return (
<Dropdown
button={button}
menu={menu}
widthPixels={160}
testID="endpoint-change--dropdown"
/>
)
}
export default EndpointTypeDropdown

View File

@ -0,0 +1,64 @@
// Libraries
import React, {FC, useMemo} from 'react'
import {connect} from 'react-redux'
import {withRouter, WithRouterProps} from 'react-router'
// Actions
import {createEndpoint} from 'src/alerting/actions/notifications/endpoints'
// Components
import {Overlay} from '@influxdata/clockface'
import {EndpointOverlayProvider} from 'src/alerting/components/endpoints/EndpointOverlayProvider'
import EndpointOverlayContents from 'src/alerting/components/endpoints/EndpointOverlayContents'
// Constants
import {NEW_ENDPOINT_DRAFT} from 'src/alerting/constants'
import {NotificationEndpoint} from 'src/types'
interface DispatchProps {
onCreateEndpoint: typeof createEndpoint
}
type Props = WithRouterProps & DispatchProps
const NewRuleOverlay: FC<Props> = ({params, router, onCreateEndpoint}) => {
const {orgID} = params
const handleDismiss = () => {
router.push(`/orgs/${params.orgID}/alerting`)
}
const handleCreateEndpoint = async (endpoint: NotificationEndpoint) => {
await onCreateEndpoint(endpoint)
handleDismiss()
}
const initialState = useMemo(() => ({...NEW_ENDPOINT_DRAFT, orgID}), [orgID])
return (
<EndpointOverlayProvider initialState={initialState}>
<Overlay visible={true}>
<Overlay.Container maxWidth={600}>
<Overlay.Header
title="Create a Notification Endpoint"
onDismiss={handleDismiss}
/>
<Overlay.Body />
<EndpointOverlayContents
onSave={handleCreateEndpoint}
saveButtonText="Create Notification Endpoint"
/>
</Overlay.Container>
</Overlay>
</EndpointOverlayProvider>
)
}
const mdtp = {
onCreateEndpoint: createEndpoint,
}
export default connect<null, DispatchProps>(
null,
mdtp
)(withRouter<Props>(NewRuleOverlay))

View File

@ -1,5 +1,5 @@
// Libraries
import React, {FunctionComponent} from 'react'
import React, {FC} from 'react'
import {connect} from 'react-redux'
import {withRouter, WithRouterProps} from 'react-router'
@ -27,7 +27,7 @@ interface OwnProps {
type Props = OwnProps & DispatchProps & WithRouterProps
const RuleCard: FunctionComponent<Props> = ({
const RuleCard: FC<Props> = ({
rule,
updateRule,
deleteNotificationRule,

View File

@ -1,5 +1,5 @@
// Libraries
import React, {FunctionComponent} from 'react'
import React, {FC} from 'react'
// Components
import NotificationRuleCard from 'src/alerting/components/notifications/RuleCard'
@ -13,7 +13,7 @@ interface Props {
rules: NotificationRuleDraft[]
}
const NotificationRuleCards: FunctionComponent<Props> = ({rules}) => {
const NotificationRuleCards: FC<Props> = ({rules}) => {
return (
<ResourceList>
<ResourceList.Body emptyState={<EmptyNotificationRulesList />}>
@ -25,7 +25,7 @@ const NotificationRuleCards: FunctionComponent<Props> = ({rules}) => {
)
}
const EmptyNotificationRulesList: FunctionComponent = () => {
const EmptyNotificationRulesList: FC = () => {
return (
<EmptyState size={ComponentSize.Small} className="alert-column--empty">
<EmptyState.Text

View File

@ -65,7 +65,7 @@ const RuleEndpointDropdown: FC<Props> = ({
button={button}
menu={menu}
widthPixels={160}
testID="status-change--dropdown"
testID="endpoint-change--dropdown"
/>
)
}

View File

@ -33,10 +33,6 @@ export const getRuleVariantDefaults = (
return {messageTemplate: '', channel: '', type: 'slack'}
}
case 'smtp': {
return {to: '', bodyTemplate: '', subjectTemplate: '', type: 'smtp'}
}
case 'pagerduty': {
return {messageTemplate: '', type: 'pagerduty'}
}

View File

@ -103,6 +103,17 @@ export const NEW_TAG_RULE_DRAFT: TagRuleDraft = {
},
}
export const NEW_ENDPOINT_DRAFT: NotificationEndpoint = {
orgID: '1',
userID: '1',
description: 'interrupt everyone at work',
name: 'Slack',
status: 'active',
type: 'slack',
token: 'plerpstokeny',
url: 'insert.slack.url.here',
}
export const NEW_ENDPOINT_FIXTURES: NotificationEndpoint[] = [
{
id: '1',
@ -112,15 +123,8 @@ export const NEW_ENDPOINT_FIXTURES: NotificationEndpoint[] = [
name: 'Slack',
status: 'active',
type: 'slack',
},
{
id: '2',
orgID: '1',
userID: '1',
description: 'interrupt someone by email',
name: 'SMTP',
status: 'active',
type: 'smtp',
url: 'insert.slack.url.here',
token: 'plerps',
},
{
id: '3',
@ -130,5 +134,7 @@ export const NEW_ENDPOINT_FIXTURES: NotificationEndpoint[] = [
name: 'PagerDuty',
status: 'active',
type: 'pagerduty',
url: 'insert.pagerduty.url.here',
routingKey: 'plerpsy',
},
]

View File

@ -35,7 +35,9 @@ const AlertingIndex: FunctionComponent = ({children}) => {
</GetResources>
</GridColumn>
<GridColumn widthLG={4} widthMD={4} widthSM={4} widthXS={12}>
<EndpointsColumn />
<GetResources resource={ResourceTypes.NotificationEndpoints}>
<EndpointsColumn />
</GetResources>
</GridColumn>
</GridRow>
</Grid>

View File

@ -0,0 +1,51 @@
// Libraries
import produce from 'immer'
// Types
import {NotificationEndpoint} from 'src/types'
import {RemoteDataState} from '@influxdata/clockface'
import {Action} from 'src/alerting/actions/notifications/endpoints'
export interface NotificationEndpointsState {
status: RemoteDataState
list: NotificationEndpoint[]
}
const initialState = {
status: RemoteDataState.NotStarted,
list: [],
}
type State = NotificationEndpointsState
export default (
state: State = initialState,
action: Action
): NotificationEndpointsState =>
produce(state, draftState => {
switch (action.type) {
case 'SET_ALL_ENDPOINTS': {
const {status, endpoints} = action
if (endpoints) {
draftState.list = endpoints
}
draftState.status = status
return
}
case 'SET_ENDPOINT': {
const {endpoint} = action
const index = state.list.findIndex(ep => ep.id === endpoint.id)
if (index === -1) {
draftState.list.push(endpoint)
return
}
draftState.list[index] = endpoint
return
}
}
})

View File

@ -83,6 +83,7 @@ import NewCheckEO from 'src/alerting/components/NewCheckEO'
import EditCheckEO from 'src/alerting/components/EditCheckEO'
import NewRuleOverlay from 'src/alerting/components/notifications/NewRuleOverlay'
import EditRuleOverlay from 'src/alerting/components/notifications/EditRuleOverlay'
import NewEndpointOverlay from 'src/alerting/components/endpoints/NewEndpointOverlay'
import {FeatureFlag} from 'src/shared/utils/featureFlag'
@ -332,6 +333,14 @@ class Root extends PureComponent {
path="rules/:ruleID/edit"
component={EditRuleOverlay}
/>
<Route
path="endpoints/new"
component={NewEndpointOverlay}
/>
<Route
path="rules/:ruleID/edit"
component={null}
/>
</Route>
<Route
path="alert-history"

View File

@ -16,6 +16,7 @@ import {getTemplates} from 'src/templates/actions'
import {getMembers, getUsers} from 'src/members/actions'
import {getChecks} from 'src/alerting/actions/checks'
import {getNotificationRules} from 'src/alerting/actions/notifications/rules'
import {getEndpoints} from 'src/alerting/actions/notifications/endpoints'
// Types
import {AppState} from 'src/types'
@ -31,6 +32,7 @@ import {TemplatesState} from 'src/templates/reducers'
import {MembersState, UsersMap} from 'src/members/reducers'
import {ChecksState} from 'src/alerting/reducers/checks'
import {NotificationRulesState} from 'src/alerting/reducers/notifications/rules'
import {NotificationEndpointsState} from 'src/alerting/reducers/notifications/endpoints'
// Components
import {ErrorHandling} from 'src/shared/decorators/errors'
@ -54,6 +56,7 @@ interface StateProps {
users: {status: RemoteDataState; item: UsersMap}
checks: ChecksState
rules: NotificationRulesState
endpoints: NotificationEndpointsState
}
interface DispatchProps {
@ -70,6 +73,7 @@ interface DispatchProps {
getUsers: typeof getUsers
getChecks: typeof getChecks
getNotificationRules: typeof getNotificationRules
getEndpoints: typeof getEndpoints
}
interface PassedProps {
@ -92,6 +96,7 @@ export enum ResourceTypes {
Users = 'users',
Checks = 'checks',
NotificationRules = 'rules',
NotificationEndpoints = 'endpoints',
}
@ErrorHandling
@ -150,6 +155,10 @@ class GetResources extends PureComponent<Props, StateProps> {
return await this.props.getNotificationRules()
}
case ResourceTypes.NotificationEndpoints: {
return await this.props.getEndpoints()
}
default: {
throw new Error('incorrect resource type provided')
}
@ -183,6 +192,7 @@ const mstp = ({
members,
checks,
rules,
endpoints,
}: AppState): StateProps => {
return {
labels,
@ -198,6 +208,7 @@ const mstp = ({
users: members.users,
checks,
rules,
endpoints,
}
}
@ -215,6 +226,7 @@ const mdtp = {
getUsers: getUsers,
getChecks: getChecks,
getNotificationRules: getNotificationRules,
getEndpoints: getEndpoints,
}
export default connect<StateProps, DispatchProps, {}>(

View File

@ -190,3 +190,7 @@ $notification-margin: 12px;
$ix-link-default-hover
);
}
.endpoint-description--textarea {
max-height: 150;
}

View File

@ -33,6 +33,7 @@ import {autoRefreshReducer} from 'src/shared/reducers/autoRefresh'
import {limitsReducer, LimitsState} from 'src/cloud/reducers/limits'
import checksReducer from 'src/alerting/reducers/checks'
import rulesReducer from 'src/alerting/reducers/notifications/rules'
import endpointsReducer from 'src/alerting/reducers/notifications/endpoints'
// Types
import {LocalStorage} from 'src/types/localStorage'
@ -66,6 +67,7 @@ export const rootReducer = combineReducers<ReducerState>({
cloud: combineReducers<{limits: LimitsState}>({limits: limitsReducer}),
checks: checksReducer,
rules: rulesReducer,
endpoints: endpointsReducer,
VERSION: () => '',
})

View File

@ -124,6 +124,7 @@
@import 'src/timeMachine/components/AddCheckDialog.scss';
@import 'src/alerting/components/SentTableField.scss';
@import 'src/alerting/components/notifications/RuleOverlayFooter.scss';
@import 'src/alerting/components/endpoints/EndpointOverlay.scss';
// External
@import '../../node_modules/@influxdata/react-custom-scrollbars/dist/styles.css';

View File

@ -61,12 +61,16 @@ export {
NotificationEndpoint,
NotificationRuleBase,
NotificationRule,
NotificationEndpointType,
SMTPNotificationRuleBase,
SlackNotificationRuleBase,
PagerDutyNotificationRuleBase,
SMTPNotificationRule,
SlackNotificationRule,
PagerDutyNotificationRule,
PagerDutyNotificationEndpoint,
SlackNotificationEndpoint,
WebhookNotificationEndpoint,
} from '../client'
import {Check, Threshold} from '../client'

View File

@ -26,6 +26,7 @@ import {AutoRefreshState} from 'src/shared/reducers/autoRefresh'
import {LimitsState} from 'src/cloud/reducers/limits'
import {ChecksState} from 'src/alerting/reducers/checks'
import {NotificationRulesState} from 'src/alerting/reducers/notifications/rules'
import {NotificationEndpointsState} from 'src/alerting/reducers/notifications/endpoints'
export interface AppState {
VERSION: string
@ -57,6 +58,7 @@ export interface AppState {
cloud: {limits: LimitsState}
checks: ChecksState
rules: NotificationRulesState
endpoints: NotificationEndpointsState
}
export type GetState = () => AppState