Merge branch 'master' into moar-colors-n-stuff
commit
b35d668a41
|
@ -1,5 +1,5 @@
|
|||
[bumpversion]
|
||||
current_version = 1.4.2.3
|
||||
current_version = 1.4.3.0
|
||||
files = README.md server/swagger.json
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<release>\d+)
|
||||
serialize = {major}.{minor}.{patch}.{release}
|
||||
|
|
25
CHANGELOG.md
25
CHANGELOG.md
|
@ -1,5 +1,19 @@
|
|||
## v1.5.0.0 [unreleased]
|
||||
|
||||
### Features
|
||||
1. [#2526](https://github.com/influxdata/chronograf/pull/2526): Add support for RS256/JWKS verification, support for id_token parsing (as in ADFS)
|
||||
|
||||
### UI Improvements
|
||||
### Bug Fixes
|
||||
|
||||
## v1.4.3.0 [unreleased]
|
||||
|
||||
### UI Improvements
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
## v1.4.3.0 [2018-3-28]
|
||||
|
||||
### Features
|
||||
|
||||
1. [#2973](https://github.com/influxdata/chronograf/pull/2973): Add unsafe SSL to Kapacitor UI configuration
|
||||
|
@ -20,6 +34,14 @@
|
|||
1. [#2970](https://github.com/influxdata/chronograf/pull/2970): Fix hanging browser on docker host dashboard
|
||||
1. [#3006](https://github.com/influxdata/chronograf/pull/3006): Fix Kapacitor Rules task enabled checkboxes to only toggle exactly as clicked
|
||||
1. [#3048](https://github.com/influxdata/chronograf/pull/3048): Prevent Multi-Select Dropdown in InfluxDB Admin Users and Roles tabs from losing selection state
|
||||
1. [#3073](https://github.com/influxdata/chronograf/pull/3073): Fix Delete button in All Users admin page
|
||||
1. [#3068](https://github.com/influxdata/chronograf/pull/3068): Fix intermittent missing fill from graphs
|
||||
1. [#3087](https://github.com/influxdata/chronograf/pull/3087): Exit annotation edit mode when user navigates away from dashboard
|
||||
1. [#3079](https://github.com/influxdata/chronograf/pull/3082): Support custom time range in annotations api wrapper
|
||||
1. [#3068](https://github.com/influxdata/chronograf/pull/3068): Fix intermittent missing fill from graphs
|
||||
1. [#3079](https://github.com/influxdata/chronograf/pull/3082): Support custom time range in annotations api wrapper
|
||||
1. [#3087](https://github.com/influxdata/chronograf/pull/3087): Exit annotation edit mode when user navigates away from dashboard
|
||||
1. [#3073](https://github.com/influxdata/chronograf/pull/3073): Fix Delete button in All Users admin page
|
||||
|
||||
## v1.4.2.3 [2018-03-08]
|
||||
|
||||
|
@ -27,15 +49,12 @@
|
|||
|
||||
### Bug Fixes
|
||||
|
||||
1. [#2866](https://github.com/influxdata/chronograf/pull/2866): Change hover text on delete mappings confirmation button to 'Delete'
|
||||
1. [#2911](https://github.com/influxdata/chronograf/pull/2911): Fix Heroku OAuth
|
||||
1. [#2859](https://github.com/influxdata/chronograf/pull/2859): Enable Mappings save button when valid
|
||||
1. [#2933](https://github.com/influxdata/chronograf/pull/2933): Include url in Kapacitor connection creation requests
|
||||
|
||||
## v1.4.2.1 [2018-02-28]
|
||||
|
||||
### Features
|
||||
|
||||
1. [#2837](https://github.com/influxdata/chronograf/pull/2837): Prevent execution of queries in cells that are not in view on the dashboard page
|
||||
1. [#2829](https://github.com/influxdata/chronograf/pull/2829): Add an optional persistent legend which can toggle series visibility to dashboard cells
|
||||
1. [#2846](https://github.com/influxdata/chronograf/pull/2846): Allow user to annotate graphs via UI or API
|
||||
|
|
18
README.md
18
README.md
|
@ -136,7 +136,7 @@ option.
|
|||
## Versions
|
||||
|
||||
The most recent version of Chronograf is
|
||||
[v1.4.2.3](https://www.influxdata.com/downloads/).
|
||||
[v1.4.3.0](https://www.influxdata.com/downloads/).
|
||||
|
||||
Spotted a bug or have a feature request? Please open
|
||||
[an issue](https://github.com/influxdata/chronograf/issues/new)!
|
||||
|
@ -178,7 +178,7 @@ By default, chronograf runs on port `8888`.
|
|||
To get started right away with Docker, you can pull down our latest release:
|
||||
|
||||
```sh
|
||||
docker pull chronograf:1.4.2.3
|
||||
docker pull chronograf:1.4.3.0
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
@ -191,10 +191,16 @@ docker pull chronograf:1.4.2.3
|
|||
1. [Install Node and NPM](https://nodejs.org/en/download/)
|
||||
1. [Install yarn](https://yarnpkg.com/docs/install)
|
||||
1. [Setup your GOPATH](https://golang.org/doc/code.html#GOPATH)
|
||||
1. Run `go get github.com/influxdata/chronograf`
|
||||
1. Run `cd $GOPATH/src/github.com/influxdata/chronograf`
|
||||
1. Run `make`
|
||||
1. To install run `go install github.com/influxdata/chronograf/cmd/chronograf`
|
||||
1. Build the Chronograf package:
|
||||
```bash
|
||||
go get github.com/influxdata/chronograf
|
||||
cd $GOPATH/src/github.com/influxdata/chronograf
|
||||
make
|
||||
```
|
||||
1. Install the newly built Chronograf package:
|
||||
```bash
|
||||
go install github.com/influxdata/chronograf/cmd/chronograf
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
|
|
|
@ -2821,6 +2821,9 @@ func TestServer(t *testing.T) {
|
|||
// This is so that we can use staticly generate jwts
|
||||
tt.args.server.TokenSecret = "secret"
|
||||
|
||||
// Endpoint for validating RSA256 signatures when using id_token parsing for ADFS
|
||||
tt.args.server.JwksURL = ""
|
||||
|
||||
boltFile := newBoltFile()
|
||||
tt.args.server.BoltPath = boltFile
|
||||
|
||||
|
@ -2938,7 +2941,7 @@ func TestServer(t *testing.T) {
|
|||
buf, _ := json.Marshal(tt.args.payload)
|
||||
reqBody := ioutil.NopCloser(bytes.NewReader(buf))
|
||||
req, _ := http.NewRequest(tt.args.method, serverURL, reqBody)
|
||||
token, _ := oauth2.NewJWT(tt.args.server.TokenSecret).Create(ctx, tt.args.principal)
|
||||
token, _ := oauth2.NewJWT(tt.args.server.TokenSecret, tt.args.server.JwksURL).Create(ctx, tt.args.principal)
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "session",
|
||||
Value: string(token),
|
||||
|
|
|
@ -3,6 +3,7 @@ package oauth2
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
gojwt "github.com/dgrijalva/jwt-go"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
@ -32,6 +33,10 @@ func (m *MockTokenizer) ExtendedPrincipal(ctx context.Context, principal Princip
|
|||
return principal, m.ExtendErr
|
||||
}
|
||||
|
||||
func (m *MockTokenizer) GetClaims(tokenString string) (gojwt.MapClaims, error) {
|
||||
return gojwt.MapClaims{}, nil
|
||||
}
|
||||
|
||||
func TestCookieAuthorize(t *testing.T) {
|
||||
var test = []struct {
|
||||
Desc string
|
||||
|
|
|
@ -7,11 +7,20 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
gojwt "github.com/dgrijalva/jwt-go"
|
||||
"github.com/influxdata/chronograf"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
var _ Provider = &Generic{}
|
||||
// ExtendedProvider extendts the base Provider interface with optional methods
|
||||
type ExtendedProvider interface {
|
||||
Provider
|
||||
// get PrincipalID from id_token
|
||||
PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error)
|
||||
GroupFromClaims(claims gojwt.MapClaims) (string, error)
|
||||
}
|
||||
|
||||
var _ ExtendedProvider = &Generic{}
|
||||
|
||||
// Generic provides OAuth Login and Callback server and is modeled
|
||||
// after the Github OAuth2 provider. Callback will set an authentication
|
||||
|
@ -197,3 +206,26 @@ func ofDomain(requiredDomains []string, email string) bool {
|
|||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PrincipalIDFromClaims verifies an optional id_token and extracts email address of the user
|
||||
func (g *Generic) PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error) {
|
||||
if id, ok := claims[g.APIKey].(string); ok {
|
||||
return id, nil
|
||||
}
|
||||
return "", fmt.Errorf("no claim for %s", g.APIKey)
|
||||
}
|
||||
|
||||
// GroupFromClaims verifies an optional id_token, extracts the email address of the user and splits off the domain part
|
||||
func (g *Generic) GroupFromClaims(claims gojwt.MapClaims) (string, error) {
|
||||
if id, ok := claims[g.APIKey].(string); ok {
|
||||
email := strings.Split(id, "@")
|
||||
if len(email) != 2 {
|
||||
g.Logger.Error("malformed email address, expected %q to contain @ symbol", id)
|
||||
return "DEFAULT", nil
|
||||
}
|
||||
|
||||
return email[1], nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no claim for %s", g.APIKey)
|
||||
}
|
||||
|
|
137
oauth2/jwt.go
137
oauth2/jwt.go
|
@ -2,7 +2,12 @@ package oauth2
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gojwt "github.com/dgrijalva/jwt-go"
|
||||
|
@ -13,15 +18,19 @@ var _ Tokenizer = &JWT{}
|
|||
|
||||
// JWT represents a javascript web token that can be validated or marshaled into string.
|
||||
type JWT struct {
|
||||
Secret string
|
||||
Now func() time.Time
|
||||
Secret string
|
||||
Jwksurl string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// NewJWT creates a new JWT using time.Now; secret is used for signing and validating.
|
||||
func NewJWT(secret string) *JWT {
|
||||
// NewJWT creates a new JWT using time.Now
|
||||
// secret is used for signing and validating signatures (HS256/HMAC)
|
||||
// jwksurl is used for validating RS256 signatures.
|
||||
func NewJWT(secret string, jwksurl string) *JWT {
|
||||
return &JWT{
|
||||
Secret: secret,
|
||||
Now: DefaultNowTime,
|
||||
Secret: secret,
|
||||
Jwksurl: jwksurl,
|
||||
Now: DefaultNowTime,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -62,16 +71,98 @@ func (j *JWT) ValidPrincipal(ctx context.Context, jwtToken Token, lifespan time.
|
|||
gojwt.TimeFunc = j.Now
|
||||
|
||||
// Check for expected signing method.
|
||||
alg := func(token *gojwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*gojwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(j.Secret), nil
|
||||
}
|
||||
alg := j.KeyFunc
|
||||
|
||||
return j.ValidClaims(jwtToken, lifespan, alg)
|
||||
}
|
||||
|
||||
// KeyFunc verifies HMAC or RSA/RS256 signatures
|
||||
func (j *JWT) KeyFunc(token *gojwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*gojwt.SigningMethodHMAC); ok {
|
||||
return []byte(j.Secret), nil
|
||||
} else if _, ok := token.Method.(*gojwt.SigningMethodRSA); ok {
|
||||
return j.KeyFuncRS256(token)
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// For the id_token, the recommended signature algorithm is RS256, which
|
||||
// means we need to verify the token against a public key. This public key
|
||||
// is available from the key discovery service in JSON Web Key (JWK).
|
||||
// JWK is specified in RFC 7517.
|
||||
//
|
||||
// The location of the key discovery service (JWKSURL) is published in the
|
||||
// OpenID Provider Configuration Information at /.well-known/openid-configuration
|
||||
// implements rfc7517 section 4.7 "x5c" (X.509 Certificate Chain) Parameter
|
||||
|
||||
// JWK defines a JSON Web KEy nested struct
|
||||
type JWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Use string `json:"use"`
|
||||
Alg string `json:"alg"`
|
||||
Kid string `json:"kid"`
|
||||
X5t string `json:"x5t"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
X5c []string `json:"x5c"`
|
||||
}
|
||||
|
||||
// JWKS defines a JKW[]
|
||||
type JWKS struct {
|
||||
Keys []JWK `json:"keys"`
|
||||
}
|
||||
|
||||
// KeyFuncRS256 verifies RS256 signed JWT tokens, it looks up the signing key in the key discovery service
|
||||
func (j *JWT) KeyFuncRS256(token *gojwt.Token) (interface{}, error) {
|
||||
// Don't forget to validate the alg is what you expect:
|
||||
if _, ok := token.Method.(*gojwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("Unsupported signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
// read JWKS document from key discovery service
|
||||
if j.Jwksurl == "" {
|
||||
return nil, fmt.Errorf("JWKSURL not specified, cannot validate RS256 signature")
|
||||
}
|
||||
|
||||
rr, err := http.Get(j.Jwksurl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rr.Body.Close()
|
||||
body, err := ioutil.ReadAll(rr.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// parse json to struct
|
||||
var jwks JWKS
|
||||
if err := json.Unmarshal([]byte(body), &jwks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// extract cert when kid and alg match
|
||||
var certPkix []byte
|
||||
for _, jwk := range jwks.Keys {
|
||||
if token.Header["kid"] == jwk.Kid && token.Header["alg"] == jwk.Alg {
|
||||
// FIXME: optionally walk the key chain, see rfc7517 section 4.7
|
||||
certPkix, err = base64.StdEncoding.DecodeString(jwk.X5c[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("base64 decode error for JWK kid %v", token.Header["kid"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if certPkix == nil {
|
||||
return nil, fmt.Errorf("no signing key found for kid %v", token.Header["kid"])
|
||||
}
|
||||
|
||||
// parse certificate (from PKIX format) and return signing key
|
||||
cert, err := x509.ParseCertificate(certPkix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cert.PublicKey, nil
|
||||
}
|
||||
|
||||
// ValidClaims validates a token with StandardClaims
|
||||
func (j *JWT) ValidClaims(jwtToken Token, lifespan time.Duration, alg gojwt.Keyfunc) (Principal, error) {
|
||||
// 1. Checks for expired tokens
|
||||
|
@ -114,6 +205,28 @@ func (j *JWT) ValidClaims(jwtToken Token, lifespan time.Duration, alg gojwt.Keyf
|
|||
}, nil
|
||||
}
|
||||
|
||||
// GetClaims extracts claims from id_token
|
||||
func (j *JWT) GetClaims(tokenString string) (gojwt.MapClaims, error) {
|
||||
var claims gojwt.MapClaims
|
||||
|
||||
gojwt.TimeFunc = j.Now
|
||||
token, err := gojwt.Parse(tokenString, j.KeyFunc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, fmt.Errorf("token is not valid")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(gojwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("token has no claims")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// Create creates a signed JWT token from user that expires at Principal's ExpireAt time.
|
||||
func (j *JWT) Create(ctx context.Context, user Principal) (Token, error) {
|
||||
// Create a new token object, specifying signing method and the claims
|
||||
|
|
|
@ -3,6 +3,9 @@ package oauth2_test
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
@ -152,8 +155,82 @@ func TestSigningMethod(t *testing.T) {
|
|||
j := oauth2.JWT{}
|
||||
if _, err := j.ValidPrincipal(context.Background(), token, 0); err == nil {
|
||||
t.Error("Error was expected while validating incorrectly signed token")
|
||||
} else if err.Error() != "unexpected signing method: RS256" {
|
||||
t.Errorf("Error wanted 'unexpected signing method', got %s", err.Error())
|
||||
} else if err.Error() != "JWKSURL not specified, cannot validate RS256 signature" {
|
||||
t.Errorf("Error wanted 'JWKSURL not specified, cannot validate RS256 signature', got %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClaims(t *testing.T) {
|
||||
var tests = []struct {
|
||||
Name string
|
||||
TokenString string
|
||||
JwksDocument string
|
||||
Iat int64
|
||||
Err error
|
||||
}{
|
||||
{
|
||||
Name: "Valid Token with RS256 signature verified against correct JWKS document",
|
||||
TokenString: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSIsImtpZCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJjaHJvbm9ncmFmIiwiaXNzIjoiaHR0cHM6Ly9kc3RjaW1hYWQxcC5kc3QtaXRzLmRlL2FkZnMiLCJpYXQiOjE1MTMxNjU4ODksImV4cCI6MTUxMzE2OTQ4OSwiYXV0aF90aW1lIjoxNTEzMTY1ODg4LCJzdWIiOiJlWVYzamRsZE55RlkxcUZGSDRvQWRCdkRGZmJWZm51RzI5SGlIa1N1andrPSIsInVwbiI6ImJzY0Bkc3QtaXRzLmRlIiwidW5pcXVlX25hbWUiOiJEU1RcXGJzYyIsInNpZCI6IlMtMS01LTIxLTI1MDUxNTEzOTgtMjY2MTAyODEwOS0zNzU0MjY1ODIwLTExMDQifQ.nK51Ui4XN45SVul9igNaKFQd-F63BNstBzW-T5LBVm_ANHCEHyP3_88C3ffkkQIi3PxYacRJGtfswP35ws7YJUcNp-GoGZARqz62NpMtbQyhos6mCaVXwPoxPbrZx4AkMQgxkZwJcOzceX7mpjcT3kCth30chN3lkhzSjGrXe4ZDOAV25liS-dsdBiqDiaTB91sS534GM76qJQxFUs51oSbYTRdCN1VJ0XopMcasfVDzFrtSbyvEIVXlpKK2HplnhheqF4QHrM_3cjV_NGRr3tYLe-AGTdDXKWlJD1GDz1ECXeMGQHPoz3U8cqNsFLYBstIlCgfnBWgWsPZSvJPJUg",
|
||||
JwksDocument: `{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"YDBUhqdWksKXdGuX0sytjaUuxhA","x5t":"YDBUhqdWksKXdGuX0sytjaUuxhA","n":"uwVVrs5OJRKeLUk0H5N_b4Jbvff3rxlg3WIeOO-zSSPTC5oFOc5_te0rLgVoNJJB4rNM4A7BEXI885xLrjfL3l3LHqaJetvR0tdLAnkvbUKUiGxnuGnmOsgh491P95pHPIAniy2p64FQoBbTJ0a6cF5LRuPPHKVXgjXjTydvmKrt_IVaWUDgICRsw5Bbv290SahmxcdO3akSgfsZtRkR8SmaMzAPYINi2_8P2evaKAnMQLTgUVkctaEamO_6HJ5f5sWheV7trLekU35xPVkPwShDelefnhyJcO5yICXqXzuewBEni9LrxAEJYN2rYfiFQWJy-pDe5DPUBs-IFTpctQ","e":"AQAB","x5c":["MIIC6DCCAdCgAwIBAgIQPszqLhbrpZlE+jEJTyJg7jANBgkqhkiG9w0BAQsFADAwMS4wLAYDVQQDEyVBREZTIFNpZ25pbmcgLSBkc3RjaW1hYWQxcC5kc3QtaXRzLmRlMB4XDTE3MTIwNDE0MDEwOFoXDTE4MTIwNDE0MDEwOFowMDEuMCwGA1UEAxMlQURGUyBTaWduaW5nIC0gZHN0Y2ltYWFkMXAuZHN0LWl0cy5kZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALsFVa7OTiUSni1JNB+Tf2+CW733968ZYN1iHjjvs0kj0wuaBTnOf7XtKy4FaDSSQeKzTOAOwRFyPPOcS643y95dyx6miXrb0dLXSwJ5L21ClIhsZ7hp5jrIIePdT\/eaRzyAJ4stqeuBUKAW0ydGunBeS0bjzxylV4I1408nb5iq7fyFWllA4CAkbMOQW79vdEmoZsXHTt2pEoH7GbUZEfEpmjMwD2CDYtv\/D9nr2igJzEC04FFZHLWhGpjv+hyeX+bFoXle7ay3pFN+cT1ZD8EoQ3pXn54ciXDuciAl6l87nsARJ4vS68QBCWDdq2H4hUFicvqQ3uQz1AbPiBU6XLUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAPHCisZyPf\/fuuQEW5LyzZSYMwBRYVR6kk\/M2ZNx6TrUEwmOb10RQ3G97bLAshN44g5lWdPYz4EOt6d2o71etIjf79f+IR0MAjEgBB2HThaHcMU9KG229Ftcauie9XeurngMawTRu60YqH7+go8EMf6a1Kdnx37DMy\/1LRlsYJVfEoOCab3GgcIdXrRSYWqsY4SVJZiTPYdqz9vmNPSXXiDSOTl6qXHV\/f53WTS2V5aIQbuJJziXlceusuVNny0o5h+j6ovZ1HhEGAu3lpD+8kY8KUqA4kXMH3VNZqzHBYazJx\/QBB3bG45cZSOvV3gUOnGBgiv9NBWjhvmY0fC3J6Q=="]}]}`,
|
||||
Iat: int64(1513165889),
|
||||
},
|
||||
{
|
||||
Name: "Valid Token with RS256 signature verified against correct JWKS document but predated",
|
||||
TokenString: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSIsImtpZCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJjaHJvbm9ncmFmIiwiaXNzIjoiaHR0cHM6Ly9kc3RjaW1hYWQxcC5kc3QtaXRzLmRlL2FkZnMiLCJpYXQiOjE1MTMxNjU4ODksImV4cCI6MTUxMzE2OTQ4OSwiYXV0aF90aW1lIjoxNTEzMTY1ODg4LCJzdWIiOiJlWVYzamRsZE55RlkxcUZGSDRvQWRCdkRGZmJWZm51RzI5SGlIa1N1andrPSIsInVwbiI6ImJzY0Bkc3QtaXRzLmRlIiwidW5pcXVlX25hbWUiOiJEU1RcXGJzYyIsInNpZCI6IlMtMS01LTIxLTI1MDUxNTEzOTgtMjY2MTAyODEwOS0zNzU0MjY1ODIwLTExMDQifQ.nK51Ui4XN45SVul9igNaKFQd-F63BNstBzW-T5LBVm_ANHCEHyP3_88C3ffkkQIi3PxYacRJGtfswP35ws7YJUcNp-GoGZARqz62NpMtbQyhos6mCaVXwPoxPbrZx4AkMQgxkZwJcOzceX7mpjcT3kCth30chN3lkhzSjGrXe4ZDOAV25liS-dsdBiqDiaTB91sS534GM76qJQxFUs51oSbYTRdCN1VJ0XopMcasfVDzFrtSbyvEIVXlpKK2HplnhheqF4QHrM_3cjV_NGRr3tYLe-AGTdDXKWlJD1GDz1ECXeMGQHPoz3U8cqNsFLYBstIlCgfnBWgWsPZSvJPJUg",
|
||||
JwksDocument: `{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"YDBUhqdWksKXdGuX0sytjaUuxhA","x5t":"YDBUhqdWksKXdGuX0sytjaUuxhA","n":"uwVVrs5OJRKeLUk0H5N_b4Jbvff3rxlg3WIeOO-zSSPTC5oFOc5_te0rLgVoNJJB4rNM4A7BEXI885xLrjfL3l3LHqaJetvR0tdLAnkvbUKUiGxnuGnmOsgh491P95pHPIAniy2p64FQoBbTJ0a6cF5LRuPPHKVXgjXjTydvmKrt_IVaWUDgICRsw5Bbv290SahmxcdO3akSgfsZtRkR8SmaMzAPYINi2_8P2evaKAnMQLTgUVkctaEamO_6HJ5f5sWheV7trLekU35xPVkPwShDelefnhyJcO5yICXqXzuewBEni9LrxAEJYN2rYfiFQWJy-pDe5DPUBs-IFTpctQ","e":"AQAB","x5c":["MIIC6DCCAdCgAwIBAgIQPszqLhbrpZlE+jEJTyJg7jANBgkqhkiG9w0BAQsFADAwMS4wLAYDVQQDEyVBREZTIFNpZ25pbmcgLSBkc3RjaW1hYWQxcC5kc3QtaXRzLmRlMB4XDTE3MTIwNDE0MDEwOFoXDTE4MTIwNDE0MDEwOFowMDEuMCwGA1UEAxMlQURGUyBTaWduaW5nIC0gZHN0Y2ltYWFkMXAuZHN0LWl0cy5kZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALsFVa7OTiUSni1JNB+Tf2+CW733968ZYN1iHjjvs0kj0wuaBTnOf7XtKy4FaDSSQeKzTOAOwRFyPPOcS643y95dyx6miXrb0dLXSwJ5L21ClIhsZ7hp5jrIIePdT\/eaRzyAJ4stqeuBUKAW0ydGunBeS0bjzxylV4I1408nb5iq7fyFWllA4CAkbMOQW79vdEmoZsXHTt2pEoH7GbUZEfEpmjMwD2CDYtv\/D9nr2igJzEC04FFZHLWhGpjv+hyeX+bFoXle7ay3pFN+cT1ZD8EoQ3pXn54ciXDuciAl6l87nsARJ4vS68QBCWDdq2H4hUFicvqQ3uQz1AbPiBU6XLUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAPHCisZyPf\/fuuQEW5LyzZSYMwBRYVR6kk\/M2ZNx6TrUEwmOb10RQ3G97bLAshN44g5lWdPYz4EOt6d2o71etIjf79f+IR0MAjEgBB2HThaHcMU9KG229Ftcauie9XeurngMawTRu60YqH7+go8EMf6a1Kdnx37DMy\/1LRlsYJVfEoOCab3GgcIdXrRSYWqsY4SVJZiTPYdqz9vmNPSXXiDSOTl6qXHV\/f53WTS2V5aIQbuJJziXlceusuVNny0o5h+j6ovZ1HhEGAu3lpD+8kY8KUqA4kXMH3VNZqzHBYazJx\/QBB3bG45cZSOvV3gUOnGBgiv9NBWjhvmY0fC3J6Q=="]}]}`,
|
||||
Iat: int64(1513165889) - 1,
|
||||
Err: errors.New("Token used before issued"),
|
||||
},
|
||||
{
|
||||
Name: "Valid Token with RS256 signature verified against correct JWKS document but outdated",
|
||||
TokenString: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSIsImtpZCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJjaHJvbm9ncmFmIiwiaXNzIjoiaHR0cHM6Ly9kc3RjaW1hYWQxcC5kc3QtaXRzLmRlL2FkZnMiLCJpYXQiOjE1MTMxNjU4ODksImV4cCI6MTUxMzE2OTQ4OSwiYXV0aF90aW1lIjoxNTEzMTY1ODg4LCJzdWIiOiJlWVYzamRsZE55RlkxcUZGSDRvQWRCdkRGZmJWZm51RzI5SGlIa1N1andrPSIsInVwbiI6ImJzY0Bkc3QtaXRzLmRlIiwidW5pcXVlX25hbWUiOiJEU1RcXGJzYyIsInNpZCI6IlMtMS01LTIxLTI1MDUxNTEzOTgtMjY2MTAyODEwOS0zNzU0MjY1ODIwLTExMDQifQ.nK51Ui4XN45SVul9igNaKFQd-F63BNstBzW-T5LBVm_ANHCEHyP3_88C3ffkkQIi3PxYacRJGtfswP35ws7YJUcNp-GoGZARqz62NpMtbQyhos6mCaVXwPoxPbrZx4AkMQgxkZwJcOzceX7mpjcT3kCth30chN3lkhzSjGrXe4ZDOAV25liS-dsdBiqDiaTB91sS534GM76qJQxFUs51oSbYTRdCN1VJ0XopMcasfVDzFrtSbyvEIVXlpKK2HplnhheqF4QHrM_3cjV_NGRr3tYLe-AGTdDXKWlJD1GDz1ECXeMGQHPoz3U8cqNsFLYBstIlCgfnBWgWsPZSvJPJUg",
|
||||
JwksDocument: `{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"YDBUhqdWksKXdGuX0sytjaUuxhA","x5t":"YDBUhqdWksKXdGuX0sytjaUuxhA","n":"uwVVrs5OJRKeLUk0H5N_b4Jbvff3rxlg3WIeOO-zSSPTC5oFOc5_te0rLgVoNJJB4rNM4A7BEXI885xLrjfL3l3LHqaJetvR0tdLAnkvbUKUiGxnuGnmOsgh491P95pHPIAniy2p64FQoBbTJ0a6cF5LRuPPHKVXgjXjTydvmKrt_IVaWUDgICRsw5Bbv290SahmxcdO3akSgfsZtRkR8SmaMzAPYINi2_8P2evaKAnMQLTgUVkctaEamO_6HJ5f5sWheV7trLekU35xPVkPwShDelefnhyJcO5yICXqXzuewBEni9LrxAEJYN2rYfiFQWJy-pDe5DPUBs-IFTpctQ","e":"AQAB","x5c":["MIIC6DCCAdCgAwIBAgIQPszqLhbrpZlE+jEJTyJg7jANBgkqhkiG9w0BAQsFADAwMS4wLAYDVQQDEyVBREZTIFNpZ25pbmcgLSBkc3RjaW1hYWQxcC5kc3QtaXRzLmRlMB4XDTE3MTIwNDE0MDEwOFoXDTE4MTIwNDE0MDEwOFowMDEuMCwGA1UEAxMlQURGUyBTaWduaW5nIC0gZHN0Y2ltYWFkMXAuZHN0LWl0cy5kZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALsFVa7OTiUSni1JNB+Tf2+CW733968ZYN1iHjjvs0kj0wuaBTnOf7XtKy4FaDSSQeKzTOAOwRFyPPOcS643y95dyx6miXrb0dLXSwJ5L21ClIhsZ7hp5jrIIePdT\/eaRzyAJ4stqeuBUKAW0ydGunBeS0bjzxylV4I1408nb5iq7fyFWllA4CAkbMOQW79vdEmoZsXHTt2pEoH7GbUZEfEpmjMwD2CDYtv\/D9nr2igJzEC04FFZHLWhGpjv+hyeX+bFoXle7ay3pFN+cT1ZD8EoQ3pXn54ciXDuciAl6l87nsARJ4vS68QBCWDdq2H4hUFicvqQ3uQz1AbPiBU6XLUCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAPHCisZyPf\/fuuQEW5LyzZSYMwBRYVR6kk\/M2ZNx6TrUEwmOb10RQ3G97bLAshN44g5lWdPYz4EOt6d2o71etIjf79f+IR0MAjEgBB2HThaHcMU9KG229Ftcauie9XeurngMawTRu60YqH7+go8EMf6a1Kdnx37DMy\/1LRlsYJVfEoOCab3GgcIdXrRSYWqsY4SVJZiTPYdqz9vmNPSXXiDSOTl6qXHV\/f53WTS2V5aIQbuJJziXlceusuVNny0o5h+j6ovZ1HhEGAu3lpD+8kY8KUqA4kXMH3VNZqzHBYazJx\/QBB3bG45cZSOvV3gUOnGBgiv9NBWjhvmY0fC3J6Q=="]}]}`,
|
||||
Iat: int64(1513165889) + 3601,
|
||||
Err: errors.New("Token is expired"),
|
||||
},
|
||||
{
|
||||
Name: "Valid Token with RS256 signature verified against empty JWKS document",
|
||||
TokenString: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSIsImtpZCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJjaHJvbm9ncmFmIiwiaXNzIjoiaHR0cHM6Ly9kc3RjaW1hYWQxcC5kc3QtaXRzLmRlL2FkZnMiLCJpYXQiOjE1MTMxNjU4ODksImV4cCI6MTUxMzE2OTQ4OSwiYXV0aF90aW1lIjoxNTEzMTY1ODg4LCJzdWIiOiJlWVYzamRsZE55RlkxcUZGSDRvQWRCdkRGZmJWZm51RzI5SGlIa1N1andrPSIsInVwbiI6ImJzY0Bkc3QtaXRzLmRlIiwidW5pcXVlX25hbWUiOiJEU1RcXGJzYyIsInNpZCI6IlMtMS01LTIxLTI1MDUxNTEzOTgtMjY2MTAyODEwOS0zNzU0MjY1ODIwLTExMDQifQ.nK51Ui4XN45SVul9igNaKFQd-F63BNstBzW-T5LBVm_ANHCEHyP3_88C3ffkkQIi3PxYacRJGtfswP35ws7YJUcNp-GoGZARqz62NpMtbQyhos6mCaVXwPoxPbrZx4AkMQgxkZwJcOzceX7mpjcT3kCth30chN3lkhzSjGrXe4ZDOAV25liS-dsdBiqDiaTB91sS534GM76qJQxFUs51oSbYTRdCN1VJ0XopMcasfVDzFrtSbyvEIVXlpKK2HplnhheqF4QHrM_3cjV_NGRr3tYLe-AGTdDXKWlJD1GDz1ECXeMGQHPoz3U8cqNsFLYBstIlCgfnBWgWsPZSvJPJUg",
|
||||
JwksDocument: "",
|
||||
Iat: int64(1513165889),
|
||||
Err: errors.New("unexpected end of JSON input"),
|
||||
},
|
||||
{
|
||||
Name: "Invalid Token",
|
||||
Err: errors.New("token contains an invalid number of segments"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
// mock JWKS server
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.WriteString(w, tt.JwksDocument)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
j := oauth2.JWT{
|
||||
Jwksurl: ts.URL,
|
||||
Now: func() time.Time {
|
||||
return time.Unix(tt.Iat, 0)
|
||||
},
|
||||
}
|
||||
_, err := j.GetClaims(tt.TokenString)
|
||||
if tt.Err != nil {
|
||||
if err != nil {
|
||||
if tt.Err.Error() != err.Error() {
|
||||
t.Errorf("Error in test %s expected error: %v actual: %v", tt.Name, tt.Err, err)
|
||||
} // else: that's what we expect
|
||||
} else {
|
||||
t.Errorf("Error in test %s expected error: %v actual: none", tt.Name, tt.Err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Error in tt %s: %v", tt.Name, err)
|
||||
} // else: that's what we expect
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ var _ Mux = &AuthMux{}
|
|||
const TenMinutes = 10 * time.Minute
|
||||
|
||||
// NewAuthMux constructs a Mux handler that checks a cookie against the authenticator
|
||||
func NewAuthMux(p Provider, a Authenticator, t Tokenizer, basepath string, l chronograf.Logger) *AuthMux {
|
||||
func NewAuthMux(p Provider, a Authenticator, t Tokenizer, basepath string, l chronograf.Logger, UseIDToken bool) *AuthMux {
|
||||
return &AuthMux{
|
||||
Provider: p,
|
||||
Auth: a,
|
||||
|
@ -25,6 +25,7 @@ func NewAuthMux(p Provider, a Authenticator, t Tokenizer, basepath string, l chr
|
|||
FailureURL: path.Join(basepath, "/login"),
|
||||
Now: DefaultNowTime,
|
||||
Logger: l,
|
||||
UseIDToken: UseIDToken,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -41,6 +42,7 @@ type AuthMux struct {
|
|||
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 (for testing)
|
||||
UseIDToken bool // UseIDToken enables OpenID id_token support
|
||||
}
|
||||
|
||||
// Login uses a Cookie with a random string as the state validation method. JWTs are
|
||||
|
@ -116,20 +118,61 @@ func (j *AuthMux) Callback() http.Handler {
|
|||
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
|
||||
if token.Extra("id_token") != nil && !j.UseIDToken {
|
||||
log.Info("found an extra id_token, but option --useidtoken is not set")
|
||||
}
|
||||
|
||||
group, err := j.Provider.Group(oauthClient)
|
||||
if err != nil {
|
||||
log.Error("Unable to get OAuth Group", err.Error())
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
// if we received an extra id_token, inspect it
|
||||
var id string
|
||||
var group string
|
||||
if j.UseIDToken && token.Extra("id_token") != nil && token.Extra("id_token") != "" {
|
||||
log.Debug("found an extra id_token")
|
||||
if provider, ok := j.Provider.(ExtendedProvider); ok {
|
||||
log.Debug("provider implements PrincipalIDFromClaims()")
|
||||
tokenString, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
log.Error("cannot cast id_token as string")
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
claims, err := j.Tokens.GetClaims(tokenString)
|
||||
if err != nil {
|
||||
log.Error("parsing extra id_token failed:", err)
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
log.Debug("found claims: ", claims)
|
||||
id, err = provider.PrincipalIDFromClaims(claims)
|
||||
if err != nil {
|
||||
log.Error("requested claim not found in id_token:", err)
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
group, err = provider.GroupFromClaims(claims)
|
||||
if err != nil {
|
||||
log.Error("requested claim not found in id_token:", err)
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
log.Debug("provider does not implement PrincipalIDFromClaims()")
|
||||
}
|
||||
} else {
|
||||
// otherwise perform an additional lookup
|
||||
oauthClient := conf.Client(r.Context(), token)
|
||||
// Using the token get the principal identifier from the provider
|
||||
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
|
||||
}
|
||||
group, err = j.Provider.Group(oauthClient)
|
||||
if err != nil {
|
||||
log.Error("Unable to get OAuth Group", err.Error())
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
p := Principal{
|
||||
|
|
|
@ -51,7 +51,9 @@ func setupMuxTest(response interface{}, selector func(*AuthMux) http.Handler) (*
|
|||
Tokens: mt,
|
||||
}
|
||||
|
||||
jm := NewAuthMux(mp, auth, mt, "", clog.New(clog.ParseLevel("debug")))
|
||||
useidtoken := false
|
||||
|
||||
jm := NewAuthMux(mp, auth, mt, "", clog.New(clog.ParseLevel("debug")), useidtoken)
|
||||
ts := httptest.NewServer(selector(jm))
|
||||
jar, _ := cookiejar.New(nil)
|
||||
hc := http.Client{
|
||||
|
@ -177,3 +179,43 @@ func Test_AuthMux_Callback_SetsCookie(t *testing.T) {
|
|||
t.Fatal("Expected cookie to be named", DefaultCookieName, "but was", c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_AuthMux_Callback_HandlesIdToken(t *testing.T) {
|
||||
// body taken from ADFS4
|
||||
response := mockCallbackResponse{AccessToken: `eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJ1cm46bWljcm9zb2Z0OnVzZXJpbmZvIiwiaXNzIjoiaHR0cDovL2RzdGNpbWFhZDFwLmRzdC1pdHMuZGUvYWRmcy9zZXJ2aWNlcy90cnVzdCIsImlhdCI6MTUxNTcwMDU2NSwiZXhwIjoxNTE1NzA0MTY1LCJhcHB0eXBlIjoiQ29uZmlkZW50aWFsIiwiYXBwaWQiOiJjaHJvbm9ncmFmIiwiYXV0aG1ldGhvZCI6InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphYzpjbGFzc2VzOlBhc3N3b3JkUHJvdGVjdGVkVHJhbnNwb3J0IiwiYXV0aF90aW1lIjoiMjAxOC0wMS0xMVQxOTo1MToyNS44MDZaIiwidmVyIjoiMS4wIiwic2NwIjoib3BlbmlkIiwic3ViIjoiZVlWM2pkbGROeUZZMXFGRkg0b0FkQnZERmZiVmZudUcyOUhpSGtTdWp3az0ifQ.sf1qJys9LMUp2S232IRK2aTXiPCE93O-cUdYQQz7kg2woyD46KLwwKIYJVqMaqLspTn3OmaIhKtgx5ZXyAEtihODB1GOBK7DBNRBYCS1iqY_v2-Qwjf7hgaNaCqBjs0DZJspfp5G9MTykvD1FOtQNjPOcBW-i2bblG9L9jlmMbOZ3F7wrZMrroTSkiSn_gRiw2SnN8K7w8WrMEXNK2_jg9ZJ7aSHeUSBwkRNFRds2QNho3HWHg-zcsZFdZ4UGSt-6Az_0LY3yENMLj5us5Rl6Qzk_Re2dhFrlnlXlY1v1DEp3icCvvjkv6AeZWjTfW4qETZaCXUKtSyZ7d5_V1CRDQ", "token_type": "bearer", "expires_in": 3600, "resource": "urn:microsoft:userinfo", "refresh_token": "X9ZGO4H1bMk2bFeOfpv18BzAuFBzUPKQNfOEfdp60FkAAQAALPEBfj23FPEzajle-hm4DrDXp8-Kj53OqoVGalyZeuR-lfJzxpQXQhRAXZOUTuuQ8AQycByh9AylQYDA0jdMFW4FL4WL_6JhNh2JrtXCv2HQ9ozbUq9F7u_O0cY7u0P2pfNujQfk3ckYn-CMVjXbuwJTve6bXUR0JDp5c195bAVA5eFWyI-2uh432t7viyaIjAVbWxQF4fvimcpF1Et9cGodZHVsrZzGxKRnzwjYkWHsqm9go4KOeSKN6MlcWbjvS1UdMjQXSvoqSI00JnSMC3hxJZFn5JcmAPB1AMnJf4VvXZ5b-aOnwdX09YT8KayWkWekAsuZqTAsFwhZPVCRGWAFAADy0e2fTe6l-U6Cj_2bWsq6Snm1QEpWHXuwOJKWZJH-9yQn8KK3KzRowSzRuACzEIpZS5skrqXs_-2aOaZibNpjCEVyw8fF8GTw3VRLufsSrMQ5pD0KL7TppTGFpaqgwIH1yq6T8aRY4DeyoJkNpnO9cw1wuqnY7oGF-J25sfZ4XNWhk6o5e9A45PXhTilClyDKDLqTfdoIsG1Koc2ywqTIb-XI_EbWR3e4ijy8Kmlehw1kU9_xAG0MmmD2HTyGHZCBRgrskYCcHd-UNgCMrNAb5dZQ8NwpKtEL46qIq4R0lheTRRK8sOWzzuJXmvDEoJiIxqSR3Ma4MOISi-vsIsAuiEL9G1aMOkDRj-kDVmqrdKRAwYnN78AWY5EFfkQJyVBbiG882wBh9S0q3HUUCxzFerOvl4eDlVn6m18rRMz7CVZYBBltGtHRhEOQ4gumICR5JRrXAC50aBmUlhDiiMdbEIwJrvWrkhKE0oAJznqC7gleP0E4EOEh9r6CEGZ7Oj8X9Cdzjbuq2G1JGBm_yUvkhAcV61DjOiIQl35BpOfshveNZf_caUtNMa2i07BBmezve17-2kWGzRunr1BD1vMTz41z-H62fy4McR47WJjdDJnuy4DH5AZYQ6ooVxWCtEqeqRPYpzO0XdOdJGXFqXs9JzDKVXTgnHU443hZBC5H-BJkZDuuJ_ZWNKXf03JhouWkxXcdaMbuaQYOZJsUySVyJ5X4usrBFjW4udZAzy7mua-nJncbvcwoyVXiFlRfZiySXolQ9865N7XUnEk_2PijMLoVDATDbA09XuRySvngNsdsQ27p21dPxChXdtpD5ofNqKJ2FBzFKmxCkuX7L01N1nDpWQTuxhHF0JfxSKG5m3jcTx8Bd7Un94mTuAB7RuglDqkdQB9o4X9NHNGSdqGQaK-xeKoNCFWevk3VZoDoY9w2NqSNV2VIuqhy7SxtDSMjZKC5kiQi5EfGeTYZAvTwMYwaXb7K4WWtscy_ZE15EOCVeYi0hM1Ma8iFFTANkSRyX83Ju4SRphxRKnpKcJ2pPYH784I5HOm5sclhUL3aLeAA161QgxRBSa9YVIZfyXHyWQTcbNucNdhmdUZnKfRv1xtXcS9VAx2yAkoKFehZivEINX0Y500-WZ1eT_RXp0BfCKmJQ8Fu50oTaI-c5h2Q3Gp_LTSODNnMrjJiJxCLD_LD1fd1e8jTYDV3NroGlpWTuTdjMUm-Z1SMXaaJzQGEnNT6F8b6un9228L6YrDC_3MJ5J80VAHL5EO1GesdEWblugCL7AQDtFjNXq0lK8Aoo8X9_hlvDwgfdR16l8QALPT1HJVzlHPG8G3dRe50TKZnl3obU0WXN1KYG1EC4Qa3LyaVCIuGJYOeFqjMINrf7PoM368nS9yhrY08nnoHZbQ7IeA1KsNq2kANeH1doCNfWrXDwn8KxjYxZPEnzvlQ5M1RIzArOqzWL8NbftW1q2yCZZ4RVg0vOTVXsqWFnQIvWK-mkELa7bvByFzbtVHOJpc_2EKBKBNv6IYUENRCu2TOf6w7u42yvng7ccoXRTiUFUlKgVmswf9FzISxFd-YKgrzp3bMhC3gReGqcJuqEwnXPvOAY_BAkVMSd_ZaCFuyclRjFvUxrAg1T_cqOvRIlJ2Qq7z4u7W3BAo9BtFdj8QNLKJXtvvzXTprglRPDNP_QEPAkwZ_Uxa13vdYFcG18WCx4GbWQXchl5B7DnISobcdCH34M-I0xDZN98VWQVmLAfPniDUD30C8pfiYF7tW_EVy958Eg_JWVy0SstYEhV-y-adrJ1Oimjv0ptsWv-yErKBUD14aex9A_QqdnTXZUg.tqMb72eWAkAIvInuLp57NDyGxfYvms3NnhN-mllkYb7Xpd8gVbQFc2mYdzOOhtnfGuakyXYF4rZdJonQwzBO6C9KYuARciUU1Ms4bWPC-aeNO5t-aO_bDZbwC9qMPmq5ZuxG633BARGaw26fr0Z7qhcJMiou_EuaIehYTKkPB-mxtRAhxxyX91qqe0-PJnCHWoxizC4hDCUwp9Jb54tNf34BG3vtkXFX-kUARNfGucgKUkh6RYkhWiMBsMVoyWmkFXB5fYxmCAH5c5wDW6srKdyIDEWZInliuKbYR0p66vg1FfoSi4bBfrsm5NtCtLKG9V6Q0FEIA6tRRgHmKUGpkw", "refresh_token_expires_in": 28519, "scope": "openid", "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSIsImtpZCI6IllEQlVocWRXa3NLWGRHdVgwc3l0amFVdXhoQSJ9.eyJhdWQiOiJjaHJvbm9ncmFmIiwiaXNzIjoiaHR0cHM6Ly9kc3RjaW1hYWQxcC5kc3QtaXRzLmRlL2FkZnMiLCJpYXQiOjE1MTU3MDA1NjUsImV4cCI6MTUxNTcwNDE2NSwiYXV0aF90aW1lIjoxNTE1NzAwMjg1LCJzdWIiOiJlWVYzamRsZE55RlkxcUZGSDRvQWRCdkRGZmJWZm51RzI5SGlIa1N1andrPSIsInVwbiI6ImJzY0Bkc3QtaXRzLmRlIiwidW5pcXVlX25hbWUiOiJEU1RcXGJzYyIsInNpZCI6IlMtMS01LTIxLTI1MDUxNTEzOTgtMjY2MTAyODEwOS0zNzU0MjY1ODIwLTExMDQifQ.XD873K6NVRTJY1700NsflLJGZKFHJfNBjB81SlADVdAHbhnq7wkAZbGEEm8wFqvTKKysUl9EALzmDa2tR9nzohVvmHftIYBO0E-wPBzdzWWX0coEgpVAc-SysP-eIQWLsj8EaodaMkCgKO0FbTWOf4GaGIBZGklrr9EEk8VRSdbXbm6Sv9WVphezEzxq6JJBRBlCVibCnZjR5OYh1Vw_7E7P38ESPbpLY3hYYl2hz4y6dQJqCwGr7YP8KrDlYtbosZYgT7ayxokEJI1udEbX5PbAq5G6mj5rLfSOl85rMg-psZiivoM8dn9lEl2P7oT8rAvMWvQp-FIRQQHwqf9cxw`}
|
||||
hc, ts, prov := setupMuxTest(response, func(j *AuthMux) 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 != DefaultCookieName {
|
||||
t.Fatal("Expected cookie to be named", DefaultCookieName, "but was", c.Name)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
gojwt "github.com/dgrijalva/jwt-go"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
|
@ -95,4 +96,6 @@ type Tokenizer interface {
|
|||
ValidPrincipal(ctx context.Context, token Token, lifespan time.Duration) (Principal, error)
|
||||
// ExtendedPrincipal adds the extention to the principal's lifespan.
|
||||
ExtendedPrincipal(ctx context.Context, principal Principal, extension time.Duration) (Principal, error)
|
||||
// GetClaims returns a map with verified claims
|
||||
GetClaims(tokenString string) (gojwt.MapClaims, error)
|
||||
}
|
||||
|
|
|
@ -5,10 +5,12 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
goauth "golang.org/x/oauth2"
|
||||
|
||||
gojwt "github.com/dgrijalva/jwt-go"
|
||||
"github.com/influxdata/chronograf"
|
||||
)
|
||||
|
||||
|
@ -45,6 +47,20 @@ func (mp *MockProvider) PrincipalID(provider *http.Client) (string, error) {
|
|||
return mp.Email, nil
|
||||
}
|
||||
|
||||
func (mp *MockProvider) PrincipalIDFromClaims(claims gojwt.MapClaims) (string, error) {
|
||||
return mp.Email, nil
|
||||
}
|
||||
|
||||
func (mp *MockProvider) GroupFromClaims(claims gojwt.MapClaims) (string, error) {
|
||||
email := strings.Split(mp.Email, "@")
|
||||
if len(email) != 2 {
|
||||
//g.Logger.Error("malformed email address, expected %q to contain @ symbol", id)
|
||||
return "DEFAULT", nil
|
||||
}
|
||||
|
||||
return email[1], nil
|
||||
}
|
||||
|
||||
func (mp *MockProvider) Group(provider *http.Client) (string, error) {
|
||||
return mp.Orgs, nil
|
||||
}
|
||||
|
@ -76,6 +92,10 @@ func (y *YesManTokenizer) ExtendedPrincipal(ctx context.Context, p Principal, ex
|
|||
return p, nil
|
||||
}
|
||||
|
||||
func (y *YesManTokenizer) GetClaims(tokenString string) (gojwt.MapClaims, error) {
|
||||
return gojwt.MapClaims{}, nil
|
||||
}
|
||||
|
||||
func NewTestTripper(log chronograf.Logger, ts *httptest.Server, rt http.RoundTripper) (*TestTripper, error) {
|
||||
url, err := url.Parse(ts.URL)
|
||||
if err != nil {
|
||||
|
|
|
@ -57,6 +57,8 @@ type Server struct {
|
|||
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"`
|
||||
ResourcesPath string `long:"resources-path" description:"Path to directory of pre-canned dashboards, sources, kapacitors, and organizations (/usr/share/chronograf/resources)" env:"RESOURCES_PATH" default:"canned"`
|
||||
TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"`
|
||||
JwksURL string `long:"jwks-url" description:"URL that returns OpenID Key Discovery JWKS document." env:"JWKS_URL"`
|
||||
UseIDToken bool `long:"use-id-token" description:"Enable id_token processing." env:"USE_ID_TOKEN"`
|
||||
AuthDuration time.Duration `long:"auth-duration" default:"720h" description:"Total duration of cookie life for authentication (in hours). 0 means authentication expires on browser close." env:"AUTH_DURATION"`
|
||||
|
||||
GithubClientID string `short:"i" long:"github-client-id" description:"Github Client ID for OAuth 2 support" env:"GH_CLIENT_ID"`
|
||||
|
@ -144,8 +146,8 @@ func (s *Server) githubOAuth(logger chronograf.Logger, auth oauth2.Authenticator
|
|||
Orgs: s.GithubOrgs,
|
||||
Logger: logger,
|
||||
}
|
||||
jwt := oauth2.NewJWT(s.TokenSecret)
|
||||
ghMux := oauth2.NewAuthMux(&gh, auth, jwt, s.Basepath, logger)
|
||||
jwt := oauth2.NewJWT(s.TokenSecret, s.JwksURL)
|
||||
ghMux := oauth2.NewAuthMux(&gh, auth, jwt, s.Basepath, logger, s.UseIDToken)
|
||||
return &gh, ghMux, s.UseGithub
|
||||
}
|
||||
|
||||
|
@ -158,8 +160,8 @@ func (s *Server) googleOAuth(logger chronograf.Logger, auth oauth2.Authenticator
|
|||
RedirectURL: redirectURL,
|
||||
Logger: logger,
|
||||
}
|
||||
jwt := oauth2.NewJWT(s.TokenSecret)
|
||||
goMux := oauth2.NewAuthMux(&google, auth, jwt, s.Basepath, logger)
|
||||
jwt := oauth2.NewJWT(s.TokenSecret, s.JwksURL)
|
||||
goMux := oauth2.NewAuthMux(&google, auth, jwt, s.Basepath, logger, s.UseIDToken)
|
||||
return &google, goMux, s.UseGoogle
|
||||
}
|
||||
|
||||
|
@ -170,8 +172,8 @@ func (s *Server) herokuOAuth(logger chronograf.Logger, auth oauth2.Authenticator
|
|||
Organizations: s.HerokuOrganizations,
|
||||
Logger: logger,
|
||||
}
|
||||
jwt := oauth2.NewJWT(s.TokenSecret)
|
||||
hMux := oauth2.NewAuthMux(&heroku, auth, jwt, s.Basepath, logger)
|
||||
jwt := oauth2.NewJWT(s.TokenSecret, s.JwksURL)
|
||||
hMux := oauth2.NewAuthMux(&heroku, auth, jwt, s.Basepath, logger, s.UseIDToken)
|
||||
return &heroku, hMux, s.UseHeroku
|
||||
}
|
||||
|
||||
|
@ -189,8 +191,8 @@ func (s *Server) genericOAuth(logger chronograf.Logger, auth oauth2.Authenticato
|
|||
APIKey: s.GenericAPIKey,
|
||||
Logger: logger,
|
||||
}
|
||||
jwt := oauth2.NewJWT(s.TokenSecret)
|
||||
genMux := oauth2.NewAuthMux(&gen, auth, jwt, s.Basepath, logger)
|
||||
jwt := oauth2.NewJWT(s.TokenSecret, s.JwksURL)
|
||||
genMux := oauth2.NewAuthMux(&gen, auth, jwt, s.Basepath, logger, s.UseIDToken)
|
||||
return &gen, genMux, s.UseGenericOAuth2
|
||||
}
|
||||
|
||||
|
@ -205,8 +207,8 @@ func (s *Server) auth0OAuth(logger chronograf.Logger, auth oauth2.Authenticator)
|
|||
|
||||
auth0, err := oauth2.NewAuth0(s.Auth0Domain, s.Auth0ClientID, s.Auth0ClientSecret, redirectURL.String(), s.Auth0Organizations, logger)
|
||||
|
||||
jwt := oauth2.NewJWT(s.TokenSecret)
|
||||
genMux := oauth2.NewAuthMux(&auth0, auth, jwt, s.Basepath, logger)
|
||||
jwt := oauth2.NewJWT(s.TokenSecret, s.JwksURL)
|
||||
genMux := oauth2.NewAuthMux(&auth0, auth, jwt, s.Basepath, logger, s.UseIDToken)
|
||||
|
||||
if err != nil {
|
||||
logger.Error("Error parsing Auth0 domain: err:", err)
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"info": {
|
||||
"title": "Chronograf",
|
||||
"description": "API endpoints for Chronograf",
|
||||
"version": "1.4.2.3"
|
||||
"version": "1.4.3.0"
|
||||
},
|
||||
"schemes": ["http"],
|
||||
"basePath": "/chronograf/v1",
|
||||
|
@ -3988,6 +3988,48 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"tableOptions": {
|
||||
"description":
|
||||
"visualization options for a cell with table type",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"timeFormat": {
|
||||
"description":
|
||||
"timeFormat describes the display format for time values according to moment.js date formatting",
|
||||
"type": "string"
|
||||
},
|
||||
"verticalTimeAxis": {
|
||||
"description":
|
||||
"verticalTimeAxis describes the orientation of the table by indicating whether the time axis will be displayed vertically",
|
||||
"type": "boolean"
|
||||
},
|
||||
"sortBy": {
|
||||
"description":
|
||||
"sortBy contains the name of the series that is used for sorting the table",
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/RenamableField"
|
||||
},
|
||||
"wrapping": {
|
||||
"description":
|
||||
"wrapping describes the text wrapping style to be used in table cells",
|
||||
"type": "string",
|
||||
"enum": ["truncate", "wrap", "single-line"]
|
||||
},
|
||||
"fieldNames": {
|
||||
"description":
|
||||
"fieldNames represent the fields retrieved by the query with customization options",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/RenamableField"
|
||||
}
|
||||
},
|
||||
"fixFirstColumn": {
|
||||
"description":
|
||||
"fixFirstColumn indicates whether this field should be visible on the table",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -4129,6 +4171,27 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"RenamableField": {
|
||||
"description":
|
||||
"renamableField describes a field that can be renamed and made visible or invisible",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"internalName": {
|
||||
"description": "internalName is the calculated name of a field",
|
||||
"type": "string"
|
||||
},
|
||||
"displayName": {
|
||||
"description":
|
||||
"displayName is the name that a field is renamed to by the user",
|
||||
"type": "string"
|
||||
},
|
||||
"visible": {
|
||||
"description":
|
||||
"visible indicates whether this field should be visible on the table",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Routes": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "chronograf-ui",
|
||||
"version": "1.4.2-3",
|
||||
"version": "1.4.3-0",
|
||||
"private": false,
|
||||
"license": "AGPL-3.0",
|
||||
"description": "",
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import _ from 'lodash'
|
||||
|
||||
import Tags from 'shared/components/Tags'
|
||||
import SlideToggle from 'shared/components/SlideToggle'
|
||||
|
@ -38,7 +37,9 @@ const AllUsersTableRow = ({
|
|||
name: organizations.find(o => r.organization === o.id).name,
|
||||
}))
|
||||
|
||||
const wrappedDelete = _.curry(onDelete, user)
|
||||
const wrappedDelete = () => {
|
||||
onDelete(user)
|
||||
}
|
||||
|
||||
const removeWarning = userIsMe
|
||||
? 'Delete your user record\nand log yourself out?'
|
||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react'
|
|||
import PropTypes from 'prop-types'
|
||||
|
||||
import Dropdown from 'shared/components/Dropdown'
|
||||
import DeleteConfirmTableCell from 'shared/components/DeleteConfirmTableCell'
|
||||
import ConfirmButton from 'shared/components/ConfirmButton'
|
||||
|
||||
import {USER_ROLES} from 'src/admin/constants/chronografAdmin'
|
||||
import {USERS_TABLE} from 'src/admin/constants/chronografTableSizing'
|
||||
|
@ -26,6 +26,12 @@ const UsersTableRow = ({
|
|||
|
||||
const userIsMe = user.id === meID
|
||||
|
||||
const wrappedDelete = () => {
|
||||
onDelete(user)
|
||||
}
|
||||
|
||||
const removeWarning = 'Remove this user\nfrom Current Org?'
|
||||
|
||||
return (
|
||||
<tr className={'chronograf-admin-table--user'}>
|
||||
<td>
|
||||
|
@ -52,12 +58,13 @@ const UsersTableRow = ({
|
|||
</td>
|
||||
<td style={{width: colProvider}}>{user.provider}</td>
|
||||
<td style={{width: colScheme}}>{user.scheme}</td>
|
||||
<DeleteConfirmTableCell
|
||||
<ConfirmButton
|
||||
confirmText={removeWarning}
|
||||
confirmAction={wrappedDelete}
|
||||
size="btn-xs"
|
||||
type="btn-danger"
|
||||
text="Remove"
|
||||
onDelete={onDelete}
|
||||
item={user}
|
||||
buttonSize="btn-xs"
|
||||
disabled={userIsMe}
|
||||
customClass="table--show-on-row-hover"
|
||||
/>
|
||||
</tr>
|
||||
)
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
FORMAT_OPTIONS,
|
||||
TIME_FORMAT_CUSTOM,
|
||||
TIME_FORMAT_DEFAULT,
|
||||
TIME_FORMAT_TOOLTIP_LINK,
|
||||
} from 'src/shared/constants/tableGraph'
|
||||
|
||||
interface TimeFormatOptions {
|
||||
|
@ -35,7 +36,7 @@ class GraphOptionsTimeFormat extends PureComponent<Props, State> {
|
|||
return this.props.onTimeFormatChange
|
||||
}
|
||||
|
||||
public handleChangeFormat = format => {
|
||||
public handleChangeFormat = (format: string) => {
|
||||
this.onTimeFormatChange(format)
|
||||
this.setState({format})
|
||||
}
|
||||
|
@ -51,8 +52,7 @@ class GraphOptionsTimeFormat extends PureComponent<Props, State> {
|
|||
|
||||
public render() {
|
||||
const {format, customFormat} = this.state
|
||||
const tipUrl = 'http://momentjs.com/docs/#/parsing/string-format/'
|
||||
const tipContent = `For information on formatting, see <br/><a href="#">${tipUrl}</a>`
|
||||
const tipContent = `For information on formatting, see <br/><a href="#">${TIME_FORMAT_TOOLTIP_LINK}</a>`
|
||||
|
||||
const formatOption = FORMAT_OPTIONS.find(op => op.text === format)
|
||||
const showCustom = !formatOption || customFormat
|
||||
|
@ -62,7 +62,7 @@ class GraphOptionsTimeFormat extends PureComponent<Props, State> {
|
|||
<label>
|
||||
Time Format
|
||||
{showCustom && (
|
||||
<a href={tipUrl} target="_blank">
|
||||
<a href={TIME_FORMAT_TOOLTIP_LINK} target="_blank">
|
||||
<QuestionMarkTooltip
|
||||
tipID="Time Axis Format"
|
||||
tipContent={tipContent}
|
||||
|
|
|
@ -53,7 +53,7 @@ export class TableOptions extends PureComponent<Props, {}> {
|
|||
return fieldNames || []
|
||||
}
|
||||
|
||||
get timeColumn() {
|
||||
get timeField() {
|
||||
return (
|
||||
this.fieldNames.find(f => f.internalName === 'time') || TIME_FIELD_DEFAULT
|
||||
)
|
||||
|
@ -63,7 +63,7 @@ export class TableOptions extends PureComponent<Props, {}> {
|
|||
const {dataLabels} = this.props
|
||||
|
||||
return _.isEmpty(dataLabels)
|
||||
? [this.timeColumn]
|
||||
? [this.timeField]
|
||||
: dataLabels.map(label => {
|
||||
const existing = this.fieldNames.find(f => f.internalName === label)
|
||||
return (
|
||||
|
|
|
@ -28,6 +28,8 @@ import {
|
|||
hideCellEditorOverlay,
|
||||
} from 'src/dashboards/actions/cellEditorOverlay'
|
||||
|
||||
import {dismissEditingAnnotation} from 'src/shared/actions/annotations'
|
||||
|
||||
import {
|
||||
setAutoRefresh,
|
||||
templateControlBarVisibilityToggled as templateControlBarVisibilityToggledAction,
|
||||
|
@ -78,10 +80,8 @@ class DashboardPage extends Component {
|
|||
timeRange,
|
||||
} = this.props
|
||||
|
||||
getAnnotationsAsync(
|
||||
source.links.annotations,
|
||||
Date.now() - timeRange.seconds * 1000
|
||||
)
|
||||
const annotationRange = this.millisecondTimeRange(timeRange)
|
||||
getAnnotationsAsync(source.links.annotations, annotationRange)
|
||||
window.addEventListener('resize', this.handleWindowResize, true)
|
||||
|
||||
const dashboards = await getDashboardsAsync()
|
||||
|
@ -107,8 +107,9 @@ class DashboardPage extends Component {
|
|||
this.setState({windowHeight: window.innerHeight})
|
||||
}
|
||||
|
||||
componentWillUnMount() {
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleWindowResize, true)
|
||||
this.props.handleDismissEditingAnnotation()
|
||||
}
|
||||
|
||||
inView = cell => {
|
||||
|
@ -159,10 +160,25 @@ class DashboardPage extends Component {
|
|||
...timeRange,
|
||||
format: FORMAT_INFLUXQL,
|
||||
})
|
||||
getAnnotationsAsync(
|
||||
source.links.annotations,
|
||||
Date.now() - timeRange.seconds * 1000
|
||||
)
|
||||
|
||||
const annotationRange = this.millisecondTimeRange(timeRange)
|
||||
getAnnotationsAsync(source.links.annotations, annotationRange)
|
||||
}
|
||||
|
||||
millisecondTimeRange({seconds, lower, upper}) {
|
||||
// Is this a relative time range?
|
||||
if (seconds) {
|
||||
return {
|
||||
since: Date.now() - seconds * 1000,
|
||||
until: null,
|
||||
}
|
||||
}
|
||||
|
||||
// No, this is an absolute (custom) time range
|
||||
return {
|
||||
since: Date.parse(lower),
|
||||
until: Date.parse(upper),
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdatePosition = cells => {
|
||||
|
@ -513,6 +529,7 @@ DashboardPage.propTypes = {
|
|||
getAnnotationsAsync: func.isRequired,
|
||||
handleShowCellEditorOverlay: func.isRequired,
|
||||
handleHideCellEditorOverlay: func.isRequired,
|
||||
handleDismissEditingAnnotation: func.isRequired,
|
||||
selectedCell: shape({}),
|
||||
thresholdsListType: string.isRequired,
|
||||
thresholdsListColors: arrayOf(shape({}).isRequired).isRequired,
|
||||
|
@ -592,6 +609,10 @@ const mapDispatchToProps = dispatch => ({
|
|||
hideCellEditorOverlay,
|
||||
dispatch
|
||||
),
|
||||
handleDismissEditingAnnotation: bindActionCreators(
|
||||
dismissEditingAnnotation,
|
||||
dispatch
|
||||
),
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(
|
||||
|
|
|
@ -63,8 +63,11 @@ export const addAnnotationAsync = (createUrl, annotation) => async dispatch => {
|
|||
dispatch(deleteAnnotation(annotation))
|
||||
}
|
||||
|
||||
export const getAnnotationsAsync = (indexUrl, since) => async dispatch => {
|
||||
const annotations = await api.getAnnotations(indexUrl, since)
|
||||
export const getAnnotationsAsync = (
|
||||
indexUrl,
|
||||
{since, until}
|
||||
) => async dispatch => {
|
||||
const annotations = await api.getAnnotations(indexUrl, since, until)
|
||||
dispatch(loadAnnotations(annotations))
|
||||
}
|
||||
|
||||
|
|
|
@ -19,11 +19,11 @@ export const createAnnotation = async (url, annotation) => {
|
|||
return annoToMillisecond(response.data)
|
||||
}
|
||||
|
||||
export const getAnnotations = async (url, since) => {
|
||||
export const getAnnotations = async (url, since, until) => {
|
||||
const {data} = await AJAX({
|
||||
method: 'GET',
|
||||
url,
|
||||
params: {since: msToRFC(since)},
|
||||
params: {since: msToRFC(since), until: msToRFC(until)},
|
||||
})
|
||||
return data.annotations.map(annoToMillisecond)
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import * as actions from 'shared/actions/annotations'
|
|||
|
||||
const TimeStamp = ({time}) => (
|
||||
<div className="annotation-tooltip--timestamp">
|
||||
{`${moment(+time).format('YYYY/DD/MM HH:mm:ss.SS')}`}
|
||||
{`${moment(+time).format('YYYY/MM/DD HH:mm:ss.SS')}`}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
|
|
@ -1,153 +0,0 @@
|
|||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {Link} from 'react-router'
|
||||
|
||||
import classnames from 'classnames'
|
||||
import {DROPDOWN_MENU_MAX_HEIGHT} from 'shared/constants/index'
|
||||
import FancyScrollbar from 'shared/components/FancyScrollbar'
|
||||
|
||||
// AddNewResource is an optional parameter that takes the user to another
|
||||
// route defined by url prop
|
||||
const AddNewButton = ({url, text}) => (
|
||||
<li className="multi-select--apply">
|
||||
<Link className="btn btn-xs btn-default" to={url}>
|
||||
{text}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
|
||||
const DropdownMenu = ({
|
||||
items,
|
||||
addNew,
|
||||
actions,
|
||||
selected,
|
||||
onAction,
|
||||
menuClass,
|
||||
menuWidth,
|
||||
menuLabel,
|
||||
onSelection,
|
||||
onHighlight,
|
||||
useAutoComplete,
|
||||
highlightedItemIndex,
|
||||
}) => {
|
||||
return (
|
||||
<ul
|
||||
className={classnames('dropdown-menu', {
|
||||
'dropdown-menu--no-highlight': useAutoComplete,
|
||||
[menuClass]: menuClass,
|
||||
})}
|
||||
style={{width: menuWidth}}
|
||||
data-test="dropdown-ul"
|
||||
>
|
||||
<FancyScrollbar
|
||||
autoHide={false}
|
||||
autoHeight={true}
|
||||
maxHeight={DROPDOWN_MENU_MAX_HEIGHT}
|
||||
>
|
||||
{menuLabel ? <li className="dropdown-header">{menuLabel}</li> : null}
|
||||
{items.map((item, i) => {
|
||||
if (item.text === 'SEPARATOR') {
|
||||
return <li key={i} className="dropdown-divider" />
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classnames('dropdown-item', {
|
||||
highlight: i === highlightedItemIndex,
|
||||
active: item.text === selected,
|
||||
})}
|
||||
data-test="dropdown-item"
|
||||
key={i}
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
onClick={onSelection(item)}
|
||||
onMouseOver={onHighlight(i)}
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
{actions && actions.length ? (
|
||||
<div className="dropdown-actions">
|
||||
{actions.map(action => {
|
||||
return (
|
||||
<button
|
||||
key={action.text}
|
||||
className="dropdown-action"
|
||||
onClick={onAction(action, item)}
|
||||
>
|
||||
<span
|
||||
title={action.text}
|
||||
className={`icon ${action.icon}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{addNew && <AddNewButton url={addNew.url} text={addNew.text} />}
|
||||
</FancyScrollbar>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export const DropdownMenuEmpty = ({useAutoComplete, menuClass}) => (
|
||||
<ul
|
||||
className={classnames('dropdown-menu', {
|
||||
'dropdown-menu--no-highlight': useAutoComplete,
|
||||
[menuClass]: menuClass,
|
||||
})}
|
||||
>
|
||||
<li className="dropdown-empty">No matching items</li>
|
||||
</ul>
|
||||
)
|
||||
|
||||
const {arrayOf, bool, number, shape, string, func} = PropTypes
|
||||
|
||||
AddNewButton.propTypes = {
|
||||
url: string,
|
||||
text: string,
|
||||
}
|
||||
|
||||
DropdownMenuEmpty.propTypes = {
|
||||
useAutoComplete: bool,
|
||||
menuClass: string,
|
||||
}
|
||||
|
||||
DropdownMenu.propTypes = {
|
||||
onAction: func,
|
||||
actions: arrayOf(
|
||||
shape({
|
||||
icon: string.isRequired,
|
||||
text: string.isRequired,
|
||||
handler: func.isRequired,
|
||||
})
|
||||
),
|
||||
items: arrayOf(
|
||||
shape({
|
||||
text: string.isRequired,
|
||||
})
|
||||
).isRequired,
|
||||
onClick: func,
|
||||
addNew: shape({
|
||||
url: string.isRequired,
|
||||
text: string.isRequired,
|
||||
}),
|
||||
selected: string.isRequired,
|
||||
iconName: string,
|
||||
className: string,
|
||||
buttonColor: string,
|
||||
menuWidth: string,
|
||||
menuLabel: string,
|
||||
menuClass: string,
|
||||
useAutoComplete: bool,
|
||||
disabled: bool,
|
||||
searchTerm: string,
|
||||
onSelection: func,
|
||||
onHighlight: func,
|
||||
highlightedItemIndex: number,
|
||||
}
|
||||
|
||||
export default DropdownMenu
|
|
@ -0,0 +1,122 @@
|
|||
import React, {SFC} from 'react'
|
||||
import {Link} from 'react-router'
|
||||
|
||||
import classnames from 'classnames'
|
||||
import {DROPDOWN_MENU_MAX_HEIGHT} from 'src/shared/constants/index'
|
||||
import FancyScrollbar from 'src/shared/components/FancyScrollbar'
|
||||
import DropdownMenuItem from 'src/shared/components/DropdownMenuItem'
|
||||
import {
|
||||
OnActionHandler,
|
||||
OnSelectionHandler,
|
||||
OnHighlightHandler,
|
||||
} from 'src/shared/components/DropdownMenuItem'
|
||||
|
||||
import {DropdownItem, DropdownAction} from 'src/types'
|
||||
|
||||
// AddNewResource is an optional parameter that takes the user to another
|
||||
// route defined by url prop
|
||||
interface AddNewButtonProps {
|
||||
url: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const AddNewButton: SFC<AddNewButtonProps> = ({url, text}) => (
|
||||
<li className="multi-select--apply">
|
||||
<Link className="btn btn-xs btn-default" to={url}>
|
||||
{text}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
|
||||
interface AddNew {
|
||||
url: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onAction?: OnActionHandler
|
||||
actions: DropdownAction[]
|
||||
items: DropdownItem[]
|
||||
selected: string
|
||||
addNew?: AddNew
|
||||
iconName?: string
|
||||
buttonColor?: string
|
||||
menuWidth?: string
|
||||
menuLabel?: string
|
||||
menuClass?: string
|
||||
useAutoComplete?: boolean
|
||||
disabled?: boolean
|
||||
searchTerm?: string
|
||||
onSelection?: OnSelectionHandler
|
||||
onHighlight?: OnHighlightHandler
|
||||
highlightedItemIndex?: number
|
||||
}
|
||||
|
||||
const DropdownMenu: SFC<Props> = ({
|
||||
items,
|
||||
addNew,
|
||||
actions,
|
||||
selected,
|
||||
onAction,
|
||||
menuClass,
|
||||
menuWidth,
|
||||
menuLabel,
|
||||
onSelection,
|
||||
onHighlight,
|
||||
useAutoComplete,
|
||||
highlightedItemIndex,
|
||||
}) => {
|
||||
return (
|
||||
<ul
|
||||
className={classnames('dropdown-menu', {
|
||||
'dropdown-menu--no-highlight': useAutoComplete,
|
||||
[menuClass]: menuClass,
|
||||
})}
|
||||
style={{width: menuWidth}}
|
||||
data-test="dropdown-ul"
|
||||
>
|
||||
<FancyScrollbar
|
||||
autoHide={false}
|
||||
autoHeight={true}
|
||||
maxHeight={DROPDOWN_MENU_MAX_HEIGHT}
|
||||
>
|
||||
{menuLabel ? <li className="dropdown-header">{menuLabel}</li> : null}
|
||||
{items.map((item, i) => (
|
||||
<DropdownMenuItem
|
||||
item={item}
|
||||
actions={actions}
|
||||
onAction={onAction}
|
||||
highlightedItemIndex={highlightedItemIndex}
|
||||
onHighlight={onHighlight}
|
||||
selected={selected}
|
||||
onSelection={onSelection}
|
||||
index={i}
|
||||
key={i}
|
||||
/>
|
||||
))}
|
||||
{addNew && <AddNewButton url={addNew.url} text={addNew.text} />}
|
||||
</FancyScrollbar>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
interface DropdownMenuEmptyProps {
|
||||
useAutoComplete?: boolean
|
||||
menuClass: string
|
||||
}
|
||||
|
||||
export const DropdownMenuEmpty: SFC<DropdownMenuEmptyProps> = ({
|
||||
useAutoComplete,
|
||||
menuClass,
|
||||
}) => (
|
||||
<ul
|
||||
className={classnames('dropdown-menu', {
|
||||
'dropdown-menu--no-highlight': useAutoComplete,
|
||||
[menuClass]: menuClass,
|
||||
})}
|
||||
>
|
||||
<li className="dropdown-empty">No matching items</li>
|
||||
</ul>
|
||||
)
|
||||
|
||||
export default DropdownMenu
|
|
@ -0,0 +1,80 @@
|
|||
import React, {SFC, MouseEvent} from 'react'
|
||||
|
||||
import _ from 'lodash'
|
||||
import classnames from 'classnames'
|
||||
import {DropdownAction, DropdownItem} from 'src/types'
|
||||
|
||||
export type OnSelectionHandler = (
|
||||
item: DropdownItem
|
||||
) => (e: MouseEvent<HTMLAnchorElement>) => void
|
||||
|
||||
export type OnHighlightHandler = (
|
||||
key: number
|
||||
) => (e: MouseEvent<HTMLAnchorElement>) => void
|
||||
|
||||
export type OnActionHandler = (
|
||||
action: DropdownAction,
|
||||
item: DropdownItem
|
||||
) => (e: MouseEvent<HTMLElement>) => void
|
||||
|
||||
interface ItemProps {
|
||||
index: number
|
||||
selected: string
|
||||
item: DropdownItem
|
||||
highlightedItemIndex?: number
|
||||
onSelection?: OnSelectionHandler
|
||||
onHighlight?: OnHighlightHandler
|
||||
actions?: DropdownAction[]
|
||||
onAction?: OnActionHandler
|
||||
}
|
||||
|
||||
const DropdownMenuItem: SFC<ItemProps> = ({
|
||||
item,
|
||||
highlightedItemIndex,
|
||||
onSelection,
|
||||
onHighlight,
|
||||
actions,
|
||||
onAction,
|
||||
selected,
|
||||
index,
|
||||
}) => {
|
||||
if (_.isString(item)) {
|
||||
item = {text: item}
|
||||
}
|
||||
|
||||
if (item.text === 'SEPARATOR') {
|
||||
return <li className="dropdown-divider" />
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classnames('dropdown-item', {
|
||||
highlight: index === highlightedItemIndex,
|
||||
active: item.text === selected,
|
||||
})}
|
||||
data-test="dropdown-item"
|
||||
>
|
||||
<a href="#" onClick={onSelection(item)} onMouseOver={onHighlight(index)}>
|
||||
{item.text}
|
||||
</a>
|
||||
{actions &&
|
||||
!!actions.length && (
|
||||
<div className="dropdown-actions">
|
||||
{actions.map(action => {
|
||||
return (
|
||||
<button
|
||||
key={action.text}
|
||||
className="dropdown-action"
|
||||
onClick={onAction(action, item)}
|
||||
>
|
||||
<span title={action.text} className={`icon ${action.icon}`} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default DropdownMenuItem
|
|
@ -0,0 +1,15 @@
|
|||
import {SFC} from 'react'
|
||||
|
||||
interface Props {
|
||||
children?: any
|
||||
}
|
||||
|
||||
const FeatureFlag: SFC<Props> = props => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return props.children
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default FeatureFlag
|
|
@ -96,16 +96,17 @@ class LayoutCellMenu extends Component {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{mode === 'editing' && (
|
||||
<div className="dash-graph-context--buttons">
|
||||
<div
|
||||
className="btn btn-xs btn-success"
|
||||
onClick={onDismissEditingAnnotation}
|
||||
>
|
||||
Done Editing
|
||||
{mode === 'editing' &&
|
||||
cellSupportsAnnotations(cell.type) && (
|
||||
<div className="dash-graph-context--buttons">
|
||||
<div
|
||||
className="btn btn-xs btn-success"
|
||||
onClick={onDismissEditingAnnotation}
|
||||
>
|
||||
Done Editing
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -74,6 +74,7 @@ class LineGraph extends Component {
|
|||
rightGap: 0,
|
||||
yRangePad: 10,
|
||||
labelsKMB: true,
|
||||
fillGraph: true,
|
||||
underlayCallback,
|
||||
axisLabelWidth: 60,
|
||||
drawAxesAtZero: true,
|
||||
|
|
|
@ -17,12 +17,13 @@ import {
|
|||
FIX_FIRST_COLUMN_DEFAULT,
|
||||
VERTICAL_TIME_AXIS_DEFAULT,
|
||||
calculateTimeColumnWidth,
|
||||
calculateLabelsColumnWidth,
|
||||
} from 'src/shared/constants/tableGraph'
|
||||
const DEFAULT_SORT = ASCENDING
|
||||
export const DEFAULT_SORT = ASCENDING
|
||||
|
||||
import {generateThresholdsListHexs} from 'shared/constants/colorOperations'
|
||||
|
||||
const filterInvisibleColumns = (data, fieldNames) => {
|
||||
export const filterInvisibleColumns = (data, fieldNames) => {
|
||||
const visibility = {}
|
||||
const filteredData = data.map((row, i) => {
|
||||
return row.filter((col, j) => {
|
||||
|
@ -36,7 +37,7 @@ const filterInvisibleColumns = (data, fieldNames) => {
|
|||
return filteredData[0].length ? filteredData : [[]]
|
||||
}
|
||||
|
||||
const processData = (
|
||||
export const processData = (
|
||||
data,
|
||||
sortFieldName,
|
||||
direction,
|
||||
|
@ -48,10 +49,11 @@ const processData = (
|
|||
data[0],
|
||||
..._.orderBy(_.drop(data, 1), sortIndex, [direction]),
|
||||
]
|
||||
const sortedTimeVals = sortedData.map(r => r[0])
|
||||
const filteredData = filterInvisibleColumns(sortedData, fieldNames)
|
||||
const processedData = verticalTimeAxis ? filteredData : _.unzip(filteredData)
|
||||
|
||||
return {processedData}
|
||||
return {processedData, sortedTimeVals}
|
||||
}
|
||||
|
||||
class TableGraph extends Component {
|
||||
|
@ -59,11 +61,14 @@ class TableGraph extends Component {
|
|||
super(props)
|
||||
this.state = {
|
||||
data: [[]],
|
||||
timeColumnWidth: calculateTimeColumnWidth(props.tableOptions.timeFormat),
|
||||
processedData: [[]],
|
||||
sortedTimeVals: [],
|
||||
labels: [],
|
||||
timeColumnWidth: calculateTimeColumnWidth(props.tableOptions.timeFormat),
|
||||
labelsColumnWidth: calculateLabelsColumnWidth(props.data.labels),
|
||||
hoveredColumnIndex: NULL_ARRAY_INDEX,
|
||||
hoveredRowIndex: NULL_ARRAY_INDEX,
|
||||
sortField: '',
|
||||
sortField: 'time',
|
||||
sortDirection: DEFAULT_SORT,
|
||||
}
|
||||
}
|
||||
|
@ -89,6 +94,7 @@ class TableGraph extends Component {
|
|||
this.setState({
|
||||
timeColumnWidth: calculateTimeColumnWidth(timeFormat),
|
||||
})
|
||||
this.multiGridRef.forceUpdateGrids()
|
||||
}
|
||||
|
||||
if (setDataLabels) {
|
||||
|
@ -108,7 +114,7 @@ class TableGraph extends Component {
|
|||
sortFieldName = sortField
|
||||
}
|
||||
|
||||
const {processedData} = processData(
|
||||
const {processedData, sortedTimeVals} = processData(
|
||||
data,
|
||||
sortFieldName,
|
||||
direction,
|
||||
|
@ -116,36 +122,61 @@ class TableGraph extends Component {
|
|||
fieldNames
|
||||
)
|
||||
|
||||
const processedLabels = verticalTimeAxis
|
||||
? processedData[0]
|
||||
: processedData.map(row => row[0])
|
||||
|
||||
this.setState({
|
||||
data,
|
||||
labels,
|
||||
processedData,
|
||||
sortedTimeVals,
|
||||
sortField: sortFieldName,
|
||||
sortDirection: direction,
|
||||
labelsColumnWidth: calculateLabelsColumnWidth(
|
||||
processedLabels,
|
||||
fieldNames
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
calcHoverTimeIndex = (data, hoverTime, verticalTimeAxis) => {
|
||||
if (_.isEmpty(data) || hoverTime === NULL_HOVER_TIME) {
|
||||
return undefined
|
||||
calcScrollToColRow = () => {
|
||||
const {data, sortedTimeVals, hoveredColumnIndex} = this.state
|
||||
const {hoverTime, tableOptions} = this.props
|
||||
const hoveringThisTable = hoveredColumnIndex !== NULL_ARRAY_INDEX
|
||||
const notHovering = hoverTime === NULL_HOVER_TIME
|
||||
if (_.isEmpty(data[0]) || notHovering || hoveringThisTable) {
|
||||
return {scrollToColumn: undefined, scrollToRow: undefined}
|
||||
}
|
||||
if (verticalTimeAxis) {
|
||||
return data.findIndex(
|
||||
row => row[0] && _.isNumber(row[0]) && row[0] >= hoverTime
|
||||
)
|
||||
}
|
||||
return data[0].findIndex(d => _.isNumber(d) && d >= hoverTime)
|
||||
|
||||
const firstDiff = Math.abs(hoverTime - sortedTimeVals[1]) // sortedTimeVals[0] is "time"
|
||||
const hoverTimeFound = sortedTimeVals.reduce(
|
||||
(acc, currentTime, index) => {
|
||||
const thisDiff = Math.abs(hoverTime - currentTime)
|
||||
if (thisDiff < acc.diff) {
|
||||
return {index, diff: thisDiff}
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{index: 1, diff: firstDiff}
|
||||
)
|
||||
|
||||
const {verticalTimeAxis} = tableOptions
|
||||
const scrollToColumn = verticalTimeAxis ? undefined : hoverTimeFound.index
|
||||
const scrollToRow = verticalTimeAxis ? hoverTimeFound.index : undefined
|
||||
return {scrollToRow, scrollToColumn}
|
||||
}
|
||||
|
||||
handleHover = (columnIndex, rowIndex) => () => {
|
||||
const {onSetHoverTime, tableOptions: {verticalTimeAxis}} = this.props
|
||||
const {data} = this.state
|
||||
if (rowIndex === 0 && verticalTimeAxis) {
|
||||
const {sortedTimeVals} = this.state
|
||||
if (verticalTimeAxis && rowIndex === 0) {
|
||||
return
|
||||
}
|
||||
if (onSetHoverTime) {
|
||||
const hoverTime = verticalTimeAxis
|
||||
? data[rowIndex][0]
|
||||
: data[columnIndex][0]
|
||||
? sortedTimeVals[rowIndex]
|
||||
: sortedTimeVals[columnIndex]
|
||||
onSetHoverTime(hoverTime.toString())
|
||||
}
|
||||
this.setState({
|
||||
|
@ -154,7 +185,7 @@ class TableGraph extends Component {
|
|||
})
|
||||
}
|
||||
|
||||
handleMouseOut = () => {
|
||||
handleMouseLeave = () => {
|
||||
if (this.props.onSetHoverTime) {
|
||||
this.props.onSetHoverTime(NULL_HOVER_TIME)
|
||||
this.setState({
|
||||
|
@ -177,7 +208,7 @@ class TableGraph extends Component {
|
|||
direction = DEFAULT_SORT
|
||||
}
|
||||
|
||||
const {processedData} = processData(
|
||||
const {processedData, sortedTimeVals} = processData(
|
||||
data,
|
||||
fieldName,
|
||||
direction,
|
||||
|
@ -187,6 +218,7 @@ class TableGraph extends Component {
|
|||
|
||||
this.setState({
|
||||
processedData,
|
||||
sortedTimeVals,
|
||||
sortField: fieldName,
|
||||
sortDirection: direction,
|
||||
})
|
||||
|
@ -195,19 +227,28 @@ class TableGraph extends Component {
|
|||
calculateColumnWidth = columnSizerWidth => column => {
|
||||
const {index} = column
|
||||
const {tableOptions: {verticalTimeAxis}} = this.props
|
||||
const {timeColumnWidth, processedData} = this.state
|
||||
const {timeColumnWidth, labelsColumnWidth, processedData} = this.state
|
||||
const columnCount = _.get(processedData, ['0', 'length'], 0)
|
||||
const processedLabels = verticalTimeAxis
|
||||
? processedData[0]
|
||||
: processedData.map(row => row[0])
|
||||
|
||||
const labels = verticalTimeAxis
|
||||
? _.unzip(processedData)[0]
|
||||
: processedData[0]
|
||||
const specialColumnWidth = verticalTimeAxis
|
||||
? timeColumnWidth
|
||||
: labelsColumnWidth
|
||||
|
||||
if (labels.length > 0) {
|
||||
return verticalTimeAxis && labels[index] === 'time'
|
||||
? timeColumnWidth
|
||||
: columnSizerWidth
|
||||
let adjustedColumnSizerWidth = columnSizerWidth
|
||||
|
||||
if (columnSizerWidth !== specialColumnWidth) {
|
||||
const difference = columnSizerWidth - specialColumnWidth
|
||||
const increment = difference / (columnCount - 1)
|
||||
|
||||
adjustedColumnSizerWidth = columnSizerWidth + increment
|
||||
}
|
||||
|
||||
return columnSizerWidth
|
||||
return processedLabels[index] === 'time'
|
||||
? specialColumnWidth
|
||||
: adjustedColumnSizerWidth
|
||||
}
|
||||
|
||||
cellRenderer = ({columnIndex, rowIndex, key, parent, style}) => {
|
||||
|
@ -302,25 +343,34 @@ class TableGraph extends Component {
|
|||
)
|
||||
}
|
||||
|
||||
getMultiGridRef = (r, registerChild) => {
|
||||
this.multiGridRef = r
|
||||
return registerChild(r)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
hoveredColumnIndex,
|
||||
hoveredRowIndex,
|
||||
timeColumnWidth,
|
||||
labelsColumnWidth,
|
||||
sortField,
|
||||
sortDirection,
|
||||
processedData,
|
||||
data,
|
||||
} = this.state
|
||||
const {hoverTime, tableOptions, colors} = this.props
|
||||
const {
|
||||
verticalTimeAxis = VERTICAL_TIME_AXIS_DEFAULT,
|
||||
fixFirstColumn = FIX_FIRST_COLUMN_DEFAULT,
|
||||
} = tableOptions
|
||||
|
||||
hoverTime,
|
||||
tableOptions,
|
||||
tableOptions: {verticalTimeAxis},
|
||||
colors,
|
||||
} = this.props
|
||||
const {fixFirstColumn = FIX_FIRST_COLUMN_DEFAULT} = tableOptions
|
||||
const columnCount = _.get(processedData, ['0', 'length'], 0)
|
||||
const rowCount = columnCount === 0 ? 0 : processedData.length
|
||||
|
||||
const COLUMN_MIN_WIDTH = 98
|
||||
const COLUMN_MIN_WIDTH = verticalTimeAxis
|
||||
? labelsColumnWidth
|
||||
: timeColumnWidth
|
||||
const COLUMN_MAX_WIDTH = 1000
|
||||
const ROW_HEIGHT = 30
|
||||
|
||||
|
@ -328,23 +378,12 @@ class TableGraph extends Component {
|
|||
|
||||
const tableWidth = _.get(this, ['gridContainer', 'clientWidth'], 0)
|
||||
const tableHeight = _.get(this, ['gridContainer', 'clientHeight'], 0)
|
||||
|
||||
const hoverTimeIndex =
|
||||
hoveredRowIndex === NULL_ARRAY_INDEX
|
||||
? this.calcHoverTimeIndex(data, hoverTime, verticalTimeAxis)
|
||||
: hoveredRowIndex
|
||||
const hoveringThisTable = hoveredColumnIndex !== NULL_ARRAY_INDEX
|
||||
|
||||
const scrollToColumn =
|
||||
!hoveringThisTable && !verticalTimeAxis ? hoverTimeIndex : undefined
|
||||
const scrollToRow =
|
||||
!hoveringThisTable && verticalTimeAxis ? hoverTimeIndex : undefined
|
||||
|
||||
const {scrollToColumn, scrollToRow} = this.calcScrollToColRow()
|
||||
return (
|
||||
<div
|
||||
className="table-graph-container"
|
||||
ref={gridContainer => (this.gridContainer = gridContainer)}
|
||||
onMouseOut={this.handleMouseOut}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
>
|
||||
{rowCount > 0 && (
|
||||
<ColumnSizer
|
||||
|
@ -352,12 +391,13 @@ class TableGraph extends Component {
|
|||
columnMaxWidth={COLUMN_MAX_WIDTH}
|
||||
columnMinWidth={COLUMN_MIN_WIDTH}
|
||||
width={tableWidth}
|
||||
timeColumnWidth={timeColumnWidth}
|
||||
>
|
||||
{({getColumnWidth, registerChild}) => (
|
||||
{({columnWidth, registerChild}) => (
|
||||
<MultiGrid
|
||||
ref={registerChild}
|
||||
ref={r => this.getMultiGridRef(r, registerChild)}
|
||||
columnCount={columnCount}
|
||||
columnWidth={this.calculateColumnWidth(getColumnWidth())}
|
||||
columnWidth={this.calculateColumnWidth(columnWidth)}
|
||||
rowCount={rowCount}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
height={tableHeight}
|
||||
|
@ -376,6 +416,7 @@ class TableGraph extends Component {
|
|||
hoverTime={hoverTime}
|
||||
colors={colors}
|
||||
tableOptions={tableOptions}
|
||||
timeColumnWidth={timeColumnWidth}
|
||||
classNameBottomRightGrid="table-graph--scroll-window"
|
||||
/>
|
||||
)}
|
||||
|
@ -386,10 +427,9 @@ class TableGraph extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
const {arrayOf, bool, number, shape, string, func} = PropTypes
|
||||
const {arrayOf, bool, shape, string, func} = PropTypes
|
||||
|
||||
TableGraph.propTypes = {
|
||||
cellHeight: number,
|
||||
data: arrayOf(shape()),
|
||||
tableOptions: shape({
|
||||
timeFormat: string.isRequired,
|
||||
|
|
|
@ -5,9 +5,12 @@ export const NULL_ARRAY_INDEX = -1
|
|||
|
||||
export const NULL_HOVER_TIME = '0'
|
||||
|
||||
export const TIME_FORMAT_DEFAULT = 'MM/DD/YYYY HH:mm:ss.ss'
|
||||
export const TIME_FORMAT_DEFAULT = 'MM/DD/YYYY HH:mm:ss.SS'
|
||||
export const TIME_FORMAT_CUSTOM = 'Custom'
|
||||
|
||||
export const TIME_FORMAT_TOOLTIP_LINK =
|
||||
'http://momentjs.com/docs/#/parsing/string-format/'
|
||||
|
||||
export const TIME_FIELD_DEFAULT = {
|
||||
internalName: 'time',
|
||||
displayName: '',
|
||||
|
@ -57,3 +60,35 @@ export const calculateTimeColumnWidth = timeFormat => {
|
|||
|
||||
return width + CELL_HORIZONTAL_PADDING
|
||||
}
|
||||
|
||||
export const calculateLabelsColumnWidth = (labels, fieldNames) => {
|
||||
if (!labels) {
|
||||
return
|
||||
}
|
||||
if (fieldNames.length === 1) {
|
||||
const longestLabel = labels.reduce((a, b) => (a.length > b.length ? a : b))
|
||||
const {width} = calculateSize(longestLabel, {
|
||||
font: '"RobotoMono", monospace',
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
})
|
||||
|
||||
return width + CELL_HORIZONTAL_PADDING
|
||||
}
|
||||
|
||||
const longestFieldName = fieldNames
|
||||
.map(fieldName => {
|
||||
return fieldName.displayName
|
||||
? fieldName.displayName
|
||||
: fieldName.internalName
|
||||
})
|
||||
.reduce((a, b) => (a.length > b.length ? a : b))
|
||||
|
||||
const {width} = calculateSize(longestFieldName, {
|
||||
font: '"RobotoMono", monospace',
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
})
|
||||
|
||||
return width + CELL_HORIZONTAL_PADDING
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import {AuthLinks, Organization, Role, User} from './auth'
|
|||
import {AlertRule, Kapacitor} from './kapacitor'
|
||||
import {Query, QueryConfig} from './query'
|
||||
import {Source} from './sources'
|
||||
import {DropdownAction, DropdownItem} from './shared'
|
||||
|
||||
export {
|
||||
AuthLinks,
|
||||
|
@ -13,4 +14,6 @@ export {
|
|||
Query,
|
||||
QueryConfig,
|
||||
Source,
|
||||
DropdownAction,
|
||||
DropdownItem,
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
export type DropdownItem =
|
||||
| {
|
||||
text: string
|
||||
}
|
||||
| string
|
||||
|
||||
export interface DropdownAction {
|
||||
icon: string
|
||||
text: string
|
||||
handler: () => void
|
||||
}
|
|
@ -45,6 +45,23 @@ describe('Dashboards.Components.GraphOptionsCustomizableField', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('when there is an displayName', () => {
|
||||
it('display name displayed by input click to edit', () => {
|
||||
const internalName = 'test'
|
||||
const displayName = 'TESTING'
|
||||
const {wrapper} = setup({internalName, displayName})
|
||||
const label = wrapper.find('div').last()
|
||||
const icon = wrapper.find('span')
|
||||
const input = wrapper.find(InputClickToEdit)
|
||||
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(label.children().contains(internalName)).toBe(true)
|
||||
expect(icon.exists()).toBe(true)
|
||||
expect(input.exists()).toBe(true)
|
||||
expect(input.prop('value')).toBe(displayName)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when visible is false', () => {
|
||||
it('displays disabled inputClickToEdit', () => {
|
||||
const visible = false
|
||||
|
|
|
@ -24,12 +24,12 @@ describe('Dashboards.Components.GraphOptionsCustomizableField', () => {
|
|||
const fields = [TIME_FIELD_DEFAULT]
|
||||
const {wrapper} = setup({fields})
|
||||
const label = wrapper.find('label')
|
||||
const CustomizableFields = wrapper.find(GraphOptionsCustomizableField)
|
||||
const customizableFields = wrapper.find(GraphOptionsCustomizableField)
|
||||
const Scrollbox = wrapper.find(FancyScrollbar)
|
||||
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(CustomizableFields.exists()).toBe(true)
|
||||
expect(CustomizableFields.length).toBe(fields.length)
|
||||
expect(customizableFields.exists()).toBe(true)
|
||||
expect(customizableFields.length).toBe(fields.length)
|
||||
expect(Scrollbox.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -26,5 +26,23 @@ describe('Dashboards.Components.GraphOptionsFixFirstColumn', () => {
|
|||
expect(checkbox.exists()).toBe(true)
|
||||
expect(checkbox.prop('type')).toBe('checkbox')
|
||||
})
|
||||
|
||||
describe('if fixed is true', () => {
|
||||
it('input is checked', () => {
|
||||
const {wrapper} = setup()
|
||||
const checkbox = wrapper.find('input')
|
||||
|
||||
expect(checkbox.prop('checked')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('if fixed is false', () => {
|
||||
it('input is not checked', () => {
|
||||
const {wrapper} = setup({fixed: false})
|
||||
const checkbox = wrapper.find('input')
|
||||
|
||||
expect(checkbox.prop('checked')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -8,8 +8,9 @@ import {Dropdown} from 'src/shared/components/Dropdown'
|
|||
const defaultProps = {
|
||||
onChooseSortBy: () => {},
|
||||
selected: {
|
||||
displayName: 'here',
|
||||
internalName: 'boom',
|
||||
displayName: '',
|
||||
internalName: '',
|
||||
visible: true,
|
||||
},
|
||||
sortByOptions: [],
|
||||
}
|
||||
|
@ -24,23 +25,35 @@ const setup = (override = {}) => {
|
|||
describe('Dashboards.Components.GraphOptionsSortBy', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders component', () => {
|
||||
const {wrapper} = setup()
|
||||
const selected = {
|
||||
displayName: 'here',
|
||||
internalName: 'boom',
|
||||
visible: true,
|
||||
}
|
||||
const {wrapper} = setup({
|
||||
selected,
|
||||
})
|
||||
|
||||
const dropdown = wrapper.find(Dropdown)
|
||||
const label = wrapper.find('label')
|
||||
|
||||
expect(dropdown.props().selected).toEqual('here')
|
||||
expect(dropdown.props().selected).toEqual(selected.displayName)
|
||||
expect(dropdown.exists()).toBe(true)
|
||||
expect(label.exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('when selected display name is not available', () => {
|
||||
it('render internal name as selected', () => {
|
||||
const {wrapper} = setup({selected: {internalName: 'boom'}})
|
||||
const selected = {
|
||||
displayName: '',
|
||||
internalName: 'boom',
|
||||
visible: true,
|
||||
}
|
||||
const {wrapper} = setup({selected})
|
||||
|
||||
const dropdown = wrapper.find(Dropdown)
|
||||
|
||||
expect(dropdown.props().selected).toEqual('boom')
|
||||
expect(dropdown.props().selected).toEqual(selected.internalName)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -6,7 +6,10 @@ import GraphOptionsTimeFormat from 'src/dashboards/components/GraphOptionsTimeFo
|
|||
import {Dropdown} from 'src/shared/components/Dropdown'
|
||||
import InputClickToEdit from 'src/shared/components/InputClickToEdit'
|
||||
import QuestionMarkTooltip from 'src/shared/components/QuestionMarkTooltip'
|
||||
import {TIME_FORMAT_CUSTOM} from 'src/shared/constants/tableGraph'
|
||||
import {
|
||||
TIME_FORMAT_CUSTOM,
|
||||
TIME_FORMAT_TOOLTIP_LINK,
|
||||
} from 'src/shared/constants/tableGraph'
|
||||
|
||||
const setup = (override = {}) => {
|
||||
const props = {
|
||||
|
@ -36,8 +39,14 @@ describe('Dashboards.Components.GraphOptionsTimeFormat', () => {
|
|||
|
||||
wrapper.setState({customFormat: true})
|
||||
|
||||
const label = wrapper.find('label')
|
||||
const link = label.find('a')
|
||||
|
||||
expect(wrapper.find(Dropdown).exists()).toBe(true)
|
||||
expect(wrapper.find(QuestionMarkTooltip).exists()).toBe(true)
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.prop('href')).toBe(TIME_FORMAT_TOOLTIP_LINK)
|
||||
expect(link.find(QuestionMarkTooltip).exists()).toBe(true)
|
||||
expect(wrapper.find(InputClickToEdit).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
@ -48,10 +57,14 @@ describe('Dashboards.Components.GraphOptionsTimeFormat', () => {
|
|||
const wrapper = setup({timeFormat})
|
||||
const dropdown = wrapper.find(Dropdown)
|
||||
const input = wrapper.find(InputClickToEdit)
|
||||
const label = wrapper.find('label')
|
||||
const link = label.find('a')
|
||||
|
||||
expect(dropdown.prop('selected')).toBe(TIME_FORMAT_CUSTOM)
|
||||
expect(input.exists()).toBe(true)
|
||||
expect(input.prop('value')).toBe(timeFormat)
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.find(QuestionMarkTooltip).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -68,7 +81,7 @@ describe('Dashboards.Components.GraphOptionsTimeFormat', () => {
|
|||
})
|
||||
|
||||
describe('when input is not custom', () => {
|
||||
it('sets the state custom format to false', () => {
|
||||
it('sets the state custom format to false and calls onTimeFormatChange', () => {
|
||||
const onTimeFormatChange = jest.fn()
|
||||
const instance = setup({
|
||||
onTimeFormatChange,
|
||||
|
@ -81,5 +94,20 @@ describe('Dashboards.Components.GraphOptionsTimeFormat', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('#handleChangeFormat', () => {
|
||||
it('sets state format to format and calls onTimeFormatChange', () => {
|
||||
const onTimeFormatChange = jest.fn()
|
||||
const format = 'mmmmmm'
|
||||
const instance = setup({
|
||||
onTimeFormatChange,
|
||||
}).instance() as GraphOptionsTimeFormat
|
||||
|
||||
instance.handleChangeFormat(format)
|
||||
expect(instance.state.format).toBe(format)
|
||||
expect(onTimeFormatChange).toBeCalledWith(format)
|
||||
expect(onTimeFormatChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -9,6 +9,7 @@ import {TableOptions} from 'src/dashboards/components/TableOptions'
|
|||
import FancyScrollbar from 'src/shared/components/FancyScrollbar'
|
||||
import ThresholdsList from 'src/shared/components/ThresholdsList'
|
||||
import ThresholdsListTypeToggle from 'src/shared/components/ThresholdsListTypeToggle'
|
||||
import {TIME_FIELD_DEFAULT} from 'src/shared/constants/tableGraph'
|
||||
|
||||
const defaultProps = {
|
||||
dataLabels: [],
|
||||
|
@ -32,12 +33,104 @@ const setup = (override = {}) => {
|
|||
|
||||
const wrapper = shallow(<TableOptions {...props} />)
|
||||
|
||||
return {wrapper, props}
|
||||
const instance = wrapper.instance() as TableOptions
|
||||
|
||||
return {wrapper, instance, props}
|
||||
}
|
||||
|
||||
describe('Dashboards.Components.TableOptions', () => {
|
||||
describe('getters', () => {
|
||||
describe('computedColumnNames', () => {})
|
||||
describe('fieldNames', () => {
|
||||
describe('if fieldNames are passed in tableOptions as props', () => {
|
||||
it('returns fieldNames', () => {
|
||||
const fieldNames = [
|
||||
{internalName: 'time', displayName: 'TIME', visible: true},
|
||||
{internalName: 'foo', displayName: 'BAR', visible: false},
|
||||
]
|
||||
const {instance} = setup({tableOptions: {fieldNames}})
|
||||
|
||||
expect(instance.fieldNames).toBe(fieldNames)
|
||||
})
|
||||
})
|
||||
|
||||
describe('if fieldNames are not passed in tableOptions as props', () => {
|
||||
it('returns empty array', () => {
|
||||
const {instance} = setup()
|
||||
|
||||
expect(instance.fieldNames).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeField', () => {
|
||||
describe('if time field in fieldNames', () => {
|
||||
it('returns time field', () => {
|
||||
const timeField = {
|
||||
internalName: 'time',
|
||||
displayName: 'TIME',
|
||||
visible: true,
|
||||
}
|
||||
const fieldNames = [
|
||||
timeField,
|
||||
{internalName: 'foo', displayName: 'BAR', visible: false},
|
||||
]
|
||||
const {instance} = setup({tableOptions: {fieldNames}})
|
||||
|
||||
expect(instance.timeField).toBe(timeField)
|
||||
})
|
||||
})
|
||||
|
||||
describe('if time field not in fieldNames', () => {
|
||||
it('returns default time field', () => {
|
||||
const fieldNames = [
|
||||
{internalName: 'foo', displayName: 'BAR', visible: false},
|
||||
]
|
||||
const {instance} = setup({tableOptions: {fieldNames}})
|
||||
|
||||
expect(instance.timeField).toBe(TIME_FIELD_DEFAULT)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('computedFieldNames', () => {
|
||||
describe('if dataLabels are not passed in', () => {
|
||||
it('returns an array of the time column', () => {
|
||||
const {instance} = setup()
|
||||
|
||||
expect(instance.computedFieldNames).toEqual([TIME_FIELD_DEFAULT])
|
||||
})
|
||||
})
|
||||
|
||||
describe('if dataLabels are passed in', () => {
|
||||
describe('if dataLabel has a matching fieldName', () => {
|
||||
it('returns array with the matching fieldName', () => {
|
||||
const fieldNames = [
|
||||
{internalName: 'foo', displayName: 'bar', visible: true},
|
||||
]
|
||||
const dataLabels = ['foo']
|
||||
const {instance} = setup({tableOptions: {fieldNames}, dataLabels})
|
||||
|
||||
expect(instance.computedFieldNames).toEqual(fieldNames)
|
||||
})
|
||||
})
|
||||
|
||||
describe('if dataLabel does not have a matching fieldName', () => {
|
||||
it('returns array with a new fieldName created for it', () => {
|
||||
const fieldNames = [
|
||||
{internalName: 'time', displayName: 'bar', visible: true},
|
||||
]
|
||||
const unmatchedLabel = 'foo'
|
||||
const dataLabels = ['time', unmatchedLabel]
|
||||
const {instance} = setup({tableOptions: {fieldNames}, dataLabels})
|
||||
|
||||
expect(instance.computedFieldNames).toEqual([
|
||||
...fieldNames,
|
||||
{internalName: unmatchedLabel, displayName: '', visible: true},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
|
|
|
@ -0,0 +1,223 @@
|
|||
import React from 'react'
|
||||
|
||||
import {shallow} from 'enzyme'
|
||||
|
||||
import TableGraph, {
|
||||
filterInvisibleColumns,
|
||||
processData,
|
||||
DEFAULT_SORT,
|
||||
} from 'src/shared/components/TableGraph'
|
||||
|
||||
const setup = (override = []) => {
|
||||
const props = {
|
||||
data: [],
|
||||
tableOptions: {
|
||||
timeFormat: '',
|
||||
verticalTimeAxis: true,
|
||||
sortBy: {
|
||||
internalName: '',
|
||||
displayName: '',
|
||||
visible: true,
|
||||
},
|
||||
wrapping: '',
|
||||
fieldNames: [],
|
||||
fixFirstColumn: true,
|
||||
},
|
||||
hoverTime: '',
|
||||
onSetHoverTime: () => {},
|
||||
colors: [],
|
||||
setDataLabels: () => {},
|
||||
...override,
|
||||
}
|
||||
|
||||
const data = [
|
||||
['time', 'f1', 'f2'],
|
||||
[1000, 3000, 2000],
|
||||
[2000, 1000, 3000],
|
||||
[3000, 2000, 1000],
|
||||
]
|
||||
|
||||
const wrapper = shallow(<TableGraph {...props} />)
|
||||
const instance = wrapper.instance() as TableGraph
|
||||
return {wrapper, instance, props, data}
|
||||
}
|
||||
|
||||
describe('Components.Shared.TableGraph', () => {
|
||||
describe('functions', () => {
|
||||
describe('filterInvisibleColumns', () => {
|
||||
it("returns a nested array of that only include columns whose corresponding fieldName's visibility is true", () => {
|
||||
const {data} = setup()
|
||||
|
||||
const fieldNames = [
|
||||
{internalName: 'time', displayName: 'Time', visible: true},
|
||||
{internalName: 'f1', displayName: '', visible: false},
|
||||
{internalName: 'f2', displayName: 'F2', visible: false},
|
||||
]
|
||||
|
||||
const actual = filterInvisibleColumns(data, fieldNames)
|
||||
const expected = [['time'], [1000], [2000], [3000]]
|
||||
expect(actual).toEqual(expected)
|
||||
})
|
||||
|
||||
it('returns an array of an empty array if all fieldNames are not visible', () => {
|
||||
const {data} = setup()
|
||||
|
||||
const fieldNames = [
|
||||
{internalName: 'time', displayName: 'Time', visible: false},
|
||||
{internalName: 'f1', displayName: '', visible: false},
|
||||
{internalName: 'f2', displayName: 'F2', visible: false},
|
||||
]
|
||||
|
||||
const actual = filterInvisibleColumns(data, fieldNames)
|
||||
const expected = [[]]
|
||||
expect(actual).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('processData', () => {
|
||||
it('sorts the data based on the provided sortFieldName', () => {
|
||||
const {data} = setup()
|
||||
const sortFieldName = 'f1'
|
||||
const direction = DEFAULT_SORT
|
||||
const verticalTimeAxis = true
|
||||
|
||||
const fieldNames = [
|
||||
{internalName: 'time', displayName: 'Time', visible: true},
|
||||
{internalName: 'f1', displayName: '', visible: true},
|
||||
{internalName: 'f2', displayName: 'F2', visible: true},
|
||||
]
|
||||
|
||||
const actual = processData(
|
||||
data,
|
||||
sortFieldName,
|
||||
direction,
|
||||
verticalTimeAxis,
|
||||
fieldNames
|
||||
)
|
||||
const expected = [
|
||||
['time', 'f1', 'f2'],
|
||||
[2000, 1000, 3000],
|
||||
[3000, 2000, 1000],
|
||||
[1000, 3000, 2000],
|
||||
]
|
||||
|
||||
expect(actual.processedData).toEqual(expected)
|
||||
})
|
||||
|
||||
it('filters out invisible columns', () => {
|
||||
const {data} = setup()
|
||||
const sortFieldName = 'time'
|
||||
const direction = DEFAULT_SORT
|
||||
const verticalTimeAxis = true
|
||||
|
||||
const fieldNames = [
|
||||
{internalName: 'time', displayName: 'Time', visible: true},
|
||||
{internalName: 'f1', displayName: '', visible: false},
|
||||
{internalName: 'f2', displayName: 'F2', visible: true},
|
||||
]
|
||||
|
||||
const actual = processData(
|
||||
data,
|
||||
sortFieldName,
|
||||
direction,
|
||||
verticalTimeAxis,
|
||||
fieldNames
|
||||
)
|
||||
const expected = [
|
||||
['time', 'f2'],
|
||||
[1000, 2000],
|
||||
[2000, 3000],
|
||||
[3000, 1000],
|
||||
]
|
||||
|
||||
expect(actual.processedData).toEqual(expected)
|
||||
})
|
||||
|
||||
it('filters out invisible columns after sorting', () => {
|
||||
const {data} = setup()
|
||||
const sortFieldName = 'f1'
|
||||
const direction = DEFAULT_SORT
|
||||
const verticalTimeAxis = true
|
||||
|
||||
const fieldNames = [
|
||||
{internalName: 'time', displayName: 'Time', visible: true},
|
||||
{internalName: 'f1', displayName: '', visible: false},
|
||||
{internalName: 'f2', displayName: 'F2', visible: true},
|
||||
]
|
||||
|
||||
const actual = processData(
|
||||
data,
|
||||
sortFieldName,
|
||||
direction,
|
||||
verticalTimeAxis,
|
||||
fieldNames
|
||||
)
|
||||
const expected = [
|
||||
['time', 'f2'],
|
||||
[2000, 3000],
|
||||
[3000, 1000],
|
||||
[1000, 2000],
|
||||
]
|
||||
|
||||
expect(actual.processedData).toEqual(expected)
|
||||
})
|
||||
|
||||
describe('if verticalTimeAxis is false', () => {
|
||||
it('transforms data', () => {
|
||||
const {data} = setup()
|
||||
const sortFieldName = 'time'
|
||||
const direction = DEFAULT_SORT
|
||||
const verticalTimeAxis = false
|
||||
|
||||
const fieldNames = [
|
||||
{internalName: 'time', displayName: 'Time', visible: true},
|
||||
{internalName: 'f1', displayName: '', visible: true},
|
||||
{internalName: 'f2', displayName: 'F2', visible: true},
|
||||
]
|
||||
|
||||
const actual = processData(
|
||||
data,
|
||||
sortFieldName,
|
||||
direction,
|
||||
verticalTimeAxis,
|
||||
fieldNames
|
||||
)
|
||||
const expected = [
|
||||
['time', 1000, 2000, 3000],
|
||||
['f1', 3000, 1000, 2000],
|
||||
['f2', 2000, 3000, 1000],
|
||||
]
|
||||
|
||||
expect(actual.processedData).toEqual(expected)
|
||||
})
|
||||
|
||||
it('transforms data after filtering out invisible columns', () => {
|
||||
const {data} = setup()
|
||||
const sortFieldName = 'f1'
|
||||
const direction = DEFAULT_SORT
|
||||
const verticalTimeAxis = false
|
||||
|
||||
const fieldNames = [
|
||||
{internalName: 'time', displayName: 'Time', visible: true},
|
||||
{internalName: 'f1', displayName: '', visible: false},
|
||||
{internalName: 'f2', displayName: 'F2', visible: true},
|
||||
]
|
||||
|
||||
const actual = processData(
|
||||
data,
|
||||
sortFieldName,
|
||||
direction,
|
||||
verticalTimeAxis,
|
||||
fieldNames
|
||||
)
|
||||
const expected = [
|
||||
['time', 2000, 3000, 1000],
|
||||
['f2', 3000, 1000, 2000],
|
||||
]
|
||||
|
||||
expect(actual.processedData).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,4 +1,6 @@
|
|||
import timeSeriesToDygraph from 'src/utils/timeSeriesToDygraph'
|
||||
import timeSeriesToDygraph, {
|
||||
timeSeriesToTableGraph,
|
||||
} from 'src/utils/timeSeriesToDygraph'
|
||||
|
||||
describe('timeSeriesToDygraph', () => {
|
||||
it('parses a raw InfluxDB response into a dygraph friendly data format', () => {
|
||||
|
@ -288,3 +290,174 @@ describe('timeSeriesToDygraph', () => {
|
|||
expect(actual.labels).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('timeSeriesToTableGraph', () => {
|
||||
it('parses raw data into a nested array of data', () => {
|
||||
const influxResponse = [
|
||||
{
|
||||
response: {
|
||||
results: [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
name: 'mb',
|
||||
columns: ['time', 'f1'],
|
||||
values: [[1000, 1], [2000, 2]],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
series: [
|
||||
{
|
||||
name: 'ma',
|
||||
columns: ['time', 'f1'],
|
||||
values: [[1000, 1], [2000, 2]],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
series: [
|
||||
{
|
||||
name: 'mc',
|
||||
columns: ['time', 'f2'],
|
||||
values: [[2000, 3], [4000, 4]],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
series: [
|
||||
{
|
||||
name: 'mc',
|
||||
columns: ['time', 'f1'],
|
||||
values: [[2000, 3], [4000, 4]],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const actual = timeSeriesToTableGraph(influxResponse)
|
||||
const expected = [
|
||||
['time', 'ma.f1', 'mb.f1', 'mc.f1', 'mc.f2'],
|
||||
[1000, 1, 1, null, null],
|
||||
[2000, 2, 2, 3, 3],
|
||||
[4000, null, null, 4, 4],
|
||||
]
|
||||
expect(actual.data).toEqual(expected)
|
||||
})
|
||||
|
||||
it('returns labels starting with time and then alphabetized', () => {
|
||||
const influxResponse = [
|
||||
{
|
||||
response: {
|
||||
results: [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
name: 'mb',
|
||||
columns: ['time', 'f1'],
|
||||
values: [[1000, 1], [2000, 2]],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
series: [
|
||||
{
|
||||
name: 'ma',
|
||||
columns: ['time', 'f1'],
|
||||
values: [[1000, 1], [2000, 2]],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
series: [
|
||||
{
|
||||
name: 'mc',
|
||||
columns: ['time', 'f2'],
|
||||
values: [[2000, 3], [4000, 4]],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
series: [
|
||||
{
|
||||
name: 'mc',
|
||||
columns: ['time', 'f1'],
|
||||
values: [[2000, 3], [4000, 4]],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const actual = timeSeriesToTableGraph(influxResponse)
|
||||
const expected = ['time', 'ma.f1', 'mb.f1', 'mc.f1', 'mc.f2']
|
||||
|
||||
expect(actual.labels).toEqual(expected)
|
||||
})
|
||||
|
||||
it('parses raw data into a table-readable format with the first row being labels', () => {
|
||||
const influxResponse = [
|
||||
{
|
||||
response: {
|
||||
results: [
|
||||
{
|
||||
series: [
|
||||
{
|
||||
name: 'mb',
|
||||
columns: ['time', 'f1'],
|
||||
values: [[1000, 1], [2000, 2]],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
series: [
|
||||
{
|
||||
name: 'ma',
|
||||
columns: ['time', 'f1'],
|
||||
values: [[1000, 1], [2000, 2]],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
series: [
|
||||
{
|
||||
name: 'mc',
|
||||
columns: ['time', 'f2'],
|
||||
values: [[2000, 3], [4000, 4]],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
series: [
|
||||
{
|
||||
name: 'mc',
|
||||
columns: ['time', 'f1'],
|
||||
values: [[2000, 3], [4000, 4]],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const actual = timeSeriesToTableGraph(influxResponse)
|
||||
const expected = ['time', 'ma.f1', 'mb.f1', 'mc.f1', 'mc.f2']
|
||||
|
||||
expect(actual.data[0]).toEqual(expected)
|
||||
})
|
||||
|
||||
it('returns an array of an empty array if there is an empty response', () => {
|
||||
const influxResponse = []
|
||||
|
||||
const actual = timeSeriesToTableGraph(influxResponse)
|
||||
const expected = [[]]
|
||||
|
||||
expect(actual.data).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -145,6 +145,9 @@ module.exports = {
|
|||
],
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify('development'),
|
||||
}),
|
||||
new webpack.DllReferencePlugin({
|
||||
context: process.cwd(),
|
||||
manifest: require('../build/vendor.dll.json'),
|
||||
|
|
Loading…
Reference in New Issue