Merge branch 'master' into feature/#54-tr-enterprise-client
Conflicts: Makefile chronograf.go server/routes.go server/users.gopull/10616/head
commit
f60a358751
|
@ -5,10 +5,17 @@
|
|||
2. [#907](https://github.com/influxdata/chronograf/pull/907): Fix react-router warning
|
||||
|
||||
### Features
|
||||
1. [#873](https://github.com/influxdata/chronograf/pull/873): Add [TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) support
|
||||
1. [#873](https://github.com/influxdata/chronograf/pull/873): Add [TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) support
|
||||
2. [#885](https://github.com/influxdata/chronograf/issues/885): Add presentation mode to dashboard page
|
||||
3. [#891](https://github.com/influxdata/chronograf/issues/891): Make dashboard visualizations draggable
|
||||
4. [#892](https://github.com/influxdata/chronograf/issues/891): Make dashboard visualizations resizable
|
||||
5. [#893](https://github.com/influxdata/chronograf/issues/893): Persist dashboard visualization position
|
||||
6. [#922](https://github.com/influxdata/chronograf/issues/922): Additional OAuth2 support for Heroku and Google
|
||||
|
||||
### UI Improvements
|
||||
1. [#905](https://github.com/influxdata/chronograf/pull/905): Make scroll bar thumb element bigger
|
||||
2. [#917](https://github.com/influxdata/chronograf/pull/917): Simplify side navigation
|
||||
3. [#920](https://github.com/influxdata/chronograf/pull/920): Display stacked and step plot graphs
|
||||
|
||||
## v1.2.0-beta3 [2017-02-15]
|
||||
|
||||
|
|
1
Godeps
1
Godeps
|
@ -15,3 +15,4 @@ github.com/sergi/go-diff 1d28411638c1e67fe1930830df207bef72496ae9
|
|||
github.com/tylerb/graceful 50a48b6e73fcc75b45e22c05b79629a67c79e938
|
||||
golang.org/x/net 749a502dd1eaf3e5bfd4f8956748c502357c0bbe
|
||||
golang.org/x/oauth2 1e695b1c8febf17aad3bfa7bf0a819ef94b98ad5
|
||||
google.golang.org/api bc20c61134e1d25265dd60049f5735381e79b631
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
* github.com/tylerb/graceful [MIT](https://github.com/tylerb/graceful/blob/master/LICENSE)
|
||||
* golang.org/x/net [BSD](https://github.com/golang/net/blob/master/LICENSE)
|
||||
* golang.org/x/oauth2 [BSD](https://github.com/golang/oauth2/blob/master/LICENSE)
|
||||
* google.golang.org/api/oauth2/v2 [BSD](https://github.com/google/google-api-go-client/blob/master/LICENSE)
|
||||
|
||||
### Javascript
|
||||
* Base64 0.2.1 [WTFPL](http://github.com/davidchambers/Base64.js)
|
||||
|
|
4
Makefile
4
Makefile
|
@ -1,4 +1,4 @@
|
|||
.PHONY: assets dep clean test gotest gotestrace jstest run run-dev
|
||||
.PHONY: assets dep clean test gotest gotestrace jstest run run-dev ctags continuous
|
||||
|
||||
VERSION ?= $(shell git describe --always --tags)
|
||||
COMMIT ?= $(shell git rev-parse --short=8 HEAD)
|
||||
|
@ -106,3 +106,5 @@ clean:
|
|||
continuous:
|
||||
while true; do if fswatch -r --one-event .; then echo "#-> Starting build: `date`"; make dev; pkill chronograf; ./chronograf -d --log-level=debug & echo "#-> Build complete."; fi; sleep 0.5; done
|
||||
|
||||
ctags:
|
||||
ctags -R --languages="Go" --exclude=.git --exclude=ui .
|
||||
|
|
|
@ -110,7 +110,7 @@ A UI for [Kapacitor](https://github.com/influxdata/kapacitor) alert creation and
|
|||
### TLS/HTTPS support
|
||||
See [Chronograf with TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) for more information.
|
||||
|
||||
### GitHub OAuth Login
|
||||
### OAuth Login
|
||||
See [Chronograf with OAuth 2.0](https://github.com/influxdata/chronograf/blob/master/docs/auth.md) for more information.
|
||||
|
||||
### Advanced Routing
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "0e980b97-c162-487b-a815-3f955df6243f",
|
||||
"measurement": "docker",
|
||||
"app": "docker",
|
||||
"measurement": "docker",
|
||||
"autoflow": true,
|
||||
"cells": [
|
||||
{
|
||||
|
@ -16,10 +16,10 @@
|
|||
"query": "SELECT mean(\"usage_percent\") AS \"usage_percent\" FROM \"docker_container_cpu\"",
|
||||
"groupbys": [
|
||||
"\"container_name\""
|
||||
],
|
||||
"wheres": []
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"type": "line-stacked"
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
|
@ -33,10 +33,10 @@
|
|||
"query": "SELECT mean(\"usage\") AS \"usage\" FROM \"docker_container_mem\"",
|
||||
"groupbys": [
|
||||
"\"container_name\""
|
||||
],
|
||||
"wheres": []
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"type": "line-stepplot"
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
|
@ -45,16 +45,15 @@
|
|||
"h": 4,
|
||||
"i": "4c79cefb-5152-410c-9b88-74f9bff7ef01",
|
||||
"name": "Docker - Containers",
|
||||
"type": "single-stat",
|
||||
"queries": [
|
||||
{
|
||||
"query": "SELECT max(\"n_containers\") AS \"max_n_containers\" FROM \"docker\"",
|
||||
"groupbys": [
|
||||
"\"host\""
|
||||
],
|
||||
"wheres": []
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"type": "single-stat"
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
|
@ -63,18 +62,17 @@
|
|||
"h": 4,
|
||||
"i": "4c79cefb-5152-410c-9b88-74f9bff7ef02",
|
||||
"name": "Docker - Images",
|
||||
"type": "single-stat",
|
||||
"queries": [
|
||||
{
|
||||
"query": "SELECT max(\"n_images\") AS \"max_n_images\" FROM \"docker\"",
|
||||
"groupbys": [
|
||||
"\"host\""
|
||||
],
|
||||
"wheres": []
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
],
|
||||
"type": "single-stat"
|
||||
},
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
|
@ -86,24 +84,22 @@
|
|||
"query": "SELECT max(\"n_containers_running\") AS \"max_n_containers_running\" FROM \"docker\"",
|
||||
"groupbys": [
|
||||
"\"host\""
|
||||
],
|
||||
"wheres": []
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "SELECT max(\"n_containers_stopped\") AS \"max_n_containers_stopped\" FROM \"docker\"",
|
||||
"groupbys": [
|
||||
"\"host\""
|
||||
],
|
||||
"wheres": []
|
||||
]
|
||||
},
|
||||
{
|
||||
"query": "SELECT max(\"n_containers_paused\") AS \"max_n_containers_paused\" FROM \"docker\"",
|
||||
"groupbys": [
|
||||
"\"host\""
|
||||
],
|
||||
"wheres": []
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"type": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ package chronograf
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// General errors.
|
||||
|
@ -360,25 +359,3 @@ type LayoutStore interface {
|
|||
// Update the dashboard in the store.
|
||||
Update(context.Context, Layout) error
|
||||
}
|
||||
|
||||
// Principal is any entity that can be authenticated
|
||||
type Principal string
|
||||
|
||||
// PrincipalKey is used to pass principal
|
||||
// via context.Context to request-scoped
|
||||
// functions.
|
||||
const PrincipalKey Principal = "principal"
|
||||
|
||||
// Authenticator represents a service for authenticating users.
|
||||
type Authenticator interface {
|
||||
// Authenticate returns User associated with token if successful.
|
||||
Authenticate(ctx context.Context, token string) (Principal, error)
|
||||
// Token generates a valid token for Principal lasting a duration
|
||||
Token(context.Context, Principal, time.Duration) (string, error)
|
||||
}
|
||||
|
||||
// TokenExtractor extracts tokens from http requests
|
||||
type TokenExtractor interface {
|
||||
// Extract will return the token or an error.
|
||||
Extract(r *http.Request) (string, error)
|
||||
}
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 59 KiB |
120
docs/auth.md
120
docs/auth.md
|
@ -7,6 +7,18 @@ OAuth 2.0 Style Authentication
|
|||
|
||||
To use authentication in Chronograf, both Github OAuth and JWT signature need to be configured.
|
||||
|
||||
#### Configuring JWT signature
|
||||
|
||||
Set a [JWT](https://tools.ietf.org/html/rfc7519) signature to a random string. This is needed for all OAuth2 providers that you choose to configure. *Keep this random string around!*
|
||||
|
||||
You'll need it each time you start a chronograf server because it is used to verify user authorization. If you are running multiple chronograf servers in an HA configuration set the `TOKEN_SECRET` on each to allow users to stay logged in.
|
||||
|
||||
```sh
|
||||
export TOKEN_SECRET=supersupersecret
|
||||
```
|
||||
|
||||
# Github
|
||||
|
||||
#### Creating Github OAuth Application
|
||||
|
||||
To create a Github OAuth Application follow the [Register your app](https://developer.github.com/guides/basics-of-authentication/#registering-your-app) instructions.
|
||||
|
@ -29,18 +41,6 @@ export GH_CLIENT_ID=b339dd4fddd95abec9aa
|
|||
export GH_CLIENT_SECRET=260041897d3252c146ece6b46ba39bc1e54416dc
|
||||
```
|
||||
|
||||
#### Configuring JWT signature
|
||||
|
||||
Set a [JWT](https://tools.ietf.org/html/rfc7519) signature to a random string.
|
||||
*Keep this random string around!*
|
||||
|
||||
You'll need it each time you start a chronograf server because it is used to verify
|
||||
user authorization. If you are running multiple chronograf servers in an HA configuration set the `TOKEN_SECRET` on each to allow users to stay logged in.
|
||||
|
||||
```sh
|
||||
export TOKEN_SECRET=supersupersecret
|
||||
```
|
||||
|
||||
#### Optional Github Organizations
|
||||
|
||||
To require an organization membership for a user, set the `GH_ORGS` environment variables
|
||||
|
@ -56,72 +56,50 @@ To support multiple organizations use a comma delimted list like so:
|
|||
export GH_ORGS=hill-valley-preservation-sociey,the-pinheads
|
||||
```
|
||||
|
||||
### Design
|
||||
# Google
|
||||
|
||||
The Chronograf authentication scheme is a standard [web application](https://developer.github.com/v3/oauth/#web-application-flow) OAuth flow.
|
||||
#### Creating Google OAuth Application
|
||||
|
||||
![oauth 2.0 flow](./OauthStyleAuthentication.png)
|
||||
You will need to obtain a client ID and an application secret by following the steps under "Basic Steps" [here](https://developers.google.com/identity/protocols/OAuth2). Chronograf will also need to be publicly accessible via a fully qualified domain name so that Google properly redirects users back to the application.
|
||||
|
||||
The browser receives a cookie from Chronograf, authorizing it. The contents of the cookie is a JWT whose "sub" claim is the user's primary
|
||||
github email address.
|
||||
This information should be set in the following ENVs:
|
||||
|
||||
On each request to Chronograf, the JWT contained in the cookie will be validated against the `TOKEN_SECRET` signature and checked for expiration.
|
||||
The JWT's "sub" becomes the [principal](https://en.wikipedia.org/wiki/Principal_(computer_security)) used for authorization to resources.
|
||||
* `GOOGLE_CLIENT_ID`
|
||||
* `GOOGLE_CLIENT_SECRET`
|
||||
* `PUBLIC_URL`
|
||||
|
||||
The API provides three endpoints `/oauth`, `/oauth/logout` and `/oauth/github/callback`.
|
||||
Alternatively, this can also be set using the command line switches:
|
||||
|
||||
#### /oauth
|
||||
* `--google-client-id`
|
||||
* `--google-client-secret`
|
||||
* `--public-url`
|
||||
|
||||
The `/oauth` endpoint redirects to Github for OAuth. Chronograf sets the OAuth `state` request parameter to a JWT with a random "sub". Using $TOKEN_SECRET `/oauth/github/callback`
|
||||
can validate the `state` parameter without needing `state` to be saved.
|
||||
#### Optional Google Domains
|
||||
|
||||
#### /oauth/github/callback
|
||||
Similar to Github's organization restriction, Google authentication can be restricted to permit access to Chronograf from only specific domains. These are configured using the `GOOGLE_DOMAINS` ENV or the `--google-domains` switch. Multiple domains are separated with a comma. For example, if we wanted to permit access only from biffspleasurepalace.com and savetheclocktower.com the ENV would be set as follows:
|
||||
|
||||
The `/oauth/github/callback` receives the OAuth `authorization code` and `state`.
|
||||
|
||||
First, it will validate the `state` JWT from the `/oauth` endpoint. `JWT` validation
|
||||
only requires access to the signature token. Therefore, there is no need for `state`
|
||||
to be saved. Additionally, multiple Chronograf servers will not need to share third
|
||||
party storage to synchronize `state`. If this validation fails, the request
|
||||
will be redirected to `/login`.
|
||||
|
||||
Secondly, the endpoint will use the `authorization code` to retrieve a valid OAuth token
|
||||
with the `user:email` scope. If unable to get a token from Github, the request will
|
||||
be redirected to `/login`.
|
||||
|
||||
Finally, the endpoint will attempt to get the primary email address of the Github user.
|
||||
Again, if not successful, the request will redirect to `/login`.
|
||||
|
||||
The email address is used as the subject claim for a new JWT. This JWT becomes the
|
||||
value of the cookie sent back to the browser. The cookie is valid for thirty days.
|
||||
|
||||
Next, the request is redirected to `/`.
|
||||
|
||||
For all API calls to `/chronograf/v1`, the server checks for the existence and validity
|
||||
of the JWT within the cookie value.
|
||||
If the request did not have a valid JWT, the API returns `HTTP/1.1 401 Unauthorized`.
|
||||
|
||||
#### /oauth/logout
|
||||
|
||||
Simply expires the session cookie and redirects to `/`.
|
||||
|
||||
### Authorization
|
||||
|
||||
After successful validation of the JWT, each API endpoint of `/chronograf/v1` receives the
|
||||
JWT subject within the `http.Request` as a `context.Context` value.
|
||||
|
||||
Within the Go API code all interfaces take `context.Context`. This means that each
|
||||
interface can use the value as a principal. The design allows for authorization to happen
|
||||
at the level of design most closely related to the problem.
|
||||
|
||||
An example usage in Go would be:
|
||||
|
||||
```go
|
||||
func ShallIPass(ctx context.Context) (string, error) {
|
||||
principal := ctx.Value(chronograf.PrincipalKey).(chronograf.Principal)
|
||||
if principal != "gandolf@moria.misty.mt" {
|
||||
return "you shall not pass", chronograf.ErrAuthentication
|
||||
}
|
||||
return "run you fools", nil
|
||||
}
|
||||
```sh
|
||||
export GOOGLE_DOMAINS=biffspleasurepalance.com,savetheclocktower.com
|
||||
```
|
||||
|
||||
# Heroku
|
||||
|
||||
#### Creating Heroku Application
|
||||
|
||||
To obtain a client ID and application secret for Heroku, you will need to follow the guide posted [here](https://devcenter.heroku.com/articles/oauth#register-client). Once your application has been created, those two values should be inserted into the following ENVs:
|
||||
|
||||
* `HEROKU_CLIENT_ID`
|
||||
* `HEROKU_SECRET`
|
||||
|
||||
The equivalent command line switches are:
|
||||
|
||||
* `--heroku-client-id`
|
||||
* `--heroku-secret`
|
||||
|
||||
#### Optional Heroku Organizations
|
||||
|
||||
Like the other OAuth2 providers, access to Chronograf via Heroku can be restricted to members of specific Heroku organizations. This is controlled using the `HEROKU_ORGS` ENV or the `--heroku-organizations` switch and is comma-separated. If we wanted to permit access from the `hill-valley-preservation-society` orgization and `the-pinheads` organization, we would use the following ENV:
|
||||
|
||||
```sh
|
||||
export HEROKU_ORGS=hill-valley-preservation-sociey,the-pinheads
|
||||
```
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package server
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -17,7 +17,7 @@ type CookieExtractor struct {
|
|||
func (c *CookieExtractor) Extract(r *http.Request) (string, error) {
|
||||
cookie, err := r.Cookie(c.Name)
|
||||
if err != nil {
|
||||
return "", chronograf.ErrAuthentication
|
||||
return "", ErrAuthentication
|
||||
}
|
||||
return cookie.Value, nil
|
||||
}
|
||||
|
@ -29,14 +29,14 @@ type BearerExtractor struct{}
|
|||
func (b *BearerExtractor) Extract(r *http.Request) (string, error) {
|
||||
s := r.Header.Get("Authorization")
|
||||
if s == "" {
|
||||
return "", chronograf.ErrAuthentication
|
||||
return "", ErrAuthentication
|
||||
}
|
||||
|
||||
// Check for Bearer token.
|
||||
strs := strings.Split(s, " ")
|
||||
|
||||
if len(strs) != 2 || strs[0] != "Bearer" {
|
||||
return "", chronograf.ErrAuthentication
|
||||
return "", ErrAuthentication
|
||||
}
|
||||
return strs[1], nil
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ func (b *BearerExtractor) Extract(r *http.Request) (string, error) {
|
|||
// will be run. The principal will be sent to the next handler via the request's
|
||||
// Context. It is up to the next handler to determine if the principal has access.
|
||||
// On failure, will return http.StatusUnauthorized.
|
||||
func AuthorizedToken(auth chronograf.Authenticator, te chronograf.TokenExtractor, logger chronograf.Logger, next http.Handler) http.HandlerFunc {
|
||||
func AuthorizedToken(auth Authenticator, te TokenExtractor, logger chronograf.Logger, next http.Handler) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log := logger.
|
||||
WithField("component", "auth").
|
||||
|
@ -55,12 +55,13 @@ func AuthorizedToken(auth chronograf.Authenticator, te chronograf.TokenExtractor
|
|||
|
||||
token, err := te.Extract(r)
|
||||
if err != nil {
|
||||
log.Error("Unable to extract token")
|
||||
// Happens when Provider okays authentication, but Token is bad
|
||||
log.Info("Unauthenticated user")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// We do not check the validity of the principal. Those
|
||||
// server further down the chain should do so.
|
||||
// served further down the chain should do so.
|
||||
principal, err := auth.Authenticate(r.Context(), token)
|
||||
if err != nil {
|
||||
log.Error("Invalid token")
|
||||
|
@ -69,7 +70,7 @@ func AuthorizedToken(auth chronograf.Authenticator, te chronograf.TokenExtractor
|
|||
}
|
||||
|
||||
// Send the principal to the next handler
|
||||
ctx := context.WithValue(r.Context(), chronograf.PrincipalKey, principal)
|
||||
ctx := context.WithValue(r.Context(), PrincipalKey, principal)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
})
|
|
@ -1,4 +1,4 @@
|
|||
package server_test
|
||||
package oauth2_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -8,9 +8,8 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
clog "github.com/influxdata/chronograf/log"
|
||||
"github.com/influxdata/chronograf/server"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
func TestCookieExtractor(t *testing.T) {
|
||||
|
@ -28,7 +27,7 @@ func TestCookieExtractor(t *testing.T) {
|
|||
Value: "reallyimportant",
|
||||
Lookup: "Doesntexist",
|
||||
Expected: "",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
Err: oauth2.ErrAuthentication,
|
||||
},
|
||||
{
|
||||
Desc: "Cookie token extracted",
|
||||
|
@ -46,7 +45,7 @@ func TestCookieExtractor(t *testing.T) {
|
|||
Value: test.Value,
|
||||
})
|
||||
|
||||
var e chronograf.TokenExtractor = &server.CookieExtractor{
|
||||
var e oauth2.TokenExtractor = &oauth2.CookieExtractor{
|
||||
Name: test.Lookup,
|
||||
}
|
||||
actual, err := e.Extract(req)
|
||||
|
@ -74,21 +73,21 @@ func TestBearerExtractor(t *testing.T) {
|
|||
Header: "Doesntexist",
|
||||
Value: "reallyimportant",
|
||||
Expected: "",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
Err: oauth2.ErrAuthentication,
|
||||
},
|
||||
{
|
||||
Desc: "Auth header doesn't have Bearer",
|
||||
Header: "Authorization",
|
||||
Value: "Bad Value",
|
||||
Expected: "",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
Err: oauth2.ErrAuthentication,
|
||||
},
|
||||
{
|
||||
Desc: "Auth header doesn't have Bearer token",
|
||||
Header: "Authorization",
|
||||
Value: "Bearer",
|
||||
Expected: "",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
Err: oauth2.ErrAuthentication,
|
||||
},
|
||||
{
|
||||
Desc: "Authorization Bearer token success",
|
||||
|
@ -102,7 +101,7 @@ func TestBearerExtractor(t *testing.T) {
|
|||
req, _ := http.NewRequest("", "http://howdy.com", nil)
|
||||
req.Header.Add(test.Header, test.Value)
|
||||
|
||||
var e chronograf.TokenExtractor = &server.BearerExtractor{}
|
||||
var e oauth2.TokenExtractor = &oauth2.BearerExtractor{}
|
||||
actual, err := e.Extract(req)
|
||||
if err != test.Err {
|
||||
t.Errorf("Bearer extract error; expected %v actual %v", test.Err, err)
|
||||
|
@ -123,15 +122,15 @@ func (m *MockExtractor) Extract(*http.Request) (string, error) {
|
|||
}
|
||||
|
||||
type MockAuthenticator struct {
|
||||
Principal chronograf.Principal
|
||||
Principal oauth2.Principal
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m *MockAuthenticator) Authenticate(context.Context, string) (chronograf.Principal, error) {
|
||||
func (m *MockAuthenticator) Authenticate(context.Context, string) (oauth2.Principal, error) {
|
||||
return m.Principal, m.Err
|
||||
}
|
||||
|
||||
func (m *MockAuthenticator) Token(context.Context, chronograf.Principal, time.Duration) (string, error) {
|
||||
func (m *MockAuthenticator) Token(context.Context, oauth2.Principal, time.Duration) (string, error) {
|
||||
return "", m.Err
|
||||
}
|
||||
|
||||
|
@ -139,7 +138,7 @@ func TestAuthorizedToken(t *testing.T) {
|
|||
var tests = []struct {
|
||||
Desc string
|
||||
Code int
|
||||
Principal chronograf.Principal
|
||||
Principal oauth2.Principal
|
||||
ExtractorErr error
|
||||
AuthErr error
|
||||
Expected string
|
||||
|
@ -155,19 +154,21 @@ func TestAuthorizedToken(t *testing.T) {
|
|||
AuthErr: errors.New("error"),
|
||||
},
|
||||
{
|
||||
Desc: "Authorized ok",
|
||||
Code: http.StatusOK,
|
||||
Principal: "Principal Strickland",
|
||||
Expected: "Principal Strickland",
|
||||
Desc: "Authorized ok",
|
||||
Code: http.StatusOK,
|
||||
Principal: oauth2.Principal{
|
||||
Subject: "Principal Strickland",
|
||||
},
|
||||
Expected: "Principal Strickland",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
// next is a sentinel StatusOK and
|
||||
// principal recorder.
|
||||
var principal chronograf.Principal
|
||||
var principal oauth2.Principal
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
principal = r.Context().Value(chronograf.PrincipalKey).(chronograf.Principal)
|
||||
principal = r.Context().Value(oauth2.PrincipalKey).(oauth2.Principal)
|
||||
})
|
||||
req, _ := http.NewRequest("GET", "", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
@ -181,7 +182,7 @@ func TestAuthorizedToken(t *testing.T) {
|
|||
}
|
||||
|
||||
logger := clog.New(clog.DebugLevel)
|
||||
handler := server.AuthorizedToken(a, e, logger, next)
|
||||
handler := oauth2.AuthorizedToken(a, e, logger, next)
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != test.Code {
|
||||
t.Errorf("Status code expected: %d actual %d", test.Code, w.Code)
|
|
@ -0,0 +1,140 @@
|
|||
// The oauth2 package provides http.Handlers necessary for implementing Oauth2
|
||||
// authentication with multiple Providers.
|
||||
//
|
||||
// This is how the pieces of this package fit together:
|
||||
//
|
||||
// ┌────────────────────────────────────────┐
|
||||
// │github.com/influxdata/chronograf/oauth2 │
|
||||
// ├────────────────────────────────────────┴────────────────────────────────────┐
|
||||
// │┌────────────────────┐ │
|
||||
// ││ <<interface>> │ ┌─────────────────────────┐ │
|
||||
// ││ Authenticator │ │ CookieMux │ │
|
||||
// │├────────────────────┤ ├─────────────────────────┤ │
|
||||
// ││Authenticate() │ Auth │+SuccessURL : string │ │
|
||||
// ││Token() ◀────────│+FailureURL : string │──────────┐ │
|
||||
// │└──────────△─────────┘ │+Now : func() time.Time │ │ │
|
||||
// │ │ └─────────────────────────┘ │ │
|
||||
// │ │ │ │ │
|
||||
// │ │ │ │ │
|
||||
// │ │ Provider│ │ │
|
||||
// │ │ ┌───┘ │ │
|
||||
// │┌──────────┴────────────┐ │ ▽ │
|
||||
// ││ JWT │ │ ┌───────────────┐ │
|
||||
// │├───────────────────────┤ ▼ │ <<interface>> │ │
|
||||
// ││+Secret : string │ ┌───────────────┐ │ OAuth2Mux │ │
|
||||
// ││+Now : func() time.Time│ │ <<interface>> │ ├───────────────┤ │
|
||||
// │└───────────────────────┘ │ Provider │ │Login() │ │
|
||||
// │ ├───────────────┤ │Logout() │ │
|
||||
// │ │ID() │ │Callback() │ │
|
||||
// │ │Scopes() │ └───────────────┘ │
|
||||
// │ │Secret() │ │
|
||||
// │ │Authenticator()│ │
|
||||
// │ └───────────────┘ │
|
||||
// │ △ │
|
||||
// │ │ │
|
||||
// │ ┌─────────────────────────┼─────────────────────────┐ │
|
||||
// │ │ │ │ │
|
||||
// │ │ │ │ │
|
||||
// │ │ │ │ │
|
||||
// │ ┌───────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐│
|
||||
// │ │ Github │ │ Google │ │ Heroku ││
|
||||
// │ ├───────────────────────┤ ├──────────────────────┤ ├──────────────────────┤│
|
||||
// │ │+ClientID : string │ │+ClientID : string │ │+ClientID : string ││
|
||||
// │ │+ClientSecret : string │ │+ClientSecret : string│ │+ClientSecret : string││
|
||||
// │ │+Orgs : []string │ │+Domains : []string │ └──────────────────────┘│
|
||||
// │ └───────────────────────┘ │+RedirectURL : string │ │
|
||||
// │ └──────────────────────┘ │
|
||||
// └─────────────────────────────────────────────────────────────────────────────┘
|
||||
//
|
||||
// The design focuses on an Authenticator, a Provider, and an OAuth2Mux. Their
|
||||
// responsibilities, respectively, are to decode and encode secrets received
|
||||
// from a Provider, to perform Provider specific operations in order to extract
|
||||
// information about a user, and to produce the handlers which persist secrets.
|
||||
// To add a new provider, You need only implement the Provider interface, and
|
||||
// add its endpoints to the server Mux.
|
||||
//
|
||||
// The Oauth2 flow between a browser, backend, and a Provider that this package
|
||||
// implements is pictured below for reference.
|
||||
//
|
||||
// ┌─────────┐ ┌───────────┐ ┌────────┐
|
||||
// │ Browser │ │Chronograf │ │Provider│
|
||||
// └─────────┘ └───────────┘ └────────┘
|
||||
// │ │ │
|
||||
// ├─────── GET /auth ─────────▶ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// ◀ ─ ─ ─302 to Provider ─ ─ ┤ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// ├──────────────── GET /auth w/ callback ─────────────────────▶
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// ◀─ ─ ─ ─ ─ ─ ─ 302 to Chronograf Callback ─ ─ ─ ─ ─ ─ ─ ─ ┤
|
||||
// │ │ │
|
||||
// │ Code and State from │ │
|
||||
// │ Provider │ │
|
||||
// ├───────────────────────────▶ Request token w/ code & │
|
||||
// │ │ state │
|
||||
// │ ├────────────────────────────────▶
|
||||
// │ │ │
|
||||
// │ │ Response with │
|
||||
// │ │ Token │
|
||||
// │ Set cookie, Redirect │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤
|
||||
// │ to / │ │
|
||||
// ◀───────────────────────────┤ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
// │ │ │
|
||||
//
|
||||
// The browser ultimately receives a cookie from Chronograf, authorizing it.
|
||||
// Its contents are encoded as a JWT whose "sub" claim is the user's email
|
||||
// address for whatever provider they have authenticated with. Each request to
|
||||
// Chronograf will validate the contents of this JWT against the `TOKEN_SECRET`
|
||||
// and checked for expiration. The JWT's "sub" becomes the
|
||||
// https://en.wikipedia.org/wiki/Principal_(computer_security) used for
|
||||
// authorization to resources.
|
||||
//
|
||||
// The Mux is responsible for providing three http.Handlers for servicing the
|
||||
// above interaction. These are mounted at specific endpoints by convention
|
||||
// shared with the front end. Any future Provider routes should follow the same
|
||||
// convention to ensure compatibility with the front end logic. These routes
|
||||
// and their responsibilities are:
|
||||
//
|
||||
// /oauth/{provider}/login
|
||||
//
|
||||
// The `/oauth` endpoint redirects to the Provider for OAuth. Chronograf sets
|
||||
// the OAuth `state` request parameter to a JWT with a random "sub". Using
|
||||
// $TOKEN_SECRET `/oauth/github/callback` can validate the `state` parameter
|
||||
// without needing `state` to be saved.
|
||||
//
|
||||
// /oauth/{provider}/callback
|
||||
//
|
||||
// The `/oauth/github/callback` receives the OAuth `authorization code` and `state`.
|
||||
//
|
||||
// First, it will validate the `state` JWT from the `/oauth` endpoint. `JWT` validation
|
||||
// only requires access to the signature token. Therefore, there is no need for `state`
|
||||
// to be saved. Additionally, multiple Chronograf servers will not need to share third
|
||||
// party storage to synchronize `state`. If this validation fails, the request
|
||||
// will be redirected to `/login`.
|
||||
//
|
||||
// Secondly, the endpoint will use the `authorization code` to retrieve a valid OAuth token
|
||||
// with the `user:email` scope. If unable to get a token from Github, the request will
|
||||
// be redirected to `/login`.
|
||||
//
|
||||
// Finally, the endpoint will attempt to get the primary email address of the
|
||||
// user. Again, if not successful, the request will redirect to `/login`.
|
||||
//
|
||||
// The email address is used as the subject claim for a new JWT. This JWT becomes the
|
||||
// value of the cookie sent back to the browser. The cookie is valid for thirty days.
|
||||
//
|
||||
// Next, the request is redirected to `/`.
|
||||
//
|
||||
// For all API calls to `/chronograf/v1`, the server checks for the existence and validity
|
||||
// of the JWT within the cookie value.
|
||||
// If the request did not have a valid JWT, the API returns `HTTP/1.1 401 Unauthorized`.
|
||||
//
|
||||
// /oauth/{provider}/logout
|
||||
//
|
||||
// Simply expires the session cookie and redirects to `/`.
|
||||
package oauth2
|
|
@ -0,0 +1,162 @@
|
|||
package oauth2
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/go-github/github"
|
||||
"github.com/influxdata/chronograf"
|
||||
"golang.org/x/oauth2"
|
||||
ogh "golang.org/x/oauth2/github"
|
||||
)
|
||||
|
||||
var _ Provider = &Github{}
|
||||
|
||||
// Github provides OAuth Login and Callback server. Callback will set
|
||||
// an authentication cookie. This cookie's value is a JWT containing
|
||||
// the user's primary Github email address.
|
||||
type Github struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Orgs []string // Optional github organization checking
|
||||
Logger chronograf.Logger
|
||||
}
|
||||
|
||||
// Name is the name of the provider
|
||||
func (g *Github) Name() string {
|
||||
return "github"
|
||||
}
|
||||
|
||||
// ID returns the github application client id
|
||||
func (g *Github) ID() string {
|
||||
return g.ClientID
|
||||
}
|
||||
|
||||
// Secret returns the github application client secret
|
||||
func (g *Github) Secret() string {
|
||||
return g.ClientSecret
|
||||
}
|
||||
|
||||
// Scopes for github is only the email addres and possible organizations if
|
||||
// we are filtering by organizations.
|
||||
func (g *Github) Scopes() []string {
|
||||
scopes := []string{"user:email"}
|
||||
if len(g.Orgs) > 0 {
|
||||
scopes = append(scopes, "read:org")
|
||||
}
|
||||
return scopes
|
||||
}
|
||||
|
||||
// Config is the Github OAuth2 exchange information and endpoints
|
||||
func (g *Github) Config() *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: g.ID(),
|
||||
ClientSecret: g.Secret(),
|
||||
Scopes: g.Scopes(),
|
||||
Endpoint: ogh.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// PrincipalID returns the github email address of the user.
|
||||
func (g *Github) PrincipalID(provider *http.Client) (string, error) {
|
||||
client := github.NewClient(provider)
|
||||
// If we need to restrict to a set of organizations, we first get the org
|
||||
// and filter.
|
||||
if len(g.Orgs) > 0 {
|
||||
orgs, err := getOrganizations(client, g.Logger)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Not a member, so, deny permission
|
||||
if ok := isMember(g.Orgs, orgs); !ok {
|
||||
g.Logger.Error("Not a member of required github organization")
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
email, err := getPrimaryEmail(client, g.Logger)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func randomString(length int) string {
|
||||
k := make([]byte, length)
|
||||
if _, err := io.ReadFull(rand.Reader, k); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k)
|
||||
}
|
||||
|
||||
func logResponseError(log chronograf.Logger, resp *github.Response, err error) {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
log.Error("OAuth access to email address forbidden ", err.Error())
|
||||
default:
|
||||
log.Error("Unable to retrieve Github email ", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// isMember makes sure that the user is in one of the required organizations
|
||||
func isMember(requiredOrgs []string, userOrgs []*github.Organization) bool {
|
||||
for _, requiredOrg := range requiredOrgs {
|
||||
for _, userOrg := range userOrgs {
|
||||
if userOrg.Login != nil && *userOrg.Login == requiredOrg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getOrganizations gets all organization for the currently authenticated user
|
||||
func getOrganizations(client *github.Client, log chronograf.Logger) ([]*github.Organization, error) {
|
||||
// Get all pages of results
|
||||
var allOrgs []*github.Organization
|
||||
for {
|
||||
opt := &github.ListOptions{
|
||||
PerPage: 10,
|
||||
}
|
||||
// Get the organizations for the current authenticated user.
|
||||
orgs, resp, err := client.Organizations.List("", opt)
|
||||
if err != nil {
|
||||
logResponseError(log, resp, err)
|
||||
return nil, err
|
||||
}
|
||||
allOrgs = append(allOrgs, orgs...)
|
||||
if resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
opt.Page = resp.NextPage
|
||||
}
|
||||
return allOrgs, nil
|
||||
}
|
||||
|
||||
// getPrimaryEmail gets the primary email account for the authenticated user.
|
||||
func getPrimaryEmail(client *github.Client, log chronograf.Logger) (string, error) {
|
||||
emails, resp, err := client.Users.ListEmails(nil)
|
||||
if err != nil {
|
||||
logResponseError(log, resp, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
email, err := primaryEmail(emails)
|
||||
if err != nil {
|
||||
log.Error("Unable to retrieve primary Github email ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func primaryEmail(emails []*github.UserEmail) (string, error) {
|
||||
for _, m := range emails {
|
||||
if m != nil && m.Primary != nil && m.Verified != nil && m.Email != nil {
|
||||
return *m.Email, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("No primary email address")
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
package oauth2_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
clog "github.com/influxdata/chronograf/log"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
func TestGithubPrincipalID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}{
|
||||
{"martymcfly@example.com", true, false},
|
||||
}
|
||||
mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/user/emails" {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(rw)
|
||||
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expected)
|
||||
}))
|
||||
defer mockAPI.Close()
|
||||
|
||||
logger := clog.New(clog.ParseLevel("debug"))
|
||||
prov := oauth2.Github{
|
||||
Logger: logger,
|
||||
}
|
||||
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal("Error initializing TestTripper: err:", err)
|
||||
}
|
||||
|
||||
tc := &http.Client{
|
||||
Transport: tt,
|
||||
}
|
||||
|
||||
email, err := prov.PrincipalID(tc)
|
||||
if err != nil {
|
||||
t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err)
|
||||
}
|
||||
|
||||
if email != expected[0].Email {
|
||||
t.Fatal("Retrieved email was not as expected. Want:", expected[0].Email, "Got:", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGithubPrincipalIDOrganization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expectedUser := []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}{
|
||||
{"martymcfly@example.com", true, false},
|
||||
}
|
||||
expectedOrg := []struct {
|
||||
Login string `json:"login"`
|
||||
}{
|
||||
{"Hill Valley Preservation Society"},
|
||||
}
|
||||
|
||||
mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/user/emails" {
|
||||
enc := json.NewEncoder(rw)
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expectedUser)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/user/orgs" {
|
||||
enc := json.NewEncoder(rw)
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expectedOrg)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer mockAPI.Close()
|
||||
|
||||
logger := clog.New(clog.ParseLevel("debug"))
|
||||
prov := oauth2.Github{
|
||||
Logger: logger,
|
||||
Orgs: []string{"Hill Valley Preservation Society"},
|
||||
}
|
||||
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal("Error initializing TestTripper: err:", err)
|
||||
}
|
||||
|
||||
tc := &http.Client{
|
||||
Transport: tt,
|
||||
}
|
||||
|
||||
email, err := prov.PrincipalID(tc)
|
||||
if err != nil {
|
||||
t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err)
|
||||
}
|
||||
|
||||
if email != expectedUser[0].Email {
|
||||
t.Fatal("Retrieved email was not as expected. Want:", expectedUser[0].Email, "Got:", email)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package oauth2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
goauth2 "google.golang.org/api/oauth2/v2"
|
||||
)
|
||||
|
||||
// Endpoint is Google's OAuth 2.0 endpoint.
|
||||
// Copied here to remove tons of package dependencies
|
||||
var GoogleEndpoint = oauth2.Endpoint{
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/auth",
|
||||
TokenURL: "https://accounts.google.com/o/oauth2/token",
|
||||
}
|
||||
var _ Provider = &Google{}
|
||||
|
||||
type Google struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
Domains []string // Optional google email domain checking
|
||||
Logger chronograf.Logger
|
||||
}
|
||||
|
||||
// Name is the name of the provider
|
||||
func (g *Google) Name() string {
|
||||
return "google"
|
||||
}
|
||||
|
||||
// ID returns the google application client id
|
||||
func (g *Google) ID() string {
|
||||
return g.ClientID
|
||||
}
|
||||
|
||||
// Secret returns the google application client secret
|
||||
func (g *Google) Secret() string {
|
||||
return g.ClientSecret
|
||||
}
|
||||
|
||||
// Scopes for google is only the email address
|
||||
// Documentation is here: https://developers.google.com/+/web/api/rest/oauth#email
|
||||
func (g *Google) Scopes() []string {
|
||||
return []string{
|
||||
goauth2.UserinfoEmailScope,
|
||||
goauth2.UserinfoProfileScope,
|
||||
}
|
||||
}
|
||||
|
||||
// Config is the Google OAuth2 exchange information and endpoints
|
||||
func (g *Google) Config() *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: g.ID(),
|
||||
ClientSecret: g.Secret(),
|
||||
Scopes: g.Scopes(),
|
||||
Endpoint: GoogleEndpoint,
|
||||
RedirectURL: g.RedirectURL,
|
||||
}
|
||||
}
|
||||
|
||||
// PrincipalID returns the google email address of the user.
|
||||
func (g *Google) PrincipalID(provider *http.Client) (string, error) {
|
||||
srv, err := goauth2.New(provider)
|
||||
if err != nil {
|
||||
g.Logger.Error("Unable to communicate with Google ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
info, err := srv.Userinfo.Get().Do()
|
||||
if err != nil {
|
||||
g.Logger.Error("Unable to retrieve Google email ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
// No domain filtering required, so, the user is autenticated.
|
||||
if len(g.Domains) == 0 {
|
||||
return info.Email, nil
|
||||
}
|
||||
|
||||
// Check if the account domain is acceptable
|
||||
for _, requiredDomain := range g.Domains {
|
||||
if info.Hd == requiredDomain {
|
||||
return info.Email, nil
|
||||
}
|
||||
}
|
||||
g.Logger.Error("Domain '", info.Hd, "' is not a member of required Google domain(s): ", g.Domains)
|
||||
return "", fmt.Errorf("Not in required domain")
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
package oauth2_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
clog "github.com/influxdata/chronograf/log"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
func TestGooglePrincipalID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := struct {
|
||||
Email string `json:"email"`
|
||||
}{
|
||||
"martymcfly@example.com",
|
||||
}
|
||||
mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/oauth2/v2/userinfo" {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(rw)
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expected)
|
||||
}))
|
||||
defer mockAPI.Close()
|
||||
|
||||
logger := clog.New(clog.ParseLevel("debug"))
|
||||
prov := oauth2.Google{
|
||||
Logger: logger,
|
||||
}
|
||||
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal("Error initializing TestTripper: err:", err)
|
||||
}
|
||||
|
||||
tc := &http.Client{
|
||||
Transport: tt,
|
||||
}
|
||||
|
||||
email, err := prov.PrincipalID(tc)
|
||||
if err != nil {
|
||||
t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err)
|
||||
}
|
||||
|
||||
if email != expected.Email {
|
||||
t.Fatal("Retrieved email was not as expected. Want:", expected.Email, "Got:", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooglePrincipalIDDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expectedUser := struct {
|
||||
Email string `json:"email"`
|
||||
Hd string `json:"hd"`
|
||||
}{
|
||||
"martymcfly@example.com",
|
||||
"Hill Valley Preservation Society",
|
||||
}
|
||||
//a := goauth2.Userinfoplus{}
|
||||
|
||||
mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/oauth2/v2/userinfo" {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(rw)
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expectedUser)
|
||||
}))
|
||||
defer mockAPI.Close()
|
||||
|
||||
logger := clog.New(clog.ParseLevel("debug"))
|
||||
prov := oauth2.Google{
|
||||
Logger: logger,
|
||||
Domains: []string{"Hill Valley Preservation Society"},
|
||||
}
|
||||
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal("Error initializing TestTripper: err:", err)
|
||||
}
|
||||
|
||||
tc := &http.Client{
|
||||
Transport: tt,
|
||||
}
|
||||
|
||||
email, err := prov.PrincipalID(tc)
|
||||
if err != nil {
|
||||
t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err)
|
||||
}
|
||||
|
||||
if email != expectedUser.Email {
|
||||
t.Fatal("Retrieved email was not as expected. Want:", expectedUser.Email, "Got:", email)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
package oauth2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
hrk "golang.org/x/oauth2/heroku"
|
||||
)
|
||||
|
||||
// Ensure that Heroku is an oauth2.Provider
|
||||
var _ Provider = &Heroku{}
|
||||
|
||||
const (
|
||||
// Routes required for interacting with Heroku API
|
||||
HEROKU_ACCOUNT_ROUTE string = "https://api.heroku.com/account"
|
||||
)
|
||||
|
||||
// Heroku is an OAuth2 Provider allowing users to authenticate with Heroku to
|
||||
// gain access to Chronograf
|
||||
type Heroku struct {
|
||||
// OAuth2 Secrets
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
|
||||
Organizations []string // set of organizations permitted to access the protected resource. Empty means "all"
|
||||
|
||||
Logger chronograf.Logger
|
||||
}
|
||||
|
||||
// Config returns the OAuth2 exchange information and endpoints
|
||||
func (h *Heroku) Config() *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: h.ID(),
|
||||
ClientSecret: h.Secret(),
|
||||
Scopes: h.Scopes(),
|
||||
Endpoint: hrk.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// ID returns the Heroku application client ID
|
||||
func (h *Heroku) ID() string {
|
||||
return h.ClientID
|
||||
}
|
||||
|
||||
// Name returns the name of this provider (heroku)
|
||||
func (h *Heroku) Name() string {
|
||||
return "heroku"
|
||||
}
|
||||
|
||||
// PrincipalID returns the Heroku email address of the user.
|
||||
func (h *Heroku) PrincipalID(provider *http.Client) (string, error) {
|
||||
type DefaultOrg struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type Account struct {
|
||||
Email string `json:"email"`
|
||||
DefaultOrganization DefaultOrg `json:"default_organization"`
|
||||
}
|
||||
|
||||
resp, err := provider.Get(HEROKU_ACCOUNT_ROUTE)
|
||||
if err != nil {
|
||||
h.Logger.Error("Unable to communicate with Heroku. err:", err)
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
d := json.NewDecoder(resp.Body)
|
||||
var account Account
|
||||
if err := d.Decode(&account); err != nil {
|
||||
h.Logger.Error("Unable to decode response from Heroku. err:", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// check if member of org
|
||||
if len(h.Organizations) > 0 {
|
||||
for _, org := range h.Organizations {
|
||||
if account.DefaultOrganization.Name == org {
|
||||
return account.Email, nil
|
||||
}
|
||||
}
|
||||
h.Logger.Error(ErrOrgMembership)
|
||||
return "", ErrOrgMembership
|
||||
} else {
|
||||
return account.Email, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Scopes for heroku is "identity" which grants access to user account
|
||||
// information. This will grant us access to the user's email address which is
|
||||
// used as the Principal's identifier.
|
||||
func (h *Heroku) Scopes() []string {
|
||||
return []string{"identity"}
|
||||
}
|
||||
|
||||
// Secret returns the Heroku application client secret
|
||||
func (h *Heroku) Secret() string {
|
||||
return h.ClientSecret
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
package oauth2_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
clog "github.com/influxdata/chronograf/log"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
func Test_Heroku_PrincipalID_ExtractsEmailAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := struct {
|
||||
Email string `json:"email"`
|
||||
}{
|
||||
"martymcfly@example.com",
|
||||
}
|
||||
|
||||
mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/account" {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(rw)
|
||||
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expected)
|
||||
}))
|
||||
defer mockAPI.Close()
|
||||
|
||||
logger := clog.New(clog.ParseLevel("debug"))
|
||||
prov := oauth2.Heroku{
|
||||
Logger: logger,
|
||||
}
|
||||
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal("Error initializing TestTripper: err:", err)
|
||||
}
|
||||
|
||||
tc := &http.Client{
|
||||
Transport: tt,
|
||||
}
|
||||
|
||||
email, err := prov.PrincipalID(tc)
|
||||
if err != nil {
|
||||
t.Fatal("Unexpected error while retrieiving PrincipalID: err:", err)
|
||||
}
|
||||
|
||||
if email != expected.Email {
|
||||
t.Fatal("Retrieved email was not as expected. Want:", expected.Email, "Got:", email)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Heroku_PrincipalID_RestrictsByOrganization(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := struct {
|
||||
Email string `json:"email"`
|
||||
DefaultOrganization map[string]string `json:"default_organization"`
|
||||
}{
|
||||
"martymcfly@example.com",
|
||||
map[string]string{
|
||||
"id": "a85eac89-56cc-498e-9a89-d8f49f6aed71",
|
||||
"name": "hill-valley-preservation-society",
|
||||
},
|
||||
}
|
||||
|
||||
mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/account" {
|
||||
rw.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(rw)
|
||||
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
_ = enc.Encode(expected)
|
||||
}))
|
||||
defer mockAPI.Close()
|
||||
|
||||
logger := clog.New(clog.ParseLevel("debug"))
|
||||
prov := oauth2.Heroku{
|
||||
Logger: logger,
|
||||
Organizations: []string{"enchantment-under-the-sea-dance-committee"},
|
||||
}
|
||||
|
||||
tt, err := NewTestTripper(logger, mockAPI, http.DefaultTransport)
|
||||
if err != nil {
|
||||
t.Fatal("Error initializing TestTripper: err:", err)
|
||||
}
|
||||
|
||||
tc := &http.Client{
|
||||
Transport: tt,
|
||||
}
|
||||
|
||||
_, err = prov.PrincipalID(tc)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error while authenticating user with mismatched orgs, but received none")
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package jwt
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -6,11 +6,10 @@ import (
|
|||
"time"
|
||||
|
||||
gojwt "github.com/dgrijalva/jwt-go"
|
||||
"github.com/influxdata/chronograf"
|
||||
)
|
||||
|
||||
// Test if JWT implements Authenticator
|
||||
var _ chronograf.Authenticator = &JWT{}
|
||||
var _ Authenticator = &JWT{}
|
||||
|
||||
// JWT represents a javascript web token that can be validated or marshaled into string.
|
||||
type JWT struct {
|
||||
|
@ -45,7 +44,7 @@ func (c *Claims) Valid() error {
|
|||
}
|
||||
|
||||
// Authenticate checks if the jwtToken is signed correctly and validates with Claims.
|
||||
func (j *JWT) Authenticate(ctx context.Context, jwtToken string) (chronograf.Principal, error) {
|
||||
func (j *JWT) Authenticate(ctx context.Context, jwtToken string) (Principal, error) {
|
||||
gojwt.TimeFunc = j.Now
|
||||
|
||||
// Check for expected signing method.
|
||||
|
@ -62,27 +61,31 @@ func (j *JWT) Authenticate(ctx context.Context, jwtToken string) (chronograf.Pri
|
|||
// 4. Check if subject is not empty
|
||||
token, err := gojwt.ParseWithClaims(jwtToken, &Claims{}, alg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return Principal{}, err
|
||||
} else if !token.Valid {
|
||||
return "", err
|
||||
return Principal{}, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unable to convert claims to standard claims")
|
||||
return Principal{}, fmt.Errorf("unable to convert claims to standard claims")
|
||||
}
|
||||
|
||||
return chronograf.Principal(claims.Subject), nil
|
||||
return Principal{
|
||||
Subject: claims.Subject,
|
||||
Issuer: claims.Issuer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Token creates a signed JWT token from user that expires at Now + duration
|
||||
func (j *JWT) Token(ctx context.Context, user chronograf.Principal, duration time.Duration) (string, error) {
|
||||
func (j *JWT) Token(ctx context.Context, user Principal, duration time.Duration) (string, error) {
|
||||
// Create a new token object, specifying signing method and the claims
|
||||
// you would like it to contain.
|
||||
now := j.Now().UTC()
|
||||
claims := &Claims{
|
||||
gojwt.StandardClaims{
|
||||
Subject: string(user),
|
||||
Subject: user.Subject,
|
||||
Issuer: user.Issuer,
|
||||
ExpiresAt: now.Add(duration).Unix(),
|
||||
IssuedAt: now.Unix(),
|
||||
NotBefore: now.Unix(),
|
|
@ -1,4 +1,4 @@
|
|||
package jwt_test
|
||||
package oauth2_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
@ -6,8 +6,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
"github.com/influxdata/chronograf/jwt"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
func TestAuthenticate(t *testing.T) {
|
||||
|
@ -15,46 +14,56 @@ func TestAuthenticate(t *testing.T) {
|
|||
Desc string
|
||||
Secret string
|
||||
Token string
|
||||
User chronograf.Principal
|
||||
User oauth2.Principal
|
||||
Err error
|
||||
}{
|
||||
{
|
||||
Desc: "Test bad jwt token",
|
||||
Secret: "secret",
|
||||
Token: "badtoken",
|
||||
User: "",
|
||||
Err: errors.New("token contains an invalid number of segments"),
|
||||
User: oauth2.Principal{
|
||||
Subject: "",
|
||||
},
|
||||
Err: errors.New("token contains an invalid number of segments"),
|
||||
},
|
||||
{
|
||||
Desc: "Test valid jwt token",
|
||||
Secret: "secret",
|
||||
Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAwLCJuYmYiOi00NDY3NzQ0MDB9._rZ4gOIei9PizHOABH6kLcJTA3jm8ls0YnDxtz1qeUI",
|
||||
User: "/chronograf/v1/users/1",
|
||||
User: oauth2.Principal{
|
||||
Subject: "/chronograf/v1/users/1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Desc: "Test expired jwt token",
|
||||
Secret: "secret",
|
||||
Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAxLCJuYmYiOi00NDY3NzQ0MDB9.vWXdm0-XQ_pW62yBpSISFFJN_yz0vqT9_INcUKTp5Q8",
|
||||
User: "",
|
||||
Err: errors.New("token is expired by 1s"),
|
||||
User: oauth2.Principal{
|
||||
Subject: "",
|
||||
},
|
||||
Err: errors.New("token is expired by 1s"),
|
||||
},
|
||||
{
|
||||
Desc: "Test jwt token not before time",
|
||||
Secret: "secret",
|
||||
Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIiwibmFtZSI6IkRvYyBCcm93biIsImlhdCI6LTQ0Njc3NDQwMCwiZXhwIjotNDQ2Nzc0NDAwLCJuYmYiOi00NDY3NzQzOTl9.TMGAhv57u1aosjc4ywKC7cElP1tKyQH7GmRF2ToAxlE",
|
||||
User: "",
|
||||
Err: errors.New("token is not valid yet"),
|
||||
User: oauth2.Principal{
|
||||
Subject: "",
|
||||
},
|
||||
Err: errors.New("token is not valid yet"),
|
||||
},
|
||||
{
|
||||
Desc: "Test jwt with empty subject is invalid",
|
||||
Secret: "secret",
|
||||
Token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOi00NDY3NzQ0MDAsImV4cCI6LTQ0Njc3NDQwMCwibmJmIjotNDQ2Nzc0NDAwfQ.gxsA6_Ei3s0f2I1TAtrrb8FmGiO25OqVlktlF_ylhX4",
|
||||
User: "",
|
||||
Err: errors.New("claim has no subject"),
|
||||
User: oauth2.Principal{
|
||||
Subject: "",
|
||||
},
|
||||
Err: errors.New("claim has no subject"),
|
||||
},
|
||||
}
|
||||
for i, test := range tests {
|
||||
j := jwt.JWT{
|
||||
j := oauth2.JWT{
|
||||
Secret: test.Secret,
|
||||
Now: func() time.Time {
|
||||
return time.Unix(-446774400, 0)
|
||||
|
@ -77,13 +86,16 @@ func TestAuthenticate(t *testing.T) {
|
|||
func TestToken(t *testing.T) {
|
||||
duration := time.Second
|
||||
expected := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOi00NDY3NzQzOTksImlhdCI6LTQ0Njc3NDQwMCwibmJmIjotNDQ2Nzc0NDAwLCJzdWIiOiIvY2hyb25vZ3JhZi92MS91c2Vycy8xIn0.ofQM6yTmrmve5JeEE0RcK4_euLXuZ_rdh6bLAbtbC9M"
|
||||
j := jwt.JWT{
|
||||
j := oauth2.JWT{
|
||||
Secret: "secret",
|
||||
Now: func() time.Time {
|
||||
return time.Unix(-446774400, 0)
|
||||
},
|
||||
}
|
||||
if token, err := j.Token(context.Background(), chronograf.Principal("/chronograf/v1/users/1"), duration); err != nil {
|
||||
p := oauth2.Principal{
|
||||
Subject: "/chronograf/v1/users/1",
|
||||
}
|
||||
if token, err := j.Token(context.Background(), p, duration); err != nil {
|
||||
t.Errorf("Error creating token for user: %v", err)
|
||||
} else if token != expected {
|
||||
t.Errorf("Error creating token; expected: %s actual: %s", "", token)
|
|
@ -0,0 +1,169 @@
|
|||
package oauth2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultCookieName is the name of the stored cookie
|
||||
DefaultCookieName = "session"
|
||||
// DefaultCookieDuration is the length of time the cookie is valid
|
||||
DefaultCookieDuration = time.Hour * 24 * 30
|
||||
)
|
||||
|
||||
// Cookie represents the location and expiration time of new cookies.
|
||||
type cookie struct {
|
||||
Name string
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// Check to ensure CookieMux is an oauth2.Mux
|
||||
var _ Mux = &CookieMux{}
|
||||
|
||||
func NewCookieMux(p Provider, a Authenticator, l chronograf.Logger) *CookieMux {
|
||||
return &CookieMux{
|
||||
Provider: p,
|
||||
Auth: a,
|
||||
Logger: l,
|
||||
SuccessURL: "/",
|
||||
FailureURL: "/login",
|
||||
Now: time.Now,
|
||||
|
||||
cookie: cookie{
|
||||
Name: DefaultCookieName,
|
||||
Duration: DefaultCookieDuration,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CookieMux services an Oauth2 interaction with a provider and browser and
|
||||
// stores the resultant token in the user's browser as a cookie. The benefit of
|
||||
// this is that the cookie's authenticity can be verified independently by any
|
||||
// Chronograf instance as long as the Authenticator has no external
|
||||
// dependencies (e.g. on a Database).
|
||||
type CookieMux struct {
|
||||
Provider Provider
|
||||
Auth Authenticator
|
||||
cookie cookie
|
||||
Logger chronograf.Logger
|
||||
SuccessURL string // SuccessURL is redirect location after successful authorization
|
||||
FailureURL string // FailureURL is redirect location after authorization failure
|
||||
Now func() time.Time // Now returns the current time
|
||||
}
|
||||
|
||||
// Uses a Cookie with a random string as the state validation method. JWTs are
|
||||
// a good choice here for encoding because they can be validated without
|
||||
// storing state.
|
||||
func (j *CookieMux) Login() http.Handler {
|
||||
conf := j.Provider.Config()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// We are creating a token with an encoded random string to prevent CSRF attacks
|
||||
// This token will be validated during the OAuth callback.
|
||||
// We'll give our users 10 minutes from this point to type in their github password.
|
||||
// If the callback is not received within 10 minutes, then authorization will fail.
|
||||
csrf := randomString(32) // 32 is not important... just long
|
||||
p := Principal{
|
||||
Subject: csrf,
|
||||
}
|
||||
state, err := j.Auth.Token(r.Context(), p, 10*time.Minute)
|
||||
// This is likely an internal server error
|
||||
if err != nil {
|
||||
j.Logger.
|
||||
WithField("component", "auth").
|
||||
WithField("remote_addr", r.RemoteAddr).
|
||||
WithField("method", r.Method).
|
||||
WithField("url", r.URL).
|
||||
Error("Internal authentication error: ", err.Error())
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
url := conf.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
// Callback is used by OAuth2 provider after authorization is granted. If
|
||||
// granted, Callback will set a cookie with a month-long expiration. It is
|
||||
// recommended that the value of the cookie be encoded as a JWT because the JWT
|
||||
// can be validated without the need for saving state. The JWT contains the
|
||||
// principal's identifier (e.g. email address).
|
||||
func (j *CookieMux) Callback() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log := j.Logger.
|
||||
WithField("component", "auth").
|
||||
WithField("remote_addr", r.RemoteAddr).
|
||||
WithField("method", r.Method).
|
||||
WithField("url", r.URL)
|
||||
|
||||
state := r.FormValue("state")
|
||||
// Check if the OAuth state token is valid to prevent CSRF
|
||||
_, err := j.Auth.Authenticate(r.Context(), state)
|
||||
if err != nil {
|
||||
log.Error("Invalid OAuth state received: ", err.Error())
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange the code back with the provider to the the token
|
||||
conf := j.Provider.Config()
|
||||
code := r.FormValue("code")
|
||||
token, err := conf.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
log.Error("Unable to exchange code for token ", err.Error())
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Using the token get the principal identifier from the provider
|
||||
oauthClient := conf.Client(r.Context(), token)
|
||||
id, err := j.Provider.PrincipalID(oauthClient)
|
||||
if err != nil {
|
||||
log.Error("Unable to get principal identifier ", err.Error())
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
p := Principal{
|
||||
Subject: id,
|
||||
Issuer: j.Provider.Name(),
|
||||
}
|
||||
// We create an auth token that will be used by all other endpoints to validate the principal has a claim
|
||||
authToken, err := j.Auth.Token(r.Context(), p, j.cookie.Duration)
|
||||
if err != nil {
|
||||
log.Error("Unable to create cookie auth token ", err.Error())
|
||||
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
expireCookie := j.Now().UTC().Add(j.cookie.Duration)
|
||||
cookie := http.Cookie{
|
||||
Name: j.cookie.Name,
|
||||
Value: authToken,
|
||||
Expires: expireCookie,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
}
|
||||
log.Info("User ", id, " is authenticated")
|
||||
http.SetCookie(w, &cookie)
|
||||
http.Redirect(w, r, j.SuccessURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
} // Login returns a handler that redirects to the providers OAuth login.
|
||||
|
||||
// Logout handler will expire our authentication cookie and redirect to the successURL
|
||||
func (j *CookieMux) Logout() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteCookie := http.Cookie{
|
||||
Name: j.cookie.Name,
|
||||
Value: "none",
|
||||
Expires: j.Now().UTC().Add(-1 * time.Hour),
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
}
|
||||
http.SetCookie(w, &deleteCookie)
|
||||
http.Redirect(w, r, j.SuccessURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,157 @@
|
|||
package oauth2_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clog "github.com/influxdata/chronograf/log"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
var testTime time.Time = time.Date(1985, time.October, 25, 18, 0, 0, 0, time.UTC)
|
||||
|
||||
// setupMuxTest produces an http.Client and an httptest.Server configured to
|
||||
// use a particular http.Handler selected from a CookieMux. As this selection is
|
||||
// done during the setup process, this configuration is performed by providing
|
||||
// a function, and returning the desired handler. Cleanup is still the
|
||||
// responsibility of the test writer, so the httptest.Server's Close() method
|
||||
// should be deferred.
|
||||
func setupMuxTest(selector func(*oauth2.CookieMux) http.Handler) (*http.Client, *httptest.Server, *httptest.Server) {
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
mp := &MockProvider{"biff@example.com", provider.URL}
|
||||
|
||||
jm := oauth2.NewCookieMux(mp, &YesManAuthenticator{}, clog.New(clog.ParseLevel("debug")))
|
||||
|
||||
jm.Now = func() time.Time {
|
||||
return testTime
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(selector(jm))
|
||||
|
||||
jar, _ := cookiejar.New(nil)
|
||||
|
||||
hc := http.Client{
|
||||
Jar: jar,
|
||||
CheckRedirect: func(r *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
return &hc, ts, provider
|
||||
}
|
||||
|
||||
// teardownMuxTest cleans up any resources created by setupMuxTest. This should
|
||||
// be deferred in your test after setupMuxTest is called
|
||||
func teardownMuxTest(hc *http.Client, backend *httptest.Server, provider *httptest.Server) {
|
||||
provider.Close()
|
||||
backend.Close()
|
||||
}
|
||||
|
||||
func Test_CookieMux_Logout_DeletesSessionCookie(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hc, ts, prov := setupMuxTest(func(j *oauth2.CookieMux) http.Handler {
|
||||
return j.Logout()
|
||||
})
|
||||
defer teardownMuxTest(hc, ts, prov)
|
||||
|
||||
tsUrl, _ := url.Parse(ts.URL)
|
||||
|
||||
hc.Jar.SetCookies(tsUrl, []*http.Cookie{
|
||||
&http.Cookie{
|
||||
Name: oauth2.DefaultCookieName,
|
||||
Value: "",
|
||||
},
|
||||
})
|
||||
|
||||
resp, err := hc.Get(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatal("Error communicating with Logout() handler: err:", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
|
||||
t.Fatal("Expected to be redirected, but received status code", resp.StatusCode)
|
||||
}
|
||||
|
||||
cookies := resp.Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatal("Expected that cookie would be present but wasn't")
|
||||
}
|
||||
|
||||
c := cookies[0]
|
||||
if c.Name != oauth2.DefaultCookieName || c.Expires != testTime.Add(-1*time.Hour) {
|
||||
t.Fatal("Expected cookie to be expired but wasn't")
|
||||
}
|
||||
}
|
||||
|
||||
func Test_CookieMux_Login_RedirectsToCorrectURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hc, ts, prov := setupMuxTest(func(j *oauth2.CookieMux) http.Handler {
|
||||
return j.Login() // Use Login handler for httptest server.
|
||||
})
|
||||
defer teardownMuxTest(hc, ts, prov)
|
||||
|
||||
resp, err := hc.Get(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatal("Error communicating with Login() handler: err:", err)
|
||||
}
|
||||
|
||||
// Ensure we were redirected
|
||||
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
|
||||
t.Fatal("Expected to be redirected, but received status code", resp.StatusCode)
|
||||
}
|
||||
|
||||
loc, err := resp.Location()
|
||||
if err != nil {
|
||||
t.Fatal("Expected a location to be redirected to, but wasn't present")
|
||||
}
|
||||
|
||||
if state := loc.Query().Get("state"); state != "HELLO?!MCFLY?!ANYONEINTHERE?!" {
|
||||
t.Fatal("Expected state to be set but was", state)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_CookieMux_Callback_SetsCookie(t *testing.T) {
|
||||
hc, ts, prov := setupMuxTest(func(j *oauth2.CookieMux) http.Handler {
|
||||
return j.Callback()
|
||||
})
|
||||
defer teardownMuxTest(hc, ts, prov)
|
||||
|
||||
tsURL, _ := url.Parse(ts.URL)
|
||||
|
||||
v := url.Values{
|
||||
"code": {"4815162342"},
|
||||
"state": {"foobar"},
|
||||
}
|
||||
|
||||
tsURL.RawQuery = v.Encode()
|
||||
|
||||
resp, err := hc.Get(tsURL.String())
|
||||
if err != nil {
|
||||
t.Fatal("Error communicating with Callback() handler: err", err)
|
||||
}
|
||||
|
||||
// Ensure we were redirected
|
||||
if resp.StatusCode < 300 || resp.StatusCode >= 400 {
|
||||
t.Fatal("Expected to be redirected, but received status code", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Check that cookie was set
|
||||
cookies := resp.Cookies()
|
||||
if count := len(cookies); count != 1 {
|
||||
t.Fatal("Expected exactly one cookie to be set but found", count)
|
||||
}
|
||||
|
||||
c := cookies[0]
|
||||
|
||||
if c.Name != oauth2.DefaultCookieName {
|
||||
t.Fatal("Expected cookie to be named", oauth2.DefaultCookieName, "but was", c.Name)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
package oauth2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type principalKey string
|
||||
|
||||
func (p principalKey) String() string {
|
||||
return string(p)
|
||||
}
|
||||
|
||||
var (
|
||||
// PrincipalKey is used to pass principal
|
||||
// via context.Context to request-scoped
|
||||
// functions.
|
||||
PrincipalKey = principalKey("principal")
|
||||
// ErrAuthentication means that oauth2 exchange failed
|
||||
ErrAuthentication = errors.New("user not authenticated")
|
||||
// ErrOrgMembership means that the user is not in the OAuth2 filtered group
|
||||
ErrOrgMembership = errors.New("Not a member of the required organization")
|
||||
)
|
||||
|
||||
/* Types */
|
||||
|
||||
// Principal is any entity that can be authenticated
|
||||
type Principal struct {
|
||||
Subject string
|
||||
Issuer string
|
||||
}
|
||||
|
||||
/* Interfaces */
|
||||
|
||||
// Provider are the common parameters for all providers (RFC 6749)
|
||||
type Provider interface {
|
||||
// ID is issued to the registered client by the authorization (RFC 6749 Section 2.2)
|
||||
ID() string
|
||||
// Secret associated is with the ID (Section 2.2)
|
||||
Secret() string
|
||||
// Scopes is used by the authorization server to "scope" responses (Section 3.3)
|
||||
Scopes() []string
|
||||
// Config is the OAuth2 configuration settings for this provider
|
||||
Config() *oauth2.Config
|
||||
// PrincipalID with fetch the identifier to be associated with the principal.
|
||||
PrincipalID(provider *http.Client) (string, error)
|
||||
|
||||
// Name is the name of the Provider
|
||||
Name() string
|
||||
}
|
||||
|
||||
// Mux is a collection of handlers responsible for servicing an Oauth2 interaction between a browser and a provider
|
||||
type Mux interface {
|
||||
Login() http.Handler
|
||||
Logout() http.Handler
|
||||
Callback() http.Handler
|
||||
}
|
||||
|
||||
// Authenticator represents a service for authenticating users.
|
||||
type Authenticator interface {
|
||||
// Authenticate returns User associated with token if successful.
|
||||
Authenticate(ctx context.Context, token string) (Principal, error)
|
||||
// Token generates a valid token for Principal lasting a duration
|
||||
Token(context.Context, Principal, time.Duration) (string, error)
|
||||
}
|
||||
|
||||
// TokenExtractor extracts tokens from http requests
|
||||
type TokenExtractor interface {
|
||||
// Extract will return the token or an error.
|
||||
Extract(r *http.Request) (string, error)
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
package oauth2_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
goauth "golang.org/x/oauth2"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
var _ oauth2.Provider = &MockProvider{}
|
||||
|
||||
type MockProvider struct {
|
||||
Email string
|
||||
|
||||
ProviderURL string
|
||||
}
|
||||
|
||||
func (mp *MockProvider) Config() *goauth.Config {
|
||||
return &goauth.Config{
|
||||
RedirectURL: "http://www.example.com",
|
||||
ClientID: "4815162342",
|
||||
ClientSecret: "8675309",
|
||||
Endpoint: goauth.Endpoint{
|
||||
mp.ProviderURL + "/oauth/auth",
|
||||
mp.ProviderURL + "/oauth/token",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (mp *MockProvider) ID() string {
|
||||
return "8675309"
|
||||
}
|
||||
|
||||
func (mp *MockProvider) Name() string {
|
||||
return "mockly"
|
||||
}
|
||||
|
||||
func (mp *MockProvider) PrincipalID(provider *http.Client) (string, error) {
|
||||
return mp.Email, nil
|
||||
}
|
||||
|
||||
func (mp *MockProvider) Scopes() []string {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (mp *MockProvider) Secret() string {
|
||||
return "4815162342"
|
||||
}
|
||||
|
||||
var _ oauth2.Authenticator = &YesManAuthenticator{}
|
||||
|
||||
type YesManAuthenticator struct{}
|
||||
|
||||
func (y *YesManAuthenticator) Authenticate(ctx context.Context, token string) (oauth2.Principal, error) {
|
||||
return oauth2.Principal{
|
||||
Subject: "biff@example.com",
|
||||
Issuer: "Biff Tannen's Pleasure Paradise",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (y *YesManAuthenticator) Token(ctx context.Context, p oauth2.Principal, t time.Duration) (string, error) {
|
||||
return "HELLO?!MCFLY?!ANYONEINTHERE?!", nil
|
||||
}
|
||||
|
||||
func NewTestTripper(log chronograf.Logger, ts *httptest.Server, rt http.RoundTripper) (*TestTripper, error) {
|
||||
url, err := url.Parse(ts.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TestTripper{log, rt, url}, nil
|
||||
}
|
||||
|
||||
type TestTripper struct {
|
||||
Log chronograf.Logger
|
||||
|
||||
rt http.RoundTripper
|
||||
tsURL *url.URL
|
||||
}
|
||||
|
||||
// RoundTrip modifies the Hostname of the incoming request to be directed to the
|
||||
// test server.
|
||||
func (tt *TestTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
tt.Log.
|
||||
WithField("component", "test").
|
||||
WithField("remote_addr", r.RemoteAddr).
|
||||
WithField("method", r.Method).
|
||||
WithField("url", r.URL).
|
||||
Info("Request")
|
||||
|
||||
r.URL.Host = tt.tsURL.Host
|
||||
r.URL.Scheme = tt.tsURL.Scheme
|
||||
|
||||
return tt.rt.RoundTrip(r)
|
||||
}
|
|
@ -10,6 +10,13 @@ import (
|
|||
"github.com/influxdata/chronograf"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultWidth is used if not specified
|
||||
DefaultWidth = 4
|
||||
// DefaultHeight is used if not specified
|
||||
DefaultHeight = 4
|
||||
)
|
||||
|
||||
type dashboardLinks struct {
|
||||
Self string `json:"self"` // Self link mapping to this resource
|
||||
}
|
||||
|
@ -25,6 +32,7 @@ type getDashboardsResponse struct {
|
|||
|
||||
func newDashboardResponse(d chronograf.Dashboard) dashboardResponse {
|
||||
base := "/chronograf/v1/dashboards"
|
||||
DashboardDefaults(&d)
|
||||
return dashboardResponse{
|
||||
Dashboard: d,
|
||||
Links: dashboardLinks{
|
||||
|
@ -80,7 +88,7 @@ func (s *Service) NewDashboard(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if err := ValidDashboardRequest(dashboard); err != nil {
|
||||
if err := ValidDashboardRequest(&dashboard); err != nil {
|
||||
invalidData(w, err, s.Logger)
|
||||
return
|
||||
}
|
||||
|
@ -119,8 +127,8 @@ func (s *Service) RemoveDashboard(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// UpdateDashboard replaces a dashboard
|
||||
func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
// ReplaceDashboard completely replaces a dashboard
|
||||
func (s *Service) ReplaceDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
idParam, err := strconv.Atoi(httprouter.GetParamFromContext(ctx, "id"))
|
||||
if err != nil {
|
||||
|
@ -142,7 +150,7 @@ func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
req.ID = id
|
||||
|
||||
if err := ValidDashboardRequest(req); err != nil {
|
||||
if err := ValidDashboardRequest(&req); err != nil {
|
||||
invalidData(w, err, s.Logger)
|
||||
return
|
||||
}
|
||||
|
@ -157,17 +165,85 @@ func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) {
|
|||
encodeJSON(w, http.StatusOK, res, s.Logger)
|
||||
}
|
||||
|
||||
// UpdateDashboard completely updates either the dashboard name or the cells
|
||||
func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
idParam, err := strconv.Atoi(httprouter.GetParamFromContext(ctx, "id"))
|
||||
if err != nil {
|
||||
msg := fmt.Sprintf("Could not parse dashboard ID: %s", err)
|
||||
Error(w, http.StatusInternalServerError, msg, s.Logger)
|
||||
}
|
||||
id := chronograf.DashboardID(idParam)
|
||||
|
||||
orig, err := s.DashboardsStore.Get(ctx, id)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, fmt.Sprintf("ID %d not found", id), s.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req chronograf.Dashboard
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
invalidJSON(w, s.Logger)
|
||||
return
|
||||
}
|
||||
req.ID = id
|
||||
|
||||
if req.Name != "" {
|
||||
orig.Name = req.Name
|
||||
} else if len(req.Cells) > 0 {
|
||||
if err := ValidDashboardRequest(&req); err != nil {
|
||||
invalidData(w, err, s.Logger)
|
||||
return
|
||||
}
|
||||
orig.Cells = req.Cells
|
||||
} else {
|
||||
invalidData(w, fmt.Errorf("Update must include either name or cells"), s.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.DashboardsStore.Update(ctx, orig); err != nil {
|
||||
msg := fmt.Sprintf("Error updating dashboard ID %d: %v", id, err)
|
||||
Error(w, http.StatusInternalServerError, msg, s.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
res := newDashboardResponse(orig)
|
||||
encodeJSON(w, http.StatusOK, res, s.Logger)
|
||||
}
|
||||
|
||||
// ValidDashboardRequest verifies that the dashboard cells have a query
|
||||
func ValidDashboardRequest(d chronograf.Dashboard) error {
|
||||
func ValidDashboardRequest(d *chronograf.Dashboard) error {
|
||||
if len(d.Cells) == 0 {
|
||||
return fmt.Errorf("cells are required")
|
||||
}
|
||||
|
||||
for _, c := range d.Cells {
|
||||
if (len(c.Queries) == 0) {
|
||||
for i, c := range d.Cells {
|
||||
if len(c.Queries) == 0 {
|
||||
return fmt.Errorf("query required")
|
||||
}
|
||||
CorrectWidthHeight(&c)
|
||||
d.Cells[i] = c
|
||||
}
|
||||
|
||||
DashboardDefaults(d)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DashboardDefaults updates the dashboard with the default values
|
||||
// if none are specified
|
||||
func DashboardDefaults(d *chronograf.Dashboard) {
|
||||
for i, c := range d.Cells {
|
||||
CorrectWidthHeight(&c)
|
||||
d.Cells[i] = c
|
||||
}
|
||||
}
|
||||
|
||||
// CorrectWidthHeight changes the cell to have at least the
|
||||
// minimum width and height
|
||||
func CorrectWidthHeight(c *chronograf.DashboardCell) {
|
||||
if c.W < 1 {
|
||||
c.W = DefaultWidth
|
||||
}
|
||||
if c.H < 1 {
|
||||
c.H = DefaultHeight
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,299 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
)
|
||||
|
||||
func TestCorrectWidthHeight(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
cell chronograf.DashboardCell
|
||||
want chronograf.DashboardCell
|
||||
}{
|
||||
{
|
||||
name: "updates width",
|
||||
cell: chronograf.DashboardCell{
|
||||
W: 0,
|
||||
H: 4,
|
||||
},
|
||||
want: chronograf.DashboardCell{
|
||||
W: 4,
|
||||
H: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "updates height",
|
||||
cell: chronograf.DashboardCell{
|
||||
W: 4,
|
||||
H: 0,
|
||||
},
|
||||
want: chronograf.DashboardCell{
|
||||
W: 4,
|
||||
H: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "updates both",
|
||||
cell: chronograf.DashboardCell{
|
||||
W: 0,
|
||||
H: 0,
|
||||
},
|
||||
want: chronograf.DashboardCell{
|
||||
W: 4,
|
||||
H: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "updates neither",
|
||||
cell: chronograf.DashboardCell{
|
||||
W: 4,
|
||||
H: 4,
|
||||
},
|
||||
want: chronograf.DashboardCell{
|
||||
W: 4,
|
||||
H: 4,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if CorrectWidthHeight(&tt.cell); !reflect.DeepEqual(tt.cell, tt.want) {
|
||||
t.Errorf("%q. CorrectWidthHeight() = %v, want %v", tt.name, tt.cell, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
d chronograf.Dashboard
|
||||
want chronograf.Dashboard
|
||||
}{
|
||||
{
|
||||
name: "Updates all cell widths/heights",
|
||||
d: chronograf.Dashboard{
|
||||
Cells: []chronograf.DashboardCell{
|
||||
{
|
||||
W: 0,
|
||||
H: 0,
|
||||
},
|
||||
{
|
||||
W: 2,
|
||||
H: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: chronograf.Dashboard{
|
||||
Cells: []chronograf.DashboardCell{
|
||||
{
|
||||
W: 4,
|
||||
H: 4,
|
||||
},
|
||||
{
|
||||
W: 2,
|
||||
H: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Updates no cell",
|
||||
d: chronograf.Dashboard{
|
||||
Cells: []chronograf.DashboardCell{
|
||||
{
|
||||
W: 4,
|
||||
H: 4,
|
||||
}, {
|
||||
W: 2,
|
||||
H: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: chronograf.Dashboard{
|
||||
Cells: []chronograf.DashboardCell{
|
||||
{
|
||||
W: 4,
|
||||
H: 4,
|
||||
},
|
||||
{
|
||||
W: 2,
|
||||
H: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if DashboardDefaults(&tt.d); !reflect.DeepEqual(tt.d, tt.want) {
|
||||
t.Errorf("%q. DashboardDefaults() = %v, want %v", tt.name, tt.d, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidDashboardRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
d chronograf.Dashboard
|
||||
want chronograf.Dashboard
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Updates all cell widths/heights",
|
||||
d: chronograf.Dashboard{
|
||||
Cells: []chronograf.DashboardCell{
|
||||
{
|
||||
W: 0,
|
||||
H: 0,
|
||||
Queries: []chronograf.Query{
|
||||
{
|
||||
Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
W: 2,
|
||||
H: 2,
|
||||
Queries: []chronograf.Query{
|
||||
{
|
||||
Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: chronograf.Dashboard{
|
||||
Cells: []chronograf.DashboardCell{
|
||||
{
|
||||
W: 4,
|
||||
H: 4,
|
||||
Queries: []chronograf.Query{
|
||||
{
|
||||
Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
W: 2,
|
||||
H: 2,
|
||||
Queries: []chronograf.Query{
|
||||
{
|
||||
Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "No queries",
|
||||
d: chronograf.Dashboard{
|
||||
Cells: []chronograf.DashboardCell{
|
||||
{
|
||||
W: 2,
|
||||
H: 2,
|
||||
Queries: []chronograf.Query{},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: chronograf.Dashboard{
|
||||
Cells: []chronograf.DashboardCell{
|
||||
{
|
||||
W: 2,
|
||||
H: 2,
|
||||
Queries: []chronograf.Query{},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Empty Cells",
|
||||
d: chronograf.Dashboard{
|
||||
Cells: []chronograf.DashboardCell{},
|
||||
},
|
||||
want: chronograf.Dashboard{
|
||||
Cells: []chronograf.DashboardCell{},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
err := ValidDashboardRequest(&tt.d)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("%q. ValidDashboardRequest() error = %v, wantErr %v", tt.name, err, tt.wantErr)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(tt.d, tt.want) {
|
||||
t.Errorf("%q. ValidDashboardRequest() = %v, want %v", tt.name, tt.d, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Test_newDashboardResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
d chronograf.Dashboard
|
||||
want dashboardResponse
|
||||
}{
|
||||
{
|
||||
name: "Updates all cell widths/heights",
|
||||
d: chronograf.Dashboard{
|
||||
Cells: []chronograf.DashboardCell{
|
||||
{
|
||||
W: 0,
|
||||
H: 0,
|
||||
Queries: []chronograf.Query{
|
||||
{
|
||||
Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
W: 0,
|
||||
H: 0,
|
||||
Queries: []chronograf.Query{
|
||||
{
|
||||
Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: dashboardResponse{
|
||||
Dashboard: chronograf.Dashboard{
|
||||
Cells: []chronograf.DashboardCell{
|
||||
{
|
||||
W: 4,
|
||||
H: 4,
|
||||
Queries: []chronograf.Query{
|
||||
{
|
||||
Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
W: 4,
|
||||
H: 4,
|
||||
Queries: []chronograf.Query{
|
||||
{
|
||||
Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Links: dashboardLinks{
|
||||
Self: "/chronograf/v1/dashboards/0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := newDashboardResponse(tt.d); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("%q. newDashboardResponse() = %v, want %v", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
279
server/github.go
279
server/github.go
|
@ -1,279 +0,0 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/github"
|
||||
"github.com/influxdata/chronograf"
|
||||
"golang.org/x/oauth2"
|
||||
ogh "golang.org/x/oauth2/github"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultCookieName is the name of the stored cookie
|
||||
DefaultCookieName = "session"
|
||||
// DefaultCookieDuration is the length of time the cookie is valid
|
||||
DefaultCookieDuration = time.Hour * 24 * 30
|
||||
)
|
||||
|
||||
// Cookie represents the location and expiration time of new cookies.
|
||||
type Cookie struct {
|
||||
Name string
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// NewCookie creates a Cookie with DefaultCookieName and DefaultCookieDuration
|
||||
func NewCookie() Cookie {
|
||||
return Cookie{
|
||||
Name: DefaultCookieName,
|
||||
Duration: DefaultCookieDuration,
|
||||
}
|
||||
}
|
||||
|
||||
// Github provides OAuth Login and Callback server. Callback will set
|
||||
// an authentication cookie. This cookie's value is a JWT containing
|
||||
// the user's primary Github email address.
|
||||
type Github struct {
|
||||
Cookie Cookie
|
||||
Authenticator chronograf.Authenticator
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Scopes []string
|
||||
SuccessURL string // SuccessURL is redirect location after successful authorization
|
||||
FailureURL string // FailureURL is redirect location after authorization failure
|
||||
Orgs []string // Optional github organization checking
|
||||
Now func() time.Time
|
||||
Logger chronograf.Logger
|
||||
}
|
||||
|
||||
// NewGithub constructs a Github with default cookie behavior and scopes.
|
||||
func NewGithub(clientID, clientSecret, successURL, failureURL string, orgs []string, auth chronograf.Authenticator, log chronograf.Logger) Github {
|
||||
scopes := []string{"user:email"}
|
||||
if len(orgs) > 0 {
|
||||
scopes = append(scopes, "read:org")
|
||||
}
|
||||
return Github{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
Cookie: NewCookie(),
|
||||
Scopes: scopes,
|
||||
Orgs: orgs,
|
||||
SuccessURL: successURL,
|
||||
FailureURL: failureURL,
|
||||
Authenticator: auth,
|
||||
Now: time.Now,
|
||||
Logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Github) config() *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: g.ClientID,
|
||||
ClientSecret: g.ClientSecret,
|
||||
Scopes: g.Scopes,
|
||||
Endpoint: ogh.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// Login returns a handler that redirects to Github's OAuth login.
|
||||
// Uses JWT with a random string as the state validation method.
|
||||
// JWTs are used because they can be validated without storing
|
||||
// state.
|
||||
func (g *Github) Login() http.HandlerFunc {
|
||||
conf := g.config()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// We are creating a token with an encoded random string to prevent CSRF attacks
|
||||
// This token will be validated during the OAuth callback.
|
||||
// We'll give our users 10 minutes from this point to type in their github password.
|
||||
// If the callback is not received within 10 minutes, then authorization will fail.
|
||||
csrf := randomString(32) // 32 is not important... just long
|
||||
state, err := g.Authenticator.Token(r.Context(), chronograf.Principal(csrf), 10*time.Minute)
|
||||
// This is likely an internal server error
|
||||
if err != nil {
|
||||
g.Logger.
|
||||
WithField("component", "auth").
|
||||
WithField("remote_addr", r.RemoteAddr).
|
||||
WithField("method", r.Method).
|
||||
WithField("url", r.URL).
|
||||
Error("Internal authentication error: ", err.Error())
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
url := conf.AuthCodeURL(state, oauth2.AccessTypeOnline)
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
// Logout will expire our authentication cookie and redirect to the SuccessURL
|
||||
func (g *Github) Logout() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
deleteCookie := http.Cookie{
|
||||
Name: g.Cookie.Name,
|
||||
Value: "none",
|
||||
Expires: g.Now().UTC().Add(-1 * time.Hour),
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
}
|
||||
http.SetCookie(w, &deleteCookie)
|
||||
http.Redirect(w, r, g.SuccessURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
// Callback used by github callback after authorization is granted. If
|
||||
// granted, Callback will set a cookie with a month-long expiration. The
|
||||
// value of the cookie is a JWT because the JWT can be validated without
|
||||
// the need for saving state. The JWT contains the Github user's primary
|
||||
// email address.
|
||||
func (g *Github) Callback() http.HandlerFunc {
|
||||
conf := g.config()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log := g.Logger.
|
||||
WithField("component", "auth").
|
||||
WithField("remote_addr", r.RemoteAddr).
|
||||
WithField("method", r.Method).
|
||||
WithField("url", r.URL)
|
||||
|
||||
state := r.FormValue("state")
|
||||
// Check if the OAuth state token is valid to prevent CSRF
|
||||
_, err := g.Authenticator.Authenticate(r.Context(), state)
|
||||
if err != nil {
|
||||
log.Error("Invalid OAuth state received: ", err.Error())
|
||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.FormValue("code")
|
||||
token, err := conf.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
log.Error("Unable to exchange code for token ", err.Error())
|
||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
oauthClient := conf.Client(r.Context(), token)
|
||||
client := github.NewClient(oauthClient)
|
||||
// If we need to restrict to a set of organizations, we first get the org
|
||||
// and filter.
|
||||
if len(g.Orgs) > 0 {
|
||||
orgs, err := getOrganizations(client, log)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
// Not a member, so, deny permission
|
||||
if ok := isMember(g.Orgs, orgs); !ok {
|
||||
log.Error("Not a member of required github organization")
|
||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
email, err := getPrimaryEmail(client, log)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// We create an auth token that will be used by all other endpoints to validate the principal has a claim
|
||||
authToken, err := g.Authenticator.Token(r.Context(), chronograf.Principal(email), g.Cookie.Duration)
|
||||
if err != nil {
|
||||
log.Error("Unable to create cookie auth token ", err.Error())
|
||||
http.Redirect(w, r, g.FailureURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
expireCookie := time.Now().UTC().Add(g.Cookie.Duration)
|
||||
cookie := http.Cookie{
|
||||
Name: g.Cookie.Name,
|
||||
Value: authToken,
|
||||
Expires: expireCookie,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
}
|
||||
log.Info("User ", email, " is authenticated")
|
||||
http.SetCookie(w, &cookie)
|
||||
http.Redirect(w, r, g.SuccessURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
func randomString(length int) string {
|
||||
k := make([]byte, length)
|
||||
if _, err := io.ReadFull(rand.Reader, k); err != nil {
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k)
|
||||
}
|
||||
|
||||
func logResponseError(log chronograf.Logger, resp *github.Response, err error) {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
log.Error("OAuth access to email address forbidden ", err.Error())
|
||||
default:
|
||||
log.Error("Unable to retrieve Github email ", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// isMember makes sure that the user is in one of the required organizations
|
||||
func isMember(requiredOrgs []string, userOrgs []*github.Organization) bool {
|
||||
for _, requiredOrg := range requiredOrgs {
|
||||
for _, userOrg := range userOrgs {
|
||||
if userOrg.Login != nil && *userOrg.Login == requiredOrg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getOrganizations gets all organization for the currently authenticated user
|
||||
func getOrganizations(client *github.Client, log chronograf.Logger) ([]*github.Organization, error) {
|
||||
// Get all pages of results
|
||||
var allOrgs []*github.Organization
|
||||
for {
|
||||
opt := &github.ListOptions{
|
||||
PerPage: 10,
|
||||
}
|
||||
// Get the organizations for the current authenticated user.
|
||||
orgs, resp, err := client.Organizations.List("", opt)
|
||||
if err != nil {
|
||||
logResponseError(log, resp, err)
|
||||
return nil, err
|
||||
}
|
||||
allOrgs = append(allOrgs, orgs...)
|
||||
if resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
opt.Page = resp.NextPage
|
||||
}
|
||||
return allOrgs, nil
|
||||
}
|
||||
|
||||
// getPrimaryEmail gets the primary email account for the authenticated user.
|
||||
func getPrimaryEmail(client *github.Client, log chronograf.Logger) (string, error) {
|
||||
emails, resp, err := client.Users.ListEmails(nil)
|
||||
if err != nil {
|
||||
logResponseError(log, resp, err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
email, err := primaryEmail(emails)
|
||||
if err != nil {
|
||||
log.Error("Unable to retrieve primary Github email ", err.Error())
|
||||
return "", err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func primaryEmail(emails []*github.UserEmail) (string, error) {
|
||||
for _, m := range emails {
|
||||
if m != nil && m.Primary != nil && m.Verified != nil && m.Email != nil {
|
||||
return *m.Email, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("No primary email address")
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package server
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Logout chooses the correct provider logout route and redirects to it
|
||||
func Logout(nextURL string, routes AuthRoutes) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
principal, err := getPrincipal(ctx)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, nextURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
route, ok := routes.Lookup(principal.Issuer)
|
||||
if !ok {
|
||||
http.Redirect(w, r, nextURL, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, route.Logout, http.StatusTemporaryRedirect)
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/NYTimes/gziphandler"
|
||||
"github.com/bouk/httprouter"
|
||||
"github.com/influxdata/chronograf" // When julienschmidt/httprouter v2 w/ context is out, switch
|
||||
"github.com/influxdata/chronograf/jwt"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -20,13 +20,13 @@ const (
|
|||
|
||||
// MuxOpts are the options for the router. Mostly related to auth.
|
||||
type MuxOpts struct {
|
||||
Logger chronograf.Logger
|
||||
Develop bool // Develop loads assets from filesystem instead of bindata
|
||||
UseAuth bool // UseAuth turns on Github OAuth and JWT
|
||||
TokenSecret string // TokenSecret is the JWT secret
|
||||
GithubClientID string // GithubClientID is the GH OAuth id
|
||||
GithubClientSecret string // GithubClientSecret is the GH OAuth secret
|
||||
GithubOrgs []string // GithubOrgs is the list of organizations a user my be a member of
|
||||
Logger chronograf.Logger
|
||||
Develop bool // Develop loads assets from filesystem instead of bindata
|
||||
Basepath string // URL path prefix under which all chronograf routes will be mounted
|
||||
UseAuth bool // UseAuth turns on Github OAuth and JWT
|
||||
TokenSecret string
|
||||
|
||||
ProviderFuncs []func(func(oauth2.Provider, oauth2.Mux))
|
||||
}
|
||||
|
||||
// NewMux attaches all the route handlers; handler returned servers chronograf.
|
||||
|
@ -55,9 +55,6 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
|
|||
router.GET("/docs", Redoc("/swagger.json"))
|
||||
|
||||
/* API */
|
||||
// Root Routes returns all top-level routes in the API
|
||||
router.GET("/chronograf/v1/", AllRoutes(opts.Logger))
|
||||
|
||||
// Sources
|
||||
router.GET("/chronograf/v1/sources", service.Sources)
|
||||
router.POST("/chronograf/v1/sources", service.NewSource)
|
||||
|
@ -131,47 +128,65 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
|
|||
|
||||
router.GET("/chronograf/v1/dashboards/:id", service.DashboardID)
|
||||
router.DELETE("/chronograf/v1/dashboards/:id", service.RemoveDashboard)
|
||||
router.PUT("/chronograf/v1/dashboards/:id", service.UpdateDashboard)
|
||||
router.PUT("/chronograf/v1/dashboards/:id", service.ReplaceDashboard)
|
||||
router.PATCH("/chronograf/v1/dashboards/:id", service.UpdateDashboard)
|
||||
|
||||
var authRoutes AuthRoutes
|
||||
|
||||
var out http.Handler
|
||||
/* Authentication */
|
||||
if opts.UseAuth {
|
||||
auth := AuthAPI(opts, router)
|
||||
return Logger(opts.Logger, auth)
|
||||
// Encapsulate the router with OAuth2
|
||||
var auth http.Handler
|
||||
auth, authRoutes = AuthAPI(opts, router)
|
||||
|
||||
// Create middleware to redirect to the appropriate provider logout
|
||||
targetURL := "/"
|
||||
router.GET("/oauth/logout", Logout(targetURL, authRoutes))
|
||||
|
||||
out = Logger(opts.Logger, auth)
|
||||
} else {
|
||||
out = Logger(opts.Logger, router)
|
||||
}
|
||||
|
||||
logged := Logger(opts.Logger, router)
|
||||
return logged
|
||||
router.GET("/chronograf/v1/", AllRoutes(authRoutes, opts.Logger))
|
||||
router.GET("/chronograf/v1", AllRoutes(authRoutes, opts.Logger))
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// AuthAPI adds the OAuth routes if auth is enabled.
|
||||
func AuthAPI(opts MuxOpts, router *httprouter.Router) http.Handler {
|
||||
auth := jwt.NewJWT(opts.TokenSecret)
|
||||
// TODO: this function is not great. Would be good if providers added their routes.
|
||||
func AuthAPI(opts MuxOpts, router *httprouter.Router) (http.Handler, AuthRoutes) {
|
||||
auth := oauth2.NewJWT(opts.TokenSecret)
|
||||
routes := AuthRoutes{}
|
||||
for _, pf := range opts.ProviderFuncs {
|
||||
pf(func(p oauth2.Provider, m oauth2.Mux) {
|
||||
loginPath := fmt.Sprintf("%s/oauth/%s/login", opts.Basepath, strings.ToLower(p.Name()))
|
||||
logoutPath := fmt.Sprintf("%s/oauth/%s/logout", opts.Basepath, strings.ToLower(p.Name()))
|
||||
callbackPath := fmt.Sprintf("%s/oauth/%s/callback", opts.Basepath, strings.ToLower(p.Name()))
|
||||
router.Handler("GET", loginPath, m.Login())
|
||||
router.Handler("GET", logoutPath, m.Logout())
|
||||
router.Handler("GET", callbackPath, m.Callback())
|
||||
routes = append(routes, AuthRoute{
|
||||
Name: p.Name(),
|
||||
Label: strings.Title(p.Name()),
|
||||
Login: loginPath,
|
||||
Logout: logoutPath,
|
||||
Callback: callbackPath,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
successURL := "/"
|
||||
failureURL := "/login"
|
||||
gh := NewGithub(
|
||||
opts.GithubClientID,
|
||||
opts.GithubClientSecret,
|
||||
successURL,
|
||||
failureURL,
|
||||
opts.GithubOrgs,
|
||||
&auth,
|
||||
opts.Logger,
|
||||
)
|
||||
|
||||
router.GET("/oauth/github", gh.Login())
|
||||
router.GET("/oauth/logout", gh.Logout())
|
||||
router.GET("/oauth/github/callback", gh.Callback())
|
||||
|
||||
tokenMiddleware := AuthorizedToken(&auth, &CookieExtractor{Name: "session"}, opts.Logger, router)
|
||||
tokenMiddleware := oauth2.AuthorizedToken(&auth, &oauth2.CookieExtractor{Name: "session"}, opts.Logger, router)
|
||||
// Wrap the API with token validation middleware.
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/chronograf/v1/") {
|
||||
if strings.HasPrefix(r.URL.Path, "/chronograf/v1/") || r.URL.Path == "/oauth/logout" {
|
||||
tokenMiddleware.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
router.ServeHTTP(w, r)
|
||||
})
|
||||
}), routes
|
||||
}
|
||||
|
||||
func encodeJSON(w http.ResponseWriter, status int, v interface{}, logger chronograf.Logger) {
|
||||
|
|
|
@ -6,22 +6,50 @@ import (
|
|||
"github.com/influxdata/chronograf"
|
||||
)
|
||||
|
||||
// AuthRoute are the routes for each type of OAuth2 provider
|
||||
type AuthRoute struct {
|
||||
Name string `json:"name"` // Name uniquely identifies the provider
|
||||
Label string `json:"label"` // Label is a user-facing string to present in the UI
|
||||
Login string `json:"login"` // Login is the route to the login redirect path
|
||||
Logout string `json:"logout"` // Logout is the route to the logout redirect path
|
||||
Callback string `json:"callback"` // Callback is the route the provider calls to exchange the code/state
|
||||
}
|
||||
|
||||
// AuthRoutes contains all OAuth2 provider routes.
|
||||
type AuthRoutes []AuthRoute
|
||||
|
||||
// Lookup searches all the routes for a specific provider
|
||||
func (r *AuthRoutes) Lookup(provider string) (AuthRoute, bool) {
|
||||
for _, route := range *r {
|
||||
if route.Name == provider {
|
||||
return route, true
|
||||
}
|
||||
}
|
||||
return AuthRoute{}, false
|
||||
}
|
||||
|
||||
type getRoutesResponse struct {
|
||||
Layouts string `json:"layouts"` // Location of the layouts endpoint
|
||||
Mappings string `json:"mappings"` // Location of the application mappings endpoint
|
||||
Sources string `json:"sources"` // Location of the sources endpoint
|
||||
Me string `json:"me"` // Location of the me endpoint
|
||||
Dashboards string `json:"dashboards"` // Location of the dashboards endpoint
|
||||
Layouts string `json:"layouts"` // Location of the layouts endpoint
|
||||
Mappings string `json:"mappings"` // Location of the application mappings endpoint
|
||||
Sources string `json:"sources"` // Location of the sources endpoint
|
||||
Me string `json:"me"` // Location of the me endpoint
|
||||
Dashboards string `json:"dashboards"` // Location of the dashboards endpoint
|
||||
Auth []AuthRoute `json:"auth"` // Location of all auth routes.
|
||||
}
|
||||
|
||||
// AllRoutes returns all top level routes within chronograf
|
||||
func AllRoutes(logger chronograf.Logger) http.HandlerFunc {
|
||||
func AllRoutes(authRoutes []AuthRoute, logger chronograf.Logger) http.HandlerFunc {
|
||||
routes := getRoutesResponse{
|
||||
Sources: "/chronograf/v1/sources",
|
||||
Layouts: "/chronograf/v1/layouts",
|
||||
Me: "/chronograf/v1/me",
|
||||
Mappings: "/chronograf/v1/mappings",
|
||||
Dashboards: "/chronograf/v1/dashboards",
|
||||
Auth: make([]AuthRoute, len(authRoutes)),
|
||||
}
|
||||
|
||||
for i, route := range authRoutes {
|
||||
routes.Auth[i] = route
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
115
server/server.go
115
server/server.go
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/influxdata/chronograf/canned"
|
||||
"github.com/influxdata/chronograf/layouts"
|
||||
clog "github.com/influxdata/chronograf/log"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
"github.com/influxdata/chronograf/uuid"
|
||||
client "github.com/influxdata/usage-client/v1"
|
||||
flags "github.com/jessevdk/go-flags"
|
||||
|
@ -37,20 +38,88 @@ type Server struct {
|
|||
Cert flags.Filename `long:"cert" description:"Path to PEM encoded public key certificate. " env:"TLS_CERTIFICATE"`
|
||||
Key flags.Filename `long:"key" description:"Path to private key associated with given certificate. " env:"TLS_PRIVATE_KEY"`
|
||||
|
||||
Develop bool `short:"d" long:"develop" description:"Run server in develop mode."`
|
||||
BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (/var/lib/chronograf/chronograf-v1.db)" env:"BOLT_PATH" default:"chronograf-v1.db"`
|
||||
CannedPath string `short:"c" long:"canned-path" description:"Path to directory of pre-canned application layouts (/usr/share/chronograf/canned)" env:"CANNED_PATH" default:"canned"`
|
||||
TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"`
|
||||
Develop bool `short:"d" long:"develop" description:"Run server in develop mode."`
|
||||
BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (/var/lib/chronograf/chronograf-v1.db)" env:"BOLT_PATH" default:"chronograf-v1.db"`
|
||||
CannedPath string `short:"c" long:"canned-path" description:"Path to directory of pre-canned application layouts (/usr/share/chronograf/canned)" env:"CANNED_PATH" default:"canned"`
|
||||
TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"`
|
||||
|
||||
GithubClientID string `short:"i" long:"github-client-id" description:"Github Client ID for OAuth 2 support" env:"GH_CLIENT_ID"`
|
||||
GithubClientSecret string `short:"s" long:"github-client-secret" description:"Github Client Secret for OAuth 2 support" env:"GH_CLIENT_SECRET"`
|
||||
GithubOrgs []string `short:"o" long:"github-organization" description:"Github organization user is required to have active membership" env:"GH_ORGS" env-delim:","`
|
||||
ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"`
|
||||
LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"fatal" choice:"panic" default:"info" description:"Set the logging level" env:"LOG_LEVEL"`
|
||||
Basepath string `short:"p" long:"basepath" description:"A URL path prefix under which all chronograf routes will be mounted" env:"BASE_PATH"`
|
||||
ShowVersion bool `short:"v" long:"version" description:"Show Chronograf version info"`
|
||||
BuildInfo BuildInfo
|
||||
Listener net.Listener
|
||||
handler http.Handler
|
||||
|
||||
GoogleClientID string `long:"google-client-id" description:"Google Client ID for OAuth 2 support" env:"GOOGLE_CLIENT_ID"`
|
||||
GoogleClientSecret string `long:"google-client-secret" description:"Google Client Secret for OAuth 2 support" env:"GOGGLE_CLIENT_SECRET"`
|
||||
GoogleDomains []string `long:"google-domains" description:"Google email domain user is required to have active membership" env:"GOOGLE_DOMAINS" env-delim:","`
|
||||
PublicURL string `long:"public-url" description:"Full public URL used to access Chronograf from a web browser. Used for Google OAuth2 authentication. (http://localhost:8888)" env:"PUBLIC_URL"`
|
||||
|
||||
HerokuClientID string `long:"heroku-client-id" description:"Heroku Client ID for OAuth 2 support" env:"HEROKU_CLIENT_ID"`
|
||||
HerokuSecret string `long:"heroku-secret" description:"Heroku Secret for OAuth 2 support" env:"HEROKU_SECRET"`
|
||||
HerokuOrganizations []string `long:"heroku-organization" description:"Heroku Organization Memberships a user is required to have for access to Chronograf (comma separated)" env:"HEROKU_ORGS" env-delim:","`
|
||||
|
||||
ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"`
|
||||
LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"fatal" choice:"panic" default:"info" description:"Set the logging level" env:"LOG_LEVEL"`
|
||||
Basepath string `short:"p" long:"basepath" description:"A URL path prefix under which all chronograf routes will be mounted" env:"BASE_PATH"`
|
||||
ShowVersion bool `short:"v" long:"version" description:"Show Chronograf version info"`
|
||||
BuildInfo BuildInfo
|
||||
Listener net.Listener
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func provide(p oauth2.Provider, m oauth2.Mux, ok func() bool) func(func(oauth2.Provider, oauth2.Mux)) {
|
||||
return func(configure func(oauth2.Provider, oauth2.Mux)) {
|
||||
if ok() {
|
||||
configure(p, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) UseGithub() bool {
|
||||
return s.TokenSecret != "" && s.GithubClientID != "" && s.GithubClientSecret != ""
|
||||
}
|
||||
|
||||
func (s *Server) UseGoogle() bool {
|
||||
return s.TokenSecret != "" && s.GoogleClientID != "" && s.GoogleClientSecret != "" && s.PublicURL != ""
|
||||
}
|
||||
|
||||
func (s *Server) UseHeroku() bool {
|
||||
return s.TokenSecret != "" && s.HerokuClientID != "" && s.HerokuSecret != ""
|
||||
}
|
||||
|
||||
func (s *Server) githubOAuth(logger chronograf.Logger, auth oauth2.Authenticator) (oauth2.Provider, oauth2.Mux, func() bool) {
|
||||
gh := oauth2.Github{
|
||||
ClientID: s.GithubClientID,
|
||||
ClientSecret: s.GithubClientSecret,
|
||||
Orgs: s.GithubOrgs,
|
||||
Logger: logger,
|
||||
}
|
||||
ghMux := oauth2.NewCookieMux(&gh, auth, logger)
|
||||
return &gh, ghMux, s.UseGithub
|
||||
}
|
||||
|
||||
func (s *Server) googleOAuth(logger chronograf.Logger, auth oauth2.Authenticator) (oauth2.Provider, oauth2.Mux, func() bool) {
|
||||
redirectURL := s.PublicURL + s.Basepath + "/oauth/google/callback"
|
||||
google := oauth2.Google{
|
||||
ClientID: s.GoogleClientID,
|
||||
ClientSecret: s.GoogleClientSecret,
|
||||
Domains: s.GoogleDomains,
|
||||
RedirectURL: redirectURL,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
goMux := oauth2.NewCookieMux(&google, auth, logger)
|
||||
return &google, goMux, s.UseGoogle
|
||||
}
|
||||
|
||||
func (s *Server) herokuOAuth(logger chronograf.Logger, auth oauth2.Authenticator) (oauth2.Provider, oauth2.Mux, func() bool) {
|
||||
heroku := oauth2.Heroku{
|
||||
ClientID: s.HerokuClientID,
|
||||
ClientSecret: s.HerokuSecret,
|
||||
Organizations: s.HerokuOrganizations,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
hMux := oauth2.NewCookieMux(&heroku, auth, logger)
|
||||
return &heroku, hMux, s.UseHeroku
|
||||
}
|
||||
|
||||
// BuildInfo is sent to the usage client to track versions and commits
|
||||
|
@ -60,7 +129,10 @@ type BuildInfo struct {
|
|||
}
|
||||
|
||||
func (s *Server) useAuth() bool {
|
||||
return s.TokenSecret != "" && s.GithubClientID != "" && s.GithubClientSecret != ""
|
||||
gh := s.TokenSecret != "" && s.GithubClientID != "" && s.GithubClientSecret != ""
|
||||
google := s.TokenSecret != "" && s.GoogleClientID != "" && s.GoogleClientSecret != "" && s.PublicURL != ""
|
||||
heroku := s.TokenSecret != "" && s.HerokuClientID != "" && s.HerokuSecret != ""
|
||||
return gh || google || heroku
|
||||
}
|
||||
|
||||
func (s *Server) useTLS() bool {
|
||||
|
@ -104,14 +176,19 @@ func (s *Server) Serve() error {
|
|||
service := openService(s.BoltPath, s.CannedPath, logger, s.useAuth())
|
||||
basepath = s.Basepath
|
||||
|
||||
providerFuncs := []func(func(oauth2.Provider, oauth2.Mux)){}
|
||||
|
||||
auth := oauth2.NewJWT(s.TokenSecret)
|
||||
providerFuncs = append(providerFuncs, provide(s.githubOAuth(logger, &auth)))
|
||||
providerFuncs = append(providerFuncs, provide(s.googleOAuth(logger, &auth)))
|
||||
providerFuncs = append(providerFuncs, provide(s.herokuOAuth(logger, &auth)))
|
||||
|
||||
s.handler = NewMux(MuxOpts{
|
||||
Develop: s.Develop,
|
||||
TokenSecret: s.TokenSecret,
|
||||
GithubClientID: s.GithubClientID,
|
||||
GithubClientSecret: s.GithubClientSecret,
|
||||
GithubOrgs: s.GithubOrgs,
|
||||
Logger: logger,
|
||||
UseAuth: s.useAuth(),
|
||||
Develop: s.Develop,
|
||||
TokenSecret: s.TokenSecret,
|
||||
Logger: logger,
|
||||
UseAuth: s.useAuth(),
|
||||
ProviderFuncs: providerFuncs,
|
||||
}, service)
|
||||
|
||||
// Add chronograf's version header to all requests
|
||||
|
|
|
@ -1863,6 +1863,51 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"layouts"
|
||||
],
|
||||
"summary": "Update dashboard information.",
|
||||
"description": "Update either the dashboard name or the dashboard cells",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"type": "integer",
|
||||
"description": "ID of a dashboard",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "config",
|
||||
"in": "body",
|
||||
"description": "dashboard configuration update parameters. Must be either name or cells",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Dashboard"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Dashboard has been updated and the new dashboard is returned.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Dashboard"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Happens when trying to access a non-existent dashboard.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Error"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "A processing or an unexpected error.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -3013,12 +3058,16 @@
|
|||
"w": {
|
||||
"description": "Width of Cell in the Dashboard",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
"format": "int32",
|
||||
"minimum": 1,
|
||||
"default": 4
|
||||
},
|
||||
"h": {
|
||||
"description": "Height of Cell in the Dashboard",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
"format": "int32",
|
||||
"minimum": 1,
|
||||
"default": 4
|
||||
},
|
||||
"queries": {
|
||||
"description": "Time-series data queries for Cell.",
|
||||
|
@ -3033,7 +3082,9 @@
|
|||
"enum": [
|
||||
"single-stat",
|
||||
"line",
|
||||
"line-plus-single-stat"
|
||||
"line-plus-single-stat",
|
||||
"line-stacked",
|
||||
"line-stepplot"
|
||||
],
|
||||
"default": "line"
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"golang.org/x/net/context"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
type userLinks struct {
|
||||
|
@ -19,42 +20,62 @@ type userResponse struct {
|
|||
Links userLinks `json:"links"`
|
||||
}
|
||||
|
||||
// If new user response is nil, return an empty userResponse because it
|
||||
// indicates authentication is not needed
|
||||
func newUserResponse(usr *chronograf.User) userResponse {
|
||||
base := "/chronograf/v1/users"
|
||||
// TODO: Change to usrl.PathEscape for go 1.8
|
||||
u := &url.URL{Path: usr.Name}
|
||||
encodedUser := u.String()
|
||||
name := "me"
|
||||
if usr != nil {
|
||||
// TODO: Change to usrl.PathEscape for go 1.8
|
||||
u := &url.URL{Path: usr.Name}
|
||||
name = u.String()
|
||||
}
|
||||
|
||||
return userResponse{
|
||||
User: usr,
|
||||
Links: userLinks{
|
||||
Self: fmt.Sprintf("%s/%s", base, encodedUser),
|
||||
Self: fmt.Sprintf("%s/%s", base, name),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getPrincipal(ctx context.Context) (string, error) {
|
||||
principal := ctx.Value(chronograf.PrincipalKey).(chronograf.Principal)
|
||||
if principal == "" {
|
||||
func getEmail(ctx context.Context) (string, error) {
|
||||
principal, err := getPrincipal(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if principal.Subject == "" {
|
||||
return "", fmt.Errorf("Token not found")
|
||||
}
|
||||
return string(principal), nil
|
||||
return principal.Subject, nil
|
||||
}
|
||||
|
||||
func getPrincipal(ctx context.Context) (oauth2.Principal, error) {
|
||||
principal, ok := ctx.Value(oauth2.PrincipalKey).(oauth2.Principal)
|
||||
if !ok {
|
||||
return oauth2.Principal{}, fmt.Errorf("Token not found")
|
||||
}
|
||||
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
// Me does a findOrCreate based on the email in the context
|
||||
func (h *Service) Me(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !h.UseAuth {
|
||||
// Using status code to signal no need for authentication
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
// If there's no authentication, return an empty user
|
||||
res := newUserResponse(nil)
|
||||
encodeJSON(w, http.StatusOK, res, h.Logger)
|
||||
return
|
||||
}
|
||||
principal, err := getPrincipal(ctx)
|
||||
|
||||
email, err := getEmail(ctx)
|
||||
if err != nil {
|
||||
invalidData(w, err, h.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
usr, err := h.UsersStore.Get(ctx, principal)
|
||||
usr, err := h.UsersStore.Get(ctx, email)
|
||||
if err == nil {
|
||||
res := newUserResponse(usr)
|
||||
encodeJSON(w, http.StatusOK, res, h.Logger)
|
||||
|
@ -63,7 +84,7 @@ func (h *Service) Me(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// Because we didnt find a user, making a new one
|
||||
user := &chronograf.User{
|
||||
Name: principal,
|
||||
Name: email,
|
||||
}
|
||||
|
||||
newUser, err := h.UsersStore.Add(ctx, user)
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/influxdata/chronograf"
|
||||
"github.com/influxdata/chronograf/log"
|
||||
"github.com/influxdata/chronograf/mocks"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
type MockUsers struct{}
|
||||
|
@ -29,7 +30,7 @@ func TestService_Me(t *testing.T) {
|
|||
name string
|
||||
fields fields
|
||||
args args
|
||||
principal chronograf.Principal
|
||||
principal oauth2.Principal
|
||||
wantStatus int
|
||||
wantContentType string
|
||||
wantBody string
|
||||
|
@ -51,7 +52,9 @@ func TestService_Me(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
principal: "me",
|
||||
principal: oauth2.Principal{
|
||||
Subject: "me",
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
wantContentType: "application/json",
|
||||
wantBody: `{"name":"me","password":"hunter2","links":{"self":"/chronograf/v1/users/me"}}
|
||||
|
@ -74,7 +77,9 @@ func TestService_Me(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
principal: "secret",
|
||||
principal: oauth2.Principal{
|
||||
Subject: "secret",
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
wantContentType: "application/json",
|
||||
wantBody: `{"name":"secret","password":"","links":{"self":"/chronograf/v1/users/secret"}}
|
||||
|
@ -98,13 +103,15 @@ func TestService_Me(t *testing.T) {
|
|||
},
|
||||
Logger: log.New(log.DebugLevel),
|
||||
},
|
||||
principal: "secret",
|
||||
principal: oauth2.Principal{
|
||||
Subject: "secret",
|
||||
},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantContentType: "application/json",
|
||||
wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`,
|
||||
},
|
||||
{
|
||||
name: "No Auth Teapot",
|
||||
name: "No Auth",
|
||||
args: args{
|
||||
w: httptest.NewRecorder(),
|
||||
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
|
||||
|
@ -113,7 +120,10 @@ func TestService_Me(t *testing.T) {
|
|||
UseAuth: false,
|
||||
Logger: log.New(log.DebugLevel),
|
||||
},
|
||||
wantStatus: http.StatusTeapot,
|
||||
wantStatus: http.StatusOK,
|
||||
wantContentType: "application/json",
|
||||
wantBody: `{"links":{"self":"/chronograf/v1/users/me"}}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "Empty Principal",
|
||||
|
@ -126,11 +136,13 @@ func TestService_Me(t *testing.T) {
|
|||
Logger: log.New(log.DebugLevel),
|
||||
},
|
||||
wantStatus: http.StatusUnprocessableEntity,
|
||||
principal: "",
|
||||
principal: oauth2.Principal{
|
||||
Subject: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt.args.r = tt.args.r.WithContext(context.WithValue(context.Background(), chronograf.PrincipalKey, tt.principal))
|
||||
tt.args.r = tt.args.r.WithContext(context.WithValue(context.Background(), oauth2.PrincipalKey, tt.principal))
|
||||
h := &Service{
|
||||
UsersStore: tt.fields.UsersStore,
|
||||
Logger: tt.fields.Logger,
|
||||
|
|
|
@ -182,7 +182,7 @@
|
|||
'quote-props': [2, 'as-needed', {keywords: true, numbers: false }],
|
||||
'require-jsdoc': 0,
|
||||
'semi-spacing': [2, {before: false, after: true}],
|
||||
'semi': [2, 'always'],
|
||||
// 'semi': [2, 'always'],
|
||||
'sort-vars': 0,
|
||||
'keyword-spacing': 'error',
|
||||
'space-before-blocks': [2, 'always'],
|
||||
|
@ -194,7 +194,7 @@
|
|||
'wrap-regex': 0,
|
||||
'arrow-body-style': 0,
|
||||
'arrow-spacing': [2, {before: true, after: true}],
|
||||
'no-confusing-arrow': 2,
|
||||
'no-confusing-arrow': 0,
|
||||
'no-class-assign': 2,
|
||||
'no-const-assign': 2,
|
||||
'no-dupe-class-members': 2,
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
import reducer from 'src/dashboards/reducers/ui'
|
||||
import timeRanges from 'hson!src/shared/data/timeRanges.hson';
|
||||
|
||||
import {
|
||||
loadDashboards,
|
||||
setDashboard,
|
||||
setTimeRange,
|
||||
setEditMode,
|
||||
} from 'src/dashboards/actions'
|
||||
|
||||
const noopAction = () => {
|
||||
return {type: 'NOOP'}
|
||||
}
|
||||
|
||||
let state = undefined
|
||||
const timeRange = timeRanges[1];
|
||||
const d1 = {id: 1, cells: [], name: "d1"}
|
||||
const d2 = {id: 2, cells: [], name: "d2"}
|
||||
const dashboards = [d1, d2]
|
||||
|
||||
describe('DataExplorer.Reducers.UI', () => {
|
||||
it('can load the dashboards', () => {
|
||||
const actual = reducer(state, loadDashboards(dashboards, d1.id))
|
||||
const expected = {
|
||||
dashboards,
|
||||
dashboard: d1,
|
||||
}
|
||||
|
||||
expect(actual.dashboards).to.deep.equal(expected.dashboards)
|
||||
expect(actual.dashboard).to.deep.equal(expected.dashboard)
|
||||
})
|
||||
|
||||
it('can set a dashboard', () => {
|
||||
const loadedState = reducer(state, loadDashboards(dashboards, d1.id))
|
||||
const actual = reducer(loadedState, setDashboard(d2.id))
|
||||
|
||||
expect(actual.dashboard).to.deep.equal(d2)
|
||||
})
|
||||
|
||||
it('can set the time range', () => {
|
||||
const expected = {upper: null, lower: 'now() - 1h'}
|
||||
const actual = reducer(state, setTimeRange(expected))
|
||||
|
||||
expect(actual.timeRange).to.deep.equal(expected)
|
||||
})
|
||||
|
||||
it('can set edit mode', () => {
|
||||
const isEditMode = true
|
||||
const actual = reducer(state, setEditMode(isEditMode))
|
||||
expect(actual.isEditMode).to.equal(isEditMode)
|
||||
})
|
||||
})
|
|
@ -8,22 +8,29 @@ import {
|
|||
dismissAllNotifications as dismissAllNotificationsAction,
|
||||
} from 'src/shared/actions/notifications';
|
||||
|
||||
const {
|
||||
node,
|
||||
shape,
|
||||
string,
|
||||
func,
|
||||
} = PropTypes
|
||||
|
||||
const App = React.createClass({
|
||||
propTypes: {
|
||||
children: PropTypes.node.isRequired,
|
||||
location: PropTypes.shape({
|
||||
pathname: PropTypes.string,
|
||||
children: node.isRequired,
|
||||
location: shape({
|
||||
pathname: string,
|
||||
}),
|
||||
params: PropTypes.shape({
|
||||
sourceID: PropTypes.string.isRequired,
|
||||
params: shape({
|
||||
sourceID: string.isRequired,
|
||||
}).isRequired,
|
||||
publishNotification: PropTypes.func.isRequired,
|
||||
dismissNotification: PropTypes.func.isRequired,
|
||||
dismissAllNotifications: PropTypes.func.isRequired,
|
||||
notifications: PropTypes.shape({
|
||||
success: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
warning: PropTypes.string,
|
||||
publishNotification: func.isRequired,
|
||||
dismissNotification: func.isRequired,
|
||||
dismissAllNotifications: func.isRequired,
|
||||
notifications: shape({
|
||||
success: string,
|
||||
error: string,
|
||||
warning: string,
|
||||
}),
|
||||
},
|
||||
|
||||
|
@ -46,11 +53,15 @@ const App = React.createClass({
|
|||
},
|
||||
|
||||
render() {
|
||||
const {sourceID} = this.props.params;
|
||||
const {params: {sourceID}} = this.props;
|
||||
|
||||
return (
|
||||
<div className="chronograf-root">
|
||||
<SideNavContainer sourceID={sourceID} addFlashMessage={this.handleNotification} currentLocation={this.props.location.pathname} />
|
||||
<SideNavContainer
|
||||
sourceID={sourceID}
|
||||
addFlashMessage={this.handleNotification}
|
||||
currentLocation={this.props.location.pathname}
|
||||
/>
|
||||
{this.renderNotifications()}
|
||||
{this.props.children && React.cloneElement(this.props.children, {
|
||||
addFlashMessage: this.handleNotification,
|
||||
|
|
|
@ -1,21 +1,33 @@
|
|||
import React from 'react';
|
||||
import {withRouter} from 'react-router';
|
||||
/* global VERSION */
|
||||
import React, {PropTypes} from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
|
||||
const Login = React.createClass({
|
||||
render() {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<div className="auth-logo"></div>
|
||||
<h1 className="auth-text-logo">Chronograf</h1>
|
||||
<p><strong>v1.1</strong> / Time-Series Data Visualization</p>
|
||||
<a className="btn btn-primary" href="/oauth/github"><span className="icon github"></span> Login with GitHub</a>
|
||||
</div>
|
||||
<p className="auth-credits">Made by <span className="icon cubo-uniform"></span>InfluxData</p>
|
||||
<div className="auth-image"></div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
const {array} = PropTypes
|
||||
|
||||
export default withRouter(Login);
|
||||
const Login = ({auth}) => (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<div className="auth-logo"></div>
|
||||
<h1 className="auth-text-logo">Chronograf</h1>
|
||||
<p><strong>{VERSION}</strong> / Time-Series Data Visualization</p>
|
||||
{auth.map(({name, login, label}) => (
|
||||
<a key={name} className="btn btn-primary" href={login}>
|
||||
<span className={`icon ${name}`}></span>
|
||||
Login with {label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="auth-credits">Made by <span className="icon cubo-uniform"></span>InfluxData</p>
|
||||
<div className="auth-image"></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Login.propTypes = {
|
||||
auth: array.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
auth: state.auth,
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps)(Login)
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
import {
|
||||
getDashboards as getDashboardsAJAX,
|
||||
updateDashboard as updateDashboardAJAX,
|
||||
} from 'src/dashboards/apis'
|
||||
|
||||
export function loadDashboards(dashboards, dashboardID) {
|
||||
return {
|
||||
type: 'LOAD_DASHBOARDS',
|
||||
payload: {
|
||||
dashboards,
|
||||
dashboardID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function setDashboard(dashboardID) {
|
||||
return {
|
||||
type: 'SET_DASHBOARD',
|
||||
payload: {
|
||||
dashboardID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function setTimeRange(timeRange) {
|
||||
return {
|
||||
type: 'SET_DASHBOARD_TIME_RANGE',
|
||||
payload: {
|
||||
timeRange,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function setEditMode(isEditMode) {
|
||||
return {
|
||||
type: 'SET_EDIT_MODE',
|
||||
payload: {
|
||||
isEditMode,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getDashboards(dashboardID) {
|
||||
return (dispatch) => {
|
||||
getDashboardsAJAX().then(({data: {dashboards}}) => {
|
||||
dispatch(loadDashboards(dashboards, dashboardID))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function putDashboard(dashboard) {
|
||||
return (dispatch) => {
|
||||
updateDashboardAJAX(dashboard).then(({data}) => {
|
||||
dispatch(updateDashboard(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDashboard(dashboard) {
|
||||
return {
|
||||
type: 'UPDATE_DASHBOARD',
|
||||
payload: {
|
||||
dashboard,
|
||||
},
|
||||
}
|
||||
}
|
|
@ -3,6 +3,14 @@ import AJAX from 'utils/ajax';
|
|||
export function getDashboards() {
|
||||
return AJAX({
|
||||
method: 'GET',
|
||||
url: `/chronograf/v1/dashboards`,
|
||||
resource: 'dashboards',
|
||||
});
|
||||
}
|
||||
|
||||
export function updateDashboard(dashboard) {
|
||||
return AJAX({
|
||||
method: 'PUT',
|
||||
url: dashboard.links.self,
|
||||
data: dashboard,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
import React, {PropTypes} from 'react'
|
||||
import classnames from 'classnames'
|
||||
|
||||
import LayoutRenderer from 'shared/components/LayoutRenderer'
|
||||
import Visualizations from 'src/dashboards/components/VisualizationSelector'
|
||||
|
||||
const Dashboard = ({
|
||||
dashboard,
|
||||
isEditMode,
|
||||
inPresentationMode,
|
||||
onPositionChange,
|
||||
source,
|
||||
timeRange,
|
||||
}) => {
|
||||
if (dashboard.id === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classnames({'page-contents': true, 'presentation-mode': inPresentationMode})}>
|
||||
<div className={classnames('container-fluid full-width dashboard', {'dashboard-edit': isEditMode})}>
|
||||
{isEditMode ? <Visualizations/> : null}
|
||||
{Dashboard.renderDashboard(dashboard, timeRange, source, onPositionChange)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Dashboard.renderDashboard = (dashboard, timeRange, source, onPositionChange) => {
|
||||
const autoRefreshMs = 15000
|
||||
const cells = dashboard.cells.map((cell, i) => {
|
||||
i = `${i}`
|
||||
const dashboardCell = {...cell, i}
|
||||
dashboardCell.queries.forEach((q) => {
|
||||
q.text = q.query;
|
||||
q.database = source.telegraf;
|
||||
});
|
||||
return dashboardCell;
|
||||
})
|
||||
|
||||
return (
|
||||
<LayoutRenderer
|
||||
timeRange={timeRange}
|
||||
cells={cells}
|
||||
autoRefreshMs={autoRefreshMs}
|
||||
source={source.links.proxy}
|
||||
onPositionChange={onPositionChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
bool,
|
||||
func,
|
||||
shape,
|
||||
string,
|
||||
} = PropTypes
|
||||
|
||||
Dashboard.propTypes = {
|
||||
dashboard: shape({}).isRequired,
|
||||
isEditMode: bool,
|
||||
inPresentationMode: bool,
|
||||
onPositionChange: func,
|
||||
source: shape({
|
||||
links: shape({
|
||||
proxy: string,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
timeRange: shape({}).isRequired,
|
||||
}
|
||||
|
||||
export default Dashboard
|
|
@ -0,0 +1,77 @@
|
|||
import React, {PropTypes} from 'react'
|
||||
import ReactTooltip from 'react-tooltip'
|
||||
import {Link} from 'react-router';
|
||||
|
||||
import TimeRangeDropdown from 'shared/components/TimeRangeDropdown'
|
||||
|
||||
const DashboardHeader = ({
|
||||
children,
|
||||
buttonText,
|
||||
dashboard,
|
||||
headerText,
|
||||
timeRange,
|
||||
isHidden,
|
||||
handleChooseTimeRange,
|
||||
handleClickPresentationButton,
|
||||
sourceID,
|
||||
}) => isHidden ? null : (
|
||||
<div className="page-header full-width">
|
||||
<div className="page-header__container">
|
||||
<div className="page-header__left">
|
||||
{buttonText &&
|
||||
<div className="dropdown page-header-dropdown">
|
||||
<button className="dropdown-toggle" type="button" data-toggle="dropdown">
|
||||
<span className="button-text">{buttonText}</span>
|
||||
<span className="caret"></span>
|
||||
</button>
|
||||
<ul className="dropdown-menu" aria-labelledby="dropdownMenu1">
|
||||
{children}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
{headerText &&
|
||||
<h1>Kubernetes Dashboard</h1>
|
||||
}
|
||||
</div>
|
||||
<div className="page-header__right">
|
||||
{sourceID ?
|
||||
<Link className="btn btn-info btn-sm" to={`/sources/${sourceID}/dashboards/${dashboard && dashboard.id}/edit`} >
|
||||
<span className="icon pencil" />
|
||||
Edit
|
||||
</Link> : null
|
||||
}
|
||||
<div className="btn btn-info btn-sm" data-for="graph-tips-tooltip" data-tip="<p><code>Click + Drag</code> Zoom in (X or Y)</p><p><code>Shift + Click</code> Pan Graph Window</p><p><code>Double Click</code> Reset Graph Window</p>">
|
||||
<span className="icon heart"></span>
|
||||
Graph Tips
|
||||
</div>
|
||||
<ReactTooltip id="graph-tips-tooltip" effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
|
||||
<TimeRangeDropdown onChooseTimeRange={handleChooseTimeRange} selected={timeRange.inputValue} />
|
||||
<div className="btn btn-info btn-sm" onClick={handleClickPresentationButton}>
|
||||
<span className="icon keynote" style={{margin: 0}}></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const {
|
||||
shape,
|
||||
array,
|
||||
string,
|
||||
func,
|
||||
bool,
|
||||
} = PropTypes
|
||||
|
||||
DashboardHeader.propTypes = {
|
||||
sourceID: string,
|
||||
children: array,
|
||||
buttonText: string,
|
||||
dashboard: shape({}),
|
||||
headerText: string,
|
||||
timeRange: shape({}).isRequired,
|
||||
isHidden: bool.isRequired,
|
||||
handleChooseTimeRange: func.isRequired,
|
||||
handleClickPresentationButton: func.isRequired,
|
||||
}
|
||||
|
||||
export default DashboardHeader
|
|
@ -0,0 +1,36 @@
|
|||
import React, {PropTypes} from 'react'
|
||||
|
||||
const DashboardEditHeader = ({
|
||||
dashboard,
|
||||
onSave,
|
||||
}) => (
|
||||
<div className="page-header full-width">
|
||||
<div className="page-header__container">
|
||||
<div className="page-header__left">
|
||||
<input
|
||||
className="chronograf-header__editing"
|
||||
autoFocus={true}
|
||||
defaultValue={dashboard && dashboard.name}
|
||||
placeholder="Dashboard name"
|
||||
/>
|
||||
</div>
|
||||
<div className="page-header__right">
|
||||
<div className="btn btn-sm btn-success" onClick={onSave}>
|
||||
Save
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const {
|
||||
shape,
|
||||
func,
|
||||
} = PropTypes
|
||||
|
||||
DashboardEditHeader.propTypes = {
|
||||
dashboard: shape({}),
|
||||
onSave: func.isRequired,
|
||||
}
|
||||
|
||||
export default DashboardEditHeader
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react'
|
||||
|
||||
const VisualizationSelector = () => (
|
||||
<div className="" style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
background: '#676978',
|
||||
padding: '10px',
|
||||
borderRadius: '3px',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
<div className="">
|
||||
VISUALIZATIONS
|
||||
<div className="btn btn-info" style={{margin: "0 5px 0 5px"}}>
|
||||
Line Graph
|
||||
</div>
|
||||
<div className="btn btn-info" style={{margin: "0 5px 0 5px"}}>
|
||||
SingleStat
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default VisualizationSelector
|
|
@ -0,0 +1,12 @@
|
|||
export const EMPTY_DASHBOARD = {
|
||||
id: 0,
|
||||
name: '',
|
||||
cells: [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
queries: [],
|
||||
name: 'Loading...',
|
||||
},
|
||||
],
|
||||
}
|
|
@ -1,130 +1,169 @@
|
|||
import React, {PropTypes} from 'react';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import {Link} from 'react-router';
|
||||
import _ from 'lodash';
|
||||
import React, {PropTypes} from 'react'
|
||||
import {Link} from 'react-router'
|
||||
import {connect} from 'react-redux'
|
||||
import {bindActionCreators} from 'redux'
|
||||
|
||||
import LayoutRenderer from 'shared/components/LayoutRenderer';
|
||||
import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown';
|
||||
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
|
||||
import Header from 'src/dashboards/components/DashboardHeader'
|
||||
import EditHeader from 'src/dashboards/components/DashboardHeaderEdit'
|
||||
import Dashboard from 'src/dashboards/components/Dashboard'
|
||||
import timeRanges from 'hson!../../shared/data/timeRanges.hson'
|
||||
|
||||
import {getDashboards} from '../apis';
|
||||
import {getSource} from 'shared/apis';
|
||||
import * as dashboardActionCreators from 'src/dashboards/actions'
|
||||
|
||||
import {presentationButtonDispatcher} from 'shared/dispatchers'
|
||||
|
||||
const {
|
||||
arrayOf,
|
||||
bool,
|
||||
func,
|
||||
number,
|
||||
shape,
|
||||
string,
|
||||
} = PropTypes
|
||||
|
||||
const DashboardPage = React.createClass({
|
||||
propTypes: {
|
||||
params: PropTypes.shape({
|
||||
sourceID: PropTypes.string.isRequired,
|
||||
dashboardID: PropTypes.string.isRequired,
|
||||
source: PropTypes.shape({
|
||||
links: PropTypes.shape({
|
||||
proxy: PropTypes.string,
|
||||
self: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
params: shape({
|
||||
sourceID: string.isRequired,
|
||||
dashboardID: string.isRequired,
|
||||
}).isRequired,
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
const fifteenMinutesIndex = 1;
|
||||
|
||||
return {
|
||||
dashboards: [],
|
||||
timeRange: timeRanges[fifteenMinutesIndex],
|
||||
};
|
||||
location: shape({
|
||||
pathname: string.isRequired,
|
||||
}).isRequired,
|
||||
dashboardActions: shape({
|
||||
putDashboard: func.isRequired,
|
||||
getDashboards: func.isRequired,
|
||||
setDashboard: func.isRequired,
|
||||
setTimeRange: func.isRequired,
|
||||
setEditMode: func.isRequired,
|
||||
}).isRequired,
|
||||
dashboards: arrayOf(shape({
|
||||
id: number.isRequired,
|
||||
cells: arrayOf(shape({})).isRequired,
|
||||
})).isRequired,
|
||||
dashboard: shape({
|
||||
id: number.isRequired,
|
||||
cells: arrayOf(shape({})).isRequired,
|
||||
}).isRequired,
|
||||
timeRange: shape({}).isRequired,
|
||||
inPresentationMode: bool.isRequired,
|
||||
isEditMode: bool.isRequired,
|
||||
handleClickPresentationButton: func,
|
||||
},
|
||||
|
||||
componentDidMount() {
|
||||
getDashboards().then((resp) => {
|
||||
getSource(this.props.params.sourceID).then(({data: source}) => {
|
||||
this.setState({
|
||||
dashboards: resp.data.dashboards,
|
||||
source,
|
||||
});
|
||||
});
|
||||
});
|
||||
const {
|
||||
params: {dashboardID},
|
||||
dashboardActions: {getDashboards},
|
||||
} = this.props;
|
||||
|
||||
getDashboards(dashboardID)
|
||||
},
|
||||
|
||||
currentDashboard(dashboards, dashboardID) {
|
||||
return _.find(dashboards, (d) => d.id.toString() === dashboardID);
|
||||
},
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const {location: {pathname}} = this.props
|
||||
const {
|
||||
location: {pathname: nextPathname},
|
||||
params: {dashboardID: nextID},
|
||||
dashboardActions: {setDashboard, setEditMode},
|
||||
} = nextProps
|
||||
|
||||
renderDashboard(dashboard) {
|
||||
const autoRefreshMs = 15000;
|
||||
const {timeRange} = this.state;
|
||||
const {source} = this.state;
|
||||
if (nextPathname.pathname === pathname) {
|
||||
return
|
||||
}
|
||||
|
||||
const cellWidth = 4;
|
||||
const cellHeight = 4;
|
||||
|
||||
const cells = dashboard.cells.map((cell, i) => {
|
||||
const dashboardCell = Object.assign(cell, {
|
||||
w: cellWidth,
|
||||
h: cellHeight,
|
||||
queries: cell.queries,
|
||||
i: i.toString(),
|
||||
});
|
||||
|
||||
dashboardCell.queries.forEach((q) => {
|
||||
q.text = q.query;
|
||||
q.database = source.telegraf;
|
||||
});
|
||||
return dashboardCell;
|
||||
});
|
||||
|
||||
return (
|
||||
<LayoutRenderer
|
||||
timeRange={timeRange}
|
||||
cells={cells}
|
||||
autoRefreshMs={autoRefreshMs}
|
||||
source={source.links.proxy}
|
||||
/>
|
||||
);
|
||||
setDashboard(nextID)
|
||||
setEditMode(nextPathname.includes('/edit'))
|
||||
},
|
||||
|
||||
handleChooseTimeRange({lower}) {
|
||||
const timeRange = timeRanges.find((range) => range.queryValue === lower);
|
||||
this.setState({timeRange});
|
||||
this.props.dashboardActions.setTimeRange(timeRange)
|
||||
},
|
||||
|
||||
handleUpdatePosition(cells) {
|
||||
this.props.dashboardActions.putDashboard({...this.props.dashboard, cells})
|
||||
},
|
||||
|
||||
render() {
|
||||
const {dashboards, timeRange} = this.state;
|
||||
const dashboard = this.currentDashboard(dashboards, this.props.params.dashboardID);
|
||||
const {
|
||||
dashboards,
|
||||
dashboard,
|
||||
params: {sourceID},
|
||||
inPresentationMode,
|
||||
isEditMode,
|
||||
handleClickPresentationButton,
|
||||
source,
|
||||
timeRange,
|
||||
} = this.props
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header full-width">
|
||||
<div className="page-header__container">
|
||||
<div className="page-header__left">
|
||||
<div className="dropdown page-header-dropdown">
|
||||
<button className="dropdown-toggle" type="button" data-toggle="dropdown">
|
||||
<span className="button-text">{dashboard ? dashboard.name : ''}</span>
|
||||
<span className="caret"></span>
|
||||
</button>
|
||||
<ul className="dropdown-menu" aria-labelledby="dropdownMenu1">
|
||||
{(dashboards).map((d, i) => {
|
||||
return (
|
||||
<li key={i}>
|
||||
<Link to={`/sources/${this.props.params.sourceID}/dashboards/${d.id}`} className="role-option">
|
||||
{d.name}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-header__right">
|
||||
<div className="btn btn-info btn-sm" data-for="graph-tips-tooltip" data-tip="<p><code>Click + Drag</code> Zoom in (X or Y)</p><p><code>Shift + Click</code> Pan Graph Window</p><p><code>Double Click</code> Reset Graph Window</p>">
|
||||
<span className="icon heart"></span>
|
||||
Graph Tips
|
||||
</div>
|
||||
<ReactTooltip id="graph-tips-tooltip" effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
|
||||
<TimeRangeDropdown onChooseTimeRange={this.handleChooseTimeRange} selected={timeRange.inputValue} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-contents">
|
||||
<div className="container-fluid full-width">
|
||||
{ dashboard ? this.renderDashboard(dashboard) : '' }
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
isEditMode ?
|
||||
<EditHeader dashboard={dashboard} onSave={() => {}} /> :
|
||||
<Header
|
||||
buttonText={dashboard ? dashboard.name : ''}
|
||||
timeRange={timeRange}
|
||||
handleChooseTimeRange={this.handleChooseTimeRange}
|
||||
isHidden={inPresentationMode}
|
||||
handleClickPresentationButton={handleClickPresentationButton}
|
||||
dashboard={dashboard}
|
||||
sourceID={sourceID}
|
||||
>
|
||||
{(dashboards).map((d, i) => {
|
||||
return (
|
||||
<li key={i}>
|
||||
<Link to={`/sources/${sourceID}/dashboards/${d.id}`} className="role-option">
|
||||
{d.name}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</Header>
|
||||
}
|
||||
<Dashboard
|
||||
dashboard={dashboard}
|
||||
isEditMode={isEditMode}
|
||||
inPresentationMode={inPresentationMode}
|
||||
source={source}
|
||||
timeRange={timeRange}
|
||||
onPositionChange={this.handleUpdatePosition}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default DashboardPage;
|
||||
const mapStateToProps = (state) => {
|
||||
const {
|
||||
appUI,
|
||||
dashboardUI: {
|
||||
dashboards,
|
||||
dashboard,
|
||||
timeRange,
|
||||
isEditMode,
|
||||
},
|
||||
} = state
|
||||
|
||||
return {
|
||||
inPresentationMode: appUI.presentationMode,
|
||||
dashboards,
|
||||
dashboard,
|
||||
timeRange,
|
||||
isEditMode,
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
handleClickPresentationButton: presentationButtonDispatcher(dispatch),
|
||||
dashboardActions: bindActionCreators(dashboardActionCreators, dispatch),
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DashboardPage);
|
||||
|
|
|
@ -34,13 +34,14 @@ const DashboardsPage = React.createClass({
|
|||
},
|
||||
|
||||
render() {
|
||||
const dashboardLink = `/sources/${this.props.source.id}`;
|
||||
let tableHeader;
|
||||
if (this.state.waiting) {
|
||||
tableHeader = "Loading Dashboards...";
|
||||
} else if (this.state.dashboards.length === 0) {
|
||||
tableHeader = "No Dashboards";
|
||||
tableHeader = "1 Dashboard";
|
||||
} else {
|
||||
tableHeader = `${this.state.dashboards.length} Dashboards`;
|
||||
tableHeader = `${this.state.dashboards.length + 1} Dashboards`;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -75,7 +76,7 @@ const DashboardsPage = React.createClass({
|
|||
return (
|
||||
<tr key={dashboard.id}>
|
||||
<td className="monotype">
|
||||
<Link to={`/sources/${this.props.source.id}/dashboards/${dashboard.id}`}>
|
||||
<Link to={`${dashboardLink}/dashboards/${dashboard.id}`}>
|
||||
{dashboard.name}
|
||||
</Link>
|
||||
</td>
|
||||
|
@ -83,6 +84,13 @@ const DashboardsPage = React.createClass({
|
|||
);
|
||||
})
|
||||
}
|
||||
<tr>
|
||||
<td className="monotype">
|
||||
<Link to={`${dashboardLink}/kubernetes`}>
|
||||
{'Kubernetes'}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import _ from 'lodash';
|
||||
import {EMPTY_DASHBOARD} from 'src/dashboards/constants'
|
||||
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
|
||||
|
||||
const initialState = {
|
||||
dashboards: [],
|
||||
dashboard: EMPTY_DASHBOARD,
|
||||
timeRange: timeRanges[1],
|
||||
isEditMode: false,
|
||||
};
|
||||
|
||||
export default function ui(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case 'LOAD_DASHBOARDS': {
|
||||
const {dashboards, dashboardID} = action.payload;
|
||||
const newState = {
|
||||
dashboards,
|
||||
dashboard: _.find(dashboards, (d) => d.id === +dashboardID),
|
||||
};
|
||||
|
||||
return {...state, ...newState};
|
||||
}
|
||||
|
||||
case 'SET_DASHBOARD': {
|
||||
const {dashboardID} = action.payload
|
||||
const newState = {
|
||||
dashboard: _.find(state.dashboards, (d) => d.id === +dashboardID),
|
||||
};
|
||||
|
||||
return {...state, ...newState}
|
||||
}
|
||||
|
||||
case 'SET_DASHBOARD_TIME_RANGE': {
|
||||
const {timeRange} = action.payload
|
||||
|
||||
return {...state, timeRange};
|
||||
}
|
||||
|
||||
case 'SET_EDIT_MODE': {
|
||||
const {isEditMode} = action.payload
|
||||
return {...state, isEditMode}
|
||||
}
|
||||
|
||||
case 'UPDATE_DASHBOARD': {
|
||||
const {dashboard} = action.payload
|
||||
const newState = {
|
||||
dashboard,
|
||||
dashboards: state.dashboards.map((d) => d.id === dashboard.id ? dashboard : d),
|
||||
}
|
||||
|
||||
return {...state, ...newState}
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
|
@ -73,7 +73,7 @@ const Visualization = React.createClass({
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames("", {"graph-container": isGraphInView, "table-container": !isGraphInView})}>
|
||||
<div className={classNames({"graph-container": isGraphInView, "table-container": !isGraphInView})}>
|
||||
{isGraphInView ? (
|
||||
<RefreshingLineGraph
|
||||
queries={queries}
|
||||
|
|
|
@ -87,7 +87,7 @@ export async function getAllHosts(proxyLink, telegrafDB) {
|
|||
export function getMappings() {
|
||||
return AJAX({
|
||||
method: 'GET',
|
||||
url: `/chronograf/v1/mappings`,
|
||||
resource: 'mappings',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,31 +1,42 @@
|
|||
import React, {PropTypes} from 'react';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import {Link} from 'react-router';
|
||||
import _ from 'lodash';
|
||||
import React, {PropTypes} from 'react'
|
||||
import {Link} from 'react-router'
|
||||
import {connect} from 'react-redux'
|
||||
import _ from 'lodash'
|
||||
import classnames from 'classnames';
|
||||
|
||||
import LayoutRenderer from 'shared/components/LayoutRenderer';
|
||||
import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown';
|
||||
import DashboardHeader from 'src/dashboards/components/DashboardHeader';
|
||||
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
|
||||
import {getMappings, getAppsForHosts, getMeasurementsForHost, getAllHosts} from 'src/hosts/apis';
|
||||
import {fetchLayouts} from 'shared/apis';
|
||||
import {presentationButtonDispatcher} from 'shared/dispatchers'
|
||||
|
||||
const {
|
||||
shape,
|
||||
string,
|
||||
bool,
|
||||
func,
|
||||
} = PropTypes
|
||||
|
||||
export const HostPage = React.createClass({
|
||||
propTypes: {
|
||||
source: PropTypes.shape({
|
||||
links: PropTypes.shape({
|
||||
proxy: PropTypes.string.isRequired,
|
||||
source: shape({
|
||||
links: shape({
|
||||
proxy: string.isRequired,
|
||||
}).isRequired,
|
||||
telegraf: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
telegraf: string.isRequired,
|
||||
id: string.isRequired,
|
||||
}),
|
||||
params: PropTypes.shape({
|
||||
hostID: PropTypes.string.isRequired,
|
||||
params: shape({
|
||||
hostID: string.isRequired,
|
||||
}).isRequired,
|
||||
location: PropTypes.shape({
|
||||
query: PropTypes.shape({
|
||||
app: PropTypes.string,
|
||||
location: shape({
|
||||
query: shape({
|
||||
app: string,
|
||||
}),
|
||||
}),
|
||||
inPresentationMode: bool,
|
||||
handleClickPresentationButton: func,
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
|
@ -134,45 +145,34 @@ export const HostPage = React.createClass({
|
|||
},
|
||||
|
||||
render() {
|
||||
const hostID = this.props.params.hostID;
|
||||
const {layouts, timeRange, hosts} = this.state;
|
||||
const appParam = this.props.location.query.app ? `?app=${this.props.location.query.app}` : '';
|
||||
const {params: {hostID}, location: {query: {app}}, source: {id}, inPresentationMode, handleClickPresentationButton} = this.props
|
||||
const {layouts, timeRange, hosts} = this.state
|
||||
const appParam = app ? `?app=${app}` : ''
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header full-width">
|
||||
<div className="page-header__container">
|
||||
<div className="page-header__left">
|
||||
<div className="dropdown page-header-dropdown">
|
||||
<button className="dropdown-toggle" type="button" data-toggle="dropdown">
|
||||
<span className="button-text">{hostID}</span>
|
||||
<span className="caret"></span>
|
||||
</button>
|
||||
<ul className="dropdown-menu" aria-labelledby="dropdownMenu1">
|
||||
{Object.keys(hosts).map((host, i) => {
|
||||
return (
|
||||
<li key={i}>
|
||||
<Link to={`/sources/${this.props.source.id}/hosts/${host + appParam}`} className="role-option">
|
||||
{host}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-header__right">
|
||||
<div className="btn btn-info btn-sm" data-for="graph-tips-tooltip" data-tip="<p><code>Click + Drag</code> Zoom in (X or Y)</p><p><code>Shift + Click</code> Pan Graph Window</p><p><code>Double Click</code> Reset Graph Window</p>">
|
||||
<span className="icon heart"></span>
|
||||
Graph Tips
|
||||
</div>
|
||||
<ReactTooltip id="graph-tips-tooltip" effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
|
||||
<TimeRangeDropdown onChooseTimeRange={this.handleChooseTimeRange} selected={timeRange.inputValue} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-contents">
|
||||
<div className="container-fluid full-width">
|
||||
<DashboardHeader
|
||||
buttonText={hostID}
|
||||
timeRange={timeRange}
|
||||
isHidden={inPresentationMode}
|
||||
handleChooseTimeRange={this.handleChooseTimeRange}
|
||||
handleClickPresentationButton={handleClickPresentationButton}
|
||||
>
|
||||
{Object.keys(hosts).map((host, i) => {
|
||||
return (
|
||||
<li key={i}>
|
||||
<Link to={`/sources/${id}/hosts/${host + appParam}`} className="role-option">
|
||||
{host}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</DashboardHeader>
|
||||
<div className={classnames({
|
||||
'page-contents': true,
|
||||
'presentation-mode': inPresentationMode,
|
||||
})}>
|
||||
<div className="container-fluid full-width dashboard">
|
||||
{ (layouts.length > 0) ? this.renderLayouts(layouts) : '' }
|
||||
</div>
|
||||
</div>
|
||||
|
@ -181,4 +181,12 @@ export const HostPage = React.createClass({
|
|||
},
|
||||
});
|
||||
|
||||
export default HostPage;
|
||||
const mapStateToProps = (state) => ({
|
||||
inPresentationMode: state.appUI.presentationMode,
|
||||
})
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
handleClickPresentationButton: presentationButtonDispatcher(dispatch),
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(HostPage)
|
||||
|
|
|
@ -18,6 +18,8 @@ import NotFound from 'src/shared/components/NotFound';
|
|||
import configureStore from 'src/store/configureStore';
|
||||
import {getMe, getSources} from 'shared/apis';
|
||||
import {receiveMe} from 'shared/actions/me';
|
||||
import {receiveAuth} from 'shared/actions/auth';
|
||||
import {disablePresentationMode} from 'shared/actions/ui';
|
||||
import {loadLocalStorage} from './localStorage';
|
||||
|
||||
import 'src/style/chronograf.scss';
|
||||
|
@ -38,6 +40,12 @@ if (basepath) {
|
|||
});
|
||||
}
|
||||
|
||||
window.addEventListener('keyup', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
store.dispatch(disablePresentationMode())
|
||||
}
|
||||
})
|
||||
|
||||
const Root = React.createClass({
|
||||
getInitialState() {
|
||||
return {
|
||||
|
@ -69,14 +77,13 @@ const Root = React.createClass({
|
|||
if (store.getState().me.links) {
|
||||
return this.setState({loggedIn: true});
|
||||
}
|
||||
getMe().then(({data: me}) => {
|
||||
getMe().then(({data: me, auth}) => {
|
||||
store.dispatch(receiveMe(me));
|
||||
store.dispatch(receiveAuth(auth));
|
||||
this.setState({loggedIn: true});
|
||||
}).catch((err) => {
|
||||
const AUTH_DISABLED = 418;
|
||||
if (err.response.status === AUTH_DISABLED) {
|
||||
return this.setState({loggedIn: true});
|
||||
// Could store a boolean indicating auth is not set up
|
||||
}).catch((error) => {
|
||||
if (error.auth) {
|
||||
store.dispatch(receiveAuth(error.auth));
|
||||
}
|
||||
|
||||
this.setState({loggedIn: false});
|
||||
|
@ -116,12 +123,13 @@ const Root = React.createClass({
|
|||
<Route path="alerts" component={AlertsApp} />
|
||||
<Route path="dashboards" component={DashboardsPage} />
|
||||
<Route path="dashboards/:dashboardID" component={DashboardPage} />
|
||||
<Route path="dashboards/:dashboardID/edit" component={DashboardPage} />
|
||||
<Route path="alert-rules" component={KapacitorRulesPage} />
|
||||
<Route path="alert-rules/:ruleID" component={KapacitorRulePage} />
|
||||
<Route path="alert-rules/new" component={KapacitorRulePage} />
|
||||
</Route>
|
||||
<Route path="*" component={NotFound} />
|
||||
</Route>
|
||||
<Route path="*" component={NotFound} />
|
||||
</Router>
|
||||
</Provider>
|
||||
);
|
||||
|
|
|
@ -1,18 +1,29 @@
|
|||
import React, {PropTypes} from 'react';
|
||||
import classnames from 'classnames'
|
||||
|
||||
import LayoutRenderer from 'shared/components/LayoutRenderer';
|
||||
import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import DashboardHeader from 'src/dashboards/components/DashboardHeader';
|
||||
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
|
||||
|
||||
export const KubernetesPage = React.createClass({
|
||||
const {
|
||||
shape,
|
||||
string,
|
||||
arrayOf,
|
||||
bool,
|
||||
func,
|
||||
} = PropTypes
|
||||
|
||||
export const KubernetesDashboard = React.createClass({
|
||||
propTypes: {
|
||||
source: PropTypes.shape({
|
||||
links: PropTypes.shape({
|
||||
proxy: PropTypes.string.isRequired,
|
||||
source: shape({
|
||||
links: shape({
|
||||
proxy: string.isRequired,
|
||||
}).isRequired,
|
||||
telegraf: PropTypes.string.isRequired,
|
||||
telegraf: string.isRequired,
|
||||
}),
|
||||
layouts: PropTypes.arrayOf(PropTypes.shape().isRequired).isRequired,
|
||||
layouts: arrayOf(shape().isRequired).isRequired,
|
||||
inPresentationMode: bool.isRequired,
|
||||
handleClickPresentationButton: func,
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
|
@ -57,7 +68,7 @@ export const KubernetesPage = React.createClass({
|
|||
},
|
||||
|
||||
render() {
|
||||
const {layouts} = this.props;
|
||||
const {layouts, inPresentationMode, handleClickPresentationButton} = this.props;
|
||||
const {timeRange} = this.state;
|
||||
const emptyState = (
|
||||
<div className="generic-empty-state">
|
||||
|
@ -68,23 +79,18 @@ export const KubernetesPage = React.createClass({
|
|||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header full-width">
|
||||
<div className="page-header__container">
|
||||
<div className="page-header__left">
|
||||
<h1>Kubernetes Dashboard</h1>
|
||||
</div>
|
||||
<div className="page-header__right">
|
||||
<div className="btn btn-info btn-sm" data-for="graph-tips-tooltip" data-tip="<p><code>Click + Drag</code> Zoom in (X or Y)</p><p><code>Shift + Click</code> Pan Graph Window</p><p><code>Double Click</code> Reset Graph Window</p>">
|
||||
<span className="icon heart"></span>
|
||||
Graph Tips
|
||||
</div>
|
||||
<ReactTooltip id="graph-tips-tooltip" effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
|
||||
<TimeRangeDropdown onChooseTimeRange={this.handleChooseTimeRange} selected={timeRange.inputValue} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-contents">
|
||||
<div className="container-fluid full-width">
|
||||
<DashboardHeader
|
||||
headerText="Kubernetes Dashboard"
|
||||
timeRange={timeRange}
|
||||
handleChooseTimeRange={this.handleChooseTimeRange}
|
||||
isHidden={inPresentationMode}
|
||||
handleClickPresentationButton={handleClickPresentationButton}
|
||||
/>
|
||||
<div className={classnames({
|
||||
'page-contents': true,
|
||||
'presentation-mode': inPresentationMode,
|
||||
})}>
|
||||
<div className="container-fluid full-width dashboard">
|
||||
{layouts.length ? this.renderLayouts(layouts) : emptyState}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -92,4 +98,5 @@ export const KubernetesPage = React.createClass({
|
|||
);
|
||||
},
|
||||
});
|
||||
export default KubernetesPage;
|
||||
|
||||
export default KubernetesDashboard;
|
||||
|
|
|
@ -1,14 +1,26 @@
|
|||
import React, {PropTypes} from 'react';
|
||||
import {connect} from 'react-redux'
|
||||
|
||||
import {fetchLayouts} from 'shared/apis';
|
||||
import KubernetesDashboard from 'src/kubernetes/components/KubernetesDashboard';
|
||||
import {presentationButtonDispatcher} from 'shared/dispatchers'
|
||||
|
||||
const {
|
||||
shape,
|
||||
string,
|
||||
bool,
|
||||
func,
|
||||
} = PropTypes
|
||||
|
||||
export const KubernetesPage = React.createClass({
|
||||
propTypes: {
|
||||
source: PropTypes.shape({
|
||||
links: PropTypes.shape({
|
||||
proxy: PropTypes.string.isRequired,
|
||||
source: shape({
|
||||
links: shape({
|
||||
proxy: string.isRequired,
|
||||
}).isRequired,
|
||||
}),
|
||||
inPresentationMode: bool.isRequired,
|
||||
handleClickPresentationButton: func,
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
|
@ -25,10 +37,26 @@ export const KubernetesPage = React.createClass({
|
|||
},
|
||||
|
||||
render() {
|
||||
const {layouts} = this.state
|
||||
const {source, inPresentationMode, handleClickPresentationButton} = this.props
|
||||
|
||||
return (
|
||||
<KubernetesDashboard layouts={this.state.layouts} source={this.props.source} />
|
||||
<KubernetesDashboard
|
||||
layouts={layouts}
|
||||
source={source}
|
||||
inPresentationMode={inPresentationMode}
|
||||
handleClickPresentationButton={handleClickPresentationButton}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export default KubernetesPage;
|
||||
const mapStateToProps = (state) => ({
|
||||
inPresentationMode: state.appUI.presentationMode,
|
||||
})
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
handleClickPresentationButton: presentationButtonDispatcher(dispatch),
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(KubernetesPage);
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
export function receiveAuth(auth) {
|
||||
return {
|
||||
type: 'AUTH_RECEIVED',
|
||||
payload: {
|
||||
auth,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -17,6 +17,12 @@ export function dismissNotification(type) {
|
|||
};
|
||||
}
|
||||
|
||||
export function delayDismissNotification(type, wait) {
|
||||
return (dispatch) => {
|
||||
setTimeout(() => dispatch(dismissNotification(type)), wait)
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissAllNotifications() {
|
||||
return {
|
||||
type: 'ALL_NOTIFICATIONS_DISMISSED',
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import {PRESENTATION_MODE_ANIMATION_DELAY} from '../constants'
|
||||
|
||||
export function enablePresentationMode() {
|
||||
return {
|
||||
type: 'ENABLE_PRESENTATION_MODE',
|
||||
}
|
||||
}
|
||||
|
||||
export function disablePresentationMode() {
|
||||
return {
|
||||
type: 'DISABLE_PRESENTATION_MODE',
|
||||
}
|
||||
}
|
||||
|
||||
export function delayEnablePresentationMode() {
|
||||
return (dispatch) => {
|
||||
setTimeout(() => dispatch(enablePresentationMode()), PRESENTATION_MODE_ANIMATION_DELAY)
|
||||
}
|
||||
}
|
|
@ -4,31 +4,33 @@ export function fetchLayouts() {
|
|||
return AJAX({
|
||||
url: `/chronograf/v1/layouts`,
|
||||
method: 'GET',
|
||||
resource: 'layouts',
|
||||
});
|
||||
}
|
||||
|
||||
export function getMe() {
|
||||
return AJAX({
|
||||
url: `/chronograf/v1/me`,
|
||||
resource: 'me',
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
export function getSources() {
|
||||
return AJAX({
|
||||
url: '/chronograf/v1/sources',
|
||||
resource: 'sources',
|
||||
});
|
||||
}
|
||||
|
||||
export function getSource(sourceID) {
|
||||
export function getSource(id) {
|
||||
return AJAX({
|
||||
url: `/chronograf/v1/sources/${sourceID}`,
|
||||
resource: 'sources',
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSource(attributes) {
|
||||
return AJAX({
|
||||
url: '/chronograf/v1/sources',
|
||||
resource: 'sources',
|
||||
method: 'POST',
|
||||
data: attributes,
|
||||
});
|
||||
|
|
|
@ -108,6 +108,7 @@ export default React.createClass({
|
|||
const legendWidth = legendRect.width;
|
||||
const legendMaxLeft = graphWidth - (legendWidth / 2);
|
||||
const trueGraphX = (e.pageX - graphRect.left);
|
||||
const legendTop = graphRect.height + 0
|
||||
let legendLeft = trueGraphX;
|
||||
// Enforcing max & min legend offsets
|
||||
if (trueGraphX < (legendWidth / 2)) {
|
||||
|
@ -117,6 +118,7 @@ export default React.createClass({
|
|||
}
|
||||
|
||||
legendContainerNode.style.left = `${legendLeft}px`;
|
||||
legendContainerNode.style.top = `${legendTop}px`;
|
||||
setMarker(points);
|
||||
},
|
||||
unhighlightCallback() {
|
||||
|
|
|
@ -4,50 +4,52 @@ import LineGraph from 'shared/components/LineGraph';
|
|||
import SingleStat from 'shared/components/SingleStat';
|
||||
import ReactGridLayout, {WidthProvider} from 'react-grid-layout';
|
||||
const GridLayout = WidthProvider(ReactGridLayout);
|
||||
import _ from 'lodash';
|
||||
|
||||
const RefreshingLineGraph = AutoRefresh(LineGraph);
|
||||
const RefreshingSingleStat = AutoRefresh(SingleStat);
|
||||
|
||||
const {
|
||||
arrayOf,
|
||||
func,
|
||||
number,
|
||||
shape,
|
||||
string,
|
||||
} = PropTypes;
|
||||
|
||||
export const LayoutRenderer = React.createClass({
|
||||
propTypes: {
|
||||
timeRange: PropTypes.shape({
|
||||
defaultGroupBy: PropTypes.string.isRequired,
|
||||
queryValue: PropTypes.string.isRequired,
|
||||
timeRange: shape({
|
||||
defaultGroupBy: string.isRequired,
|
||||
queryValue: string.isRequired,
|
||||
}).isRequired,
|
||||
cells: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
queries: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
range: PropTypes.shape({
|
||||
upper: PropTypes.number,
|
||||
lower: PropTypes.number,
|
||||
cells: arrayOf(
|
||||
shape({
|
||||
queries: arrayOf(
|
||||
shape({
|
||||
label: string,
|
||||
range: shape({
|
||||
upper: number,
|
||||
lower: number,
|
||||
}),
|
||||
rp: PropTypes.string,
|
||||
text: PropTypes.string.isRequired,
|
||||
database: PropTypes.string.isRequired,
|
||||
groupbys: PropTypes.arrayOf(PropTypes.string),
|
||||
wheres: PropTypes.arrayOf(PropTypes.string),
|
||||
rp: string,
|
||||
text: string.isRequired,
|
||||
database: string.isRequired,
|
||||
groupbys: arrayOf(string),
|
||||
wheres: arrayOf(string),
|
||||
}).isRequired
|
||||
).isRequired,
|
||||
x: PropTypes.number.isRequired,
|
||||
y: PropTypes.number.isRequired,
|
||||
w: PropTypes.number.isRequired,
|
||||
h: PropTypes.number.isRequired,
|
||||
i: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
x: number.isRequired,
|
||||
y: number.isRequired,
|
||||
w: number.isRequired,
|
||||
h: number.isRequired,
|
||||
i: string.isRequired,
|
||||
name: string.isRequired,
|
||||
}).isRequired
|
||||
),
|
||||
autoRefreshMs: PropTypes.number.isRequired,
|
||||
host: PropTypes.string,
|
||||
source: PropTypes.string,
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
return ({
|
||||
layout: _.without(this.props.cells, ['queries']),
|
||||
});
|
||||
autoRefreshMs: number.isRequired,
|
||||
host: string,
|
||||
source: string,
|
||||
onPositionChange: func,
|
||||
},
|
||||
|
||||
buildQuery(q) {
|
||||
|
@ -96,33 +98,81 @@ export const LayoutRenderer = React.createClass({
|
|||
if (cell.type === 'single-stat') {
|
||||
return (
|
||||
<div key={cell.i}>
|
||||
<h2 className="hosts-graph-heading">{cell.name}</h2>
|
||||
<div className="hosts-graph graph-container">
|
||||
<h2 className="dash-graph--heading">{cell.name || `Graph`}</h2>
|
||||
<div className="dash-graph--container">
|
||||
<RefreshingSingleStat queries={[qs[0]]} autoRefresh={autoRefreshMs} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayOptions = {
|
||||
stepPlot: cell.type === 'line-stepplot',
|
||||
stackedGraph: cell.type === 'line-stacked',
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={cell.i}>
|
||||
<h2 className="hosts-graph-heading">{cell.name}</h2>
|
||||
<div className="hosts-graph graph-container">
|
||||
<RefreshingLineGraph queries={qs} autoRefresh={autoRefreshMs} showSingleStat={cell.type === "line-plus-single-stat"} />
|
||||
<h2 className="dash-graph--heading">{cell.name || `Graph`}</h2>
|
||||
<div className="dash-graph--container">
|
||||
<RefreshingLineGraph
|
||||
queries={qs}
|
||||
autoRefresh={autoRefreshMs}
|
||||
showSingleStat={cell.type === "line-plus-single-stat"}
|
||||
displayOptions={displayOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
handleLayoutChange(layout) {
|
||||
this.triggerWindowResize()
|
||||
|
||||
if (!this.props.onPositionChange) {
|
||||
return
|
||||
}
|
||||
|
||||
const newCells = this.props.cells.map((cell) => {
|
||||
const l = layout.find((ly) => ly.i === cell.i)
|
||||
const newLayout = {x: l.x, y: l.y, h: l.h, w: l.w}
|
||||
return {...cell, ...newLayout}
|
||||
})
|
||||
|
||||
this.props.onPositionChange(newCells)
|
||||
},
|
||||
|
||||
render() {
|
||||
const layoutMargin = 4;
|
||||
const layoutMargin = 4
|
||||
const isDashboard = !!this.props.onPositionChange
|
||||
|
||||
return (
|
||||
<GridLayout layout={this.state.layout} isDraggable={false} isResizable={false} cols={12} rowHeight={83.5} margin={[layoutMargin, layoutMargin]} containerPadding={[0, 0]} useCSSTransforms={false} >
|
||||
<GridLayout
|
||||
layout={this.props.cells}
|
||||
cols={12}
|
||||
rowHeight={83.5}
|
||||
margin={[layoutMargin, layoutMargin]}
|
||||
containerPadding={[0, 0]}
|
||||
useCSSTransforms={false}
|
||||
onResize={this.triggerWindowResize}
|
||||
onLayoutChange={this.handleLayoutChange}
|
||||
draggableHandle={'.dash-graph--heading'}
|
||||
isDraggable={isDashboard}
|
||||
isResizable={isDashboard}
|
||||
>
|
||||
{this.generateVisualizations()}
|
||||
</GridLayout>
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
triggerWindowResize() {
|
||||
// Hack to get dygraphs to fit properly during and after resize (dispatchEvent is a global method on window).
|
||||
const evt = document.createEvent('CustomEvent'); // MUST be 'CustomEvent'
|
||||
evt.initCustomEvent('resize', false, false, null);
|
||||
dispatchEvent(evt);
|
||||
},
|
||||
});
|
||||
|
||||
export default LayoutRenderer;
|
||||
|
|
|
@ -33,6 +33,10 @@ export default React.createClass({
|
|||
overrideLineColors: array,
|
||||
queries: arrayOf(shape({}).isRequired).isRequired,
|
||||
showSingleStat: bool,
|
||||
displayOptions: shape({
|
||||
stepPlot: bool,
|
||||
stackedGraph: bool,
|
||||
}),
|
||||
activeQueryIndex: number,
|
||||
ruleValues: shape({}),
|
||||
isInDataExplorer: bool,
|
||||
|
@ -63,7 +67,7 @@ export default React.createClass({
|
|||
},
|
||||
|
||||
render() {
|
||||
const {data, ranges, isFetchingInitially, isRefreshing, isGraphFilled, overrideLineColors, title, underlayCallback, queries, showSingleStat, ruleValues} = this.props;
|
||||
const {data, ranges, isFetchingInitially, isRefreshing, isGraphFilled, overrideLineColors, title, underlayCallback, queries, showSingleStat, displayOptions, ruleValues} = this.props;
|
||||
const {labels, timeSeries, dygraphSeries} = this._timeSeries;
|
||||
|
||||
// If data for this graph is being fetched for the first time, show a graph-wide spinner.
|
||||
|
@ -75,7 +79,7 @@ export default React.createClass({
|
|||
);
|
||||
}
|
||||
|
||||
const options = {
|
||||
const options = Object.assign({}, {
|
||||
labels,
|
||||
connectSeparatedPoints: true,
|
||||
labelsKMB: true,
|
||||
|
@ -89,7 +93,7 @@ export default React.createClass({
|
|||
underlayCallback,
|
||||
ylabel: _.get(queries, ['0', 'label'], ''),
|
||||
y2label: _.get(queries, ['1', 'label'], ''),
|
||||
};
|
||||
}, displayOptions);
|
||||
|
||||
let roundedValue;
|
||||
if (showSingleStat) {
|
||||
|
@ -103,7 +107,7 @@ export default React.createClass({
|
|||
<div className={classNames({"graph--hasYLabel": !!(options.ylabel || options.y2label)})}>
|
||||
{isRefreshing ? this.renderSpinner() : null}
|
||||
<Dygraph
|
||||
containerStyle={{width: '100%', height: '300px'}}
|
||||
containerStyle={{width: '100%', height: '100%'}}
|
||||
overrideLineColors={overrideLineColors}
|
||||
isGraphFilled={isGraphFilled}
|
||||
timeSeries={timeSeries}
|
||||
|
|
|
@ -466,4 +466,7 @@ export const DEFAULT_LINE_COLORS = [
|
|||
export const STROKE_WIDTH = {
|
||||
heavy: 3.5,
|
||||
light: 1.5,
|
||||
};
|
||||
};
|
||||
|
||||
export const PRESENTATION_MODE_ANIMATION_DELAY = 250 // In milliseconds.
|
||||
export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds.
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import {delayEnablePresentationMode} from 'shared/actions/ui'
|
||||
import {publishNotification, delayDismissNotification} from 'shared/actions/notifications'
|
||||
import {PRESENTATION_MODE_NOTIFICATION_DELAY} from 'shared/constants'
|
||||
|
||||
export const presentationButtonDispatcher = (dispatch) => () => {
|
||||
dispatch(delayEnablePresentationMode())
|
||||
dispatch(publishNotification('success', 'Press ESC to disable presentation mode.'))
|
||||
dispatch(delayDismissNotification('success', PRESENTATION_MODE_NOTIFICATION_DELAY))
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
function getInitialState() {
|
||||
return [];
|
||||
}
|
||||
const initialState = getInitialState();
|
||||
|
||||
export default function auth(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case 'AUTH_RECEIVED': {
|
||||
return action.payload.auth;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
|
@ -1,9 +1,13 @@
|
|||
import appUI from './ui';
|
||||
import me from './me';
|
||||
import auth from './auth';
|
||||
import notifications from './notifications';
|
||||
import sources from './sources';
|
||||
|
||||
export {
|
||||
appUI,
|
||||
me,
|
||||
auth,
|
||||
notifications,
|
||||
sources,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
const initialState = {
|
||||
presentationMode: false,
|
||||
};
|
||||
|
||||
export default function ui(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case 'ENABLE_PRESENTATION_MODE': {
|
||||
return {
|
||||
...state,
|
||||
presentationMode: true,
|
||||
}
|
||||
}
|
||||
|
||||
case 'DISABLE_PRESENTATION_MODE': {
|
||||
return {
|
||||
...state,
|
||||
presentationMode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
|
@ -1,7 +1,11 @@
|
|||
import React, {PropTypes} from 'react';
|
||||
import {NavBar, NavBlock, NavHeader, NavListItem} from 'src/side_nav/components/NavItems';
|
||||
|
||||
const {string, shape} = PropTypes;
|
||||
const {
|
||||
string,
|
||||
shape,
|
||||
bool,
|
||||
} = PropTypes;
|
||||
const SideNav = React.createClass({
|
||||
propTypes: {
|
||||
location: string.isRequired,
|
||||
|
@ -9,36 +13,36 @@ const SideNav = React.createClass({
|
|||
me: shape({
|
||||
email: string,
|
||||
}),
|
||||
isHidden: bool.isRequired,
|
||||
},
|
||||
|
||||
render() {
|
||||
const {me, location, sourceID} = this.props;
|
||||
const {me, location, sourceID, isHidden} = this.props;
|
||||
const sourcePrefix = `/sources/${sourceID}`;
|
||||
const dataExplorerLink = `${sourcePrefix}/chronograf/data-explorer`;
|
||||
|
||||
const loggedIn = !!(me && me.email);
|
||||
|
||||
return (
|
||||
return isHidden ? null : (
|
||||
<NavBar location={location}>
|
||||
<div className="sidebar__logo">
|
||||
<a href="/"><span className="icon cubo-uniform"></span></a>
|
||||
</div>
|
||||
<NavBlock icon="cpu" link={`${sourcePrefix}/hosts`}>
|
||||
<NavHeader link={`${sourcePrefix}/hosts`} title="Infrastructure" />
|
||||
<NavListItem link={`${sourcePrefix}/hosts`}>Host List</NavListItem>
|
||||
<NavListItem link={`${sourcePrefix}/kubernetes`}>Kubernetes Dashboard</NavListItem>
|
||||
<NavBlock icon="cubo-node" link={`${sourcePrefix}/hosts`}>
|
||||
<NavHeader link={`${sourcePrefix}/hosts`} title="Host List" />
|
||||
</NavBlock>
|
||||
<NavBlock icon="graphline" link={dataExplorerLink}>
|
||||
<NavHeader link={dataExplorerLink} title={'Data'} />
|
||||
<NavListItem link={dataExplorerLink}>Explorer</NavListItem>
|
||||
<NavListItem link={`${sourcePrefix}/dashboards`}>Dashboards</NavListItem>
|
||||
<NavHeader link={dataExplorerLink} title="Data Explorer" />
|
||||
</NavBlock>
|
||||
<NavBlock matcher="alerts" icon="pulse-b" link={`${sourcePrefix}/alerts`}>
|
||||
<NavBlock icon="dash-h" link={`${sourcePrefix}/dashboards`}>
|
||||
<NavHeader link={`${sourcePrefix}/dashboards`} title={'Dashboards'} />
|
||||
</NavBlock>
|
||||
<NavBlock matcher="alerts" icon="alert-triangle" link={`${sourcePrefix}/alerts`}>
|
||||
<NavHeader link={`${sourcePrefix}/alerts`} title="Alerting" />
|
||||
<NavListItem link={`${sourcePrefix}/alerts`}>Alert History</NavListItem>
|
||||
<NavListItem link={`${sourcePrefix}/alert-rules`}>Kapacitor Rules</NavListItem>
|
||||
</NavBlock>
|
||||
<NavBlock icon="access-key" link={`${sourcePrefix}/manage-sources`}>
|
||||
<NavBlock icon="cog-thick" link={`${sourcePrefix}/manage-sources`}>
|
||||
<NavHeader link={`${sourcePrefix}/manage-sources`} title="Configuration" />
|
||||
<NavListItem link={`${sourcePrefix}/manage-sources`}>InfluxDB</NavListItem>
|
||||
<NavListItem link={`${sourcePrefix}/kapacitor-config`}>Kapacitor</NavListItem>
|
||||
|
|
|
@ -2,7 +2,13 @@ import React, {PropTypes} from 'react';
|
|||
import {connect} from 'react-redux';
|
||||
import SideNav from '../components/SideNav';
|
||||
|
||||
const {func, string, shape} = PropTypes;
|
||||
const {
|
||||
func,
|
||||
string,
|
||||
shape,
|
||||
bool,
|
||||
} = PropTypes
|
||||
|
||||
const SideNavApp = React.createClass({
|
||||
propTypes: {
|
||||
currentLocation: string.isRequired,
|
||||
|
@ -11,25 +17,27 @@ const SideNavApp = React.createClass({
|
|||
me: shape({
|
||||
email: string,
|
||||
}),
|
||||
inPresentationMode: bool.isRequired,
|
||||
},
|
||||
|
||||
render() {
|
||||
const {me, currentLocation, sourceID} = this.props;
|
||||
const {me, currentLocation, sourceID, inPresentationMode} = this.props;
|
||||
|
||||
return (
|
||||
<SideNav
|
||||
sourceID={sourceID}
|
||||
location={currentLocation}
|
||||
me={me}
|
||||
isHidden={inPresentationMode}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
me: state.me,
|
||||
inPresentationMode: state.appUI.presentationMode,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -5,12 +5,14 @@ import makeQueryExecuter from 'src/shared/middleware/queryExecuter';
|
|||
import * as dataExplorerReducers from 'src/data_explorer/reducers';
|
||||
import * as sharedReducers from 'src/shared/reducers';
|
||||
import rulesReducer from 'src/kapacitor/reducers/rules';
|
||||
import dashboardUI from 'src/dashboards/reducers/ui';
|
||||
import persistStateEnhancer from './persistStateEnhancer';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
...sharedReducers,
|
||||
...dataExplorerReducers,
|
||||
rules: rulesReducer,
|
||||
dashboardUI,
|
||||
});
|
||||
|
||||
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
|
|
|
@ -45,6 +45,7 @@
|
|||
@import 'pages/hosts';
|
||||
@import 'pages/kapacitor';
|
||||
@import 'pages/data-explorer';
|
||||
@import 'pages/dashboards';
|
||||
|
||||
// TODO
|
||||
@import 'unsorted';
|
||||
|
|
|
@ -15,9 +15,8 @@
|
|||
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='fade-out($g20-white, 0.71)', endColorstr='fade-out($g20-white, 0.71)',GradientType=0 );
|
||||
}
|
||||
.container--dygraph-legend {
|
||||
top: 300px !important;
|
||||
transform: translate(-50%,-6px);
|
||||
background-color: $g1-raven;
|
||||
transform: translateX(-50%);
|
||||
background-color: $g0-obsidian;
|
||||
display: block;
|
||||
position: absolute;
|
||||
padding: 11px;
|
||||
|
@ -119,9 +118,9 @@
|
|||
.graph--hasYLabel {
|
||||
|
||||
.dygraph-axis-label-y {
|
||||
padding: 0 1px 0 12px !important;
|
||||
padding: 0 1px 0 10px !important;
|
||||
}
|
||||
.dygraph-axis-label-y2 {
|
||||
padding: 0 12px 0 1px !important;
|
||||
padding: 0 10px 0 1px !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,15 @@
|
|||
&--purple-scrollbar {
|
||||
@include custom-scrollbar($g2-kevlar,$c-comet);
|
||||
}
|
||||
|
||||
&.presentation-mode {
|
||||
top: 0;
|
||||
height: 100%;
|
||||
|
||||
.dashboard {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.container-fluid {
|
||||
padding: ($chronograf-page-header-height / 2) $page-wrapper-padding ($chronograf-page-header-height / 2) $page-wrapper-padding;
|
||||
|
@ -457,4 +466,4 @@ table .monotype {
|
|||
margin-bottom: 75px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,9 +13,31 @@
|
|||
background: linear-gradient(to right, $startColor 0%,$endColor 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 );
|
||||
}
|
||||
@mixin gradient-diag-up($startColor, $endColor) {
|
||||
background: $startColor;
|
||||
background: -moz-linear-gradient(45deg, $startColor 0%, $endColor 100%);
|
||||
background: -webkit-linear-gradient(45deg, $startColor 0%,$endColor 100%);
|
||||
background: linear-gradient(45deg, $startColor 0%,$endColor 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 );
|
||||
}
|
||||
@mixin gradient-diag-down($startColor, $endColor) {
|
||||
background: $startColor;
|
||||
background: -moz-linear-gradient(135deg, $startColor 0%, $endColor 100%);
|
||||
background: -webkit-linear-gradient(135deg, $startColor 0%,$endColor 100%);
|
||||
background: linear-gradient(135deg, $startColor 0%,$endColor 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 );
|
||||
}
|
||||
@mixin gradient-r($startColor, $endColor) {
|
||||
background: $startColor;
|
||||
background: -moz-radial-gradient(center, ellipse cover, $startColor 0%, $endColor 100%);
|
||||
background: -webkit-radial-gradient(center, ellipse cover, $startColor 0%,$endColor 100%);
|
||||
background: radial-gradient(ellipse at center, $startColor 0%,$endColor 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 );
|
||||
}
|
||||
|
||||
// Custom Scrollbars (Chrome Only)
|
||||
$scrollbar-width: 16px;
|
||||
$scrollbar-offset: 3px;
|
||||
@mixin custom-scrollbar($trackColor, $handleColor) {
|
||||
&::-webkit-scrollbar {
|
||||
width: $scrollbar-width;
|
||||
|
@ -30,12 +52,12 @@ $scrollbar-width: 16px;
|
|||
}
|
||||
&-track-piece {
|
||||
background-color: $trackColor;
|
||||
border: 3px solid $trackColor;
|
||||
border: $scrollbar-offset solid $trackColor;
|
||||
border-radius: ($scrollbar-width / 2);
|
||||
}
|
||||
&-thumb {
|
||||
background-color: $handleColor;
|
||||
border: 3px solid $trackColor;
|
||||
border: $scrollbar-offset solid $trackColor;
|
||||
border-radius: ($scrollbar-width / 2);
|
||||
}
|
||||
&-corner {
|
||||
|
@ -45,4 +67,4 @@ $scrollbar-width: 16px;
|
|||
&::-webkit-resizer {
|
||||
background-color: $trackColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,229 @@
|
|||
/*
|
||||
Variables
|
||||
------------------------------------------------------
|
||||
*/
|
||||
$dash-graph-heading: 30px;
|
||||
|
||||
|
||||
/*
|
||||
Animations
|
||||
------------------------------------------------------
|
||||
*/
|
||||
@keyframes refreshingSpinnerA {
|
||||
0% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; }
|
||||
33% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
66% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
100% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
}
|
||||
@keyframes refreshingSpinnerB {
|
||||
0% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
33% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; }
|
||||
66% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
100% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
}
|
||||
@keyframes refreshingSpinnerC {
|
||||
0% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
33% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
66% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; }
|
||||
100% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
}
|
||||
|
||||
/*
|
||||
Default Dashboard Mode
|
||||
------------------------------------------------------
|
||||
*/
|
||||
.dashboard {
|
||||
.react-grid-item {
|
||||
background-color: $g3-castle;
|
||||
border-radius: $radius;
|
||||
border: 2px solid $g3-castle;
|
||||
transition-property: left, top, border-color, background-color;
|
||||
}
|
||||
.graph-empty {
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
.dash-graph--container {
|
||||
user-select: none !important;
|
||||
-o-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: calc(100% - #{$dash-graph-heading});
|
||||
top: $dash-graph-heading;
|
||||
left: 0;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
cursor: crosshair;
|
||||
}
|
||||
& > div:not(.graph-empty) {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
& > div:not(.graph-panel__refreshing) {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
}
|
||||
.graph-panel__refreshing {
|
||||
top: (-$dash-graph-heading + 5px) !important;
|
||||
}
|
||||
}
|
||||
.dash-graph--heading {
|
||||
user-select: none !important;
|
||||
-o-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: $dash-graph-heading;
|
||||
padding: 0 16px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: $radius;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: $g14-chromium;
|
||||
transition:
|
||||
color 0.25s ease,
|
||||
background-color 0.25s ease;
|
||||
&:hover {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
.graph-panel__refreshing {
|
||||
position: absolute;
|
||||
top: -18px !important;
|
||||
transform: translate(0,0);
|
||||
right: 16px !important;
|
||||
width: 16px;
|
||||
height: 18px;
|
||||
|
||||
> div {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background-color: $g6-smoke;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%,-50%);
|
||||
}
|
||||
|
||||
div:nth-child(1) {left: 0; animation: refreshingSpinnerA 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; }
|
||||
div:nth-child(2) {left: 50%; animation: refreshingSpinnerB 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; }
|
||||
div:nth-child(3) {left: 100%; animation: refreshingSpinnerC 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;}
|
||||
}
|
||||
|
||||
/*
|
||||
Dashboard Edit Mode
|
||||
------------------------------------------------------
|
||||
*/
|
||||
.dashboard.dashboard-edit {
|
||||
.dash-graph--heading:hover {
|
||||
background-color: $g4-onyx;
|
||||
color: $g18-cloud;
|
||||
cursor: move; /* fallback if grab cursor is unsupported */
|
||||
cursor: grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: -webkit-grab;
|
||||
}
|
||||
.react-grid-placeholder {
|
||||
@include gradient-diag-down($c-pool,$c-comet);
|
||||
border: 0;
|
||||
opacity: 0.3;
|
||||
z-index: 2;
|
||||
}
|
||||
.react-grid-item {
|
||||
&.resizing {
|
||||
background-color: fade-out($g3-castle,0.09);
|
||||
border-color: $c-pool;
|
||||
border-image-slice: 3%;
|
||||
border-image-repeat: initial;
|
||||
border-image-outset: 0;
|
||||
border-image-width: 2px;
|
||||
border-image-source: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDY3IDc5LjE1Nzc0NywgMjAxNS8wMy8zMC0yMzo0MDo0MiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTg0NjVDRkVGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTg0NjVDRkZGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDoxODQ2NUNGQ0YwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDoxODQ2NUNGREYwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PpeetfIAAAMnSURBVHja7N1PatwwFMfxJ5NlKT1DIfQKWZfSA/Q0hexDL9McoOQAPUKglwhp6dZ9Ho/HfyTZs6l+b/E1GDm27IH5oH9Pyji9//7XfLtNZt88/eT722TzlvrFseXHaXFmypuO8vd5nmW6nyeNefrKfZv7i9f75blU/NzafXvns2dV7tl8zqsnT55+9f3Xjf/xwQ9+evou+xLB+N8Ydi4AX3z/6PnvOj94AEOGMV/rB4P00J2rKTC0GNOTPne0GWEwhv1NB0YYjNPWgREHI00gYMTAOIGAEQdjuKcDIw7GXGWBEQJjrLLACIORrFBlgaHDsG2VBYYWY1VlgaHHSH3WqIOhxLB1ow6GGmPRqIMRAeMMAkYUDFuGTsDQYwxP6MCIg1Hp9oKhwih0e8FQYthuLAuM5hj1WBYYEoxUjGWBIcOwrFEHQ4qxLiFgyDFOvSww4mCM8yFghMEoDgzB0GGk2owhGBoMq5UQMDQYxRIChg4ja0PA0GLYMrgIhh7jUkLAiIExV1lghMA4GBiC0RrjNIULRhyMysAQDBVGYWAIhhJjM6cOhhpjUULAiIAxr1wEIwTGPDAEIwTGWGWBEQajHu0FQ4JRjvaCIcPIo71gSDHW0V4w5Bj5SB0MKUZxoRwYOoxsPgQMLcZqPgQMPUaxUQdDh2HVcQgYEoxUHIeAIcPIqywwpBjrKgsMOcb8f+pghMDIwu9gaDFWI3Uw9Bg2N+pgRMA497LAiIJRXf0OhgajuPodDB3G1dFeMNpgXBXtBaMdxmG0F4y2GLvRXjDaY2wGhmCoMawU7QVDh5G20V4wtBjzwBCMEBiXVx6BEQPjsJcFRluM3V4WGO0xqr0sMDQYVuplgaHDWL1YEgw9hi17WWDoMVJ1ChcMCYYVp3DBkGFUl5KCocGw6deAwIiBYUfBRTDaYmTdXjC0GFYLLoKhwSj+cAAYOgzbBhfB0GKsgotg6DGuWrkIRjuMudsLRgiMsQ0BIwzG5ZVHYMTAmKqsVzBiYPj2Z+j2PoERAmM4/2MoIfe+v4Ahx3jx5H4AefYLd37q0Y9/g9EcY/jOHz11A3v+J8AA9wisahRCWTQAAAAASUVORK5CYII=);
|
||||
z-index: 3;
|
||||
|
||||
& > .react-resizable-handle {
|
||||
&:before, &:after {
|
||||
background-color: $c-comet;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.react-draggable-dragging {
|
||||
background-color: fade-out($g3-castle,0.09);
|
||||
border-color: $c-pool;
|
||||
border-image-slice: 3%;
|
||||
border-image-repeat: initial;
|
||||
border-image-outset: 0;
|
||||
border-image-width: 2px;
|
||||
border-image-source: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDY3IDc5LjE1Nzc0NywgMjAxNS8wMy8zMC0yMzo0MDo0MiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTg0NjVDRkVGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTg0NjVDRkZGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDoxODQ2NUNGQ0YwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDoxODQ2NUNGREYwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PpeetfIAAAMnSURBVHja7N1PatwwFMfxJ5NlKT1DIfQKWZfSA/Q0hexDL9McoOQAPUKglwhp6dZ9Ho/HfyTZs6l+b/E1GDm27IH5oH9Pyji9//7XfLtNZt88/eT722TzlvrFseXHaXFmypuO8vd5nmW6nyeNefrKfZv7i9f75blU/NzafXvns2dV7tl8zqsnT55+9f3Xjf/xwQ9+evou+xLB+N8Ydi4AX3z/6PnvOj94AEOGMV/rB4P00J2rKTC0GNOTPne0GWEwhv1NB0YYjNPWgREHI00gYMTAOIGAEQdjuKcDIw7GXGWBEQJjrLLACIORrFBlgaHDsG2VBYYWY1VlgaHHSH3WqIOhxLB1ow6GGmPRqIMRAeMMAkYUDFuGTsDQYwxP6MCIg1Hp9oKhwih0e8FQYthuLAuM5hj1WBYYEoxUjGWBIcOwrFEHQ4qxLiFgyDFOvSww4mCM8yFghMEoDgzB0GGk2owhGBoMq5UQMDQYxRIChg4ja0PA0GLYMrgIhh7jUkLAiIExV1lghMA4GBiC0RrjNIULRhyMysAQDBVGYWAIhhJjM6cOhhpjUULAiIAxr1wEIwTGPDAEIwTGWGWBEQajHu0FQ4JRjvaCIcPIo71gSDHW0V4w5Bj5SB0MKUZxoRwYOoxsPgQMLcZqPgQMPUaxUQdDh2HVcQgYEoxUHIeAIcPIqywwpBjrKgsMOcb8f+pghMDIwu9gaDFWI3Uw9Bg2N+pgRMA497LAiIJRXf0OhgajuPodDB3G1dFeMNpgXBXtBaMdxmG0F4y2GLvRXjDaY2wGhmCoMawU7QVDh5G20V4wtBjzwBCMEBiXVx6BEQPjsJcFRluM3V4WGO0xqr0sMDQYVuplgaHDWL1YEgw9hi17WWDoMVJ1ChcMCYYVp3DBkGFUl5KCocGw6deAwIiBYUfBRTDaYmTdXjC0GFYLLoKhwSj+cAAYOgzbBhfB0GKsgotg6DGuWrkIRjuMudsLRgiMsQ0BIwzG5ZVHYMTAmKqsVzBiYPj2Z+j2PoERAmM4/2MoIfe+v4Ahx3jx5H4AefYLd37q0Y9/g9EcY/jOHz11A3v+J8AA9wisahRCWTQAAAAASUVORK5CYII=);
|
||||
cursor: grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: -webkit-grabbing;
|
||||
&:hover {
|
||||
cursor: grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: -webkit-grabbing;
|
||||
}
|
||||
|
||||
& > .dash-graph--heading,
|
||||
& > .dash-graph--heading:hover {
|
||||
background-color: $g4-onyx;
|
||||
color: $g18-cloud;
|
||||
cursor: grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: -webkit-grabbing;
|
||||
}
|
||||
}
|
||||
&.cssTransforms {
|
||||
transition-property: transform, border-color, background-color;
|
||||
}
|
||||
& > .react-resizable-handle {
|
||||
background-image: none;
|
||||
cursor: nwse-resize;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
height: 2px;
|
||||
background-color: $g6-smoke;
|
||||
transition: background-color 0.25s ease;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
&:before {
|
||||
width: 20px;
|
||||
transform: translate(-50%,-50%) rotate(-45deg);
|
||||
}
|
||||
&:after {
|
||||
width: 12px;
|
||||
transform: translate(-3px,2px) rotate(-45deg);
|
||||
}
|
||||
&:hover {
|
||||
&:before, &:after {
|
||||
background-color: $c-comet;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,29 +3,13 @@
|
|||
----------------------------------------------
|
||||
*/
|
||||
|
||||
@keyframes refreshingSpinnerA {
|
||||
0% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; }
|
||||
33% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
66% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
100% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
}
|
||||
@keyframes refreshingSpinnerB {
|
||||
0% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
33% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; }
|
||||
66% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
100% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
}
|
||||
@keyframes refreshingSpinnerC {
|
||||
0% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
33% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
66% { transform: translate(-50%,-50%) scale(1.75); background-color: $g7-graphite; }
|
||||
100% { transform: translate(-50%,-50%) scale(1,1); }
|
||||
}
|
||||
.graph-container.hosts-graph {
|
||||
padding: 8px 16px;
|
||||
|
||||
.single-stat {
|
||||
font-size: 32px;
|
||||
font-size: 60px;
|
||||
font-weight: 300;
|
||||
color: $c-pool;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
@ -37,41 +21,8 @@
|
|||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-panel__refreshing {
|
||||
position: absolute;
|
||||
top: -18px !important;
|
||||
transform: translate(0,0);
|
||||
right: 16px !important;
|
||||
width: 16px;
|
||||
height: 18px;
|
||||
|
||||
> div {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background-color: $g6-smoke;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%,-50%);
|
||||
}
|
||||
|
||||
div:nth-child(1) {left: 0; animation: refreshingSpinnerA 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; }
|
||||
div:nth-child(2) {left: 50%; animation: refreshingSpinnerB 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite; }
|
||||
div:nth-child(3) {left: 100%; animation: refreshingSpinnerC 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;}
|
||||
}
|
||||
}
|
||||
.hosts-graph-heading {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
background-color: $g3-castle;
|
||||
padding: 14px 16px 2px 16px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: $g14-chromium;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.host-list--active-source {
|
||||
text-transform: uppercase;
|
||||
font-size: 15px;
|
||||
|
@ -89,8 +40,6 @@
|
|||
}
|
||||
|
||||
/* Hacky way to ensure that legends cannot be obscured by neighboring graphs */
|
||||
.react-grid-item {
|
||||
&:hover {
|
||||
z-index: 8999;
|
||||
}
|
||||
div:not(.dashboard-edit) .react-grid-item:hover {
|
||||
z-index: 8999;
|
||||
}
|
||||
|
|
|
@ -74,13 +74,14 @@
|
|||
line-height: 30px !important;
|
||||
height: 30px !important;
|
||||
padding: 0 9px !important;
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
margin: 0 4px 0 0 ;
|
||||
}
|
||||
}
|
||||
.btn.btn-xs .icon {
|
||||
a.btn.btn-sm > span.icon,
|
||||
div.btn.btn-sm > span.icon,
|
||||
button.btn.btn-sm > span.icon {
|
||||
font-size: 16px;
|
||||
margin: 0 4px 0 0 ;
|
||||
}
|
||||
.btn.btn-xs > .icon {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
|
|
@ -1,20 +1,56 @@
|
|||
import axios from 'axios';
|
||||
|
||||
export default function AJAX({
|
||||
let links
|
||||
|
||||
const UNAUTHORIZED = 401
|
||||
|
||||
export default async function AJAX({
|
||||
url,
|
||||
resource,
|
||||
id,
|
||||
method = 'GET',
|
||||
data = {},
|
||||
params = {},
|
||||
headers = {},
|
||||
}) {
|
||||
if (window.basepath) {
|
||||
url = `${window.basepath}${url}`;
|
||||
let response
|
||||
|
||||
try {
|
||||
const basepath = window.basepath || ''
|
||||
|
||||
url = `${basepath}${url}`
|
||||
|
||||
if (!links) {
|
||||
const linksRes = response = await axios({
|
||||
url: `${basepath}/chronograf/v1`,
|
||||
method: 'GET',
|
||||
})
|
||||
links = linksRes.data
|
||||
}
|
||||
|
||||
const {auth} = links
|
||||
|
||||
if (resource) {
|
||||
url = id ? `${basepath}${links[resource]}/${id}` : `${basepath}${links[resource]}`
|
||||
}
|
||||
|
||||
response = await axios({
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
params,
|
||||
headers,
|
||||
})
|
||||
|
||||
return {
|
||||
auth,
|
||||
...response,
|
||||
}
|
||||
} catch (error) {
|
||||
if (!response.status === UNAUTHORIZED) {
|
||||
console.error(error) // eslint-disable-line no-console
|
||||
}
|
||||
const {auth} = links
|
||||
throw {auth, ...response} // eslint-disable-line no-throw-literal
|
||||
}
|
||||
return axios({
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
params,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -81,6 +81,9 @@ module.exports = {
|
|||
new webpack.optimize.CommonsChunkPlugin({
|
||||
names: ['vendor', 'manifest'],
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
VERSION: JSON.stringify(require('../package.json').version),
|
||||
}),
|
||||
],
|
||||
postcss: require('./postcss'),
|
||||
target: 'web',
|
||||
|
|
|
@ -101,7 +101,10 @@ var config = {
|
|||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
new webpack.DefinePlugin({
|
||||
VERSION: JSON.stringify(require('../package.json').version),
|
||||
}),
|
||||
],
|
||||
postcss: require('./postcss'),
|
||||
target: 'web',
|
||||
|
|
35
uuid/v4.go
35
uuid/v4.go
|
@ -1,12 +1,6 @@
|
|||
package uuid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
import uuid "github.com/satori/go.uuid"
|
||||
|
||||
// V4 implements chronograf.ID
|
||||
type V4 struct{}
|
||||
|
@ -15,30 +9,3 @@ type V4 struct{}
|
|||
func (i *V4) Generate() (string, error) {
|
||||
return uuid.NewV4().String(), nil
|
||||
}
|
||||
|
||||
// APIKey implements chronograf.Authenticator using V4
|
||||
type APIKey struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
// NewAPIKey creates an APIKey with a UUID v4 Key
|
||||
func NewAPIKey() chronograf.Authenticator {
|
||||
v4 := V4{}
|
||||
key, _ := v4.Generate()
|
||||
return &APIKey{
|
||||
Key: key,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate checks the key against the UUID v4 key
|
||||
func (k *APIKey) Authenticate(ctx context.Context, key string) (chronograf.Principal, error) {
|
||||
if key != k.Key {
|
||||
return "", chronograf.ErrAuthentication
|
||||
}
|
||||
return "admin", nil
|
||||
}
|
||||
|
||||
// Token returns the UUID v4 key
|
||||
func (k *APIKey) Token(context.Context, chronograf.Principal, time.Duration) (string, error) {
|
||||
return k.Key, nil
|
||||
}
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
package uuid_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/influxdata/chronograf"
|
||||
"github.com/influxdata/chronograf/uuid"
|
||||
)
|
||||
|
||||
func TestAuthenticate(t *testing.T) {
|
||||
var tests = []struct {
|
||||
Desc string
|
||||
APIKey string
|
||||
Key string
|
||||
Err error
|
||||
User chronograf.Principal
|
||||
}{
|
||||
|
||||
{
|
||||
Desc: "Test auth err when keys are different",
|
||||
APIKey: "key",
|
||||
Key: "badkey",
|
||||
Err: chronograf.ErrAuthentication,
|
||||
User: "",
|
||||
},
|
||||
{
|
||||
Desc: "Test that admin user comes back",
|
||||
APIKey: "key",
|
||||
Key: "key",
|
||||
Err: nil,
|
||||
User: "admin",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
k := uuid.APIKey{
|
||||
Key: test.APIKey,
|
||||
}
|
||||
u, err := k.Authenticate(context.Background(), test.Key)
|
||||
if err != test.Err {
|
||||
t.Errorf("Auth error different; expected %v actual %v", test.Err, err)
|
||||
}
|
||||
if u != test.User {
|
||||
t.Errorf("Auth user different; expected %v actual %v", test.User, u)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue