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 = {