Merge branch 'master' into alert-message-polish

pull/10616/head
Alex P 2017-03-13 16:10:04 -07:00
commit 5f1dd3e12c
70 changed files with 5653 additions and 2469 deletions

View File

@ -1,14 +1,26 @@
## v1.2.0 [unreleased] ## v1.2.0 [unreleased]
### Bug Fixes ### Bug Fixes
1. [#936](https://github.com/influxdata/chronograf/pull/936): Fix leaking sockets for InfluxQL queries
2. [#967](https://github.com/influxdata/chronograf/pull/967): Fix flash of empty graph on auto-refresh when no results were previously returned from a query.
3. [#968](https://github.com/influxdata/chronograf/issue/968): Fix wrong database used in dashboards
### Features ### Features
### UI Improvements
## v1.2.0-beta5 [2017-03-10]
### Bug Fixes
1. [#936](https://github.com/influxdata/chronograf/pull/936): Fix leaking sockets for InfluxQL queries
2. [#967](https://github.com/influxdata/chronograf/pull/967): Fix flash of empty graph on auto-refresh when no results were previously returned from a query
3. [#968](https://github.com/influxdata/chronograf/issue/968): Fix wrong database used in dashboards
### Features
1. [#993](https://github.com/influxdata/chronograf/pull/993): Add Admin page for managing users, roles, and permissions for [OSS InfluxDB](https://github.com/influxdata/influxdb) and InfluxData's [Enterprise](https://docs.influxdata.com/enterprise/v1.2/) product
2. [#993](https://github.com/influxdata/chronograf/pull/993): Add Query Management features including the ability to view active queries and stop queries
### UI Improvements ### UI Improvements
1. [#989](https://github.com/influxdata/chronograf/pull/989) Add a canned dashboard for mesos 1. [#989](https://github.com/influxdata/chronograf/pull/989) Add a canned dashboard for mesos
2. [#993](https://github.com/influxdata/chronograf/pull/993): Improve the multi-select dropdown
3. [#993](https://github.com/influxdata/chronograf/pull/993): Provide better error information to users
## v1.2.0-beta4 [2017-02-24] ## v1.2.0-beta4 [2017-02-24]

View File

@ -110,7 +110,14 @@ A UI for [Kapacitor](https://github.com/influxdata/kapacitor) alert creation and
* View all active alerts at a glance on the alerting dashboard * View all active alerts at a glance on the alerting dashboard
* Enable and disable existing alert rules with the check of a box * Enable and disable existing alert rules with the check of a box
### TLS/HTTPS support ### User and Query Management
Manage users, roles, permissions for [OSS InfluxDB](https://github.com/influxdata/influxdb) and InfluxData's [Enterprise](https://docs.influxdata.com/enterprise/v1.2/) product.
View actively running queries and stop expensive queries on the Query Management page.
These features are new in Chronograf version 1.2.0-beta5. We recommend using them in a non-production environment only. Should you come across any bugs or unexpected behavior please open [an issue](https://github.com/influxdata/chronograf/issues/new). We appreciate the feedback as we work to finalize and improve the user and query management features!
### TLS/HTTPS Support
See [Chronograf with TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) for more information. See [Chronograf with TLS](https://github.com/influxdata/chronograf/blob/master/docs/tls.md) for more information.
### OAuth Login ### OAuth Login
@ -121,7 +128,7 @@ Change the default root path of the Chronograf server with the `--basepath` opti
## Versions ## Versions
Chronograf v1.2.0-beta4 is a beta release. Chronograf v1.2.0-beta5 is a beta release.
We will be iterating quickly based on user feedback and recommend using the [nightly builds](https://www.influxdata.com/downloads/) for the time being. We will be iterating quickly based on user feedback and recommend using the [nightly builds](https://www.influxdata.com/downloads/) for the time being.
Spotted a bug or have a feature request? Spotted a bug or have a feature request?

View File

@ -32,6 +32,8 @@ type Ctrl interface {
DeleteRole(ctx context.Context, name string) error DeleteRole(ctx context.Context, name string) error
SetRolePerms(ctx context.Context, name string, perms Permissions) error SetRolePerms(ctx context.Context, name string, perms Permissions) error
SetRoleUsers(ctx context.Context, name string, users []string) error SetRoleUsers(ctx context.Context, name string, users []string) error
AddRoleUsers(ctx context.Context, name string, users []string) error
RemoveRoleUsers(ctx context.Context, name string, users []string) error
} }
// Client is a device for retrieving time series data from an Influx Enterprise // Client is a device for retrieving time series data from an Influx Enterprise

View File

@ -272,32 +272,52 @@ func (m *MetaClient) SetRolePerms(ctx context.Context, name string, perms Permis
return m.Post(ctx, "/role", a, nil) return m.Post(ctx, "/role", a, nil)
} }
// RemoveAllRoleUsers removes all users from a role // SetRoleUsers removes all users and then adds the requested users to role
func (m *MetaClient) RemoveAllRoleUsers(ctx context.Context, name string) error { func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []string) error {
role, err := m.Role(ctx, name) role, err := m.Role(ctx, name)
if err != nil { if err != nil {
return err return err
} }
revoke, add := Difference(users, role.Users)
// No users to remove if err := m.RemoveRoleUsers(ctx, name, revoke); err != nil {
if len(role.Users) == 0 {
return nil
}
a := &RoleAction{
Action: "remove-users",
Role: role,
}
return m.Post(ctx, "/role", a, nil)
}
// SetRoleUsers removes all users and then adds the requested users to role
func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []string) error {
err := m.RemoveAllRoleUsers(ctx, name)
if err != nil {
return err return err
} }
return m.AddRoleUsers(ctx, name, add)
}
// Difference compares two sets and returns a set to be removed and a set to be added
func Difference(wants []string, haves []string) (revoke []string, add []string) {
for _, want := range wants {
found := false
for _, got := range haves {
if want != got {
continue
}
found = true
}
if !found {
add = append(add, want)
}
}
for _, got := range haves {
found := false
for _, want := range wants {
if want != got {
continue
}
found = true
break
}
if !found {
revoke = append(revoke, got)
}
}
return
}
// AddRoleUsers updates a role to have additional users.
func (m *MetaClient) AddRoleUsers(ctx context.Context, name string, users []string) error {
// No permissions to add, so, role is in the right state // No permissions to add, so, role is in the right state
if len(users) == 0 { if len(users) == 0 {
return nil return nil
@ -313,6 +333,23 @@ func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []stri
return m.Post(ctx, "/role", a, nil) return m.Post(ctx, "/role", a, nil)
} }
// RemoveRoleUsers updates a role to remove some users.
func (m *MetaClient) RemoveRoleUsers(ctx context.Context, name string, users []string) error {
// No permissions to add, so, role is in the right state
if len(users) == 0 {
return nil
}
a := &RoleAction{
Action: "remove-users",
Role: &Role{
Name: name,
Users: users,
},
}
return m.Post(ctx, "/role", a, nil)
}
// Post is a helper function to POST to Influx Enterprise // Post is a helper function to POST to Influx Enterprise
func (m *MetaClient) Post(ctx context.Context, path string, action interface{}, params map[string]string) error { func (m *MetaClient) Post(ctx context.Context, path string, action interface{}, params map[string]string) error {
b, err := json.Marshal(action) b, err := json.Marshal(action)

View File

@ -1252,12 +1252,11 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
name string name string
fields fields fields fields
args args args args
wantRm string wants []string
wantAdd string
wantErr bool wantErr bool
}{ }{
{ {
name: "Successful set users role", name: "Successful set users role (remove user from role)",
fields: fields{ fields: fields{
URL: &url.URL{ URL: &url.URL{
Host: "twinpinesmall.net:8091", Host: "twinpinesmall.net:8091",
@ -1274,7 +1273,7 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
ctx: context.Background(), ctx: context.Background(),
name: "admin", name: "admin",
}, },
wantRm: `{"action":"remove-users","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`, wants: []string{`{"action":"remove-users","role":{"name":"admin","users":["marty"]}}`},
}, },
{ {
name: "Successful set single user role", name: "Successful set single user role",
@ -1285,7 +1284,7 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
}, },
client: NewMockClient( client: NewMockClient(
http.StatusOK, http.StatusOK,
[]byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), []byte(`{"roles":[{"name":"admin","users":[],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`),
nil, nil,
nil, nil,
), ),
@ -1295,8 +1294,9 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
name: "admin", name: "admin",
users: []string{"marty"}, users: []string{"marty"},
}, },
wantRm: `{"action":"remove-users","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`, wants: []string{
wantAdd: `{"action":"add-users","role":{"name":"admin","users":["marty"]}}`, `{"action":"add-users","role":{"name":"admin","users":["marty"]}}`,
},
}, },
} }
for _, tt := range tests { for _, tt := range tests {
@ -1312,8 +1312,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
continue continue
} }
reqs := tt.fields.client.(*MockClient).Requests reqs := tt.fields.client.(*MockClient).Requests
if len(reqs) < 2 { if len(reqs) != len(tt.wants)+1 {
t.Errorf("%q. MetaClient.SetRoleUsers() expected 2 but got %d", tt.name, len(reqs)) t.Errorf("%q. MetaClient.SetRoleUsers() expected %d but got %d", tt.name, len(tt.wants)+1, len(reqs))
continue continue
} }
@ -1324,21 +1324,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
if usr.URL.Path != "/role" { if usr.URL.Path != "/role" {
t.Errorf("%q. MetaClient.SetRoleUsers() expected /user path but got %s", tt.name, usr.URL.Path) t.Errorf("%q. MetaClient.SetRoleUsers() expected /user path but got %s", tt.name, usr.URL.Path)
} }
for i := range tt.wants {
prm := reqs[1] prm := reqs[i+1]
if prm.Method != "POST" {
t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name)
}
if prm.URL.Path != "/role" {
t.Errorf("%q. MetaClient.SetRoleUsers() expected /role path but got %s", tt.name, prm.URL.Path)
}
got, _ := ioutil.ReadAll(prm.Body)
if string(got) != tt.wantRm {
t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wantRm)
}
if tt.wantAdd != "" {
prm := reqs[2]
if prm.Method != "POST" { if prm.Method != "POST" {
t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name) t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name)
} }
@ -1347,8 +1334,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
} }
got, _ := ioutil.ReadAll(prm.Body) got, _ := ioutil.ReadAll(prm.Body)
if string(got) != tt.wantAdd { if string(got) != tt.wants[i] {
t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wantAdd) t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wants[i])
} }
} }
} }

View File

@ -88,6 +88,14 @@ func (cc *ControlClient) SetRoleUsers(ctx context.Context, name string, users []
return nil return nil
} }
func (cc *ControlClient) AddRoleUsers(ctx context.Context, name string, users []string) error {
return nil
}
func (cc *ControlClient) RemoveRoleUsers(ctx context.Context, name string, users []string) error {
return nil
}
type TimeSeries struct { type TimeSeries struct {
URLs []string URLs []string
Response Response Response Response

View File

@ -66,16 +66,20 @@ func (c *RolesStore) Get(ctx context.Context, name string) (*chronograf.Role, er
// Update the Role's permissions and roles // Update the Role's permissions and roles
func (c *RolesStore) Update(ctx context.Context, u *chronograf.Role) error { func (c *RolesStore) Update(ctx context.Context, u *chronograf.Role) error {
perms := ToEnterprise(u.Permissions) if u.Permissions != nil {
if err := c.Ctrl.SetRolePerms(ctx, u.Name, perms); err != nil { perms := ToEnterprise(u.Permissions)
return err if err := c.Ctrl.SetRolePerms(ctx, u.Name, perms); err != nil {
return err
}
} }
if u.Users != nil {
users := make([]string, len(u.Users)) users := make([]string, len(u.Users))
for i, u := range u.Users { for i, u := range u.Users {
users[i] = u.Name users[i] = u.Name
}
return c.Ctrl.SetRoleUsers(ctx, u.Name, users)
} }
return c.Ctrl.SetRoleUsers(ctx, u.Name, users) return nil
} }
// All is all Roles in influx // All is all Roles in influx

View File

@ -18,10 +18,17 @@ func (c *UserStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.Us
return nil, err return nil, err
} }
perms := ToEnterprise(u.Permissions) perms := ToEnterprise(u.Permissions)
if err := c.Ctrl.SetUserPerms(ctx, u.Name, perms); err != nil { if err := c.Ctrl.SetUserPerms(ctx, u.Name, perms); err != nil {
return nil, err return nil, err
} }
return u, nil for _, role := range u.Roles {
if err := c.Ctrl.AddRoleUsers(ctx, role.Name, []string{u.Name}); err != nil {
return nil, err
}
}
return c.Get(ctx, u.Name)
} }
// Delete the User from Influx Enterprise // Delete the User from Influx Enterprise
@ -62,6 +69,43 @@ func (c *UserStore) Update(ctx context.Context, u *chronograf.User) error {
if u.Passwd != "" { if u.Passwd != "" {
return c.Ctrl.ChangePassword(ctx, u.Name, u.Passwd) return c.Ctrl.ChangePassword(ctx, u.Name, u.Passwd)
} }
// Make a list of the roles we want this user to have:
want := make([]string, len(u.Roles))
for i, r := range u.Roles {
want[i] = r.Name
}
// Find the list of all roles this user is currently in
userRoles, err := c.UserRoles(ctx)
if err != nil {
return nil
}
// Make a list of the roles the user currently has
roles := userRoles[u.Name]
have := make([]string, len(roles.Roles))
for i, r := range roles.Roles {
have[i] = r.Name
}
// Calculate the roles the user will be removed from and the roles the user
// will be added to.
revoke, add := Difference(want, have)
// First, add the user to the new roles
for _, role := range add {
if err := c.Ctrl.AddRoleUsers(ctx, role, []string{u.Name}); err != nil {
return err
}
}
// ... and now remove the user from an extra roles
for _, role := range revoke {
if err := c.Ctrl.RemoveRoleUsers(ctx, role, []string{u.Name}); err != nil {
return err
}
}
perms := ToEnterprise(u.Permissions) perms := ToEnterprise(u.Permissions)
return c.Ctrl.SetUserPerms(ctx, u.Name, perms) return c.Ctrl.SetUserPerms(ctx, u.Name, perms)
} }

View File

