diff --git a/http/swagger.yml b/http/swagger.yml index 2399fbadd4..5a6577eabb 100644 --- a/http/swagger.yml +++ b/http/swagger.yml @@ -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 diff --git a/ui/cypress/e2e/dashboardsIndex.test.ts b/ui/cypress/e2e/dashboardsIndex.test.ts index b7878bc862..354eee0d48 100644 --- a/ui/cypress/e2e/dashboardsIndex.test.ts +++ b/ui/cypress/e2e/dashboardsIndex.test.ts @@ -13,7 +13,7 @@ describe('Dashboards', () => { }) cy.fixture('routes').then(({orgs}) => { - cy.get('@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('@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('@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('@org').then(({id}) => { + cy.get('@org').then(({id}: Organization) => { cy.createDashboardTemplate(id) }) @@ -70,7 +70,7 @@ describe('Dashboards', () => { describe('Dashboard List', () => { beforeEach(() => { - cy.get('@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('@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('@org').then(({id}) => { + cy.get('@org').then(({id}: Organization) => { cy.createLabel(labelName, id).then(() => { cy.getByTestID(`inline-labels--add`) .first() diff --git a/ui/cypress/e2e/notificationEndpoints.test.ts b/ui/cypress/e2e/notificationEndpoints.test.ts new file mode 100644 index 0000000000..5a61b5df41 --- /dev/null +++ b/ui/cypress/e2e/notificationEndpoints.test.ts @@ -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') + }) +}) diff --git a/ui/cypress/index.d.ts b/ui/cypress/index.d.ts index eb6dae36c4..21aba496d9 100644 --- a/ui/cypress/index.d.ts +++ b/ui/cypress/index.d.ts @@ -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 } } } diff --git a/ui/cypress/support/commands.ts b/ui/cypress/support/commands.ts index a5564d0096..7a9ea47e07 100644 --- a/ui/cypress/support/commands.ts +++ b/ui/cypress/support/commands.ts @@ -1,3 +1,5 @@ +import {NotificationEndpoint} from '../../src/types' + export const signin = (): Cypress.Chainable => { 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 => { + 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) diff --git a/ui/src/alerting/actions/notifications/endpoints.ts b/ui/src/alerting/actions/notifications/endpoints.ts new file mode 100644 index 0000000000..52d13cb980 --- /dev/null +++ b/ui/src/alerting/actions/notifications/endpoints.ts @@ -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, + 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 +) => { + 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, + }) +} diff --git a/ui/src/alerting/components/EndpointsColumn.tsx b/ui/src/alerting/components/EndpointsColumn.tsx index 5021bc9dff..db6348ea2d 100644 --- a/ui/src/alerting/components/EndpointsColumn.tsx +++ b/ui/src/alerting/components/EndpointsColumn.tsx @@ -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 = ({router, params, endpoints}) => { + const handleOpenOverlay = () => { + const newRuleRoute = `/orgs/${params.orgID}/alerting/endpoints/new` + router.push(newRuleRoute) + } -const EndpointsColumn: FunctionComponent = () => { return ( {}} + onCreate={handleOpenOverlay} > - - -
- - Documentation - -
+
) } -export default EndpointsColumn +const mstp = ({endpoints}: AppState) => { + return {endpoints: endpoints.list} +} + +export default connect(mstp)(withRouter(EndpointsColumn)) diff --git a/ui/src/alerting/components/endpoints/EndpointCard.tsx b/ui/src/alerting/components/endpoints/EndpointCard.tsx new file mode 100644 index 0000000000..675902a049 --- /dev/null +++ b/ui/src/alerting/components/endpoints/EndpointCard.tsx @@ -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 = ({endpoint}) => { + const {id, name, status} = endpoint + + const handleUpdateName = () => console.trace('implement update endpoint name') + const handleClick = () => console.trace('implement click endpoint name') + + const nameComponent = ( + + ) + + const handleToggle = () => console.trace('implement toggle status') + const toggle = ( + + ) + + const handleDelete = () => console.trace('implement delete') + const handleExport = () => console.trace('implement export') + const handleClone = () => console.trace('implement delete') + const contextMenu = ( + + ) + + return ( + {endpoint.updatedAt}]} + testID={`endpoint-card ${name}`} + /> + ) +} + +export default EndpointCard diff --git a/ui/src/alerting/components/endpoints/EndpointCardMenu.tsx b/ui/src/alerting/components/endpoints/EndpointCardMenu.tsx new file mode 100644 index 0000000000..a6b356643b --- /dev/null +++ b/ui/src/alerting/components/endpoints/EndpointCardMenu.tsx @@ -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 = ({onDelete, onClone, onExport}) => { + return ( + + + + + + + + + + + + ) +} + +export default EndpointCardContext diff --git a/ui/src/alerting/components/endpoints/EndpointCards.tsx b/ui/src/alerting/components/endpoints/EndpointCards.tsx new file mode 100644 index 0000000000..331b38bd53 --- /dev/null +++ b/ui/src/alerting/components/endpoints/EndpointCards.tsx @@ -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 = ({endpoints}) => { + const cards = endpoints.map(endpoint => ( + + )) + + return ( + + }> + {cards} + + + ) +} + +const EmptyEndpointList: FC = () => ( + + +
+ + Documentation + +
+) + +export default EndpointCards diff --git a/ui/src/alerting/components/endpoints/EndpointOptions.tsx b/ui/src/alerting/components/endpoints/EndpointOptions.tsx new file mode 100644 index 0000000000..313d01cb0b --- /dev/null +++ b/ui/src/alerting/components/endpoints/EndpointOptions.tsx @@ -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) => void +} + +const EndpointOptions: FC = ({endpoint, onChange}) => { + switch (endpoint.type) { + case 'slack': { + const {url, token} = endpoint as SlackNotificationEndpoint + return ( + + ) + } + case 'pagerduty': { + const {url, routingKey} = endpoint as PagerDutyNotificationEndpoint + return ( + + ) + } + 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 ( + + ) + } + + default: + throw new Error( + `Unknown endpoint type for endpoint: ${JSON.stringify( + endpoint, + null, + 2 + )}` + ) + } +} + +export default EndpointOptions diff --git a/ui/src/alerting/components/endpoints/EndpointOptionsPagerDuty.tsx b/ui/src/alerting/components/endpoints/EndpointOptionsPagerDuty.tsx new file mode 100644 index 0000000000..f38afdc573 --- /dev/null +++ b/ui/src/alerting/components/endpoints/EndpointOptionsPagerDuty.tsx @@ -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) => void +} + +const EndpointOptionsPagerDuty: FC = ({url, routingKey, onChange}) => { + return ( + <> + + + + + + + + ) +} + +export default EndpointOptionsPagerDuty diff --git a/ui/src/alerting/components/endpoints/EndpointOptionsSlack.tsx b/ui/src/alerting/components/endpoints/EndpointOptionsSlack.tsx new file mode 100644 index 0000000000..fa8d03fc2c --- /dev/null +++ b/ui/src/alerting/components/endpoints/EndpointOptionsSlack.tsx @@ -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) => void +} + +const EndpointOptionsSlack: FC = ({url, token, onChange}) => { + return ( + <> + + + + + + + + ) +} + +export default EndpointOptionsSlack diff --git a/ui/src/alerting/components/endpoints/EndpointOptionsWebhook.tsx b/ui/src/alerting/components/endpoints/EndpointOptionsWebhook.tsx new file mode 100644 index 0000000000..f5af2bcbd8 --- /dev/null +++ b/ui/src/alerting/components/endpoints/EndpointOptionsWebhook.tsx @@ -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 = ({url, token}) => { + return ( + <> + + + + + + + + + + + + + {/** add dropdowns for method and authmethod */} + + + + + ) +} + +export default EndpointOptionsWebhook diff --git a/ui/src/alerting/components/endpoints/EndpointOverlay.reducer.ts b/ui/src/alerting/components/endpoints/EndpointOverlay.reducer.ts new file mode 100644 index 0000000000..8b762fb599 --- /dev/null +++ b/ui/src/alerting/components/endpoints/EndpointOverlay.reducer.ts @@ -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` + ) + } +} diff --git a/ui/src/alerting/components/endpoints/EndpointOverlay.scss b/ui/src/alerting/components/endpoints/EndpointOverlay.scss new file mode 100644 index 0000000000..2e82a21c42 --- /dev/null +++ b/ui/src/alerting/components/endpoints/EndpointOverlay.scss @@ -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; +} diff --git a/ui/src/alerting/components/endpoints/EndpointOverlayContents.tsx b/ui/src/alerting/components/endpoints/EndpointOverlayContents.tsx new file mode 100644 index 0000000000..4aeefee63d --- /dev/null +++ b/ui/src/alerting/components/endpoints/EndpointOverlayContents.tsx @@ -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 + saveButtonText: string +} + +const EndpointOverlayContents: FC = ({onSave, saveButtonText}) => { + const [endpoint, dispatch] = useEndpointReducer() + const handleChange = ( + e: ChangeEvent + ) => { + 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 ( + +
+ + + + + + + + +