diff --git a/integrations/server_test.go b/integrations/server_test.go index 9cd271ac5b..79cc78c144 100644 --- a/integrations/server_test.go +++ b/integrations/server_test.go @@ -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" } } ] diff --git a/server/mux.go b/server/mux.go index 11730293f4..a41d49b9e6 100644 --- a/server/mux.go +++ b/server/mux.go @@ -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)) diff --git a/server/sources.go b/server/sources.go index ddb7eac76b..3c88f294c9 100644 --- a/server/sources.go +++ b/server/sources.go @@ -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 { diff --git a/server/sources_test.go b/server/sources_test.go index f0da2cc4d2..1842a1ae32 100644 --- a/server/sources_test.go +++ b/server/sources_test.go @@ -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", }, }, }, diff --git a/server/swagger.json b/server/swagger.json index e8208779b0..08325aff1b 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -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" } } } diff --git a/ui/mocks/MockChild.tsx b/ui/mocks/MockChild.tsx new file mode 100644 index 0000000000..62f5386009 --- /dev/null +++ b/ui/mocks/MockChild.tsx @@ -0,0 +1,5 @@ +import React, {SFC} from 'react' + +const MockChild: SFC = () =>
+ +export default MockChild diff --git a/ui/mocks/sources/apis/index.ts b/ui/mocks/sources/apis/index.ts new file mode 100644 index 0000000000..d233b2c8ca --- /dev/null +++ b/ui/mocks/sources/apis/index.ts @@ -0,0 +1 @@ +export const getSourceHealth = () => Promise.resolve() diff --git a/ui/src/CheckSources.js b/ui/src/CheckSources.tsx similarity index 52% rename from ui/src/CheckSources.js rename to ui/src/CheckSources.tsx index 7a8a84cf45..a0289076dc 100644 --- a/ui/src/CheckSources.js +++ b/ui/src/CheckSources.tsx @@ -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 + 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 { + 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, diff --git a/ui/src/shared/copy/notifications.js b/ui/src/shared/copy/notifications.js index 3858953126..4a0f5dc19e 100644 --- a/ui/src/shared/copy/notifications.js +++ b/ui/src/shared/copy/notifications.js @@ -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.` diff --git a/ui/src/sources/apis/index.ts b/ui/src/sources/apis/index.ts new file mode 100644 index 0000000000..9c528aa71e --- /dev/null +++ b/ui/src/sources/apis/index.ts @@ -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 + } +} diff --git a/ui/src/types/auth.ts b/ui/src/types/auth.ts index 62ca59ca69..e87cb52385 100644 --- a/ui/src/types/auth.ts +++ b/ui/src/types/auth.ts @@ -7,6 +7,11 @@ export interface Organization { name: string } +export interface Me { + currentOrganization?: Organization + role: Role +} + export interface Role { name: string organization: string diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 09d26f4b4b..8a85272704 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -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, diff --git a/ui/test/CheckSource.test.tsx b/ui/test/CheckSource.test.tsx new file mode 100644 index 0000000000..c52d65140a --- /dev/null +++ b/ui/test/CheckSource.test.tsx @@ -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( + + + + ) + + 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) + }) + }) + }) +}) diff --git a/ui/test/resources.ts b/ui/test/resources.ts index b77dcbb2b5..22170ba655 100644 --- a/ui/test/resources.ts +++ b/ui/test/resources.ts @@ -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 = {