@ -36,6 +36,22 @@ func TestClient_Add(t *testing.T) {
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error { setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
return nil return nil
}, },
user: func(ctx context.Context, name string) (*enterprise.User, error) {
return &enterprise.User{
Name: "marty",
Password: "johnny be good",
Permissions: map[string][]string{
"": {
"ViewChronograf",
"ReadData",
"WriteData",
},
},
}, nil
},
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
return map[string]enterprise.Roles{}, nil
},
}, },
}, },
args: args{ args: args{
@ -46,8 +62,82 @@ func TestClient_Add(t *testing.T) {
}, },
}, },
want: &chronograf.User{ want: &chronograf.User{
Name: "marty", Name: "marty",
Passwd: "johnny be good", Permissions: chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"ViewChronograf", "ReadData", "WriteData"},
},
},
Roles: []chronograf.Role{},
},
},
{
name: "Successful Create User with roles",
fields: fields{
Ctrl: &mockCtrl{
createUser: func(ctx context.Context, name, passwd string) error {
return nil
},
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
return nil
},
user: func(ctx context.Context, name string) (*enterprise.User, error) {
return &enterprise.User{
Name: "marty",
Password: "johnny be good",
Permissions: map[string][]string{
"": {
"ViewChronograf",
"ReadData",
"WriteData",
},
},
}, nil
},
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
return map[string]enterprise.Roles{
"marty": enterprise.Roles{
Roles: []enterprise.Role{
{
Name: "admin",
},
},
},
}, nil
},
addRoleUsers: func(ctx context.Context, name string, users []string) error {
return nil
},
},
},
args: args{
ctx: context.Background(),
u: &chronograf.User{
Name: "marty",
Passwd: "johnny be good",
Roles: []chronograf.Role{
{
Name: "admin",
},
},
},
},
want: &chronograf.User{
Name: "marty",
Permissions: chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"ViewChronograf", "ReadData", "WriteData"},
},
},
Roles: []chronograf.Role{
{
Name: "admin",
Users: []chronograf.User{},
Permissions: chronograf.Permissions{},
},
},
}, },
}, },
{ {
@ -80,7 +170,7 @@ func TestClient_Add(t *testing.T) {
continue continue
} }
if !reflect.DeepEqual(got, tt.want) { if !reflect.DeepEqual(got, tt.want) {
t.Errorf("%q. Client.Add() = %v, want %v", tt.name, got, tt.want) t.Errorf("%q. Client.Add() = \n%#v\n, want \n%#v\n", tt.name, got, tt.want)
} }
} }
} }
@ -353,6 +443,9 @@ func TestClient_Update(t *testing.T) {
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error { setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
return nil return nil
}, },
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
return map[string]enterprise.Roles{}, nil
},
}, },
}, },
args: args{ args: args{
@ -369,6 +462,40 @@ func TestClient_Update(t *testing.T) {
}, },
wantErr: false, wantErr: false,
}, },
{
name: "Success setting permissions and roles for user",
fields: fields{
Ctrl: &mockCtrl{
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
return nil
},
addRoleUsers: func(ctx context.Context, name string, users []string) error {
return nil
},
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
return map[string]enterprise.Roles{}, nil
},
},
},
args: args{
ctx: context.Background(),
u: &chronograf.User{
Name: "marty",
Permissions: chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"ViewChronograf", "KapacitorAPI"},
},
},
Roles: []chronograf.Role{
{
Name: "adminrole",
},
},
},
},
wantErr: false,
},
{ {
name: "Failure setting permissions User", name: "Failure setting permissions User",
fields: fields{ fields: fields{
@ -376,6 +503,9 @@ func TestClient_Update(t *testing.T) {
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error { setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
return fmt.Errorf("They found me, I don't know how, but they found me.") return fmt.Errorf("They found me, I don't know how, but they found me.")
}, },
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
return map[string]enterprise.Roles{}, nil
},
}, },
}, },
args: args{ args: args{
@ -573,12 +703,14 @@ type mockCtrl struct {
userRoles func(ctx context.Context) (map[string]enterprise.Roles, error) userRoles func(ctx context.Context) (map[string]enterprise.Roles, error)
roles func(ctx context.Context, name *string) (*enterprise.Roles, error) roles func(ctx context.Context, name *string) (*enterprise.Roles, error)
role func(ctx context.Context, name string) (*enterprise.Role, error) role func(ctx context.Context, name string) (*enterprise.Role, error)
createRole func(ctx context.Context, name string) error createRole func(ctx context.Context, name string) error
deleteRole func(ctx context.Context, name string) error deleteRole func(ctx context.Context, name string) error
setRolePerms func(ctx context.Context, name string, perms enterprise.Permissions) error setRolePerms func(ctx context.Context, name string, perms enterprise.Permissions) error
setRoleUsers func(ctx context.Context, name string, users []string) error setRoleUsers func(ctx context.Context, name string, users []string) error
addRoleUsers func(ctx context.Context, name string, users []string) error
removeRoleUsers func(ctx context.Context, name string, users []string) error
} }
func (m *mockCtrl) ShowCluster(ctx context.Context) (*enterprise.Cluster, error) { func (m *mockCtrl) ShowCluster(ctx context.Context) (*enterprise.Cluster, error) {
@ -636,3 +768,11 @@ func (m *mockCtrl) SetRolePerms(ctx context.Context, name string, perms enterpri
func (m *mockCtrl) SetRoleUsers(ctx context.Context, name string, users []string) error { func (m *mockCtrl) SetRoleUsers(ctx context.Context, name string, users []string) error {
return m.setRoleUsers(ctx, name, users) return m.setRoleUsers(ctx, name, users)
} }
func (m *mockCtrl) AddRoleUsers(ctx context.Context, name string, users []string) error {
return m.addRoleUsers(ctx, name, users)
}
func (m *mockCtrl) RemoveRoleUsers(ctx context.Context, name string, users []string) error {
return m.removeRoleUsers(ctx, name, users)
}

View File

@ -8,8 +8,10 @@ import (
) )
var ( var (
// AllowAll means a user gets both read and write permissions // AllowAllDB means a user gets both read and write permissions for a db
AllowAll = chronograf.Allowances{"WRITE", "READ"} AllowAllDB = chronograf.Allowances{"WRITE", "READ"}
// AllowAllAdmin means a user gets both read and write permissions for an admin
AllowAllAdmin = chronograf.Allowances{"ALL"}
// AllowRead means a user is only able to read the database. // AllowRead means a user is only able to read the database.
AllowRead = chronograf.Allowances{"READ"} AllowRead = chronograf.Allowances{"READ"}
// AllowWrite means a user is able to only write to the database // AllowWrite means a user is able to only write to the database
@ -31,11 +33,11 @@ func (c *Client) Permissions(context.Context) chronograf.Permissions {
return chronograf.Permissions{ return chronograf.Permissions{
{ {
Scope: chronograf.AllScope, Scope: chronograf.AllScope,
Allowed: AllowAll, Allowed: AllowAllAdmin,
}, },
{ {
Scope: chronograf.DBScope, Scope: chronograf.DBScope,
Allowed: AllowAll, Allowed: AllowAllDB,
}, },
} }
} }
@ -90,7 +92,7 @@ func (r *showResults) Permissions() chronograf.Permissions {
} }
switch priv { switch priv {
case AllPrivileges, All: case AllPrivileges, All:
c.Allowed = AllowAll c.Allowed = AllowAllDB
case Read: case Read:
c.Allowed = AllowRead c.Allowed = AllowRead
case Write: case Write:
@ -111,7 +113,7 @@ func adminPerms() chronograf.Permissions {
return []chronograf.Permission{ return []chronograf.Permission{
{ {
Scope: chronograf.AllScope, Scope: chronograf.AllScope,
Allowed: AllowAll, Allowed: AllowAllAdmin,
}, },
} }
} }

View File

@ -318,7 +318,7 @@ func Test_showResults_Users(t *testing.T) {
Permissions: chronograf.Permissions{ Permissions: chronograf.Permissions{
{ {
Scope: chronograf.AllScope, Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"WRITE", "READ"}, Allowed: chronograf.Allowances{"ALL"},
}, },
}, },
}, },

View File

@ -16,8 +16,12 @@ func (c *Client) Add(ctx context.Context, u *chronograf.User) (*chronograf.User,
if err != nil { if err != nil {
return nil, err return nil, err
} }
for _, p := range u.Permissions {
return u, nil if err := c.grantPermission(ctx, u.Name, p); err != nil {
return nil, err
}
}
return c.Get(ctx, u.Name)
} }
// Delete the User from InfluxDB // Delete the User from InfluxDB

View File

@ -97,12 +97,12 @@ func TestClient_Add(t *testing.T) {
u *chronograf.User u *chronograf.User
} }
tests := []struct { tests := []struct {
name string name string
args args args args
status int status int
want *chronograf.User want *chronograf.User
wantQuery string wantQueries []string
wantErr bool wantErr bool
}{ }{
{ {
name: "Create User", name: "Create User",
@ -114,10 +114,57 @@ func TestClient_Add(t *testing.T) {
Passwd: "Dont Need Roads", Passwd: "Dont Need Roads",
}, },
}, },
wantQuery: `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`, wantQueries: []string{
`CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`,
`SHOW USERS`,
`SHOW GRANTS FOR "docbrown"`,
},
want: &chronograf.User{ want: &chronograf.User{
Name: "docbrown", Name: "docbrown",
Passwd: "Dont Need Roads", Permissions: chronograf.Permissions{
chronograf.Permission{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{
"ALL",
},
},
},
},
},
{
name: "Create User with permissions",
status: http.StatusOK,
args: args{
ctx: context.Background(),
u: &chronograf.User{
Name: "docbrown",
Passwd: "Dont Need Roads",
Permissions: chronograf.Permissions{
chronograf.Permission{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{
"ALL",
},
},
},
},
},
wantQueries: []string{
`CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`,
`GRANT ALL PRIVILEGES TO "docbrown"`,
`SHOW USERS`,
`SHOW GRANTS FOR "docbrown"`,
},
want: &chronograf.User{
Name: "docbrown",
Permissions: chronograf.Permissions{
chronograf.Permission{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{
"ALL",
},
},
},
}, },
}, },
{ {
@ -130,19 +177,19 @@ func TestClient_Add(t *testing.T) {
Passwd: "Dont Need Roads", Passwd: "Dont Need Roads",
}, },
}, },
wantQuery: `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`, wantQueries: []string{`CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`},
wantErr: true, wantErr: true,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
query := "" queries := []string{}
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if path := r.URL.Path; path != "/query" { if path := r.URL.Path; path != "/query" {
t.Error("Expected the path to contain `/query` but was", path) t.Error("Expected the path to contain `/query` but was", path)
} }
query = r.URL.Query().Get("q") queries = append(queries, r.URL.Query().Get("q"))
rw.WriteHeader(tt.status) rw.WriteHeader(tt.status)
rw.Write([]byte(`{"results":[{}]}`)) rw.Write([]byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`))
})) }))
u, _ := url.Parse(ts.URL) u, _ := url.Parse(ts.URL)
c := &Client{ c := &Client{
@ -155,9 +202,16 @@ func TestClient_Add(t *testing.T) {
t.Errorf("%q. Client.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr) t.Errorf("%q. Client.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr)
continue continue
} }
if tt.wantQuery != query { if len(tt.wantQueries) != len(queries) {
t.Errorf("%q. Client.Add() query = %v, want %v", tt.name, query, tt.wantQuery) t.Errorf("%q. Client.Add() queries = %v, want %v", tt.name, queries, tt.wantQueries)
continue
} }
for i := range tt.wantQueries {
if tt.wantQueries[i] != queries[i] {
t.Errorf("%q. Client.Add() query = %v, want %v", tt.name, queries[i], tt.wantQueries[i])
}
}
if !reflect.DeepEqual(got, tt.want) { if !reflect.DeepEqual(got, tt.want) {
t.Errorf("%q. Client.Add() = %v, want %v", tt.name, got, tt.want) t.Errorf("%q. Client.Add() = %v, want %v", tt.name, got, tt.want)
} }
@ -275,7 +329,7 @@ func TestClient_Get(t *testing.T) {
Permissions: chronograf.Permissions{ Permissions: chronograf.Permissions{
chronograf.Permission{ chronograf.Permission{
Scope: "all", Scope: "all",
Allowed: []string{"WRITE", "READ"}, Allowed: []string{"ALL"},
}, },
chronograf.Permission{ chronograf.Permission{
Scope: "database", Scope: "database",
@ -548,7 +602,7 @@ func TestClient_All(t *testing.T) {
Permissions: chronograf.Permissions{ Permissions: chronograf.Permissions{
chronograf.Permission{ chronograf.Permission{
Scope: "all", Scope: "all",
Allowed: []string{"WRITE", "READ"}, Allowed: []string{"ALL"},
}, },
chronograf.Permission{ chronograf.Permission{
Scope: "database", Scope: "database",
@ -562,7 +616,7 @@ func TestClient_All(t *testing.T) {
Permissions: chronograf.Permissions{ Permissions: chronograf.Permissions{
chronograf.Permission{ chronograf.Permission{
Scope: "all", Scope: "all",
Allowed: []string{"WRITE", "READ"}, Allowed: []string{"ALL"},
}, },
chronograf.Permission{ chronograf.Permission{
Scope: "database", Scope: "database",
@ -688,7 +742,7 @@ func TestClient_Update(t *testing.T) {
Permissions: chronograf.Permissions{ Permissions: chronograf.Permissions{
{ {
Scope: "all", Scope: "all",
Allowed: []string{"WRITE", "READ"}, Allowed: []string{"all"},
}, },
{ {
Scope: "database", Scope: "database",
@ -743,7 +797,7 @@ func TestClient_Update(t *testing.T) {
Permissions: chronograf.Permissions{ Permissions: chronograf.Permissions{
{ {
Scope: "all", Scope: "all",
Allowed: []string{"WRITE", "READ"}, Allowed: []string{"all"},
}, },
{ {
Scope: "database", Scope: "database",
@ -800,6 +854,34 @@ func TestClient_Update(t *testing.T) {
`REVOKE ALL PRIVILEGES FROM "docbrown"`, `REVOKE ALL PRIVILEGES FROM "docbrown"`,
}, },
}, },
{
name: "Revoke some",
statusUsers: http.StatusOK,
showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",false],["reader",false]]}]}]}`),
statusGrants: http.StatusOK,
showGrants: []byte(`{"results":[]}`),
statusRevoke: http.StatusOK,
revoke: []byte(`{"results":[]}`),
statusGrant: http.StatusOK,
grant: []byte(`{"results":[]}`),
args: args{
ctx: context.Background(),
u: &chronograf.User{
Name: "docbrown",
Permissions: chronograf.Permissions{
{
Scope: "all",
Allowed: []string{"ALL"},
},
},
},
},
want: []string{
`SHOW USERS`,
`SHOW GRANTS FOR "docbrown"`,
`GRANT ALL PRIVILEGES TO "docbrown"`,
},
},
{ {
name: "Fail users", name: "Fail users",
statusUsers: http.StatusBadRequest, statusUsers: http.StatusBadRequest,

View File

@ -1,545 +0,0 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
)
func validPermissions(perms *chronograf.Permissions) error {
if perms == nil {
return nil
}
for _, perm := range *perms {
if perm.Scope != chronograf.AllScope && perm.Scope != chronograf.DBScope {
return fmt.Errorf("Invalid permission scope")
}
if perm.Scope == chronograf.DBScope && perm.Name == "" {
return fmt.Errorf("Database scoped permission requires a name")
}
}
return nil
}
type sourceUserRequest struct {
Username string `json:"name,omitempty"` // Username for new account
Password string `json:"password,omitempty"` // Password for new account
Permissions chronograf.Permissions `json:"permissions,omitempty"` // Optional permissions
}
func (r *sourceUserRequest) ValidCreate() error {
if r.Username == "" {
return fmt.Errorf("Username required")
}
if r.Password == "" {
return fmt.Errorf("Password required")
}
return validPermissions(&r.Permissions)
}
func (r *sourceUserRequest) ValidUpdate() error {
if r.Password == "" && len(r.Permissions) == 0 {
return fmt.Errorf("No fields to update")
}
return validPermissions(&r.Permissions)
}
type sourceUser struct {
Username string `json:"name,omitempty"` // Username for new account
Permissions chronograf.Permissions `json:"permissions,omitempty"` // Account's permissions
Roles []roleResponse `json:"roles,omitempty"` // Roles if source uses them
Links selfLinks `json:"links"` // Links are URI locations related to user
}
type selfLinks struct {
Self string `json:"self"` // Self link mapping to this resource
}
func newSelfLinks(id int, parent, resource string) selfLinks {
httpAPISrcs := "/chronograf/v1/sources"
u := &url.URL{Path: resource}
encodedResource := u.String()
return selfLinks{
Self: fmt.Sprintf("%s/%d/%s/%s", httpAPISrcs, id, parent, encodedResource),
}
}
// NewSourceUser adds user to source
func (h *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) {
var req sourceUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidCreate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
srcID, store, err := h.sourceUsersStore(ctx, w, r)
if err != nil {
return
}
user := &chronograf.User{
Name: req.Username,
Passwd: req.Password,
}
res, err := store.Add(ctx, user)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
su := sourceUser{
Username: res.Name,
Permissions: req.Permissions,
Links: newSelfLinks(srcID, "users", res.Name),
}
w.Header().Add("Location", su.Links.Self)
encodeJSON(w, http.StatusCreated, su, h.Logger)
}
type sourceUsers struct {
Users []sourceUser `json:"users"`
}
// SourceUsers retrieves all users from source.
func (h *Service) SourceUsers(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, store, err := h.sourceUsersStore(ctx, w, r)
if err != nil {
return
}
users, err := store.All(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
su := []sourceUser{}
for _, u := range users {
res := sourceUser{
Username: u.Name,
Permissions: u.Permissions,
Links: newSelfLinks(srcID, "users", u.Name),
}
if len(u.Roles) > 0 {
rr := make([]roleResponse, len(u.Roles))
for i, role := range u.Roles {
rr[i] = newRoleResponse(srcID, &role)
}
res.Roles = rr
}
su = append(su, res)
}
res := sourceUsers{
Users: su,
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// SourceUserID retrieves a user with ID from store.
func (h *Service) SourceUserID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := httprouter.GetParamFromContext(ctx, "uid")
srcID, store, err := h.sourceUsersStore(ctx, w, r)
if err != nil {
return
}
u, err := store.Get(ctx, uid)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
res := sourceUser{
Username: u.Name,
Permissions: u.Permissions,
Links: newSelfLinks(srcID, "users", u.Name),
}
if len(u.Roles) > 0 {
rr := make([]roleResponse, len(u.Roles))
for i, role := range u.Roles {
rr[i] = newRoleResponse(srcID, &role)
}
res.Roles = rr
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// RemoveSourceUser removes the user from the InfluxDB source
func (h *Service) RemoveSourceUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := httprouter.GetParamFromContext(ctx, "uid")
_, store, err := h.sourceUsersStore(ctx, w, r)
if err != nil {
return
}
if err := store.Delete(ctx, &chronograf.User{Name: uid}); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateSourceUser changes the password or permissions of a source user
func (h *Service) UpdateSourceUser(w http.ResponseWriter, r *http.Request) {
var req sourceUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidUpdate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
uid := httprouter.GetParamFromContext(ctx, "uid")
srcID, store, err := h.sourceUsersStore(ctx, w, r)
if err != nil {
return
}
user := &chronograf.User{
Name: uid,
Passwd: req.Password,
Permissions: req.Permissions,
}
if err := store.Update(ctx, user); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
su := sourceUser{
Username: user.Name,
Permissions: user.Permissions,
Links: newSelfLinks(srcID, "users", user.Name),
}
w.Header().Add("Location", su.Links.Self)
encodeJSON(w, http.StatusOK, su, h.Logger)
}
func (h *Service) sourcesSeries(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.TimeSeries, error) {
srcID, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return 0, nil, err
}
src, err := h.SourcesStore.Get(ctx, srcID)
if err != nil {
notFound(w, srcID, h.Logger)
return 0, nil, err
}
ts, err := h.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return 0, nil, err
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return 0, nil, err
}
return srcID, ts, nil
}
func (h *Service) sourceUsersStore(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.UsersStore, error) {
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return 0, nil, err
}
store := ts.Users(ctx)
return srcID, store, nil
}
// hasRoles checks if the influx source has roles or not
func (h *Service) hasRoles(ctx context.Context, ts chronograf.TimeSeries) (chronograf.RolesStore, bool) {
store, err := ts.Roles(ctx)
if err != nil {
return nil, false
}
return store, true
}
// Permissions returns all possible permissions for this source.
func (h *Service) Permissions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return
}
src, err := h.SourcesStore.Get(ctx, srcID)
if err != nil {
notFound(w, srcID, h.Logger)
return
}
ts, err := h.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return
}
perms := ts.Permissions(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
httpAPISrcs := "/chronograf/v1/sources"
res := struct {
Permissions chronograf.Permissions `json:"permissions"`
Links map[string]string `json:"links"` // Links are URI locations related to user
}{
Permissions: perms,
Links: map[string]string{
"self": fmt.Sprintf("%s/%d/permissions", httpAPISrcs, srcID),
"source": fmt.Sprintf("%s/%d", httpAPISrcs, srcID),
},
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
type sourceRoleRequest struct {
chronograf.Role
}
func (r *sourceRoleRequest) ValidCreate() error {
if r.Name == "" || len(r.Name) > 254 {
return fmt.Errorf("Name is required for a role")
}
for _, user := range r.Users {
if user.Name == "" {
return fmt.Errorf("Username required")
}
}
return validPermissions(&r.Permissions)
}
func (r *sourceRoleRequest) ValidUpdate() error {
if len(r.Name) > 254 {
return fmt.Errorf("Username too long; must be less than 254 characters")
}
for _, user := range r.Users {
if user.Name == "" {
return fmt.Errorf("Username required")
}
}
return validPermissions(&r.Permissions)
}
type roleResponse struct {
Users []sourceUser `json:"users,omitempty"`
Name string `json:"name"`
Permissions chronograf.Permissions `json:"permissions"`
Links selfLinks `json:"links"`
}
func newRoleResponse(srcID int, res *chronograf.Role) roleResponse {
su := make([]sourceUser, len(res.Users))
for i := range res.Users {
name := res.Users[i].Name
su[i] = sourceUser{
Username: name,
Links: newSelfLinks(srcID, "users", name),
}
}
if res.Permissions == nil {
res.Permissions = make(chronograf.Permissions, 0)
}
return roleResponse{
Name: res.Name,
Permissions: res.Permissions,
Users: su,
Links: newSelfLinks(srcID, "roles", res.Name),
}
}
// NewRole adds role to source
func (h *Service) NewRole(w http.ResponseWriter, r *http.Request) {
var req sourceRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidCreate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
res, err := roles.Add(ctx, &req.Role)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := newRoleResponse(srcID, res)
w.Header().Add("Location", rr.Links.Self)
encodeJSON(w, http.StatusCreated, rr, h.Logger)
}
// UpdateRole changes the permissions or users of a role
func (h *Service) UpdateRole(w http.ResponseWriter, r *http.Request) {
var req sourceRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidUpdate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
rid := httprouter.GetParamFromContext(ctx, "rid")
req.Name = rid
if err := roles.Update(ctx, &req.Role); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
role, err := roles.Get(ctx, req.Name)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := newRoleResponse(srcID, role)
w.Header().Add("Location", rr.Links.Self)
encodeJSON(w, http.StatusOK, rr, h.Logger)
}
// RoleID retrieves a role with ID from store.
func (h *Service) RoleID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
rid := httprouter.GetParamFromContext(ctx, "rid")
role, err := roles.Get(ctx, rid)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := newRoleResponse(srcID, role)
encodeJSON(w, http.StatusOK, rr, h.Logger)
}
// Roles retrieves all roles from the store
func (h *Service) Roles(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
store, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
roles, err := store.All(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := make([]roleResponse, len(roles))
for i, role := range roles {
rr[i] = newRoleResponse(srcID, &role)
}
res := struct {
Roles []roleResponse `json:"roles"`
}{rr}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// RemoveRole removes role from data source.
func (h *Service) RemoveRole(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
rid := httprouter.GetParamFromContext(ctx, "rid")
if err := roles.Delete(ctx, &chronograf.Role{Name: rid}); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}

File diff suppressed because it is too large Load Diff

99
server/me.go Normal file
View File

@ -0,0 +1,99 @@
package server
import (
"fmt"
"net/http"
"net/url"
"golang.org/x/net/context"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/oauth2"
)
type meLinks struct {
Self string `json:"self"` // Self link mapping to this resource
}
type meResponse struct {
*chronograf.User
Links meLinks `json:"links"`
}
// If new user response is nil, return an empty meResponse because it
// indicates authentication is not needed
func newMeResponse(usr *chronograf.User) meResponse {
base := "/chronograf/v1/users"
name := "me"
if usr != nil {
// TODO: Change to urls.PathEscape for go 1.8
u := &url.URL{Path: usr.Name}
name = u.String()
}
return meResponse{
User: usr,
Links: meLinks{
Self: fmt.Sprintf("%s/%s", base, name),
},
}
}
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 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 {
// If there's no authentication, return an empty user
res := newMeResponse(nil)
encodeJSON(w, http.StatusOK, res, h.Logger)
return
}
email, err := getEmail(ctx)
if err != nil {
invalidData(w, err, h.Logger)
return
}
usr, err := h.UsersStore.Get(ctx, email)
if err == nil {
res := newMeResponse(usr)
encodeJSON(w, http.StatusOK, res, h.Logger)
return
}
// Because we didnt find a user, making a new one
user := &chronograf.User{
Name: email,
}
newUser, err := h.UsersStore.Add(ctx, user)
if err != nil {
msg := fmt.Errorf("error storing user %s: %v", user.Name, err)
unknownErrorWithMessage(w, msg, h.Logger)
return
}
res := newMeResponse(newUser)
encodeJSON(w, http.StatusOK, res, h.Logger)
}

168
server/me_test.go Normal file
View File

@ -0,0 +1,168 @@
package server
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/log"
"github.com/influxdata/chronograf/mocks"
"github.com/influxdata/chronograf/oauth2"
)
type MockUsers struct{}
func TestService_Me(t *testing.T) {
type fields struct {
UsersStore chronograf.UsersStore
Logger chronograf.Logger
UseAuth bool
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
principal oauth2.Principal
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Existing user",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: true,
UsersStore: &mocks.UsersStore{
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
return &chronograf.User{
Name: "me",
Passwd: "hunter2",
}, nil
},
},
},
principal: oauth2.Principal{
Subject: "me",
},
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"name":"me","password":"hunter2","links":{"self":"/chronograf/v1/users/me"}}
`,
},
{
name: "New user",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: true,
UsersStore: &mocks.UsersStore{
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
return nil, fmt.Errorf("Unknown User")
},
AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
return u, nil
},
},
},
principal: oauth2.Principal{
Subject: "secret",
},
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"name":"secret","password":"","links":{"self":"/chronograf/v1/users/secret"}}
`,
},
{
name: "Error adding user",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: true,
UsersStore: &mocks.UsersStore{
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
return nil, fmt.Errorf("Unknown User")
},
AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
return nil, fmt.Errorf("Why Heavy?")
},
},
Logger: log.New(log.DebugLevel),
},
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",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: false,
Logger: log.New(log.DebugLevel),
},
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"links":{"self":"/chronograf/v1/users/me"}}
`,
},
{
name: "Empty Principal",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
},
wantStatus: http.StatusUnprocessableEntity,
principal: oauth2.Principal{
Subject: "",
},
},
}
for _, tt := range tests {
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,
UseAuth: tt.fields.UseAuth,
}
h.Me(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. Me() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. Me() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. Me() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}

70
server/permissions.go Normal file
View File

@ -0,0 +1,70 @@
package server
import (
"fmt"
"net/http"
"github.com/influxdata/chronograf"
)
// Permissions returns all possible permissions for this source.
func (h *Service) Permissions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return
}
src, err := h.SourcesStore.Get(ctx, srcID)
if err != nil {
notFound(w, srcID, h.Logger)
return
}
ts, err := h.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return
}
perms := ts.Permissions(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
httpAPISrcs := "/chronograf/v1/sources"
res := struct {
Permissions chronograf.Permissions `json:"permissions"`
Links map[string]string `json:"links"` // Links are URI locations related to user
}{
Permissions: perms,
Links: map[string]string{
"self": fmt.Sprintf("%s/%d/permissions", httpAPISrcs, srcID),
"source": fmt.Sprintf("%s/%d", httpAPISrcs, srcID),
},
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
func validPermissions(perms *chronograf.Permissions) error {
if perms == nil {
return nil
}
for _, perm := range *perms {
if perm.Scope != chronograf.AllScope && perm.Scope != chronograf.DBScope {
return fmt.Errorf("Invalid permission scope")
}
if perm.Scope == chronograf.DBScope && perm.Name == "" {
return fmt.Errorf("Database scoped permission requires a name")
}
}
return nil
}

112
server/permissions_test.go Normal file
View File

@ -0,0 +1,112 @@
package server
import (
"bytes"
"context"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/log"
"github.com/influxdata/chronograf/mocks"
)
func TestService_Permissions(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "New user for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
PermissionsF: func(ctx context.Context) chronograf.Permissions {
return chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"READ", "WRITE"},
},
}
},
},
},
ID: "1",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"permissions":[{"scope":"all","allowed":["READ","WRITE"]}],"links":{"self":"/chronograf/v1/sources/1/permissions","source":"/chronograf/v1/sources/1"}}
`,
},
}
for _, tt := range tests {
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
}))
h := &Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
}
h.Permissions(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. Permissions() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. Permissions() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. Permissions() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}

