diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ea4ecd8f..81aad496d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,17 @@ 2. [#907](https://github.com/influxdata/chronograf/pull/907): Fix react-router warning ### Features - 1. [#873](https://github.com/influxdata/chronograf/pull/873): Add [TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) support + 1. [#873](https://github.com/influxdata/chronograf/pull/873): Add [TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) support + 2. [#885](https://github.com/influxdata/chronograf/issues/885): Add presentation mode to dashboard page + 3. [#891](https://github.com/influxdata/chronograf/issues/891): Make dashboard visualizations draggable + 4. [#892](https://github.com/influxdata/chronograf/issues/891): Make dashboard visualizations resizable + 5. [#893](https://github.com/influxdata/chronograf/issues/893): Persist dashboard visualization position + 6. [#922](https://github.com/influxdata/chronograf/issues/922): Additional OAuth2 support for Heroku and Google ### UI Improvements 1. [#905](https://github.com/influxdata/chronograf/pull/905): Make scroll bar thumb element bigger + 2. [#917](https://github.com/influxdata/chronograf/pull/917): Simplify side navigation + 3. [#920](https://github.com/influxdata/chronograf/pull/920): Display stacked and step plot graphs ## v1.2.0-beta3 [2017-02-15] diff --git a/Godeps b/Godeps index a81e679b1e..dadd63a428 100644 --- a/Godeps +++ b/Godeps @@ -15,3 +15,4 @@ github.com/sergi/go-diff 1d28411638c1e67fe1930830df207bef72496ae9 github.com/tylerb/graceful 50a48b6e73fcc75b45e22c05b79629a67c79e938 golang.org/x/net 749a502dd1eaf3e5bfd4f8956748c502357c0bbe golang.org/x/oauth2 1e695b1c8febf17aad3bfa7bf0a819ef94b98ad5 +google.golang.org/api bc20c61134e1d25265dd60049f5735381e79b631 diff --git a/LICENSE_OF_DEPENDENCIES.md b/LICENSE_OF_DEPENDENCIES.md index 0f96a24c34..73a7bc2523 100644 --- a/LICENSE_OF_DEPENDENCIES.md +++ b/LICENSE_OF_DEPENDENCIES.md @@ -16,6 +16,7 @@ * github.com/tylerb/graceful [MIT](https://github.com/tylerb/graceful/blob/master/LICENSE) * golang.org/x/net [BSD](https://github.com/golang/net/blob/master/LICENSE) * golang.org/x/oauth2 [BSD](https://github.com/golang/oauth2/blob/master/LICENSE) +* google.golang.org/api/oauth2/v2 [BSD](https://github.com/google/google-api-go-client/blob/master/LICENSE) ### Javascript * Base64 0.2.1 [WTFPL](http://github.com/davidchambers/Base64.js) diff --git a/Makefile b/Makefile index 8e1672faaa..a43b18e1cc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: assets dep clean test gotest gotestrace jstest run run-dev +.PHONY: assets dep clean test gotest gotestrace jstest run run-dev ctags continuous VERSION ?= $(shell git describe --always --tags) COMMIT ?= $(shell git rev-parse --short=8 HEAD) @@ -106,3 +106,5 @@ clean: continuous: while true; do if fswatch -r --one-event .; then echo "#-> Starting build: `date`"; make dev; pkill chronograf; ./chronograf -d --log-level=debug & echo "#-> Build complete."; fi; sleep 0.5; done +ctags: + ctags -R --languages="Go" --exclude=.git --exclude=ui . diff --git a/README.md b/README.md index 57fd4a06dd..e093027334 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ A UI for [Kapacitor](https://github.com/influxdata/kapacitor) alert creation and ### TLS/HTTPS support See [Chronograf with TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) for more information. -### GitHub OAuth Login +### OAuth Login See [Chronograf with OAuth 2.0](https://github.com/influxdata/chronograf/blob/master/docs/auth.md) for more information. ### Advanced Routing diff --git a/canned/docker.json b/canned/docker.json index c924518f35..b0192d65e9 100644 --- a/canned/docker.json +++ b/canned/docker.json @@ -1,7 +1,7 @@ { "id": "0e980b97-c162-487b-a815-3f955df6243f", - "measurement": "docker", "app": "docker", + "measurement": "docker", "autoflow": true, "cells": [ { @@ -16,10 +16,10 @@ "query": "SELECT mean(\"usage_percent\") AS \"usage_percent\" FROM \"docker_container_cpu\"", "groupbys": [ "\"container_name\"" - ], - "wheres": [] + ] } - ] + ], + "type": "line-stacked" }, { "x": 0, @@ -33,10 +33,10 @@ "query": "SELECT mean(\"usage\") AS \"usage\" FROM \"docker_container_mem\"", "groupbys": [ "\"container_name\"" - ], - "wheres": [] + ] } - ] + ], + "type": "line-stepplot" }, { "x": 0, @@ -45,16 +45,15 @@ "h": 4, "i": "4c79cefb-5152-410c-9b88-74f9bff7ef01", "name": "Docker - Containers", - "type": "single-stat", "queries": [ { "query": "SELECT max(\"n_containers\") AS \"max_n_containers\" FROM \"docker\"", "groupbys": [ "\"host\"" - ], - "wheres": [] + ] } - ] + ], + "type": "single-stat" }, { "x": 0, @@ -63,18 +62,17 @@ "h": 4, "i": "4c79cefb-5152-410c-9b88-74f9bff7ef02", "name": "Docker - Images", - "type": "single-stat", "queries": [ { "query": "SELECT max(\"n_images\") AS \"max_n_images\" FROM \"docker\"", "groupbys": [ "\"host\"" - ], - "wheres": [] + ] } - ] - }, - { + ], + "type": "single-stat" + }, + { "x": 0, "y": 0, "w": 4, @@ -86,24 +84,22 @@ "query": "SELECT max(\"n_containers_running\") AS \"max_n_containers_running\" FROM \"docker\"", "groupbys": [ "\"host\"" - ], - "wheres": [] + ] }, { "query": "SELECT max(\"n_containers_stopped\") AS \"max_n_containers_stopped\" FROM \"docker\"", "groupbys": [ "\"host\"" - ], - "wheres": [] + ] }, { "query": "SELECT max(\"n_containers_paused\") AS \"max_n_containers_paused\" FROM \"docker\"", "groupbys": [ "\"host\"" - ], - "wheres": [] + ] } - ] - } + ], + "type": "" + } ] } diff --git a/chronograf.go b/chronograf.go index 5e4ee05363..0377b34129 100644 --- a/chronograf.go +++ b/chronograf.go @@ -3,7 +3,6 @@ package chronograf import ( "context" "net/http" - "time" ) // General errors. @@ -360,25 +359,3 @@ type LayoutStore interface { // Update the dashboard in the store. Update(context.Context, Layout) error } - -// Principal is any entity that can be authenticated -type Principal string - -// PrincipalKey is used to pass principal -// via context.Context to request-scoped -// functions. -const PrincipalKey Principal = "principal" - -// Authenticator represents a service for authenticating users. -type Authenticator interface { - // Authenticate returns User associated with token if successful. - Authenticate(ctx context.Context, token string) (Principal, error) - // Token generates a valid token for Principal lasting a duration - Token(context.Context, Principal, time.Duration) (string, error) -} - -// TokenExtractor extracts tokens from http requests -type TokenExtractor interface { - // Extract will return the token or an error. - Extract(r *http.Request) (string, error) -} diff --git a/docs/OauthStyleAuthentication.png b/docs/OauthStyleAuthentication.png deleted file mode 100644 index feafee6502..0000000000 Binary files a/docs/OauthStyleAuthentication.png and /dev/null differ diff --git a/docs/auth.md b/docs/auth.md index 8644f512bc..f4a795d360 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -7,6 +7,18 @@ OAuth 2.0 Style Authentication To use authentication in Chronograf, both Github OAuth and JWT signature need to be configured. +#### Configuring JWT signature + +Set a [JWT](https://tools.ietf.org/html/rfc7519) signature to a random string. This is needed for all OAuth2 providers that you choose to configure. *Keep this random string around!* + +You'll need it each time you start a chronograf server because it is used to verify user authorization. If you are running multiple chronograf servers in an HA configuration set the `TOKEN_SECRET` on each to allow users to stay logged in. + +```sh +export TOKEN_SECRET=supersupersecret +``` + +# Github + #### Creating Github OAuth Application To create a Github OAuth Application follow the [Register your app](https://developer.github.com/guides/basics-of-authentication/#registering-your-app) instructions. @@ -29,18 +41,6 @@ export GH_CLIENT_ID=b339dd4fddd95abec9aa export GH_CLIENT_SECRET=260041897d3252c146ece6b46ba39bc1e54416dc ``` -#### Configuring JWT signature - -Set a [JWT](https://tools.ietf.org/html/rfc7519) signature to a random string. -*Keep this random string around!* - -You'll need it each time you start a chronograf server because it is used to verify -user authorization. If you are running multiple chronograf servers in an HA configuration set the `TOKEN_SECRET` on each to allow users to stay logged in. - -```sh -export TOKEN_SECRET=supersupersecret -``` - #### Optional Github Organizations To require an organization membership for a user, set the `GH_ORGS` environment variables @@ -56,72 +56,50 @@ To support multiple organizations use a comma delimted list like so: export GH_ORGS=hill-valley-preservation-sociey,the-pinheads ``` -### Design +# Google -The Chronograf authentication scheme is a standard [web application](https://developer.github.com/v3/oauth/#web-application-flow) OAuth flow. +#### Creating Google OAuth Application -![oauth 2.0 flow](./OauthStyleAuthentication.png) +You will need to obtain a client ID and an application secret by following the steps under "Basic Steps" [here](https://developers.google.com/identity/protocols/OAuth2). Chronograf will also need to be publicly accessible via a fully qualified domain name so that Google properly redirects users back to the application. -The browser receives a cookie from Chronograf, authorizing it. The contents of the cookie is a JWT whose "sub" claim is the user's primary -github email address. +This information should be set in the following ENVs: -On each request to Chronograf, the JWT contained in the cookie will be validated against the `TOKEN_SECRET` signature and checked for expiration. -The JWT's "sub" becomes the [principal](https://en.wikipedia.org/wiki/Principal_(computer_security)) used for authorization to resources. +* `GOOGLE_CLIENT_ID` +* `GOOGLE_CLIENT_SECRET` +* `PUBLIC_URL` -The API provides three endpoints `/oauth`, `/oauth/logout` and `/oauth/github/callback`. +Alternatively, this can also be set using the command line switches: -#### /oauth +* `--google-client-id` +* `--google-client-secret` +* `--public-url` -The `/oauth` endpoint redirects to Github for OAuth. Chronograf sets the OAuth `state` request parameter to a JWT with a random "sub". Using $TOKEN_SECRET `/oauth/github/callback` -can validate the `state` parameter without needing `state` to be saved. +#### Optional Google Domains -#### /oauth/github/callback +Similar to Github's organization restriction, Google authentication can be restricted to permit access to Chronograf from only specific domains. These are configured using the `GOOGLE_DOMAINS` ENV or the `--google-domains` switch. Multiple domains are separated with a comma. For example, if we wanted to permit access only from biffspleasurepalace.com and savetheclocktower.com the ENV would be set as follows: -The `/oauth/github/callback` receives the OAuth `authorization code` and `state`. - -First, it will validate the `state` JWT from the `/oauth` endpoint. `JWT` validation -only requires access to the signature token. Therefore, there is no need for `state` -to be saved. Additionally, multiple Chronograf servers will not need to share third -party storage to synchronize `state`. If this validation fails, the request -will be redirected to `/login`. - -Secondly, the endpoint will use the `authorization code` to retrieve a valid OAuth token -with the `user:email` scope. If unable to get a token from Github, the request will -be redirected to `/login`. - -Finally, the endpoint will attempt to get the primary email address of the Github user. -Again, if not successful, the request will redirect to `/login`. - -The email address is used as the subject claim for a new JWT. This JWT becomes the -value of the cookie sent back to the browser. The cookie is valid for thirty days. - -Next, the request is redirected to `/`. - -For all API calls to `/chronograf/v1`, the server checks for the existence and validity -of the JWT within the cookie value. -If the request did not have a valid JWT, the API returns `HTTP/1.1 401 Unauthorized`. - -#### /oauth/logout - -Simply expires the session cookie and redirects to `/`. - -### Authorization - -After successful validation of the JWT, each API endpoint of `/chronograf/v1` receives the -JWT subject within the `http.Request` as a `context.Context` value. - -Within the Go API code all interfaces take `context.Context`. This means that each -interface can use the value as a principal. The design allows for authorization to happen -at the level of design most closely related to the problem. - -An example usage in Go would be: - -```go -func ShallIPass(ctx context.Context) (string, error) { - principal := ctx.Value(chronograf.PrincipalKey).(chronograf.Principal) - if principal != "gandolf@moria.misty.mt" { - return "you shall not pass", chronograf.ErrAuthentication - } - return "run you fools", nil -} +```sh +export GOOGLE_DOMAINS=biffspleasurepalance.com,savetheclocktower.com +``` + +# Heroku + +#### Creating Heroku Application + +To obtain a client ID and application secret for Heroku, you will need to follow the guide posted [here](https://devcenter.heroku.com/articles/oauth#register-client). Once your application has been created, those two values should be inserted into the following ENVs: + +* `HEROKU_CLIENT_ID` +* `HEROKU_SECRET` + +The equivalent command line switches are: + +* `--heroku-client-id` +* `--heroku-secret` + +#### Optional Heroku Organizations + +Like the other OAuth2 providers, access to Chronograf via Heroku can be restricted to members of specific Heroku organizations. This is controlled using the `HEROKU_ORGS` ENV or the `--heroku-organizations` switch and is comma-separated. If we wanted to permit access from the `hill-valley-preservation-society` orgization and `the-pinheads` organization, we would use the following ENV: + +```sh +export HEROKU_ORGS=hill-valley-preservation-sociey,the-pinheads ``` diff --git a/server/auth.go b/oauth2/auth.go similarity index 79% rename from server/auth.go rename to oauth2/auth.go index 5cb3d30d69..fc132eab2a 100644 --- a/server/auth.go +++ b/oauth2/auth.go @@ -1,4 +1,4 @@ -package server +package oauth2 import ( "context" @@ -17,7 +17,7 @@ type CookieExtractor struct { func (c *CookieExtractor) Extract(r *http.Request) (string, error) { cookie, err := r.Cookie(c.Name) if err != nil { - return "", chronograf.ErrAuthentication + return "", ErrAuthentication } return cookie.Value, nil } @@ -29,14 +29,14 @@ type BearerExtractor struct{} func (b *BearerExtractor) Extract(r *http.Request) (string, error) { s := r.Header.Get("Authorization") if s == "" { - return "", chronograf.ErrAuthentication + return "", ErrAuthentication } // Check for Bearer token. strs := strings.Split(s, " ") if len(strs) != 2 || strs[0] != "Bearer" { - return "", chronograf.ErrAuthentication + return "", ErrAuthentication } return strs[1], nil } @@ -45,7 +45,7 @@ func (b *BearerExtractor) Extract(r *http.Request) (string, error) { // will be run. The principal will be sent to the next handler via the request's // Context. It is up to the next handler to determine if the principal has access. // On failure, will return http.StatusUnauthorized. -func AuthorizedToken(auth chronograf.Authenticator, te chronograf.TokenExtractor, logger chronograf.Logger, next http.Handler) http.HandlerFunc { +func AuthorizedToken(auth Authenticator, te TokenExtractor, logger chronograf.Logger, next http.Handler) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log := logger. WithField("component", "auth"). @@ -55,12 +55,13 @@ func AuthorizedToken(auth chronograf.Authenticator, te chronograf.TokenExtractor token, err := te.Extract(r) if err != nil { - log.Error("Unable to extract token") + // Happens when Provider okays authentication, but Token is bad + log.Info("Unauthenticated user") w.WriteHeader(http.StatusUnauthorized) return } // We do not check the validity of the principal. Those - // server further down the chain should do so. + // served further down the chain should do so. principal, err := auth.Authenticate(r.Context(), token) if err != nil { log.Error("Invalid token") @@ -69,7 +70,7 @@ func AuthorizedToken(auth chronograf.Authenticator, te chronograf.TokenExtractor } // Send the principal to the next handler - ctx := context.WithValue(r.Context(), chronograf.PrincipalKey, principal) + ctx := context.WithValue(r.Context(), PrincipalKey, principal) next.ServeHTTP(w, r.WithContext(ctx)) return }) diff --git a/server/auth_test.go b/oauth2/auth_test.go similarity index 79% rename from server/auth_test.go rename to oauth2/auth_test.go index cb231aebbb..40d2ec36f6 100644 --- a/server/auth_test.go +++ b/oauth2/auth_test.go @@ -1,4 +1,4 @@ -package server_test +package oauth2_test import ( "context" @@ -8,9 +8,8 @@ import ( "testing" "time" - "github.com/influxdata/chronograf" clog "github.com/influxdata/chronograf/log" - "github.com/influxdata/chronograf/server" + "github.com/influxdata/chronograf/oauth2" ) func TestCookieExtractor(t *testing.T) { @@ -28,7 +27,7 @@ func TestCookieExtractor(t *testing.T) { Value: "reallyimportant", Lookup: "Doesntexist", Expected: "", - Err: chronograf.ErrAuthentication, + Err: oauth2.ErrAuthentication, }, { Desc: "Cookie token extracted", @@ -46,7 +45,7 @@ func TestCookieExtractor(t *testing.T) { Value: test.Value, }) - var e chronograf.TokenExtractor = &server.CookieExtractor{ + var e oauth2.TokenExtractor = &oauth2.CookieExtractor{ Name: test.Lookup, } actual, err := e.Extract(req) @@ -74,21 +73,21 @@ func TestBearerExtractor(t *testing.T) { Header: "Doesntexist", Value: "reallyimportant", Expected: "", - Err: chronograf.ErrAuthentication, + Err: oauth2.ErrAuthentication, }, { Desc: "Auth header doesn't have Bearer", Header: "Authorization", Value: "Bad Value", Expected: "", - Err: chronograf.ErrAuthentication, + Err: oauth2.ErrAuthentication, }, { Desc: "Auth header doesn't have Bearer token", Header: "Authorization", Value: "Bearer", Expected: "", - Err: chronograf.ErrAuthentication, + Err: oauth2.ErrAuthentication, }, { Desc: "Authorization Bearer token success", @@ -102,7 +101,7 @@ func TestBearerExtractor(t *testing.T) { req, _ := http.NewRequest("", "http://howdy.com", nil) req.Header.Add(test.Header, test.Value) - var e chronograf.TokenExtractor = &server.BearerExtractor{} + var e oauth2.TokenExtractor = &oauth2.BearerExtractor{} actual, err := e.Extract(req) if err != test.Err { t.Errorf("Bearer extract error; expected %v actual %v", test.Err, err) @@ -123,15 +122,15 @@ func (m *MockExtractor) Extract(*http.Request) (string, error) { } type MockAuthenticator struct { - Principal chronograf.Principal + Principal oauth2.Principal Err error } -func (m *MockAuthenticator) Authenticate(context.Context, string) (chronograf.Principal, error) { +func (m *MockAuthenticator) Authenticate(context.Context, string) (oauth2.Principal, error) { return m.Principal, m.Err } -func (m *MockAuthenticator) Token(context.Context, chronograf.Principal, time.Duration) (string, error) { +func (m *MockAuthenticator) Token(context.Context, oauth2.Principal, time.Duration) (string, error) { return "", m.Err } @@ -139,7 +138,7 @@ func TestAuthorizedToken(t *testing.T) { var tests = []struct { Desc string Code int - Principal chronograf.Principal + Principal oauth2.Principal ExtractorErr error AuthErr error Expected string @@ -155,19 +154,21 @@ func TestAuthorizedToken(t *testing.T) { AuthErr: errors.New("error"), }, { - Desc: "Authorized ok", - Code: http.StatusOK, - Principal: "Principal Strickland", - Expected: "Principal Strickland", + Desc: "Authorized ok", + Code: http.StatusOK, + Principal: oauth2.Principal{ + Subject: "Principal Strickland", + }, + Expected: "Principal Strickland", }, } for _, test := range tests { // next is a sentinel StatusOK and // principal recorder. - var principal chronograf.Principal + var principal oauth2.Principal next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - principal = r.Context().Value(chronograf.PrincipalKey).(chronograf.Principal) + principal = r.Context().Value(oauth2.PrincipalKey).(oauth2.Principal) }) req, _ := http.NewRequest("GET", "", nil) w := httptest.NewRecorder() @@ -181,7 +182,7 @@ func TestAuthorizedToken(t *testing.T) { } logger := clog.New(clog.DebugLevel) - handler := server.AuthorizedToken(a, e, logger, next) + handler := oauth2.AuthorizedToken(a, e, logger, next) handler.ServeHTTP(w, req) if w.Code != test.Code { t.Errorf("Status code expected: %d actual %d", test.Code, w.Code) diff --git a/oauth2/doc.go b/oauth2/doc.go new file mode 100644 index 0000000000..db132a6caa --- /dev/null +++ b/oauth2/doc.go @@ -0,0 +1,140 @@ +// The oauth2 package provides http.Handlers necessary for implementing Oauth2 +// authentication with multiple Providers. +// +// This is how the pieces of this package fit together: +// +// ┌────────────────────────────────────────┐ +// │github.com/influxdata/chronograf/oauth2 │ +// ├────────────────────────────────────────┴────────────────────────────────────┐ +// │┌────────────────────┐ │ +// ││ <> │ ┌─────────────────────────┐ │ +// ││ Authenticator │ │ CookieMux │ │ +// │├────────────────────┤ ├─────────────────────────┤ │ +// ││Authenticate() │ Auth │+SuccessURL : string │ │ +// ││Token() ◀────────│+FailureURL : string │──────────┐ │ +// │└──────────△─────────┘ │+Now : func() time.Time │ │ │ +// │ │ └─────────────────────────┘ │ │ +// │ │ │ │ │ +// │ │ │ │ │ +// │ │ Provider│ │ │ +// │ │ ┌───┘ │ │ +// │┌──────────┴────────────┐ │ ▽ │ +// ││ JWT │ │ ┌───────────────┐ │ +// │├───────────────────────┤ ▼ │ <> │ │ +// ││+Secret : string │ ┌───────────────┐ │ OAuth2Mux │ │ +// ││+Now : func() time.Time│ │ <> │ ├───────────────┤ │ +// │└───────────────────────┘ │ Provider │ │Login() │ │ +// │ ├───────────────┤ │Logout() │ │ +// │ │ID() │ │Callback() │ │ +// │ │Scopes() │ └───────────────┘ │ +// │ │Secret() │ │ +// │ │Authenticator()│ │ +// │ └───────────────┘ │ +// │ △ │ +// │ │ │ +// │ ┌─────────────────────────┼─────────────────────────┐ │ +// │ │ │ │ │ +// │ │ │ │ │ +// │ │ │ │ │ +// │ ┌───────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐│ +// │ │ Github │ │ Google │ │ Heroku ││ +// │ ├───────────────────────┤ ├──────────────────────┤ ├──────────────────────┤│ +// │ │+ClientID : string │ │+ClientID : string │ │+ClientID : string ││ +// │ │+ClientSecret : string │ │+ClientSecret : string│ │+ClientSecret : string││ +// │ │+Orgs : []string │ │+Domains : []string │ └──────────────────────┘│ +// │ └───────────────────────┘ │+RedirectURL : string │ │ +// │ └──────────────────────┘ │ +// └─────────────────────────────────────────────────────────────────────────────┘ +// +// The design focuses on an Authenticator, a Provider, and an OAuth2Mux. Their +// responsibilities, respectively, are to decode and encode secrets received +// from a Provider, to perform Provider specific operations in order to extract +// information about a user, and to produce the handlers which persist secrets. +// To add a new provider, You need only implement the Provider interface, and +// add its endpoints to the server Mux. +// +// The Oauth2 flow between a browser, backend, and a Provider that this package +// implements is pictured below for reference. +// +// ┌─────────┐ ┌───────────┐ ┌────────┐ +// │ Browser │ │Chronograf │ │Provider│ +// └─────────┘ └───────────┘ └────────┘ +// │ │ │ +// ├─────── GET /auth ─────────▶ │ +// │ │ │ +// │ │ │ +// ◀ ─ ─ ─302 to Provider ─ ─ ┤ │ +// │ │ │ +// │ │ │ +// ├──────────────── GET /auth w/ callback ─────────────────────▶ +// │ │ │ +// │ │ │ +// ◀─ ─ ─ ─ ─ ─ ─ 302 to Chronograf Callback ─ ─ ─ ─ ─ ─ ─ ─ ┤ +// │ │ │ +// │ Code and State from │ │ +// │ Provider │ │ +// ├───────────────────────────▶ Request token w/ code & │ +// │ │ state │ +// │ ├────────────────────────────────▶ +// │ │ │ +// │ │ Response with │ +// │ │ Token │ +// │ Set cookie, Redirect │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤ +// │ to / │ │ +// ◀───────────────────────────┤ │ +// │ │ │ +// │ │ │ +// │ │ │ +// │ │ │ +// +// The browser ultimately receives a cookie from Chronograf, authorizing it. +// Its contents are encoded as a JWT whose "sub" claim is the user's email +// address for whatever provider they have authenticated with. Each request to +// Chronograf will validate the contents of this JWT against the `TOKEN_SECRET` +// and checked for expiration. The JWT's "sub" becomes the +// https://en.wikipedia.org/wiki/Principal_(computer_security) used for +// authorization to resources. +// +// The Mux is responsible for providing three http.Handlers for servicing the +// above interaction. These are mounted at specific endpoints by convention +// shared with the front end. Any future Provider routes should follow the same +// convention to ensure compatibility with the front end logic. These routes +// and their responsibilities are: +// +// /oauth/{provider}/login +// +// The `/oauth` endpoint redirects to the Provider for OAuth. Chronograf sets +// the OAuth `state` request parameter to a JWT with a random "sub". Using +// $TOKEN_SECRET `/oauth/github/callback` can validate the `state` parameter +// without needing `state` to be saved. +// +// /oauth/{provider}/callback +// +// The `/oauth/github/callback` receives the OAuth `authorization code` and `state`. +// +// First, it will validate the `state` JWT from the `/oauth` endpoint. `JWT` validation +// only requires access to the signature token. Therefore, there is no need for `state` +// to be saved. Additionally, multiple Chronograf servers will not need to share third +// party storage to synchronize `state`. If this validation fails, the request +// will be redirected to `/login`. +// +// Secondly, the endpoint will use the `authorization code` to retrieve a valid OAuth token +// with the `user:email` scope. If unable to get a token from Github, the request will +// be redirected to `/login`. +// +// Finally, the endpoint will attempt to get the primary email address of the +// user. Again, if not successful, the request will redirect to `/login`. +// +// The email address is used as the subject claim for a new JWT. This JWT becomes the +// value of the cookie sent back to the browser. The cookie is valid for thirty days. +// +// Next, the request is redirected to `/`. +// +// For all API calls to `/chronograf/v1`, the server checks for the existence and validity +// of the JWT within the cookie value. +// If the request did not have a valid JWT, the API returns `HTTP/1.1 401 Unauthorized`. +// +// /oauth/{provider}/logout +// +// Simply expires the session cookie and redirects to `/`. +package oauth2 diff --git a/oauth2/github.go b/oauth2/github.go new file mode 100644 index 0000000000..0f3e0e932d --- /dev/null +++ b/oauth2/github.go @@ -0,0 +1,162 @@ +package oauth2 + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "io" + "net/http" + + "github.com/google/go-github/github" + "github.com/influxdata/chronograf" + "golang.org/x/oauth2" + ogh "golang.org/x/oauth2/github" +) + +var _ Provider = &Github{} + +// Github provides OAuth Login and Callback server. Callback will set +// an authentication cookie. This cookie's value is a JWT containing +// the user's primary Github email address. +type Github struct { + ClientID string + ClientSecret string + Orgs []string // Optional github organization checking + Logger chronograf.Logger +} + +// Name is the name of the provider +func (g *Github) Name() string { + return "github" +} + +// ID returns the github application client id +func (g *Github) ID() string { + return g.ClientID +} + +// Secret returns the github application client secret +func (g *Github) Secret() string { + return g.ClientSecret +} + +// Scopes for github is only the email addres and possible organizations if +// we are filtering by organizations. +func (g *Github) Scopes() []string { + scopes := []string{"user:email"} + if len(g.Orgs) > 0 { + scopes = append(scopes, "read:org") + } + return scopes +} + +// Config is the Github OAuth2 exchange information and endpoints +func (g *Github) Config() *oauth2.Config { + return &oauth2.Config{ + ClientID: g.ID(), + ClientSecret: g.Secret(), + Scopes: g.Scopes(), + Endpoint: ogh.Endpoint, + } +} + +// PrincipalID returns the github email address of the user. +func (g *Github) PrincipalID(provider *http.Client) (string, error) { + client := github.NewClient(provider) + // If we need to restrict to a set of organizations, we first get the org + // and filter. + if len(g.Orgs) > 0 { + orgs, err := getOrganizations(client, g.Logger) + if err != nil { + return "", err + } + // Not a member, so, deny permission + if ok := isMember(g.Orgs, orgs); !ok { + g.Logger.Error("Not a member of required github organization") + return "", err + } + } + + email, err := getPrimaryEmail(client, g.Logger) + if err != nil { + return "", nil + } + return email, nil +} + +func randomString(length int) string { + k := make([]byte, length) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + return "" + } + return base64.StdEncoding.EncodeToString(k) +} + +func logResponseError(log chronograf.Logger, resp *github.Response, err error) { + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + log.Error("OAuth access to email address forbidden ", err.Error()) + default: + log.Error("Unable to retrieve Github email ", err.Error()) + } +} + +// isMember makes sure that the user is in one of the required organizations +func isMember(requiredOrgs []string, userOrgs []*github.Organization) bool { + for _, requiredOrg := range requiredOrgs { + for _, userOrg := range userOrgs { + if userOrg.Login != nil && *userOrg.Login == requiredOrg { + return true + } + } + } + return false +} + +// getOrganizations gets all organization for the currently authenticated user +func getOrganizations(client *github.Client, log chronograf.Logger) ([]*github.Organization, error) { + // Get all pages of results + var allOrgs []*github.Organization + for { + opt := &github.ListOptions{ + PerPage: 10, + } + // Get the organizations for the current authenticated user. + orgs, resp, err := client.Organizations.List("", opt) + if err != nil { + logResponseError(log, resp, err) + return nil, err + } + allOrgs = append(allOrgs, orgs...) + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + return allOrgs, nil +} + +// getPrimaryEmail gets the primary email account for the authenticated user. +func getPrimaryEmail(client *github.Client, log chronograf.Logger) (string, error) { + emails, resp, err := client.Users.ListEmails(nil) + if err != nil { + logResponseError(log, resp, err) + return "", err + } + + email, err := primaryEmail(emails) + if err != nil { + log.Error("Unable to retrieve primary Github email ", err.Error()) + return "", err + } + return email, nil +} + +func primaryEmail(emails []*github.UserEmail) (string, error) { + for _, m := range emails { + if m != nil && m.Primary != nil && m.Verified != nil && m.Email != nil { + return *m.Email, nil + } + } + return "", errors.New("No primary email address") +} diff --git a/oauth2/github_test.go b/oauth2/github_test.go new file mode 100644 index 0000000000..07f6791d6d --- /dev/null +++ b/oauth2/github_test.go @@ -0,0 +1,113 @@ +package oauth2_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + clog "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/oauth2" +) + +func TestGithubPrincipalID(t *testing.T) { + t.Parallel() + + expected := []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + }{ + {"martymcfly@example.com", true, false}, + } + mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/user/emails" { + rw.WriteHeader(http.StatusNotFound) + return + } + enc := json.NewEncoder(rw) + + rw.WriteHeader(http.StatusOK) + _ = enc.Encode(expected) + })) + defer mockAPI.Close() + + logger := clog.New(clog.ParseLevel("debug")) + prov := oauth2.Github{ + Logger: logger, + } + tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport) + if err != nil { + t.Fatal("Error initializing TestTripper: err:", err) + } + + tc := &http.Client{ + Transport: tt, + } + + email, err := prov.PrincipalID(tc) + if err != nil { + t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err) + } + + if email != expected[0].Email { + t.Fatal("Retrieved email was not as expected. Want:", expected[0].Email, "Got:", email) + } +} + +func TestGithubPrincipalIDOrganization(t *testing.T) { + t.Parallel() + + expectedUser := []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + }{ + {"martymcfly@example.com", true, false}, + } + expectedOrg := []struct { + Login string `json:"login"` + }{ + {"Hill Valley Preservation Society"}, + } + + mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/user/emails" { + enc := json.NewEncoder(rw) + rw.WriteHeader(http.StatusOK) + _ = enc.Encode(expectedUser) + return + } + if r.URL.Path == "/user/orgs" { + enc := json.NewEncoder(rw) + rw.WriteHeader(http.StatusOK) + _ = enc.Encode(expectedOrg) + return + } + rw.WriteHeader(http.StatusNotFound) + })) + defer mockAPI.Close() + + logger := clog.New(clog.ParseLevel("debug")) + prov := oauth2.Github{ + Logger: logger, + Orgs: []string{"Hill Valley Preservation Society"}, + } + tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport) + if err != nil { + t.Fatal("Error initializing TestTripper: err:", err) + } + + tc := &http.Client{ + Transport: tt, + } + + email, err := prov.PrincipalID(tc) + if err != nil { + t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err) + } + + if email != expectedUser[0].Email { + t.Fatal("Retrieved email was not as expected. Want:", expectedUser[0].Email, "Got:", email) + } +} diff --git a/oauth2/google.go b/oauth2/google.go new file mode 100644 index 0000000000..fb082ace6c --- /dev/null +++ b/oauth2/google.go @@ -0,0 +1,89 @@ +package oauth2 + +import ( + "fmt" + "net/http" + + "github.com/influxdata/chronograf" + + "golang.org/x/oauth2" + goauth2 "google.golang.org/api/oauth2/v2" +) + +// Endpoint is Google's OAuth 2.0 endpoint. +// Copied here to remove tons of package dependencies +var GoogleEndpoint = oauth2.Endpoint{ + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://accounts.google.com/o/oauth2/token", +} +var _ Provider = &Google{} + +type Google struct { + ClientID string + ClientSecret string + RedirectURL string + Domains []string // Optional google email domain checking + Logger chronograf.Logger +} + +// Name is the name of the provider +func (g *Google) Name() string { + return "google" +} + +// ID returns the google application client id +func (g *Google) ID() string { + return g.ClientID +} + +// Secret returns the google application client secret +func (g *Google) Secret() string { + return g.ClientSecret +} + +// Scopes for google is only the email address +// Documentation is here: https://developers.google.com/+/web/api/rest/oauth#email +func (g *Google) Scopes() []string { + return []string{ + goauth2.UserinfoEmailScope, + goauth2.UserinfoProfileScope, + } +} + +// Config is the Google OAuth2 exchange information and endpoints +func (g *Google) Config() *oauth2.Config { + return &oauth2.Config{ + ClientID: g.ID(), + ClientSecret: g.Secret(), + Scopes: g.Scopes(), + Endpoint: GoogleEndpoint, + RedirectURL: g.RedirectURL, + } +} + +// PrincipalID returns the google email address of the user. +func (g *Google) PrincipalID(provider *http.Client) (string, error) { + srv, err := goauth2.New(provider) + if err != nil { + g.Logger.Error("Unable to communicate with Google ", err.Error()) + return "", err + } + info, err := srv.Userinfo.Get().Do() + if err != nil { + g.Logger.Error("Unable to retrieve Google email ", err.Error()) + return "", err + } + // No domain filtering required, so, the user is autenticated. + if len(g.Domains) == 0 { + return info.Email, nil + } + + // Check if the account domain is acceptable + for _, requiredDomain := range g.Domains { + if info.Hd == requiredDomain { + return info.Email, nil + } + } + g.Logger.Error("Domain '", info.Hd, "' is not a member of required Google domain(s): ", g.Domains) + return "", fmt.Errorf("Not in required domain") +} diff --git a/oauth2/google_test.go b/oauth2/google_test.go new file mode 100644 index 0000000000..1ad3ea4bea --- /dev/null +++ b/oauth2/google_test.go @@ -0,0 +1,102 @@ +package oauth2_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + clog "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/oauth2" +) + +func TestGooglePrincipalID(t *testing.T) { + t.Parallel() + + expected := struct { + Email string `json:"email"` + }{ + "martymcfly@example.com", + } + mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth2/v2/userinfo" { + rw.WriteHeader(http.StatusNotFound) + return + } + + enc := json.NewEncoder(rw) + rw.WriteHeader(http.StatusOK) + _ = enc.Encode(expected) + })) + defer mockAPI.Close() + + logger := clog.New(clog.ParseLevel("debug")) + prov := oauth2.Google{ + Logger: logger, + } + tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport) + if err != nil { + t.Fatal("Error initializing TestTripper: err:", err) + } + + tc := &http.Client{ + Transport: tt, + } + + email, err := prov.PrincipalID(tc) + if err != nil { + t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err) + } + + if email != expected.Email { + t.Fatal("Retrieved email was not as expected. Want:", expected.Email, "Got:", email) + } +} + +func TestGooglePrincipalIDDomain(t *testing.T) { + t.Parallel() + + expectedUser := struct { + Email string `json:"email"` + Hd string `json:"hd"` + }{ + "martymcfly@example.com", + "Hill Valley Preservation Society", + } + //a := goauth2.Userinfoplus{} + + mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth2/v2/userinfo" { + rw.WriteHeader(http.StatusNotFound) + return + } + + enc := json.NewEncoder(rw) + rw.WriteHeader(http.StatusOK) + _ = enc.Encode(expectedUser) + })) + defer mockAPI.Close() + + logger := clog.New(clog.ParseLevel("debug")) + prov := oauth2.Google{ + Logger: logger, + Domains: []string{"Hill Valley Preservation Society"}, + } + tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport) + if err != nil { + t.Fatal("Error initializing TestTripper: err:", err) + } + + tc := &http.Client{ + Transport: tt, + } + + email, err := prov.PrincipalID(tc) + if err != nil { + t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err) + } + + if email != expectedUser.Email { + t.Fatal("Retrieved email was not as expected. Want:", expectedUser.Email, "Got:", email) + } +} diff --git a/oauth2/heroku.go b/oauth2/heroku.go new file mode 100644 index 0000000000..637c57adf1 --- /dev/null +++ b/oauth2/heroku.go @@ -0,0 +1,101 @@ +package oauth2 + +import ( + "encoding/json" + "net/http" + + "github.com/influxdata/chronograf" + + "golang.org/x/oauth2" + hrk "golang.org/x/oauth2/heroku" +) + +// Ensure that Heroku is an oauth2.Provider +var _ Provider = &Heroku{} + +const ( + // Routes required for interacting with Heroku API + HEROKU_ACCOUNT_ROUTE string = "https://api.heroku.com/account" +) + +// Heroku is an OAuth2 Provider allowing users to authenticate with Heroku to +// gain access to Chronograf +type Heroku struct { + // OAuth2 Secrets + ClientID string + ClientSecret string + + Organizations []string // set of organizations permitted to access the protected resource. Empty means "all" + + Logger chronograf.Logger +} + +// Config returns the OAuth2 exchange information and endpoints +func (h *Heroku) Config() *oauth2.Config { + return &oauth2.Config{ + ClientID: h.ID(), + ClientSecret: h.Secret(), + Scopes: h.Scopes(), + Endpoint: hrk.Endpoint, + } +} + +// ID returns the Heroku application client ID +func (h *Heroku) ID() string { + return h.ClientID +} + +// Name returns the name of this provider (heroku) +func (h *Heroku) Name() string { + return "heroku" +} + +// PrincipalID returns the Heroku email address of the user. +func (h *Heroku) PrincipalID(provider *http.Client) (string, error) { + type DefaultOrg struct { + ID string `json:"id"` + Name string `json:"name"` + } + type Account struct { + Email string `json:"email"` + DefaultOrganization DefaultOrg `json:"default_organization"` + } + + resp, err := provider.Get(HEROKU_ACCOUNT_ROUTE) + if err != nil { + h.Logger.Error("Unable to communicate with Heroku. err:", err) + return "", err + } + defer resp.Body.Close() + d := json.NewDecoder(resp.Body) + var account Account + if err := d.Decode(&account); err != nil { + h.Logger.Error("Unable to decode response from Heroku. err:", err) + return "", err + } + + // check if member of org + if len(h.Organizations) > 0 { + for _, org := range h.Organizations { + if account.DefaultOrganization.Name == org { + return account.Email, nil + } + } + h.Logger.Error(ErrOrgMembership) + return "", ErrOrgMembership + } else { + return account.Email, nil + } +} + +// Scopes for heroku is "identity" which grants access to user account +// information. This will grant us access to the user's email address which is +// used as the Principal's identifier. +func (h *Heroku) Scopes() []string { + return []string{"identity"} +} + +// Secret returns the Heroku application client secret +func (h *Heroku) Secret() string { + return h.ClientSecret +} diff --git a/oauth2/heroku_test.go b/oauth2/heroku_test.go new file mode 100644 index 0000000000..fc96194038 --- /dev/null +++ b/oauth2/heroku_test.go @@ -0,0 +1,102 @@ +package oauth2_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + clog "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/oauth2" +) + +func Test_Heroku_PrincipalID_ExtractsEmailAddress(t *testing.T) { + t.Parallel() + + expected := struct { + Email string `json:"email"` + }{ + "martymcfly@example.com", + } + + mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/account" { + rw.WriteHeader(http.StatusNotFound) + return + } + enc := json.NewEncoder(rw) + + rw.WriteHeader(http.StatusOK) + _ = enc.Encode(expected) + })) + defer mockAPI.Close() + + logger := clog.New(clog.ParseLevel("debug")) + prov := oauth2.Heroku{ + Logger: logger, + } + tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport) + if err != nil { + t.Fatal("Error initializing TestTripper: err:", err) + } + + tc := &http.Client{ + Transport: tt, + } + + email, err := prov.PrincipalID(tc) + if err != nil { + t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err) + } + + if email != expected.Email { + t.Fatal("Retrieved email was not as expected. Want:", expected.Email, "Got:", email) + } +} + +func Test_Heroku_PrincipalID_RestrictsByOrganization(t *testing.T) { + t.Parallel() + + expected := struct { + Email string `json:"email"` + DefaultOrganization map[string]string `json:"default_organization"` + }{ + "martymcfly@example.com", + map[string]string{ + "id": "a85eac89-56cc-498e-9a89-d8f49f6aed71", + "name": "hill-valley-preservation-society", + }, + } + + mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/account" { + rw.WriteHeader(http.StatusNotFound) + return + } + enc := json.NewEncoder(rw) + + rw.WriteHeader(http.StatusOK) + _ = enc.Encode(expected) + })) + defer mockAPI.Close() + + logger := clog.New(clog.ParseLevel("debug")) + prov := oauth2.Heroku{ + Logger: logger, + Organizations: []string{"enchantment-under-the-sea-dance-committee"}, + } + + tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport) + if err != nil { + t.Fatal("Error initializing TestTripper: err:", err) + } + + tc := &http.Client{ + Transport: tt, + } + + _, err = prov.PrincipalID(tc) + if err == nil { + t.Fatal("Expected error while authenticating user with mismatched orgs, but received none") + } +} diff --git a/jwt/jwt.go b/oauth2/jwt.go similarity index 83% rename from jwt/jwt.go rename to oauth2/jwt.go index 1eedbe0ce3..fb54b62f99 100644 --- a/jwt/jwt.go +++ b/oauth2/jwt.go @@ -1,4 +1,4 @@ -package jwt +package oauth2 import ( "context" @@ -6,11 +6,10 @@ import ( "time" gojwt "github.com/dgrijalva/jwt-go" - "github.com/influxdata/chronograf" ) // Test if JWT implements Authenticator -var _ chronograf.Authenticator = &JWT{} +var _ Authenticator = &JWT{} // JWT represents a javascript web token that can be validated or marshaled into string. type JWT struct { @@ -45,7 +44,7 @@ func (c *Claims) Valid() error { } // Authenticate checks if the jwtToken is signed correctly and validates with Claims. -func (j *JWT) Authenticate(ctx context.Context, jwtToken string) (chronograf.Principal, error) { +func (j *JWT) Authenticate(ctx context.Context, jwtToken string) (Principal, error) { gojwt.TimeFunc = j.Now // Check for expected signing method. @@ -62,27 +61,31 @@ func (j *JWT) Authenticate(ctx context.Context, jwtToken string) (chronograf.Pri // 4. Check if subject is not empty token, err := gojwt.ParseWithClaims(jwtToken, &Claims{}, alg) if err != nil { - return "", err + return Principal{}, err } else if !token.Valid { - return "", err + return Principal{}, err } claims, ok := token.Claims.(*Claims) if !ok { - return "", fmt.Errorf("unable to convert claims to standard claims") + return Principal{}, fmt.Errorf("unable to convert claims to standard claims") } - return chronograf.Principal(claims.Subject), nil + return Principal{ + Subject: claims.Subject, + Issuer: claims.Issuer, + }, nil } // Token creates a signed JWT token from user that expires at Now + duration -func (j *JWT) Token(ctx context.Context, user chronograf.Principal, duration time.Duration) (string, error) { +func (j *JWT) Token(ctx context.Context, user Principal, duration time.Duration) (string, error) { // Create a new token object, specifying signing method and the claims // you would like it to contain. now := j.Now().UTC() claims := &Claims{ gojwt.StandardClaims{ - Subject: string(user), + Subject: user.Subject, + Issuer: user.Issuer, ExpiresAt: now.Add(duration).Unix(), IssuedAt: now.Unix(), NotBefore: now.Unix(), diff --git a/jwt/jwt_test.go b/oauth2/jwt_test.go similarity index 77% rename from jwt/jwt_test.go rename to oauth2/jwt_test.go index a88014cf1c..d65a828003 100644 --- a/jwt/jwt_test.go +++ b/oauth2/jwt_test.go @@ -1,4 +1,4 @@ -package jwt_test +package oauth2_test import ( "context" @@ -6,8 +6,7 @@ import ( "testing" "time" - "github.com/influxdata/chronograf" - "github.com/influxdata/chronograf/jwt" + "github.com/influxdata/chronograf/oauth2" ) func TestAuthenticate(t *testing.T) { @@ -15,46 +14,56 @@ func TestAuthenticate(t *testing.T) { Desc string Secret string Token string - User chronograf.Principal + User oauth2.Principal Err error }{ { Desc: "Test bad jwt token", Secret: "secret", Token: "badtoken", - User: "", - Err: errors.New("token contains an invalid number of segments"), + User: oauth2.Principal{ + Subject: "", + }, + Err: errors.New("token contains an invalid number of segments"), }, { Desc: "Test valid jwt token", Secret: "secret", Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAwLCJuYmYiOi00NDY3NzQ0MDB9._rZ4gOIei9PizHOABH6kLcJTA3jm8ls0YnDxtz1qeUI", - User: "/chronograf/v1/users/1", + User: oauth2.Principal{ + Subject: "/chronograf/v1/users/1", + }, }, { Desc: "Test expired jwt token", Secret: "secret", Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAxLCJuYmYiOi00NDY3NzQ0MDB9.vWXdm0-XQ_pW62yBpSISFFJN_yz0vqT9_INcUKTp5Q8", - User: "", - Err: errors.New("token is expired by 1s"), + User: oauth2.Principal{ + Subject: "", + }, + Err: errors.New("token is expired by 1s"), }, { Desc: "Test jwt token not before time", Secret: "secret", Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAwLCJuYmYiOi00NDY3NzQzOTl9.TMGAhv57u1aosjc4ywKC7cElP1tKyQH7GmRF2ToAxlE", - User: "", - Err: errors.New("token is not valid yet"), + User: oauth2.Principal{ + Subject: "", + }, + Err: errors.New("token is not valid yet"), }, { Desc: "Test jwt with empty subject is invalid", Secret: "secret", Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOi00NDY3NzQ0MDAsImV4cCI6LTQ0Njc3NDQwMCwibmJmIjotNDQ2Nzc0NDAwfQ.gxsA6_Ei3s0f2I1TAtrrb8FmGiO25OqVlktlF_ylhX4", - User: "", - Err: errors.New("claim has no subject"), + User: oauth2.Principal{ + Subject: "", + }, + Err: errors.New("claim has no subject"), }, } for i, test := range tests { - j := jwt.JWT{ + j := oauth2.JWT{ Secret: test.Secret, Now: func() time.Time { return time.Unix(-446774400, 0) @@ -77,13 +86,16 @@ func TestAuthenticate(t *testing.T) { func TestToken(t *testing.T) { duration := time.Second expected := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOi00NDY3NzQzOTksImlhdCI6LTQ0Njc3NDQwMCwibmJmIjotNDQ2Nzc0NDAwLCJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIn0.ofQM6yTmrmve5JeEE0RcK4_euLXuZ_rdh6bLAbtbC9M" - j := jwt.JWT{ + j := oauth2.JWT{ Secret: "secret", Now: func() time.Time { return time.Unix(-446774400, 0) }, } - if token, err := j.Token(context.Background(), chronograf.Principal("/chronograf/v1/users/1"), duration); err != nil { + p := oauth2.Principal{ + Subject: "/chronograf/v1/users/1", + } + if token, err := j.Token(context.Background(), p, duration); err != nil { t.Errorf("Error creating token for user: %v", err) } else if token != expected { t.Errorf("Error creating token; expected: %s actual: %s", "", token) diff --git a/oauth2/mux.go b/oauth2/mux.go new file mode 100644 index 0000000000..dc45a15256 --- /dev/null +++ b/oauth2/mux.go @@ -0,0 +1,169 @@ +package oauth2 + +import ( + "net/http" + "time" + + "github.com/influxdata/chronograf" + "golang.org/x/oauth2" +) + +const ( + // DefaultCookieName is the name of the stored cookie + DefaultCookieName = "session" + // DefaultCookieDuration is the length of time the cookie is valid + DefaultCookieDuration = time.Hour * 24 * 30 +) + +// Cookie represents the location and expiration time of new cookies. +type cookie struct { + Name string + Duration time.Duration +} + +// Check to ensure CookieMux is an oauth2.Mux +var _ Mux = &CookieMux{} + +func NewCookieMux(p Provider, a Authenticator, l chronograf.Logger) *CookieMux { + return &CookieMux{ + Provider: p, + Auth: a, + Logger: l, + SuccessURL: "/", + FailureURL: "/login", + Now: time.Now, + + cookie: cookie{ + Name: DefaultCookieName, + Duration: DefaultCookieDuration, + }, + } +} + +// CookieMux services an Oauth2 interaction with a provider and browser and +// stores the resultant token in the user's browser as a cookie. The benefit of +// this is that the cookie's authenticity can be verified independently by any +// Chronograf instance as long as the Authenticator has no external +// dependencies (e.g. on a Database). +type CookieMux struct { + Provider Provider + Auth Authenticator + cookie cookie + Logger chronograf.Logger + SuccessURL string // SuccessURL is redirect location after successful authorization + FailureURL string // FailureURL is redirect location after authorization failure + Now func() time.Time // Now returns the current time +} + +// Uses a Cookie with a random string as the state validation method. JWTs are +// a good choice here for encoding because they can be validated without +// storing state. +func (j *CookieMux) Login() http.Handler { + conf := j.Provider.Config() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // We are creating a token with an encoded random string to prevent CSRF attacks + // This token will be validated during the OAuth callback. + // We'll give our users 10 minutes from this point to type in their github password. + // If the callback is not received within 10 minutes, then authorization will fail. + csrf := randomString(32) // 32 is not important... just long + p := Principal{ + Subject: csrf, + } + state, err := j.Auth.Token(r.Context(), p, 10*time.Minute) + // This is likely an internal server error + if err != nil { + j.Logger. + WithField("component", "auth"). + WithField("remote_addr", r.RemoteAddr). + WithField("method", r.Method). + WithField("url", r.URL). + Error("Internal authentication error: ", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + url := conf.AuthCodeURL(state, oauth2.AccessTypeOnline) + http.Redirect(w, r, url, http.StatusTemporaryRedirect) + }) +} + +// Callback is used by OAuth2 provider after authorization is granted. If +// granted, Callback will set a cookie with a month-long expiration. It is +// recommended that the value of the cookie be encoded as a JWT because the JWT +// can be validated without the need for saving state. The JWT contains the +// principal's identifier (e.g. email address). +func (j *CookieMux) Callback() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log := j.Logger. + WithField("component", "auth"). + WithField("remote_addr", r.RemoteAddr). + WithField("method", r.Method). + WithField("url", r.URL) + + state := r.FormValue("state") + // Check if the OAuth state token is valid to prevent CSRF + _, err := j.Auth.Authenticate(r.Context(), state) + if err != nil { + log.Error("Invalid OAuth state received: ", err.Error()) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } + + // Exchange the code back with the provider to the the token + conf := j.Provider.Config() + code := r.FormValue("code") + token, err := conf.Exchange(r.Context(), code) + if err != nil { + log.Error("Unable to exchange code for token ", err.Error()) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } + + // Using the token get the principal identifier from the provider + oauthClient := conf.Client(r.Context(), token) + id, err := j.Provider.PrincipalID(oauthClient) + if err != nil { + log.Error("Unable to get principal identifier ", err.Error()) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } + + p := Principal{ + Subject: id, + Issuer: j.Provider.Name(), + } + // We create an auth token that will be used by all other endpoints to validate the principal has a claim + authToken, err := j.Auth.Token(r.Context(), p, j.cookie.Duration) + if err != nil { + log.Error("Unable to create cookie auth token ", err.Error()) + http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect) + return + } + + expireCookie := j.Now().UTC().Add(j.cookie.Duration) + cookie := http.Cookie{ + Name: j.cookie.Name, + Value: authToken, + Expires: expireCookie, + HttpOnly: true, + Path: "/", + } + log.Info("User ", id, " is authenticated") + http.SetCookie(w, &cookie) + http.Redirect(w, r, j.SuccessURL, http.StatusTemporaryRedirect) + }) +} // Login returns a handler that redirects to the providers OAuth login. + +// Logout handler will expire our authentication cookie and redirect to the successURL +func (j *CookieMux) Logout() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + deleteCookie := http.Cookie{ + Name: j.cookie.Name, + Value: "none", + Expires: j.Now().UTC().Add(-1 * time.Hour), + HttpOnly: true, + Path: "/", + } + http.SetCookie(w, &deleteCookie) + http.Redirect(w, r, j.SuccessURL, http.StatusTemporaryRedirect) + }) +} diff --git a/oauth2/mux_test.go b/oauth2/mux_test.go new file mode 100644 index 0000000000..bb4403ca6c --- /dev/null +++ b/oauth2/mux_test.go @@ -0,0 +1,157 @@ +package oauth2_test + +import ( + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "testing" + "time" + + clog "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/oauth2" +) + +var testTime time.Time = time.Date(1985, time.October, 25, 18, 0, 0, 0, time.UTC) + +// setupMuxTest produces an http.Client and an httptest.Server configured to +// use a particular http.Handler selected from a CookieMux. As this selection is +// done during the setup process, this configuration is performed by providing +// a function, and returning the desired handler. Cleanup is still the +// responsibility of the test writer, so the httptest.Server's Close() method +// should be deferred. +func setupMuxTest(selector func(*oauth2.CookieMux) http.Handler) (*http.Client, *httptest.Server, *httptest.Server) { + provider := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + rw.WriteHeader(http.StatusOK) + })) + + mp := &MockProvider{"biff@example.com", provider.URL} + + jm := oauth2.NewCookieMux(mp, &YesManAuthenticator{}, clog.New(clog.ParseLevel("debug"))) + + jm.Now = func() time.Time { + return testTime + } + + ts := httptest.NewServer(selector(jm)) + + jar, _ := cookiejar.New(nil) + + hc := http.Client{ + Jar: jar, + CheckRedirect: func(r *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + return &hc, ts, provider +} + +// teardownMuxTest cleans up any resources created by setupMuxTest. This should +// be deferred in your test after setupMuxTest is called +func teardownMuxTest(hc *http.Client, backend *httptest.Server, provider *httptest.Server) { + provider.Close() + backend.Close() +} + +func Test_CookieMux_Logout_DeletesSessionCookie(t *testing.T) { + t.Parallel() + + hc, ts, prov := setupMuxTest(func(j *oauth2.CookieMux) http.Handler { + return j.Logout() + }) + defer teardownMuxTest(hc, ts, prov) + + tsUrl, _ := url.Parse(ts.URL) + + hc.Jar.SetCookies(tsUrl, []*http.Cookie{ + &http.Cookie{ + Name: oauth2.DefaultCookieName, + Value: "", + }, + }) + + resp, err := hc.Get(ts.URL) + if err != nil { + t.Fatal("Error communicating with Logout() handler: err:", err) + } + + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + t.Fatal("Expected to be redirected, but received status code", resp.StatusCode) + } + + cookies := resp.Cookies() + if len(cookies) != 1 { + t.Fatal("Expected that cookie would be present but wasn't") + } + + c := cookies[0] + if c.Name != oauth2.DefaultCookieName || c.Expires != testTime.Add(-1*time.Hour) { + t.Fatal("Expected cookie to be expired but wasn't") + } +} + +func Test_CookieMux_Login_RedirectsToCorrectURL(t *testing.T) { + t.Parallel() + + hc, ts, prov := setupMuxTest(func(j *oauth2.CookieMux) http.Handler { + return j.Login() // Use Login handler for httptest server. + }) + defer teardownMuxTest(hc, ts, prov) + + resp, err := hc.Get(ts.URL) + if err != nil { + t.Fatal("Error communicating with Login() handler: err:", err) + } + + // Ensure we were redirected + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + t.Fatal("Expected to be redirected, but received status code", resp.StatusCode) + } + + loc, err := resp.Location() + if err != nil { + t.Fatal("Expected a location to be redirected to, but wasn't present") + } + + if state := loc.Query().Get("state"); state != "HELLO?!MCFLY?!ANYONEINTHERE?!" { + t.Fatal("Expected state to be set but was", state) + } +} + +func Test_CookieMux_Callback_SetsCookie(t *testing.T) { + hc, ts, prov := setupMuxTest(func(j *oauth2.CookieMux) http.Handler { + return j.Callback() + }) + defer teardownMuxTest(hc, ts, prov) + + tsURL, _ := url.Parse(ts.URL) + + v := url.Values{ + "code": {"4815162342"}, + "state": {"foobar"}, + } + + tsURL.RawQuery = v.Encode() + + resp, err := hc.Get(tsURL.String()) + if err != nil { + t.Fatal("Error communicating with Callback() handler: err", err) + } + + // Ensure we were redirected + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + t.Fatal("Expected to be redirected, but received status code", resp.StatusCode) + } + + // Check that cookie was set + cookies := resp.Cookies() + if count := len(cookies); count != 1 { + t.Fatal("Expected exactly one cookie to be set but found", count) + } + + c := cookies[0] + + if c.Name != oauth2.DefaultCookieName { + t.Fatal("Expected cookie to be named", oauth2.DefaultCookieName, "but was", c.Name) + } +} diff --git a/oauth2/oauth2.go b/oauth2/oauth2.go new file mode 100644 index 0000000000..393359f206 --- /dev/null +++ b/oauth2/oauth2.go @@ -0,0 +1,75 @@ +package oauth2 + +import ( + "context" + "errors" + "net/http" + "time" + + "golang.org/x/oauth2" +) + +type principalKey string + +func (p principalKey) String() string { + return string(p) +} + +var ( + // PrincipalKey is used to pass principal + // via context.Context to request-scoped + // functions. + PrincipalKey = principalKey("principal") + // ErrAuthentication means that oauth2 exchange failed + ErrAuthentication = errors.New("user not authenticated") + // ErrOrgMembership means that the user is not in the OAuth2 filtered group + ErrOrgMembership = errors.New("Not a member of the required organization") +) + +/* Types */ + +// Principal is any entity that can be authenticated +type Principal struct { + Subject string + Issuer string +} + +/* Interfaces */ + +// Provider are the common parameters for all providers (RFC 6749) +type Provider interface { + // ID is issued to the registered client by the authorization (RFC 6749 Section 2.2) + ID() string + // Secret associated is with the ID (Section 2.2) + Secret() string + // Scopes is used by the authorization server to "scope" responses (Section 3.3) + Scopes() []string + // Config is the OAuth2 configuration settings for this provider + Config() *oauth2.Config + // PrincipalID with fetch the identifier to be associated with the principal. + PrincipalID(provider *http.Client) (string, error) + + // Name is the name of the Provider + Name() string +} + +// Mux is a collection of handlers responsible for servicing an Oauth2 interaction between a browser and a provider +type Mux interface { + Login() http.Handler + Logout() http.Handler + Callback() http.Handler +} + +// Authenticator represents a service for authenticating users. +type Authenticator interface { + // Authenticate returns User associated with token if successful. + Authenticate(ctx context.Context, token string) (Principal, error) + // Token generates a valid token for Principal lasting a duration + Token(context.Context, Principal, time.Duration) (string, error) +} + +// TokenExtractor extracts tokens from http requests +type TokenExtractor interface { + // Extract will return the token or an error. + Extract(r *http.Request) (string, error) +} diff --git a/oauth2/oauth2_test.go b/oauth2/oauth2_test.go new file mode 100644 index 0000000000..60c34e1eb6 --- /dev/null +++ b/oauth2/oauth2_test.go @@ -0,0 +1,100 @@ +package oauth2_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "time" + + goauth "golang.org/x/oauth2" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/oauth2" +) + +var _ oauth2.Provider = &MockProvider{} + +type MockProvider struct { + Email string + + ProviderURL string +} + +func (mp *MockProvider) Config() *goauth.Config { + return &goauth.Config{ + RedirectURL: "http://www.example.com", + ClientID: "4815162342", + ClientSecret: "8675309", + Endpoint: goauth.Endpoint{ + mp.ProviderURL + "/oauth/auth", + mp.ProviderURL + "/oauth/token", + }, + } +} + +func (mp *MockProvider) ID() string { + return "8675309" +} + +func (mp *MockProvider) Name() string { + return "mockly" +} + +func (mp *MockProvider) PrincipalID(provider *http.Client) (string, error) { + return mp.Email, nil +} + +func (mp *MockProvider) Scopes() []string { + return []string{} +} + +func (mp *MockProvider) Secret() string { + return "4815162342" +} + +var _ oauth2.Authenticator = &YesManAuthenticator{} + +type YesManAuthenticator struct{} + +func (y *YesManAuthenticator) Authenticate(ctx context.Context, token string) (oauth2.Principal, error) { + return oauth2.Principal{ + Subject: "biff@example.com", + Issuer: "Biff Tannen's Pleasure Paradise", + }, nil +} + +func (y *YesManAuthenticator) Token(ctx context.Context, p oauth2.Principal, t time.Duration) (string, error) { + return "HELLO?!MCFLY?!ANYONEINTHERE?!", nil +} + +func NewTestTripper(log chronograf.Logger, ts *httptest.Server, rt http.RoundTripper) (*TestTripper, error) { + url, err := url.Parse(ts.URL) + if err != nil { + return nil, err + } + return &TestTripper{log, rt, url}, nil +} + +type TestTripper struct { + Log chronograf.Logger + + rt http.RoundTripper + tsURL *url.URL +} + +// RoundTrip modifies the Hostname of the incoming request to be directed to the +// test server. +func (tt *TestTripper) RoundTrip(r *http.Request) (*http.Response, error) { + tt.Log. + WithField("component", "test"). + WithField("remote_addr", r.RemoteAddr). + WithField("method", r.Method). + WithField("url", r.URL). + Info("Request") + + r.URL.Host = tt.tsURL.Host + r.URL.Scheme = tt.tsURL.Scheme + + return tt.rt.RoundTrip(r) +} diff --git a/server/dashboards.go b/server/dashboards.go index c56aa2c75f..5eef491c92 100644 --- a/server/dashboards.go +++ b/server/dashboards.go @@ -10,6 +10,13 @@ import ( "github.com/influxdata/chronograf" ) +const ( + // DefaultWidth is used if not specified + DefaultWidth = 4 + // DefaultHeight is used if not specified + DefaultHeight = 4 +) + type dashboardLinks struct { Self string `json:"self"` // Self link mapping to this resource } @@ -25,6 +32,7 @@ type getDashboardsResponse struct { func newDashboardResponse(d chronograf.Dashboard) dashboardResponse { base := "/chronograf/v1/dashboards" + DashboardDefaults(&d) return dashboardResponse{ Dashboard: d, Links: dashboardLinks{ @@ -80,7 +88,7 @@ func (s *Service) NewDashboard(w http.ResponseWriter, r *http.Request) { return } - if err := ValidDashboardRequest(dashboard); err != nil { + if err := ValidDashboardRequest(&dashboard); err != nil { invalidData(w, err, s.Logger) return } @@ -119,8 +127,8 @@ func (s *Service) RemoveDashboard(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// UpdateDashboard replaces a dashboard -func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) { +// ReplaceDashboard completely replaces a dashboard +func (s *Service) ReplaceDashboard(w http.ResponseWriter, r *http.Request) { ctx := r.Context() idParam, err := strconv.Atoi(httprouter.GetParamFromContext(ctx, "id")) if err != nil { @@ -142,7 +150,7 @@ func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) { } req.ID = id - if err := ValidDashboardRequest(req); err != nil { + if err := ValidDashboardRequest(&req); err != nil { invalidData(w, err, s.Logger) return } @@ -157,17 +165,85 @@ func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) { encodeJSON(w, http.StatusOK, res, s.Logger) } +// UpdateDashboard completely updates either the dashboard name or the cells +func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + idParam, err := strconv.Atoi(httprouter.GetParamFromContext(ctx, "id")) + if err != nil { + msg := fmt.Sprintf("Could not parse dashboard ID: %s", err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + } + id := chronograf.DashboardID(idParam) + + orig, err := s.DashboardsStore.Get(ctx, id) + if err != nil { + Error(w, http.StatusNotFound, fmt.Sprintf("ID %d not found", id), s.Logger) + return + } + + var req chronograf.Dashboard + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + invalidJSON(w, s.Logger) + return + } + req.ID = id + + if req.Name != "" { + orig.Name = req.Name + } else if len(req.Cells) > 0 { + if err := ValidDashboardRequest(&req); err != nil { + invalidData(w, err, s.Logger) + return + } + orig.Cells = req.Cells + } else { + invalidData(w, fmt.Errorf("Update must include either name or cells"), s.Logger) + return + } + + if err := s.DashboardsStore.Update(ctx, orig); err != nil { + msg := fmt.Sprintf("Error updating dashboard ID %d: %v", id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + + res := newDashboardResponse(orig) + encodeJSON(w, http.StatusOK, res, s.Logger) +} + // ValidDashboardRequest verifies that the dashboard cells have a query -func ValidDashboardRequest(d chronograf.Dashboard) error { +func ValidDashboardRequest(d *chronograf.Dashboard) error { if len(d.Cells) == 0 { return fmt.Errorf("cells are required") } - for _, c := range d.Cells { - if (len(c.Queries) == 0) { + for i, c := range d.Cells { + if len(c.Queries) == 0 { return fmt.Errorf("query required") } + CorrectWidthHeight(&c) + d.Cells[i] = c } - + DashboardDefaults(d) return nil } + +// DashboardDefaults updates the dashboard with the default values +// if none are specified +func DashboardDefaults(d *chronograf.Dashboard) { + for i, c := range d.Cells { + CorrectWidthHeight(&c) + d.Cells[i] = c + } +} + +// CorrectWidthHeight changes the cell to have at least the +// minimum width and height +func CorrectWidthHeight(c *chronograf.DashboardCell) { + if c.W < 1 { + c.W = DefaultWidth + } + if c.H < 1 { + c.H = DefaultHeight + } +} diff --git a/server/dashboards_test.go b/server/dashboards_test.go new file mode 100644 index 0000000000..d362f94fe9 --- /dev/null +++ b/server/dashboards_test.go @@ -0,0 +1,299 @@ +package server + +import ( + "reflect" + "testing" + + "github.com/influxdata/chronograf" +) + +func TestCorrectWidthHeight(t *testing.T) { + t.Parallel() + tests := []struct { + name string + cell chronograf.DashboardCell + want chronograf.DashboardCell + }{ + { + name: "updates width", + cell: chronograf.DashboardCell{ + W: 0, + H: 4, + }, + want: chronograf.DashboardCell{ + W: 4, + H: 4, + }, + }, + { + name: "updates height", + cell: chronograf.DashboardCell{ + W: 4, + H: 0, + }, + want: chronograf.DashboardCell{ + W: 4, + H: 4, + }, + }, + { + name: "updates both", + cell: chronograf.DashboardCell{ + W: 0, + H: 0, + }, + want: chronograf.DashboardCell{ + W: 4, + H: 4, + }, + }, + { + name: "updates neither", + cell: chronograf.DashboardCell{ + W: 4, + H: 4, + }, + want: chronograf.DashboardCell{ + W: 4, + H: 4, + }, + }, + } + for _, tt := range tests { + if CorrectWidthHeight(&tt.cell); !reflect.DeepEqual(tt.cell, tt.want) { + t.Errorf("%q. CorrectWidthHeight() = %v, want %v", tt.name, tt.cell, tt.want) + } + } +} + +func TestDashboardDefaults(t *testing.T) { + tests := []struct { + name string + d chronograf.Dashboard + want chronograf.Dashboard + }{ + { + name: "Updates all cell widths/heights", + d: chronograf.Dashboard{ + Cells: []chronograf.DashboardCell{ + { + W: 0, + H: 0, + }, + { + W: 2, + H: 2, + }, + }, + }, + want: chronograf.Dashboard{ + Cells: []chronograf.DashboardCell{ + { + W: 4, + H: 4, + }, + { + W: 2, + H: 2, + }, + }, + }, + }, + { + name: "Updates no cell", + d: chronograf.Dashboard{ + Cells: []chronograf.DashboardCell{ + { + W: 4, + H: 4, + }, { + W: 2, + H: 2, + }, + }, + }, + want: chronograf.Dashboard{ + Cells: []chronograf.DashboardCell{ + { + W: 4, + H: 4, + }, + { + W: 2, + H: 2, + }, + }, + }, + }, + } + for _, tt := range tests { + if DashboardDefaults(&tt.d); !reflect.DeepEqual(tt.d, tt.want) { + t.Errorf("%q. DashboardDefaults() = %v, want %v", tt.name, tt.d, tt.want) + } + } +} + +func TestValidDashboardRequest(t *testing.T) { + tests := []struct { + name string + d chronograf.Dashboard + want chronograf.Dashboard + wantErr bool + }{ + { + name: "Updates all cell widths/heights", + d: chronograf.Dashboard{ + Cells: []chronograf.DashboardCell{ + { + W: 0, + H: 0, + Queries: []chronograf.Query{ + { + Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00", + }, + }, + }, + { + W: 2, + H: 2, + Queries: []chronograf.Query{ + { + Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00", + }, + }, + }, + }, + }, + want: chronograf.Dashboard{ + Cells: []chronograf.DashboardCell{ + { + W: 4, + H: 4, + Queries: []chronograf.Query{ + { + Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00", + }, + }, + }, + { + W: 2, + H: 2, + Queries: []chronograf.Query{ + { + Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00", + }, + }, + }, + }, + }, + }, + { + name: "No queries", + d: chronograf.Dashboard{ + Cells: []chronograf.DashboardCell{ + { + W: 2, + H: 2, + Queries: []chronograf.Query{}, + }, + }, + }, + want: chronograf.Dashboard{ + Cells: []chronograf.DashboardCell{ + { + W: 2, + H: 2, + Queries: []chronograf.Query{}, + }, + }, + }, + wantErr: true, + }, + { + name: "Empty Cells", + d: chronograf.Dashboard{ + Cells: []chronograf.DashboardCell{}, + }, + want: chronograf.Dashboard{ + Cells: []chronograf.DashboardCell{}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + err := ValidDashboardRequest(&tt.d) + if (err != nil) != tt.wantErr { + t.Errorf("%q. ValidDashboardRequest() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if !reflect.DeepEqual(tt.d, tt.want) { + t.Errorf("%q. ValidDashboardRequest() = %v, want %v", tt.name, tt.d, tt.want) + } + } +} + +func Test_newDashboardResponse(t *testing.T) { + tests := []struct { + name string + d chronograf.Dashboard + want dashboardResponse + }{ + { + name: "Updates all cell widths/heights", + d: chronograf.Dashboard{ + Cells: []chronograf.DashboardCell{ + { + W: 0, + H: 0, + Queries: []chronograf.Query{ + { + Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00", + }, + }, + }, + { + W: 0, + H: 0, + Queries: []chronograf.Query{ + { + Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00", + }, + }, + }, + }, + }, + want: dashboardResponse{ + Dashboard: chronograf.Dashboard{ + Cells: []chronograf.DashboardCell{ + { + W: 4, + H: 4, + Queries: []chronograf.Query{ + { + Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00", + }, + }, + }, + { + W: 4, + H: 4, + Queries: []chronograf.Query{ + { + Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00", + }, + }, + }, + }, + }, + Links: dashboardLinks{ + Self: "/chronograf/v1/dashboards/0", + }, + }, + }, + } + for _, tt := range tests { + if got := newDashboardResponse(tt.d); !reflect.DeepEqual(got, tt.want) { + t.Errorf("%q. newDashboardResponse() = %v, want %v", tt.name, got, tt.want) + } + } +} diff --git a/server/github.go b/server/github.go deleted file mode 100644 index b4cdf92bbf..0000000000 --- a/server/github.go +++ /dev/null @@ -1,279 +0,0 @@ -package server - -import ( - "crypto/rand" - "encoding/base64" - "errors" - "io" - "net/http" - "time" - - "github.com/google/go-github/github" - "github.com/influxdata/chronograf" - "golang.org/x/oauth2" - ogh "golang.org/x/oauth2/github" -) - -const ( - // DefaultCookieName is the name of the stored cookie - DefaultCookieName = "session" - // DefaultCookieDuration is the length of time the cookie is valid - DefaultCookieDuration = time.Hour * 24 * 30 -) - -// Cookie represents the location and expiration time of new cookies. -type Cookie struct { - Name string - Duration time.Duration -} - -// NewCookie creates a Cookie with DefaultCookieName and DefaultCookieDuration -func NewCookie() Cookie { - return Cookie{ - Name: DefaultCookieName, - Duration: DefaultCookieDuration, - } -} - -// Github provides OAuth Login and Callback server. Callback will set -// an authentication cookie. This cookie's value is a JWT containing -// the user's primary Github email address. -type Github struct { - Cookie Cookie - Authenticator chronograf.Authenticator - ClientID string - ClientSecret string - Scopes []string - SuccessURL string // SuccessURL is redirect location after successful authorization - FailureURL string // FailureURL is redirect location after authorization failure - Orgs []string // Optional github organization checking - Now func() time.Time - Logger chronograf.Logger -} - -// NewGithub constructs a Github with default cookie behavior and scopes. -func NewGithub(clientID, clientSecret, successURL, failureURL string, orgs []string, auth chronograf.Authenticator, log chronograf.Logger) Github { - scopes := []string{"user:email"} - if len(orgs) > 0 { - scopes = append(scopes, "read:org") - } - return Github{ - ClientID: clientID, - ClientSecret: clientSecret, - Cookie: NewCookie(), - Scopes: scopes, - Orgs: orgs, - SuccessURL: successURL, - FailureURL: failureURL, - Authenticator: auth, - Now: time.Now, - Logger: log, - } -} - -func (g *Github) config() *oauth2.Config { - return &oauth2.Config{ - ClientID: g.ClientID, - ClientSecret: g.ClientSecret, - Scopes: g.Scopes, - Endpoint: ogh.Endpoint, - } -} - -// Login returns a handler that redirects to Github's OAuth login. -// Uses JWT with a random string as the state validation method. -// JWTs are used because they can be validated without storing -// state. -func (g *Github) Login() http.HandlerFunc { - conf := g.config() - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // We are creating a token with an encoded random string to prevent CSRF attacks - // This token will be validated during the OAuth callback. - // We'll give our users 10 minutes from this point to type in their github password. - // If the callback is not received within 10 minutes, then authorization will fail. - csrf := randomString(32) // 32 is not important... just long - state, err := g.Authenticator.Token(r.Context(), chronograf.Principal(csrf), 10*time.Minute) - // This is likely an internal server error - if err != nil { - g.Logger. - WithField("component", "auth"). - WithField("remote_addr", r.RemoteAddr). - WithField("method", r.Method). - WithField("url", r.URL). - Error("Internal authentication error: ", err.Error()) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - url := conf.AuthCodeURL(state, oauth2.AccessTypeOnline) - http.Redirect(w, r, url, http.StatusTemporaryRedirect) - }) -} - -// Logout will expire our authentication cookie and redirect to the SuccessURL -func (g *Github) Logout() http.HandlerFunc { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - deleteCookie := http.Cookie{ - Name: g.Cookie.Name, - Value: "none", - Expires: g.Now().UTC().Add(-1 * time.Hour), - HttpOnly: true, - Path: "/", - } - http.SetCookie(w, &deleteCookie) - http.Redirect(w, r, g.SuccessURL, http.StatusTemporaryRedirect) - }) -} - -// Callback used by github callback after authorization is granted. If -// granted, Callback will set a cookie with a month-long expiration. The -// value of the cookie is a JWT because the JWT can be validated without -// the need for saving state. The JWT contains the Github user's primary -// email address. -func (g *Github) Callback() http.HandlerFunc { - conf := g.config() - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log := g.Logger. - WithField("component", "auth"). - WithField("remote_addr", r.RemoteAddr). - WithField("method", r.Method). - WithField("url", r.URL) - - state := r.FormValue("state") - // Check if the OAuth state token is valid to prevent CSRF - _, err := g.Authenticator.Authenticate(r.Context(), state) - if err != nil { - log.Error("Invalid OAuth state received: ", err.Error()) - http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect) - return - } - - code := r.FormValue("code") - token, err := conf.Exchange(r.Context(), code) - if err != nil { - log.Error("Unable to exchange code for token ", err.Error()) - http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect) - return - } - - oauthClient := conf.Client(r.Context(), token) - client := github.NewClient(oauthClient) - // If we need to restrict to a set of organizations, we first get the org - // and filter. - if len(g.Orgs) > 0 { - orgs, err := getOrganizations(client, log) - if err != nil { - http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect) - return - } - // Not a member, so, deny permission - if ok := isMember(g.Orgs, orgs); !ok { - log.Error("Not a member of required github organization") - http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect) - return - } - } - - email, err := getPrimaryEmail(client, log) - if err != nil { - http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect) - return - } - - // We create an auth token that will be used by all other endpoints to validate the principal has a claim - authToken, err := g.Authenticator.Token(r.Context(), chronograf.Principal(email), g.Cookie.Duration) - if err != nil { - log.Error("Unable to create cookie auth token ", err.Error()) - http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect) - return - } - - expireCookie := time.Now().UTC().Add(g.Cookie.Duration) - cookie := http.Cookie{ - Name: g.Cookie.Name, - Value: authToken, - Expires: expireCookie, - HttpOnly: true, - Path: "/", - } - log.Info("User ", email, " is authenticated") - http.SetCookie(w, &cookie) - http.Redirect(w, r, g.SuccessURL, http.StatusTemporaryRedirect) - }) -} - -func randomString(length int) string { - k := make([]byte, length) - if _, err := io.ReadFull(rand.Reader, k); err != nil { - return "" - } - return base64.StdEncoding.EncodeToString(k) -} - -func logResponseError(log chronograf.Logger, resp *github.Response, err error) { - switch resp.StatusCode { - case http.StatusUnauthorized, http.StatusForbidden: - log.Error("OAuth access to email address forbidden ", err.Error()) - default: - log.Error("Unable to retrieve Github email ", err.Error()) - } -} - -// isMember makes sure that the user is in one of the required organizations -func isMember(requiredOrgs []string, userOrgs []*github.Organization) bool { - for _, requiredOrg := range requiredOrgs { - for _, userOrg := range userOrgs { - if userOrg.Login != nil && *userOrg.Login == requiredOrg { - return true - } - } - } - return false -} - -// getOrganizations gets all organization for the currently authenticated user -func getOrganizations(client *github.Client, log chronograf.Logger) ([]*github.Organization, error) { - // Get all pages of results - var allOrgs []*github.Organization - for { - opt := &github.ListOptions{ - PerPage: 10, - } - // Get the organizations for the current authenticated user. - orgs, resp, err := client.Organizations.List("", opt) - if err != nil { - logResponseError(log, resp, err) - return nil, err - } - allOrgs = append(allOrgs, orgs...) - if resp.NextPage == 0 { - break - } - opt.Page = resp.NextPage - } - return allOrgs, nil -} - -// getPrimaryEmail gets the primary email account for the authenticated user. -func getPrimaryEmail(client *github.Client, log chronograf.Logger) (string, error) { - emails, resp, err := client.Users.ListEmails(nil) - if err != nil { - logResponseError(log, resp, err) - return "", err - } - - email, err := primaryEmail(emails) - if err != nil { - log.Error("Unable to retrieve primary Github email ", err.Error()) - return "", err - } - return email, nil -} - -func primaryEmail(emails []*github.UserEmail) (string, error) { - for _, m := range emails { - if m != nil && m.Primary != nil && m.Verified != nil && m.Email != nil { - return *m.Email, nil - } - } - return "", errors.New("No primary email address") -} diff --git a/server/logout.go b/server/logout.go new file mode 100644 index 0000000000..3e827c2afd --- /dev/null +++ b/server/logout.go @@ -0,0 +1,21 @@ +package server + +import "net/http" + +// Logout chooses the correct provider logout route and redirects to it +func Logout(nextURL string, routes AuthRoutes) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + principal, err := getPrincipal(ctx) + if err != nil { + http.Redirect(w, r, nextURL, http.StatusTemporaryRedirect) + return + } + route, ok := routes.Lookup(principal.Issuer) + if !ok { + http.Redirect(w, r, nextURL, http.StatusTemporaryRedirect) + return + } + http.Redirect(w, r, route.Logout, http.StatusTemporaryRedirect) + } +} diff --git a/server/mux.go b/server/mux.go index 7c3d1da6bd..1738ba2553 100644 --- a/server/mux.go +++ b/server/mux.go @@ -10,7 +10,7 @@ import ( "github.com/NYTimes/gziphandler" "github.com/bouk/httprouter" "github.com/influxdata/chronograf" // When julienschmidt/httprouter v2 w/ context is out, switch - "github.com/influxdata/chronograf/jwt" + "github.com/influxdata/chronograf/oauth2" ) const ( @@ -20,13 +20,13 @@ const ( // MuxOpts are the options for the router. Mostly related to auth. type MuxOpts struct { - Logger chronograf.Logger - Develop bool // Develop loads assets from filesystem instead of bindata - UseAuth bool // UseAuth turns on Github OAuth and JWT - TokenSecret string // TokenSecret is the JWT secret - GithubClientID string // GithubClientID is the GH OAuth id - GithubClientSecret string // GithubClientSecret is the GH OAuth secret - GithubOrgs []string // GithubOrgs is the list of organizations a user my be a member of + Logger chronograf.Logger + Develop bool // Develop loads assets from filesystem instead of bindata + Basepath string // URL path prefix under which all chronograf routes will be mounted + UseAuth bool // UseAuth turns on Github OAuth and JWT + TokenSecret string + + ProviderFuncs []func(func(oauth2.Provider, oauth2.Mux)) } // NewMux attaches all the route handlers; handler returned servers chronograf. @@ -55,9 +55,6 @@ func NewMux(opts MuxOpts, service Service) http.Handler { router.GET("/docs", Redoc("/swagger.json")) /* API */ - // Root Routes returns all top-level routes in the API - router.GET("/chronograf/v1/", AllRoutes(opts.Logger)) - // Sources router.GET("/chronograf/v1/sources", service.Sources) router.POST("/chronograf/v1/sources", service.NewSource) @@ -131,47 +128,65 @@ func NewMux(opts MuxOpts, service Service) http.Handler { router.GET("/chronograf/v1/dashboards/:id", service.DashboardID) router.DELETE("/chronograf/v1/dashboards/:id", service.RemoveDashboard) - router.PUT("/chronograf/v1/dashboards/:id", service.UpdateDashboard) + router.PUT("/chronograf/v1/dashboards/:id", service.ReplaceDashboard) + router.PATCH("/chronograf/v1/dashboards/:id", service.UpdateDashboard) + var authRoutes AuthRoutes + + var out http.Handler /* Authentication */ if opts.UseAuth { - auth := AuthAPI(opts, router) - return Logger(opts.Logger, auth) + // Encapsulate the router with OAuth2 + var auth http.Handler + auth, authRoutes = AuthAPI(opts, router) + + // Create middleware to redirect to the appropriate provider logout + targetURL := "/" + router.GET("/oauth/logout", Logout(targetURL, authRoutes)) + + out = Logger(opts.Logger, auth) + } else { + out = Logger(opts.Logger, router) } - logged := Logger(opts.Logger, router) - return logged + router.GET("/chronograf/v1/", AllRoutes(authRoutes, opts.Logger)) + router.GET("/chronograf/v1", AllRoutes(authRoutes, opts.Logger)) + + return out } // AuthAPI adds the OAuth routes if auth is enabled. -func AuthAPI(opts MuxOpts, router *httprouter.Router) http.Handler { - auth := jwt.NewJWT(opts.TokenSecret) +// TODO: this function is not great. Would be good if providers added their routes. +func AuthAPI(opts MuxOpts, router *httprouter.Router) (http.Handler, AuthRoutes) { + auth := oauth2.NewJWT(opts.TokenSecret) + routes := AuthRoutes{} + for _, pf := range opts.ProviderFuncs { + pf(func(p oauth2.Provider, m oauth2.Mux) { + loginPath := fmt.Sprintf("%s/oauth/%s/login", opts.Basepath, strings.ToLower(p.Name())) + logoutPath := fmt.Sprintf("%s/oauth/%s/logout", opts.Basepath, strings.ToLower(p.Name())) + callbackPath := fmt.Sprintf("%s/oauth/%s/callback", opts.Basepath, strings.ToLower(p.Name())) + router.Handler("GET", loginPath, m.Login()) + router.Handler("GET", logoutPath, m.Logout()) + router.Handler("GET", callbackPath, m.Callback()) + routes = append(routes, AuthRoute{ + Name: p.Name(), + Label: strings.Title(p.Name()), + Login: loginPath, + Logout: logoutPath, + Callback: callbackPath, + }) + }) + } - successURL := "/" - failureURL := "/login" - gh := NewGithub( - opts.GithubClientID, - opts.GithubClientSecret, - successURL, - failureURL, - opts.GithubOrgs, - &auth, - opts.Logger, - ) - - router.GET("/oauth/github", gh.Login()) - router.GET("/oauth/logout", gh.Logout()) - router.GET("/oauth/github/callback", gh.Callback()) - - tokenMiddleware := AuthorizedToken(&auth, &CookieExtractor{Name: "session"}, opts.Logger, router) + tokenMiddleware := oauth2.AuthorizedToken(&auth, &oauth2.CookieExtractor{Name: "session"}, opts.Logger, router) // Wrap the API with token validation middleware. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/chronograf/v1/") { + if strings.HasPrefix(r.URL.Path, "/chronograf/v1/") || r.URL.Path == "/oauth/logout" { tokenMiddleware.ServeHTTP(w, r) return } router.ServeHTTP(w, r) - }) + }), routes } func encodeJSON(w http.ResponseWriter, status int, v interface{}, logger chronograf.Logger) { diff --git a/server/routes.go b/server/routes.go index 172aa65b01..8797667449 100644 --- a/server/routes.go +++ b/server/routes.go @@ -6,22 +6,50 @@ import ( "github.com/influxdata/chronograf" ) +// AuthRoute are the routes for each type of OAuth2 provider +type AuthRoute struct { + Name string `json:"name"` // Name uniquely identifies the provider + Label string `json:"label"` // Label is a user-facing string to present in the UI + Login string `json:"login"` // Login is the route to the login redirect path + Logout string `json:"logout"` // Logout is the route to the logout redirect path + Callback string `json:"callback"` // Callback is the route the provider calls to exchange the code/state +} + +// AuthRoutes contains all OAuth2 provider routes. +type AuthRoutes []AuthRoute + +// Lookup searches all the routes for a specific provider +func (r *AuthRoutes) Lookup(provider string) (AuthRoute, bool) { + for _, route := range *r { + if route.Name == provider { + return route, true + } + } + return AuthRoute{}, false +} + type getRoutesResponse struct { - Layouts string `json:"layouts"` // Location of the layouts endpoint - Mappings string `json:"mappings"` // Location of the application mappings endpoint - Sources string `json:"sources"` // Location of the sources endpoint - Me string `json:"me"` // Location of the me endpoint - Dashboards string `json:"dashboards"` // Location of the dashboards endpoint + Layouts string `json:"layouts"` // Location of the layouts endpoint + Mappings string `json:"mappings"` // Location of the application mappings endpoint + Sources string `json:"sources"` // Location of the sources endpoint + Me string `json:"me"` // Location of the me endpoint + Dashboards string `json:"dashboards"` // Location of the dashboards endpoint + Auth []AuthRoute `json:"auth"` // Location of all auth routes. } // AllRoutes returns all top level routes within chronograf -func AllRoutes(logger chronograf.Logger) http.HandlerFunc { +func AllRoutes(authRoutes []AuthRoute, logger chronograf.Logger) http.HandlerFunc { 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)), + } + + for i, route := range authRoutes { + routes.Auth[i] = route } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/server/server.go b/server/server.go index ddecda3361..f9a12cd971 100644 --- a/server/server.go +++ b/server/server.go @@ -14,6 +14,7 @@ import ( "github.com/influxdata/chronograf/canned" "github.com/influxdata/chronograf/layouts" clog "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/oauth2" "github.com/influxdata/chronograf/uuid" client "github.com/influxdata/usage-client/v1" flags "github.com/jessevdk/go-flags" @@ -37,20 +38,88 @@ type Server struct { Cert flags.Filename `long:"cert" description:"Path to PEM encoded public key certificate. " env:"TLS_CERTIFICATE"` Key flags.Filename `long:"key" description:"Path to private key associated with given certificate. " env:"TLS_PRIVATE_KEY"` - Develop bool `short:"d" long:"develop" description:"Run server in develop mode."` - BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (/var/lib/chronograf/chronograf-v1.db)" env:"BOLT_PATH" default:"chronograf-v1.db"` - CannedPath string `short:"c" long:"canned-path" description:"Path to directory of pre-canned application layouts (/usr/share/chronograf/canned)" env:"CANNED_PATH" default:"canned"` - TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` + Develop bool `short:"d" long:"develop" description:"Run server in develop mode."` + BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (/var/lib/chronograf/chronograf-v1.db)" env:"BOLT_PATH" default:"chronograf-v1.db"` + CannedPath string `short:"c" long:"canned-path" description:"Path to directory of pre-canned application layouts (/usr/share/chronograf/canned)" env:"CANNED_PATH" default:"canned"` + TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` + GithubClientID string `short:"i" long:"github-client-id" description:"Github Client ID for OAuth 2 support" env:"GH_CLIENT_ID"` GithubClientSecret string `short:"s" long:"github-client-secret" description:"Github Client Secret for OAuth 2 support" env:"GH_CLIENT_SECRET"` GithubOrgs []string `short:"o" long:"github-organization" description:"Github organization user is required to have active membership" env:"GH_ORGS" env-delim:","` - ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"` - LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"fatal" choice:"panic" default:"info" description:"Set the logging level" env:"LOG_LEVEL"` - Basepath string `short:"p" long:"basepath" description:"A URL path prefix under which all chronograf routes will be mounted" env:"BASE_PATH"` - ShowVersion bool `short:"v" long:"version" description:"Show Chronograf version info"` - BuildInfo BuildInfo - Listener net.Listener - handler http.Handler + + GoogleClientID string `long:"google-client-id" description:"Google Client ID for OAuth 2 support" env:"GOOGLE_CLIENT_ID"` + GoogleClientSecret string `long:"google-client-secret" description:"Google Client Secret for OAuth 2 support" env:"GOGGLE_CLIENT_SECRET"` + GoogleDomains []string `long:"google-domains" description:"Google email domain user is required to have active membership" env:"GOOGLE_DOMAINS" env-delim:","` + PublicURL string `long:"public-url" description:"Full public URL used to access Chronograf from a web browser. Used for Google OAuth2 authentication. (http://localhost:8888)" env:"PUBLIC_URL"` + + HerokuClientID string `long:"heroku-client-id" description:"Heroku Client ID for OAuth 2 support" env:"HEROKU_CLIENT_ID"` + HerokuSecret string `long:"heroku-secret" description:"Heroku Secret for OAuth 2 support" env:"HEROKU_SECRET"` + HerokuOrganizations []string `long:"heroku-organization" description:"Heroku Organization Memberships a user is required to have for access to Chronograf (comma separated)" env:"HEROKU_ORGS" env-delim:","` + + ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"` + LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"fatal" choice:"panic" default:"info" description:"Set the logging level" env:"LOG_LEVEL"` + Basepath string `short:"p" long:"basepath" description:"A URL path prefix under which all chronograf routes will be mounted" env:"BASE_PATH"` + ShowVersion bool `short:"v" long:"version" description:"Show Chronograf version info"` + BuildInfo BuildInfo + Listener net.Listener + handler http.Handler +} + +func provide(p oauth2.Provider, m oauth2.Mux, ok func() bool) func(func(oauth2.Provider, oauth2.Mux)) { + return func(configure func(oauth2.Provider, oauth2.Mux)) { + if ok() { + configure(p, m) + } + } +} + +func (s *Server) UseGithub() bool { + return s.TokenSecret != "" && s.GithubClientID != "" && s.GithubClientSecret != "" +} + +func (s *Server) UseGoogle() bool { + return s.TokenSecret != "" && s.GoogleClientID != "" && s.GoogleClientSecret != "" && s.PublicURL != "" +} + +func (s *Server) UseHeroku() bool { + return s.TokenSecret != "" && s.HerokuClientID != "" && s.HerokuSecret != "" +} + +func (s *Server) githubOAuth(logger chronograf.Logger, auth oauth2.Authenticator) (oauth2.Provider, oauth2.Mux, func() bool) { + gh := oauth2.Github{ + ClientID: s.GithubClientID, + ClientSecret: s.GithubClientSecret, + Orgs: s.GithubOrgs, + Logger: logger, + } + ghMux := oauth2.NewCookieMux(&gh, auth, logger) + return &gh, ghMux, s.UseGithub +} + +func (s *Server) googleOAuth(logger chronograf.Logger, auth oauth2.Authenticator) (oauth2.Provider, oauth2.Mux, func() bool) { + redirectURL := s.PublicURL + s.Basepath + "/oauth/google/callback" + google := oauth2.Google{ + ClientID: s.GoogleClientID, + ClientSecret: s.GoogleClientSecret, + Domains: s.GoogleDomains, + RedirectURL: redirectURL, + Logger: logger, + } + + goMux := oauth2.NewCookieMux(&google, auth, logger) + return &google, goMux, s.UseGoogle +} + +func (s *Server) herokuOAuth(logger chronograf.Logger, auth oauth2.Authenticator) (oauth2.Provider, oauth2.Mux, func() bool) { + heroku := oauth2.Heroku{ + ClientID: s.HerokuClientID, + ClientSecret: s.HerokuSecret, + Organizations: s.HerokuOrganizations, + Logger: logger, + } + + hMux := oauth2.NewCookieMux(&heroku, auth, logger) + return &heroku, hMux, s.UseHeroku } // BuildInfo is sent to the usage client to track versions and commits @@ -60,7 +129,10 @@ type BuildInfo struct { } func (s *Server) useAuth() bool { - return s.TokenSecret != "" && s.GithubClientID != "" && s.GithubClientSecret != "" + gh := s.TokenSecret != "" && s.GithubClientID != "" && s.GithubClientSecret != "" + google := s.TokenSecret != "" && s.GoogleClientID != "" && s.GoogleClientSecret != "" && s.PublicURL != "" + heroku := s.TokenSecret != "" && s.HerokuClientID != "" && s.HerokuSecret != "" + return gh || google || heroku } func (s *Server) useTLS() bool { @@ -104,14 +176,19 @@ func (s *Server) Serve() error { service := openService(s.BoltPath, s.CannedPath, logger, s.useAuth()) basepath = s.Basepath + providerFuncs := []func(func(oauth2.Provider, oauth2.Mux)){} + + auth := oauth2.NewJWT(s.TokenSecret) + providerFuncs = append(providerFuncs, provide(s.githubOAuth(logger, &auth))) + providerFuncs = append(providerFuncs, provide(s.googleOAuth(logger, &auth))) + providerFuncs = append(providerFuncs, provide(s.herokuOAuth(logger, &auth))) + s.handler = NewMux(MuxOpts{ - Develop: s.Develop, - TokenSecret: s.TokenSecret, - GithubClientID: s.GithubClientID, - GithubClientSecret: s.GithubClientSecret, - GithubOrgs: s.GithubOrgs, - Logger: logger, - UseAuth: s.useAuth(), + Develop: s.Develop, + TokenSecret: s.TokenSecret, + Logger: logger, + UseAuth: s.useAuth(), + ProviderFuncs: providerFuncs, }, service) // Add chronograf's version header to all requests diff --git a/server/swagger.json b/server/swagger.json index 2d623cd5aa..ea03cd5cd4 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -1863,6 +1863,51 @@ } } } + }, + "patch": { + "tags": [ + "layouts" + ], + "summary": "Update dashboard information.", + "description": "Update either the dashboard name or the dashboard cells", + "parameters": [ + { + "name": "id", + "in": "path", + "type": "integer", + "description": "ID of a dashboard", + "required": true + }, + { + "name": "config", + "in": "body", + "description": "dashboard configuration update parameters. Must be either name or cells", + "schema": { + "$ref": "#/definitions/Dashboard" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Dashboard has been updated and the new dashboard is returned.", + "schema": { + "$ref": "#/definitions/Dashboard" + } + }, + "404": { + "description": "Happens when trying to access a non-existent dashboard.", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "default": { + "description": "A processing or an unexpected error.", + "schema": { + "$ref": "#/definitions/Error" + } + } + } } } }, @@ -3013,12 +3058,16 @@ "w": { "description": "Width of Cell in the Dashboard", "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 1, + "default": 4 }, "h": { "description": "Height of Cell in the Dashboard", "type": "integer", - "format": "int32" + "format": "int32", + "minimum": 1, + "default": 4 }, "queries": { "description": "Time-series data queries for Cell.", @@ -3033,7 +3082,9 @@ "enum": [ "single-stat", "line", - "line-plus-single-stat" + "line-plus-single-stat", + "line-stacked", + "line-stepplot" ], "default": "line" } diff --git a/server/users.go b/server/users.go index 595d38b3e9..bbf4f61db1 100644 --- a/server/users.go +++ b/server/users.go @@ -8,6 +8,7 @@ import ( "golang.org/x/net/context" "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/oauth2" ) type userLinks struct { @@ -19,42 +20,62 @@ type userResponse struct { Links userLinks `json:"links"` } +// If new user response is nil, return an empty userResponse because it +// indicates authentication is not needed func newUserResponse(usr *chronograf.User) userResponse { base := "/chronograf/v1/users" - // TODO: Change to usrl.PathEscape for go 1.8 - u := &url.URL{Path: usr.Name} - encodedUser := u.String() + name := "me" + if usr != nil { + // TODO: Change to usrl.PathEscape for go 1.8 + u := &url.URL{Path: usr.Name} + name = u.String() + } + return userResponse{ User: usr, Links: userLinks{ - Self: fmt.Sprintf("%s/%s", base, encodedUser), + Self: fmt.Sprintf("%s/%s", base, name), }, } } -func getPrincipal(ctx context.Context) (string, error) { - principal := ctx.Value(chronograf.PrincipalKey).(chronograf.Principal) - if principal == "" { +func getEmail(ctx context.Context) (string, error) { + principal, err := getPrincipal(ctx) + if err != nil { + return "", err + } + if principal.Subject == "" { return "", fmt.Errorf("Token not found") } - return string(principal), nil + return principal.Subject, nil +} + +func getPrincipal(ctx context.Context) (oauth2.Principal, error) { + principal, ok := ctx.Value(oauth2.PrincipalKey).(oauth2.Principal) + if !ok { + return oauth2.Principal{}, fmt.Errorf("Token not found") + } + + return principal, nil } // Me does a findOrCreate based on the email in the context func (h *Service) Me(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !h.UseAuth { - // Using status code to signal no need for authentication - w.WriteHeader(http.StatusTeapot) + // If there's no authentication, return an empty user + res := newUserResponse(nil) + encodeJSON(w, http.StatusOK, res, h.Logger) return } - principal, err := getPrincipal(ctx) + + email, err := getEmail(ctx) if err != nil { invalidData(w, err, h.Logger) return } - usr, err := h.UsersStore.Get(ctx, principal) + usr, err := h.UsersStore.Get(ctx, email) if err == nil { res := newUserResponse(usr) encodeJSON(w, http.StatusOK, res, h.Logger) @@ -63,7 +84,7 @@ func (h *Service) Me(w http.ResponseWriter, r *http.Request) { // Because we didnt find a user, making a new one user := &chronograf.User{ - Name: principal, + Name: email, } newUser, err := h.UsersStore.Add(ctx, user) diff --git a/server/users_test.go b/server/users_test.go index 42161f8f4c..147bf8f3ae 100644 --- a/server/users_test.go +++ b/server/users_test.go @@ -11,6 +11,7 @@ import ( "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/log" "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/oauth2" ) type MockUsers struct{} @@ -29,7 +30,7 @@ func TestService_Me(t *testing.T) { name string fields fields args args - principal chronograf.Principal + principal oauth2.Principal wantStatus int wantContentType string wantBody string @@ -51,7 +52,9 @@ func TestService_Me(t *testing.T) { }, }, }, - principal: "me", + principal: oauth2.Principal{ + Subject: "me", + }, wantStatus: http.StatusOK, wantContentType: "application/json", wantBody: `{"name":"me","password":"hunter2","links":{"self":"/chronograf/v1/users/me"}} @@ -74,7 +77,9 @@ func TestService_Me(t *testing.T) { }, }, }, - principal: "secret", + principal: oauth2.Principal{ + Subject: "secret", + }, wantStatus: http.StatusOK, wantContentType: "application/json", wantBody: `{"name":"secret","password":"","links":{"self":"/chronograf/v1/users/secret"}} @@ -98,13 +103,15 @@ func TestService_Me(t *testing.T) { }, Logger: log.New(log.DebugLevel), }, - principal: "secret", + principal: oauth2.Principal{ + Subject: "secret", + }, wantStatus: http.StatusInternalServerError, wantContentType: "application/json", wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`, }, { - name: "No Auth Teapot", + name: "No Auth", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest("GET", "http://example.com/foo", nil), @@ -113,7 +120,10 @@ func TestService_Me(t *testing.T) { UseAuth: false, Logger: log.New(log.DebugLevel), }, - wantStatus: http.StatusTeapot, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/users/me"}} +`, }, { name: "Empty Principal", @@ -126,11 +136,13 @@ func TestService_Me(t *testing.T) { Logger: log.New(log.DebugLevel), }, wantStatus: http.StatusUnprocessableEntity, - principal: "", + principal: oauth2.Principal{ + Subject: "", + }, }, } for _, tt := range tests { - tt.args.r = tt.args.r.WithContext(context.WithValue(context.Background(), chronograf.PrincipalKey, tt.principal)) + tt.args.r = tt.args.r.WithContext(context.WithValue(context.Background(), oauth2.PrincipalKey, tt.principal)) h := &Service{ UsersStore: tt.fields.UsersStore, Logger: tt.fields.Logger, diff --git a/ui/.eslintrc b/ui/.eslintrc index 360af5a034..c86aa2eea6 100644 --- a/ui/.eslintrc +++ b/ui/.eslintrc @@ -182,7 +182,7 @@ 'quote-props': [2, 'as-needed', {keywords: true, numbers: false }], 'require-jsdoc': 0, 'semi-spacing': [2, {before: false, after: true}], - 'semi': [2, 'always'], + // 'semi': [2, 'always'], 'sort-vars': 0, 'keyword-spacing': 'error', 'space-before-blocks': [2, 'always'], @@ -194,7 +194,7 @@ 'wrap-regex': 0, 'arrow-body-style': 0, 'arrow-spacing': [2, {before: true, after: true}], - 'no-confusing-arrow': 2, + 'no-confusing-arrow': 0, 'no-class-assign': 2, 'no-const-assign': 2, 'no-dupe-class-members': 2, diff --git a/ui/spec/dashboards/reducers/uiSpec.js b/ui/spec/dashboards/reducers/uiSpec.js new file mode 100644 index 0000000000..3fb00e3922 --- /dev/null +++ b/ui/spec/dashboards/reducers/uiSpec.js @@ -0,0 +1,52 @@ +import reducer from 'src/dashboards/reducers/ui' +import timeRanges from 'hson!src/shared/data/timeRanges.hson'; + +import { + loadDashboards, + setDashboard, + setTimeRange, + setEditMode, +} from 'src/dashboards/actions' + +const noopAction = () => { + return {type: 'NOOP'} +} + +let state = undefined +const timeRange = timeRanges[1]; +const d1 = {id: 1, cells: [], name: "d1"} +const d2 = {id: 2, cells: [], name: "d2"} +const dashboards = [d1, d2] + +describe('DataExplorer.Reducers.UI', () => { + it('can load the dashboards', () => { + const actual = reducer(state, loadDashboards(dashboards, d1.id)) + const expected = { + dashboards, + dashboard: d1, + } + + expect(actual.dashboards).to.deep.equal(expected.dashboards) + expect(actual.dashboard).to.deep.equal(expected.dashboard) + }) + + it('can set a dashboard', () => { + const loadedState = reducer(state, loadDashboards(dashboards, d1.id)) + const actual = reducer(loadedState, setDashboard(d2.id)) + + expect(actual.dashboard).to.deep.equal(d2) + }) + + it('can set the time range', () => { + const expected = {upper: null, lower: 'now() - 1h'} + const actual = reducer(state, setTimeRange(expected)) + + expect(actual.timeRange).to.deep.equal(expected) + }) + + it('can set edit mode', () => { + const isEditMode = true + const actual = reducer(state, setEditMode(isEditMode)) + expect(actual.isEditMode).to.equal(isEditMode) + }) +}) diff --git a/ui/src/App.js b/ui/src/App.js index 0c1acfa8f9..f19438b16a 100644 --- a/ui/src/App.js +++ b/ui/src/App.js @@ -8,22 +8,29 @@ import { dismissAllNotifications as dismissAllNotificationsAction, } from 'src/shared/actions/notifications'; +const { + node, + shape, + string, + func, +} = PropTypes + const App = React.createClass({ propTypes: { - children: PropTypes.node.isRequired, - location: PropTypes.shape({ - pathname: PropTypes.string, + children: node.isRequired, + location: shape({ + pathname: string, }), - params: PropTypes.shape({ - sourceID: PropTypes.string.isRequired, + params: shape({ + sourceID: string.isRequired, }).isRequired, - publishNotification: PropTypes.func.isRequired, - dismissNotification: PropTypes.func.isRequired, - dismissAllNotifications: PropTypes.func.isRequired, - notifications: PropTypes.shape({ - success: PropTypes.string, - error: PropTypes.string, - warning: PropTypes.string, + publishNotification: func.isRequired, + dismissNotification: func.isRequired, + dismissAllNotifications: func.isRequired, + notifications: shape({ + success: string, + error: string, + warning: string, }), }, @@ -46,11 +53,15 @@ const App = React.createClass({ }, render() { - const {sourceID} = this.props.params; + const {params: {sourceID}} = this.props; return (
- + {this.renderNotifications()} {this.props.children && React.cloneElement(this.props.children, { addFlashMessage: this.handleNotification, diff --git a/ui/src/auth/Login.js b/ui/src/auth/Login.js index 8ce2f72f52..87a9dd4ba0 100644 --- a/ui/src/auth/Login.js +++ b/ui/src/auth/Login.js @@ -1,21 +1,33 @@ -import React from 'react'; -import {withRouter} from 'react-router'; +/* global VERSION */ +import React, {PropTypes} from 'react' +import {connect} from 'react-redux' -const Login = React.createClass({ - render() { - return ( -
-
-
-

Chronograf

-

v1.1 / Time-Series Data Visualization

- Login with GitHub -
-

Made by InfluxData

-
-
- ); - }, -}); +const {array} = PropTypes -export default withRouter(Login); +const Login = ({auth}) => ( +
+
+
+

Chronograf

+

{VERSION} / Time-Series Data Visualization

+ {auth.map(({name, login, label}) => ( + + + Login with {label} + + ))} +
+

Made by InfluxData

+
+
+) + +Login.propTypes = { + auth: array.isRequired, +} + +const mapStateToProps = (state) => ({ + auth: state.auth, +}) + +export default connect(mapStateToProps)(Login) diff --git a/ui/src/dashboards/actions/index.js b/ui/src/dashboards/actions/index.js new file mode 100644 index 0000000000..8deeba704b --- /dev/null +++ b/ui/src/dashboards/actions/index.js @@ -0,0 +1,66 @@ +import { + getDashboards as getDashboardsAJAX, + updateDashboard as updateDashboardAJAX, +} from 'src/dashboards/apis' + +export function loadDashboards(dashboards, dashboardID) { + return { + type: 'LOAD_DASHBOARDS', + payload: { + dashboards, + dashboardID, + }, + } +} + +export function setDashboard(dashboardID) { + return { + type: 'SET_DASHBOARD', + payload: { + dashboardID, + }, + } +} + +export function setTimeRange(timeRange) { + return { + type: 'SET_DASHBOARD_TIME_RANGE', + payload: { + timeRange, + }, + } +} + +export function setEditMode(isEditMode) { + return { + type: 'SET_EDIT_MODE', + payload: { + isEditMode, + }, + } +} + +export function getDashboards(dashboardID) { + return (dispatch) => { + getDashboardsAJAX().then(({data: {dashboards}}) => { + dispatch(loadDashboards(dashboards, dashboardID)) + }); + } +} + +export function putDashboard(dashboard) { + return (dispatch) => { + updateDashboardAJAX(dashboard).then(({data}) => { + dispatch(updateDashboard(data)) + }) + } +} + +export function updateDashboard(dashboard) { + return { + type: 'UPDATE_DASHBOARD', + payload: { + dashboard, + }, + } +} diff --git a/ui/src/dashboards/apis/index.js b/ui/src/dashboards/apis/index.js index f0106f76bb..10dbef1d2b 100644 --- a/ui/src/dashboards/apis/index.js +++ b/ui/src/dashboards/apis/index.js @@ -3,6 +3,14 @@ import AJAX from 'utils/ajax'; export function getDashboards() { return AJAX({ method: 'GET', - url: `/chronograf/v1/dashboards`, + resource: 'dashboards', + }); +} + +export function updateDashboard(dashboard) { + return AJAX({ + method: 'PUT', + url: dashboard.links.self, + data: dashboard, }); } diff --git a/ui/src/dashboards/components/Dashboard.js b/ui/src/dashboards/components/Dashboard.js new file mode 100644 index 0000000000..46eb173b05 --- /dev/null +++ b/ui/src/dashboards/components/Dashboard.js @@ -0,0 +1,72 @@ +import React, {PropTypes} from 'react' +import classnames from 'classnames' + +import LayoutRenderer from 'shared/components/LayoutRenderer' +import Visualizations from 'src/dashboards/components/VisualizationSelector' + +const Dashboard = ({ + dashboard, + isEditMode, + inPresentationMode, + onPositionChange, + source, + timeRange, +}) => { + if (dashboard.id === 0) { + return null + } + + return ( +
+
+ {isEditMode ? : null} + {Dashboard.renderDashboard(dashboard, timeRange, source, onPositionChange)} +
+
+ ) +} + +Dashboard.renderDashboard = (dashboard, timeRange, source, onPositionChange) => { + const autoRefreshMs = 15000 + const cells = dashboard.cells.map((cell, i) => { + i = `${i}` + const dashboardCell = {...cell, i} + dashboardCell.queries.forEach((q) => { + q.text = q.query; + q.database = source.telegraf; + }); + return dashboardCell; + }) + + return ( + + ) +} + +const { + bool, + func, + shape, + string, +} = PropTypes + +Dashboard.propTypes = { + dashboard: shape({}).isRequired, + isEditMode: bool, + inPresentationMode: bool, + onPositionChange: func, + source: shape({ + links: shape({ + proxy: string, + }).isRequired, + }).isRequired, + timeRange: shape({}).isRequired, +} + +export default Dashboard diff --git a/ui/src/dashboards/components/DashboardHeader.js b/ui/src/dashboards/components/DashboardHeader.js new file mode 100644 index 0000000000..60ff5e8ffc --- /dev/null +++ b/ui/src/dashboards/components/DashboardHeader.js @@ -0,0 +1,77 @@ +import React, {PropTypes} from 'react' +import ReactTooltip from 'react-tooltip' +import {Link} from 'react-router'; + +import TimeRangeDropdown from 'shared/components/TimeRangeDropdown' + +const DashboardHeader = ({ + children, + buttonText, + dashboard, + headerText, + timeRange, + isHidden, + handleChooseTimeRange, + handleClickPresentationButton, + sourceID, +}) => isHidden ? null : ( +
+
+
+ {buttonText && +
+ +
    + {children} +
+
+ } + {headerText && +

Kubernetes Dashboard

+ } +
+
+ {sourceID ? + + +  Edit + : null + } +
+ + Graph Tips +
+ + +
+ +
+
+
+
+) + +const { + shape, + array, + string, + func, + bool, +} = PropTypes + +DashboardHeader.propTypes = { + sourceID: string, + children: array, + buttonText: string, + dashboard: shape({}), + headerText: string, + timeRange: shape({}).isRequired, + isHidden: bool.isRequired, + handleChooseTimeRange: func.isRequired, + handleClickPresentationButton: func.isRequired, +} + +export default DashboardHeader diff --git a/ui/src/dashboards/components/DashboardHeaderEdit.js b/ui/src/dashboards/components/DashboardHeaderEdit.js new file mode 100644 index 0000000000..987ed02f87 --- /dev/null +++ b/ui/src/dashboards/components/DashboardHeaderEdit.js @@ -0,0 +1,36 @@ +import React, {PropTypes} from 'react' + +const DashboardEditHeader = ({ + dashboard, + onSave, +}) => ( +
+
+
+ +
+
+
+ Save +
+
+
+
+) + +const { + shape, + func, +} = PropTypes + +DashboardEditHeader.propTypes = { + dashboard: shape({}), + onSave: func.isRequired, +} + +export default DashboardEditHeader diff --git a/ui/src/dashboards/components/VisualizationSelector.js b/ui/src/dashboards/components/VisualizationSelector.js new file mode 100644 index 0000000000..f44518d387 --- /dev/null +++ b/ui/src/dashboards/components/VisualizationSelector.js @@ -0,0 +1,24 @@ +import React from 'react' + +const VisualizationSelector = () => ( +
+
+ VISUALIZATIONS +
+ Line Graph +
+
+ SingleStat +
+
+
+) + +export default VisualizationSelector diff --git a/ui/src/dashboards/constants/index.js b/ui/src/dashboards/constants/index.js new file mode 100644 index 0000000000..8e751072ee --- /dev/null +++ b/ui/src/dashboards/constants/index.js @@ -0,0 +1,12 @@ +export const EMPTY_DASHBOARD = { + id: 0, + name: '', + cells: [ + { + x: 0, + y: 0, + queries: [], + name: 'Loading...', + }, + ], +} diff --git a/ui/src/dashboards/containers/DashboardPage.js b/ui/src/dashboards/containers/DashboardPage.js index 135e7ebc59..c83c920202 100644 --- a/ui/src/dashboards/containers/DashboardPage.js +++ b/ui/src/dashboards/containers/DashboardPage.js @@ -1,130 +1,169 @@ -import React, {PropTypes} from 'react'; -import ReactTooltip from 'react-tooltip'; -import {Link} from 'react-router'; -import _ from 'lodash'; +import React, {PropTypes} from 'react' +import {Link} from 'react-router' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' -import LayoutRenderer from 'shared/components/LayoutRenderer'; -import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown'; -import timeRanges from 'hson!../../shared/data/timeRanges.hson'; +import Header from 'src/dashboards/components/DashboardHeader' +import EditHeader from 'src/dashboards/components/DashboardHeaderEdit' +import Dashboard from 'src/dashboards/components/Dashboard' +import timeRanges from 'hson!../../shared/data/timeRanges.hson' -import {getDashboards} from '../apis'; -import {getSource} from 'shared/apis'; +import * as dashboardActionCreators from 'src/dashboards/actions' + +import {presentationButtonDispatcher} from 'shared/dispatchers' + +const { + arrayOf, + bool, + func, + number, + shape, + string, +} = PropTypes const DashboardPage = React.createClass({ propTypes: { - params: PropTypes.shape({ - sourceID: PropTypes.string.isRequired, - dashboardID: PropTypes.string.isRequired, + source: PropTypes.shape({ + links: PropTypes.shape({ + proxy: PropTypes.string, + self: PropTypes.string, + }), + }), + params: shape({ + sourceID: string.isRequired, + dashboardID: string.isRequired, }).isRequired, - }, - - getInitialState() { - const fifteenMinutesIndex = 1; - - return { - dashboards: [], - timeRange: timeRanges[fifteenMinutesIndex], - }; + location: shape({ + pathname: string.isRequired, + }).isRequired, + dashboardActions: shape({ + putDashboard: func.isRequired, + getDashboards: func.isRequired, + setDashboard: func.isRequired, + setTimeRange: func.isRequired, + setEditMode: func.isRequired, + }).isRequired, + dashboards: arrayOf(shape({ + id: number.isRequired, + cells: arrayOf(shape({})).isRequired, + })).isRequired, + dashboard: shape({ + id: number.isRequired, + cells: arrayOf(shape({})).isRequired, + }).isRequired, + timeRange: shape({}).isRequired, + inPresentationMode: bool.isRequired, + isEditMode: bool.isRequired, + handleClickPresentationButton: func, }, componentDidMount() { - getDashboards().then((resp) => { - getSource(this.props.params.sourceID).then(({data: source}) => { - this.setState({ - dashboards: resp.data.dashboards, - source, - }); - }); - }); + const { + params: {dashboardID}, + dashboardActions: {getDashboards}, + } = this.props; + + getDashboards(dashboardID) }, - currentDashboard(dashboards, dashboardID) { - return _.find(dashboards, (d) => d.id.toString() === dashboardID); - }, + componentWillReceiveProps(nextProps) { + const {location: {pathname}} = this.props + const { + location: {pathname: nextPathname}, + params: {dashboardID: nextID}, + dashboardActions: {setDashboard, setEditMode}, + } = nextProps - renderDashboard(dashboard) { - const autoRefreshMs = 15000; - const {timeRange} = this.state; - const {source} = this.state; + if (nextPathname.pathname === pathname) { + return + } - const cellWidth = 4; - const cellHeight = 4; - - const cells = dashboard.cells.map((cell, i) => { - const dashboardCell = Object.assign(cell, { - w: cellWidth, - h: cellHeight, - queries: cell.queries, - i: i.toString(), - }); - - dashboardCell.queries.forEach((q) => { - q.text = q.query; - q.database = source.telegraf; - }); - return dashboardCell; - }); - - return ( - - ); + setDashboard(nextID) + setEditMode(nextPathname.includes('/edit')) }, handleChooseTimeRange({lower}) { const timeRange = timeRanges.find((range) => range.queryValue === lower); - this.setState({timeRange}); + this.props.dashboardActions.setTimeRange(timeRange) + }, + + handleUpdatePosition(cells) { + this.props.dashboardActions.putDashboard({...this.props.dashboard, cells}) }, render() { - const {dashboards, timeRange} = this.state; - const dashboard = this.currentDashboard(dashboards, this.props.params.dashboardID); + const { + dashboards, + dashboard, + params: {sourceID}, + inPresentationMode, + isEditMode, + handleClickPresentationButton, + source, + timeRange, + } = this.props return (
-
-
-
-
- -
    - {(dashboards).map((d, i) => { - return ( -
  • - - {d.name} - -
  • - ); - })} -
-
-
-
-
- - Graph Tips -
- - -
-
-
-
-
- { dashboard ? this.renderDashboard(dashboard) : '' } -
-
+ { + isEditMode ? + {}} /> : +
+ {(dashboards).map((d, i) => { + return ( +
  • + + {d.name} + +
  • + ); + })} +
    + } +
    ); }, }); -export default DashboardPage; +const mapStateToProps = (state) => { + const { + appUI, + dashboardUI: { + dashboards, + dashboard, + timeRange, + isEditMode, + }, + } = state + + return { + inPresentationMode: appUI.presentationMode, + dashboards, + dashboard, + timeRange, + isEditMode, + } +} + +const mapDispatchToProps = (dispatch) => ({ + handleClickPresentationButton: presentationButtonDispatcher(dispatch), + dashboardActions: bindActionCreators(dashboardActionCreators, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(DashboardPage); diff --git a/ui/src/dashboards/containers/DashboardsPage.js b/ui/src/dashboards/containers/DashboardsPage.js index 4bc59e3742..6ffc66d09d 100644 --- a/ui/src/dashboards/containers/DashboardsPage.js +++ b/ui/src/dashboards/containers/DashboardsPage.js @@ -34,13 +34,14 @@ const DashboardsPage = React.createClass({ }, render() { + const dashboardLink = `/sources/${this.props.source.id}`; let tableHeader; if (this.state.waiting) { tableHeader = "Loading Dashboards..."; } else if (this.state.dashboards.length === 0) { - tableHeader = "No Dashboards"; + tableHeader = "1 Dashboard"; } else { - tableHeader = `${this.state.dashboards.length} Dashboards`; + tableHeader = `${this.state.dashboards.length + 1} Dashboards`; } return ( @@ -75,7 +76,7 @@ const DashboardsPage = React.createClass({ return ( - + {dashboard.name} @@ -83,6 +84,13 @@ const DashboardsPage = React.createClass({ ); }) } + + + + {'Kubernetes'} + + +
    diff --git a/ui/src/dashboards/reducers/ui.js b/ui/src/dashboards/reducers/ui.js new file mode 100644 index 0000000000..72c3392945 --- /dev/null +++ b/ui/src/dashboards/reducers/ui.js @@ -0,0 +1,56 @@ +import _ from 'lodash'; +import {EMPTY_DASHBOARD} from 'src/dashboards/constants' +import timeRanges from 'hson!../../shared/data/timeRanges.hson'; + +const initialState = { + dashboards: [], + dashboard: EMPTY_DASHBOARD, + timeRange: timeRanges[1], + isEditMode: false, +}; + +export default function ui(state = initialState, action) { + switch (action.type) { + case 'LOAD_DASHBOARDS': { + const {dashboards, dashboardID} = action.payload; + const newState = { + dashboards, + dashboard: _.find(dashboards, (d) => d.id === +dashboardID), + }; + + return {...state, ...newState}; + } + + case 'SET_DASHBOARD': { + const {dashboardID} = action.payload + const newState = { + dashboard: _.find(state.dashboards, (d) => d.id === +dashboardID), + }; + + return {...state, ...newState} + } + + case 'SET_DASHBOARD_TIME_RANGE': { + const {timeRange} = action.payload + + return {...state, timeRange}; + } + + case 'SET_EDIT_MODE': { + const {isEditMode} = action.payload + return {...state, isEditMode} + } + + case 'UPDATE_DASHBOARD': { + const {dashboard} = action.payload + const newState = { + dashboard, + dashboards: state.dashboards.map((d) => d.id === dashboard.id ? dashboard : d), + } + + return {...state, ...newState} + } + } + + return state; +} diff --git a/ui/src/data_explorer/components/Visualization.js b/ui/src/data_explorer/components/Visualization.js index d2f94d99a3..b88972a9e1 100644 --- a/ui/src/data_explorer/components/Visualization.js +++ b/ui/src/data_explorer/components/Visualization.js @@ -73,7 +73,7 @@ const Visualization = React.createClass({ -
    +
    {isGraphInView ? ( -
    -
    -
    -
    - -
      - {Object.keys(hosts).map((host, i) => { - return ( -
    • - - {host} - -
    • - ); - })} -
    -
    -
    -
    -
    - - Graph Tips -
    - - -
    -
    -
    -
    -
    + + {Object.keys(hosts).map((host, i) => { + return ( +
  • + + {host} + +
  • + ); + })} +
    +
    +
    { (layouts.length > 0) ? this.renderLayouts(layouts) : '' }
    @@ -181,4 +181,12 @@ export const HostPage = React.createClass({ }, }); -export default HostPage; +const mapStateToProps = (state) => ({ + inPresentationMode: state.appUI.presentationMode, +}) + +const mapDispatchToProps = (dispatch) => ({ + handleClickPresentationButton: presentationButtonDispatcher(dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(HostPage) diff --git a/ui/src/index.js b/ui/src/index.js index fcaa209f35..c2a8f4cd3d 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -18,6 +18,8 @@ import NotFound from 'src/shared/components/NotFound'; import configureStore from 'src/store/configureStore'; import {getMe, getSources} from 'shared/apis'; import {receiveMe} from 'shared/actions/me'; +import {receiveAuth} from 'shared/actions/auth'; +import {disablePresentationMode} from 'shared/actions/ui'; import {loadLocalStorage} from './localStorage'; import 'src/style/chronograf.scss'; @@ -38,6 +40,12 @@ if (basepath) { }); } +window.addEventListener('keyup', (event) => { + if (event.key === 'Escape') { + store.dispatch(disablePresentationMode()) + } +}) + const Root = React.createClass({ getInitialState() { return { @@ -69,14 +77,13 @@ const Root = React.createClass({ if (store.getState().me.links) { return this.setState({loggedIn: true}); } - getMe().then(({data: me}) => { + getMe().then(({data: me, auth}) => { store.dispatch(receiveMe(me)); + store.dispatch(receiveAuth(auth)); this.setState({loggedIn: true}); - }).catch((err) => { - const AUTH_DISABLED = 418; - if (err.response.status === AUTH_DISABLED) { - return this.setState({loggedIn: true}); - // Could store a boolean indicating auth is not set up + }).catch((error) => { + if (error.auth) { + store.dispatch(receiveAuth(error.auth)); } this.setState({loggedIn: false}); @@ -116,12 +123,13 @@ const Root = React.createClass({ + - + ); diff --git a/ui/src/kubernetes/components/KubernetesDashboard.js b/ui/src/kubernetes/components/KubernetesDashboard.js index 086b821e8c..2c4afc5b41 100644 --- a/ui/src/kubernetes/components/KubernetesDashboard.js +++ b/ui/src/kubernetes/components/KubernetesDashboard.js @@ -1,18 +1,29 @@ import React, {PropTypes} from 'react'; +import classnames from 'classnames' + import LayoutRenderer from 'shared/components/LayoutRenderer'; -import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown'; -import ReactTooltip from 'react-tooltip'; +import DashboardHeader from 'src/dashboards/components/DashboardHeader'; import timeRanges from 'hson!../../shared/data/timeRanges.hson'; -export const KubernetesPage = React.createClass({ +const { + shape, + string, + arrayOf, + bool, + func, +} = PropTypes + +export const KubernetesDashboard = React.createClass({ propTypes: { - source: PropTypes.shape({ - links: PropTypes.shape({ - proxy: PropTypes.string.isRequired, + source: shape({ + links: shape({ + proxy: string.isRequired, }).isRequired, - telegraf: PropTypes.string.isRequired, + telegraf: string.isRequired, }), - layouts: PropTypes.arrayOf(PropTypes.shape().isRequired).isRequired, + layouts: arrayOf(shape().isRequired).isRequired, + inPresentationMode: bool.isRequired, + handleClickPresentationButton: func, }, getInitialState() { @@ -57,7 +68,7 @@ export const KubernetesPage = React.createClass({ }, render() { - const {layouts} = this.props; + const {layouts, inPresentationMode, handleClickPresentationButton} = this.props; const {timeRange} = this.state; const emptyState = (
    @@ -68,23 +79,18 @@ export const KubernetesPage = React.createClass({ return (
    -
    -
    -
    -

    Kubernetes Dashboard

    -
    -
    -
    - - Graph Tips -
    - - -
    -
    -
    -
    -
    + +
    +
    {layouts.length ? this.renderLayouts(layouts) : emptyState}
    @@ -92,4 +98,5 @@ export const KubernetesPage = React.createClass({ ); }, }); -export default KubernetesPage; + +export default KubernetesDashboard; diff --git a/ui/src/kubernetes/containers/KubernetesPage.js b/ui/src/kubernetes/containers/KubernetesPage.js index b67241f628..213bc8a06c 100644 --- a/ui/src/kubernetes/containers/KubernetesPage.js +++ b/ui/src/kubernetes/containers/KubernetesPage.js @@ -1,14 +1,26 @@ import React, {PropTypes} from 'react'; +import {connect} from 'react-redux' + import {fetchLayouts} from 'shared/apis'; import KubernetesDashboard from 'src/kubernetes/components/KubernetesDashboard'; +import {presentationButtonDispatcher} from 'shared/dispatchers' + +const { + shape, + string, + bool, + func, +} = PropTypes export const KubernetesPage = React.createClass({ propTypes: { - source: PropTypes.shape({ - links: PropTypes.shape({ - proxy: PropTypes.string.isRequired, + source: shape({ + links: shape({ + proxy: string.isRequired, }).isRequired, }), + inPresentationMode: bool.isRequired, + handleClickPresentationButton: func, }, getInitialState() { @@ -25,10 +37,26 @@ export const KubernetesPage = React.createClass({ }, render() { + const {layouts} = this.state + const {source, inPresentationMode, handleClickPresentationButton} = this.props + return ( - + ); }, }); -export default KubernetesPage; +const mapStateToProps = (state) => ({ + inPresentationMode: state.appUI.presentationMode, +}) + +const mapDispatchToProps = (dispatch) => ({ + handleClickPresentationButton: presentationButtonDispatcher(dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(KubernetesPage); diff --git a/ui/src/shared/actions/auth.js b/ui/src/shared/actions/auth.js new file mode 100644 index 0000000000..0f2d4481c2 --- /dev/null +++ b/ui/src/shared/actions/auth.js @@ -0,0 +1,8 @@ +export function receiveAuth(auth) { + return { + type: 'AUTH_RECEIVED', + payload: { + auth, + }, + }; +} diff --git a/ui/src/shared/actions/notifications.js b/ui/src/shared/actions/notifications.js index 7551e4facf..8e2b419110 100644 --- a/ui/src/shared/actions/notifications.js +++ b/ui/src/shared/actions/notifications.js @@ -17,6 +17,12 @@ export function dismissNotification(type) { }; } +export function delayDismissNotification(type, wait) { + return (dispatch) => { + setTimeout(() => dispatch(dismissNotification(type)), wait) + } +} + export function dismissAllNotifications() { return { type: 'ALL_NOTIFICATIONS_DISMISSED', diff --git a/ui/src/shared/actions/ui.js b/ui/src/shared/actions/ui.js new file mode 100644 index 0000000000..740566beb9 --- /dev/null +++ b/ui/src/shared/actions/ui.js @@ -0,0 +1,19 @@ +import {PRESENTATION_MODE_ANIMATION_DELAY} from '../constants' + +export function enablePresentationMode() { + return { + type: 'ENABLE_PRESENTATION_MODE', + } +} + +export function disablePresentationMode() { + return { + type: 'DISABLE_PRESENTATION_MODE', + } +} + +export function delayEnablePresentationMode() { + return (dispatch) => { + setTimeout(() => dispatch(enablePresentationMode()), PRESENTATION_MODE_ANIMATION_DELAY) + } +} diff --git a/ui/src/shared/apis/index.js b/ui/src/shared/apis/index.js index 15dd96c30b..f712d1988f 100644 --- a/ui/src/shared/apis/index.js +++ b/ui/src/shared/apis/index.js @@ -4,31 +4,33 @@ export function fetchLayouts() { return AJAX({ url: `/chronograf/v1/layouts`, method: 'GET', + resource: 'layouts', }); } export function getMe() { return AJAX({ - url: `/chronograf/v1/me`, + resource: 'me', method: 'GET', }); } export function getSources() { return AJAX({ - url: '/chronograf/v1/sources', + resource: 'sources', }); } -export function getSource(sourceID) { +export function getSource(id) { return AJAX({ - url: `/chronograf/v1/sources/${sourceID}`, + resource: 'sources', + id, }); } export function createSource(attributes) { return AJAX({ - url: '/chronograf/v1/sources', + resource: 'sources', method: 'POST', data: attributes, }); diff --git a/ui/src/shared/components/Dygraph.js b/ui/src/shared/components/Dygraph.js index 12895f8624..f819e77999 100644 --- a/ui/src/shared/components/Dygraph.js +++ b/ui/src/shared/components/Dygraph.js @@ -108,6 +108,7 @@ export default React.createClass({ const legendWidth = legendRect.width; const legendMaxLeft = graphWidth - (legendWidth / 2); const trueGraphX = (e.pageX - graphRect.left); + const legendTop = graphRect.height + 0 let legendLeft = trueGraphX; // Enforcing max & min legend offsets if (trueGraphX < (legendWidth / 2)) { @@ -117,6 +118,7 @@ export default React.createClass({ } legendContainerNode.style.left = `${legendLeft}px`; + legendContainerNode.style.top = `${legendTop}px`; setMarker(points); }, unhighlightCallback() { diff --git a/ui/src/shared/components/LayoutRenderer.js b/ui/src/shared/components/LayoutRenderer.js index 1457e86e23..e1c2df5e77 100644 --- a/ui/src/shared/components/LayoutRenderer.js +++ b/ui/src/shared/components/LayoutRenderer.js @@ -4,50 +4,52 @@ import LineGraph from 'shared/components/LineGraph'; import SingleStat from 'shared/components/SingleStat'; import ReactGridLayout, {WidthProvider} from 'react-grid-layout'; const GridLayout = WidthProvider(ReactGridLayout); -import _ from 'lodash'; const RefreshingLineGraph = AutoRefresh(LineGraph); const RefreshingSingleStat = AutoRefresh(SingleStat); +const { + arrayOf, + func, + number, + shape, + string, +} = PropTypes; + export const LayoutRenderer = React.createClass({ propTypes: { - timeRange: PropTypes.shape({ - defaultGroupBy: PropTypes.string.isRequired, - queryValue: PropTypes.string.isRequired, + timeRange: shape({ + defaultGroupBy: string.isRequired, + queryValue: string.isRequired, }).isRequired, - cells: PropTypes.arrayOf( - PropTypes.shape({ - queries: PropTypes.arrayOf( - PropTypes.shape({ - label: PropTypes.string, - range: PropTypes.shape({ - upper: PropTypes.number, - lower: PropTypes.number, + cells: arrayOf( + shape({ + queries: arrayOf( + shape({ + label: string, + range: shape({ + upper: number, + lower: number, }), - rp: PropTypes.string, - text: PropTypes.string.isRequired, - database: PropTypes.string.isRequired, - groupbys: PropTypes.arrayOf(PropTypes.string), - wheres: PropTypes.arrayOf(PropTypes.string), + rp: string, + text: string.isRequired, + database: string.isRequired, + groupbys: arrayOf(string), + wheres: arrayOf(string), }).isRequired ).isRequired, - x: PropTypes.number.isRequired, - y: PropTypes.number.isRequired, - w: PropTypes.number.isRequired, - h: PropTypes.number.isRequired, - i: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, + x: number.isRequired, + y: number.isRequired, + w: number.isRequired, + h: number.isRequired, + i: string.isRequired, + name: string.isRequired, }).isRequired ), - autoRefreshMs: PropTypes.number.isRequired, - host: PropTypes.string, - source: PropTypes.string, - }, - - getInitialState() { - return ({ - layout: _.without(this.props.cells, ['queries']), - }); + autoRefreshMs: number.isRequired, + host: string, + source: string, + onPositionChange: func, }, buildQuery(q) { @@ -96,33 +98,81 @@ export const LayoutRenderer = React.createClass({ if (cell.type === 'single-stat') { return (
    -

    {cell.name}

    -
    +

    {cell.name || `Graph`}

    +
    ); } + const displayOptions = { + stepPlot: cell.type === 'line-stepplot', + stackedGraph: cell.type === 'line-stacked', + } + return (
    -

    {cell.name}

    -
    - +

    {cell.name || `Graph`}

    +
    +
    ); }); }, + handleLayoutChange(layout) { + this.triggerWindowResize() + + if (!this.props.onPositionChange) { + return + } + + const newCells = this.props.cells.map((cell) => { + const l = layout.find((ly) => ly.i === cell.i) + const newLayout = {x: l.x, y: l.y, h: l.h, w: l.w} + return {...cell, ...newLayout} + }) + + this.props.onPositionChange(newCells) + }, + render() { - const layoutMargin = 4; + const layoutMargin = 4 + const isDashboard = !!this.props.onPositionChange + return ( - + {this.generateVisualizations()} ); }, + + + triggerWindowResize() { + // Hack to get dygraphs to fit properly during and after resize (dispatchEvent is a global method on window). + const evt = document.createEvent('CustomEvent'); // MUST be 'CustomEvent' + evt.initCustomEvent('resize', false, false, null); + dispatchEvent(evt); + }, }); export default LayoutRenderer; diff --git a/ui/src/shared/components/LineGraph.js b/ui/src/shared/components/LineGraph.js index ea36c088d5..9658c8d6f1 100644 --- a/ui/src/shared/components/LineGraph.js +++ b/ui/src/shared/components/LineGraph.js @@ -33,6 +33,10 @@ export default React.createClass({ overrideLineColors: array, queries: arrayOf(shape({}).isRequired).isRequired, showSingleStat: bool, + displayOptions: shape({ + stepPlot: bool, + stackedGraph: bool, + }), activeQueryIndex: number, ruleValues: shape({}), isInDataExplorer: bool, @@ -63,7 +67,7 @@ export default React.createClass({ }, render() { - const {data, ranges, isFetchingInitially, isRefreshing, isGraphFilled, overrideLineColors, title, underlayCallback, queries, showSingleStat, ruleValues} = this.props; + const {data, ranges, isFetchingInitially, isRefreshing, isGraphFilled, overrideLineColors, title, underlayCallback, queries, showSingleStat, displayOptions, ruleValues} = this.props; const {labels, timeSeries, dygraphSeries} = this._timeSeries; // If data for this graph is being fetched for the first time, show a graph-wide spinner. @@ -75,7 +79,7 @@ export default React.createClass({ ); } - const options = { + const options = Object.assign({}, { labels, connectSeparatedPoints: true, labelsKMB: true, @@ -89,7 +93,7 @@ export default React.createClass({ underlayCallback, ylabel: _.get(queries, ['0', 'label'], ''), y2label: _.get(queries, ['1', 'label'], ''), - }; + }, displayOptions); let roundedValue; if (showSingleStat) { @@ -103,7 +107,7 @@ export default React.createClass({
    {isRefreshing ? this.renderSpinner() : null} () => { + dispatch(delayEnablePresentationMode()) + dispatch(publishNotification('success', 'Press ESC to disable presentation mode.')) + dispatch(delayDismissNotification('success', PRESENTATION_MODE_NOTIFICATION_DELAY)) +} diff --git a/ui/src/shared/reducers/auth.js b/ui/src/shared/reducers/auth.js new file mode 100644 index 0000000000..2a4cc8cc93 --- /dev/null +++ b/ui/src/shared/reducers/auth.js @@ -0,0 +1,14 @@ +function getInitialState() { + return []; +} +const initialState = getInitialState(); + +export default function auth(state = initialState, action) { + switch (action.type) { + case 'AUTH_RECEIVED': { + return action.payload.auth; + } + } + + return state; +} diff --git a/ui/src/shared/reducers/index.js b/ui/src/shared/reducers/index.js index 500de90d67..47ed0c62f4 100644 --- a/ui/src/shared/reducers/index.js +++ b/ui/src/shared/reducers/index.js @@ -1,9 +1,13 @@ +import appUI from './ui'; import me from './me'; +import auth from './auth'; import notifications from './notifications'; import sources from './sources'; export { + appUI, me, + auth, notifications, sources, }; diff --git a/ui/src/shared/reducers/ui.js b/ui/src/shared/reducers/ui.js new file mode 100644 index 0000000000..77a2f77a8a --- /dev/null +++ b/ui/src/shared/reducers/ui.js @@ -0,0 +1,23 @@ +const initialState = { + presentationMode: false, +}; + +export default function ui(state = initialState, action) { + switch (action.type) { + case 'ENABLE_PRESENTATION_MODE': { + return { + ...state, + presentationMode: true, + } + } + + case 'DISABLE_PRESENTATION_MODE': { + return { + ...state, + presentationMode: false, + } + } + } + + return state +} diff --git a/ui/src/side_nav/components/SideNav.js b/ui/src/side_nav/components/SideNav.js index af186e3191..72c45d1bac 100644 --- a/ui/src/side_nav/components/SideNav.js +++ b/ui/src/side_nav/components/SideNav.js @@ -1,7 +1,11 @@ import React, {PropTypes} from 'react'; import {NavBar, NavBlock, NavHeader, NavListItem} from 'src/side_nav/components/NavItems'; -const {string, shape} = PropTypes; +const { + string, + shape, + bool, +} = PropTypes; const SideNav = React.createClass({ propTypes: { location: string.isRequired, @@ -9,36 +13,36 @@ const SideNav = React.createClass({ me: shape({ email: string, }), + isHidden: bool.isRequired, }, render() { - const {me, location, sourceID} = this.props; + const {me, location, sourceID, isHidden} = this.props; const sourcePrefix = `/sources/${sourceID}`; const dataExplorerLink = `${sourcePrefix}/chronograf/data-explorer`; const loggedIn = !!(me && me.email); - return ( + return isHidden ? null : (
    - - - Host List - Kubernetes Dashboard + + - - Explorer - Dashboards + - + + + + Alert History Kapacitor Rules - + InfluxDB Kapacitor diff --git a/ui/src/side_nav/containers/SideNavApp.js b/ui/src/side_nav/containers/SideNavApp.js index b19ab5f4b0..a93bffdf72 100644 --- a/ui/src/side_nav/containers/SideNavApp.js +++ b/ui/src/side_nav/containers/SideNavApp.js @@ -2,7 +2,13 @@ import React, {PropTypes} from 'react'; import {connect} from 'react-redux'; import SideNav from '../components/SideNav'; -const {func, string, shape} = PropTypes; +const { + func, + string, + shape, + bool, +} = PropTypes + const SideNavApp = React.createClass({ propTypes: { currentLocation: string.isRequired, @@ -11,25 +17,27 @@ const SideNavApp = React.createClass({ me: shape({ email: string, }), + inPresentationMode: bool.isRequired, }, render() { - const {me, currentLocation, sourceID} = this.props; + const {me, currentLocation, sourceID, inPresentationMode} = this.props; return ( ); }, - }); function mapStateToProps(state) { return { me: state.me, + inPresentationMode: state.appUI.presentationMode, }; } diff --git a/ui/src/store/configureStore.js b/ui/src/store/configureStore.js index 286e96e47b..85b4a6e11a 100644 --- a/ui/src/store/configureStore.js +++ b/ui/src/store/configureStore.js @@ -5,12 +5,14 @@ import makeQueryExecuter from 'src/shared/middleware/queryExecuter'; import * as dataExplorerReducers from 'src/data_explorer/reducers'; import * as sharedReducers from 'src/shared/reducers'; import rulesReducer from 'src/kapacitor/reducers/rules'; +import dashboardUI from 'src/dashboards/reducers/ui'; import persistStateEnhancer from './persistStateEnhancer'; const rootReducer = combineReducers({ ...sharedReducers, ...dataExplorerReducers, rules: rulesReducer, + dashboardUI, }); const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index 901fa020c8..83e08cd7c6 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -45,6 +45,7 @@ @import 'pages/hosts'; @import 'pages/kapacitor'; @import 'pages/data-explorer'; +@import 'pages/dashboards'; // TODO @import 'unsorted'; diff --git a/ui/src/style/components/dygraphs.scss b/ui/src/style/components/dygraphs.scss index 991c3a9e67..b6653fff93 100644 --- a/ui/src/style/components/dygraphs.scss +++ b/ui/src/style/components/dygraphs.scss @@ -15,9 +15,8 @@ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='fade-out($g20-white, 0.71)', endColorstr='fade-out($g20-white, 0.71)',GradientType=0 ); } .container--dygraph-legend { - top: 300px !important; - transform: translate(-50%,-6px); - background-color: $g1-raven; + transform: translateX(-50%); + background-color: $g0-obsidian; display: block; position: absolute; padding: 11px; @@ -119,9 +118,9 @@ .graph--hasYLabel { .dygraph-axis-label-y { - padding: 0 1px 0 12px !important; + padding: 0 1px 0 10px !important; } .dygraph-axis-label-y2 { - padding: 0 12px 0 1px !important; + padding: 0 10px 0 1px !important; } } diff --git a/ui/src/style/layout/page.scss b/ui/src/style/layout/page.scss index bf19239cfc..0034b3e96a 100644 --- a/ui/src/style/layout/page.scss +++ b/ui/src/style/layout/page.scss @@ -35,6 +35,15 @@ &--purple-scrollbar { @include custom-scrollbar($g2-kevlar,$c-comet); } + + &.presentation-mode { + top: 0; + height: 100%; + + .dashboard { + padding: 12px; + } + } } .container-fluid { padding: ($chronograf-page-header-height / 2) $page-wrapper-padding ($chronograf-page-header-height / 2) $page-wrapper-padding; @@ -457,4 +466,4 @@ table .monotype { margin-bottom: 75px; } } -} \ No newline at end of file +} diff --git a/ui/src/style/mixins/mixins.scss b/ui/src/style/mixins/mixins.scss index 1ee71b0d8d..9d03a2567b 100644 --- a/ui/src/style/mixins/mixins.scss +++ b/ui/src/style/mixins/mixins.scss @@ -13,9 +13,31 @@ background: linear-gradient(to right, $startColor 0%,$endColor 100%); filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 ); } +@mixin gradient-diag-up($startColor, $endColor) { + background: $startColor; + background: -moz-linear-gradient(45deg, $startColor 0%, $endColor 100%); + background: -webkit-linear-gradient(45deg, $startColor 0%,$endColor 100%); + background: linear-gradient(45deg, $startColor 0%,$endColor 100%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 ); +} +@mixin gradient-diag-down($startColor, $endColor) { + background: $startColor; + background: -moz-linear-gradient(135deg, $startColor 0%, $endColor 100%); + background: -webkit-linear-gradient(135deg, $startColor 0%,$endColor 100%); + background: linear-gradient(135deg, $startColor 0%,$endColor 100%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 ); +} +@mixin gradient-r($startColor, $endColor) { + background: $startColor; + background: -moz-radial-gradient(center, ellipse cover, $startColor 0%, $endColor 100%); + background: -webkit-radial-gradient(center, ellipse cover, $startColor 0%,$endColor 100%); + background: radial-gradient(ellipse at center, $startColor 0%,$endColor 100%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 ); +} // Custom Scrollbars (Chrome Only) $scrollbar-width: 16px; +$scrollbar-offset: 3px; @mixin custom-scrollbar($trackColor, $handleColor) { &::-webkit-scrollbar { width: $scrollbar-width; @@ -30,12 +52,12 @@ $scrollbar-width: 16px; } &-track-piece { background-color: $trackColor; - border: 3px solid $trackColor; + border: $scrollbar-offset solid $trackColor; border-radius: ($scrollbar-width / 2); } &-thumb { background-color: $handleColor; - border: 3px solid $trackColor; + border: $scrollbar-offset solid $trackColor; border-radius: ($scrollbar-width / 2); } &-corner { @@ -45,4 +67,4 @@ $scrollbar-width: 16px; &::-webkit-resizer { background-color: $trackColor; } -} \ No newline at end of file +} diff --git a/ui/src/style/pages/dashboards.scss b/ui/src/style/pages/dashboards.scss new file mode 100644 index 0000000000..30c36980b1 --- /dev/null +++ b/ui/src/style/pages/dashboards.scss @@ -0,0 +1,229 @@ +/* + Variables + ------------------------------------------------------ +*/ +$dash-graph-heading: 30px; + + +/* + Animations + ------------------------------------------------------ +*/ +@keyframes refreshingSpinnerA { + 0% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; } + 33% { transform: translate(-50%,-50%) scale(1,1); } + 66% { transform: translate(-50%,-50%) scale(1,1); } + 100% { transform: translate(-50%,-50%) scale(1,1); } +} +@keyframes refreshingSpinnerB { + 0% { transform: translate(-50%,-50%) scale(1,1); } + 33% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; } + 66% { transform: translate(-50%,-50%) scale(1,1); } + 100% { transform: translate(-50%,-50%) scale(1,1); } +} +@keyframes refreshingSpinnerC { + 0% { transform: translate(-50%,-50%) scale(1,1); } + 33% { transform: translate(-50%,-50%) scale(1,1); } + 66% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; } + 100% { transform: translate(-50%,-50%) scale(1,1); } +} + +/* + Default Dashboard Mode + ------------------------------------------------------ +*/ +.dashboard { + .react-grid-item { + background-color: $g3-castle; + border-radius: $radius; + border: 2px solid $g3-castle; + transition-property: left, top, border-color, background-color; + } + .graph-empty { + background-color: transparent; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + } +} +.dash-graph--container { + user-select: none !important; + -o-user-select: none !important; + -moz-user-select: none !important; + -webkit-user-select: none !important; + background-color: transparent; + position: absolute; + width: 100%; + height: calc(100% - #{$dash-graph-heading}); + top: $dash-graph-heading; + left: 0; + padding: 0; + + &:hover { + cursor: crosshair; + } + & > div:not(.graph-empty) { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + + & > div:not(.graph-panel__refreshing) { + position: absolute; + width: 100%; + height: 100%; + padding: 8px 16px; + } + } + .graph-panel__refreshing { + top: (-$dash-graph-heading + 5px) !important; + } +} +.dash-graph--heading { + user-select: none !important; + -o-user-select: none !important; + -moz-user-select: none !important; + -webkit-user-select: none !important; + background-color: transparent; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: $dash-graph-heading; + padding: 0 16px; + margin: 0; + display: flex; + align-items: center; + border-radius: $radius; + font-weight: 600; + font-size: 13px; + color: $g14-chromium; + transition: + color 0.25s ease, + background-color 0.25s ease; + &:hover { + cursor: default; + } +} +.graph-panel__refreshing { + position: absolute; + top: -18px !important; + transform: translate(0,0); + right: 16px !important; + width: 16px; + height: 18px; + + > div { + width: 4px; + height: 4px; + background-color: $g6-smoke; + border-radius: 50%; + position: absolute; + top: 50%; + transform: translate(-50%,-50%); + } + + div:nth-child(1) {left: 0; animation: refreshingSpinnerA 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; } + div:nth-child(2) {left: 50%; animation: refreshingSpinnerB 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; } + div:nth-child(3) {left: 100%; animation: refreshingSpinnerC 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;} +} + +/* + Dashboard Edit Mode + ------------------------------------------------------ +*/ +.dashboard.dashboard-edit { + .dash-graph--heading:hover { + background-color: $g4-onyx; + color: $g18-cloud; + cursor: move; /* fallback if grab cursor is unsupported */ + cursor: grab; + cursor: -moz-grab; + cursor: -webkit-grab; + } + .react-grid-placeholder { + @include gradient-diag-down($c-pool,$c-comet); + border: 0; + opacity: 0.3; + z-index: 2; + } + .react-grid-item { + &.resizing { + background-color: fade-out($g3-castle,0.09); + border-color: $c-pool; + border-image-slice: 3%; + border-image-repeat: initial; + border-image-outset: 0; + border-image-width: 2px; + border-image-source: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDY3IDc5LjE1Nzc0NywgMjAxNS8wMy8zMC0yMzo0MDo0MiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTg0NjVDRkVGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTg0NjVDRkZGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDoxODQ2NUNGQ0YwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDoxODQ2NUNGREYwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PpeetfIAAAMnSURBVHja7N1PatwwFMfxJ5NlKT1DIfQKWZfSA/Q0hexDL9McoOQAPUKglwhp6dZ9Ho/HfyTZs6l+b/E1GDm27IH5oH9Pyji9//7XfLtNZt88/eT722TzlvrFseXHaXFmypuO8vd5nmW6nyeNefrKfZv7i9f75blU/NzafXvns2dV7tl8zqsnT55+9f3Xjf/xwQ9+evou+xLB+N8Ydi4AX3z/6PnvOj94AEOGMV/rB4P00J2rKTC0GNOTPne0GWEwhv1NB0YYjNPWgREHI00gYMTAOIGAEQdjuKcDIw7GXGWBEQJjrLLACIORrFBlgaHDsG2VBYYWY1VlgaHHSH3WqIOhxLB1ow6GGmPRqIMRAeMMAkYUDFuGTsDQYwxP6MCIg1Hp9oKhwih0e8FQYthuLAuM5hj1WBYYEoxUjGWBIcOwrFEHQ4qxLiFgyDFOvSww4mCM8yFghMEoDgzB0GGk2owhGBoMq5UQMDQYxRIChg4ja0PA0GLYMrgIhh7jUkLAiIExV1lghMA4GBiC0RrjNIULRhyMysAQDBVGYWAIhhJjM6cOhhpjUULAiIAxr1wEIwTGPDAEIwTGWGWBEQajHu0FQ4JRjvaCIcPIo71gSDHW0V4w5Bj5SB0MKUZxoRwYOoxsPgQMLcZqPgQMPUaxUQdDh2HVcQgYEoxUHIeAIcPIqywwpBjrKgsMOcb8f+pghMDIwu9gaDFWI3Uw9Bg2N+pgRMA497LAiIJRXf0OhgajuPodDB3G1dFeMNpgXBXtBaMdxmG0F4y2GLvRXjDaY2wGhmCoMawU7QVDh5G20V4wtBjzwBCMEBiXVx6BEQPjsJcFRluM3V4WGO0xqr0sMDQYVuplgaHDWL1YEgw9hi17WWDoMVJ1ChcMCYYVp3DBkGFUl5KCocGw6deAwIiBYUfBRTDaYmTdXjC0GFYLLoKhwSj+cAAYOgzbBhfB0GKsgotg6DGuWrkIRjuMudsLRgiMsQ0BIwzG5ZVHYMTAmKqsVzBiYPj2Z+j2PoERAmM4/2MoIfe+v4Ahx3jx5H4AefYLd37q0Y9/g9EcY/jOHz11A3v+J8AA9wisahRCWTQAAAAASUVORK5CYII=); + z-index: 3; + + & > .react-resizable-handle { + &:before, &:after { + background-color: $c-comet; + } + } + } + &.react-draggable-dragging { + background-color: fade-out($g3-castle,0.09); + border-color: $c-pool; + border-image-slice: 3%; + border-image-repeat: initial; + border-image-outset: 0; + border-image-width: 2px; + border-image-source: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDY3IDc5LjE1Nzc0NywgMjAxNS8wMy8zMC0yMzo0MDo0MiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTg0NjVDRkVGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTg0NjVDRkZGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDoxODQ2NUNGQ0YwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDoxODQ2NUNGREYwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PpeetfIAAAMnSURBVHja7N1PatwwFMfxJ5NlKT1DIfQKWZfSA/Q0hexDL9McoOQAPUKglwhp6dZ9Ho/HfyTZs6l+b/E1GDm27IH5oH9Pyji9//7XfLtNZt88/eT722TzlvrFseXHaXFmypuO8vd5nmW6nyeNefrKfZv7i9f75blU/NzafXvns2dV7tl8zqsnT55+9f3Xjf/xwQ9+evou+xLB+N8Ydi4AX3z/6PnvOj94AEOGMV/rB4P00J2rKTC0GNOTPne0GWEwhv1NB0YYjNPWgREHI00gYMTAOIGAEQdjuKcDIw7GXGWBEQJjrLLACIORrFBlgaHDsG2VBYYWY1VlgaHHSH3WqIOhxLB1ow6GGmPRqIMRAeMMAkYUDFuGTsDQYwxP6MCIg1Hp9oKhwih0e8FQYthuLAuM5hj1WBYYEoxUjGWBIcOwrFEHQ4qxLiFgyDFOvSww4mCM8yFghMEoDgzB0GGk2owhGBoMq5UQMDQYxRIChg4ja0PA0GLYMrgIhh7jUkLAiIExV1lghMA4GBiC0RrjNIULRhyMysAQDBVGYWAIhhJjM6cOhhpjUULAiIAxr1wEIwTGPDAEIwTGWGWBEQajHu0FQ4JRjvaCIcPIo71gSDHW0V4w5Bj5SB0MKUZxoRwYOoxsPgQMLcZqPgQMPUaxUQdDh2HVcQgYEoxUHIeAIcPIqywwpBjrKgsMOcb8f+pghMDIwu9gaDFWI3Uw9Bg2N+pgRMA497LAiIJRXf0OhgajuPodDB3G1dFeMNpgXBXtBaMdxmG0F4y2GLvRXjDaY2wGhmCoMawU7QVDh5G20V4wtBjzwBCMEBiXVx6BEQPjsJcFRluM3V4WGO0xqr0sMDQYVuplgaHDWL1YEgw9hi17WWDoMVJ1ChcMCYYVp3DBkGFUl5KCocGw6deAwIiBYUfBRTDaYmTdXjC0GFYLLoKhwSj+cAAYOgzbBhfB0GKsgotg6DGuWrkIRjuMudsLRgiMsQ0BIwzG5ZVHYMTAmKqsVzBiYPj2Z+j2PoERAmM4/2MoIfe+v4Ahx3jx5H4AefYLd37q0Y9/g9EcY/jOHz11A3v+J8AA9wisahRCWTQAAAAASUVORK5CYII=); + cursor: grabbing; + cursor: -moz-grabbing; + cursor: -webkit-grabbing; + &:hover { + cursor: grabbing; + cursor: -moz-grabbing; + cursor: -webkit-grabbing; + } + + & > .dash-graph--heading, + & > .dash-graph--heading:hover { + background-color: $g4-onyx; + color: $g18-cloud; + cursor: grabbing; + cursor: -moz-grabbing; + cursor: -webkit-grabbing; + } + } + &.cssTransforms { + transition-property: transform, border-color, background-color; + } + & > .react-resizable-handle { + background-image: none; + cursor: nwse-resize; + + &:before, + &:after { + content: ''; + display: block; + position: absolute; + height: 2px; + background-color: $g6-smoke; + transition: background-color 0.25s ease; + top: 50%; + left: 50%; + } + &:before { + width: 20px; + transform: translate(-50%,-50%) rotate(-45deg); + } + &:after { + width: 12px; + transform: translate(-3px,2px) rotate(-45deg); + } + &:hover { + &:before, &:after { + background-color: $c-comet; + } + } + } + } +} diff --git a/ui/src/style/pages/hosts.scss b/ui/src/style/pages/hosts.scss index 89aeb0e402..0f658a28cb 100644 --- a/ui/src/style/pages/hosts.scss +++ b/ui/src/style/pages/hosts.scss @@ -3,29 +3,13 @@ ---------------------------------------------- */ -@keyframes refreshingSpinnerA { - 0% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; } - 33% { transform: translate(-50%,-50%) scale(1,1); } - 66% { transform: translate(-50%,-50%) scale(1,1); } - 100% { transform: translate(-50%,-50%) scale(1,1); } -} -@keyframes refreshingSpinnerB { - 0% { transform: translate(-50%,-50%) scale(1,1); } - 33% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; } - 66% { transform: translate(-50%,-50%) scale(1,1); } - 100% { transform: translate(-50%,-50%) scale(1,1); } -} -@keyframes refreshingSpinnerC { - 0% { transform: translate(-50%,-50%) scale(1,1); } - 33% { transform: translate(-50%,-50%) scale(1,1); } - 66% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; } - 100% { transform: translate(-50%,-50%) scale(1,1); } -} .graph-container.hosts-graph { padding: 8px 16px; .single-stat { - font-size: 32px; + font-size: 60px; + font-weight: 300; + color: $c-pool; display: flex; justify-content: center; align-items: center; @@ -37,41 +21,8 @@ top: 0; } } - - .graph-panel__refreshing { - position: absolute; - top: -18px !important; - transform: translate(0,0); - right: 16px !important; - width: 16px; - height: 18px; - - > div { - width: 4px; - height: 4px; - background-color: $g6-smoke; - border-radius: 50%; - position: absolute; - top: 50%; - transform: translate(-50%,-50%); - } - - div:nth-child(1) {left: 0; animation: refreshingSpinnerA 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; } - div:nth-child(2) {left: 50%; animation: refreshingSpinnerB 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; } - div:nth-child(3) {left: 100%; animation: refreshingSpinnerC 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;} - } -} -.hosts-graph-heading { - display: block; - width: 100%; - margin: 0; - background-color: $g3-castle; - padding: 14px 16px 2px 16px; - font-weight: 600; - font-size: 13px; - color: $g14-chromium; - border-radius: 4px 4px 0 0; } + .host-list--active-source { text-transform: uppercase; font-size: 15px; @@ -89,8 +40,6 @@ } /* Hacky way to ensure that legends cannot be obscured by neighboring graphs */ -.react-grid-item { - &:hover { - z-index: 8999; - } +div:not(.dashboard-edit) .react-grid-item:hover { + z-index: 8999; } diff --git a/ui/src/style/theme/theme-dark.scss b/ui/src/style/theme/theme-dark.scss index 86acb32587..c28053f900 100644 --- a/ui/src/style/theme/theme-dark.scss +++ b/ui/src/style/theme/theme-dark.scss @@ -74,13 +74,14 @@ line-height: 30px !important; height: 30px !important; padding: 0 9px !important; - - .icon { - font-size: 16px; - margin: 0 4px 0 0 ; - } } -.btn.btn-xs .icon { +a.btn.btn-sm > span.icon, +div.btn.btn-sm > span.icon, +button.btn.btn-sm > span.icon { + font-size: 16px; + margin: 0 4px 0 0 ; +} +.btn.btn-xs > .icon { position: relative; top: -1px; } diff --git a/ui/src/utils/ajax.js b/ui/src/utils/ajax.js index 5292f64907..654e4dbfc2 100644 --- a/ui/src/utils/ajax.js +++ b/ui/src/utils/ajax.js @@ -1,20 +1,56 @@ import axios from 'axios'; -export default function AJAX({ +let links + +const UNAUTHORIZED = 401 + +export default async function AJAX({ url, + resource, + id, method = 'GET', data = {}, params = {}, headers = {}, }) { - if (window.basepath) { - url = `${window.basepath}${url}`; + let response + + try { + const basepath = window.basepath || '' + + url = `${basepath}${url}` + + if (!links) { + const linksRes = response = await axios({ + url: `${basepath}/chronograf/v1`, + method: 'GET', + }) + links = linksRes.data + } + + const {auth} = links + + if (resource) { + url = id ? `${basepath}${links[resource]}/${id}` : `${basepath}${links[resource]}` + } + + response = await axios({ + url, + method, + data, + params, + headers, + }) + + return { + auth, + ...response, + } + } catch (error) { + if (!response.status === UNAUTHORIZED) { + console.error(error) // eslint-disable-line no-console + } + const {auth} = links + throw {auth, ...response} // eslint-disable-line no-throw-literal } - return axios({ - url, - method, - data, - params, - headers, - }); } diff --git a/ui/webpack/devConfig.js b/ui/webpack/devConfig.js index aaf34f2dc4..a90e8b97b8 100644 --- a/ui/webpack/devConfig.js +++ b/ui/webpack/devConfig.js @@ -81,6 +81,9 @@ module.exports = { new webpack.optimize.CommonsChunkPlugin({ names: ['vendor', 'manifest'], }), + new webpack.DefinePlugin({ + VERSION: JSON.stringify(require('../package.json').version), + }), ], postcss: require('./postcss'), target: 'web', diff --git a/ui/webpack/prodConfig.js b/ui/webpack/prodConfig.js index fd06f9d6b0..8b9abe10b6 100644 --- a/ui/webpack/prodConfig.js +++ b/ui/webpack/prodConfig.js @@ -101,7 +101,10 @@ var config = { process.exit(1); } }); - } + }, + new webpack.DefinePlugin({ + VERSION: JSON.stringify(require('../package.json').version), + }), ], postcss: require('./postcss'), target: 'web', diff --git a/uuid/v4.go b/uuid/v4.go index c713f0a552..75077df682 100644 --- a/uuid/v4.go +++ b/uuid/v4.go @@ -1,12 +1,6 @@ package uuid -import ( - "context" - "time" - - "github.com/influxdata/chronograf" - uuid "github.com/satori/go.uuid" -) +import uuid "github.com/satori/go.uuid" // V4 implements chronograf.ID type V4 struct{} @@ -15,30 +9,3 @@ type V4 struct{} func (i *V4) Generate() (string, error) { return uuid.NewV4().String(), nil } - -// APIKey implements chronograf.Authenticator using V4 -type APIKey struct { - Key string -} - -// NewAPIKey creates an APIKey with a UUID v4 Key -func NewAPIKey() chronograf.Authenticator { - v4 := V4{} - key, _ := v4.Generate() - return &APIKey{ - Key: key, - } -} - -// Authenticate checks the key against the UUID v4 key -func (k *APIKey) Authenticate(ctx context.Context, key string) (chronograf.Principal, error) { - if key != k.Key { - return "", chronograf.ErrAuthentication - } - return "admin", nil -} - -// Token returns the UUID v4 key -func (k *APIKey) Token(context.Context, chronograf.Principal, time.Duration) (string, error) { - return k.Key, nil -} diff --git a/uuid/v4_test.go b/uuid/v4_test.go deleted file mode 100644 index 4791e78191..0000000000 --- a/uuid/v4_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package uuid_test - -import ( - "context" - "testing" - - "github.com/influxdata/chronograf" - "github.com/influxdata/chronograf/uuid" -) - -func TestAuthenticate(t *testing.T) { - var tests = []struct { - Desc string - APIKey string - Key string - Err error - User chronograf.Principal - }{ - - { - Desc: "Test auth err when keys are different", - APIKey: "key", - Key: "badkey", - Err: chronograf.ErrAuthentication, - User: "", - }, - { - Desc: "Test that admin user comes back", - APIKey: "key", - Key: "key", - Err: nil, - User: "admin", - }, - } - - for _, test := range tests { - k := uuid.APIKey{ - Key: test.APIKey, - } - u, err := k.Authenticate(context.Background(), test.Key) - if err != test.Err { - t.Errorf("Auth error different; expected %v actual %v", test.Err, err) - } - if u != test.User { - t.Errorf("Auth user different; expected %v actual %v", test.User, u) - } - } -}