Merge pull request #3118 from influxdata/bugfix/flash-notifications
Bugfix/flash notificationspull/10616/head
commit
4438d982aa
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import React, {SFC} from 'react'
|
||||
|
||||
const MockChild: SFC = () => <div data-test="mock-child" />
|
||||
|
||||
export default MockChild
|
|
@ -0,0 +1 @@
|
|||
export const getSourceHealth = () => Promise.resolve()
|
|
@ -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,
|
|
@ -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.`
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -7,6 +7,11 @@ export interface Organization {
|
|||
name: string
|
||||
}
|
||||
|
||||
export interface Me {
|
||||
currentOrganization?: Organization
|
||||
role: Role
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
name: string
|
||||
organization: string
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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 = {
|
||||
|
|
Loading…
Reference in New Issue