224
server/roles.go Normal file
View File

@ -0,0 +1,224 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
)
// NewRole adds role to source
func (h *Service) NewRole(w http.ResponseWriter, r *http.Request) {
var req sourceRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidCreate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
if _, err := roles.Get(ctx, req.Name); err == nil {
Error(w, http.StatusBadRequest, fmt.Sprintf("Source %d already has role %s", srcID, req.Name), h.Logger)
return
}
res, err := roles.Add(ctx, &req.Role)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := newRoleResponse(srcID, res)
w.Header().Add("Location", rr.Links.Self)
encodeJSON(w, http.StatusCreated, rr, h.Logger)
}
// UpdateRole changes the permissions or users of a role
func (h *Service) UpdateRole(w http.ResponseWriter, r *http.Request) {
var req sourceRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidUpdate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
rid := httprouter.GetParamFromContext(ctx, "rid")
req.Name = rid
if err := roles.Update(ctx, &req.Role); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
role, err := roles.Get(ctx, req.Name)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := newRoleResponse(srcID, role)
w.Header().Add("Location", rr.Links.Self)
encodeJSON(w, http.StatusOK, rr, h.Logger)
}
// RoleID retrieves a role with ID from store.
func (h *Service) RoleID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
rid := httprouter.GetParamFromContext(ctx, "rid")
role, err := roles.Get(ctx, rid)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := newRoleResponse(srcID, role)
encodeJSON(w, http.StatusOK, rr, h.Logger)
}
// Roles retrieves all roles from the store
func (h *Service) Roles(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
store, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
roles, err := store.All(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := make([]roleResponse, len(roles))
for i, role := range roles {
rr[i] = newRoleResponse(srcID, &role)
}
res := struct {
Roles []roleResponse `json:"roles"`
}{rr}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// RemoveRole removes role from data source.
func (h *Service) RemoveRole(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
rid := httprouter.GetParamFromContext(ctx, "rid")
if err := roles.Delete(ctx, &chronograf.Role{Name: rid}); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}
// sourceRoleRequest is the format used for both creating and updating roles
type sourceRoleRequest struct {
chronograf.Role
}
func (r *sourceRoleRequest) ValidCreate() error {
if r.Name == "" || len(r.Name) > 254 {
return fmt.Errorf("Name is required for a role")
}
for _, user := range r.Users {
if user.Name == "" {
return fmt.Errorf("Username required")
}
}
return validPermissions(&r.Permissions)
}
func (r *sourceRoleRequest) ValidUpdate() error {
if len(r.Name) > 254 {
return fmt.Errorf("Username too long; must be less than 254 characters")
}
for _, user := range r.Users {
if user.Name == "" {
return fmt.Errorf("Username required")
}
}
return validPermissions(&r.Permissions)
}
type roleResponse struct {
Users []*userResponse `json:"users"`
Name string `json:"name"`
Permissions chronograf.Permissions `json:"permissions"`
Links selfLinks `json:"links"`
}
func newRoleResponse(srcID int, res *chronograf.Role) roleResponse {
su := make([]*userResponse, len(res.Users))
for i := range res.Users {
name := res.Users[i].Name
su[i] = newUserResponse(srcID, name)
}
if res.Permissions == nil {
res.Permissions = make(chronograf.Permissions, 0)
}
return roleResponse{
Name: res.Name,
Permissions: res.Permissions,
Users: su,
Links: newSelfLinks(srcID, "roles", res.Name),
}
}

697
server/roles_test.go Normal file
View File

@ -0,0 +1,697 @@
package server
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/log"
"github.com/influxdata/chronograf/mocks"
)
func TestService_NewSourceRole(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries TimeSeriesClient
Logger chronograf.Logger
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Bad JSON",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{BAD}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
},
wantStatus: http.StatusBadRequest,
wantContentType: "application/json",
wantBody: `{"code":400,"message":"Unparsable JSON"}`,
},
{
name: "Invalid request",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": ""}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
},
ID: "1",
wantStatus: http.StatusUnprocessableEntity,
wantContentType: "application/json",
wantBody: `{"code":422,"message":"Name is required for a role"}`,
},
{
name: "Invalid source ID",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "newrole"}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
},
ID: "BADROLE",
wantStatus: http.StatusUnprocessableEntity,
wantContentType: "application/json",
wantBody: `{"code":422,"message":"Error converting ID BADROLE"}`,
},
{
name: "Source doesn't support roles",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "role"}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return nil, fmt.Errorf("roles not supported")
},
},
},
ID: "1",
wantStatus: http.StatusNotFound,
wantContentType: "application/json",
wantBody: `{"code":404,"message":"Source 1 does not have role capability"}`,
},
{
name: "Unable to add role to server",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "role"}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return &mocks.RolesStore{
AddF: func(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) {
return nil, fmt.Errorf("server had and issue")
},
GetF: func(ctx context.Context, name string) (*chronograf.Role, error) {
return nil, fmt.Errorf("No such role")
},
}, nil
},
},
},
ID: "1",
wantStatus: http.StatusBadRequest,
wantContentType: "application/json",
wantBody: `{"code":400,"message":"server had and issue"}`,
},
{
name: "New role for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "biffsgang","users": [{"name": "match"},{"name": "skinhead"},{"name": "3-d"}]}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return &mocks.RolesStore{
AddF: func(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) {
return u, nil
},
GetF: func(ctx context.Context, name string) (*chronograf.Role, error) {
return nil, fmt.Errorf("no such role")
},
}, nil
},
},
},
ID: "1",
wantStatus: http.StatusCreated,
wantContentType: "application/json",
wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}
`,
},
}
for _, tt := range tests {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
}
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
}))
h.NewRole(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. NewRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. NewRole() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. NewRole() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}
func TestService_UpdateRole(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries TimeSeriesClient
Logger chronograf.Logger
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
RoleID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Update role for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "biffsgang","users": [{"name": "match"},{"name": "skinhead"},{"name": "3-d"}]}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return &mocks.RolesStore{
UpdateF: func(ctx context.Context, u *chronograf.Role) error {
return nil
},
GetF: func(ctx context.Context, name string) (*chronograf.Role, error) {
return &chronograf.Role{
Name: "biffsgang",
Users: []chronograf.User{
{
Name: "match",
},
{
Name: "skinhead",
},
{
Name: "3-d",
},
},
}, nil
},
}, nil
},
},
},
ID: "1",
RoleID: "biffsgang",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}
`,
},
}
for _, tt := range tests {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
}
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
{
Key: "rid",
Value: tt.RoleID,
},
}))
h.UpdateRole(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. NewRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. NewRole() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. NewRole() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}
func TestService_RoleID(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries TimeSeriesClient
Logger chronograf.Logger
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
RoleID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Get role for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://server.local/chronograf/v1/sources/1/roles/biffsgang",
nil),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return &mocks.RolesStore{
GetF: func(ctx context.Context, name string) (*chronograf.Role, error) {
return &chronograf.Role{
Name: "biffsgang",
Permissions: chronograf.Permissions{
{
Name: "grays_sports_almanac",
Scope: "DBScope",
Allowed: chronograf.Allowances{
"ReadData",
},
},
},
Users: []chronograf.User{
{
Name: "match",
},
{
Name: "skinhead",
},
{
Name: "3-d",
},
},
}, nil
},
}, nil
},
},
},
ID: "1",
RoleID: "biffsgang",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[{"scope":"DBScope","name":"grays_sports_almanac","allowed":["ReadData"]}],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}
`,
},
}
for _, tt := range tests {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
}
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
{
Key: "rid",
Value: tt.RoleID,
},
}))
h.RoleID(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. RoleID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. RoleID() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. RoleID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}
func TestService_RemoveRole(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries TimeSeriesClient
Logger chronograf.Logger
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
RoleID string
wantStatus int
}{
{
name: "remove role for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://server.local/chronograf/v1/sources/1/roles/biffsgang",
nil),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return &mocks.RolesStore{
DeleteF: func(context.Context, *chronograf.Role) error {
return nil
},
}, nil
},
},
},
ID: "1",
RoleID: "biffsgang",
wantStatus: http.StatusNoContent,
},
}
for _, tt := range tests {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
}
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
{
Key: "rid",
Value: tt.RoleID,
},
}))
h.RemoveRole(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. RemoveRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
}
}
func TestService_Roles(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries TimeSeriesClient
Logger chronograf.Logger
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
RoleID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Get roles for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://server.local/chronograf/v1/sources/1/roles",
nil),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return &mocks.RolesStore{
AllF: func(ctx context.Context) ([]chronograf.Role, error) {
return []chronograf.Role{
chronograf.Role{
Name: "biffsgang",
Permissions: chronograf.Permissions{
{
Name: "grays_sports_almanac",
Scope: "DBScope",
Allowed: chronograf.Allowances{
"ReadData",
},
},
},
Users: []chronograf.User{
{
Name: "match",
},
{
Name: "skinhead",
},
{
Name: "3-d",
},
},
},
}, nil
},
}, nil
},
},
},
ID: "1",
RoleID: "biffsgang",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"roles":[{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[{"scope":"DBScope","name":"grays_sports_almanac","allowed":["ReadData"]}],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}]}
`,
},
}
for _, tt := range tests {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
}
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
{
Key: "rid",
Value: tt.RoleID,
},
}))
h.Roles(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. RoleID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. RoleID() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. RoleID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}

