diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cfd8f037..bbd1f62f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Bug Fixes 1. [#1530](https://github.com/influxdata/chronograf/pull/1530): Update query config field ordering to always match input query 1. [#1535](https://github.com/influxdata/chronograf/pull/1535): Fix add field functions to existing Kapacitor rules +1. [#1564](https://github.com/influxdata/chronograf/pull/1564): Fix regression of logout menu item functionality 1. [#1562](https://github.com/influxdata/chronograf/pull/1562): Fix InfluxQL parsing with multiple tag values for a tag key ### Features @@ -14,6 +15,7 @@ 1. [#1549](https://github.com/influxdata/chronograf/pull/1549): Reset graph zoom when a new time range is selected 1. [#1544](https://github.com/influxdata/chronograf/pull/1544): Upgrade to new version of Influx Theme, remove excess stylesheets 1. [#1567](https://github.com/influxdata/chronograf/pull/1567): Replace outline style User icon with solid style + 1. [#1560](https://github.com/influxdata/chronograf/pull/1560): Apply mean to fields by default 1. [#1561](https://github.com/influxdata/chronograf/pull/1561): Disable query save in dashboard editing if the query does not have a database, measurement, and field ## v1.3.1.0 [2017-05-22] diff --git a/etc/build.py b/etc/build.py index d4b3edfa6..3ffc229d2 100755 --- a/etc/build.py +++ b/etc/build.py @@ -900,11 +900,11 @@ def main(args): lines.sort() print ("## Docker") - print("docker pull quay.io/influxdb/chronograf:"+get_current_version_tag()) + print("`docker pull quay.io/influxdb/chronograf:"+get_current_version_tag() + "`") print("") print("## Packages") print("") - print("Arch | Package | SHA256") + print("Platform | Package | SHA256") print("--- | --- | ---") for line in lines: print(line) diff --git a/server/mux.go b/server/mux.go index 9613ea09c..91c22bcfc 100644 --- a/server/mux.go +++ b/server/mux.go @@ -179,30 +179,34 @@ func NewMux(opts MuxOpts, service Service) http.Handler { router.PUT("/chronograf/v1/sources/:id/dbs/:dbid/rps/:rpid", service.UpdateRetentionPolicy) router.DELETE("/chronograf/v1/sources/:id/dbs/:dbid/rps/:rpid", service.DropRetentionPolicy) - var authRoutes AuthRoutes + + allRoutes := &AllRoutes{ + Logger: opts.Logger, + } + + router.Handler("GET", "/chronograf/v1/", allRoutes) + var out http.Handler - /* Authentication */ - logout := "/oauth/logout" basepath := "" if opts.PrefixRoutes { basepath = opts.Basepath } + + /* Authentication */ if opts.UseAuth { // Encapsulate the router with OAuth2 var auth http.Handler - auth, authRoutes = AuthAPI(opts, router) + auth, allRoutes.AuthRoutes = AuthAPI(opts, router) + allRoutes.LogoutLink = "/oauth/logout" - // Create middleware to redirect to the appropriate provider logout - targetURL := "/" - router.GET(logout, Logout(targetURL, basepath, authRoutes)) + // Create middleware that redirects to the appropriate provider logout + router.GET(allRoutes.LogoutLink, Logout("/", basepath, allRoutes.AuthRoutes)) out = Logger(opts.Logger, PrefixedRedirect(opts.Basepath, auth)) } else { out = Logger(opts.Logger, PrefixedRedirect(opts.Basepath, router)) } - router.GET("/chronograf/v1/", AllRoutes(authRoutes, path.Join(opts.Basepath, logout), opts.Logger)) - return out } diff --git a/server/routes.go b/server/routes.go index b17d203d1..d50116cac 100644 --- a/server/routes.go +++ b/server/routes.go @@ -38,26 +38,33 @@ type getRoutesResponse struct { Logout *string `json:"logout,omitempty"` // Location of the logout route for all auth routes } -// AllRoutes returns all top level routes within chronograf -func AllRoutes(authRoutes []AuthRoute, logout string, logger chronograf.Logger) http.HandlerFunc { +// AllRoutes is a handler that returns all links to resources in Chronograf server. +// Optionally, routes for authentication can be returned. +type AllRoutes struct { + AuthRoutes []AuthRoute // Location of all auth routes. If no auth, this can be empty. + LogoutLink string // Location of the logout route for all auth routes. If no auth, this can be empty. + Logger chronograf.Logger +} + +// ServeHTTP returns all top level routes within chronograf +func (a *AllRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) { routes := getRoutesResponse{ Sources: "/chronograf/v1/sources", Layouts: "/chronograf/v1/layouts", Me: "/chronograf/v1/me", Mappings: "/chronograf/v1/mappings", Dashboards: "/chronograf/v1/dashboards", - Auth: make([]AuthRoute, len(authRoutes)), - } - if logout != "" { - routes.Logout = &logout + Auth: make([]AuthRoute, len(a.AuthRoutes)), // We want to return at least an empty array, rather than null } - for i, route := range authRoutes { + // The JSON response will have no field present for the LogoutLink if there is no logout link. + if a.LogoutLink != "" { + routes.Logout = &a.LogoutLink + } + + for i, route := range a.AuthRoutes { routes.Auth[i] = route } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - encodeJSON(w, http.StatusOK, routes, logger) - return - }) + encodeJSON(w, http.StatusOK, routes, a.Logger) } diff --git a/server/routes_test.go b/server/routes_test.go index 02f776979..8e18fd0f8 100644 --- a/server/routes_test.go +++ b/server/routes_test.go @@ -11,10 +11,12 @@ import ( func TestAllRoutes(t *testing.T) { logger := log.New(log.DebugLevel) - handler := AllRoutes([]AuthRoute{}, "", logger) + handler := &AllRoutes{ + Logger: logger, + } req := httptest.NewRequest("GET", "http://docbrowns-inventions.com", nil) w := httptest.NewRecorder() - handler(w, req) + handler.ServeHTTP(w, req) resp := w.Result() body, err := ioutil.ReadAll(resp.Body) @@ -27,4 +29,47 @@ func TestAllRoutes(t *testing.T) { if err := json.Unmarshal(body, &routes); err != nil { t.Error("TestAllRoutes not able to unmarshal JSON response") } + want := `{"layouts":"/chronograf/v1/layouts","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[]} +` + if want != string(body) { + t.Errorf("TestAllRoutes\nwanted\n*%s*\ngot\n*%s*", want, string(body)) + } + +} + +func TestAllRoutesWithAuth(t *testing.T) { + logger := log.New(log.DebugLevel) + handler := &AllRoutes{ + AuthRoutes: []AuthRoute{ + { + Name: "github", + Label: "GitHub", + Login: "/oauth/github/login", + Logout: "/oauth/github/logout", + Callback: "/oauth/github/callback", + }, + }, + LogoutLink: "/oauth/logout", + Logger: logger, + } + req := httptest.NewRequest("GET", "http://docbrowns-inventions.com", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + body, err := ioutil.ReadAll(resp.Body) + defer resp.Body.Close() + + if err != nil { + t.Error("TestAllRoutesWithAuth not able to retrieve body") + } + var routes getRoutesResponse + if err := json.Unmarshal(body, &routes); err != nil { + t.Error("TestAllRoutesWithAuth not able to unmarshal JSON response") + } + want := `{"layouts":"/chronograf/v1/layouts","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[{"name":"github","label":"GitHub","login":"/oauth/github/login","logout":"/oauth/github/logout","callback":"/oauth/github/callback"}],"logout":"/oauth/logout"} +` + if want != string(body) { + t.Errorf("TestAllRoutesWithAuth\nwanted\n*%s*\ngot\n*%s*", want, string(body)) + } } diff --git a/ui/spec/data_explorer/reducers/queryConfigSpec.js b/ui/spec/data_explorer/reducers/queryConfigSpec.js index 99887a197..901a255a9 100644 --- a/ui/spec/data_explorer/reducers/queryConfigSpec.js +++ b/ui/spec/data_explorer/reducers/queryConfigSpec.js @@ -43,10 +43,13 @@ describe('Chronograf.Reducers.queryConfig', () => { }) it('sets the db and rp', () => { - const newState = reducer(state, chooseNamespace(queryId, { - database: 'telegraf', - retentionPolicy: 'monitor', - })) + const newState = reducer( + state, + chooseNamespace(queryId, { + database: 'telegraf', + retentionPolicy: 'monitor', + }) + ) expect(newState[queryId].database).to.equal('telegraf') expect(newState[queryId].retentionPolicy).to.equal('monitor') @@ -63,23 +66,33 @@ describe('Chronograf.Reducers.queryConfig', () => { let state beforeEach(() => { const one = reducer({}, fakeAddQueryAction('any', queryId)) - const two = reducer(one, chooseNamespace(queryId, { - database: '_internal', - retentionPolicy: 'daily', - })) + const two = reducer( + one, + chooseNamespace(queryId, { + database: '_internal', + retentionPolicy: 'daily', + }) + ) const three = reducer(two, chooseMeasurement(queryId, 'disk')) - state = reducer(three, toggleField(queryId, {field: 'a great field', funcs: []})) + state = reducer( + three, + toggleField(queryId, {field: 'a great field', funcs: []}) + ) }) describe('choosing a new namespace', () => { - it('clears out the old measurement and fields', () => { // what about tags? + it('clears out the old measurement and fields', () => { + // what about tags? expect(state[queryId].measurement).to.exist expect(state[queryId].fields.length).to.equal(1) - const newState = reducer(state, chooseNamespace(queryId, { - database: 'newdb', - retentionPolicy: 'newrp', - })) + const newState = reducer( + state, + chooseNamespace(queryId, { + database: 'newdb', + retentionPolicy: 'newrp', + }) + ) expect(newState[queryId].measurement).not.to.exist expect(newState[queryId].fields.length).to.equal(0) @@ -87,13 +100,19 @@ describe('Chronograf.Reducers.queryConfig', () => { }) describe('choosing a new measurement', () => { - it('leaves the namespace and clears out the old fields', () => { // what about tags? + it('leaves the namespace and clears out the old fields', () => { + // what about tags? expect(state[queryId].fields.length).to.equal(1) - const newState = reducer(state, chooseMeasurement(queryId, 'newmeasurement')) + const newState = reducer( + state, + chooseMeasurement(queryId, 'newmeasurement') + ) expect(state[queryId].database).to.equal(newState[queryId].database) - expect(state[queryId].retentionPolicy).to.equal(newState[queryId].retentionPolicy) + expect(state[queryId].retentionPolicy).to.equal( + newState[queryId].retentionPolicy + ) expect(newState[queryId].fields.length).to.equal(0) }) }) @@ -103,12 +122,50 @@ describe('Chronograf.Reducers.queryConfig', () => { expect(state[queryId].fields.length).to.equal(1) const isKapacitorRule = true - const newState = reducer(state, toggleField(queryId, {field: 'a different field', funcs: []}, isKapacitorRule)) + const newState = reducer( + state, + toggleField( + queryId, + {field: 'a different field', funcs: []}, + isKapacitorRule + ) + ) expect(newState[queryId].fields.length).to.equal(1) expect(newState[queryId].fields[0].field).to.equal('a different field') }) }) + + describe('TOGGLE_FIELDS', () => { + it('can toggle multiple fields', () => { + expect(state[queryId].fields.length).to.equal(1) + + const newState = reducer( + state, + toggleField(queryId, {field: 'a different field', funcs: []}) + ) + + expect(newState[queryId].fields.length).to.equal(2) + expect(newState[queryId].fields[1].field).to.equal('a different field') + }) + + it('applies a funcs to newly selected fields', () => { + expect(state[queryId].fields.length).to.equal(1) + + const oneFieldOneFunc = reducer( + state, + applyFuncsToField(queryId, {field: 'a great field', funcs: ['func1']}) + ) + + const newState = reducer( + oneFieldOneFunc, + toggleField(queryId, {field: 'a different field', funcs: []}) + ) + + expect(newState[queryId].fields[1].funcs.length).to.equal(1) + expect(newState[queryId].fields[1].funcs[0]).to.equal('func1') + }) + }) }) describe('APPLY_FUNCS_TO_FIELD', () => { @@ -192,7 +249,7 @@ describe('Chronograf.Reducers.queryConfig', () => { }) }) - it('creates a new entry if it\'s the first key', () => { + it("creates a new entry if it's the first key", () => { const initialState = { [queryId]: buildInitialState(queryId, { tags: {}, @@ -283,7 +340,9 @@ describe('Chronograf.Reducers.queryConfig', () => { const nextState = reducer(initialState, action) - expect(nextState[queryId].areTagsAccepted).to.equal(!initialState[queryId].areTagsAccepted) + expect(nextState[queryId].areTagsAccepted).to.equal( + !initialState[queryId].areTagsAccepted + ) }) }) @@ -314,7 +373,7 @@ describe('Chronograf.Reducers.queryConfig', () => { expect(nextState[queryId]).to.deep.equal(expected) }) - it('updates a query\'s raw text', () => { + it("updates a query's raw text", () => { const initialState = { [queryId]: buildInitialState(queryId), } @@ -326,7 +385,7 @@ describe('Chronograf.Reducers.queryConfig', () => { expect(nextState[queryId].rawText).to.equal('foo') }) - it('updates a query\'s raw status', () => { + it("updates a query's raw status", () => { const initialState = { [queryId]: buildInitialState(queryId), } diff --git a/ui/src/side_nav/components/NavItems.js b/ui/src/side_nav/components/NavItems.js index e7e3aa093..df43acea8 100644 --- a/ui/src/side_nav/components/NavItems.js +++ b/ui/src/side_nav/components/NavItems.js @@ -2,7 +2,7 @@ import React, {PropTypes} from 'react' import {Link} from 'react-router' import classnames from 'classnames' -const {node, string} = PropTypes +const {bool, node, string} = PropTypes const NavListItem = React.createClass({ propTypes: { @@ -16,7 +16,10 @@ const NavListItem = React.createClass({ const isActive = location.startsWith(link) return ( - + {children} ) @@ -27,13 +30,20 @@ const NavHeader = React.createClass({ propTypes: { link: string, title: string, + useAnchor: bool, }, render() { - return ( - -

{this.props.title}

- - ) + const {link, title, useAnchor} = this.props + + // Some nav items, such as Logout, need to hit an external link rather + // than simply route to an internal page. Anchor tags serve that purpose. + return useAnchor + ? +

