diff --git a/CHANGELOG.md b/CHANGELOG.md index 5305e8c91..e1043217d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,17 @@ ### 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 with ability to manage Users, Roles, and Permissions for InfluxDB and Enterprise 2. [#993](https://github.com/influxdata/chronograf/pull/993): Add ability to manage Queries for InfluxDB and Enterprise ### UI Improvements - 1. [#993](https://github.com/influxdata/chronograf/pull/993): Improve multi-select dropdown - 2. [#993](https://github.com/influxdata/chronograf/pull/993): Provide better error information to users + 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 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] @@ -25,6 +28,7 @@ 4. [#892](https://github.com/influxdata/chronograf/issues/891): Make dashboard visualizations resizable 5. [#893](https://github.com/influxdata/chronograf/issues/893): Persist dashboard visualization position 6. [#922](https://github.com/influxdata/chronograf/issues/922): Additional OAuth2 support for [Heroku](https://github.com/influxdata/chronograf/blob/master/docs/auth.md#heroku) and [Google](https://github.com/influxdata/chronograf/blob/master/docs/auth.md#google) + 7. [#781](https://github.com/influxdata/chronograf/issues/781): Add global auto-refresh dropdown to all graph dashboards ### UI Improvements 1. [#905](https://github.com/influxdata/chronograf/pull/905): Make scroll bar thumb element bigger diff --git a/LICENSE b/LICENSE index 9ffd67f52..fa74759fc 100644 --- a/LICENSE +++ b/LICENSE @@ -17,12 +17,11 @@ You should have received a copy of the GNU Affero General Public License along with this program. If not, see . InfluxData Inc. -150 Spear Street -Suite 1750 -San Francisco, CA 94105 -contact@influxdata.com - - +799 Market Street, Suite 400 +San Francisco, CA 94103 +contact@influxdata.com + + GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 diff --git a/README.md b/README.md index 662dc3a94..f4050f062 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Currently, Chronograf offers dashboard templates for the following Telegraf inpu * [InfluxDB](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/influxdb) * [Kubernetes](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/kubernetes) * [Memcached](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/memcached) +* [Mesos](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/mesos) * [MongoDB](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/mongodb) * [MySQL](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/mysql) * Network diff --git a/bolt/alerts.go b/bolt/alerts.go index d67264840..d49f4d3a1 100644 --- a/bolt/alerts.go +++ b/bolt/alerts.go @@ -11,8 +11,10 @@ import ( // Ensure AlertsStore implements chronograf.AlertsStore. var _ chronograf.AlertRulesStore = &AlertsStore{} +// AlertsBucket is the name of the bucket alert configuration is stored in var AlertsBucket = []byte("Alerts") +// AlertsStore represents the bolt implementation of a store for alerts type AlertsStore struct { client *Client } diff --git a/bolt/client.go b/bolt/client.go index e34a37828..ae57cc558 100644 --- a/bolt/client.go +++ b/bolt/client.go @@ -23,6 +23,7 @@ type Client struct { DashboardsStore *DashboardsStore } +// NewClient initializes all stores func NewClient() *Client { c := &Client{Now: time.Now} c.SourcesStore = &SourcesStore{client: c} @@ -79,6 +80,7 @@ func (c *Client) Open() error { return nil } +// Close the connection to the bolt database func (c *Client) Close() error { if c.db != nil { return c.db.Close() diff --git a/bolt/dashboards.go b/bolt/dashboards.go index 4df9ebf8e..28cf83f41 100644 --- a/bolt/dashboards.go +++ b/bolt/dashboards.go @@ -12,8 +12,10 @@ import ( // Ensure DashboardsStore implements chronograf.DashboardsStore. var _ chronograf.DashboardsStore = &DashboardsStore{} +// DashboardBucket is the bolt bucket dashboards are stored in var DashboardBucket = []byte("Dashoard") +// DashboardsStore is the bolt implementation of storing dashboards type DashboardsStore struct { client *Client IDs chronograf.DashboardID @@ -81,9 +83,9 @@ func (d *DashboardsStore) Get(ctx context.Context, id chronograf.DashboardID) (c } // Delete the dashboard from DashboardsStore -func (s *DashboardsStore) Delete(ctx context.Context, d chronograf.Dashboard) error { - if err := s.client.db.Update(func(tx *bolt.Tx) error { - if err := tx.Bucket(DashboardBucket).Delete(itob(int(d.ID))); err != nil { +func (d *DashboardsStore) Delete(ctx context.Context, dash chronograf.Dashboard) error { + if err := d.client.db.Update(func(tx *bolt.Tx) error { + if err := tx.Bucket(DashboardBucket).Delete(itob(int(dash.ID))); err != nil { return err } return nil @@ -95,16 +97,16 @@ func (s *DashboardsStore) Delete(ctx context.Context, d chronograf.Dashboard) er } // Update the dashboard in DashboardsStore -func (s *DashboardsStore) Update(ctx context.Context, d chronograf.Dashboard) error { - if err := s.client.db.Update(func(tx *bolt.Tx) error { +func (d *DashboardsStore) Update(ctx context.Context, dash chronograf.Dashboard) error { + if err := d.client.db.Update(func(tx *bolt.Tx) error { // Get an existing dashboard with the same ID. b := tx.Bucket(DashboardBucket) - strID := strconv.Itoa(int(d.ID)) + strID := strconv.Itoa(int(dash.ID)) if v := b.Get([]byte(strID)); v == nil { return chronograf.ErrDashboardNotFound } - if v, err := internal.MarshalDashboard(d); err != nil { + if v, err := internal.MarshalDashboard(dash); err != nil { return err } else if err := b.Put([]byte(strID), v); err != nil { return err diff --git a/bolt/layouts.go b/bolt/layouts.go index bd6ce4a8c..e443f80b1 100644 --- a/bolt/layouts.go +++ b/bolt/layouts.go @@ -11,8 +11,10 @@ import ( // Ensure LayoutStore implements chronograf.LayoutStore. var _ chronograf.LayoutStore = &LayoutStore{} +// LayoutBucket is the bolt bucket layouts are stored in var LayoutBucket = []byte("Layout") +// LayoutStore is the bolt implementation to store layouts type LayoutStore struct { client *Client IDs chronograf.ID diff --git a/bolt/servers.go b/bolt/servers.go index d9a4ebccb..63bd7c801 100644 --- a/bolt/servers.go +++ b/bolt/servers.go @@ -11,8 +11,11 @@ import ( // Ensure ServersStore implements chronograf.ServersStore. var _ chronograf.ServersStore = &ServersStore{} +// ServersBucket is the bolt bucket to store lists of servers var ServersBucket = []byte("Servers") +// ServersStore is the bolt implementation to store servers in a store. +// Used store servers that are associated in some way with a source type ServersStore struct { client *Client } diff --git a/bolt/sources.go b/bolt/sources.go index 46ced92b8..a2809ff23 100644 --- a/bolt/sources.go +++ b/bolt/sources.go @@ -11,8 +11,10 @@ import ( // Ensure SourcesStore implements chronograf.SourcesStore. var _ chronograf.SourcesStore = &SourcesStore{} +// SourcesBucket is the bolt bucket used to store source information var SourcesBucket = []byte("Sources") +// SourcesStore is a bolt implementation to store time-series source information. type SourcesStore struct { client *Client } diff --git a/canned/apps.go b/canned/apps.go index 1fd8440d3..8ad626e8d 100644 --- a/canned/apps.go +++ b/canned/apps.go @@ -11,6 +11,7 @@ import ( "github.com/influxdata/chronograf" ) +// AppExt is the the file extension searched for in the directory for layout files const AppExt = ".json" // Apps are canned JSON layouts. Implements LayoutStore. @@ -25,6 +26,7 @@ type Apps struct { Logger chronograf.Logger } +// NewApps constructs a layout store wrapping a file system directory func NewApps(dir string, ids chronograf.ID, logger chronograf.Logger) chronograf.LayoutStore { return &Apps{ Dir: dir, @@ -63,14 +65,14 @@ func createLayout(file string, layout chronograf.Layout) error { defer h.Close() if octets, err := json.MarshalIndent(layout, " ", " "); err != nil { return chronograf.ErrLayoutInvalid - } else { - if _, err := h.Write(octets); err != nil { - return err - } + } else if _, err := h.Write(octets); err != nil { + return err } + return nil } +// All returns all layouts from the directory func (a *Apps) All(ctx context.Context) ([]chronograf.Layout, error) { files, err := a.ReadDir(a.Dir) if err != nil { @@ -91,6 +93,7 @@ func (a *Apps) All(ctx context.Context) ([]chronograf.Layout, error) { return layouts, nil } +// Add creates a new layout within the directory func (a *Apps) Add(ctx context.Context, layout chronograf.Layout) (chronograf.Layout, error) { var err error layout.ID, err = a.IDs.Generate() @@ -118,6 +121,7 @@ func (a *Apps) Add(ctx context.Context, layout chronograf.Layout) (chronograf.La return layout, nil } +// Delete removes a layout file from the directory func (a *Apps) Delete(ctx context.Context, layout chronograf.Layout) error { _, file, err := a.idToFile(layout.ID) if err != nil { @@ -134,6 +138,7 @@ func (a *Apps) Delete(ctx context.Context, layout chronograf.Layout) error { return nil } +// Get returns an app file from the layout directory func (a *Apps) Get(ctx context.Context, ID string) (chronograf.Layout, error) { l, file, err := a.idToFile(ID) if err != nil { @@ -157,6 +162,7 @@ func (a *Apps) Get(ctx context.Context, ID string) (chronograf.Layout, error) { return l, nil } +// Update replaces a layout from the file system directory func (a *Apps) Update(ctx context.Context, layout chronograf.Layout) error { l, _, err := a.idToFile(layout.ID) if err != nil { diff --git a/canned/bin.go b/canned/bin.go index 2c32e9e04..1b5fd4399 100644 --- a/canned/bin.go +++ b/canned/bin.go @@ -10,6 +10,7 @@ import ( //go:generate go-bindata -o bin_gen.go -ignore README|apps|.sh|go -pkg canned . +// BinLayoutStore represents a layout store using data generated by go-bindata type BinLayoutStore struct { Logger chronograf.Logger } diff --git a/canned/mesos.json b/canned/mesos.json new file mode 100644 index 000000000..370f67f0c --- /dev/null +++ b/canned/mesos.json @@ -0,0 +1,131 @@ +{ + "id": "0fa47984-825b-46f1-9ca5-0366e3220000", + "measurement": "mesos", + "app": "mesos", + "autoflow": true, + "cells": [ + { + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "i": "0fa47984-825b-46f1-9ca5-0366e3220007", + "name": "Mesos Active Slaves", + "queries": [ + { + "query": "SELECT max(\"master/slaves_active\") AS \"Active Slaves\" FROM \"mesos\"", + "label": "count", + "groupbys": [], + "wheres": [] + } + ] + }, + { + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "i": "0fa47984-825b-46f1-9ca5-0366e3220001", + "name": "Mesos Tasks Active", + "queries": [ + { + "query": "SELECT max(\"master/tasks_running\") AS \"num tasks\" FROM \"mesos\"", + "label": "count", + "groupbys": [], + "wheres": [] + } + ] + }, + { + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "i": "0fa47984-825b-46f1-9ca5-0366e3220004", + "name": "Mesos Tasks", + "queries": [ + { + "query": "SELECT non_negative_derivative(max(\"master/tasks_finished\"), 60s) AS \"tasks finished\" FROM \"mesos\"", + "label": "count", + "groupbys": [], + "wheres": [] + }, + { + "query": "SELECT non_negative_derivative(max(\"master/tasks_failed\"), 60s) AS \"tasks failed\" FROM \"mesos\"", + "groupbys": [], + "wheres": [] + }, + { + "query": "SELECT non_negative_derivative(max(\"master/tasks_killed\"), 60s) AS \"tasks killed\" FROM \"mesos\"", + "groupbys": [], + "wheres": [] + } + ] + }, + { + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "i": "0fa47984-825b-46f1-9ca5-0366e3220005", + "name": "Mesos Outstanding offers", + "queries": [ + { + "query": "SELECT max(\"master/outstanding_offers\") AS \"Outstanding Offers\" FROM \"mesos\"", + "label": "count", + "groupbys": [], + "wheres": [] + } + ] + }, + { + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "i": "0fa47984-825b-46f1-9ca5-0366e3220002", + "name": "Mesos Available/Used CPUs", + "queries": [ + { + "query": "SELECT max(\"master/cpus_total\") AS \"cpu total\", max(\"master/cpus_used\") AS \"cpu used\" FROM \"mesos\"", + "label": "count", + "groupbys": [], + "wheres": [] + } + ] + }, + { + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "i": "0fa47984-825b-46f1-9ca5-0366e3220003", + "name": "Mesos Available/Used Memory", + "queries": [ + { + "query": "SELECT max(\"master/mem_total\") AS \"memory total\", max(\"master/mem_used\") AS \"memory used\" FROM \"mesos\"", + "label": "MB", + "groupbys": [], + "wheres": [] + } + ] + }, + { + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "i": "0fa47984-825b-46f1-9ca5-0366e3220008", + "name": "Mesos Master Uptime", + "type": "single-stat", + "queries": [ + { + "query": "SELECT max(\"master/uptime_secs\") AS \"uptime\" FROM \"mesos\"", + "label": "Seconds", + "groupbys": [], + "wheres": [] + } + ] + } + ] +} diff --git a/chronograf.go b/chronograf.go index 1a8b7730d..fc624bea4 100644 --- a/chronograf.go +++ b/chronograf.go @@ -2,6 +2,7 @@ package chronograf import ( "context" + "io" "net/http" ) @@ -32,12 +33,13 @@ func (e Error) Error() string { type Logger interface { Debug(...interface{}) Info(...interface{}) - Warn(...interface{}) Error(...interface{}) - Fatal(...interface{}) - Panic(...interface{}) WithField(string, interface{}) Logger + + // Logger can be transformed into an io.Writer. + // That writer is the end of an io.Pipe and it is your responsibility to close it. + Writer() *io.PipeWriter } // Assets returns a handler to serve the website. diff --git a/dist/dir.go b/dist/dir.go index 828ef231d..1f4ac90b9 100644 --- a/dist/dir.go +++ b/dist/dir.go @@ -11,6 +11,7 @@ type Dir struct { dir http.Dir } +// NewDir constructs a Dir with a default file func NewDir(dir, def string) Dir { return Dir{ Default: def, diff --git a/enterprise/enterprise.go b/enterprise/enterprise.go index 3a3999349..189e5a150 100644 --- a/enterprise/enterprise.go +++ b/enterprise/enterprise.go @@ -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 @@ -148,7 +150,7 @@ func (c *Client) Roles(ctx context.Context) (chronograf.RolesStore, error) { return c.RolesStore, nil } -// Allowances returns all Influx Enterprise permission strings +// Permissions returns all Influx Enterprise permission strings func (c *Client) Permissions(context.Context) chronograf.Permissions { all := chronograf.Allowances{ "NoPermissions", diff --git a/enterprise/meta.go b/enterprise/meta.go index 399b057b3..cd08730b7 100644 --- a/enterprise/meta.go +++ b/enterprise/meta.go @@ -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) diff --git a/enterprise/meta_test.go b/enterprise/meta_test.go index 2823c1256..96150d054 100644 --- a/enterprise/meta_test.go +++ b/enterprise/meta_test.go @@ -70,7 +70,7 @@ func TestMetaClient_ShowCluster(t *testing.T) { http.StatusBadGateway, nil, nil, - fmt.Errorf("Time circuits on. Flux Capacitor... fluxxing."), + fmt.Errorf("time circuits on. Flux Capacitor... fluxxing"), ), }, wantErr: true, @@ -214,7 +214,7 @@ func TestMetaClient_Users(t *testing.T) { http.StatusOK, []byte(`{"users":[{"name":"admin","hash":"1234","permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`), nil, - fmt.Errorf("Time circuits on. Flux Capacitor... fluxxing."), + fmt.Errorf("time circuits on. Flux Capacitor... fluxxing"), ), }, args: args{ @@ -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]) } } } diff --git a/enterprise/mocks_test.go b/enterprise/mocks_test.go index 08e025362..6e9cc95cd 100644 --- a/enterprise/mocks_test.go +++ b/enterprise/mocks_test.go @@ -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 diff --git a/enterprise/users.go b/enterprise/users.go index 02605d05a..68c04d193 100644 --- a/enterprise/users.go +++ b/enterprise/users.go @@ -22,7 +22,13 @@ func (c *UserStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.Us 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 @@ -63,11 +69,45 @@ func (c *UserStore) Update(ctx context.Context, u *chronograf.User) error { if u.Passwd != "" { return c.Ctrl.ChangePassword(ctx, u.Name, u.Passwd) } - if u.Permissions != nil { - perms := ToEnterprise(u.Permissions) - return c.Ctrl.SetUserPerms(ctx, u.Name, perms) + + // 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 } - return nil + + // 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) } // All is all users in influx diff --git a/enterprise/users_test.go b/enterprise/users_test.go index 54dc7f502..9cc0cddc5 100644 --- a/enterprise/users_test.go +++ b/enterprise/users_test.go @@ -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) +} diff --git a/influx/permissions.go b/influx/permissions.go index 1548e794f..809aff953 100644 --- a/influx/permissions.go +++ b/influx/permissions.go @@ -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, }, } } diff --git a/influx/permissions_test.go b/influx/permissions_test.go index 956e706a8..9aca7aa74 100644 --- a/influx/permissions_test.go +++ b/influx/permissions_test.go @@ -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"}, }, }, }, diff --git a/influx/users.go b/influx/users.go index bca83fec3..a8e10bcfa 100644 --- a/influx/users.go +++ b/influx/users.go @@ -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 diff --git a/influx/users_test.go b/influx/users_test.go index f486e13a9..9922d4e51 100644 --- a/influx/users_test.go +++ b/influx/users_test.go @@ -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, diff --git a/kapacitor/http_out.go b/kapacitor/http_out.go index 848e44a97..45bb0b36e 100644 --- a/kapacitor/http_out.go +++ b/kapacitor/http_out.go @@ -6,8 +6,10 @@ import ( "github.com/influxdata/chronograf" ) +// HTTPEndpoint is the default location of the tickscript output const HTTPEndpoint = "output" +// HTTPOut adds a kapacitor httpOutput to a tickscript func HTTPOut(rule chronograf.AlertRule) (string, error) { return fmt.Sprintf(`trigger|httpOut('%s')`, HTTPEndpoint), nil } diff --git a/kapacitor/operators.go b/kapacitor/operators.go index 745789fa6..869c6ec23 100644 --- a/kapacitor/operators.go +++ b/kapacitor/operators.go @@ -3,8 +3,8 @@ package kapacitor import "fmt" const ( - GreaterThan = "greater than" - LessThan = "less than" + greaterThan = "greater than" + lessThan = "less than" LessThanEqual = "equal to or less than" GreaterThanEqual = "equal to or greater" Equal = "equal to" @@ -16,9 +16,9 @@ const ( // kapaOperator converts UI strings to kapacitor operators func kapaOperator(operator string) (string, error) { switch operator { - case GreaterThan: + case greaterThan: return ">", nil - case LessThan: + case lessThan: return "<", nil case LessThanEqual: return "<=", nil diff --git a/kapacitor/validate.go b/kapacitor/validate.go index ed8dca32a..b7984fc16 100644 --- a/kapacitor/validate.go +++ b/kapacitor/validate.go @@ -3,7 +3,6 @@ package kapacitor import ( "bytes" "fmt" - "log" "time" "github.com/influxdata/chronograf" @@ -25,7 +24,6 @@ func ValidateAlert(service string) error { func formatTick(tickscript string) (chronograf.TICKScript, error) { node, err := ast.Parse(tickscript) if err != nil { - log.Fatalf("parse execution: %s", err) return "", err } @@ -41,6 +39,9 @@ func validateTick(script chronograf.TICKScript) error { return err } +// deadman is an empty implementation of a kapacitor DeadmanService to allow CreatePipeline +var _ pipeline.DeadmanService = &deadman{} + type deadman struct { interval time.Duration threshold float64 diff --git a/kapacitor/vars.go b/kapacitor/vars.go index 081f349fb..038834b46 100644 --- a/kapacitor/vars.go +++ b/kapacitor/vars.go @@ -41,17 +41,16 @@ func Vars(rule chronograf.AlertRule) (string, error) { var crit = %s ` return fmt.Sprintf(vars, common, formatValue(rule.TriggerValues.Value)), nil - } else { - vars := ` + } + vars := ` %s var lower = %s var upper = %s ` - return fmt.Sprintf(vars, - common, - rule.TriggerValues.Value, - rule.TriggerValues.RangeValue), nil - } + return fmt.Sprintf(vars, + common, + rule.TriggerValues.Value, + rule.TriggerValues.RangeValue), nil case Relative: vars := ` %s diff --git a/log/log.go b/log/log.go index 2cfbb2513..1b00e3332 100644 --- a/log/log.go +++ b/log/log.go @@ -1,6 +1,7 @@ package log import ( + "io" "os" "github.com/Sirupsen/logrus" @@ -81,6 +82,11 @@ func (ll *logrusLogger) WithField(key string, value interface{}) chronograf.Logg return &logrusLogger{ll.l.WithField(key, value)} } +func (ll *logrusLogger) Writer() *io.PipeWriter { + return ll.l.Logger.WriterLevel(logrus.ErrorLevel) +} + +// New wraps a logrus Logger func New(l Level) chronograf.Logger { logger := &logrus.Logger{ Out: os.Stderr, diff --git a/oauth2/doc.go b/oauth2/doc.go index db132a6ca..7fed8f3b6 100644 --- a/oauth2/doc.go +++ b/oauth2/doc.go @@ -1,4 +1,4 @@ -// The oauth2 package provides http.Handlers necessary for implementing Oauth2 +// Package oauth2 provides http.Handlers necessary for implementing Oauth2 // authentication with multiple Providers. // // This is how the pieces of this package fit together: diff --git a/oauth2/google.go b/oauth2/google.go index fb082ace6..ee5ff3bb1 100644 --- a/oauth2/google.go +++ b/oauth2/google.go @@ -10,7 +10,7 @@ import ( goauth2 "google.golang.org/api/oauth2/v2" ) -// Endpoint is Google's OAuth 2.0 endpoint. +// GoogleEndpoint is Google's OAuth 2.0 endpoint. // Copied here to remove tons of package dependencies var GoogleEndpoint = oauth2.Endpoint{ AuthURL: "https://accounts.google.com/o/oauth2/auth", @@ -18,6 +18,7 @@ var GoogleEndpoint = oauth2.Endpoint{ } var _ Provider = &Google{} +// Google is an oauth2 provider supporting google. type Google struct { ClientID string ClientSecret string diff --git a/oauth2/heroku.go b/oauth2/heroku.go index 637c57adf..831b095df 100644 --- a/oauth2/heroku.go +++ b/oauth2/heroku.go @@ -14,8 +14,8 @@ import ( var _ Provider = &Heroku{} const ( - // Routes required for interacting with Heroku API - HEROKU_ACCOUNT_ROUTE string = "https://api.heroku.com/account" + // HerokuAccountRoute is required for interacting with Heroku API + HerokuAccountRoute string = "https://api.heroku.com/account" ) // Heroku is an OAuth2 Provider allowing users to authenticate with Heroku to @@ -61,13 +61,14 @@ func (h *Heroku) PrincipalID(provider *http.Client) (string, error) { DefaultOrganization DefaultOrg `json:"default_organization"` } - resp, err := provider.Get(HEROKU_ACCOUNT_ROUTE) + resp, err := provider.Get(HerokuAccountRoute) if err != nil { h.Logger.Error("Unable to communicate with Heroku. err:", err) return "", err } defer resp.Body.Close() d := json.NewDecoder(resp.Body) + var account Account if err := d.Decode(&account); err != nil { h.Logger.Error("Unable to decode response from Heroku. err:", err) @@ -83,9 +84,8 @@ func (h *Heroku) PrincipalID(provider *http.Client) (string, error) { } h.Logger.Error(ErrOrgMembership) return "", ErrOrgMembership - } else { - return account.Email, nil } + return account.Email, nil } // Scopes for heroku is "identity" which grants access to user account diff --git a/oauth2/mux.go b/oauth2/mux.go index dc45a1525..9ecc11dba 100644 --- a/oauth2/mux.go +++ b/oauth2/mux.go @@ -24,6 +24,7 @@ type cookie struct { // Check to ensure CookieMux is an oauth2.Mux var _ Mux = &CookieMux{} +// NewCookieMux constructs a Mux handler that checks a cookie against the authenticator func NewCookieMux(p Provider, a Authenticator, l chronograf.Logger) *CookieMux { return &CookieMux{ Provider: p, @@ -55,7 +56,7 @@ type CookieMux struct { Now func() time.Time // Now returns the current time } -// Uses a Cookie with a random string as the state validation method. JWTs are +// Login uses a Cookie with a random string as the state validation method. JWTs are // a good choice here for encoding because they can be validated without // storing state. func (j *CookieMux) Login() http.Handler { diff --git a/oauth2/oauth2_test.go b/oauth2/oauth2_test.go index 60c34e1eb..ed6379414 100644 --- a/oauth2/oauth2_test.go +++ b/oauth2/oauth2_test.go @@ -27,8 +27,8 @@ func (mp *MockProvider) Config() *goauth.Config { ClientID: "4815162342", ClientSecret: "8675309", Endpoint: goauth.Endpoint{ - mp.ProviderURL + "/oauth/auth", - mp.ProviderURL + "/oauth/token", + AuthURL: mp.ProviderURL + "/oauth/auth", + TokenURL: mp.ProviderURL + "/oauth/token", }, } } diff --git a/server/admin.go b/server/admin.go deleted file mode 100644 index 27eb3428d..000000000 --- a/server/admin.go +++ /dev/null @@ -1,586 +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"` // Username for new account - Permissions chronograf.Permissions `json:"permissions,omitempty"` // Account's permissions - Links selfLinks `json:"links"` // Links are URI locations related to user -} - -type enterpriseSourceUser struct { - Username string `json:"name"` // Username for new account - Permissions chronograf.Permissions `json:"permissions"` // Account's permissions - Roles []userRoleResponse `json:"roles"` // Roles if source uses them - Links selfLinks `json:"links"` // Links are URI locations related to user -} - -type userRoleResponse struct { - Name string `json:"name"` - Permissions chronograf.Permissions `json:"permissions"` - Links selfLinks `json:"links"` -} - -func newUserRoleResponse(srcID int, res *chronograf.Role) userRoleResponse { - if res.Permissions == nil { - res.Permissions = make(chronograf.Permissions, 0) - } - return userRoleResponse{ - Name: res.Name, - Permissions: res.Permissions, - Links: newSelfLinks(srcID, "roles", res.Name), - } -} - -type selfLinks struct { - Self string `json:"self"` // Self link mapping to this resource -} - -func sourceUserResponse(u *chronograf.User, srcID int, hasRoles bool) interface{} { - // Permissions should always be returned. If no permissions, then - // return empty array - perms := u.Permissions - if len(perms) == 0 { - perms = make([]chronograf.Permission, 0) - } - - // If the source supports roles, we return all - // associated with this user - if hasRoles { - res := enterpriseSourceUser{ - Username: u.Name, - Permissions: perms, - Roles: make([]userRoleResponse, 0), - Links: newSelfLinks(srcID, "users", u.Name), - } - - if len(u.Roles) > 0 { - rr := make([]userRoleResponse, len(u.Roles)) - for i, role := range u.Roles { - rr[i] = newUserRoleResponse(srcID, &role) - } - res.Roles = rr - } - return &res - } - - res := sourceUser{ - Username: u.Name, - Permissions: perms, - Links: newSelfLinks(srcID, "users", u.Name), - } - return &res -} - -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, - Permissions: req.Permissions, - } - - 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 []interface{} `json:"users"` -} - -// 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) - - su := []interface{}{} - for _, u := range users { - res := sourceUserResponse(&u, srcID, hasRoles) - 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, 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 - } - _, hasRoles := h.hasRoles(ctx, ts) - - res := sourceUserResponse(u, srcID, hasRoles) - 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"` - 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) -} diff --git a/server/admin_test.go b/server/admin_test.go deleted file mode 100644 index 27a421618..000000000 --- a/server/admin_test.go +++ /dev/null @@ -1,1482 +0,0 @@ -package server_test - -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" - "github.com/influxdata/chronograf/server" -) - -func TestService_NewSourceUser(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries server.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 - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { - return u, nil - }, - } - }, - }, - }, - ID: "1", - wantStatus: http.StatusCreated, - wantContentType: "application/json", - wantBody: `{"name":"marty","links":{"self":"/chronograf/v1/sources/1/users/marty"}} -`, - }, - { - name: "Error adding user", - 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 - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { - return nil, fmt.Errorf("Weight Has Nothing to Do With It") - }, - } - }, - }, - }, - ID: "1", - wantStatus: http.StatusBadRequest, - wantContentType: "application/json", - wantBody: `{"code":400,"message":"Weight Has Nothing to Do With It"}`, - }, - { - name: "Failure connecting to user store", - 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 fmt.Errorf("Biff just happens to be my supervisor") - }, - }, - }, - ID: "1", - wantStatus: http.StatusBadRequest, - wantContentType: "application/json", - wantBody: `{"code":400,"message":"Unable to connect to source 1: Biff just happens to be my supervisor"}`, - }, - { - name: "Failure getting 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{}, fmt.Errorf("No McFly ever amounted to anything in the history of Hill Valley") - }, - }, - }, - ID: "1", - wantStatus: http.StatusNotFound, - wantContentType: "application/json", - wantBody: `{"code":404,"message":"ID 1 not found"}`, - }, - { - name: "Bad ID", - 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), - }, - ID: "BAD", - wantStatus: http.StatusUnprocessableEntity, - wantContentType: "application/json", - wantBody: `{"code":422,"message":"Error converting ID BAD"}`, - }, - { - name: "Bad name", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"password": "the_lake"}`)))), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - }, - ID: "BAD", - wantStatus: http.StatusUnprocessableEntity, - wantContentType: "application/json", - wantBody: `{"code":422,"message":"Username required"}`, - }, - { - name: "Bad JSON", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{password}`)))), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - }, - ID: "BAD", - wantStatus: http.StatusBadRequest, - wantContentType: "application/json", - wantBody: `{"code":400,"message":"Unparsable JSON"}`, - }, - } - for _, tt := range tests { - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - })) - - h := &server.Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, - } - - h.NewSourceUser(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. NewSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. NewSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. NewSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } - } -} - -func TestService_SourceUsers(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries server.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: "All users for data source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "GET", - "http://server.local/chronograf/v1/sources/1", - nil), - }, - 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 - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return nil, fmt.Errorf("No role store for this test") - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - AllF: func(ctx context.Context) ([]chronograf.User, error) { - return []chronograf.User{ - { - Name: "strickland", - Passwd: "discipline", - Permissions: chronograf.Permissions{ - { - Scope: chronograf.AllScope, - Allowed: chronograf.Allowances{"READ"}, - }, - }, - }, - }, nil - }, - } - }, - }, - }, - ID: "1", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"users":[{"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"links":{"self":"/chronograf/v1/sources/1/users/strickland"}}]} -`, - }, - } - for _, tt := range tests { - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - })) - h := &server.Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, - } - - h.SourceUsers(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. SourceUsers() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. SourceUsers() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. SourceUsers() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } - } -} - -func TestService_SourceUserID(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries server.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 - UID string - wantStatus int - wantContentType string - wantBody string - }{ - { - name: "Single user for data source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "GET", - "http://server.local/chronograf/v1/sources/1", - nil), - }, - 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 - }, - RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { - return nil, fmt.Errorf("No role store for this test") - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - GetF: func(ctx context.Context, uid string) (*chronograf.User, error) { - return &chronograf.User{ - Name: "strickland", - Passwd: "discipline", - Permissions: chronograf.Permissions{ - { - Scope: chronograf.AllScope, - Allowed: chronograf.Allowances{"READ"}, - }, - }, - }, nil - }, - } - }, - }, - }, - ID: "1", - UID: "strickland", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"links":{"self":"/chronograf/v1/sources/1/users/strickland"}} -`, - }, - } - for _, tt := range tests { - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - })) - h := &server.Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, - } - - h.SourceUserID(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. SourceUserID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. SourceUserID() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. SourceUserID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } - } -} - -func TestService_RemoveSourceUser(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries server.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 - UID string - wantStatus int - wantContentType string - wantBody string - }{ - { - name: "Delete user for data source", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "GET", - "http://server.local/chronograf/v1/sources/1", - nil), - }, - 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 - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - DeleteF: func(ctx context.Context, u *chronograf.User) error { - return nil - }, - } - }, - }, - }, - ID: "1", - UID: "strickland", - wantStatus: http.StatusNoContent, - }, - } - for _, tt := range tests { - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - })) - h := &server.Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, - } - h.RemoveSourceUser(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. RemoveSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. RemoveSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. RemoveSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } - } -} - -func TestService_UpdateSourceUser(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries server.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 - UID string - wantStatus int - wantContentType string - wantBody string - }{ - { - name: "Update user password 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 - }, - UsersF: func(ctx context.Context) chronograf.UsersStore { - return &mocks.UsersStore{ - UpdateF: func(ctx context.Context, u *chronograf.User) error { - return nil - }, - } - }, - }, - }, - ID: "1", - UID: "marty", - wantStatus: http.StatusOK, - wantContentType: "application/json", - wantBody: `{"name":"marty","links":{"self":"/chronograf/v1/sources/1/users/marty"}} -`, - }, - { - name: "Invalid update JSON", - args: args{ - w: httptest.NewRecorder(), - r: httptest.NewRequest( - "POST", - "http://server.local/chronograf/v1/sources/1", - ioutil.NopCloser( - bytes.NewReader([]byte(`{"name": "marty"}`)))), - }, - fields: fields{ - UseAuth: true, - Logger: log.New(log.DebugLevel), - }, - ID: "1", - UID: "marty", - wantStatus: http.StatusUnprocessableEntity, - wantContentType: "application/json", - wantBody: `{"code":422,"message":"No fields to update"}`, - }, - } - for _, tt := range tests { - tt.args.r = tt.args.r.WithContext(httprouter.WithParams( - context.Background(), - httprouter.Params{ - { - Key: "id", - Value: tt.ID, - }, - { - Key: "uid", - Value: tt.UID, - }, - })) - h := &server.Service{ - SourcesStore: tt.fields.SourcesStore, - TimeSeriesClient: tt.fields.TimeSeries, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, - } - h.UpdateSourceUser(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. UpdateSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) - } - if tt.wantContentType != "" && content != tt.wantContentType { - t.Errorf("%q. UpdateSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) - } - if tt.wantBody != "" && string(body) != tt.wantBody { - t.Errorf("%q. UpdateSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) - } - } -} - -func TestService_Permissions(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries server.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 := &server.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) - } - } -} - -func TestService_NewSourceRole(t *testing.T) { - type fields struct { - SourcesStore chronograf.SourcesStore - TimeSeries server.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") - }, - }, 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 - }, - }, nil - }, - }, - }, - ID: "1", - wantStatus: http.StatusCreated, - wantContentType: "application/json", - wantBody: `{"users":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/3-d"}}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} -`, - }, - } - for _, tt := range tests { - h := &server.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 server.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":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/3-d"}}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}} -`, - }, - } - for _, tt := range tests { - h := &server.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 server.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":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/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 := &server.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 server.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 := &server.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 server.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":[{"name":"match","links":{"self":"/chronograf/v1/sources/1/users/match"}},{"name":"skinhead","links":{"self":"/chronograf/v1/sources/1/users/skinhead"}},{"name":"3-d","links":{"self":"/chronograf/v1/sources/1/users/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 := &server.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) - } - } -} diff --git a/server/me.go b/server/me.go new file mode 100644 index 000000000..48d66ed6b --- /dev/null +++ b/server/me.go @@ -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) +} diff --git a/server/me_test.go b/server/me_test.go new file mode 100644 index 000000000..147bf8f3a --- /dev/null +++ b/server/me_test.go @@ -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) + } + } +} diff --git a/server/permissions.go b/server/permissions.go new file mode 100644 index 000000000..cd353d8b2 --- /dev/null +++ b/server/permissions.go @@ -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 +} diff --git a/server/permissions_test.go b/server/permissions_test.go new file mode 100644 index 000000000..092a89c45 --- /dev/null +++ b/server/permissions_test.go @@ -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) + } + } +} diff --git a/server/roles.go b/server/roles.go new file mode 100644 index 000000000..d738cbc42 --- /dev/null +++ b/server/roles.go @@ -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), + } +} diff --git a/server/roles_test.go b/server/roles_test.go new file mode 100644 index 000000000..7f6da27c2 --- /dev/null +++ b/server/roles_test.go @@ -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) + } + } +} diff --git a/server/server.go b/server/server.go index f9a12cd97..3a494b9f8 100644 --- a/server/server.go +++ b/server/server.go @@ -2,9 +2,11 @@ package server import ( "crypto/tls" + "log" "math/rand" "net" "net/http" + "os" "runtime" "strconv" "time" @@ -57,7 +59,7 @@ type Server struct { HerokuOrganizations []string `long:"heroku-organization" description:"Heroku Organization Memberships a user is required to have for access to Chronograf (comma separated)" env:"HEROKU_ORGS" env-delim:","` ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"` - LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"fatal" choice:"panic" default:"info" description:"Set the logging level" env:"LOG_LEVEL"` + LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"error" default:"info" description:"Set the logging level" env:"LOG_LEVEL"` Basepath string `short:"p" long:"basepath" description:"A URL path prefix under which all chronograf routes will be mounted" env:"BASE_PATH"` ShowVersion bool `short:"v" long:"version" description:"Show Chronograf version info"` BuildInfo BuildInfo @@ -73,14 +75,17 @@ func provide(p oauth2.Provider, m oauth2.Mux, ok func() bool) func(func(oauth2.P } } +// UseGithub validates the CLI parameters to enable github oauth support func (s *Server) UseGithub() bool { return s.TokenSecret != "" && s.GithubClientID != "" && s.GithubClientSecret != "" } +// UseGoogle validates the CLI parameters to enable google oauth support func (s *Server) UseGoogle() bool { return s.TokenSecret != "" && s.GoogleClientID != "" && s.GoogleClientSecret != "" && s.PublicURL != "" } +// UseHeroku validates the CLI parameters to enable heroku oauth support func (s *Server) UseHeroku() bool { return s.TokenSecret != "" && s.HerokuClientID != "" && s.HerokuSecret != "" } @@ -208,10 +213,21 @@ func (s *Server) Serve() error { } s.Listener = listener - httpServer := &graceful.Server{Server: new(http.Server)} + // Using a log writer for http server logging + w := logger.Writer() + defer w.Close() + stdLog := log.New(w, "", 0) + + // TODO: Remove graceful when changing to go 1.8 + httpServer := &graceful.Server{ + Server: &http.Server{ + ErrorLog: stdLog, + Handler: s.handler, + }, + Logger: stdLog, + TCPKeepAlive: 5 * time.Second, + } httpServer.SetKeepAlivesEnabled(true) - httpServer.TCPKeepAlive = 5 * time.Second - httpServer.Handler = s.handler if !s.ReportingDisabled { go reportUsageStats(s.BuildInfo, logger) @@ -244,7 +260,8 @@ func openService(boltPath, cannedPath string, logger chronograf.Logger, useAuth if err := db.Open(); err != nil { logger. WithField("component", "boltstore"). - Fatal("Unable to open boltdb; is there a chronograf already running? ", err) + Error("Unable to open boltdb; is there a chronograf already running? ", err) + os.Exit(1) } // These apps are those handled from a directory diff --git a/server/url_prefixer.go b/server/url_prefixer.go index d20563fc1..10387c08e 100644 --- a/server/url_prefixer.go +++ b/server/url_prefixer.go @@ -62,7 +62,8 @@ func (wrw *wrapResponseWriter) Header() http.Header { return *wrw.dupHeader } -const CHUNK_SIZE int = 512 +// ChunkSize is the number of bytes per chunked transfer-encoding +const ChunkSize int = 512 // ServeHTTP implements an http.Handler that prefixes relative URLs from the // Next handler with the configured prefix. It does this by examining the @@ -79,9 +80,9 @@ func (up *URLPrefixer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { // extract the flusher for flushing chunks flusher, ok := rw.(http.Flusher) if !ok { - up.Logger. - WithField("component", "prefixer"). - Fatal("Expected http.ResponseWriter to be an http.Flusher, but wasn't") + msg := "Expected http.ResponseWriter to be an http.Flusher, but wasn't" + Error(rw, http.StatusInternalServerError, msg, up.Logger) + return } nextRead, nextWrite := io.Pipe() @@ -109,7 +110,7 @@ func (up *URLPrefixer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { writtenCount++ buf.Write(src.Bytes()) - if writtenCount >= CHUNK_SIZE { + if writtenCount >= ChunkSize { flusher.Flush() writtenCount = 0 } diff --git a/server/users.go b/server/users.go index bbf4f61db..cacf5da01 100644 --- a/server/users.go +++ b/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), + } +} diff --git a/server/users_test.go b/server/users_test.go index 147bf8f3a..c46106d66 100644 --- a/server/users_test.go +++ b/server/users_test.go @@ -1,6 +1,7 @@ -package server +package server_test import ( + "bytes" "context" "fmt" "io/ioutil" @@ -8,19 +9,19 @@ import ( "net/http/httptest" "testing" + "github.com/bouk/httprouter" "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/log" "github.com/influxdata/chronograf/mocks" - "github.com/influxdata/chronograf/oauth2" + "github.com/influxdata/chronograf/server" ) -type MockUsers struct{} - -func TestService_Me(t *testing.T) { +func TestService_NewSourceUser(t *testing.T) { type fields struct { - UsersStore chronograf.UsersStore - Logger chronograf.Logger - UseAuth bool + SourcesStore chronograf.SourcesStore + TimeSeries server.TimeSeriesClient + Logger chronograf.Logger + UseAuth bool } type args struct { w *httptest.ResponseRecorder @@ -30,139 +31,900 @@ func TestService_Me(t *testing.T) { name string fields fields args args - principal oauth2.Principal + ID string wantStatus int wantContentType string wantBody string }{ { - name: "Existing user", + name: "New user for data source", args: args{ w: httptest.NewRecorder(), - r: httptest.NewRequest("GET", "http://example.com/foo", nil), + 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, - UsersStore: &mocks.UsersStore{ - GetF: func(ctx context.Context, name string) (*chronograf.User, error) { - return &chronograf.User{ - Name: "me", - Passwd: "hunter2", + 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 }, }, - }, - 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") + TimeSeries: &mocks.TimeSeries{ + ConnectF: func(ctx context.Context, src *chronograf.Source) error { + return nil }, - AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { - return u, nil + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + } + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, fmt.Errorf("no roles") }, }, }, - principal: oauth2.Principal{ - Subject: "secret", - }, - wantStatus: http.StatusOK, + ID: "1", + wantStatus: http.StatusCreated, wantContentType: "application/json", - wantBody: `{"name":"secret","password":"","links":{"self":"/chronograf/v1/users/secret"}} + wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[]} +`, + }, + { + name: "New user for data source with roles", + 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 + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + } + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, nil + }, + }, + }, + ID: "1", + wantStatus: http.StatusCreated, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[],"roles":[]} `, }, { name: "Error adding user", args: args{ w: httptest.NewRecorder(), - r: httptest.NewRequest("GET", "http://example.com/foo", nil), + 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, - 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), + 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 + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return nil, fmt.Errorf("Weight Has Nothing to Do With It") + }, + } }, }, - Logger: log.New(log.DebugLevel), }, - principal: oauth2.Principal{ - Subject: "secret", - }, - wantStatus: http.StatusInternalServerError, + ID: "1", + wantStatus: http.StatusBadRequest, wantContentType: "application/json", - wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`, + wantBody: `{"code":400,"message":"Weight Has Nothing to Do With It"}`, }, { - name: "No Auth", + name: "Failure connecting to user store", args: args{ w: httptest.NewRecorder(), - r: httptest.NewRequest("GET", "http://example.com/foo", nil), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))), }, fields: fields{ - UseAuth: false, + 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 fmt.Errorf("Biff just happens to be my supervisor") + }, + }, }, - wantStatus: http.StatusOK, + ID: "1", + wantStatus: http.StatusBadRequest, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/users/me"}} -`, + wantBody: `{"code":400,"message":"Unable to connect to source 1: Biff just happens to be my supervisor"}`, }, { - name: "Empty Principal", + name: "Failure getting source", args: args{ w: httptest.NewRecorder(), - r: httptest.NewRequest("GET", "http://example.com/foo", nil), + 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{}, fmt.Errorf("No McFly ever amounted to anything in the history of Hill Valley") + }, + }, + }, + ID: "1", + wantStatus: http.StatusNotFound, + wantContentType: "application/json", + wantBody: `{"code":404,"message":"ID 1 not found"}`, + }, + { + name: "Bad ID", + 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), }, - wantStatus: http.StatusUnprocessableEntity, - principal: oauth2.Principal{ - Subject: "", + ID: "BAD", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"Error converting ID BAD"}`, + }, + { + name: "Bad name", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"password": "the_lake"}`)))), }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + ID: "BAD", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"Username required"}`, + }, + { + name: "Bad JSON", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{password}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + ID: "BAD", + wantStatus: http.StatusBadRequest, + wantContentType: "application/json", + wantBody: `{"code":400,"message":"Unparsable JSON"}`, }, } 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, + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, } - h.Me(tt.args.w, tt.args.r) + h.NewSourceUser(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) + t.Errorf("%q. NewSourceUser() = %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) + t.Errorf("%q. NewSourceUser() = %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) + t.Errorf("%q. NewSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_SourceUsers(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.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: "All users for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1", + nil), + }, + 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 + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, fmt.Errorf("no roles") + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AllF: func(ctx context.Context) ([]chronograf.User, error) { + return []chronograf.User{ + { + Name: "strickland", + Passwd: "discipline", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"READ"}, + }, + }, + }, + }, nil + }, + } + }, + }, + }, + ID: "1", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}]}]} +`, + }, + { + name: "All users for data source with roles", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1", + nil), + }, + 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 + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + AllF: func(ctx context.Context) ([]chronograf.User, error) { + return []chronograf.User{ + { + Name: "strickland", + Passwd: "discipline", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"READ"}, + }, + }, + }, + }, nil + }, + } + }, + }, + }, + ID: "1", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"roles":[]}]} +`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + + h.SourceUsers(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. SourceUsers() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. SourceUsers() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. SourceUsers() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_SourceUserID(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.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 + UID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Single user for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1", + nil), + }, + 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 + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, fmt.Errorf("no roles") + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + GetF: func(ctx context.Context, uid string) (*chronograf.User, error) { + return &chronograf.User{ + Name: "strickland", + Passwd: "discipline", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"READ"}, + }, + }, + }, nil + }, + } + }, + }, + }, + ID: "1", + UID: "strickland", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}]} +`, + }, + { + name: "Single user for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1", + nil), + }, + 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 + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + GetF: func(ctx context.Context, uid string) (*chronograf.User, error) { + return &chronograf.User{ + Name: "strickland", + Passwd: "discipline", + Permissions: chronograf.Permissions{ + { + Scope: chronograf.AllScope, + Allowed: chronograf.Allowances{"READ"}, + }, + }, + }, nil + }, + } + }, + }, + }, + ID: "1", + UID: "strickland", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"roles":[]} +`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + + h.SourceUserID(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. SourceUserID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. SourceUserID() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. SourceUserID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_RemoveSourceUser(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.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 + UID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Delete user for data source", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "GET", + "http://server.local/chronograf/v1/sources/1", + nil), + }, + 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 + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + DeleteF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + } + }, + }, + }, + ID: "1", + UID: "strickland", + wantStatus: http.StatusNoContent, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + })) + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + h.RemoveSourceUser(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. RemoveSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. RemoveSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. RemoveSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) + } + } +} + +func TestService_UpdateSourceUser(t *testing.T) { + type fields struct { + SourcesStore chronograf.SourcesStore + TimeSeries server.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 + UID string + wantStatus int + wantContentType string + wantBody string + }{ + { + name: "Update user password 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 + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, fmt.Errorf("no roles") + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, name string) (*chronograf.User, error) { + return &chronograf.User{ + Name: "marty", + }, nil + }, + } + }, + }, + }, + ID: "1", + UID: "marty", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[]} +`, + }, + { + name: "Update user password for data source with roles", + 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 + }, + RolesF: func(ctx context.Context) (chronograf.RolesStore, error) { + return nil, nil + }, + UsersF: func(ctx context.Context) chronograf.UsersStore { + return &mocks.UsersStore{ + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, name string) (*chronograf.User, error) { + return &chronograf.User{ + Name: "marty", + }, nil + }, + } + }, + }, + }, + ID: "1", + UID: "marty", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[],"roles":[]} +`, + }, + { + name: "Invalid update JSON", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://server.local/chronograf/v1/sources/1", + ioutil.NopCloser( + bytes.NewReader([]byte(`{"name": "marty"}`)))), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + }, + ID: "1", + UID: "marty", + wantStatus: http.StatusUnprocessableEntity, + wantContentType: "application/json", + wantBody: `{"code":422,"message":"No fields to update"}`, + }, + } + for _, tt := range tests { + tt.args.r = tt.args.r.WithContext(httprouter.WithParams( + context.Background(), + httprouter.Params{ + { + Key: "id", + Value: tt.ID, + }, + { + Key: "uid", + Value: tt.UID, + }, + })) + h := &server.Service{ + SourcesStore: tt.fields.SourcesStore, + TimeSeriesClient: tt.fields.TimeSeries, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, + } + h.UpdateSourceUser(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. UpdateSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus) + } + if tt.wantContentType != "" && content != tt.wantContentType { + t.Errorf("%q. UpdateSourceUser() = %v, want %v", tt.name, content, tt.wantContentType) + } + if tt.wantBody != "" && string(body) != tt.wantBody { + t.Errorf("%q. UpdateSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody) } } } diff --git a/server/version.go b/server/version.go index 6bf7dbe9d..e7fc4c901 100644 --- a/server/version.go +++ b/server/version.go @@ -4,6 +4,7 @@ import ( "net/http" ) +// Version handler adds X-Chronograf-Version header to responses func Version(version string, h http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { w.Header().Add("X-Chronograf-Version", version) diff --git a/ui/.eslintrc b/ui/.eslintrc index 7e30b492e..564d38c56 100644 --- a/ui/.eslintrc +++ b/ui/.eslintrc @@ -230,7 +230,7 @@ 'react/no-string-refs': 0, // TODO: 2 'react/no-unknown-property': 2, 'react/prop-types': 2, - 'react/prefer-es6-class': 0, + 'react/prefer-es6-class': [0, 'never'], 'react/react-in-jsx-scope': 2, 'react/require-extension': 0, 'react/self-closing-comp': 0, // TODO: we can re-enable this if some brave soul wants to update the code (mostly spans acting as icons) diff --git a/ui/spec/shared/reducers/appSpec.js b/ui/spec/shared/reducers/appSpec.js new file mode 100644 index 000000000..e43ee650a --- /dev/null +++ b/ui/spec/shared/reducers/appSpec.js @@ -0,0 +1,40 @@ +import appReducer from 'src/shared/reducers/app' +import { + enablePresentationMode, + disablePresentationMode, + // delayEnablePresentationMode, + setAutoRefresh, +} from 'src/shared/actions/app' + +describe('Shared.Reducers.appReducer', () => { + const initialState = { + ephemeral: { + inPresentationMode: false, + }, + persisted: { + autoRefresh: 0 + }, + } + + it('should handle ENABLE_PRESENTATION_MODE', () => { + const reducedState = appReducer(initialState, enablePresentationMode()); + + expect(reducedState.ephemeral.inPresentationMode).to.equal(true); + }) + + it('should handle DISABLE_PRESENTATION_MODE', () => { + Object.assign(initialState, {ephemeral: {inPresentationMode: true}}) + + const reducedState = appReducer(initialState, disablePresentationMode()); + + expect(reducedState.ephemeral.inPresentationMode).to.equal(false); + }) + + it('should handle SET_AUTOREFRESH', () => { + const expectedMs = 15000 + + const reducedState = appReducer(initialState, setAutoRefresh(expectedMs)); + + expect(reducedState.persisted.autoRefresh).to.equal(expectedMs); + }) +}) diff --git a/ui/src/admin/containers/AdminPage.js b/ui/src/admin/containers/AdminPage.js index 37ff0688f..3ec74b635 100644 --- a/ui/src/admin/containers/AdminPage.js +++ b/ui/src/admin/containers/AdminPage.js @@ -158,7 +158,7 @@ class AdminPage extends Component {
{ - users.length ? + users ?
+
+ +
diff --git a/ui/src/dashboards/components/Dashboard.js b/ui/src/dashboards/components/Dashboard.js index 46eb173b0..2f03f4079 100644 --- a/ui/src/dashboards/components/Dashboard.js +++ b/ui/src/dashboards/components/Dashboard.js @@ -10,6 +10,7 @@ const Dashboard = ({ inPresentationMode, onPositionChange, source, + autoRefresh, timeRange, }) => { if (dashboard.id === 0) { @@ -20,20 +21,19 @@ const Dashboard = ({
{isEditMode ? : null} - {Dashboard.renderDashboard(dashboard, timeRange, source, onPositionChange)} + {Dashboard.renderDashboard(dashboard, autoRefresh, timeRange, source, onPositionChange)}
) } -Dashboard.renderDashboard = (dashboard, timeRange, source, onPositionChange) => { - const autoRefreshMs = 15000 +Dashboard.renderDashboard = (dashboard, autoRefresh, timeRange, source, onPositionChange) => { const cells = dashboard.cells.map((cell, i) => { i = `${i}` const dashboardCell = {...cell, i} dashboardCell.queries.forEach((q) => { q.text = q.query; - q.database = source.telegraf; + q.database = q.db; }); return dashboardCell; }) @@ -42,7 +42,7 @@ Dashboard.renderDashboard = (dashboard, timeRange, source, onPositionChange) => @@ -54,6 +54,7 @@ const { func, shape, string, + number, } = PropTypes Dashboard.propTypes = { @@ -66,6 +67,7 @@ Dashboard.propTypes = { proxy: string, }).isRequired, }).isRequired, + autoRefresh: number.isRequired, timeRange: shape({}).isRequired, } diff --git a/ui/src/dashboards/components/DashboardHeader.js b/ui/src/dashboards/components/DashboardHeader.js index be6fd69a0..3c9c657d2 100644 --- a/ui/src/dashboards/components/DashboardHeader.js +++ b/ui/src/dashboards/components/DashboardHeader.js @@ -2,7 +2,9 @@ import React, {PropTypes} from 'react' import ReactTooltip from 'react-tooltip' import {Link} from 'react-router'; +import AutoRefreshDropdown from 'shared/components/AutoRefreshDropdown' import TimeRangeDropdown from 'shared/components/TimeRangeDropdown' +import SourceIndicator from '../../shared/components/SourceIndicator' const DashboardHeader = ({ children, @@ -10,10 +12,13 @@ const DashboardHeader = ({ dashboard, headerText, timeRange, + autoRefresh, isHidden, handleChooseTimeRange, + handleChooseAutoRefresh, handleClickPresentationButton, sourceID, + source, }) => isHidden ? null : (
@@ -45,6 +50,8 @@ const DashboardHeader = ({ Graph Tips
+ +
@@ -55,11 +62,12 @@ const DashboardHeader = ({ ) const { - shape, array, - string, - func, bool, + func, + number, + shape, + string, } = PropTypes DashboardHeader.propTypes = { @@ -69,9 +77,12 @@ DashboardHeader.propTypes = { dashboard: shape({}), headerText: string, timeRange: shape({}).isRequired, + autoRefresh: number.isRequired, isHidden: bool.isRequired, handleChooseTimeRange: func.isRequired, + handleChooseAutoRefresh: func.isRequired, handleClickPresentationButton: func.isRequired, + source: shape({}), } export default DashboardHeader diff --git a/ui/src/dashboards/containers/DashboardPage.js b/ui/src/dashboards/containers/DashboardPage.js index c83c92020..38aa33c5b 100644 --- a/ui/src/dashboards/containers/DashboardPage.js +++ b/ui/src/dashboards/containers/DashboardPage.js @@ -51,6 +51,7 @@ const DashboardPage = React.createClass({ id: number.isRequired, cells: arrayOf(shape({})).isRequired, }).isRequired, + autoRefresh: number.isRequired, timeRange: shape({}).isRequired, inPresentationMode: bool.isRequired, isEditMode: bool.isRequired, @@ -100,6 +101,7 @@ const DashboardPage = React.createClass({ isEditMode, handleClickPresentationButton, source, + autoRefresh, timeRange, } = this.props @@ -110,12 +112,14 @@ const DashboardPage = React.createClass({ {}} /> :
{(dashboards).map((d, i) => { return ( @@ -133,6 +137,7 @@ const DashboardPage = React.createClass({ isEditMode={isEditMode} inPresentationMode={inPresentationMode} source={source} + autoRefresh={autoRefresh} timeRange={timeRange} onPositionChange={this.handleUpdatePosition} /> @@ -143,7 +148,10 @@ const DashboardPage = React.createClass({ const mapStateToProps = (state) => { const { - appUI, + app: { + ephemeral: {inPresentationMode}, + persisted: {autoRefresh}, + }, dashboardUI: { dashboards, dashboard, @@ -153,11 +161,12 @@ const mapStateToProps = (state) => { } = state return { - inPresentationMode: appUI.presentationMode, dashboards, dashboard, + autoRefresh, timeRange, isEditMode, + inPresentationMode, } } diff --git a/ui/src/dashboards/containers/DashboardsPage.js b/ui/src/dashboards/containers/DashboardsPage.js index 6ffc66d09..cedbcae9d 100644 --- a/ui/src/dashboards/containers/DashboardsPage.js +++ b/ui/src/dashboards/containers/DashboardsPage.js @@ -1,5 +1,6 @@ import React, {PropTypes} from 'react'; import {Link} from 'react-router'; +import SourceIndicator from '../../shared/components/SourceIndicator'; import {getDashboards} from '../apis'; @@ -53,6 +54,9 @@ const DashboardsPage = React.createClass({ Dashboards
+
+ +
diff --git a/ui/src/data_explorer/components/Visualization.js b/ui/src/data_explorer/components/Visualization.js index b88972a9e..b58355da6 100644 --- a/ui/src/data_explorer/components/Visualization.js +++ b/ui/src/data_explorer/components/Visualization.js @@ -15,6 +15,7 @@ const { const Visualization = React.createClass({ propTypes: { + autoRefresh: number.isRequired, timeRange: shape({ upper: string, lower: string, @@ -45,7 +46,7 @@ const Visualization = React.createClass({ }, render() { - const {queryConfigs, timeRange, activeQueryIndex, height, heightPixels} = this.props; + const {queryConfigs, autoRefresh, timeRange, activeQueryIndex, height, heightPixels} = this.props; const {source} = this.context; const proxyLink = source.links.proxy; @@ -57,7 +58,6 @@ const Visualization = React.createClass({ const queries = statements.filter((s) => s.text !== null).map((s) => { return {host: [proxyLink], text: s.text, id: s.id}; }); - const autoRefreshMs = 10000; const isInDataExplorer = true; return ( @@ -77,7 +77,7 @@ const Visualization = React.createClass({ {isGraphInView ? ( diff --git a/ui/src/data_explorer/containers/DataExplorer.js b/ui/src/data_explorer/containers/DataExplorer.js index 68a7c7b2e..9468fca75 100644 --- a/ui/src/data_explorer/containers/DataExplorer.js +++ b/ui/src/data_explorer/containers/DataExplorer.js @@ -1,17 +1,18 @@ import React, {PropTypes} from 'react'; import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; import QueryBuilder from '../components/QueryBuilder'; import Visualization from '../components/Visualization'; import Header from '../containers/Header'; import ResizeContainer from 'src/shared/components/ResizeContainer'; -import { - setTimeRange as setTimeRangeAction, -} from '../actions/view'; +import {setAutoRefresh} from 'shared/actions/app' +import {setTimeRange as setTimeRangeAction} from '../actions/view'; const { arrayOf, func, + number, shape, string, } = PropTypes; @@ -25,6 +26,8 @@ const DataExplorer = React.createClass({ }).isRequired, }).isRequired, queryConfigs: PropTypes.shape({}), + autoRefresh: number.isRequired, + handleChooseAutoRefresh: func.isRequired, timeRange: shape({ upper: string, lower: string, @@ -59,18 +62,20 @@ const DataExplorer = React.createClass({ }, render() { - const {timeRange, setTimeRange, queryConfigs, dataExplorer} = this.props; + const {autoRefresh, handleChooseAutoRefresh, timeRange, setTimeRange, queryConfigs, dataExplorer} = this.props; const {activeQueryID} = this.state; const queries = dataExplorer.queryIDs.map((qid) => queryConfigs[qid]); return (
@@ -45,11 +58,8 @@ const Header = React.createClass({

Explorer

-

Source:

-
- - {this.context.source.name} -
+ +
diff --git a/ui/src/data_explorer/reducers/index.js b/ui/src/data_explorer/reducers/index.js index cccca5d79..6db959b19 100644 --- a/ui/src/data_explorer/reducers/index.js +++ b/ui/src/data_explorer/reducers/index.js @@ -2,8 +2,8 @@ import queryConfigs from './queryConfigs'; import timeRange from './timeRange'; import dataExplorer from './ui'; -export { +export default { queryConfigs, timeRange, dataExplorer, -}; +} diff --git a/ui/src/hosts/components/HostsTable.js b/ui/src/hosts/components/HostsTable.js index e4a98f4d2..0801871d0 100644 --- a/ui/src/hosts/components/HostsTable.js +++ b/ui/src/hosts/components/HostsTable.js @@ -87,12 +87,12 @@ const HostsTable = React.createClass({ const hostCount = sortedHosts.length; let hostsTitle; - if (hostCount === 1) { - hostsTitle = `${hostCount} Host`; - } else if (hostCount > 1) { - hostsTitle = `${hostCount} Hosts`; - } else { + if (hosts.length === 0) { hostsTitle = `Loading Hosts...`; + } else if (hostCount === 1) { + hostsTitle = `${hostCount} Host`; + } else { + hostsTitle = `${hostCount} Hosts`; } return ( diff --git a/ui/src/hosts/containers/HostPage.js b/ui/src/hosts/containers/HostPage.js index 38ff3ae16..fbed5e336 100644 --- a/ui/src/hosts/containers/HostPage.js +++ b/ui/src/hosts/containers/HostPage.js @@ -1,6 +1,7 @@ import React, {PropTypes} from 'react' import {Link} from 'react-router' import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' import _ from 'lodash' import classnames from 'classnames'; @@ -9,6 +10,8 @@ import DashboardHeader from 'src/dashboards/components/DashboardHeader'; import timeRanges from 'hson!../../shared/data/timeRanges.hson'; import {getMappings, getAppsForHosts, getMeasurementsForHost, getAllHosts} from 'src/hosts/apis'; import {fetchLayouts} from 'shared/apis'; + +import {setAutoRefresh} from 'shared/actions/app' import {presentationButtonDispatcher} from 'shared/dispatchers' const { @@ -16,6 +19,7 @@ const { string, bool, func, + number, } = PropTypes export const HostPage = React.createClass({ @@ -35,6 +39,8 @@ export const HostPage = React.createClass({ app: string, }), }), + autoRefresh: number.isRequired, + handleChooseAutoRefresh: func.isRequired, inPresentationMode: bool, handleClickPresentationButton: func, }, @@ -87,9 +93,8 @@ export const HostPage = React.createClass({ }, renderLayouts(layouts) { - const autoRefreshMs = 15000; const {timeRange} = this.state; - const {source} = this.props; + const {source, autoRefresh} = this.props; const autoflowLayouts = layouts.filter((layout) => !!layout.autoflow); @@ -137,7 +142,7 @@ export const HostPage = React.createClass({ @@ -145,7 +150,16 @@ export const HostPage = React.createClass({ }, render() { - const {params: {hostID}, location: {query: {app}}, source: {id}, inPresentationMode, handleClickPresentationButton} = this.props + const { + params: {hostID}, + location: {query: {app}}, + source: {id}, + autoRefresh, + handleChooseAutoRefresh, + inPresentationMode, + handleClickPresentationButton, + source, + } = this.props const {layouts, timeRange, hosts} = this.state const appParam = app ? `?app=${app}` : '' @@ -153,10 +167,13 @@ export const HostPage = React.createClass({
{Object.keys(hosts).map((host, i) => { return ( @@ -181,11 +198,13 @@ export const HostPage = React.createClass({ }, }); -const mapStateToProps = (state) => ({ - inPresentationMode: state.appUI.presentationMode, +const mapStateToProps = ({app: {ephemeral: {inPresentationMode}, persisted: {autoRefresh}}}) => ({ + inPresentationMode, + autoRefresh, }) const mapDispatchToProps = (dispatch) => ({ + handleChooseAutoRefresh: bindActionCreators(setAutoRefresh, dispatch), handleClickPresentationButton: presentationButtonDispatcher(dispatch), }) diff --git a/ui/src/hosts/containers/HostsPage.js b/ui/src/hosts/containers/HostsPage.js index dd3a7b846..127d0d582 100644 --- a/ui/src/hosts/containers/HostsPage.js +++ b/ui/src/hosts/containers/HostsPage.js @@ -1,6 +1,7 @@ import React, {PropTypes} from 'react'; import _ from 'lodash'; import HostsTable from '../components/HostsTable'; +import SourceIndicator from '../../shared/components/SourceIndicator' import {getCpuAndLoadForHosts, getMappings, getAppsForHosts} from '../apis'; export const HostsPage = React.createClass({ @@ -44,6 +45,7 @@ export const HostsPage = React.createClass({ }, render() { + const {source} = this.props; return (
@@ -53,13 +55,16 @@ export const HostsPage = React.createClass({ Host List
+
+ +
- +
diff --git a/ui/src/index.js b/ui/src/index.js index db8cc6663..d8eb4fde5 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -20,7 +20,7 @@ import configureStore from 'src/store/configureStore'; import {getMe, getSources} from 'shared/apis'; import {receiveMe} from 'shared/actions/me'; import {receiveAuth} from 'shared/actions/auth'; -import {disablePresentationMode} from 'shared/actions/ui'; +import {disablePresentationMode} from 'shared/actions/app'; import {loadLocalStorage} from './localStorage'; import 'src/style/chronograf.scss'; diff --git a/ui/src/kapacitor/actions/view/index.js b/ui/src/kapacitor/actions/view/index.js index 2c89fe293..3d9f9e6d9 100644 --- a/ui/src/kapacitor/actions/view/index.js +++ b/ui/src/kapacitor/actions/view/index.js @@ -163,12 +163,11 @@ export function deleteRule(rule) { }; } -export function updateRuleStatus(rule, {status}) { +export function updateRuleStatus(rule, status) { return (dispatch) => { updateRuleStatusAPI(rule, status).then(() => { dispatch(publishNotification('success', `${rule.name} ${status} successfully`)); }).catch(() => { - dispatch(updateRuleStatusSuccess(rule.id, status)); dispatch(publishNotification('error', `${rule.name} could not be ${status}`)); }); }; diff --git a/ui/src/kapacitor/components/AlertOutputs.js b/ui/src/kapacitor/components/AlertOutputs.js index 303fc1c6b..04b154f5e 100644 --- a/ui/src/kapacitor/components/AlertOutputs.js +++ b/ui/src/kapacitor/components/AlertOutputs.js @@ -105,7 +105,7 @@ const AlertOutputs = React.createClass({ return (
-

Configure Alert Endpoints

+

Configure Alert Endpoints


diff --git a/ui/src/kapacitor/components/AlertaConfig.js b/ui/src/kapacitor/components/AlertaConfig.js index edc394f18..cb0f9ff62 100644 --- a/ui/src/kapacitor/components/AlertaConfig.js +++ b/ui/src/kapacitor/components/AlertaConfig.js @@ -31,10 +31,10 @@ const AlertaConfig = React.createClass({ return (
-

Alerta Alert

+

Alerta Alert


-

+

Have alerts sent to Alerta

diff --git a/ui/src/kapacitor/components/HipChatConfig.js b/ui/src/kapacitor/components/HipChatConfig.js index f53596f0d..d6878c093 100644 --- a/ui/src/kapacitor/components/HipChatConfig.js +++ b/ui/src/kapacitor/components/HipChatConfig.js @@ -30,9 +30,9 @@ const HipchatConfig = React.createClass({ return (
-

HipChat Alert

+

HipChat Alert


-

Have alerts sent to HipChat.

+

Have alerts sent to HipChat.

diff --git a/ui/src/kapacitor/components/KapacitorForm.js b/ui/src/kapacitor/components/KapacitorForm.js index f99e7a920..345fa6d32 100644 --- a/ui/src/kapacitor/components/KapacitorForm.js +++ b/ui/src/kapacitor/components/KapacitorForm.js @@ -45,13 +45,13 @@ const KapacitorForm = React.createClass({
-

+

Kapacitor is used as the monitoring and alerting agent. This page will let you configure which Kapacitor to use and set up alert end points like email, Slack, and others.


-

Connect Kapacitor to Source

+

Connect Kapacitor to Source

{source.url}


diff --git a/ui/src/kapacitor/components/KapacitorRule.js b/ui/src/kapacitor/components/KapacitorRule.js index dd1b03833..963dcbbbd 100644 --- a/ui/src/kapacitor/components/KapacitorRule.js +++ b/ui/src/kapacitor/components/KapacitorRule.js @@ -47,6 +47,7 @@ export const KapacitorRule = React.createClass({ onChooseTimeRange={this.handleChooseTimeRange} validationError={this.validationError()} timeRange={timeRange} + source={source} />
diff --git a/ui/src/kapacitor/components/KapacitorRules.js b/ui/src/kapacitor/components/KapacitorRules.js new file mode 100644 index 000000000..0ece0aa64 --- /dev/null +++ b/ui/src/kapacitor/components/KapacitorRules.js @@ -0,0 +1,96 @@ +import React, {PropTypes} from 'react' +import {Link} from 'react-router' + +import NoKapacitorError from '../../shared/components/NoKapacitorError' +import SourceIndicator from '../../shared/components/SourceIndicator' +import KapacitorRulesTable from 'src/kapacitor/components/KapacitorRulesTable' + +const KapacitorRules = ({ + source, + rules, + hasKapacitor, + loading, + onDelete, + onChangeRuleStatus, +}) => { + if (!hasKapacitor) { + return ( + + + + ) + } + + if (loading) { + return ( + +

Loading...

+
+ ) + } + + return ( + +
+

Alert Rules

+ Create New Rule +
+ +
+ ) +} + +const PageContents = ({children, source}) => ( +
+
+
+
+

Kapacitor Rules

+
+
+ +
+
+
+
+
+
+
+
+ {children} +
+
+
+
+
+
+) + +const { + arrayOf, + bool, + func, + shape, + node, +} = PropTypes + +KapacitorRules.propTypes = { + source: shape(), + rules: arrayOf(shape()), + hasKapacitor: bool, + loading: bool, + onChangeRuleStatus: func, + onDelete: func, +} + +PageContents.propTypes = { + children: node, + source: shape(), +} + +export default KapacitorRules diff --git a/ui/src/kapacitor/components/KapacitorRulesTable.js b/ui/src/kapacitor/components/KapacitorRulesTable.js new file mode 100644 index 000000000..e861acbf9 --- /dev/null +++ b/ui/src/kapacitor/components/KapacitorRulesTable.js @@ -0,0 +1,74 @@ +import React, {PropTypes} from 'react'; +import {Link} from 'react-router'; + +const KapacitorRulesTable = ({source, rules, onDelete, onChangeRuleStatus}) => { + return ( +
+ + + + + + + + + + + + + { + rules.map((rule) => { + return + }) + } + +
NameTriggerMessageAlertsEnabled
+
+ ) +} + +const RuleRow = ({rule, source, onDelete, onChangeRuleStatus}) => { + return ( + + {rule.name} + {rule.trigger} + {rule.message} + {rule.alerts.join(', ')} + +
+ onChangeRuleStatus(rule)} + /> + +
+ + + + ) +} + +const { + arrayOf, + func, + shape, +} = PropTypes + +KapacitorRulesTable.propTypes = { + source: shape(), + rules: arrayOf(shape()), + onChangeRuleStatus: func, + onDelete: func, +} + +RuleRow.propTypes = { + rule: shape(), + source: shape(), + onChangeRuleStatus: func, + onDelete: func, +} + +export default KapacitorRulesTable diff --git a/ui/src/kapacitor/components/OpsGenieConfig.js b/ui/src/kapacitor/components/OpsGenieConfig.js index 1b3f38bf5..01cbef3a3 100644 --- a/ui/src/kapacitor/components/OpsGenieConfig.js +++ b/ui/src/kapacitor/components/OpsGenieConfig.js @@ -65,9 +65,9 @@ const OpsGenieConfig = React.createClass({ return (
-

OpsGenie Alert

+

OpsGenie Alert


-

Have alerts sent to OpsGenie.

+

Have alerts sent to OpsGenie.

diff --git a/ui/src/kapacitor/components/PagerDutyConfig.js b/ui/src/kapacitor/components/PagerDutyConfig.js index 3c5119fef..d29e8720a 100644 --- a/ui/src/kapacitor/components/PagerDutyConfig.js +++ b/ui/src/kapacitor/components/PagerDutyConfig.js @@ -29,9 +29,9 @@ const PagerDutyConfig = React.createClass({ return (
-

PagerDuty Alert

+

PagerDuty Alert


-

You can have alerts sent to PagerDuty by entering info below.

+

You can have alerts sent to PagerDuty by entering info below.

diff --git a/ui/src/kapacitor/components/RuleHeader.js b/ui/src/kapacitor/components/RuleHeader.js index bfddb764f..4ed6e62ee 100644 --- a/ui/src/kapacitor/components/RuleHeader.js +++ b/ui/src/kapacitor/components/RuleHeader.js @@ -1,9 +1,11 @@ import React, {PropTypes} from 'react'; import ReactTooltip from 'react-tooltip'; import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown'; +import SourceIndicator from '../../shared/components/SourceIndicator'; export const RuleHeader = React.createClass({ propTypes: { + source: PropTypes.shape({}).isRequired, onSave: PropTypes.func.isRequired, rule: PropTypes.shape({}).isRequired, actions: PropTypes.shape({ @@ -56,15 +58,16 @@ export const RuleHeader = React.createClass({ }, renderSave() { - const {validationError, onSave, timeRange, onChooseTimeRange} = this.props; + const {validationError, onSave, timeRange, onChooseTimeRange, source} = this.props; const saveButton = validationError ? - : ; return (
+ {saveButton} diff --git a/ui/src/kapacitor/components/SMTPConfig.js b/ui/src/kapacitor/components/SMTPConfig.js index f68a8b89b..581cd060b 100644 --- a/ui/src/kapacitor/components/SMTPConfig.js +++ b/ui/src/kapacitor/components/SMTPConfig.js @@ -33,9 +33,9 @@ const SMTPConfig = React.createClass({ return (
-

SMTP Alert

+

SMTP Alert


-

You can have alerts sent to an email address by setting up an SMTP endpoint.

+

You can have alerts sent to an email address by setting up an SMTP endpoint.

diff --git a/ui/src/kapacitor/components/SensuConfig.js b/ui/src/kapacitor/components/SensuConfig.js index c23743649..826ab347d 100644 --- a/ui/src/kapacitor/components/SensuConfig.js +++ b/ui/src/kapacitor/components/SensuConfig.js @@ -27,9 +27,9 @@ const SensuConfig = React.createClass({ return (
-

Sensu Alert

+

Sensu Alert


-

Have alerts sent to Sensu.

+

Have alerts sent to Sensu.

diff --git a/ui/src/kapacitor/components/SlackConfig.js b/ui/src/kapacitor/components/SlackConfig.js index 88e91e1ca..1526d0c58 100644 --- a/ui/src/kapacitor/components/SlackConfig.js +++ b/ui/src/kapacitor/components/SlackConfig.js @@ -48,9 +48,9 @@ const SlackConfig = React.createClass({ return (
-

Slack Alert

+

Slack Alert


-

Post alerts to a Slack channel.

+

Post alerts to a Slack channel.

diff --git a/ui/src/kapacitor/components/TalkConfig.js b/ui/src/kapacitor/components/TalkConfig.js index 595adfac6..93ce4a6ee 100644 --- a/ui/src/kapacitor/components/TalkConfig.js +++ b/ui/src/kapacitor/components/TalkConfig.js @@ -34,9 +34,9 @@ const TalkConfig = React.createClass({ return (
-

Talk Alert

+

Talk Alert


-

Have alerts sent to Talk.

+

Have alerts sent to Talk.

diff --git a/ui/src/kapacitor/components/TelegramConfig.js b/ui/src/kapacitor/components/TelegramConfig.js index 11e3147a1..30f45625b 100644 --- a/ui/src/kapacitor/components/TelegramConfig.js +++ b/ui/src/kapacitor/components/TelegramConfig.js @@ -48,9 +48,9 @@ const TelegramConfig = React.createClass({ return (
-

Telegram Alert

+

Telegram Alert


-

You can have alerts sent to Telegram by entering info below.

+

You can have alerts sent to Telegram by entering info below.

diff --git a/ui/src/kapacitor/components/VictorOpsConfig.js b/ui/src/kapacitor/components/VictorOpsConfig.js index 5dfd3201a..98a5ba9a1 100644 --- a/ui/src/kapacitor/components/VictorOpsConfig.js +++ b/ui/src/kapacitor/components/VictorOpsConfig.js @@ -32,9 +32,9 @@ const VictorOpsConfig = React.createClass({ return (
-

VictorOps Alert

+

VictorOps Alert


-

Have alerts sent to VictorOps.

+

Have alerts sent to VictorOps.

diff --git a/ui/src/kapacitor/containers/KapacitorRulesPage.js b/ui/src/kapacitor/containers/KapacitorRulesPage.js index 5ab8d1e75..e095fa00b 100644 --- a/ui/src/kapacitor/containers/KapacitorRulesPage.js +++ b/ui/src/kapacitor/containers/KapacitorRulesPage.js @@ -1,48 +1,21 @@ -import React, {PropTypes} from 'react'; -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import {Link} from 'react-router'; -import {getKapacitor} from 'src/shared/apis'; -import * as kapacitorActionCreators from '../actions/view'; -import NoKapacitorError from '../../shared/components/NoKapacitorError'; +import React, {PropTypes, Component} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' +import {getKapacitor} from 'src/shared/apis' +import * as kapacitorActionCreators from '../actions/view' +import KapacitorRules from 'src/kapacitor/components/KapacitorRules' -const { - arrayOf, - func, - shape, - string, -} = PropTypes; - -export const KapacitorRulesPage = React.createClass({ - propTypes: { - source: shape({ - id: string.isRequired, - links: shape({ - proxy: string.isRequired, - self: string.isRequired, - kapacitors: string.isRequired, - }), - }), - rules: arrayOf(shape({ - name: string.isRequired, - trigger: string.isRequired, - message: string.isRequired, - alerts: arrayOf(string.isRequired).isRequired, - })).isRequired, - actions: shape({ - fetchRules: func.isRequired, - deleteRule: func.isRequired, - updateRuleStatus: func.isRequired, - }).isRequired, - addFlashMessage: func, - }, - - getInitialState() { - return { +class KapacitorRulesPage extends Component { + constructor(props) { + super(props); + this.state = { hasKapacitor: false, loading: true, - }; - }, + } + + this.handleDeleteRule = ::this.handleDeleteRule + this.handleRuleStatus = ::this.handleRuleStatus + } componentDidMount() { getKapacitor(this.props.source).then((kapacitor) => { @@ -51,131 +24,78 @@ export const KapacitorRulesPage = React.createClass({ } this.setState({loading: false, hasKapacitor: !!kapacitor}); }); - }, + } handleDeleteRule(rule) { - const {actions} = this.props; - actions.deleteRule(rule); - }, + const {actions} = this.props + actions.deleteRule(rule) + } - handleRuleStatus(e, rule) { - const {actions} = this.props; - const status = e.target.checked ? 'enabled' : 'disabled'; + handleRuleStatus(rule) { + const {actions} = this.props + const status = rule.status === 'enabled' ? 'disabled' : 'enabled' - actions.updateRuleStatusSuccess(rule.id, status); - actions.updateRuleStatus(rule, {status}); - }, - - renderSubComponent() { - const {source} = this.props; - const {hasKapacitor, loading} = this.state; - - let component; - if (loading) { - component = (

Loading...

); - } else if (hasKapacitor) { - component = ( -
-
-

Alert Rules

- Create New Rule -
-
- - - - - - - - - - - - - {this.renderAlertsTableRows()} - -
NameTriggerMessageAlertsEnabled
-
-
- ); - } else { - component = ; - } - return component; - }, + actions.updateRuleStatus(rule, status) + actions.updateRuleStatusSuccess(rule.id, status) + } render() { + const {source, rules} = this.props + const {hasKapacitor, loading} = this.state + return ( -
-
-
-
-

Kapacitor Rules

-
-
-
-
-
- {this.renderSubComponent()} -
-
-
- ); - }, + + ) + } +} - renderAlertsTableRows() { - const {rules, source} = this.props; - const numRules = rules.length; +const { + arrayOf, + func, + shape, + string, +} = PropTypes - if (numRules === 0) { - return ( - - -

You don't have any Kapacitor
Rules, why not create one?

- Create New Rule - - - ); - } +KapacitorRulesPage.propTypes = { + source: shape({ + id: string.isRequired, + links: shape({ + proxy: string.isRequired, + self: string.isRequired, + kapacitors: string.isRequired, + }), + }), + rules: arrayOf(shape({ + name: string.isRequired, + trigger: string.isRequired, + message: string.isRequired, + alerts: arrayOf(string.isRequired).isRequired, + })).isRequired, + actions: shape({ + fetchRules: func.isRequired, + deleteRule: func.isRequired, + updateRuleStatus: func.isRequired, + }).isRequired, + addFlashMessage: func, +} - return rules.map((rule) => { - return ( - - {rule.name} - {rule.trigger} - {rule.message} - {rule.alerts.join(', ')} - -
- this.enabled = r} - defaultChecked={rule.status === "enabled"} - onClick={(e) => this.handleRuleStatus(e, rule)} - /> - -
- - - - ); - }); - }, -}); - -function mapStateToProps(state) { +const mapStateToProps = (state) => { return { rules: Object.values(state.rules), - }; + } } -function mapDispatchToProps(dispatch) { +const mapDispatchToProps = (dispatch) => { return { actions: bindActionCreators(kapacitorActionCreators, dispatch), - }; + } } -export default connect(mapStateToProps, mapDispatchToProps)(KapacitorRulesPage); +export default connect(mapStateToProps, mapDispatchToProps)(KapacitorRulesPage) diff --git a/ui/src/kubernetes/components/KubernetesDashboard.js b/ui/src/kubernetes/components/KubernetesDashboard.js index 2c4afc5b4..f61650ee1 100644 --- a/ui/src/kubernetes/components/KubernetesDashboard.js +++ b/ui/src/kubernetes/components/KubernetesDashboard.js @@ -6,11 +6,12 @@ import DashboardHeader from 'src/dashboards/components/DashboardHeader'; import timeRanges from 'hson!../../shared/data/timeRanges.hson'; const { - shape, - string, arrayOf, bool, func, + number, + shape, + string, } = PropTypes export const KubernetesDashboard = React.createClass({ @@ -22,6 +23,8 @@ export const KubernetesDashboard = React.createClass({ telegraf: string.isRequired, }), layouts: arrayOf(shape().isRequired).isRequired, + autoRefresh: number.isRequired, + handleChooseAutoRefresh: func.isRequired, inPresentationMode: bool.isRequired, handleClickPresentationButton: func, }, @@ -34,9 +37,8 @@ export const KubernetesDashboard = React.createClass({ }, renderLayouts(layouts) { - const autoRefreshMs = 15000; const {timeRange} = this.state; - const {source} = this.props; + const {source, autoRefresh} = this.props; let layoutCells = []; layouts.forEach((layout) => { @@ -56,7 +58,7 @@ export const KubernetesDashboard = React.createClass({ ); @@ -68,7 +70,7 @@ export const KubernetesDashboard = React.createClass({ }, render() { - const {layouts, inPresentationMode, handleClickPresentationButton} = this.props; + const {layouts, autoRefresh, handleChooseAutoRefresh, inPresentationMode, handleClickPresentationButton, source} = this.props; const {timeRange} = this.state; const emptyState = (
@@ -81,10 +83,13 @@ export const KubernetesDashboard = React.createClass({
@@ -51,11 +59,13 @@ export const KubernetesPage = React.createClass({ }, }); -const mapStateToProps = (state) => ({ - inPresentationMode: state.appUI.presentationMode, +const mapStateToProps = ({app: {ephemeral: {inPresentationMode}, persisted: {autoRefresh}}}) => ({ + inPresentationMode, + autoRefresh, }) const mapDispatchToProps = (dispatch) => ({ + handleChooseAutoRefresh: bindActionCreators(setAutoRefresh, dispatch), handleClickPresentationButton: presentationButtonDispatcher(dispatch), }) diff --git a/ui/src/localStorage.js b/ui/src/localStorage.js index 5cd29aa07..4b8381503 100644 --- a/ui/src/localStorage.js +++ b/ui/src/localStorage.js @@ -9,9 +9,12 @@ export const loadLocalStorage = () => { } }; -export const saveToLocalStorage = ({queryConfigs, timeRange, dataExplorer}) => { +export const saveToLocalStorage = ({app: {persisted}, queryConfigs, timeRange, dataExplorer}) => { try { + const appPersisted = Object.assign({}, {app: {persisted}}) + window.localStorage.setItem('state', JSON.stringify({ + ...appPersisted, queryConfigs, timeRange, dataExplorer, diff --git a/ui/src/shared/actions/app.js b/ui/src/shared/actions/app.js new file mode 100644 index 000000000..87c78706a --- /dev/null +++ b/ui/src/shared/actions/app.js @@ -0,0 +1,22 @@ +import {PRESENTATION_MODE_ANIMATION_DELAY} from '../constants' + +// ephemeral state action creators +export const enablePresentationMode = () => ({ + type: 'ENABLE_PRESENTATION_MODE', +}) + +export const disablePresentationMode = () => ({ + type: 'DISABLE_PRESENTATION_MODE', +}) + +export const delayEnablePresentationMode = () => (dispatch) => { + setTimeout(() => dispatch(enablePresentationMode()), PRESENTATION_MODE_ANIMATION_DELAY) +} + +// persistent state action creators +export const setAutoRefresh = (milliseconds) => ({ + type: 'SET_AUTOREFRESH', + payload: { + milliseconds, + }, +}) diff --git a/ui/src/shared/actions/ui.js b/ui/src/shared/actions/ui.js deleted file mode 100644 index 740566beb..000000000 --- a/ui/src/shared/actions/ui.js +++ /dev/null @@ -1,19 +0,0 @@ -import {PRESENTATION_MODE_ANIMATION_DELAY} from '../constants' - -export function enablePresentationMode() { - return { - type: 'ENABLE_PRESENTATION_MODE', - } -} - -export function disablePresentationMode() { - return { - type: 'DISABLE_PRESENTATION_MODE', - } -} - -export function delayEnablePresentationMode() { - return (dispatch) => { - setTimeout(() => dispatch(enablePresentationMode()), PRESENTATION_MODE_ANIMATION_DELAY) - } -} diff --git a/ui/src/shared/components/AutoRefresh.js b/ui/src/shared/components/AutoRefresh.js index fd79cd620..9474b306e 100644 --- a/ui/src/shared/components/AutoRefresh.js +++ b/ui/src/shared/components/AutoRefresh.js @@ -6,25 +6,37 @@ function _fetchTimeSeries(source, db, rp, query) { return proxy({source, db, rp, query}); } +const { + element, + number, + arrayOf, + shape, + oneOfType, + string, +} = PropTypes + export default function AutoRefresh(ComposedComponent) { const wrapper = React.createClass({ displayName: `AutoRefresh_${ComposedComponent.displayName}`, propTypes: { - children: PropTypes.element, - autoRefresh: PropTypes.number, - queries: PropTypes.arrayOf(PropTypes.shape({ - host: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), - text: PropTypes.string, + children: element, + autoRefresh: number.isRequired, + queries: arrayOf(shape({ + host: oneOfType([string, arrayOf(string)]), + text: string, }).isRequired).isRequired, }, getInitialState() { - return {timeSeries: []}; + return { + lastQuerySuccessful: false, + timeSeries: [], + }; }, componentDidMount() { - const {queries} = this.props; + const {queries, autoRefresh} = this.props; this.executeQueries(queries); - if (this.props.autoRefresh) { - this.intervalID = setInterval(() => this.executeQueries(queries), this.props.autoRefresh); + if (autoRefresh) { + this.intervalID = setInterval(() => this.executeQueries(queries), autoRefresh); } }, componentWillReceiveProps(nextProps) { @@ -63,7 +75,9 @@ export default function AutoRefresh(ComposedComponent) { newSeries.push({response: resp.data}); count += 1; if (count === queries.length) { + const querySuccessful = !this._noResultsForQuery(newSeries); this.setState({ + lastQuerySuccessful: querySuccessful, isFetching: false, timeSeries: newSeries, }); @@ -77,11 +91,11 @@ export default function AutoRefresh(ComposedComponent) { render() { const {timeSeries} = this.state; - if (this.state.isFetching) { + if (this.state.isFetching && this.state.lastQuerySuccessful) { return this.renderFetching(timeSeries); } - if (this._noResultsForQuery(timeSeries)) { + if (this._noResultsForQuery(timeSeries) || !this.state.lastQuerySuccessful) { return this.renderNoResults(); } diff --git a/ui/src/shared/components/AutoRefreshDropdown.js b/ui/src/shared/components/AutoRefreshDropdown.js new file mode 100644 index 000000000..ba1e4a0a6 --- /dev/null +++ b/ui/src/shared/components/AutoRefreshDropdown.js @@ -0,0 +1,73 @@ +import React, {PropTypes} from 'react'; +import classnames from 'classnames'; +import OnClickOutside from 'shared/components/OnClickOutside'; + +import autoRefreshItems from 'hson!../data/autoRefreshes.hson'; + +const { + number, + func, +} = PropTypes + +const AutoRefreshDropdown = React.createClass({ + autobind: false, + + propTypes: { + selected: number.isRequired, + onChoose: func.isRequired, + }, + + getInitialState() { + return { + isOpen: false, + }; + }, + + findAutoRefreshItem(milliseconds) { + return autoRefreshItems.find((values) => values.milliseconds === milliseconds) + }, + + handleClickOutside() { + this.setState({isOpen: false}); + }, + + handleSelection(milliseconds) { + this.props.onChoose(milliseconds); + this.setState({isOpen: false}); + }, + + toggleMenu() { + this.setState({isOpen: !this.state.isOpen}); + }, + + render() { + const self = this; + const {selected} = self.props; + const {isOpen} = self.state; + const {milliseconds, inputValue} = this.findAutoRefreshItem(selected) + + return ( +
+
self.toggleMenu()}> + 0 ? "refresh" : "pause")}> + {inputValue} + +
+ +
+ ); + }, +}); + +export default OnClickOutside(AutoRefreshDropdown); diff --git a/ui/src/shared/components/Dropdown.js b/ui/src/shared/components/Dropdown.js index 178b842d5..2c749449a 100644 --- a/ui/src/shared/components/Dropdown.js +++ b/ui/src/shared/components/Dropdown.js @@ -1,14 +1,23 @@ import React, {PropTypes} from 'react'; +import classnames from 'classnames'; import OnClickOutside from 'shared/components/OnClickOutside'; +const { + arrayOf, + shape, + string, + func, +} = PropTypes + const Dropdown = React.createClass({ propTypes: { - items: PropTypes.arrayOf(PropTypes.shape({ - text: PropTypes.string.isRequired, + items: arrayOf(shape({ + text: string.isRequired, })).isRequired, - onChoose: PropTypes.func.isRequired, - selected: PropTypes.string.isRequired, - className: PropTypes.string, + onChoose: func.isRequired, + selected: string.isRequired, + iconName: string, + className: string, }, getInitialState() { return { @@ -39,11 +48,12 @@ const Dropdown = React.createClass({ }, render() { const self = this; - const {items, selected, className, actions} = self.props; + const {items, selected, className, iconName, actions} = self.props; return (
+ {iconName ? : null} {selected}
diff --git a/ui/src/shared/components/LayoutRenderer.js b/ui/src/shared/components/LayoutRenderer.js index e1c2df5e7..60eace146 100644 --- a/ui/src/shared/components/LayoutRenderer.js +++ b/ui/src/shared/components/LayoutRenderer.js @@ -18,6 +18,7 @@ const { export const LayoutRenderer = React.createClass({ propTypes: { + autoRefresh: number.isRequired, timeRange: shape({ defaultGroupBy: string.isRequired, queryValue: string.isRequired, @@ -46,7 +47,6 @@ export const LayoutRenderer = React.createClass({ name: string.isRequired, }).isRequired ), - autoRefreshMs: number.isRequired, host: string, source: string, onPositionChange: func, @@ -84,7 +84,7 @@ export const LayoutRenderer = React.createClass({ }, generateVisualizations() { - const {autoRefreshMs, source, cells} = this.props; + const {autoRefresh, source, cells} = this.props; return cells.map((cell) => { const qs = cell.queries.map((q) => { @@ -100,7 +100,7 @@ export const LayoutRenderer = React.createClass({

{cell.name || `Graph`}

- +
); @@ -117,7 +117,7 @@ export const LayoutRenderer = React.createClass({
diff --git a/ui/src/shared/components/NoKapacitorError.js b/ui/src/shared/components/NoKapacitorError.js index a82451dcc..fda9ce61f 100644 --- a/ui/src/shared/components/NoKapacitorError.js +++ b/ui/src/shared/components/NoKapacitorError.js @@ -1,5 +1,5 @@ -import React, {PropTypes} from 'react'; -import {Link} from 'react-router'; +import React, {PropTypes} from 'react' +import {Link} from 'react-router' const NoKapacitorError = React.createClass({ propTypes: { @@ -9,14 +9,14 @@ const NoKapacitorError = React.createClass({ }, render() { - const path = `/sources/${this.props.source.id}/kapacitor-config`; + const path = `/sources/${this.props.source.id}/kapacitor-config` return (

The current source does not have an associated Kapacitor instance, please configure one.

Add Kapacitor
- ); + ) }, -}); +}) -export default NoKapacitorError; +export default NoKapacitorError diff --git a/ui/src/shared/components/SourceIndicator.js b/ui/src/shared/components/SourceIndicator.js new file mode 100644 index 000000000..83bcbeeed --- /dev/null +++ b/ui/src/shared/components/SourceIndicator.js @@ -0,0 +1,22 @@ +import React, {PropTypes} from 'react'; + +const SourceIndicator = React.createClass({ + propTypes: { + sourceName: PropTypes.string, + }, + + render() { + const {sourceName} = this.props; + if (!sourceName) { + return null; + } + return ( +
+ + {sourceName} +
+ ); + }, +}); + +export default SourceIndicator; diff --git a/ui/src/shared/components/TimeRangeDropdown.js b/ui/src/shared/components/TimeRangeDropdown.js index dbdf5155c..076226896 100644 --- a/ui/src/shared/components/TimeRangeDropdown.js +++ b/ui/src/shared/components/TimeRangeDropdown.js @@ -1,5 +1,5 @@ import React from 'react'; -import cN from 'classnames'; +import classnames from 'classnames'; import OnClickOutside from 'shared/components/OnClickOutside'; import timeRanges from 'hson!../data/timeRanges.hson'; @@ -48,7 +48,7 @@ const TimeRangeDropdown = React.createClass({ {selected}
-
    +
    • Time Range
    • {timeRanges.map((item) => { return ( diff --git a/ui/src/shared/constants/index.js b/ui/src/shared/constants/index.js index dc68a2e29..d238e7bb7 100644 --- a/ui/src/shared/constants/index.js +++ b/ui/src/shared/constants/index.js @@ -472,3 +472,5 @@ 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 diff --git a/ui/src/shared/data/autoRefreshes.hson b/ui/src/shared/data/autoRefreshes.hson new file mode 100644 index 000000000..56453c0b8 --- /dev/null +++ b/ui/src/shared/data/autoRefreshes.hson @@ -0,0 +1,8 @@ +[ + {milliseconds: 0, inputValue: 'Paused', menuOption: 'Paused'}, + {milliseconds: 5000, inputValue: 'Every 5 seconds', menuOption: 'Every 5 seconds'}, + {milliseconds: 10000, inputValue: 'Every 10 seconds', menuOption: 'Every 10 seconds'}, + {milliseconds: 15000, inputValue: 'Every 15 seconds', menuOption: 'Every 15 seconds'}, + {milliseconds: 30000, inputValue: 'Every 30 seconds', menuOption: 'Every 30 seconds'}, + {milliseconds: 60000, inputValue: 'Every 60 seconds', menuOption: 'Every 60 seconds'} +] diff --git a/ui/src/shared/dispatchers/index.js b/ui/src/shared/dispatchers/index.js index fcf02767f..a7771a9ff 100644 --- a/ui/src/shared/dispatchers/index.js +++ b/ui/src/shared/dispatchers/index.js @@ -1,4 +1,4 @@ -import {delayEnablePresentationMode} from 'shared/actions/ui' +import {delayEnablePresentationMode} from 'shared/actions/app' import {publishNotification, delayDismissNotification} from 'shared/actions/notifications' import {PRESENTATION_MODE_NOTIFICATION_DELAY} from 'shared/constants' diff --git a/ui/src/shared/reducers/app.js b/ui/src/shared/reducers/app.js new file mode 100644 index 000000000..adcb4c242 --- /dev/null +++ b/ui/src/shared/reducers/app.js @@ -0,0 +1,57 @@ +import {combineReducers} from 'redux'; + +import {AUTOREFRESH_DEFAULT} from 'src/shared/constants' + +const initialState = { + ephemeral: { + inPresentationMode: false, + }, + persisted: { + autoRefresh: AUTOREFRESH_DEFAULT, + }, +} + +const { + ephemeral: initialEphemeralState, + persisted: initialPersistedState, +} = initialState + +const ephemeralReducer = (state = initialEphemeralState, action) => { + switch (action.type) { + case 'ENABLE_PRESENTATION_MODE': { + return { + ...state, + inPresentationMode: true, + } + } + + case 'DISABLE_PRESENTATION_MODE': { + return { + ...state, + inPresentationMode: false, + } + } + + default: + return state + } +} + +const persistedReducer = (state = initialPersistedState, action) => { + switch (action.type) { + case 'SET_AUTOREFRESH': { + return { + ...state, + autoRefresh: action.payload.milliseconds, + } + } + + default: + return state + } +} + +export default combineReducers({ + ephemeral: ephemeralReducer, + persisted: persistedReducer, +}) diff --git a/ui/src/shared/reducers/index.js b/ui/src/shared/reducers/index.js index 47ed0c62f..bf191ecd7 100644 --- a/ui/src/shared/reducers/index.js +++ b/ui/src/shared/reducers/index.js @@ -1,13 +1,13 @@ -import appUI from './ui'; import me from './me'; +import app from './app'; import auth from './auth'; import notifications from './notifications'; import sources from './sources'; -export { - appUI, +export default { me, + app, auth, notifications, sources, -}; +} diff --git a/ui/src/shared/reducers/ui.js b/ui/src/shared/reducers/ui.js deleted file mode 100644 index 77a2f77a8..000000000 --- a/ui/src/shared/reducers/ui.js +++ /dev/null @@ -1,23 +0,0 @@ -const initialState = { - presentationMode: false, -}; - -export default function ui(state = initialState, action) { - switch (action.type) { - case 'ENABLE_PRESENTATION_MODE': { - return { - ...state, - presentationMode: true, - } - } - - case 'DISABLE_PRESENTATION_MODE': { - return { - ...state, - presentationMode: false, - } - } - } - - return state -} diff --git a/ui/src/side_nav/containers/SideNavApp.js b/ui/src/side_nav/containers/SideNavApp.js index a93bffdf7..5c2541571 100644 --- a/ui/src/side_nav/containers/SideNavApp.js +++ b/ui/src/side_nav/containers/SideNavApp.js @@ -34,11 +34,9 @@ const SideNavApp = React.createClass({ }, }); -function mapStateToProps(state) { - return { - me: state.me, - inPresentationMode: state.appUI.presentationMode, - }; -} +const mapStateToProps = ({me, app: {ephemeral: {inPresentationMode}}}) => ({ + me, + inPresentationMode, +}) export default connect(mapStateToProps)(SideNavApp); diff --git a/ui/src/store/configureStore.js b/ui/src/store/configureStore.js index de2c75cbb..e4ef15996 100644 --- a/ui/src/store/configureStore.js +++ b/ui/src/store/configureStore.js @@ -3,9 +3,9 @@ import {combineReducers} from 'redux'; import thunkMiddleware from 'redux-thunk'; import makeQueryExecuter from 'src/shared/middleware/queryExecuter'; import resizeLayout from 'src/shared/middleware/resizeLayout'; -import * as dataExplorerReducers from 'src/data_explorer/reducers'; -import * as sharedReducers from 'src/shared/reducers'; 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'; import dashboardUI from 'src/dashboards/reducers/ui'; import persistStateEnhancer from './persistStateEnhancer'; diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index 83e08cd7c..e45e32ba4 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -37,6 +37,7 @@ @import 'components/search-widget'; @import 'components/tables'; @import 'components/resizer'; +@import 'components/source-indicator'; // Pages @import 'pages/alerts'; diff --git a/ui/src/style/components/dygraphs.scss b/ui/src/style/components/dygraphs.scss index b6653fff9..748755928 100644 --- a/ui/src/style/components/dygraphs.scss +++ b/ui/src/style/components/dygraphs.scss @@ -124,3 +124,28 @@ padding: 0 10px 0 1px !important; } } + + + +.single-stat { + position: absolute; + width: 100%; + height: 100%; + font-size: 60px; + font-weight: 300; + color: $c-pool; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + text-align: center; + font-size: 54px; + font-weight: 300; + color: $c-pool; + + &.graph-single-stat { + position: absolute; + width: 100%; + top: 0; + } +} diff --git a/ui/src/style/components/page-header-dropdown.scss b/ui/src/style/components/page-header-dropdown.scss index 065241d68..84ad4deb0 100644 --- a/ui/src/style/components/page-header-dropdown.scss +++ b/ui/src/style/components/page-header-dropdown.scss @@ -11,6 +11,7 @@ font-size: 17px; font-weight: 400; transition: color 0.25s ease; + @include no-user-select(); } .dropdown-toggle > .caret { right: 0; @@ -35,6 +36,7 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + @include no-user-select(); } } } \ No newline at end of file diff --git a/ui/src/style/components/source-indicator.scss b/ui/src/style/components/source-indicator.scss new file mode 100644 index 000000000..64e9bc410 --- /dev/null +++ b/ui/src/style/components/source-indicator.scss @@ -0,0 +1,27 @@ +/* + Source Indicator component styles + ---------------------------------------------------------------- +*/ +.source-indicator { + @include no-user-select(); + display: inline-block; + padding: 0 9px; + border: 0; + background-color: $g5-pepper; + height: 30px; + line-height: 30px; + color: $g13-mist; + font-weight: 700; + font-size: 12px; + border-radius: 3px; + margin-right: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + > .icon { + display: inline-block; + font-size: 16px; + margin: 0 4px 0 -2px; + } +} \ No newline at end of file diff --git a/ui/src/style/fonts/icomoon.eot b/ui/src/style/fonts/icomoon.eot index c525fa69a..689d779b4 100755 Binary files a/ui/src/style/fonts/icomoon.eot and b/ui/src/style/fonts/icomoon.eot differ diff --git a/ui/src/style/fonts/icomoon.svg b/ui/src/style/fonts/icomoon.svg index 1919cb91f..e2d9e06bf 100755 --- a/ui/src/style/fonts/icomoon.svg +++ b/ui/src/style/fonts/icomoon.svg @@ -83,6 +83,7 @@ + diff --git a/ui/src/style/fonts/icomoon.ttf b/ui/src/style/fonts/icomoon.ttf index 1cfe84f25..8ac3dfcb3 100755 Binary files a/ui/src/style/fonts/icomoon.ttf and b/ui/src/style/fonts/icomoon.ttf differ diff --git a/ui/src/style/fonts/icomoon.woff b/ui/src/style/fonts/icomoon.woff index 68c21c369..ba0308d9c 100755 Binary files a/ui/src/style/fonts/icomoon.woff and b/ui/src/style/fonts/icomoon.woff differ diff --git a/ui/src/style/fonts/icomoon.woff2 b/ui/src/style/fonts/icomoon.woff2 index 77a391b18..fae3be996 100755 Binary files a/ui/src/style/fonts/icomoon.woff2 and b/ui/src/style/fonts/icomoon.woff2 differ diff --git a/ui/src/style/layout/page-header.scss b/ui/src/style/layout/page-header.scss index 2efe54044..8f5977e64 100644 --- a/ui/src/style/layout/page-header.scss +++ b/ui/src/style/layout/page-header.scss @@ -31,6 +31,7 @@ margin: 0; display: inline-block; vertical-align: middle; + @include no-user-select(); } &__left, &__right { diff --git a/ui/src/style/layout/sidebar.scss b/ui/src/style/layout/sidebar.scss index a17712290..e93b9d85a 100644 --- a/ui/src/style/layout/sidebar.scss +++ b/ui/src/style/layout/sidebar.scss @@ -214,6 +214,7 @@ $sidebar-logo-color: $g8-storm; white-space: nowrap; margin: 0; display: block; + @include no-user-select(); } &__menu-item { diff --git a/ui/src/style/mixins/mixins.scss b/ui/src/style/mixins/mixins.scss index 9d03a2567..66f4332e7 100644 --- a/ui/src/style/mixins/mixins.scss +++ b/ui/src/style/mixins/mixins.scss @@ -1,70 +1,87 @@ // Gradients @mixin gradient-v($startColor, $endColor) { - background: $startColor; - background: -moz-linear-gradient(top, $startColor 0%, $endColor 100%); - background: -webkit-linear-gradient(top, $startColor 0%,$endColor 100%); - background: linear-gradient(to bottom, $startColor 0%,$endColor 100%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=0 ); + background: $startColor; + background: -moz-linear-gradient(top, $startColor 0%, $endColor 100%); + background: -webkit-linear-gradient(top, $startColor 0%,$endColor 100%); + background: linear-gradient(to bottom, $startColor 0%,$endColor 100%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=0 ); } @mixin gradient-h($startColor, $endColor) { - background: $startColor; - background: -moz-linear-gradient(left, $startColor 0%, $endColor 100%); - background: -webkit-linear-gradient(left, $startColor 0%,$endColor 100%); - background: linear-gradient(to right, $startColor 0%,$endColor 100%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 ); + background: $startColor; + background: -moz-linear-gradient(left, $startColor 0%, $endColor 100%); + background: -webkit-linear-gradient(left, $startColor 0%,$endColor 100%); + background: linear-gradient(to right, $startColor 0%,$endColor 100%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 ); } @mixin gradient-diag-up($startColor, $endColor) { - background: $startColor; - background: -moz-linear-gradient(45deg, $startColor 0%, $endColor 100%); - background: -webkit-linear-gradient(45deg, $startColor 0%,$endColor 100%); - background: linear-gradient(45deg, $startColor 0%,$endColor 100%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 ); + background: $startColor; + background: -moz-linear-gradient(45deg, $startColor 0%, $endColor 100%); + background: -webkit-linear-gradient(45deg, $startColor 0%,$endColor 100%); + background: linear-gradient(45deg, $startColor 0%,$endColor 100%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 ); } @mixin gradient-diag-down($startColor, $endColor) { - background: $startColor; - background: -moz-linear-gradient(135deg, $startColor 0%, $endColor 100%); - background: -webkit-linear-gradient(135deg, $startColor 0%,$endColor 100%); - background: linear-gradient(135deg, $startColor 0%,$endColor 100%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 ); + background: $startColor; + background: -moz-linear-gradient(135deg, $startColor 0%, $endColor 100%); + background: -webkit-linear-gradient(135deg, $startColor 0%,$endColor 100%); + background: linear-gradient(135deg, $startColor 0%,$endColor 100%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 ); } @mixin gradient-r($startColor, $endColor) { - background: $startColor; - background: -moz-radial-gradient(center, ellipse cover, $startColor 0%, $endColor 100%); - background: -webkit-radial-gradient(center, ellipse cover, $startColor 0%,$endColor 100%); - background: radial-gradient(ellipse at center, $startColor 0%,$endColor 100%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 ); + background: $startColor; + background: -moz-radial-gradient(center, ellipse cover, $startColor 0%, $endColor 100%); + background: -webkit-radial-gradient(center, ellipse cover, $startColor 0%,$endColor 100%); + background: radial-gradient(ellipse at center, $startColor 0%,$endColor 100%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='$startColor', endColorstr='$endColor',GradientType=1 ); } // Custom Scrollbars (Chrome Only) $scrollbar-width: 16px; $scrollbar-offset: 3px; @mixin custom-scrollbar($trackColor, $handleColor) { - &::-webkit-scrollbar { - width: $scrollbar-width; - border-bottom-right-radius: $radius; + &::-webkit-scrollbar { + width: $scrollbar-width; + border-bottom-right-radius: $radius; - &-button { - background-color: $trackColor; - } - &-track { - background-color: $trackColor; - border-bottom-right-radius: $radius; - } - &-track-piece { - background-color: $trackColor; - border: $scrollbar-offset solid $trackColor; - border-radius: ($scrollbar-width / 2); - } - &-thumb { - background-color: $handleColor; - border: $scrollbar-offset solid $trackColor; - border-radius: ($scrollbar-width / 2); - } - &-corner { - background-color: $trackColor; - } - } - &::-webkit-resizer { - background-color: $trackColor; - } + &-button { + background-color: $trackColor; + } + &-track { + background-color: $trackColor; + border-bottom-right-radius: $radius; + } + &-track-piece { + background-color: $trackColor; + border: $scrollbar-offset solid $trackColor; + border-radius: ($scrollbar-width / 2); + } + &-thumb { + background-color: $handleColor; + border: $scrollbar-offset solid $trackColor; + border-radius: ($scrollbar-width / 2); + } + &-corner { + background-color: $trackColor; + } + } + &::-webkit-resizer { + background-color: $trackColor; + } +} + + +// Block user select +@mixin no-user-select() { + user-select: none !important; + -moz-user-select: none !important; + -webkit-user-select: none !important; + -ms-user-select: none !important; + -o-user-select: none !important; +} +.no-user-select { + user-select: none !important; + -moz-user-select: none !important; + -webkit-user-select: none !important; + -ms-user-select: none !important; + -o-user-select: none !important; } diff --git a/ui/src/style/pages/data-explorer/page-header.scss b/ui/src/style/pages/data-explorer/page-header.scss index f150832f3..9cfb34060 100644 --- a/ui/src/style/pages/data-explorer/page-header.scss +++ b/ui/src/style/pages/data-explorer/page-header.scss @@ -24,25 +24,4 @@ $sessions-dropdown-width: 227px; .sessions-dropdown__btn { margin: 0 16px 0 0; border-radius: 0 3px 3px 0; -} -.source-indicator { - padding: 0 9px; - border: 0; - background-color: $g5-pepper; - height: 30px; - line-height: 30px; - color: $g13-mist; - font-weight: 700; - font-size: 12px; - border-radius: 3px; - margin-right: 16px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - > .icon { - display: inline-block; - font-size: 16px; - margin: 0 4px 0 -2px; - } } \ No newline at end of file diff --git a/ui/src/style/pages/data-explorer/query-builder.scss b/ui/src/style/pages/data-explorer/query-builder.scss index b04151d5e..5b97d8aa1 100644 --- a/ui/src/style/pages/data-explorer/query-builder.scss +++ b/ui/src/style/pages/data-explorer/query-builder.scss @@ -29,6 +29,7 @@ justify-content: space-between; h1 { + @include no-user-select(); font-size: 17px; font-weight: 400; text-transform: uppercase; @@ -105,7 +106,7 @@ padding: 0; > .icon { - margin: 0; + margin: 0 !important; font-size: 12px; position: relative; top: -1px; @@ -135,6 +136,7 @@ overflow: hidden; max-width: 177px; text-overflow: ellipsis; + @include no-user-select(); } /* @@ -165,6 +167,7 @@ $query-builder--column-heading-height: 60px; top: 60px; } .query-builder--column-heading { + @include no-user-select(); width: 100%; height: $query-builder--column-heading-height; display: flex; diff --git a/ui/src/style/pages/data-explorer/query-editor.scss b/ui/src/style/pages/data-explorer/query-editor.scss index 16d045ea1..990a2936c 100644 --- a/ui/src/style/pages/data-explorer/query-editor.scss +++ b/ui/src/style/pages/data-explorer/query-editor.scss @@ -60,6 +60,7 @@ font-weight: 600; border-radius: $radius-small $radius-small 0 0; margin-right: 2px; + @include no-user-select(); transition: color 0.25s ease, background-color 0.25s ease; @@ -82,6 +83,7 @@ border-radius: 0 0 $radius-small $radius-small; &-item { + @include no-user-select(); color: $g11-sidewalk; list-style-type: none; margin: 0; diff --git a/ui/src/style/pages/data-explorer/visualization.scss b/ui/src/style/pages/data-explorer/visualization.scss index a6bb70e95..b9e76b3c0 100644 --- a/ui/src/style/pages/data-explorer/visualization.scss +++ b/ui/src/style/pages/data-explorer/visualization.scss @@ -27,6 +27,7 @@ color: $g13-mist; font-weight: 600; margin-right: 16px; + @include no-user-select(); transition: color 0.25s ease; } @@ -70,6 +71,7 @@ } } .graph-container { + @include no-user-select(); background-color: $graph-bg-color; border-radius: 0 0 $graph-radius $graph-radius; padding: 8px 16px; diff --git a/ui/src/style/pages/hosts.scss b/ui/src/style/pages/hosts.scss index 79d8a4b80..01f805418 100644 --- a/ui/src/style/pages/hosts.scss +++ b/ui/src/style/pages/hosts.scss @@ -5,22 +5,6 @@ .graph-container.hosts-graph { padding: 8px 16px; - - .single-stat { - font-size: 60px; - font-weight: 300; - color: $c-pool; - display: flex; - justify-content: center; - align-items: center; - height: 100%; - - &.graph-single-stat { - position: absolute; - width: 100%; - top: 0; - } - } } .host-list--active-source { diff --git a/ui/src/style/pages/kapacitor.scss b/ui/src/style/pages/kapacitor.scss index e87897b66..b52915555 100644 --- a/ui/src/style/pages/kapacitor.scss +++ b/ui/src/style/pages/kapacitor.scss @@ -80,6 +80,7 @@ $kapacitor-font-sm: 13px; display: flex; align-items: center; justify-content: center; + @include no-user-select(); p { margin: 0; @@ -101,6 +102,7 @@ $kapacitor-font-sm: 13px; font-weight: 400; color: $g13-mist; position: relative; + @include no-user-select(); &:before, &:after { @@ -451,6 +453,7 @@ div.qeditor.kapacitor-metric-selector { font-weight: 600; display: inline-block; color: $g15-platinum; + @include no-user-select(); } > code { background-color: $g2-kevlar; @@ -465,6 +468,7 @@ div.qeditor.kapacitor-metric-selector { font-size: ($kapacitor-font-sm - 2px); font-weight: 600; transition: color 0.25s ease; + @include no-user-select(); &:hover { color: $c-rainforest; @@ -483,6 +487,7 @@ div.qeditor.kapacitor-metric-selector { font-weight: 600; display: inline-block; color: $g15-platinum; + @include no-user-select(); } &.top { @@ -514,6 +519,7 @@ div.qeditor.kapacitor-metric-selector { } > p { + @include no-user-select(); white-space: nowrap; font-weight: 600; color: $g15-platinum; @@ -521,6 +527,7 @@ div.qeditor.kapacitor-metric-selector { margin-right: ($kap-padding-sm / 2); } > span { + @include no-user-select(); color: $kapacitor-accent; height: $kap-input-height; line-height: $kap-input-height; diff --git a/ui/src/style/theme/bootstrap-theme.scss b/ui/src/style/theme/bootstrap-theme.scss index 9b600c65e..ed76d4444 100755 --- a/ui/src/style/theme/bootstrap-theme.scss +++ b/ui/src/style/theme/bootstrap-theme.scss @@ -269,6 +269,9 @@ .icon.refresh:before { content: "\e949"; } +.icon.pause:before { + content: "\e94a"; +} .icon.clock:before { content: "\e91b"; } diff --git a/ui/src/style/theme/theme-dark.scss b/ui/src/style/theme/theme-dark.scss index 2b0d6ac83..810c0821d 100644 --- a/ui/src/style/theme/theme-dark.scss +++ b/ui/src/style/theme/theme-dark.scss @@ -19,6 +19,10 @@ .panel hr { background-color: $g5-pepper; } +.panel-title, +.panel-title a { + @include no-user-select(); +} .panel-minimal { border: 0; @@ -72,6 +76,10 @@ } } +table thead th { + @include no-user-select(); +} + /* Dark Buttons @@ -101,6 +109,72 @@ button.btn.btn-sm > span.icon { top: -1px; } +.btn.disabled, +.btn[disabled="true"] { + &.btn-success, + &.btn-success:hover, + &.btn-success:focus, + &.btn-success:active, + &.btn-success:hover:active, + &.btn-success:hover:focus, + &.btn-success:focus:active, + &.btn-success:focus:active:hover { + border-color: $c-emerald; + background-color: $c-emerald; + color: $g2-kevlar !important; + } + &.btn-primary, + &.btn-primary:hover, + &.btn-primary:focus, + &.btn-primary:active, + &.btn-primary:hover:active, + &.btn-primary:hover:focus, + &.btn-primary:focus:active, + &.btn-primary:focus:active:hover { + border-color: $c-sapphire; + background-color: $c-sapphire; + color: $g2-kevlar !important; + } + &.btn-info, + &.btn-info:hover, + &.btn-info:focus, + &.btn-info:active, + &.btn-info:hover:active, + &.btn-info:hover:focus, + &.btn-info:focus:active, + &.btn-info:focus:active:hover { + border-color: $g5-pepper; + background-color: $g5-pepper; + color: $g8-storm !important; + } + &.btn-danger, + &.btn-danger:hover, + &.btn-danger:focus, + &.btn-danger:active, + &.btn-danger:hover:active, + &.btn-danger:hover:focus, + &.btn-danger:focus:active, + &.btn-danger:focus:active:hover { + border-color: $c-ruby; + background-color: $c-ruby; + color: $g2-kevlar !important; + } + &.btn-warning, + &.btn-warning:hover, + &.btn-warning:focus, + &.btn-warning:active, + &.btn-warning:hover:active, + &.btn-warning:hover:focus, + &.btn-warning:focus:active, + &.btn-warning:focus:active:hover { + border-color: $c-star; + background-color: $c-star; + color: $g2-kevlar !important; + } +} + + + /* Dark Inputs ---------------------------------------------- @@ -114,6 +188,7 @@ button.btn.btn-sm > span.icon { font-weight: 600; margin-bottom: 4px; padding: 0 13px; + @include no-user-select(); } .form-control { padding: 0 13px; @@ -447,6 +522,8 @@ $toggle-border: 2px; border-radius: 3px; background-color: $g5-pepper; font-size: 0; + white-space: nowrap; + @include no-user-select(); } .toggle-btn { border: 0; @@ -730,5 +807,6 @@ $form-static-checkbox-size: 16px; } } - - +br { + @include no-user-select(); +}