View File

@ -1,99 +1,317 @@
package server package server
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"golang.org/x/net/context" "github.com/bouk/httprouter"
"github.com/influxdata/chronograf" "github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/oauth2"
) )
type userLinks struct { // NewSourceUser adds user to source
Self string `json:"self"` // Self link mapping to this resource func (h *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) {
} var req userRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
type userResponse struct { invalidJSON(w, h.Logger)
*chronograf.User
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"
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, name),
},
}
}
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 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 {
// If there's no authentication, return an empty user
res := newUserResponse(nil)
encodeJSON(w, http.StatusOK, res, h.Logger)
return return
} }
email, err := getEmail(ctx) if err := req.ValidCreate(); err != nil {
if err != nil {
invalidData(w, err, h.Logger) invalidData(w, err, h.Logger)
return return
} }
usr, err := h.UsersStore.Get(ctx, email) ctx := r.Context()
if err == nil { srcID, ts, err := h.sourcesSeries(ctx, w, r)
res := newUserResponse(usr)
encodeJSON(w, http.StatusOK, res, h.Logger)
return
}
// Because we didnt find a user, making a new one
user := &chronograf.User{
Name: email,
}
newUser, err := h.UsersStore.Add(ctx, user)
if err != nil { if err != nil {
msg := fmt.Errorf("error storing user %s: %v", user.Name, err)
unknownErrorWithMessage(w, msg, h.Logger)
return return
} }
res := newUserResponse(newUser) store := ts.Users(ctx)
user := &chronograf.User{
Name: req.Username,
Passwd: req.Password,
Permissions: req.Permissions,
Roles: req.Roles,
}
res, err := store.Add(ctx, user)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
su := newUserResponse(srcID, res.Name).WithPermissions(res.Permissions)
if _, hasRoles := h.hasRoles(ctx, ts); hasRoles {
su.WithRoles(srcID, res.Roles)
}
w.Header().Add("Location", su.Links.Self)
encodeJSON(w, http.StatusCreated, su, h.Logger)
}
// SourceUsers retrieves all users from source.
func (h *Service) SourceUsers(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
store := ts.Users(ctx)
users, err := store.All(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
_, hasRoles := h.hasRoles(ctx, ts)
ur := make([]userResponse, len(users))
for i, u := range users {
usr := newUserResponse(srcID, u.Name).WithPermissions(u.Permissions)
if hasRoles {
usr.WithRoles(srcID, u.Roles)
}
ur[i] = *usr
}
res := usersResponse{
Users: ur,
}
encodeJSON(w, http.StatusOK, res, h.Logger) encodeJSON(w, http.StatusOK, res, h.Logger)
} }
// SourceUserID retrieves a user with ID from store.
func (h *Service) SourceUserID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := httprouter.GetParamFromContext(ctx, "uid")
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
store := ts.Users(ctx)
u, err := store.Get(ctx, uid)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
res := newUserResponse(srcID, u.Name).WithPermissions(u.Permissions)
if _, hasRoles := h.hasRoles(ctx, ts); hasRoles {
res.WithRoles(srcID, u.Roles)
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// RemoveSourceUser removes the user from the InfluxDB source
func (h *Service) RemoveSourceUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := httprouter.GetParamFromContext(ctx, "uid")
_, store, err := h.sourceUsersStore(ctx, w, r)
if err != nil {
return
}
if err := store.Delete(ctx, &chronograf.User{Name: uid}); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateSourceUser changes the password or permissions of a source user
func (h *Service) UpdateSourceUser(w http.ResponseWriter, r *http.Request) {
var req userRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidUpdate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
uid := httprouter.GetParamFromContext(ctx, "uid")
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
user := &chronograf.User{
Name: uid,
Passwd: req.Password,
Permissions: req.Permissions,
Roles: req.Roles,
}
store := ts.Users(ctx)
if err := store.Update(ctx, user); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
u, err := store.Get(ctx, uid)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
res := newUserResponse(srcID, u.Name).WithPermissions(u.Permissions)
if _, hasRoles := h.hasRoles(ctx, ts); hasRoles {
res.WithRoles(srcID, u.Roles)
}
w.Header().Add("Location", res.Links.Self)
encodeJSON(w, http.StatusOK, res, h.Logger)
}
func (h *Service) sourcesSeries(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.TimeSeries, error) {
srcID, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return 0, nil, err
}
src, err := h.SourcesStore.Get(ctx, srcID)
if err != nil {
notFound(w, srcID, h.Logger)
return 0, nil, err
}
ts, err := h.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return 0, nil, err
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return 0, nil, err
}
return srcID, ts, nil
}
func (h *Service) sourceUsersStore(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.UsersStore, error) {
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return 0, nil, err
}
store := ts.Users(ctx)
return srcID, store, nil
}
// hasRoles checks if the influx source has roles or not
func (h *Service) hasRoles(ctx context.Context, ts chronograf.TimeSeries) (chronograf.RolesStore, bool) {
store, err := ts.Roles(ctx)
if err != nil {
return nil, false
}
return store, true
}
type userRequest struct {
Username string `json:"name,omitempty"` // Username for new account
Password string `json:"password,omitempty"` // Password for new account
Permissions chronograf.Permissions `json:"permissions,omitempty"` // Optional permissions
Roles []chronograf.Role `json:"roles,omitempty"` // Optional roles
}
func (r *userRequest) ValidCreate() error {
if r.Username == "" {
return fmt.Errorf("Username required")
}
if r.Password == "" {
return fmt.Errorf("Password required")
}
return validPermissions(&r.Permissions)
}
type usersResponse struct {
Users []userResponse `json:"users"`
}
func (r *userRequest) ValidUpdate() error {
if r.Password == "" && len(r.Permissions) == 0 && len(r.Roles) == 0 {
return fmt.Errorf("No fields to update")
}
return validPermissions(&r.Permissions)
}
type userResponse struct {
Name string // Username for new account
Permissions chronograf.Permissions // Account's permissions
Roles []roleResponse // Roles if source uses them
Links selfLinks // Links are URI locations related to user
hasPermissions bool
hasRoles bool
}
func (u *userResponse) MarshalJSON() ([]byte, error) {
res := map[string]interface{}{
"name": u.Name,
"links": u.Links,
}
if u.hasRoles {
res["roles"] = u.Roles
}
if u.hasPermissions {
res["permissions"] = u.Permissions
}
return json.Marshal(res)
}
// newUserResponse creates an HTTP JSON response for a user w/o roles
func newUserResponse(srcID int, name string) *userResponse {
self := newSelfLinks(srcID, "users", name)
return &userResponse{
Name: name,
Links: self,
}
}
func (u *userResponse) WithPermissions(perms chronograf.Permissions) *userResponse {
u.hasPermissions = true
if perms == nil {
perms = make(chronograf.Permissions, 0)
}
u.Permissions = perms
return u
}
// WithRoles adds roles to the HTTP JSON response for a user
func (u *userResponse) WithRoles(srcID int, roles []chronograf.Role) *userResponse {
u.hasRoles = true
rr := make([]roleResponse, len(roles))
for i, role := range roles {
rr[i] = newRoleResponse(srcID, &role)
}
u.Roles = rr
return u
}
type selfLinks struct {
Self string `json:"self"` // Self link mapping to this resource
}
func newSelfLinks(id int, parent, resource string) selfLinks {
httpAPISrcs := "/chronograf/v1/sources"
u := &url.URL{Path: resource}
encodedResource := u.String()
return selfLinks{
Self: fmt.Sprintf("%s/%d/%s/%s", httpAPISrcs, id, parent, encodedResource),
}
}

File diff suppressed because it is too large Load Diff

View File

@ -46,7 +46,7 @@
'arrow-parens': 0, 'arrow-parens': 0,
'comma-dangle': [2, 'always-multiline'], 'comma-dangle': [2, 'always-multiline'],
'no-cond-assign': 2, 'no-cond-assign': 2,
'no-console': 2, 'no-console': ['error', {allow: ['error']}],
'no-constant-condition': 2, 'no-constant-condition': 2,
'no-control-regex': 2, 'no-control-regex': 2,
'no-debugger': 2, 'no-debugger': 2,

View File

@ -1,28 +0,0 @@
const express = require('express');
const request = require('request');
const app = express();
app.use('/', (req, res) => {
console.log(`${req.method} ${req.url}`);
const headers = {};
headers['Access-Control-Allow-Origin'] = '*';
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, DELETE, OPTIONS';
headers['Access-Control-Allow-Credentials'] = false;
headers['Access-Control-Max-Age'] = '86400'; // 24 hours
headers['Access-Control-Allow-Headers'] = 'X-Requested-With, X-HTTP-Method-Override, Content-Type, Accept';
res.writeHead(200, headers);
if (req.method === 'OPTIONS') {
res.end();
}
else {
const url = 'http://localhost:8888' + req.url;
req.pipe(request(url)).pipe(res);
}
});
app.listen(3888, () => {
console.log('corsless proxy server now running')
});

View File

