Merge branch 'master' into feature/#54-tr-enterprise-client

Conflicts:
	Makefile
	chronograf.go
	server/routes.go
	server/users.go
pull/10616/head
Chris Goller 2017-02-23 23:26:09 -06:00
commit f60a358751
81 changed files with 3475 additions and 1012 deletions

View File

@ -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
View File

@ -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

View File

@ -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)

View File

@ -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 .

View File

@ -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

View File

@ -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": ""
}
]
}

View File

@ -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

View File

@ -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
```

View File

@ -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
})

View File

@ -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)

140
oauth2/doc.go Normal file
View File

@ -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

162
oauth2/github.go Normal file
View File

@ -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")
}

113
oauth2/github_test.go Normal file
View File

@ -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)
}
}

89
oauth2/google.go Normal file
View File

@ -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")
}

102
oauth2/google_test.go Normal file
View File

@ -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)
}
}

101
oauth2/heroku.go Normal file
View File

@ -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
}

102
oauth2/heroku_test.go Normal file
View File

@ -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")
}
}

View File

@ -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(),

View File

@ -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)

169
oauth2/mux.go Normal file
View File

@ -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)
})
}

157
oauth2/mux_test.go Normal file
View File

@ -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)
}
}

75
oauth2/oauth2.go Normal file
View File

@ -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)
}

100
oauth2/oauth2_test.go Normal file
View File

@ -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)
}

View File

@ -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
}
}

299
server/dashboards_test.go Normal file
View File

@ -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)
}
}
}

View File

@ -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")
}

21
server/logout.go Normal file
View File

@ -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)
}
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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

View File

@ -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"
}

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -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)
})
})

View File

@ -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,

View File

@ -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)

View File

@ -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,
},
}
}

View File

@ -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,
});
}

View File

@ -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

View File

@ -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" />
&nbsp;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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,12 @@
export const EMPTY_DASHBOARD = {
id: 0,
name: '',
cells: [
{
x: 0,
y: 0,
queries: [],
name: 'Loading...',
},
],
}

View File

@ -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);

View File

@ -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>

View File

@ -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;
}

View File

@ -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}

View File

@ -87,7 +87,7 @@ export async function getAllHosts(proxyLink, telegrafDB) {
export function getMappings() {
return AJAX({
method: 'GET',
url: `/chronograf/v1/mappings`,
resource: 'mappings',
});
}

View File

@ -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)

View File

@ -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>
);

View File

@ -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;

View File

@ -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);

View File

@ -0,0 +1,8 @@
export function receiveAuth(auth) {
return {
type: 'AUTH_RECEIVED',
payload: {
auth,
},
};
}

View File

@ -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',

View File

@ -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)
}
}

View File

@ -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,
});

View File

@ -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() {

View File

@ -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;

View File

@ -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}

View File

@ -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.

View File

@ -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))
}

View File

@ -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;
}

View File

@ -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,
};

View File

@ -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
}

View File

@ -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>

View File

@ -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,
};
}

View File

@ -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;

View File

@ -45,6 +45,7 @@
@import 'pages/hosts';
@import 'pages/kapacitor';
@import 'pages/data-explorer';
@import 'pages/dashboards';
// TODO
@import 'unsorted';

View File

@ -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;
}
}

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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,
});
}

View File

@ -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',

View File

@ -101,7 +101,10 @@ var config = {
process.exit(1);
}
});
}
},
new webpack.DefinePlugin({
VERSION: JSON.stringify(require('../package.json').version),
}),
],
postcss: require('./postcss'),
target: 'web',

View File

@ -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
}

View File

@ -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)
}
}
}