Merge branch 'master' into alert-message-polish
commit
5f1dd3e12c
18
CHANGELOG.md
18
CHANGELOG.md
|
@ -1,14 +1,26 @@
|
|||
## v1.2.0 [unreleased]
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
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]
|
||||
|
||||
|
|
11
README.md
11
README.md
|
@ -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
|
||||
* 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.
|
||||
|
||||
### OAuth Login
|
||||
|
@ -121,7 +128,7 @@ Change the default root path of the Chronograf server with the `--basepath` opti
|
|||
|
||||
## 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.
|
||||
|
||||
Spotted a bug or have a feature request?
|
||||
|
|
|
@ -32,6 +32,8 @@ type Ctrl interface {
|
|||
DeleteRole(ctx context.Context, name string) error
|
||||
SetRolePerms(ctx context.Context, name string, perms Permissions) 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
|
||||
|
|
|
@ -272,32 +272,52 @@ func (m *MetaClient) SetRolePerms(ctx context.Context, name string, perms Permis
|
|||
return m.Post(ctx, "/role", a, nil)
|
||||
}
|
||||
|
||||
// RemoveAllRoleUsers removes all users from a role
|
||||
func (m *MetaClient) RemoveAllRoleUsers(ctx context.Context, name string) error {
|
||||
// SetRoleUsers removes all users and then adds the requested users to role
|
||||
func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []string) error {
|
||||
role, err := m.Role(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// No users to remove
|
||||
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 {
|
||||
revoke, add := Difference(users, role.Users)
|
||||
if err := m.RemoveRoleUsers(ctx, name, revoke); err != nil {
|
||||
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
|
||||
if len(users) == 0 {
|
||||
return nil
|
||||
|
@ -313,6 +333,23 @@ func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []stri
|
|||
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
|
||||
func (m *MetaClient) Post(ctx context.Context, path string, action interface{}, params map[string]string) error {
|
||||
b, err := json.Marshal(action)
|
||||
|
|
|
@ -1252,12 +1252,11 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
|||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantRm string
|
||||
wantAdd string
|
||||
wants []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Successful set users role",
|
||||
name: "Successful set users role (remove user from role)",
|
||||
fields: fields{
|
||||
URL: &url.URL{
|
||||
Host: "twinpinesmall.net:8091",
|
||||
|
@ -1274,7 +1273,7 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
|||
ctx: context.Background(),
|
||||
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",
|
||||
|
@ -1285,7 +1284,7 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
|||
},
|
||||
client: NewMockClient(
|
||||
http.StatusOK,
|
||||
[]byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`),
|
||||
[]byte(`{"roles":[{"name":"admin","users":[],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`),
|
||||
nil,
|
||||
nil,
|
||||
),
|
||||
|
@ -1295,8 +1294,9 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
|||
name: "admin",
|
||||
users: []string{"marty"},
|
||||
},
|
||||
wantRm: `{"action":"remove-users","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`,
|
||||
wantAdd: `{"action":"add-users","role":{"name":"admin","users":["marty"]}}`,
|
||||
wants: []string{
|
||||
`{"action":"add-users","role":{"name":"admin","users":["marty"]}}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
@ -1312,8 +1312,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
|||
continue
|
||||
}
|
||||
reqs := tt.fields.client.(*MockClient).Requests
|
||||
if len(reqs) < 2 {
|
||||
t.Errorf("%q. MetaClient.SetRoleUsers() expected 2 but got %d", tt.name, len(reqs))
|
||||
if len(reqs) != len(tt.wants)+1 {
|
||||
t.Errorf("%q. MetaClient.SetRoleUsers() expected %d but got %d", tt.name, len(tt.wants)+1, len(reqs))
|
||||
continue
|
||||
}
|
||||
|
||||
|
@ -1324,21 +1324,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
|
|||
if usr.URL.Path != "/role" {
|
||||
t.Errorf("%q. MetaClient.SetRoleUsers() expected /user path but got %s", tt.name, usr.URL.Path)
|
||||
}
|
||||
|
||||
prm := reqs[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]
|
||||
for i := range tt.wants {
|
||||
prm := reqs[i+1]
|
||||
if prm.Method != "POST" {
|
||||
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)
|
||||
if string(got) != tt.wantAdd {
|
||||
t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wantAdd)
|
||||
if string(got) != tt.wants[i] {
|
||||
t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wants[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -88,6 +88,14 @@ func (cc *ControlClient) SetRoleUsers(ctx context.Context, name string, users []
|
|||
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 {
|
||||
URLs []string
|
||||
Response Response
|
||||
|
|
|
@ -66,16 +66,20 @@ func (c *RolesStore) Get(ctx context.Context, name string) (*chronograf.Role, er
|
|||
|
||||
// Update the Role's permissions and roles
|
||||
func (c *RolesStore) Update(ctx context.Context, u *chronograf.Role) error {
|
||||
perms := ToEnterprise(u.Permissions)
|
||||
if err := c.Ctrl.SetRolePerms(ctx, u.Name, perms); err != nil {
|
||||
return err
|
||||
if u.Permissions != nil {
|
||||
perms := ToEnterprise(u.Permissions)
|
||||
if err := c.Ctrl.SetRolePerms(ctx, u.Name, perms); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
users := make([]string, len(u.Users))
|
||||
for i, u := range u.Users {
|
||||
users[i] = u.Name
|
||||
if u.Users != nil {
|
||||
users := make([]string, len(u.Users))
|
||||
for i, u := range u.Users {
|
||||
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
|
||||
|
|
|
@ -18,10 +18,17 @@ func (c *UserStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.Us
|
|||
return nil, err
|
||||
}
|
||||
perms := ToEnterprise(u.Permissions)
|
||||
|
||||
if err := c.Ctrl.SetUserPerms(ctx, u.Name, perms); err != nil {
|
||||
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
|
||||
|
@ -62,6 +69,43 @@ func (c *UserStore) Update(ctx context.Context, u *chronograf.User) error {
|
|||
if 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)
|
||||
return c.Ctrl.SetUserPerms(ctx, u.Name, perms)
|
||||
}
|
||||
|
|
|
@ -36,6 +36,22 @@ func TestClient_Add(t *testing.T) {
|
|||
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{}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
args: args{
|
||||
|
@ -46,8 +62,82 @@ func TestClient_Add(t *testing.T) {
|
|||
},
|
||||
},
|
||||
want: &chronograf.User{
|
||||
Name: "marty",
|
||||
Passwd: "johnny be good",
|
||||
Name: "marty",
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return nil
|
||||
},
|
||||
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
|
||||
return map[string]enterprise.Roles{}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
args: args{
|
||||
|
@ -369,6 +462,40 @@ func TestClient_Update(t *testing.T) {
|
|||
},
|
||||
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",
|
||||
fields: fields{
|
||||
|
@ -376,6 +503,9 @@ func TestClient_Update(t *testing.T) {
|
|||
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.")
|
||||
},
|
||||
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
|
||||
return map[string]enterprise.Roles{}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
args: args{
|
||||
|
@ -573,12 +703,14 @@ type mockCtrl struct {
|
|||
|
||||
userRoles func(ctx context.Context) (map[string]enterprise.Roles, error)
|
||||
|
||||
roles func(ctx context.Context, name *string) (*enterprise.Roles, error)
|
||||
role func(ctx context.Context, name string) (*enterprise.Role, error)
|
||||
createRole 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
|
||||
setRoleUsers func(ctx context.Context, name string, users []string) error
|
||||
roles func(ctx context.Context, name *string) (*enterprise.Roles, error)
|
||||
role func(ctx context.Context, name string) (*enterprise.Role, error)
|
||||
createRole 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
|
||||
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) {
|
||||
|
@ -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 {
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -8,8 +8,10 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
// AllowAll means a user gets both read and write permissions
|
||||
AllowAll = chronograf.Allowances{"WRITE", "READ"}
|
||||
// AllowAllDB means a user gets both read and write permissions for a db
|
||||
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 = chronograf.Allowances{"READ"}
|
||||
// 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{
|
||||
{
|
||||
Scope: chronograf.AllScope,
|
||||
Allowed: AllowAll,
|
||||
Allowed: AllowAllAdmin,
|
||||
},
|
||||
{
|
||||
Scope: chronograf.DBScope,
|
||||
Allowed: AllowAll,
|
||||
Allowed: AllowAllDB,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -90,7 +92,7 @@ func (r *showResults) Permissions() chronograf.Permissions {
|
|||
}
|
||||
switch priv {
|
||||
case AllPrivileges, All:
|
||||
c.Allowed = AllowAll
|
||||
c.Allowed = AllowAllDB
|
||||
case Read:
|
||||
c.Allowed = AllowRead
|
||||
case Write:
|
||||
|
@ -111,7 +113,7 @@ func adminPerms() chronograf.Permissions {
|
|||
return []chronograf.Permission{
|
||||
{
|
||||
Scope: chronograf.AllScope,
|
||||
Allowed: AllowAll,
|
||||
Allowed: AllowAllAdmin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -318,7 +318,7 @@ func Test_showResults_Users(t *testing.T) {
|
|||
Permissions: chronograf.Permissions{
|
||||
{
|
||||
Scope: chronograf.AllScope,
|
||||
Allowed: chronograf.Allowances{"WRITE", "READ"},
|
||||
Allowed: chronograf.Allowances{"ALL"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -16,8 +16,12 @@ func (c *Client) Add(ctx context.Context, u *chronograf.User) (*chronograf.User,
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
for _, p := range u.Permissions {
|
||||
if err := c.grantPermission(ctx, u.Name, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return c.Get(ctx, u.Name)
|
||||
}
|
||||
|
||||
// Delete the User from InfluxDB
|
||||
|
|
|
@ -97,12 +97,12 @@ func TestClient_Add(t *testing.T) {
|
|||
u *chronograf.User
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
status int
|
||||
want *chronograf.User
|
||||
wantQuery string
|
||||
wantErr bool
|
||||
name string
|
||||
args args
|
||||
status int
|
||||
want *chronograf.User
|
||||
wantQueries []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Create User",
|
||||
|
@ -114,10 +114,57 @@ func TestClient_Add(t *testing.T) {
|
|||
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{
|
||||
Name: "docbrown",
|
||||
Passwd: "Dont Need Roads",
|
||||
Name: "docbrown",
|
||||
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",
|
||||
},
|
||||
},
|
||||
wantQuery: `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`,
|
||||
wantErr: true,
|
||||
wantQueries: []string{`CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
query := ""
|
||||
queries := []string{}
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if path := r.URL.Path; path != "/query" {
|
||||
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.Write([]byte(`{"results":[{}]}`))
|
||||
rw.Write([]byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`))
|
||||
}))
|
||||
u, _ := url.Parse(ts.URL)
|
||||
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)
|
||||
continue
|
||||
}
|
||||
if tt.wantQuery != query {
|
||||
t.Errorf("%q. Client.Add() query = %v, want %v", tt.name, query, tt.wantQuery)
|
||||
if len(tt.wantQueries) != len(queries) {
|
||||
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) {
|
||||
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{
|
||||
chronograf.Permission{
|
||||
Scope: "all",
|
||||
Allowed: []string{"WRITE", "READ"},
|
||||
Allowed: []string{"ALL"},
|
||||
},
|
||||
chronograf.Permission{
|
||||
Scope: "database",
|
||||
|
@ -548,7 +602,7 @@ func TestClient_All(t *testing.T) {
|
|||
Permissions: chronograf.Permissions{
|
||||
chronograf.Permission{
|
||||
Scope: "all",
|
||||
Allowed: []string{"WRITE", "READ"},
|
||||
Allowed: []string{"ALL"},
|
||||
},
|
||||
chronograf.Permission{
|
||||
Scope: "database",
|
||||
|
@ -562,7 +616,7 @@ func TestClient_All(t *testing.T) {
|
|||
Permissions: chronograf.Permissions{
|
||||
chronograf.Permission{
|
||||
Scope: "all",
|
||||
Allowed: []string{"WRITE", "READ"},
|
||||
Allowed: []string{"ALL"},
|
||||
},
|
||||
chronograf.Permission{
|
||||
Scope: "database",
|
||||
|
@ -688,7 +742,7 @@ func TestClient_Update(t *testing.T) {
|
|||
Permissions: chronograf.Permissions{
|
||||
{
|
||||
Scope: "all",
|
||||
Allowed: []string{"WRITE", "READ"},
|
||||
Allowed: []string{"all"},
|
||||
},
|
||||
{
|
||||
Scope: "database",
|
||||
|
@ -743,7 +797,7 @@ func TestClient_Update(t *testing.T) {
|
|||
Permissions: chronograf.Permissions{
|
||||
{
|
||||
Scope: "all",
|
||||
Allowed: []string{"WRITE", "READ"},
|
||||
Allowed: []string{"all"},
|
||||
},
|
||||
{
|
||||
Scope: "database",
|
||||
|
@ -800,6 +854,34 @@ func TestClient_Update(t *testing.T) {
|
|||
`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",
|
||||
statusUsers: http.StatusBadRequest,
|
||||
|
|
545
server/admin.go
545
server/admin.go
|
@ -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)
|
||||
}
|
1476
server/admin_test.go
1476
server/admin_test.go
File diff suppressed because it is too large
Load Diff
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
370
server/users.go
370
server/users.go
|
@ -1,99 +1,317 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"golang.org/x/net/context"
|
||||
|
||||
"github.com/bouk/httprouter"
|
||||
"github.com/influxdata/chronograf"
|
||||
"github.com/influxdata/chronograf/oauth2"
|
||||
)
|
||||
|
||||
type userLinks struct {
|
||||
Self string `json:"self"` // Self link mapping to this resource
|
||||
}
|
||||
|
||||
type userResponse struct {
|
||||
*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)
|
||||
// NewSourceUser adds user to source
|
||||
func (h *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req userRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
invalidJSON(w, h.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
email, err := getEmail(ctx)
|
||||
if err != nil {
|
||||
if err := req.ValidCreate(); err != nil {
|
||||
invalidData(w, err, h.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
usr, err := h.UsersStore.Get(ctx, email)
|
||||
if err == nil {
|
||||
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)
|
||||
ctx := r.Context()
|
||||
srcID, ts, err := h.sourcesSeries(ctx, w, r)
|
||||
if err != nil {
|
||||
msg := fmt.Errorf("error storing user %s: %v", user.Name, err)
|
||||
unknownErrorWithMessage(w, msg, h.Logger)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
@ -46,7 +46,7 @@
|
|||
'arrow-parens': 0,
|
||||
'comma-dangle': [2, 'always-multiline'],
|
||||
'no-cond-assign': 2,
|
||||
'no-console': 2,
|
||||
'no-console': ['error', {allow: ['error']}],
|
||||
'no-constant-condition': 2,
|
||||
'no-control-regex': 2,
|
||||
'no-debugger': 2,
|
||||
|
|
|
@ -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')
|
||||
});
|
|
@ -17,9 +17,7 @@
|
|||
"test:lint": "npm run lint; npm run test",
|
||||
"test:dev": "nodemon --exec npm run test:lint",
|
||||
"clean": "rm -rf build",
|
||||
"storybook": "start-storybook -p 6006",
|
||||
"build-storybook": "build-storybook",
|
||||
"proxy": "node ./corsless"
|
||||
"storybook": "node ./storybook"
|
||||
},
|
||||
"author": "",
|
||||
"eslintConfig": {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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}`))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -0,0 +1,19 @@
|
|||
import React, {PropTypes} from 'react'
|
||||
|
||||
const EmptyRow = ({tableName}) => (
|
||||
<tr className="table-empty-state">
|
||||
<th colSpan="5">
|
||||
<p>You don't have any {tableName},<br/>why not create one?</p>
|
||||
</th>
|
||||
</tr>
|
||||
)
|
||||
|
||||
const {
|
||||
string,
|
||||
} = PropTypes
|
||||
|
||||
EmptyRow.propTypes = {
|
||||
tableName: string.isRequired,
|
||||
}
|
||||
|
||||
export default EmptyRow
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -0,0 +1,2 @@
|
|||
import AdminPage from './containers/AdminPage';
|
||||
export {AdminPage};
|
|
@ -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
|
||||
}
|
|
@ -6,10 +6,6 @@ import AJAX from 'utils/ajax';
|
|||
import _ from 'lodash';
|
||||
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({
|
||||
propTypes: {
|
||||
source: PropTypes.shape({
|
||||
|
|
|
@ -2,7 +2,7 @@ import React, {PropTypes} from 'react';
|
|||
import classNames from 'classnames';
|
||||
import _ from 'lodash';
|
||||
|
||||
import MultiSelectDropdown from './MultiSelectDropdown';
|
||||
import MultiSelectDropdown from 'src/shared/components/MultiSelectDropdown';
|
||||
import Dropdown from 'src/shared/components/Dropdown';
|
||||
|
||||
import {INFLUXQL_FUNCTIONS} from '../constants';
|
||||
|
|
|
@ -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);
|
|
@ -14,6 +14,7 @@ import {KapacitorPage, KapacitorRulePage, KapacitorRulesPage, KapacitorTasksPage
|
|||
import DataExplorer from 'src/data_explorer';
|
||||
import {DashboardsPage, DashboardPage} from 'src/dashboards';
|
||||
import {CreateSource, SourcePage, ManageSources} from 'src/sources';
|
||||
import {AdminPage} from 'src/admin';
|
||||
import NotFound from 'src/shared/components/NotFound';
|
||||
import configureStore from 'src/store/configureStore';
|
||||
import {getMe, getSources} from 'shared/apis';
|
||||
|
@ -127,6 +128,7 @@ const Root = React.createClass({
|
|||
<Route path="alert-rules" component={KapacitorRulesPage} />
|
||||
<Route path="alert-rules/:ruleID" component={KapacitorRulePage} />
|
||||
<Route path="alert-rules/new" component={KapacitorRulePage} />
|
||||
<Route path="admin" component={AdminPage} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" component={NotFound} />
|
||||
|
|
|
@ -7,18 +7,16 @@ export function showDatabases(source) {
|
|||
return proxy({source, query});
|
||||
}
|
||||
|
||||
export function showQueries(host, db, clusterID) {
|
||||
const statement = 'SHOW QUERIES';
|
||||
const url = buildInfluxUrl({host, statement, database: db});
|
||||
export function showQueries(source, db) {
|
||||
const query = 'SHOW QUERIES';
|
||||
|
||||
return proxy(url, clusterID);
|
||||
return proxy({source, query, db});
|
||||
}
|
||||
|
||||
export function killQuery(host, queryId, clusterID) {
|
||||
const statement = `KILL QUERY ${queryId}`;
|
||||
const url = buildInfluxUrl({host, statement});
|
||||
export function killQuery(source, queryId) {
|
||||
const query = `KILL QUERY ${queryId}`;
|
||||
|
||||
return proxy(url, clusterID);
|
||||
return proxy({source, query});
|
||||
}
|
||||
|
||||
export function showMeasurements(source, db) {
|
||||
|
|
|
@ -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)
|
|
@ -28,6 +28,7 @@ export const TabList = React.createClass({
|
|||
activeIndex: number,
|
||||
onActivate: func,
|
||||
isKapacitorTabs: string,
|
||||
customClass: string,
|
||||
},
|
||||
|
||||
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 (
|
||||
<div className="btn-group btn-group-lg tab-group">{children}</div>
|
||||
);
|
||||
|
@ -63,11 +72,13 @@ export const TabPanels = React.createClass({
|
|||
propTypes: {
|
||||
children: node.isRequired,
|
||||
activeIndex: number,
|
||||
customClass: string,
|
||||
},
|
||||
|
||||
// if only 1 child, children array index lookup will fail
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div className={this.props.customClass ? this.props.customClass : null}>
|
||||
{this.props.children[this.props.activeIndex]}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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
|
|
@ -471,4 +471,6 @@ export const STROKE_WIDTH = {
|
|||
export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds.
|
||||
export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds.
|
||||
|
||||
export const RES_UNAUTHORIZED = 401
|
||||
|
||||
export const AUTOREFRESH_DEFAULT = 15000 // in milliseconds
|
||||
|
|
|
@ -47,6 +47,9 @@ const SideNav = React.createClass({
|
|||
<NavListItem link={`${sourcePrefix}/manage-sources`}>InfluxDB</NavListItem>
|
||||
<NavListItem link={`${sourcePrefix}/kapacitor-config`}>Kapacitor</NavListItem>
|
||||
</NavBlock>
|
||||
<NavBlock icon="crown" link={`${sourcePrefix}/admin`}>
|
||||
<NavHeader link={`${sourcePrefix}/admin`} title="Admin" />
|
||||
</NavBlock>
|
||||
{loggedIn ? (
|
||||
<NavBlock icon="user-outline" className="sidebar__square-last">
|
||||
<a className="sidebar__menu-item" href="/oauth/logout">Logout</a>
|
||||
|
|
|
@ -3,6 +3,7 @@ import {combineReducers} from 'redux';
|
|||
import thunkMiddleware from 'redux-thunk';
|
||||
import makeQueryExecuter from 'src/shared/middleware/queryExecuter';
|
||||
import resizeLayout from 'src/shared/middleware/resizeLayout';
|
||||
import adminReducer from 'src/admin/reducers/admin';
|
||||
import sharedReducers from 'src/shared/reducers';
|
||||
import dataExplorerReducers from 'src/data_explorer/reducers';
|
||||
import rulesReducer from 'src/kapacitor/reducers/rules';
|
||||
|
@ -12,6 +13,7 @@ import persistStateEnhancer from './persistStateEnhancer';
|
|||
const rootReducer = combineReducers({
|
||||
...sharedReducers,
|
||||
...dataExplorerReducers,
|
||||
admin: adminReducer,
|
||||
rules: rulesReducer,
|
||||
dashboardUI,
|
||||
});
|
||||
|
|
|
@ -47,6 +47,7 @@
|
|||
@import 'pages/kapacitor';
|
||||
@import 'pages/data-explorer';
|
||||
@import 'pages/dashboards';
|
||||
@import 'pages/admin';
|
||||
|
||||
// TODO
|
||||
@import 'unsorted';
|
||||
|
|
|
@ -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__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 {
|
||||
width: 110px;
|
||||
|
||||
&.btn-xs {
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
&__apply {
|
||||
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 */
|
||||
.multi-select-dropdown.open {
|
||||
.dropdown-options {
|
||||
|
@ -56,3 +146,14 @@
|
|||
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;
|
||||
}
|
||||
|
|
|
@ -2,10 +2,13 @@
|
|||
Custom Search Widget
|
||||
----------------------------------------------
|
||||
*/
|
||||
$search-widget-height: 36px;
|
||||
|
||||
.users__search-widget {
|
||||
position: relative;
|
||||
|
||||
input.form-control {
|
||||
height: $search-widget-height;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
|
@ -19,7 +22,7 @@
|
|||
.input-group-addon {
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
line-height: 38px;
|
||||
line-height: calc(#{$search-widget-height} - 2px);
|
||||
position: absolute;
|
||||
color: $g10-wolf;
|
||||
top: 0;
|
||||
|
@ -32,4 +35,7 @@
|
|||
transition:
|
||||
color 0.25s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
.admin__search-widget {
|
||||
width: 300px;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,29 @@
|
|||
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 {
|
||||
font-family: $code-font;
|
||||
letter-spacing: 0px;
|
||||
|
@ -119,4 +142,4 @@ table .monotype {
|
|||
border-color: $g5-pepper;
|
||||
@include custom-scrollbar($g5-pepper, $c-pool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;}
|
||||
}
|
||||
}
|
|
@ -29,7 +29,6 @@
|
|||
.panel-title {
|
||||
color: $g10-wolf !important;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 30px;
|
||||
background-color: $g3-castle;
|
||||
|
@ -42,23 +41,30 @@
|
|||
> *:last-child {
|
||||
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 {
|
||||
@include no-user-select();
|
||||
}
|
||||
|
@ -259,7 +265,6 @@ input {
|
|||
max-width: 100%;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
min-height: 70px;
|
||||
max-height: 290px;
|
||||
overflow: auto;
|
||||
@include custom-scrollbar($c-pool, $c-laser);
|
||||
|
@ -751,10 +756,6 @@ $form-static-checkbox-size: 16px;
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
br {
|
||||
@include no-user-select();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import axios from 'axios';
|
|||
|
||||
let links
|
||||
|
||||
const UNAUTHORIZED = 401
|
||||
import {RES_UNAUTHORIZED} from 'shared/constants'
|
||||
|
||||
export default async function AJAX({
|
||||
url,
|
||||
|
@ -13,10 +13,9 @@ export default async function AJAX({
|
|||
params = {},
|
||||
headers = {},
|
||||
}) {
|
||||
let response
|
||||
|
||||
try {
|
||||
const basepath = window.basepath || ''
|
||||
let response
|
||||
|
||||
url = `${basepath}${url}`
|
||||
|
||||
|
@ -47,9 +46,11 @@ export default async function AJAX({
|
|||
...response,
|
||||
}
|
||||
} 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
|
||||
const {auth} = links
|
||||
throw {auth, ...response} // eslint-disable-line no-throw-literal
|
||||
}
|
||||
|
|
|
@ -1,19 +1,17 @@
|
|||
import AJAX from 'utils/ajax';
|
||||
|
||||
// TODO: delete this once all references
|
||||
// to it have been removed
|
||||
export function buildInfluxUrl() {
|
||||
return "You dont need me anymore";
|
||||
}
|
||||
|
||||
export function proxy({source, query, db, rp}) {
|
||||
return AJAX({
|
||||
method: 'POST',
|
||||
url: source,
|
||||
data: {
|
||||
query,
|
||||
db,
|
||||
rp,
|
||||
},
|
||||
});
|
||||
export const proxy = async ({source, query, db, rp}) => {
|
||||
try {
|
||||
return await AJAX({
|
||||
method: 'POST',
|
||||
url: source,
|
||||
data: {
|
||||
query,
|
||||
db,
|
||||
rp,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error) // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
))
|
|
@ -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
|
|
@ -3,3 +3,4 @@ import 'src/style/chronograf.scss';
|
|||
|
||||
// Kapacitor Stories
|
||||
import './kapacitor'
|
||||
import './admin'
|
||||
|
|
|
@ -12,7 +12,7 @@ import queryConfigs from './stubs/queryConfigs';
|
|||
|
||||
// Actions for Spies
|
||||
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
|
||||
import KapacitorRule from 'src/kapacitor/components/KapacitorRule';
|
||||
|
|
|
@ -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')
|
||||
})
|
Loading…
Reference in New Issue