@ -17,9 +17,7 @@
"test:lint": "npm run lint; npm run test", "test:lint": "npm run lint; npm run test",
"test:dev": "nodemon --exec npm run test:lint", "test:dev": "nodemon --exec npm run test:lint",
"clean": "rm -rf build", "clean": "rm -rf build",
"storybook": "start-storybook -p 6006", "storybook": "node ./storybook"
"build-storybook": "build-storybook",
"proxy": "node ./corsless"
}, },
"author": "", "author": "",
"eslintConfig": { "eslintConfig": {

View File

@ -0,0 +1,292 @@
import reducer from 'src/admin/reducers/admin'
import {
addUser,
addRole,
syncUser,
syncRole,
editUser,
editRole,
loadRoles,
loadPermissions,
deleteRole,
deleteUser,
filterRoles,
filterUsers,
} from 'src/admin/actions'
let state = undefined
// Users
const u1 = {
name: 'acidburn',
roles: [
{
name: 'hax0r',
permissions: {
allowed: [
'ViewAdmin',
'ViewChronograf',
'CreateDatabase',
'CreateUserAndRole',
'AddRemoveNode',
'DropDatabase',
'DropData',
'ReadData',
'WriteData',
'Rebalance',
'ManageShard',
'ManageContinuousQuery',
'ManageQuery',
'ManageSubscription',
'Monitor',
'CopyShard',
'KapacitorAPI',
'KapacitorConfigAPI'
],
scope: 'all',
},
}
],
permissions: [],
links: {self: '/chronograf/v1/sources/1/users/acidburn'},
}
const u2 = {
name: 'zerocool',
roles: [],
permissions: [],
links: {self: '/chronograf/v1/sources/1/users/zerocool'},
}
const users = [u1, u2]
const newDefaultUser = {
name: '',
password: '',
roles: [],
permissions: [],
links: {self: ''},
isNew: true,
}
// Roles
const r1 = {
name: 'hax0r',
users: [],
permissions: [
{
allowed: [
'ViewAdmin',
'ViewChronograf',
'CreateDatabase',
'CreateUserAndRole',
'AddRemoveNode',
'DropDatabase',
'DropData',
'ReadData',
'WriteData',
'Rebalance',
'ManageShard',
'ManageContinuousQuery',
'ManageQuery',
'ManageSubscription',
'Monitor',
'CopyShard',
'KapacitorAPI',
'KapacitorConfigAPI'
],
scope: 'all',
},
],
links: {self: '/chronograf/v1/sources/1/roles/hax0r'}
}
const r2 = {
name: 'l33tus3r',
links: {self: '/chronograf/v1/sources/1/roles/l33tus3r'}
}
const roles = [r1, r2]
const newDefaultRole = {
name: '',
users: [],
permissions: [],
links: {self: ''},
isNew: true,
}
// Permissions
const global = {scope: 'all', allowed: ['p1', 'p2']}
const scoped = {scope: 'db1', allowed: ['p1', 'p3']}
const permissions = [global, scoped]
describe('Admin.Reducers', () => {
it('it can add a user', () => {
state = {
users: [
u1,
]
}
const actual = reducer(state, addUser())
const expected = {
users: [
{...newDefaultUser, isEditing: true},
u1,
],
}
expect(actual.users).to.deep.equal(expected.users)
})
it('it can sync a stale user', () => {
const staleUser = {...u1, roles: []}
state = {users: [u2, staleUser]}
const actual = reducer(state, syncUser(staleUser, u1))
const expected = {
users: [u2, u1],
}
expect(actual.users).to.deep.equal(expected.users)
})
it('it can edit a user', () => {
const updates = {name: 'onecool'}
state = {
users: [u2, u1],
}
const actual = reducer(state, editUser(u2, updates))
const expected = {
users: [{...u2, ...updates}, u1]
}
expect(actual.users).to.deep.equal(expected.users)
})
it('it can add a role', () => {
state = {
roles: [
r1,
]
}
const actual = reducer(state, addRole())
const expected = {
roles: [
{...newDefaultRole, isEditing: true},
r1,
],
}
expect(actual.roles).to.deep.equal(expected.roles)
})
it('it can sync a stale role', () => {
const staleRole = {...r1, permissions: []}
state = {roles: [r2, staleRole]}
const actual = reducer(state, syncRole(staleRole, r1))
const expected = {
roles: [r2, r1],
}
expect(actual.roles).to.deep.equal(expected.roles)
})
it('it can edit a role', () => {
const updates = {name: 'onecool'}
state = {
roles: [r2, r1],
}
const actual = reducer(state, editRole(r2, updates))
const expected = {
roles: [{...r2, ...updates}, r1]
}
expect(actual.roles).to.deep.equal(expected.roles)
})
it('it can load the roles', () => {
const actual = reducer(state, loadRoles({roles}))
const expected = {
roles,
}
expect(actual.roles).to.deep.equal(expected.roles)
})
it('it can delete a role', () => {
state = {
roles: [
r1,
]
}
const actual = reducer(state, deleteRole(r1))
const expected = {
roles: [],
}
expect(actual.roles).to.deep.equal(expected.roles)
})
it('it can delete a user', () => {
state = {
users: [
u1,
]
}
const actual = reducer(state, deleteUser(u1))
const expected = {
users: [],
}
expect(actual.users).to.deep.equal(expected.users)
})
it('can filter roles w/ "x" text', () => {
state = {
roles,
}
const text = 'x'
const actual = reducer(state, filterRoles(text))
const expected = {
roles: [
{...r1, hidden: false},
{...r2, hidden: true},
],
}
expect(actual.roles).to.deep.equal(expected.roles)
})
it('can filter users w/ "zero" text', () => {
state = {
users,
}
const text = 'zero'
const actual = reducer(state, filterUsers(text))
const expected = {
users: [
{...u1, hidden: true},
{...u2, hidden: false},
],
}
expect(actual.users).to.deep.equal(expected.users)
})
// Permissions
it('it can load the permissions', () => {
const actual = reducer(state, loadPermissions({permissions}))
const expected = {
permissions,
}
expect(actual.permissions).to.deep.equal(expected.permissions)
})
})

View File

@ -0,0 +1,230 @@
import {
getUsers as getUsersAJAX,
getRoles as getRolesAJAX,
getPermissions as getPermissionsAJAX,
createUser as createUserAJAX,
createRole as createRoleAJAX,
deleteUser as deleteUserAJAX,
deleteRole as deleteRoleAJAX,
updateRole as updateRoleAJAX,
updateUser as updateUserAJAX,
} from 'src/admin/apis'
import {killQuery as killQueryProxy} from 'shared/apis/metaQuery'
import {publishNotification} from 'src/shared/actions/notifications';
import {ADMIN_NOTIFICATION_DELAY} from 'src/admin/constants'
export const loadUsers = ({users}) => ({
type: 'LOAD_USERS',
payload: {
users,
},
})
export const loadRoles = ({roles}) => ({
type: 'LOAD_ROLES',
payload: {
roles,
},
})
export const loadPermissions = ({permissions}) => ({
type: 'LOAD_PERMISSIONS',
payload: {
permissions,
},
})
export const addUser = () => ({
type: 'ADD_USER',
})
export const addRole = () => ({
type: 'ADD_ROLE',
})
export const syncUser = (staleUser, syncedUser) => ({
type: 'SYNC_USER',
payload: {
staleUser,
syncedUser,
},
})
export const syncRole = (staleRole, syncedRole) => ({
type: 'SYNC_ROLE',
payload: {
staleRole,
syncedRole,
},
})
export const editUser = (user, updates) => ({
type: 'EDIT_USER',
payload: {
user,
updates,
},
})
export const editRole = (role, updates) => ({
type: 'EDIT_ROLE',
payload: {
role,
updates,
},
})
export const killQuery = (queryID) => ({
type: 'KILL_QUERY',
payload: {
queryID,
},
})
export const setQueryToKill = (queryIDToKill) => ({
type: 'SET_QUERY_TO_KILL',
payload: {
queryIDToKill,
},
})
export const loadQueries = (queries) => ({
type: 'LOAD_QUERIES',
payload: {
queries,
},
})
export const deleteUser = (user) => ({
type: 'DELETE_USER',
payload: {
user,
},
})
export const deleteRole = (role) => ({
type: 'DELETE_ROLE',
payload: {
role,
},
})
export const filterUsers = (text) => ({
type: 'FILTER_USERS',
payload: {
text,
},
})
export const filterRoles = (text) => ({
type: 'FILTER_ROLES',
payload: {
text,
},
})
// async actions
export const loadUsersAsync = (url) => async (dispatch) => {
const {data} = await getUsersAJAX(url)
dispatch(loadUsers(data))
}
export const loadRolesAsync = (url) => async (dispatch) => {
const {data} = await getRolesAJAX(url)
dispatch(loadRoles(data))
}
export const loadPermissionsAsync = (url) => async (dispatch) => {
const {data} = await getPermissionsAJAX(url)
dispatch(loadPermissions(data))
}
export const createUserAsync = (url, user) => async (dispatch) => {
try {
const {data} = await createUserAJAX(url, user)
dispatch(publishNotification('success', 'User created successfully'))
dispatch(syncUser(user, data))
} catch (error) {
// undo optimistic update
dispatch(publishNotification('error', `Failed to create user: ${error.data.message}`))
setTimeout(() => dispatch(deleteUser(user)), ADMIN_NOTIFICATION_DELAY)
}
}
export const createRoleAsync = (url, role) => async (dispatch) => {
try {
const {data} = await createRoleAJAX(url, role)
dispatch(publishNotification('success', 'Role created successfully'))
dispatch(syncRole(role, data))
} catch (error) {
// undo optimistic update
dispatch(publishNotification('error', `Failed to create role: ${error.data.message}`))
setTimeout(() => dispatch(deleteRole(role)), ADMIN_NOTIFICATION_DELAY)
}
}
export const killQueryAsync = (source, queryID) => (dispatch) => {
// optimistic update
dispatch(killQuery(queryID))
dispatch(setQueryToKill(null))
// kill query on server
killQueryProxy(source, queryID)
}
export const deleteRoleAsync = (role, addFlashMessage) => (dispatch) => {
// optimistic update
dispatch(deleteRole(role))
// delete role on server
deleteRoleAJAX(role.links.self, addFlashMessage, role.name)
}
export const deleteUserAsync = (user, addFlashMessage) => (dispatch) => {
// optimistic update
dispatch(deleteUser(user))
// delete user on server
deleteUserAJAX(user.links.self, addFlashMessage, user.name)
}
export const updateRoleUsersAsync = (role, users) => async (dispatch) => {
try {
const {data} = await updateRoleAJAX(role.links.self, users, role.permissions)
dispatch(publishNotification('success', 'Role users updated'))
dispatch(syncRole(role, data))
} catch (error) {
dispatch(publishNotification('error', `Failed to update role: ${error.data.message}`))
}
}
export const updateRolePermissionsAsync = (role, permissions) => async (dispatch) => {
try {
const {data} = await updateRoleAJAX(role.links.self, role.users, permissions)
dispatch(publishNotification('success', 'Role permissions updated'))
dispatch(syncRole(role, data))
} catch (error) {
dispatch(publishNotification('error', `Failed to updated role: ${error.data.message}`))
}
}
export const updateUserPermissionsAsync = (user, permissions) => async (dispatch) => {
try {
const {data} = await updateUserAJAX(user.links.self, user.roles, permissions)
dispatch(publishNotification('success', 'User permissions updated'))
dispatch(syncUser(user, data))
} catch (error) {
dispatch(publishNotification('error', `Failed to updated user: ${error.data.message}`))
}
}
export const updateUserRolesAsync = (user, roles) => async (dispatch) => {
try {
const {data} = await updateUserAJAX(user.links.self, roles, user.permissions)
dispatch(publishNotification('success', 'User roles updated'))
dispatch(syncUser(user, data))
} catch (error) {
dispatch(publishNotification('error', `Failed to updated user: ${error.data.message}`))
}
}

133
ui/src/admin/apis/index.js Normal file
View File

@ -0,0 +1,133 @@
import AJAX from 'src/utils/ajax'
export const getUsers = async (url) => {
try {
return await AJAX({
method: 'GET',
url,
})
} catch (error) {
console.error(error)
throw error
}
}
export const getRoles = async (url) => {
try {
return await AJAX({
method: 'GET',
url,
})
} catch (error) {
console.error(error)
throw error
}
}
export const getPermissions = async (url) => {
try {
return await AJAX({
method: 'GET',
url,
})
} catch (error) {
console.error(error)
throw error
}
}
export const createUser = async (url, user) => {
try {
return await AJAX({
method: 'POST',
url,
data: user,
})
} catch (error) {
throw error
}
}
export const createRole = async (url, role) => {
try {
return await AJAX({
method: 'POST',
url,
data: role,
})
} catch (error) {
throw error
}
}
export const deleteRole = async (url, addFlashMessage, rolename) => {
try {
const response = await AJAX({
method: 'DELETE',
url,
})
addFlashMessage({
type: 'success',
text: `${rolename} successfully deleted.`,
})
return response
} catch (error) {
console.error(error)
addFlashMessage({
type: 'error',
text: `Error deleting: ${rolename}.`,
})
}
}
export const deleteUser = async (url, addFlashMessage, username) => {
try {
const response = await AJAX({
method: 'DELETE',
url,
})
addFlashMessage({
type: 'success',
text: `${username} successfully deleted.`,
})
return response
} catch (error) {
console.error(error)
addFlashMessage({
type: 'error',
text: `Error deleting: ${username}.`,
})
}
}
export const updateRole = async (url, users, permissions) => {
try {
return await AJAX({
method: 'PATCH',
url,
data: {
users,
permissions,
},
})
} catch (error) {
console.error(error)
throw error
}
}
export const updateUser = async (url, roles, permissions) => {
try {
return await AJAX({
method: 'PATCH',
url,
data: {
roles,
permissions,
},
})
} catch (error) {
console.error(error)
throw error
}
}

View File

@ -0,0 +1,135 @@
import React, {PropTypes} from 'react'
import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'src/shared/components/Tabs';
import UsersTable from 'src/admin/components/UsersTable'
import RolesTable from 'src/admin/components/RolesTable'
import QueriesPage from 'src/admin/containers/QueriesPage'
const AdminTabs = ({
users,
roles,
permissions,
source,
hasRoles,
isEditingUsers,
isEditingRoles,
onClickCreate,
onEditUser,
onSaveUser,
onCancelEditUser,
onEditRole,
onSaveRole,
onCancelEditRole,
onDeleteRole,
onDeleteUser,
onFilterRoles,
onFilterUsers,
onUpdateRoleUsers,
onUpdateRolePermissions,
onUpdateUserRoles,
onUpdateUserPermissions,
}) => {
let tabs = [
{
type: 'Users',
component: (
<UsersTable
users={users}
allRoles={roles}
hasRoles={hasRoles}
permissions={permissions}
isEditing={isEditingUsers}
onSave={onSaveUser}
onCancel={onCancelEditUser}
onClickCreate={onClickCreate}
onEdit={onEditUser}
onDelete={onDeleteUser}
onFilter={onFilterUsers}
onUpdatePermissions={onUpdateUserPermissions}
onUpdateRoles={onUpdateUserRoles}
/>
),
},
{
type: 'Roles',
component: (
<RolesTable
roles={roles}
allUsers={users}
permissions={permissions}
isEditing={isEditingRoles}
onClickCreate={onClickCreate}
onEdit={onEditRole}
onSave={onSaveRole}
onCancel={onCancelEditRole}
onDelete={onDeleteRole}
onFilter={onFilterRoles}
onUpdateRoleUsers={onUpdateRoleUsers}
onUpdateRolePermissions={onUpdateRolePermissions}
/>
),
},
{
type: 'Queries',
component: (<QueriesPage source={source} />),
},
]
if (!hasRoles) {
tabs = tabs.filter(t => t.type !== 'Roles')
}
return (
<Tabs className="row">
<TabList customClass="col-md-2 admin-tabs">
{
tabs.map((t, i) => (<Tab key={tabs[i].type}>{tabs[i].type}</Tab>))
}
</TabList>
<TabPanels customClass="col-md-10">
{
tabs.map((t, i) => (<TabPanel key={tabs[i].type}>{t.component}</TabPanel>))
}
</TabPanels>
</Tabs>
)
}
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
AdminTabs.propTypes = {
users: arrayOf(shape({
name: string.isRequired,
roles: arrayOf(shape({
name: string,
})),
})),
roles: arrayOf(shape()),
source: shape(),
permissions: arrayOf(string),
isEditingUsers: bool,
isEditingRoles: bool,
onClickCreate: func.isRequired,
onEditUser: func.isRequired,
onSaveUser: func.isRequired,
onCancelEditUser: func.isRequired,
onEditRole: func.isRequired,
onSaveRole: func.isRequired,
onCancelEditRole: func.isRequired,
onDeleteRole: func.isRequired,
onDeleteUser: func.isRequired,
onFilterRoles: func.isRequired,
onFilterUsers: func.isRequired,
onUpdateRoleUsers: func.isRequired,
onUpdateRolePermissions: func.isRequired,
hasRoles: bool.isRequired,
onUpdateUserPermissions: func,
onUpdateUserRoles: func,
}
export default AdminTabs

View File

@ -0,0 +1,31 @@
import React, {PropTypes} from 'react'
const ConfirmButtons = ({onConfirm, item, onCancel}) => (
<div>
<button
className="btn btn-xs btn-info"
onClick={() => onCancel(item)}
>
<span className="icon remove"></span>
</button>
<button
className="btn btn-xs btn-success"
onClick={() => onConfirm(item)}
>
<span className="icon checkmark"></span>
</button>
</div>
)
const {
func,
shape,
} = PropTypes
ConfirmButtons.propTypes = {
onConfirm: func.isRequired,
item: shape({}).isRequired,
onCancel: func.isRequired,
}
export default ConfirmButtons

View File

@ -0,0 +1,71 @@
import React, {PropTypes, Component} from 'react'
import OnClickOutside from 'shared/components/OnClickOutside'
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
const DeleteButton = ({onConfirm}) => (
<button
className="btn btn-xs btn-danger admin-table--delete"
onClick={onConfirm}
>
Delete
</button>
)
class DeleteRow extends Component {
constructor(props) {
super(props)
this.state = {
isConfirmed: false,
}
this.handleConfirm = ::this.handleConfirm
this.handleCancel = ::this.handleCancel
}
handleConfirm() {
this.setState({isConfirmed: true})
}
handleCancel() {
this.setState({isConfirmed: false})
}
handleClickOutside() {
this.setState({isConfirmed: false})
}
render() {
const {onDelete, item} = this.props
const {isConfirmed} = this.state
if (isConfirmed) {
return (
<ConfirmButtons
onConfirm={onDelete}
item={item}
onCancel={this.handleCancel}
/>
)
}
return (
<DeleteButton onConfirm={this.handleConfirm} />
)
}
}
const {
func,
shape,
} = PropTypes
DeleteButton.propTypes = {
onConfirm: func.isRequired,
}
DeleteRow.propTypes = {
item: shape({}),
onDelete: func.isRequired,
}
export default OnClickOutside(DeleteRow)

View File

@ -0,0 +1,19 @@
import React, {PropTypes} from 'react'
const EmptyRow = ({tableName}) => (
<tr className="table-empty-state">
<th colSpan="5">
<p>You don&#39;t have any {tableName},<br/>why not create one?</p>
</th>
</tr>
)
const {
string,
} = PropTypes
EmptyRow.propTypes = {
tableName: string.isRequired,
}
export default EmptyRow

View File

@ -0,0 +1,59 @@
import React, {Component, PropTypes} from 'react'
class FilterBar extends Component {
constructor(props) {
super(props)
this.state = {
filterText: '',
}
this.handleText = ::this.handleText
}
handleText(e) {
this.setState(
{filterText: e.target.value},
this.props.onFilter(e.target.value)
)
}
componentWillUnmount() {
this.props.onFilter('')
}
render() {
const {type, isEditing, onClickCreate} = this.props
return (
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<div className="users__search-widget input-group admin__search-widget">
<input
type="text"
className="form-control"
placeholder={`Filter ${type}...`}
value={this.state.filterText}
onChange={this.handleText}
/>
<div className="input-group-addon">
<span className="icon search" aria-hidden="true"></span>
</div>
</div>
<button className="btn btn-primary" disabled={isEditing} onClick={() => onClickCreate(type)}>Create {type.substring(0, type.length - 1)}</button>
</div>
)
}
}
const {
bool,
func,
string,
} = PropTypes
FilterBar.propTypes = {
onFilter: func.isRequired,
type: string,
isEditing: bool,
onClickCreate: func,
}
export default FilterBar

View File

@ -0,0 +1,67 @@
import React, {PropTypes} from 'react'
const QueriesTable = ({queries, onKillQuery, onConfirm}) => (
<div>
<div className="panel panel-minimal">
<div className="panel-body">
<table className="table v-center">
<thead>
<tr>
<th>Database</th>
<th>Query</th>
<th>Running</th>
<th></th>
</tr>
</thead>
<tbody>
{queries.map((q) => {
return (
<tr key={q.id}>
<td>{q.database}</td>
<td><code>{q.query}</code></td>
<td>{q.duration}</td>
<td className="text-right">
<button className="btn btn-xs btn-link-danger" onClick={onKillQuery} data-toggle="modal" data-query-id={q.id} data-target="#killModal">
Kill
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<div className="modal fade" id="killModal" tabIndex="-1" role="dialog" aria-labelledby="myModalLabel">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
<h4 className="modal-title" id="myModalLabel">Are you sure you want to kill this query?</h4>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-default" data-dismiss="modal">No</button>
<button type="button" className="btn btn-danger" data-dismiss="modal" onClick={onConfirm}>Yes, kill it!</button>
</div>
</div>
</div>
</div>
</div>
)
const {
arrayOf,
func,
shape,
} = PropTypes
QueriesTable.propTypes = {
queries: arrayOf(shape()),
onConfirm: func,
onKillQuery: func,
}
export default QueriesTable

View File

@ -0,0 +1,59 @@
import React, {Component, PropTypes} from 'react'
class RoleEditingRow extends Component {
constructor(props) {
super(props)
this.handleKeyPress = ::this.handleKeyPress
this.handleEdit = ::this.handleEdit
}
handleKeyPress(role) {
return (e) => {
if (e.key === 'Enter') {
this.props.onSave(role)
}
}
}
handleEdit(role) {
return (e) => {
this.props.onEdit(role, {[e.target.name]: e.target.value})
}
}
render() {
const {role} = this.props
return (
<td>
<div className="admin-table--edit-cell">
<input
className="form-control"
name="name"
type="text"
value={role.name || ''}
placeholder="role name"
onChange={this.handleEdit(role)}
onKeyPress={this.handleKeyPress(role)}
autoFocus={true}
/>
</div>
</td>
)
}
}
const {
bool,
func,
shape,
} = PropTypes
RoleEditingRow.propTypes = {
role: shape().isRequired,
isNew: bool,
onEdit: func.isRequired,
onSave: func.isRequired,
}
export default RoleEditingRow

View File

@ -0,0 +1,109 @@
import React, {PropTypes} from 'react'
import _ from 'lodash'
import RoleEditingRow from 'src/admin/components/RoleEditingRow'
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
import DeleteRow from 'src/admin/components/DeleteRow'
const RoleRow = ({
role: {name, permissions, users},
role,
allUsers,
allPermissions,
isNew,
isEditing,
onEdit,
onSave,
onCancel,
onDelete,
onUpdateRoleUsers,
onUpdateRolePermissions,
}) => {
const handleUpdateUsers = (u) => {
onUpdateRoleUsers(role, u.map((n) => ({name: n})))
}
const handleUpdatePermissions = (allowed) => {
onUpdateRolePermissions(role, [{scope: 'all', allowed}])
}
const perms = _.get(permissions, ['0', 'allowed'], [])
if (isEditing) {
return (
<tr className="admin-table--edit-row">
<RoleEditingRow role={role} onEdit={onEdit} onSave={onSave} isNew={isNew} />
<td></td>
<td></td>
<td className="text-right" style={{width: "85px"}}>
<ConfirmButtons item={role} onConfirm={onSave} onCancel={onCancel} />
</td>
</tr>
)
}
return (
<tr>
<td>{name}</td>
<td>
{
allPermissions && allPermissions.length ?
<MultiSelectDropdown
items={allPermissions}
selectedItems={perms}
label={perms.length ? '' : 'Select Permissions'}
onApply={handleUpdatePermissions}
/> : null
}
</td>
<td>
{
allUsers && allUsers.length ?
<MultiSelectDropdown
items={allUsers.map((u) => u.name)}
selectedItems={users === undefined ? [] : users.map((u) => u.name)}
label={users && users.length ? '' : 'Select Users'}
onApply={handleUpdateUsers}
/> : null
}
</td>
<td className="text-right" style={{width: "85px"}}>
<DeleteRow onDelete={onDelete} item={role} />
</td>
</tr>
)
}
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
RoleRow.propTypes = {
role: shape({
name: string,
permissions: arrayOf(shape({
name: string,
})),
users: arrayOf(shape({
name: string,
})),
}).isRequired,
isNew: bool,
isEditing: bool,
onCancel: func,
onEdit: func,
onSave: func,
onDelete: func.isRequired,
allUsers: arrayOf(shape()),
allPermissions: arrayOf(string),
onUpdateRoleUsers: func.isRequired,
onUpdateRolePermissions: func.isRequired,
}
export default RoleRow

View File

@ -0,0 +1,90 @@
import React, {PropTypes} from 'react'
import RoleRow from 'src/admin/components/RoleRow'
import EmptyRow from 'src/admin/components/EmptyRow'
import FilterBar from 'src/admin/components/FilterBar'
const RolesTable = ({
roles,
allUsers,
permissions,
isEditing,
onClickCreate,
onEdit,
onSave,
onCancel,
onDelete,
onFilter,
onUpdateRoleUsers,
onUpdateRolePermissions,
}) => (
<div className="panel panel-info">
<FilterBar type="roles" onFilter={onFilter} isEditing={isEditing} onClickCreate={onClickCreate} />
<div className="panel-body">
<table className="table v-center admin-table">
<thead>
<tr>
<th>Name</th>
<th>Permissions</th>
<th>Users</th>
<th></th>
</tr>
</thead>
<tbody>
{
roles.length ?
roles.filter(r => !r.hidden).map((role) =>
<RoleRow
key={role.links.self}
allUsers={allUsers}
allPermissions={permissions}
role={role}
onEdit={onEdit}
onSave={onSave}
onCancel={onCancel}
onDelete={onDelete}
onUpdateRoleUsers={onUpdateRoleUsers}
onUpdateRolePermissions={onUpdateRolePermissions}
isEditing={role.isEditing}
isNew={role.isNew}
/>
) : <EmptyRow tableName={'Roles'} />
}
</tbody>
</table>
</div>
</div>
)
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
RolesTable.propTypes = {
roles: arrayOf(shape({
name: string.isRequired,
permissions: arrayOf(shape({
name: string,
scope: string.isRequired,
})),
users: arrayOf(shape({
name: string,
})),
})),
isEditing: bool,
onClickCreate: func.isRequired,
onEdit: func.isRequired,
onSave: func.isRequired,
onCancel: func.isRequired,
onDelete: func.isRequired,
onFilter: func,
allUsers: arrayOf(shape()),
permissions: arrayOf(string),
onUpdateRoleUsers: func.isRequired,
onUpdateRolePermissions: func.isRequired,
}
export default RolesTable

View File

@ -0,0 +1,72 @@
import React, {Component, PropTypes} from 'react'
class UserEditingRow extends Component {
constructor(props) {
super(props)
this.handleKeyPress = ::this.handleKeyPress
this.handleEdit = ::this.handleEdit
}
handleKeyPress(user) {
return (e) => {
if (e.key === 'Enter') {
this.props.onSave(user)
}
}
}
handleEdit(user) {
return (e) => {
this.props.onEdit(user, {[e.target.name]: e.target.value})
}
}
render() {
const {user, isNew} = this.props
return (
<td>
<div className="admin-table--edit-cell">
<input
className="form-control"
name="name"
type="text"
value={user.name || ''}
placeholder="Username"
onChange={this.handleEdit(user)}
onKeyPress={this.handleKeyPress(user)}
autoFocus={true}
/>
{
isNew ?
<input
className="form-control"
name="password"
type="text"
value={user.password || ''}
placeholder="Password"
onChange={this.handleEdit(user)}
onKeyPress={this.handleKeyPress(user)}
/> :
null
}
</div>
</td>
)
}
}
const {
bool,
func,
shape,
} = PropTypes
UserEditingRow.propTypes = {
user: shape().isRequired,
isNew: bool,
onEdit: func.isRequired,
onSave: func.isRequired,
}
export default UserEditingRow

View File

@ -0,0 +1,110 @@
import React, {PropTypes} from 'react'
import _ from 'lodash'
import UserEditingRow from 'src/admin/components/UserEditingRow'
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
import DeleteRow from 'src/admin/components/DeleteRow'
const UserRow = ({
user: {name, roles, permissions},
user,
allRoles,
allPermissions,
hasRoles,
isNew,
isEditing,
onEdit,
onSave,
onCancel,
onDelete,
onUpdatePermissions,
onUpdateRoles,
}) => {
const handleUpdatePermissions = (allowed) => {
onUpdatePermissions(user, [{scope: 'all', allowed}])
}
const handleUpdateRoles = (roleNames) => {
onUpdateRoles(user, allRoles.filter(r => roleNames.find(rn => rn === r.name)))
}
if (isEditing) {
return (
<tr className="admin-table--edit-row">
<UserEditingRow user={user} onEdit={onEdit} onSave={onSave} isNew={isNew} />
{hasRoles ? <td></td> : null}
<td></td>
<td className="text-right" style={{width: "85px"}}>
<ConfirmButtons item={user} onConfirm={onSave} onCancel={onCancel} />
</td>
</tr>
)
}
return (
<tr>
<td>{name}</td>
{
hasRoles ?
<td>
<MultiSelectDropdown
items={allRoles.map((r) => r.name)}
selectedItems={roles ? roles.map((r) => r.name) : []/* TODO remove check when server returns empty list */}
label={roles && roles.length ? '' : 'Select Roles'}
onApply={handleUpdateRoles}
/>
</td> :
null
}
<td>
{
allPermissions && allPermissions.length ?
<MultiSelectDropdown
items={allPermissions}
selectedItems={_.get(permissions, ['0', 'allowed'], [])}
label={permissions && permissions.length ? '' : 'Select Permissions'}
onApply={handleUpdatePermissions}
/> : null
}
</td>
<td className="text-right" style={{width: "85px"}}>
<DeleteRow onDelete={onDelete} item={user} />
</td>
</tr>
)
}
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
UserRow.propTypes = {
user: shape({
name: string,
roles: arrayOf(shape({
name: string,
})),
permissions: arrayOf(shape({
name: string,
})),
}).isRequired,
allRoles: arrayOf(shape()),
allPermissions: arrayOf(string),
hasRoles: bool,
isNew: bool,
isEditing: bool,
onCancel: func,
onEdit: func,
onSave: func,
onDelete: func.isRequired,
onUpdatePermissions: func,
onUpdateRoles: func,
}
export default UserRow

View File

@ -0,0 +1,94 @@
import React, {PropTypes} from 'react'
import UserRow from 'src/admin/components/UserRow'
import EmptyRow from 'src/admin/components/EmptyRow'
import FilterBar from 'src/admin/components/FilterBar'
const UsersTable = ({
users,
allRoles,
hasRoles,
permissions,
isEditing,
onClickCreate,
onEdit,
onSave,
onCancel,
onDelete,
onFilter,
onUpdatePermissions,
onUpdateRoles,
}) => (
<div className="panel panel-info">
<FilterBar type="users" onFilter={onFilter} isEditing={isEditing} onClickCreate={onClickCreate} />
<div className="panel-body">
<table className="table v-center admin-table">
<thead>
<tr>
<th>User</th>
{hasRoles && <th>Roles</th>}
<th>Permissions</th>
<th></th>
</tr>
</thead>
<tbody>
{
users.length ?
users.filter(u => !u.hidden).map(user =>
<UserRow
key={user.links.self}
user={user}
onEdit={onEdit}
onSave={onSave}
onCancel={onCancel}
onDelete={onDelete}
isEditing={user.isEditing}
isNew={user.isNew}
allRoles={allRoles}
hasRoles={hasRoles}
allPermissions={permissions}
onUpdatePermissions={onUpdatePermissions}
onUpdateRoles={onUpdateRoles}
/>) :
<EmptyRow tableName={'Users'} />
}
</tbody>
</table>
</div>
</div>
)
const {
arrayOf,
bool,
func,
shape,
string,
} = PropTypes
UsersTable.propTypes = {
users: arrayOf(shape({
name: string.isRequired,
roles: arrayOf(shape({
name: string,
})),
permissions: arrayOf(shape({
name: string,
scope: string.isRequired,
})),
})),
isEditing: bool,
onClickCreate: func.isRequired,
onEdit: func.isRequired,
onSave: func.isRequired,
onCancel: func.isRequired,
onDelete: func.isRequired,
onFilter: func,
allRoles: arrayOf(shape()),
permissions: arrayOf(string),
hasRoles: bool.isRequired,
onUpdatePermissions: func,
onUpdateRoles: func,
}
export default UsersTable

View File

@ -0,0 +1,11 @@
export const TIMES = [
{test: /ns/, magnitude: 0},
{test: /µs/, magnitude: 1},
{test: /u/, magnitude: 1},
{test: /^\d*ms/, magnitude: 2},
{test: /^\d*s/, magnitude: 3},
{test: /^\d*m\d*s/, magnitude: 4},
{test: /^\d*h\d*m\d*s/, magnitude: 5},
];
export const ADMIN_NOTIFICATION_DELAY = 1500 // milliseconds

View File

@ -0,0 +1,263 @@
import React, {Component, PropTypes} from 'react'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import {
loadUsersAsync,
loadRolesAsync,
loadPermissionsAsync,
addUser,
addRole,
deleteUser, // TODO rename to removeUser throughout + tests
deleteRole, // TODO rename to removeUser throughout + tests
editUser,
editRole,
createUserAsync,
createRoleAsync,
deleteUserAsync,
deleteRoleAsync,
updateRoleUsersAsync,
updateRolePermissionsAsync,
updateUserPermissionsAsync,
updateUserRolesAsync,
filterUsers as filterUsersAction,
filterRoles as filterRolesAction,
} from 'src/admin/actions'
import AdminTabs from 'src/admin/components/AdminTabs'
const isValidUser = (user) => {
const minLen = 3
return (user.name.length >= minLen && user.password.length >= minLen)
}
const isValidRole = (role) => {
const minLen = 3
return (role.name.length >= minLen)
}
class AdminPage extends Component {
constructor(props) {
super(props)
this.handleClickCreate = ::this.handleClickCreate
this.handleEditUser = ::this.handleEditUser
this.handleEditRole = ::this.handleEditRole
this.handleSaveUser = ::this.handleSaveUser
this.handleSaveRole = ::this.handleSaveRole
this.handleCancelEditUser = ::this.handleCancelEditUser
this.handleCancelEditRole = ::this.handleCancelEditRole
this.handleDeleteRole = ::this.handleDeleteRole
this.handleDeleteUser = ::this.handleDeleteUser
this.handleUpdateRoleUsers = ::this.handleUpdateRoleUsers
this.handleUpdateRolePermissions = ::this.handleUpdateRolePermissions
this.handleUpdateUserPermissions = ::this.handleUpdateUserPermissions
this.handleUpdateUserRoles = ::this.handleUpdateUserRoles
}
componentDidMount() {
const {source, loadUsers, loadRoles, loadPermissions} = this.props
loadUsers(source.links.users)
loadPermissions(source.links.permissions)
if (source.links.roles) {
loadRoles(source.links.roles)
}
}
handleClickCreate(type) {
if (type === 'users') {
this.props.addUser()
} else if (type === 'roles') {
this.props.addRole()
}
}
handleEditUser(user, updates) {
this.props.editUser(user, updates)
}
handleEditRole(role, updates) {
this.props.editRole(role, updates)
}
async handleSaveUser(user) {
if (!isValidUser(user)) {
this.props.addFlashMessage({type: 'error', text: 'Username and/or password too short'})
return
}
if (user.isNew) {
this.props.createUser(this.props.source.links.users, user)
} else {
// TODO update user
}
}
async handleSaveRole(role) {
if (!isValidRole(role)) {
this.props.addFlashMessage({type: 'error', text: 'Role name too short'})
return
}
if (role.isNew) {
this.props.createRole(this.props.source.links.roles, role)
} else {
// TODO update role
// console.log('update')
}
}
handleCancelEditUser(user) {
this.props.removeUser(user)
}
handleCancelEditRole(role) {
this.props.removeRole(role)
}
handleDeleteRole(role) {
this.props.deleteRole(role, this.props.addFlashMessage)
}
handleDeleteUser(user) {
this.props.deleteUser(user, this.props.addFlashMessage)
}
handleUpdateRoleUsers(role, users) {
this.props.updateRoleUsers(role, users)
}
handleUpdateRolePermissions(role, permissions) {
this.props.updateRolePermissions(role, permissions)
}
handleUpdateUserPermissions(user, permissions) {
this.props.updateUserPermissions(user, permissions)
}
handleUpdateUserRoles(user, roles) {
this.props.updateUserRoles(user, roles)
}
render() {
const {users, roles, source, permissions, filterUsers, filterRoles} = this.props
const hasRoles = !!source.links.roles
const globalPermissions = permissions.find((p) => p.scope === 'all')
const allowed = globalPermissions ? globalPermissions.allowed : []
return (
<div className="page">
<div className="page-header">
<div className="page-header__container">
<div className="page-header__left">
<h1>
Admin
</h1>
</div>
</div>
</div>
<div className="page-contents">
<div className="container-fluid">
<div className="row">
{
users ?
<AdminTabs
users={users}
roles={roles}
source={source}
permissions={allowed}
hasRoles={hasRoles}
isEditingUsers={users.some(u => u.isEditing)}
isEditingRoles={roles.some(r => r.isEditing)}
onClickCreate={this.handleClickCreate}
onEditUser={this.handleEditUser}
onEditRole={this.handleEditRole}
onSaveUser={this.handleSaveUser}
onSaveRole={this.handleSaveRole}
onCancelEditUser={this.handleCancelEditUser}
onCancelEditRole={this.handleCancelEditRole}
onDeleteUser={this.handleDeleteUser}
onDeleteRole={this.handleDeleteRole}
onFilterUsers={filterUsers}
onFilterRoles={filterRoles}
onUpdateRoleUsers={this.handleUpdateRoleUsers}
onUpdateRolePermissions={this.handleUpdateRolePermissions}
onUpdateUserPermissions={this.handleUpdateUserPermissions}
onUpdateUserRoles={this.handleUpdateUserRoles}
/> :
<span>Loading...</span>
}
</div>
</div>
</div>
</div>
)
}
}
const {
arrayOf,
func,
shape,
string,
} = PropTypes
AdminPage.propTypes = {
source: shape({
id: string.isRequired,
links: shape({
users: string.isRequired,
}),
}).isRequired,
users: arrayOf(shape()),
roles: arrayOf(shape()),
permissions: arrayOf(shape()),
loadUsers: func,
loadRoles: func,
loadPermissions: func,
addUser: func,
addRole: func,
removeUser: func,
removeRole: func,
editUser: func,
editRole: func,
createUser: func,
createRole: func,
deleteRole: func,
deleteUser: func,
addFlashMessage: func,
filterRoles: func,
filterUsers: func,
updateRoleUsers: func,
updateRolePermissions: func,
updateUserPermissions: func,
updateUserRoles: func,
}
const mapStateToProps = ({admin: {users, roles, permissions}}) => ({
users,
roles,
permissions,
})
const mapDispatchToProps = (dispatch) => ({
loadUsers: bindActionCreators(loadUsersAsync, dispatch),
loadRoles: bindActionCreators(loadRolesAsync, dispatch),
loadPermissions: bindActionCreators(loadPermissionsAsync, dispatch),
addUser: bindActionCreators(addUser, dispatch),
addRole: bindActionCreators(addRole, dispatch),
removeUser: bindActionCreators(deleteUser, dispatch),
removeRole: bindActionCreators(deleteRole, dispatch),
editUser: bindActionCreators(editUser, dispatch),
editRole: bindActionCreators(editRole, dispatch),
createUser: bindActionCreators(createUserAsync, dispatch),
createRole: bindActionCreators(createRoleAsync, dispatch),
deleteUser: bindActionCreators(deleteUserAsync, dispatch),
deleteRole: bindActionCreators(deleteRoleAsync, dispatch),
filterUsers: bindActionCreators(filterUsersAction, dispatch),
filterRoles: bindActionCreators(filterRolesAction, dispatch),
updateRoleUsers: bindActionCreators(updateRoleUsersAsync, dispatch),
updateRolePermissions: bindActionCreators(updateRolePermissionsAsync, dispatch),
updateUserPermissions: bindActionCreators(updateUserPermissionsAsync, dispatch),
updateUserRoles: bindActionCreators(updateUserRolesAsync, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(AdminPage)

View File

@ -0,0 +1,134 @@
import React, {PropTypes, Component} from 'react'
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import flatten from 'lodash/flatten'
import uniqBy from 'lodash/uniqBy'
import {
showDatabases,
showQueries,
} from 'shared/apis/metaQuery'
import QueriesTable from 'src/admin/components/QueriesTable'
import showDatabasesParser from 'shared/parsing/showDatabases'
import showQueriesParser from 'shared/parsing/showQueries'
import {TIMES} from 'src/admin/constants'
import {
loadQueries as loadQueriesAction,
setQueryToKill as setQueryToKillAction,
killQueryAsync,
} from 'src/admin/actions'
class QueriesPage extends Component {
constructor(props) {
super(props)
this.updateQueries = ::this.updateQueries
this.handleConfirmKillQuery = ::this.handleConfirmKillQuery
this.handleKillQuery = ::this.handleKillQuery
}
componentDidMount() {
this.updateQueries()
const updateInterval = 5000
this.intervalID = setInterval(this.updateQueries, updateInterval)
}
componentWillUnmount() {
clearInterval(this.intervalID)
}
render() {
const {queries} = this.props;
return (
<QueriesTable queries={queries} onConfirm={this.handleConfirmKillQuery} onKillQuery={this.handleKillQuery} />
);
}
updateQueries() {
const {source, addFlashMessage, loadQueries} = this.props
showDatabases(source.links.proxy).then((resp) => {
const {databases, errors} = showDatabasesParser(resp.data)
if (errors.length) {
errors.forEach((message) => addFlashMessage({type: 'error', text: message}))
return;
}
const fetches = databases.map((db) => showQueries(source.links.proxy, db))
Promise.all(fetches).then((queryResponses) => {
const allQueries = [];
queryResponses.forEach((queryResponse) => {
const result = showQueriesParser(queryResponse.data);
if (result.errors.length) {
result.erorrs.forEach((message) => this.props.addFlashMessage({type: 'error', text: message}));
}
allQueries.push(...result.queries);
});
const queries = uniqBy(flatten(allQueries), (q) => q.id);
// sorting queries by magnitude, so generally longer queries will appear atop the list
const sortedQueries = queries.sort((a, b) => {
const aTime = TIMES.find((t) => a.duration.match(t.test));
const bTime = TIMES.find((t) => b.duration.match(t.test));
return +aTime.magnitude <= +bTime.magnitude;
});
loadQueries(sortedQueries)
});
});
}
handleKillQuery(e) {
e.stopPropagation();
const id = e.target.dataset.queryId;
this.props.setQueryToKill(id)
}
handleConfirmKillQuery() {
const {queryIDToKill, source, killQuery} = this.props;
if (queryIDToKill === null) {
return;
}
killQuery(source.links.proxy, queryIDToKill)
}
}
const {
arrayOf,
func,
string,
shape,
} = PropTypes
QueriesPage.propTypes = {
source: shape({
links: shape({
proxy: string,
}),
}),
queries: arrayOf(shape()),
addFlashMessage: func,
loadQueries: func,
queryIDToKill: string,
setQueryToKill: func,
killQuery: func,
}
const mapStateToProps = ({admin: {queries, queryIDToKill}}) => ({
queries,
queryIDToKill,
})
const mapDispatchToProps = (dispatch) => ({
loadQueries: bindActionCreators(loadQueriesAction, dispatch),
setQueryToKill: bindActionCreators(setQueryToKillAction, dispatch),
killQuery: bindActionCreators(killQueryAsync, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(QueriesPage)

2
ui/src/admin/index.js Normal file
View File

@ -0,0 +1,2 @@
import AdminPage from './containers/AdminPage';
export {AdminPage};

View File

@ -0,0 +1,154 @@
import reject from 'lodash/reject'
const newDefaultUser = {
name: '',
password: '',
roles: [],
permissions: [],
links: {self: ''},
isNew: true,
}
const newDefaultRole = {
name: '',
permissions: [],
users: [],
links: {self: ''},
isNew: true,
}
const initialState = {
users: null,
roles: [],
permissions: [],
queries: [],
queryIDToKill: null,
}
export default function admin(state = initialState, action) {
switch (action.type) {
case 'LOAD_USERS': {
return {...state, ...action.payload}
}
case 'LOAD_ROLES': {
return {...state, ...action.payload}
}
case 'LOAD_PERMISSIONS': {
return {...state, ...action.payload}
}
case 'ADD_USER': {
const newUser = {...newDefaultUser, isEditing: true}
return {
...state,
users: [
newUser,
...state.users,
],
}
}
case 'ADD_ROLE': {
const newRole = {...newDefaultRole, isEditing: true}
return {
...state,
roles: [
newRole,
...state.roles,
],
}
}
case 'SYNC_USER': {
const {staleUser, syncedUser} = action.payload
const newState = {
users: state.users.map(u => u.links.self === staleUser.links.self ? {...syncedUser} : u),
}
return {...state, ...newState}
}
case 'SYNC_ROLE': {
const {staleRole, syncedRole} = action.payload
const newState = {
roles: state.roles.map(r => r.links.self === staleRole.links.self ? {...syncedRole} : r),
}
return {...state, ...newState}
}
case 'EDIT_USER': {
const {user, updates} = action.payload
const newState = {
users: state.users.map(u => u.links.self === user.links.self ? {...u, ...updates} : u),
}
return {...state, ...newState}
}
case 'EDIT_ROLE': {
const {role, updates} = action.payload
const newState = {
roles: state.roles.map(r => r.links.self === role.links.self ? {...r, ...updates} : r),
}
return {...state, ...newState}
}
case 'DELETE_USER': {
const {user} = action.payload
const newState = {
users: state.users.filter(u => u.links.self !== user.links.self),
}
return {...state, ...newState}
}
case 'DELETE_ROLE': {
const {role} = action.payload
const newState = {
roles: state.roles.filter(r => r.links.self !== role.links.self),
}
return {...state, ...newState}
}
case 'LOAD_QUERIES': {
return {...state, ...action.payload}
}
case 'FILTER_USERS': {
const {text} = action.payload
const newState = {
users: state.users.map(u => {
u.hidden = !u.name.toLowerCase().includes(text)
return u
}),
}
return {...state, ...newState}
}
case 'FILTER_ROLES': {
const {text} = action.payload
const newState = {
roles: state.roles.map(r => {
r.hidden = !r.name.toLowerCase().includes(text)
return r
}),
}
return {...state, ...newState}
}
case 'KILL_QUERY': {
const {queryID} = action.payload
const nextState = {
queries: reject(state.queries, (q) => +q.id === +queryID),
}
return {...state, ...nextState}
}
case 'SET_QUERY_TO_KILL': {
return {...state, ...action.payload}
}
}
return state
}

View File

@ -6,10 +6,6 @@ import AJAX from 'utils/ajax';
import _ from 'lodash'; import _ from 'lodash';
import NoKapacitorError from '../../shared/components/NoKapacitorError'; import NoKapacitorError from '../../shared/components/NoKapacitorError';
// Kevin: because we were getting strange errors saying
// "Failed prop type: Required prop `source` was not specified in `AlertsApp`."
// Tim and I decided to make the source and addFlashMessage props not required.
// FIXME: figure out why that wasn't working
const AlertsApp = React.createClass({ const AlertsApp = React.createClass({
propTypes: { propTypes: {
source: PropTypes.shape({ source: PropTypes.shape({

View File

@ -2,7 +2,7 @@ import React, {PropTypes} from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import _ from 'lodash'; import _ from 'lodash';
import MultiSelectDropdown from './MultiSelectDropdown'; import MultiSelectDropdown from 'src/shared/components/MultiSelectDropdown';
import Dropdown from 'src/shared/components/Dropdown'; import Dropdown from 'src/shared/components/Dropdown';
import {INFLUXQL_FUNCTIONS} from '../constants'; import {INFLUXQL_FUNCTIONS} from '../constants';

View File

@ -1,106 +0,0 @@
import React, {PropTypes} from 'react';
import OnClickOutside from 'shared/components/OnClickOutside';
import classNames from 'classnames';
import _ from 'lodash';
const {func, arrayOf, string} = PropTypes;
const MultiSelectDropdown = React.createClass({
propTypes: {
onApply: func.isRequired,
items: arrayOf(PropTypes.string.isRequired).isRequired,
selectedItems: arrayOf(string.isRequired).isRequired,
},
getInitialState() {
return {
isOpen: false,
localSelectedItems: this.props.selectedItems,
};
},
componentWillReceiveProps(nextProps) {
if (!_.isEqual(this.state.localSelectedItems, nextProps.selectedItems)) {
this.setState({
localSelectedItems: nextProps.selectedItems,
});
}
},
handleClickOutside() {
this.setState({isOpen: false});
},
toggleMenu(e) {
e.stopPropagation();
this.setState({isOpen: !this.state.isOpen});
},
onSelect(item, e) {
e.stopPropagation();
const {localSelectedItems} = this.state;
let nextItems;
if (this.isSelected(item)) {
nextItems = localSelectedItems.filter((i) => i !== item);
} else {
nextItems = localSelectedItems.concat(item);
}
this.setState({localSelectedItems: nextItems});
},
isSelected(item) {
return this.state.localSelectedItems.indexOf(item) > -1;
},
onApplyFunctions(e) {
e.stopPropagation();
this.setState({isOpen: false});
this.props.onApply(this.state.localSelectedItems);
},
render() {
const {localSelectedItems} = this.state;
const {isOpen} = this.state;
const labelText = isOpen ? "0 Selected" : "Apply Function";
return (
<div className={classNames('dropdown multi-select-dropdown', {open: isOpen})}>
<div onClick={this.toggleMenu} className="btn btn-xs btn-info dropdown-toggle" type="button">
<span className="multi-select-dropdown__label">
{
localSelectedItems.length ? localSelectedItems.map((s) => s).join(', ') : labelText
}
</span>
<span className="caret"></span>
</div>
{this.renderMenu()}
</div>
);
},
renderMenu() {
const {items} = this.props;
return (
<div className="dropdown-options">
<li className="multi-select-dropdown__apply" onClick={this.onApplyFunctions}>
<div className="btn btn-xs btn-info btn-block">Apply</div>
</li>
<ul className="dropdown-menu multi-select-dropdown__menu" aria-labelledby="dropdownMenu1">
{items.map((listItem, i) => {
return (
<li className={classNames('multi-select-dropdown__item', {active: this.isSelected(listItem)})} key={i} onClick={_.wrap(listItem, this.onSelect)}>
<a href="#">{listItem}</a>
</li>
);
})}
</ul>
</div>
);
},
});
export default OnClickOutside(MultiSelectDropdown);

View File

@ -14,6 +14,7 @@ import {KapacitorPage, KapacitorRulePage, KapacitorRulesPage, KapacitorTasksPage
import DataExplorer from 'src/data_explorer'; import DataExplorer from 'src/data_explorer';
import {DashboardsPage, DashboardPage} from 'src/dashboards'; import {DashboardsPage, DashboardPage} from 'src/dashboards';
import {CreateSource, SourcePage, ManageSources} from 'src/sources'; import {CreateSource, SourcePage, ManageSources} from 'src/sources';
import {AdminPage} from 'src/admin';
import NotFound from 'src/shared/components/NotFound'; import NotFound from 'src/shared/components/NotFound';
import configureStore from 'src/store/configureStore'; import configureStore from 'src/store/configureStore';
import {getMe, getSources} from 'shared/apis'; import {getMe, getSources} from 'shared/apis';
@ -127,6 +128,7 @@ const Root = React.createClass({
<Route path="alert-rules" component={KapacitorRulesPage} /> <Route path="alert-rules" component={KapacitorRulesPage} />
<Route path="alert-rules/:ruleID" component={KapacitorRulePage} /> <Route path="alert-rules/:ruleID" component={KapacitorRulePage} />
<Route path="alert-rules/new" component={KapacitorRulePage} /> <Route path="alert-rules/new" component={KapacitorRulePage} />
<Route path="admin" component={AdminPage} />
</Route> </Route>
</Route> </Route>
<Route path="*" component={NotFound} /> <Route path="*" component={NotFound} />

View File

@ -7,18 +7,16 @@ export function showDatabases(source) {
return proxy({source, query}); return proxy({source, query});
} }
export function showQueries(host, db, clusterID) { export function showQueries(source, db) {
const statement = 'SHOW QUERIES'; const query = 'SHOW QUERIES';
const url = buildInfluxUrl({host, statement, database: db});
return proxy(url, clusterID); return proxy({source, query, db});
} }
export function killQuery(host, queryId, clusterID) { export function killQuery(source, queryId) {
const statement = `KILL QUERY ${queryId}`; const query = `KILL QUERY ${queryId}`;
const url = buildInfluxUrl({host, statement});
return proxy(url, clusterID); return proxy({source, query});
} }
export function showMeasurements(source, db) { export function showMeasurements(source, db) {

View File

@ -0,0 +1,134 @@
import React, {Component, PropTypes} from 'react'
import OnClickOutside from 'shared/components/OnClickOutside'
import classNames from 'classnames'
import _ from 'lodash'
const labelText = ({localSelectedItems, isOpen, label}) => {
if (label) {
return label
} else if (localSelectedItems.length) {
return localSelectedItems.map((s) => s).join(', ')
}
// TODO: be smarter about the text displayed here
if (isOpen) {
return '0 Selected'
}
return 'None'
}
class MultiSelectDropdown extends Component {
constructor(props) {
super(props)
this.state = {
isOpen: false,
localSelectedItems: this.props.selectedItems,
}
this.onSelect = ::this.onSelect
this.onApplyFunctions = ::this.onApplyFunctions
}
componentWillReceiveProps(nextProps) {
if (!_.isEqual(this.state.localSelectedItems, nextProps.selectedItems)) {
this.setState({
localSelectedItems: nextProps.selectedItems,
})
}
}
handleClickOutside() {
this.setState({isOpen: false})
}
toggleMenu(e) {
e.stopPropagation()
this.setState({isOpen: !this.state.isOpen})
}
onSelect(item, e) {
e.stopPropagation()
const {localSelectedItems} = this.state
let nextItems
if (this.isSelected(item)) {
nextItems = localSelectedItems.filter((i) => i !== item)
} else {
nextItems = localSelectedItems.concat(item)
}
this.setState({localSelectedItems: nextItems})
}
isSelected(item) {
return this.state.localSelectedItems.indexOf(item) > -1
}
onApplyFunctions(e) {
e.stopPropagation()
this.setState({isOpen: false})
this.props.onApply(this.state.localSelectedItems)
}
render() {
const {localSelectedItems, isOpen} = this.state
const {label} = this.props
return (
<div className={classNames('dropdown multi-select-dropdown', {open: isOpen})}>
<div onClick={::this.toggleMenu} className="btn btn-xs btn-info dropdown-toggle" type="button">
<div className="multi-select-dropdown__label">
{
labelText({localSelectedItems, isOpen, label})
}
</div>
<span className="caret"></span>
</div>
{this.renderMenu()}
</div>
)
}
renderMenu() {
const {items} = this.props
return (
<div className="dropdown-options">
<li className="multi-select-dropdown__apply" onClick={this.onApplyFunctions} style={{listStyle: 'none'}}>
<div className="btn btn-xs btn-info btn-block">Apply</div>
</li>
<ul className="dropdown-menu multi-select-dropdown__menu" aria-labelledby="dropdownMenu1">
{items.map((listItem, i) => {
return (
<li
key={i}
className={classNames('multi-select-dropdown__item', {active: this.isSelected(listItem)})}
onClick={_.wrap(listItem, this.onSelect)}
>
<a href="#">{listItem}</a>
</li>
)
})}
</ul>
</div>
)
}
}
const {
arrayOf,
func,
string,
} = PropTypes
MultiSelectDropdown.propTypes = {
onApply: func.isRequired,
items: arrayOf(string.isRequired).isRequired,
selectedItems: arrayOf(string.isRequired).isRequired,
label: string,
}
export default OnClickOutside(MultiSelectDropdown)

View File

@ -28,6 +28,7 @@ export const TabList = React.createClass({
activeIndex: number, activeIndex: number,
onActivate: func, onActivate: func,
isKapacitorTabs: string, isKapacitorTabs: string,
customClass: string,
}, },
getDefaultProps() { getDefaultProps() {
@ -53,6 +54,14 @@ export const TabList = React.createClass({
); );
} }
if (this.props.customClass) {
return (
<div className={this.props.customClass}>
<div className="btn-group btn-group-lg tab-group">{children}</div>
</div>
);
}
return ( return (
<div className="btn-group btn-group-lg tab-group">{children}</div> <div className="btn-group btn-group-lg tab-group">{children}</div>
); );
@ -63,11 +72,13 @@ export const TabPanels = React.createClass({
propTypes: { propTypes: {
children: node.isRequired, children: node.isRequired,
activeIndex: number, activeIndex: number,
customClass: string,
}, },
// if only 1 child, children array index lookup will fail
render() { render() {
return ( return (
<div> <div className={this.props.customClass ? this.props.customClass : null}>
{this.props.children[this.props.activeIndex]} {this.props.children[this.props.activeIndex]}
</div> </div>
); );

View File

@ -0,0 +1,21 @@
import React, {PropTypes} from 'react'
import ReactTooltip from 'react-tooltip'
const Tooltip = ({tip, children}) => (
<div>
<div data-tip={tip}>{children}</div>
<ReactTooltip effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
</div>
)
const {
shape,
string,
} = PropTypes
Tooltip.propTypes = {
tip: string,
children: shape({}),
}
export default Tooltip

View File

@ -471,4 +471,6 @@ export const STROKE_WIDTH = {
export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds. export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds.
export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds. export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds.
export const RES_UNAUTHORIZED = 401
export const AUTOREFRESH_DEFAULT = 15000 // in milliseconds export const AUTOREFRESH_DEFAULT = 15000 // in milliseconds

View File

@ -47,6 +47,9 @@ const SideNav = React.createClass({
<NavListItem link={`${sourcePrefix}/manage-sources`}>InfluxDB</NavListItem> <NavListItem link={`${sourcePrefix}/manage-sources`}>InfluxDB</NavListItem>
<NavListItem link={`${sourcePrefix}/kapacitor-config`}>Kapacitor</NavListItem> <NavListItem link={`${sourcePrefix}/kapacitor-config`}>Kapacitor</NavListItem>
</NavBlock> </NavBlock>
<NavBlock icon="crown" link={`${sourcePrefix}/admin`}>
<NavHeader link={`${sourcePrefix}/admin`} title="Admin" />
</NavBlock>
{loggedIn ? ( {loggedIn ? (
<NavBlock icon="user-outline" className="sidebar__square-last"> <NavBlock icon="user-outline" className="sidebar__square-last">
<a className="sidebar__menu-item" href="/oauth/logout">Logout</a> <a className="sidebar__menu-item" href="/oauth/logout">Logout</a>

View File

@ -3,6 +3,7 @@ import {combineReducers} from 'redux';
import thunkMiddleware from 'redux-thunk'; import thunkMiddleware from 'redux-thunk';
import makeQueryExecuter from 'src/shared/middleware/queryExecuter'; import makeQueryExecuter from 'src/shared/middleware/queryExecuter';
import resizeLayout from 'src/shared/middleware/resizeLayout'; import resizeLayout from 'src/shared/middleware/resizeLayout';
import adminReducer from 'src/admin/reducers/admin';
import sharedReducers from 'src/shared/reducers'; import sharedReducers from 'src/shared/reducers';
import dataExplorerReducers from 'src/data_explorer/reducers'; import dataExplorerReducers from 'src/data_explorer/reducers';
import rulesReducer from 'src/kapacitor/reducers/rules'; import rulesReducer from 'src/kapacitor/reducers/rules';
@ -12,6 +13,7 @@ import persistStateEnhancer from './persistStateEnhancer';
const rootReducer = combineReducers({ const rootReducer = combineReducers({
...sharedReducers, ...sharedReducers,
...dataExplorerReducers, ...dataExplorerReducers,
admin: adminReducer,
rules: rulesReducer, rules: rulesReducer,
dashboardUI, dashboardUI,
}); });

View File

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

View File

@ -1,6 +1,80 @@
$ms-normal-left-padding: 9px;
$ms-item-height: 26px;
$ms-checkbox-size: 14px;
$ms-checkbox-dot-size: 6px;
$ms-checkbox-bg: $c-sapphire;
$ms-checkbox-bg-hover: $c-ocean;
$ms-checkbox-dot: $g20-white;
.multi-select-dropdown { .multi-select-dropdown {
.multi-select-dropdown__item > a {
color: $c-neutrino !important;
height: $ms-item-height;
line-height: $ms-item-height;
position: relative;
padding-top: 0;
padding-bottom: 0;
padding-right: $ms-normal-left-padding;
padding-left: ($ms-normal-left-padding + $ms-checkbox-size + ($ms-normal-left-padding - 2px));
&,
&:focus,
&:active,
&:active:focus {
background: none !important;
&:hover {
background: $c-pool;
background: -moz-linear-gradient(left, $c-pool 0%, $c-pool 100%) !important;
background: -webkit-linear-gradient(left, $c-pool 0%,$c-pool 100%) !important;
background: linear-gradient(to right, $c-pool 0%,$c-pool 100%) !important;
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$c-pool', endColorstr='$c-pool',GradientType=1 ) !important;
}
}
/* Shared Checkbox Styles */
&:before,
&:after {
content: '';
position: absolute;
display: block;
top: 50%;
}
/* Before = Checkbox */
&:before {
width: $ms-checkbox-size;
height: $ms-checkbox-size;
border-radius: $radius-small;
background-color: $ms-checkbox-bg;
left: $ms-normal-left-padding;
transform: translateY(-50%);
}
/* After = Dot */
&:after {
width: $ms-checkbox-dot-size;
height: $ms-checkbox-dot-size;
background-color: $ms-checkbox-dot;
border-radius: 50%;
transform: translate(-50%,-50%) scale(2,2);
opacity: 0;
left: ($ms-normal-left-padding + ($ms-checkbox-size / 2));
transition:
opacity 0.25s ease,
transform 0.25s ease;
}
/* Hover State */
&:hover {
color: $g20-white !important;
}
}
.dropdown-toggle { .dropdown-toggle {
width: 110px; width: 110px;
&.btn-xs {
height: 22px;
line-height: 22px;
padding-left: 0;
padding-right: 0;
}
} }
&__apply { &__apply {
margin: 0; margin: 0;
@ -45,6 +119,22 @@
} }
} }
} }
/* Checked State */
.multi-select-dropdown li.multi-select-dropdown__item.active > a {
&,
&:focus,
&:active,
&:active:focus {
background: none !important;
}
color: $g20-white !important;
&:after {
transform: translate(-50%,-50%) scale(1,1);
opacity: 1;
}
}
/* Open State */ /* Open State */
.multi-select-dropdown.open { .multi-select-dropdown.open {
.dropdown-options { .dropdown-options {
@ -56,3 +146,14 @@
opacity: 1; opacity: 1;
} }
} }
.multi-select-dropdown__label {
top: 50%;
transform: translateY(-50%);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
padding-right: 10px;
position: absolute;
width: calc(100% - #{($ms-normal-left-padding * 2)});
left: $ms-normal-left-padding;
}

View File

@ -2,10 +2,13 @@
Custom Search Widget Custom Search Widget
---------------------------------------------- ----------------------------------------------
*/ */
$search-widget-height: 36px;
.users__search-widget { .users__search-widget {
position: relative; position: relative;
input.form-control { input.form-control {
height: $search-widget-height;
position: relative; position: relative;
width: 100%; width: 100%;
z-index: 1; z-index: 1;
@ -19,7 +22,7 @@
.input-group-addon { .input-group-addon {
padding: 0; padding: 0;
text-align: center; text-align: center;
line-height: 38px; line-height: calc(#{$search-widget-height} - 2px);
position: absolute; position: absolute;
color: $g10-wolf; color: $g10-wolf;
top: 0; top: 0;
@ -32,4 +35,7 @@
transition: transition:
color 0.25s ease; color 0.25s ease;
} }
} }
.admin__search-widget {
width: 300px;
}

View File

@ -2,6 +2,29 @@
Stuff for making Tables of Data more readable Stuff for making Tables of Data more readable
---------------------------------------------- ----------------------------------------------
*/ */
table {
thead th {
color: $g17-whisper !important;
border-width: 1px;
border-color: $g5-pepper !important;
}
tbody td {
font-weight: 500;
color: $g14-chromium !important;
border: 0 !important;
padding: 4px 8px !important;
}
tbody tr:hover {
background-color: $g5-pepper;
td {
color: $g19-ghost !important;
}
}
}
table .monotype { table .monotype {
font-family: $code-font; font-family: $code-font;
letter-spacing: 0px; letter-spacing: 0px;
@ -119,4 +142,4 @@ table .monotype {
border-color: $g5-pepper; border-color: $g5-pepper;
@include custom-scrollbar($g5-pepper, $c-pool); @include custom-scrollbar($g5-pepper, $c-pool);
} }
} }

View File

@ -0,0 +1,122 @@
/*
Styles for Admin Pages
----------------------------------------------
*/
/*
Admin Tabs
----------------------------------------------
*/
.admin-tabs {
padding-right: 0;
& + div {
padding-left: 0;
.panel {
border-top-left-radius: 0;
}
}
}
.admin-tabs .btn-group {
margin: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
.tab {
font-weight: 500 !important;
border-radius: $radius 0 0 $radius !important;
margin-bottom: 2px !important;
transition:
background-color 0.25s ease,
color 0.25s ease !important;
border: 0 !important;
text-align: left;
height: 60px !important;
line-height: 60px !important;
padding: 0 0 0 16px !important;
font-size: 17px;
background-color: transparent !important;
color: $g11-sidewalk !important;
&:hover,
&:active,
&:active:hover {
background-color: $g3-castle !important;
color: $g15-platinum !important;
}
&.active {
background-color: $g3-castle !important;
color: $g18-cloud !important;
}
}
}
/*
Admin Table
----------------------------------------------
*/
.admin-table {
.multi-select-dropdown {
width: 100%;
min-width: 150px;
}
.admin-table--delete {
visibility: hidden;
}
.dropdown-toggle {
background-color: transparent;
font-size: 14px;
font-weight: 500;
color: $g14-chromium;
width: 100%;
transition: none !important;
.caret {opacity: 0;}
.multi-select-dropdown__label {left: 0;}
}
tbody tr:hover {
.admin-table--delete {
visibility: visible;
}
.dropdown-toggle {
color: $g20-white !important;
background-color: $c-pool;
font-weight: 600;
.caret {opacity: 1;}
.multi-select-dropdown__label {left: 9px;}
&:hover {
transition: background-color 0.25s ease;
background-color: $c-laser;
}
}
}
}
.admin-table--edit-row {
background-color: $g4-onyx;
}
.admin-table--edit-cell {
width: 100%;
margin: 0 !important;
display: flex !important;
justify-content: space-between;
> input {
height: 30px;
padding: 0 9px;
flex-grow: 1;
margin: 0 2px;
min-width: 110px;
&:first-child {margin-left: 0;}
&:last-child {margin-right: 0;}
}
}

View File

@ -29,7 +29,6 @@
.panel-title { .panel-title {
color: $g10-wolf !important; color: $g10-wolf !important;
} }
.panel-body { .panel-body {
padding: 30px; padding: 30px;
background-color: $g3-castle; background-color: $g3-castle;
@ -42,23 +41,30 @@
> *:last-child { > *:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
table {
th,td {
border-color: $g5-pepper;
}
th {
color: $g17-whisper;
}
td {
color: $g14-chromium;
}
tbody tr:last-child td {
border-bottom: 2px solid $g5-pepper;
}
}
} }
} }
.panel.panel-info {
background-color: $g3-castle;
border: 0;
.panel-body,
.panel-heading {
background-color: transparent;
}
.panel-body {
padding: 30px;
}
.panel-heading {
padding: 0 30px;
height: 60px;
border: 0px;
.panel-title { color: $g14-chromium;}
}
}
.panel .panel-body table {
margin: 0;
}
table thead th { table thead th {
@include no-user-select(); @include no-user-select();
} }
@ -259,7 +265,6 @@ input {
max-width: 100%; max-width: 100%;
margin: 0 !important; margin: 0 !important;
padding: 0 !important; padding: 0 !important;
min-height: 70px;
max-height: 290px; max-height: 290px;
overflow: auto; overflow: auto;
@include custom-scrollbar($c-pool, $c-laser); @include custom-scrollbar($c-pool, $c-laser);
@ -751,10 +756,6 @@ $form-static-checkbox-size: 16px;
} }
} }
br { br {
@include no-user-select(); @include no-user-select();
} }

View File

@ -2,7 +2,7 @@ import axios from 'axios';
let links let links
const UNAUTHORIZED = 401 import {RES_UNAUTHORIZED} from 'shared/constants'
export default async function AJAX({ export default async function AJAX({
url, url,
@ -13,10 +13,9 @@ export default async function AJAX({
params = {}, params = {},
headers = {}, headers = {},
}) { }) {
let response
try { try {
const basepath = window.basepath || '' const basepath = window.basepath || ''
let response
url = `${basepath}${url}` url = `${basepath}${url}`
@ -47,9 +46,11 @@ export default async function AJAX({
...response, ...response,
} }
} catch (error) { } catch (error) {
if (!response.status === UNAUTHORIZED) { const {response} = error
if (!response.status === RES_UNAUTHORIZED) {
console.error(error) // eslint-disable-line no-console console.error(error) // eslint-disable-line no-console
} }
// console.error(error) // eslint-disable-line no-console
const {auth} = links const {auth} = links
throw {auth, ...response} // eslint-disable-line no-throw-literal throw {auth, ...response} // eslint-disable-line no-throw-literal
} }

View File

@ -1,19 +1,17 @@
import AJAX from 'utils/ajax'; import AJAX from 'utils/ajax';
// TODO: delete this once all references export const proxy = async ({source, query, db, rp}) => {
// to it have been removed try {
export function buildInfluxUrl() { return await AJAX({
return "You dont need me anymore"; method: 'POST',
} url: source,
data: {
export function proxy({source, query, db, rp}) { query,
return AJAX({ db,
method: 'POST', rp,
url: source, },
data: { })
query, } catch (error) {
db, console.error(error) // eslint-disable-line no-console
rp, }
},
});
} }

71
ui/stories/admin.js Normal file
View File

@ -0,0 +1,71 @@
import React from 'react'
import {storiesOf, action, linkTo} from '@kadira/storybook'
import Center from './components/Center'
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
import Tooltip from 'shared/components/Tooltip'
storiesOf('MultiSelectDropdown', module)
.add('Select Roles w/label', () => (
<Center>
<MultiSelectDropdown
items={[
'Admin',
'User',
'Chrono Girafferoo',
'Prophet',
'Susford',
]}
selectedItems={[
'User',
'Chrono Girafferoo',
]}
label={'Select Roles'}
onApply={action('onApply')}
/>
</Center>
))
.add('Selected Item list', () => (
<Center>
<MultiSelectDropdown
items={[
'Admin',
'User',
'Chrono Giraffe',
'Prophet',
'Susford',
]}
selectedItems={[
'User',
'Chrono Giraffe',
]}
onApply={action('onApply')}
/>
</Center>
))
.add('0 selected items', () => (
<Center>
<MultiSelectDropdown
items={[
'Admin',
'User',
'Chrono Giraffe',
'Prophet',
'Susford',
]}
selectedItems={[]}
onApply={action('onApply')}
/>
</Center>
))
storiesOf('Tooltip', module)
.add('Delete', () => (
<Center>
<Tooltip tip={`Are you sure? TrashIcon`}>
<div className="btn btn-info btn-sm">
Delete
</div>
</Tooltip>
</Center>
))

View File

@ -0,0 +1,14 @@
import React from 'react'
const Center = ({children}) => (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%)',
}}>
{children}
</div>
)
export default Center

View File

@ -3,3 +3,4 @@ import 'src/style/chronograf.scss';
// Kapacitor Stories // Kapacitor Stories
import './kapacitor' import './kapacitor'
import './admin'

View File

@ -12,7 +12,7 @@ import queryConfigs from './stubs/queryConfigs';
// Actions for Spies // Actions for Spies
import * as kapacitorActions from 'src/kapacitor/actions/view' import * as kapacitorActions from 'src/kapacitor/actions/view'
import * as queryActions from 'src/chronograf/actions/view'; import * as queryActions from 'src/data_explorer/actions/view';
// Components // Components
import KapacitorRule from 'src/kapacitor/components/KapacitorRule'; import KapacitorRule from 'src/kapacitor/components/KapacitorRule';

19
ui/storybook.js Normal file
View File

@ -0,0 +1,19 @@
const express = require('express')
const request = require('request')
const {default: storybook} = require('@kadira/storybook/dist/server/middleware')
const app = express()
const handler = (req, res) => {
console.log(`${req.method} ${req.url}`)
const url = 'http://localhost:8888' + req.url
req.pipe(request(url)).pipe(res)
}
app.use(storybook('./.storybook'))
app.get('/chronograf/v1/*', handler)
app.post('/chronograf/v1/*', handler)
app.listen(6006, () => {
console.log('storybook proxy server now running')
})