Merge pull request #3118 from influxdata/bugfix/flash-notifications

Bugfix/flash notifications
pull/10616/head
Andrew Watkins 2018-04-04 11:13:17 -07:00 committed by GitHub
commit 4438d982aa
14 changed files with 260 additions and 114 deletions

View File

@ -114,7 +114,8 @@ func TestServer(t *testing.T) {
"users": "/chronograf/v1/sources/5000/users",
"roles": "/chronograf/v1/sources/5000/roles",
"databases": "/chronograf/v1/sources/5000/dbs",
"annotations": "/chronograf/v1/sources/5000/annotations"
"annotations": "/chronograf/v1/sources/5000/annotations",
"health": "/chronograf/v1/sources/5000/health"
}
}
`,
@ -300,7 +301,8 @@ func TestServer(t *testing.T) {
"users": "/chronograf/v1/sources/5000/users",
"roles": "/chronograf/v1/sources/5000/roles",
"databases": "/chronograf/v1/sources/5000/dbs",
"annotations": "/chronograf/v1/sources/5000/annotations"
"annotations": "/chronograf/v1/sources/5000/annotations",
"health": "/chronograf/v1/sources/5000/health"
}
}
]

View File

@ -155,6 +155,7 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
router.GET("/chronograf/v1/sources/:id", EnsureViewer(service.SourcesID))
router.PATCH("/chronograf/v1/sources/:id", EnsureEditor(service.UpdateSource))
router.DELETE("/chronograf/v1/sources/:id", EnsureEditor(service.RemoveSource))
router.GET("/chronograf/v1/sources/:id/health", EnsureViewer(service.SourceHealth))
// IFQL
router.GET("/chronograf/v1/ifql", EnsureViewer(service.IFQL))

View File

@ -25,6 +25,7 @@ type sourceLinks struct {
Roles string `json:"roles,omitempty"` // URL for all users associated with this source
Databases string `json:"databases"` // URL for the databases contained within this source
Annotations string `json:"annotations"` // URL for the annotations of this source
Health string `json:"health"` // URL for source health
}
type sourceResponse struct {
@ -55,6 +56,7 @@ func newSourceResponse(src chronograf.Source) sourceResponse {
Users: fmt.Sprintf("%s/%d/users", httpAPISrcs, src.ID),
Databases: fmt.Sprintf("%s/%d/dbs", httpAPISrcs, src.ID),
Annotations: fmt.Sprintf("%s/%d/annotations", httpAPISrcs, src.ID),
Health: fmt.Sprintf("%s/%d/health", httpAPISrcs, src.ID),
},
}
@ -192,6 +194,38 @@ func (s *Service) RemoveSource(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// SourceHealth determines if the tsdb is running
func (s *Service) SourceHealth(w http.ResponseWriter, r *http.Request) {
id, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger)
return
}
ctx := r.Context()
src, err := s.Store.Sources(ctx).Get(ctx, id)
if err != nil {
notFound(w, id, s.Logger)
return
}
cli := &influx.Client{
Logger: s.Logger,
}
if err := cli.Connect(ctx, &src); err != nil {
Error(w, http.StatusBadRequest, "Error contacting source", s.Logger)
return
}
if err := cli.Ping(ctx); err != nil {
Error(w, http.StatusBadRequest, "Error contacting source", s.Logger)
return
}
w.WriteHeader(http.StatusOK)
}
// removeSrcsKapa will remove all kapacitors and kapacitor rules from the stores.
// However, it will not remove the kapacitor tickscript from kapacitor itself.
func (s *Service) removeSrcsKapa(ctx context.Context, srcID int) error {

View File

@ -183,6 +183,7 @@ func Test_newSourceResponse(t *testing.T) {
Permissions: "/chronograf/v1/sources/1/permissions",
Databases: "/chronograf/v1/sources/1/dbs",
Annotations: "/chronograf/v1/sources/1/annotations",
Health: "/chronograf/v1/sources/1/health",
},
},
},
@ -207,6 +208,7 @@ func Test_newSourceResponse(t *testing.T) {
Permissions: "/chronograf/v1/sources/1/permissions",
Databases: "/chronograf/v1/sources/1/dbs",
Annotations: "/chronograf/v1/sources/1/annotations",
Health: "/chronograf/v1/sources/1/health",
},
},
},

View File

@ -387,6 +387,39 @@
}
}
},
"/sources/{id}/health": {
"get": {
"tags": ["sources"],
"summary": "Health check for source",
"description": "Returns if the tsdb source can be contacted",
"parameters": [
{
"name": "id",
"in": "path",
"type": "string",
"description": "ID of the data source",
"required": true
}
],
"responses": {
"200": {
"description": "Source was able to be contacted"
},
"404": {
"description": "Source could not be contacted",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "A processing or an unexpected error.",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/sources/{id}/permissions": {
"get": {
"tags": ["sources", "users"],
@ -3685,7 +3718,8 @@
"queries": "/chronograf/v1/sources/4/queries",
"permissions": "/chronograf/v1/sources/4/permissions",
"users": "/chronograf/v1/sources/4/users",
"roles": "/chronograf/v1/sources/4/roles"
"roles": "/chronograf/v1/sources/4/roles",
"health": "/chronograf/v1/sources/4/health"
}
},
"required": ["url"],
@ -3792,6 +3826,11 @@
"description":
"Optional path to the roles endpoint IFF it is supported on this source",
"format": "url"
},
"health": {
"type": "string",
"description": "Path to determine if source is healthy",
"format": "url"
}
}
}

5
ui/mocks/MockChild.tsx Normal file
View File

@ -0,0 +1,5 @@
import React, {SFC} from 'react'
const MockChild: SFC = () => <div data-test="mock-child" />
export default MockChild

View File

@ -0,0 +1 @@
export const getSourceHealth = () => Promise.resolve()

View File

@ -1,6 +1,6 @@
import React, {Component} from 'react'
import React, {ReactElement, Component} from 'react'
import PropTypes from 'prop-types'
import {withRouter} from 'react-router'
import {withRouter, Params, Router, Location} from 'react-router'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
@ -11,26 +11,57 @@ import {
ADMIN_ROLE,
} from 'src/auth/Authorized'
import {showDatabases} from 'shared/apis/metaQuery'
import {getSourceHealth} from 'src/sources/apis'
import {getSourcesAsync} from 'src/shared/actions/sources'
import {getSourcesAsync} from 'shared/actions/sources'
import {errorThrown as errorThrownAction} from 'shared/actions/errors'
import {notify as notifyAction} from 'shared/actions/notifications'
import {errorThrown as errorThrownAction} from 'src/shared/actions/errors'
import {notify as notifyAction} from 'src/shared/actions/notifications'
import {DEFAULT_HOME_PAGE} from 'shared/constants'
import {
notifySourceNoLongerAvailable,
notifyNoSourcesAvailable,
notifyUnableToRetrieveSources,
notifyUserRemovedFromAllOrgs,
notifyUserRemovedFromCurrentOrg,
notifyOrgHasNoSources,
} from 'shared/copy/notifications'
import {DEFAULT_HOME_PAGE} from 'src/shared/constants'
import * as copy from 'src/shared/copy/notifications'
import {Source, Me} from 'src/types'
interface Auth {
isUsingAuth: boolean
me: Me
}
interface State {
isFetching: boolean
}
interface Props {
getSources: () => void
sources: Source[]
children: ReactElement<any>
params: Params
router: Router
location: Location
auth: Auth
notify: () => void
errorThrown: () => void
}
// Acts as a 'router middleware'. The main `App` component is responsible for
// getting the list of data nodes, but not every page requires them to function.
// Routes that do require data nodes can be nested under this component.
class CheckSources extends Component {
// getting the list of data sources, but not every page requires them to function.
// Routes that do require data sources can be nested under this component.
export class CheckSources extends Component<Props, State> {
public static childContextTypes = {
source: PropTypes.shape({
links: PropTypes.shape({
proxy: PropTypes.string.isRequired,
self: PropTypes.string.isRequired,
kapacitors: PropTypes.string.isRequired,
queries: PropTypes.string.isRequired,
permissions: PropTypes.string.isRequired,
users: PropTypes.string.isRequired,
databases: PropTypes.string.isRequired,
}).isRequired,
}),
}
constructor(props) {
super(props)
@ -39,14 +70,13 @@ class CheckSources extends Component {
}
}
getChildContext() {
const {sources, params: {sourceID}} = this.props
return {source: sources.find(s => s.id === sourceID)}
public getChildContext() {
const {sources, params} = this.props
return {source: sources.find(s => s.id === params.sourceID)}
}
async componentWillMount() {
public async componentWillMount() {
const {router, auth: {isUsingAuth, me}} = this.props
if (!isUsingAuth || isUserAuthorized(me.role, VIEWER_ROLE)) {
await this.props.getSources()
this.setState({isFetching: false})
@ -56,21 +86,20 @@ class CheckSources extends Component {
}
}
shouldComponentUpdate(nextProps) {
const {auth: {isUsingAuth, me}} = nextProps
// don't update this component if currentOrganization is what has changed,
// or else the app will try to call showDatabases in componentWillUpdate,
// which will fail unless sources have been refreshed
public shouldComponentUpdate(nextProps) {
const {location} = nextProps
if (
isUsingAuth &&
me.currentOrganization.id !== this.props.auth.me.currentOrganization.id
!this.state.isFetching &&
this.props.location.pathname === location.pathname
) {
return false
}
return true
}
async componentWillUpdate(nextProps, nextState) {
public async componentWillUpdate(nextProps, nextState) {
const {
router,
location,
@ -79,7 +108,6 @@ class CheckSources extends Component {
sources,
auth: {isUsingAuth, me, me: {organizations = [], currentOrganization}},
notify,
getSources,
} = nextProps
const {isFetching} = nextState
const source = sources.find(s => s.id === params.sourceID)
@ -93,7 +121,7 @@ class CheckSources extends Component {
}
if (!isFetching && isUsingAuth && !organizations.length) {
notify(notifyUserRemovedFromAllOrgs())
notify(copy.notifyUserRemovedFromAllOrgs())
return router.push('/purgatory')
}
@ -101,7 +129,7 @@ class CheckSources extends Component {
me.superAdmin &&
!organizations.find(o => o.id === currentOrganization.id)
) {
notify(notifyUserRemovedFromCurrentOrg())
notify(copy.notifyUserRemovedFromCurrentOrg())
return router.push('/purgatory')
}
@ -123,7 +151,7 @@ class CheckSources extends Component {
return router.push(`/sources/${sources[0].id}/${restString}`)
}
// if you're a viewer and there are no sources, go to purgatory.
notify(notifyOrgHasNoSources())
notify(copy.notifyOrgHasNoSources())
return router.push('/purgatory')
}
@ -139,27 +167,15 @@ class CheckSources extends Component {
}
if (!isFetching && !location.pathname.includes('/manage-sources')) {
// Do simple query to proxy to see if the source is up.
try {
// the guard around currentOrganization prevents this showDatabases
// invocation since sources haven't been refreshed yet
await showDatabases(source.links.proxy)
await getSourceHealth(source.links.health)
} catch (error) {
try {
const newSources = await getSources()
if (newSources.length) {
errorThrown(error, notifySourceNoLongerAvailable(source.name))
} else {
errorThrown(error, notifyNoSourcesAvailable(source.name))
}
} catch (error2) {
errorThrown(error2, notifyUnableToRetrieveSources())
}
errorThrown(error, copy.notifySourceNoLongerAvailable(source.name))
}
}
}
render() {
public render() {
const {
params,
sources,
@ -176,69 +192,11 @@ class CheckSources extends Component {
return (
this.props.children &&
React.cloneElement(
this.props.children,
Object.assign({}, this.props, {
source,
})
)
React.cloneElement(this.props.children, {...this.props, source})
)
}
}
const {arrayOf, bool, func, node, shape, string} = PropTypes
CheckSources.propTypes = {
getSources: func.isRequired,
sources: arrayOf(
shape({
links: shape({
proxy: string.isRequired,
self: string.isRequired,
kapacitors: string.isRequired,
queries: string.isRequired,
permissions: string.isRequired,
users: string.isRequired,
databases: string.isRequired,
}).isRequired,
})
),
children: node,
params: shape({
sourceID: string,
}).isRequired,
router: shape({
push: func.isRequired,
}).isRequired,
location: shape({
pathname: string.isRequired,
}).isRequired,
auth: shape({
isUsingAuth: bool,
me: shape({
currentOrganization: shape({
name: string.isRequired,
id: string.isRequired,
}),
}),
}),
notify: func.isRequired,
}
CheckSources.childContextTypes = {
source: shape({
links: shape({
proxy: string.isRequired,
self: string.isRequired,
kapacitors: string.isRequired,
queries: string.isRequired,
permissions: string.isRequired,
users: string.isRequired,
databases: string.isRequired,
}).isRequired,
}),
}
const mapStateToProps = ({sources, auth}) => ({
sources,
auth,

View File

@ -133,7 +133,7 @@ export const notifySourceDeleteFailed = sourceName => ({
})
export const notifySourceNoLongerAvailable = sourceName =>
`Source ${sourceName} is no longer available. Successfully connected to another source.`
`Source ${sourceName} is no longer available. Please ensure InfluxDB is running.`
export const notifyNoSourcesAvailable = sourceName =>
`Unable to connect to source ${sourceName}. No other sources available.`

View File

@ -0,0 +1,10 @@
import AJAX from 'src/utils/ajax'
export const getSourceHealth = async (url: string) => {
try {
await AJAX({url})
} catch (error) {
console.error(`Unable to contact source ${url}`, error)
throw error
}
}

View File

@ -7,6 +7,11 @@ export interface Organization {
name: string
}
export interface Me {
currentOrganization?: Organization
role: Role
}
export interface Role {
name: string
organization: string

View File

@ -1,10 +1,11 @@
import {AuthLinks, Organization, Role, User} from './auth'
import {AuthLinks, Organization, Role, User, Me} from './auth'
import {AlertRule, Kapacitor} from './kapacitor'
import {Query, QueryConfig} from './query'
import {Source} from './sources'
import {DropdownAction, DropdownItem} from './shared'
export {
Me,
AuthLinks,
Role,
User,

View File

@ -0,0 +1,68 @@
import React from 'react'
import {shallow} from 'enzyme'
import {CheckSources} from 'src/CheckSources'
import MockChild from 'mocks/MockChild'
import {source} from 'test/resources'
jest.mock('src/sources/apis', () => require('mocks/sources/apis'))
const getSources = jest.fn(() => Promise.resolve)
const setup = (override?) => {
const props = {
getSources,
sources: [source],
params: {
sourceID: source.id,
},
router: {},
location: {
pathname: 'sources',
},
auth: {
isUsingAuth: false,
me: {},
},
notify: () => {},
errorThrown: () => {},
...override,
}
const wrapper = shallow(
<CheckSources {...props}>
<MockChild />
</CheckSources>
)
return {
wrapper,
props,
}
}
describe('CheckSources', () => {
describe('rendering', () => {
it('renders', async () => {
const {wrapper} = setup()
expect(wrapper.exists()).toBe(true)
})
it('renders a spinner when the component is fetching', () => {
const {wrapper} = setup()
const spinner = wrapper.find('.page-spinner')
expect(spinner.exists()).toBe(true)
})
it('renders its children when it is done fetching', () => {
const {wrapper} = setup()
// ensure that assertion runs after async behavior of getSources
process.nextTick(() => {
wrapper.update()
const child = wrapper.find(MockChild)
expect(child.exists()).toBe(true)
})
})
})
})

View File

@ -1,3 +1,22 @@
export const role = {
name: '',
organization: '',
}
export const currentOrganization = {
name: '',
defaultRole: '',
id: '',
links: {
self: '',
},
}
export const me = {
currentOrganization,
role,
}
export const links = {
self: '/chronograf/v1/sources/16',
kapacitors: '/chronograf/v1/sources/16/kapacitors',
@ -7,6 +26,7 @@ export const links = {
permissions: '/chronograf/v1/sources/16/permissions',
users: '/chronograf/v1/sources/16/users',
databases: '/chronograf/v1/sources/16/dbs',
health: '/chronograf/v1/sources/16/health',
}
export const source = {