{title}

+
+ : +

{title}

+ }, }) @@ -63,7 +73,9 @@ const NavBlock = React.createClass({ }) return ( -
+
{this.renderLink()}
diff --git a/ui/src/side_nav/containers/SideNav.js b/ui/src/side_nav/containers/SideNav.js index b558d13d3..c9144baf6 100644 --- a/ui/src/side_nav/containers/SideNav.js +++ b/ui/src/side_nav/containers/SideNav.js @@ -19,16 +19,12 @@ const SideNav = React.createClass({ location: shape({ pathname: string.isRequired, }).isRequired, - me: shape({ - email: string, - }), isHidden: bool.isRequired, logoutLink: string, }, render() { const { - me, params: {sourceID}, location: {pathname: location}, isHidden, @@ -37,7 +33,7 @@ const SideNav = React.createClass({ const sourcePrefix = `/sources/${sourceID}` const dataExplorerLink = `${sourcePrefix}/chronograf/data-explorer` - const showLogout = !!(me && me.name) + const showLogout = !!logoutLink return isHidden ? null @@ -81,7 +77,7 @@ const SideNav = React.createClass({ {showLogout ? - + : null} @@ -89,10 +85,9 @@ const SideNav = React.createClass({ }) const mapStateToProps = ({ - auth: {me, logoutLink}, + auth: {logoutLink}, app: {ephemeral: {inPresentationMode}}, }) => ({ - me, isHidden: inPresentationMode, logoutLink, }) diff --git a/ui/src/utils/queryTransitions.js b/ui/src/utils/queryTransitions.js index e8b292e99..03637bcd7 100644 --- a/ui/src/utils/queryTransitions.js +++ b/ui/src/utils/queryTransitions.js @@ -16,31 +16,44 @@ export function chooseMeasurement(query, measurement) { }) } -export function toggleField(query, {field, funcs}, isKapacitorRule = false) { +export const toggleField = (query, {field, funcs}, isKapacitorRule = false) => { const isSelected = query.fields.find(f => f.field === field) if (isSelected) { const nextFields = query.fields.filter(f => f.field !== field) if (!nextFields.length) { - const nextGroupBy = Object.assign({}, query.groupBy, {time: null}) - return Object.assign({}, query, { + const nextGroupBy = {...query.groupBy, time: null} + return { + ...query, fields: nextFields, groupBy: nextGroupBy, - }) + } } - return Object.assign({}, query, { + return { + ...query, fields: nextFields, - }) + } } if (isKapacitorRule) { - return Object.assign({}, query, { + return { + ...query, fields: [{field, funcs}], - }) + } + } + + let newFuncs = ['mean'] + if (query.fields.length) { + newFuncs = query.fields.find(f => f.funcs).funcs + } + + return { + ...query, + fields: query.fields.concat({ + field, + funcs: newFuncs, + }), } - return Object.assign({}, query, { - fields: query.fields.concat({field, funcs}), - }) } export function groupByTime(query, time) {