diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 3fda06e84..d4d84d67a 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.4.0.0-beta1 +current_version = 1.4.0.0 files = README.md server/swagger.json parse = (?P\d+)\.(?P\d+)\.(?P\d+)\.(?P\d+) serialize = {major}.{minor}.{patch}.{release} diff --git a/.gitignore b/.gitignore index b580642af..7996088af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,23 @@ -npm-debug.log -*_gen.go -canned/apps_gen.go -server/swagger_gen.go -.pull-request -node_modules -/chronograf -node_modules/ +# Directories +ui/reports/ build/ -chronograf.db -chronograf-v1.db -npm-debug.log -.vscode +backup/ +.vscode/ + +# Binaries +/chronograf + +# Dotfiles +.pull-request .DS_Store .godep .jsdep .jssrc .dev-jssrc .bindata -ui/reports + +# Files +chronograf*.db +*_gen.go +canned/apps_gen.go +npm-debug.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec48f711..fadb27cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,41 @@ -## v1.4.0.0-beta2 [unreleased] -### Features +## v1.4.0.0 [2017-12-22] ### UI Improvements +1. [#2652](https://github.com/influxdata/chronograf/pull/2652): Add page header with instructional copy when adding initial source for consistency and clearer UX + ### Bug Fixes +1. [#2652](https://github.com/influxdata/chronograf/pull/2652): Make page render successfully when attempting to edit a source + +## v1.4.0.0-rc2 [2017-12-21] +### UI Improvements +1. [#2632](https://github.com/influxdata/chronograf/pull/2632): Tell user which organization they switched into and what role they have whenever they switch, including on Source Page + +### Bug Fixes +1. [#2639](https://github.com/influxdata/chronograf/pull/2639): Prevent SuperAdmin from modifying their own status +1. [#2632](https://github.com/influxdata/chronograf/pull/2632): Give SuperAdmin DefaultRole when switching to organization where they have no role +1. [#2642](https://github.com/influxdata/chronograf/pull/2642): Fix DE query config on first run + +## v1.4.0.0-rc1 [2017-12-19] +### Features +1. [#2593](https://github.com/influxdata/chronograf/pull/2593): Add option to use files for dashboards, organizations, data sources, and kapacitors +1. [#2604](https://github.com/influxdata/chronograf/pull/2604): After chronograf version upgrade, backup database is created in ./backups + +### UI Improvements +1. [#2492](https://github.com/influxdata/chronograf/pull/2492): Cleanup style on login page with multiple OAuth2 providers + +### Bug Fixes +1. [#2502](https://github.com/influxdata/chronograf/pull/2502): Fix stale source data after updating or creating +1. [#2616](https://github.com/influxdata/chronograf/pull/2616): Fix cell editing so query data choices are kept when updating a cell +1. [#2612](https://github.com/influxdata/chronograf/pull/2612): Allow days as a valid duration value + +## v1.4.0.0-beta2 [2017-12-14] +### UI Improvements +1. [#2502](https://github.com/influxdata/chronograf/pull/2502): Fix cursor flashing between default and pointer +1. [#2598](https://github.com/influxdata/chronograf/pull/2598): Allow appendage of a suffix to single stat visualizations +1. [#2598](https://github.com/influxdata/chronograf/pull/2598): Allow optional colorization of text instead of background on single stat visualizations + +### Bug Fixes +1. [#2528](https://github.com/influxdata/chronograf/pull/2528): Fix template rendering to ignore template if not in query +1. [#2563](https://github.com/influxdata/chronograf/pull/2563): Fix graph inversion if user input y-axis min greater than max ## v1.4.0.0-beta1 [2017-12-07] ### Features diff --git a/Makefile b/Makefile index 8f2b5be11..cf866feb8 100644 --- a/Makefile +++ b/Makefile @@ -93,10 +93,10 @@ internal.pb.go: bolt/internal/internal.proto test: jstest gotest gotestrace gotest: - go test `go list ./... | grep -v /vendor/` + go test ./... gotestrace: - go test -race `go list ./... | grep -v /vendor/` + go test -race ./... jstest: cd ui && yarn test diff --git a/README.md b/README.md index bf2ff7489..ae8c8ab6d 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ option. ## Versions The most recent version of Chronograf is -[v1.4.0.0-beta1](https://www.influxdata.com/downloads/). +[v1.4.0.0](https://www.influxdata.com/downloads/). Spotted a bug or have a feature request? Please open [an issue](https://github.com/influxdata/chronograf/issues/new)! diff --git a/bolt/client_test.go b/bolt/bolt_test.go similarity index 78% rename from bolt/client_test.go rename to bolt/bolt_test.go index 08098f0bf..a9e35b6c8 100644 --- a/bolt/client_test.go +++ b/bolt/bolt_test.go @@ -1,12 +1,15 @@ package bolt_test import ( + "context" "errors" "io/ioutil" "os" "time" + "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/bolt" + "github.com/influxdata/chronograf/mocks" ) // TestNow is a set time for testing. @@ -31,6 +34,13 @@ func NewTestClient() (*TestClient, error) { c.Path = f.Name() c.Now = func() time.Time { return TestNow } + build := chronograf.BuildInfo{ + Version: "version", + Commit: "commit", + } + + c.Open(context.TODO(), mocks.NewLogger(), build) + return c, nil } diff --git a/bolt/build.go b/bolt/build.go new file mode 100644 index 000000000..f137992c8 --- /dev/null +++ b/bolt/build.go @@ -0,0 +1,83 @@ +package bolt + +import ( + "context" + + "github.com/boltdb/bolt" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/bolt/internal" +) + +// Ensure BuildStore struct implements chronograf.BuildStore interface +var _ chronograf.BuildStore = &BuildStore{} + +// BuildBucket is the bolt bucket used to store Chronograf build information +var BuildBucket = []byte("Build") + +// BuildKey is the constant key used in the bolt bucket +var BuildKey = []byte("build") + +// BuildStore is a bolt implementation to store Chronograf build information +type BuildStore struct { + client *Client +} + +// Get retrieves Chronograf build information from the database +func (s *BuildStore) Get(ctx context.Context) (chronograf.BuildInfo, error) { + var build chronograf.BuildInfo + if err := s.client.db.View(func(tx *bolt.Tx) error { + var err error + build, err = s.get(ctx, tx) + if err != nil { + return err + } + return nil + }); err != nil { + return chronograf.BuildInfo{}, err + } + + return build, nil +} + +// Update overwrites the current Chronograf build information in the database +func (s *BuildStore) Update(ctx context.Context, build chronograf.BuildInfo) error { + if err := s.client.db.Update(func(tx *bolt.Tx) error { + return s.update(ctx, build, tx) + }); err != nil { + return err + } + + return nil +} + +// Migrate simply stores the current version in the database +func (s *BuildStore) Migrate(ctx context.Context, build chronograf.BuildInfo) error { + return s.Update(ctx, build) +} + +// get retrieves the current build, falling back to a default when missing +func (s *BuildStore) get(ctx context.Context, tx *bolt.Tx) (chronograf.BuildInfo, error) { + var build chronograf.BuildInfo + defaultBuild := chronograf.BuildInfo{ + Version: "pre-1.4.0.0", + Commit: "", + } + + if bucket := tx.Bucket(BuildBucket); bucket == nil { + return defaultBuild, nil + } else if v := bucket.Get(BuildKey); v == nil { + return defaultBuild, nil + } else if err := internal.UnmarshalBuild(v, &build); err != nil { + return build, err + } + return build, nil +} + +func (s *BuildStore) update(ctx context.Context, build chronograf.BuildInfo, tx *bolt.Tx) error { + if v, err := internal.MarshalBuild(build); err != nil { + return err + } else if err := tx.Bucket(BuildBucket).Put(BuildKey, v); err != nil { + return err + } + return nil +} diff --git a/bolt/build_test.go b/bolt/build_test.go new file mode 100644 index 000000000..b1b7986c3 --- /dev/null +++ b/bolt/build_test.go @@ -0,0 +1,54 @@ +package bolt_test + +// import ( +// "testing" + +// "github.com/google/go-cmp/cmp" +// "github.com/influxdata/chronograf" +// ) + +// func +// func TestBuildStore_Get(t *testing.T) { +// type wants struct { +// build *chronograf.BuildInfo +// err error +// } +// tests := []struct { +// name string +// wants wants +// }{ +// { +// name: "When the build info is missing", +// wants: wants{ +// build: &chronograf.BuildInfo{ +// Version: "pre-1.4.0.0", +// Commit: "", +// }, +// }, +// }, +// } +// for _, tt := range tests { +// client, err := NewTestClient() +// if err != nil { +// t.Fatal(err) +// } +// if err := client.Open(context.TODO()); err != nil { +// t.Fatal(err) +// } +// defer client.Close() + +// b := client.BuildStore +// got, err := b.Get(context.Background()) +// if (tt.wants.err != nil) != (err != nil) { +// t.Errorf("%q. BuildStore.Get() error = %v, wantErr %v", tt.name, err, tt.wants.err) +// continue +// } +// if diff := cmp.Diff(got, tt.wants.build); diff != "" { +// t.Errorf("%q. BuildStore.Get():\n-got/+want\ndiff %s", tt.name, diff) +// } +// } +// } + +// func TestBuildStore_Update(t *testing.T) { + +// } diff --git a/bolt/client.go b/bolt/client.go index 8ae39cfd0..fad811246 100644 --- a/bolt/client.go +++ b/bolt/client.go @@ -2,56 +2,122 @@ package bolt import ( "context" + "fmt" + "io" + "os" "path" "time" "github.com/boltdb/bolt" "github.com/influxdata/chronograf" - "github.com/influxdata/chronograf/uuid" + "github.com/influxdata/chronograf/id" +) + +const ( + // ErrUnableToOpen means we had an issue establishing a connection (or creating the database) + ErrUnableToOpen = "Unable to open boltdb; is there a chronograf already running? %v" + // ErrUnableToBackup means we couldn't copy the db file into ./backup + ErrUnableToBackup = "Unable to backup your database prior to migrations: %v" + // ErrUnableToInitialize means we couldn't create missing Buckets (maybe a timeout) + ErrUnableToInitialize = "Unable to boot boltdb: %v" + // ErrUnableToMigrate means we had an issue changing the db schema + ErrUnableToMigrate = "Unable to migrate boltdb: %v" ) // Client is a client for the boltDB data store. type Client struct { Path string db *bolt.DB + logger chronograf.Logger + isNew bool Now func() time.Time LayoutIDs chronograf.ID + BuildStore *BuildStore SourcesStore *SourcesStore ServersStore *ServersStore LayoutsStore *LayoutsStore DashboardsStore *DashboardsStore UsersStore *UsersStore OrganizationsStore *OrganizationsStore + ConfigStore *ConfigStore } // NewClient initializes all stores func NewClient() *Client { c := &Client{Now: time.Now} + c.BuildStore = &BuildStore{client: c} c.SourcesStore = &SourcesStore{client: c} c.ServersStore = &ServersStore{client: c} c.LayoutsStore = &LayoutsStore{ client: c, - IDs: &uuid.V4{}, + IDs: &id.UUID{}, } c.DashboardsStore = &DashboardsStore{ client: c, - IDs: &uuid.V4{}, + IDs: &id.UUID{}, } c.UsersStore = &UsersStore{client: c} c.OrganizationsStore = &OrganizationsStore{client: c} + c.ConfigStore = &ConfigStore{client: c} return c } -// Open and initialize boltDB. Initial buckets are created if they do not exist. -func (c *Client) Open(ctx context.Context) error { +// Option to change behavior of Open() +type Option interface { + Backup() bool +} + +// WithBackup returns a Backup +func WithBackup() Option { + return Backup{} +} + +// Backup tells Open to perform a backup prior to initialization +type Backup struct { +} + +// Backup returns true +func (b Backup) Backup() bool { + return true +} + +// Open / create boltDB file. +func (c *Client) Open(ctx context.Context, logger chronograf.Logger, build chronograf.BuildInfo, opts ...Option) error { + if _, err := os.Stat(c.Path); os.IsNotExist(err) { + c.isNew = true + } else if err != nil { + return err + } + // Open database file. db, err := bolt.Open(c.Path, 0600, &bolt.Options{Timeout: 1 * time.Second}) if err != nil { - return err + return fmt.Errorf(ErrUnableToOpen, err) } c.db = db + c.logger = logger + for _, opt := range opts { + if opt.Backup() { + if err = c.backup(ctx, build); err != nil { + return fmt.Errorf(ErrUnableToBackup, err) + } + } + } + + if err = c.initialize(ctx); err != nil { + return fmt.Errorf(ErrUnableToInitialize, err) + } + if err = c.migrate(ctx, build); err != nil { + return fmt.Errorf(ErrUnableToMigrate, err) + } + + return nil +} + +// initialize creates Buckets that are missing +func (c *Client) initialize(ctx context.Context) error { if err := c.db.Update(func(tx *bolt.Tx) error { // Always create Organizations bucket. if _, err := tx.CreateBucketIfNotExists(OrganizationsBucket); err != nil { @@ -77,28 +143,48 @@ func (c *Client) Open(ctx context.Context) error { if _, err := tx.CreateBucketIfNotExists(UsersBucket); err != nil { return err } + // Always create Config bucket. + if _, err := tx.CreateBucketIfNotExists(ConfigBucket); err != nil { + return err + } + // Always create Build bucket. + if _, err := tx.CreateBucketIfNotExists(BuildBucket); err != nil { + return err + } return nil }); err != nil { return err } - // Runtime migrations - if err := c.OrganizationsStore.Migrate(ctx); err != nil { - return err - } - if err := c.SourcesStore.Migrate(ctx); err != nil { - return err - } - if err := c.ServersStore.Migrate(ctx); err != nil { - return err - } - if err := c.LayoutsStore.Migrate(ctx); err != nil { - return err - } - if err := c.DashboardsStore.Migrate(ctx); err != nil { - return err - } + return nil +} +// migrate moves data from an old schema to a new schema in each Store +func (c *Client) migrate(ctx context.Context, build chronograf.BuildInfo) error { + if c.db != nil { + // Runtime migrations + if err := c.OrganizationsStore.Migrate(ctx); err != nil { + return err + } + if err := c.SourcesStore.Migrate(ctx); err != nil { + return err + } + if err := c.ServersStore.Migrate(ctx); err != nil { + return err + } + if err := c.LayoutsStore.Migrate(ctx); err != nil { + return err + } + if err := c.DashboardsStore.Migrate(ctx); err != nil { + return err + } + if err := c.ConfigStore.Migrate(ctx); err != nil { + return err + } + if err := c.BuildStore.Migrate(ctx, build); err != nil { + return err + } + } return nil } @@ -110,6 +196,66 @@ func (c *Client) Close() error { return nil } +// copy creates a copy of the database in toFile +func (c *Client) copy(ctx context.Context, version string) error { + backupDir := path.Join(path.Dir(c.Path), "backup") + if _, err := os.Stat(backupDir); os.IsNotExist(err) { + if err = os.Mkdir(backupDir, 0700); err != nil { + return err + } + } else if err != nil { + return err + } + + fromFile, err := os.Open(c.Path) + if err != nil { + return err + } + defer fromFile.Close() + + toName := fmt.Sprintf("%s.%s", path.Base(c.Path), version) + toPath := path.Join(backupDir, toName) + toFile, err := os.OpenFile(toPath, os.O_RDWR|os.O_CREATE, 0600) + if err != nil { + return err + } + defer toFile.Close() + + _, err = io.Copy(toFile, fromFile) + if err != nil { + return err + } + + c.logger.Info("Successfully created ", toPath) + + return nil +} + +// backup makes a copy of the database to the backup/ directory, if necessary: +// - If this is a fresh install, don't create a backup and store the current version +// - If we are on the same version, don't create a backup +// - If the version has changed, create a backup and store the current version +func (c *Client) backup(ctx context.Context, build chronograf.BuildInfo) error { + lastBuild, err := c.BuildStore.Get(ctx) + if err != nil { + return err + } + if lastBuild.Version == build.Version { + return nil + } + if c.isNew { + return nil + } + + // The database was pre-existing, and the version has changed + // and so create a backup + + c.logger.Info("Moving from version ", lastBuild.Version) + c.logger.Info("Moving to version ", build.Version) + + return c.copy(ctx, lastBuild.Version) +} + func bucket(b []byte, org string) []byte { return []byte(path.Join(string(b), org)) } diff --git a/bolt/config.go b/bolt/config.go new file mode 100644 index 000000000..98d6eaca2 --- /dev/null +++ b/bolt/config.go @@ -0,0 +1,71 @@ +package bolt + +import ( + "context" + "fmt" + + "github.com/boltdb/bolt" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/bolt/internal" +) + +// Ensure ConfigStore implements chronograf.ConfigStore. +var _ chronograf.ConfigStore = &ConfigStore{} + +// ConfigBucket is used to store chronograf application state +var ConfigBucket = []byte("ConfigV1") + +// configID is the boltDB key where the configuration object is stored +var configID = []byte("config/v1") + +// ConfigStore uses bolt to store and retrieve global +// application configuration +type ConfigStore struct { + client *Client +} + +func (s *ConfigStore) Migrate(ctx context.Context) error { + if _, err := s.Get(ctx); err != nil { + return s.Initialize(ctx) + } + return nil +} + +func (s *ConfigStore) Initialize(ctx context.Context) error { + cfg := chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + } + return s.Update(ctx, &cfg) +} + +func (s *ConfigStore) Get(ctx context.Context) (*chronograf.Config, error) { + var cfg chronograf.Config + err := s.client.db.View(func(tx *bolt.Tx) error { + v := tx.Bucket(ConfigBucket).Get(configID) + if v == nil { + return chronograf.ErrConfigNotFound + } + return internal.UnmarshalConfig(v, &cfg) + }) + + if err != nil { + return nil, err + } + return &cfg, nil +} + +func (s *ConfigStore) Update(ctx context.Context, cfg *chronograf.Config) error { + if cfg == nil { + return fmt.Errorf("config provided was nil") + } + return s.client.db.Update(func(tx *bolt.Tx) error { + if v, err := internal.MarshalConfig(cfg); err != nil { + return err + } else if err := tx.Bucket(ConfigBucket).Put(configID, v); err != nil { + return err + } + return nil + }) +} diff --git a/bolt/config_test.go b/bolt/config_test.go new file mode 100644 index 000000000..f23e1b736 --- /dev/null +++ b/bolt/config_test.go @@ -0,0 +1,105 @@ +package bolt_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/influxdata/chronograf" +) + +func TestConfig_Get(t *testing.T) { + type wants struct { + config *chronograf.Config + err error + } + tests := []struct { + name string + wants wants + }{ + { + name: "Get config", + wants: wants{ + config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + }, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.ConfigStore + got, err := s.Get(context.Background()) + if (tt.wants.err != nil) != (err != nil) { + t.Errorf("%q. ConfigStore.Get() error = %v, wantErr %v", tt.name, err, tt.wants.err) + continue + } + if diff := cmp.Diff(got, tt.wants.config); diff != "" { + t.Errorf("%q. ConfigStore.Get():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} + +func TestConfig_Update(t *testing.T) { + type args struct { + config *chronograf.Config + } + type wants struct { + config *chronograf.Config + err error + } + tests := []struct { + name string + args args + wants wants + }{ + { + name: "Set config", + args: args{ + config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + wants: wants{ + config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + }, + } + for _, tt := range tests { + client, err := NewTestClient() + if err != nil { + t.Fatal(err) + } + defer client.Close() + + s := client.ConfigStore + err = s.Update(context.Background(), tt.args.config) + if (tt.wants.err != nil) != (err != nil) { + t.Errorf("%q. ConfigStore.Get() error = %v, wantErr %v", tt.name, err, tt.wants.err) + continue + } + + got, _ := s.Get(context.Background()) + if (tt.wants.err != nil) != (err != nil) { + t.Errorf("%q. ConfigStore.Get() error = %v, wantErr %v", tt.name, err, tt.wants.err) + continue + } + + if diff := cmp.Diff(got, tt.wants.config); diff != "" { + t.Errorf("%q. ConfigStore.Get():\n-got/+want\ndiff %s", tt.name, diff) + } + } +} diff --git a/bolt/dashboards.go b/bolt/dashboards.go index f8fe0a6df..c3035ee1b 100644 --- a/bolt/dashboards.go +++ b/bolt/dashboards.go @@ -2,7 +2,6 @@ package bolt import ( "context" - "fmt" "strconv" "github.com/boltdb/bolt" @@ -64,11 +63,9 @@ func (d *DashboardsStore) Migrate(ctx context.Context) error { return err } - defaultOrgID := fmt.Sprintf("%d", defaultOrg.ID) - for _, board := range boards { if board.Organization == "" { - board.Organization = defaultOrgID + board.Organization = defaultOrg.ID if err := d.Update(ctx, board); err != nil { return nil } diff --git a/bolt/internal/internal.go b/bolt/internal/internal.go index c657ef678..d7e6985da 100644 --- a/bolt/internal/internal.go +++ b/bolt/internal/internal.go @@ -2,6 +2,7 @@ package internal import ( "encoding/json" + "fmt" "github.com/gogo/protobuf/proto" "github.com/influxdata/chronograf" @@ -9,6 +10,26 @@ import ( //go:generate protoc --gogo_out=. internal.proto +// MarshalBuild encodes a build to binary protobuf format. +func MarshalBuild(b chronograf.BuildInfo) ([]byte, error) { + return proto.Marshal(&BuildInfo{ + Version: b.Version, + Commit: b.Commit, + }) +} + +// UnmarshalBuild decodes a build from binary protobuf data. +func UnmarshalBuild(data []byte, b *chronograf.BuildInfo) error { + var pb BuildInfo + if err := proto.Unmarshal(data, &pb); err != nil { + return err + } + + b.Version = pb.Version + b.Commit = pb.Commit + return nil +} + // MarshalSource encodes a source to binary protobuf format. func MarshalSource(s chronograf.Source) ([]byte, error) { return proto.Marshal(&Source{ @@ -591,3 +612,39 @@ func UnmarshalOrganizationPB(data []byte, o *Organization) error { } return nil } + +// MarshalConfig encodes a config to binary protobuf format. +func MarshalConfig(c *chronograf.Config) ([]byte, error) { + return MarshalConfigPB(&Config{ + Auth: &AuthConfig{ + SuperAdminNewUsers: c.Auth.SuperAdminNewUsers, + }, + }) +} + +// MarshalConfigPB encodes a config to binary protobuf format. +func MarshalConfigPB(c *Config) ([]byte, error) { + return proto.Marshal(c) +} + +// UnmarshalConfig decodes a config from binary protobuf data. +func UnmarshalConfig(data []byte, c *chronograf.Config) error { + var pb Config + if err := UnmarshalConfigPB(data, &pb); err != nil { + return err + } + if pb.Auth == nil { + return fmt.Errorf("Auth config is nil") + } + c.Auth.SuperAdminNewUsers = pb.Auth.SuperAdminNewUsers + + return nil +} + +// UnmarshalConfigPB decodes a config from binary protobuf data. +func UnmarshalConfigPB(data []byte, c *Config) error { + if err := proto.Unmarshal(data, c); err != nil { + return err + } + return nil +} diff --git a/bolt/internal/internal.pb.go b/bolt/internal/internal.pb.go index 91eb31bf7..8864e63fa 100644 --- a/bolt/internal/internal.pb.go +++ b/bolt/internal/internal.pb.go @@ -27,6 +27,9 @@ It has these top-level messages: User Role Organization + Config + AuthConfig + BuildInfo */ package internal @@ -66,6 +69,97 @@ func (m *Source) String() string { return proto.CompactTextString(m) func (*Source) ProtoMessage() {} func (*Source) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{0} } +func (m *Source) GetID() int64 { + if m != nil { + return m.ID + } + return 0 +} + +func (m *Source) GetName() string { + if m != nil { + return m.Name + } + return "" +} + +func (m *Source) GetType() string { + if m != nil { + return m.Type + } + return "" +} + +func (m *Source) GetUsername() string { + if m != nil { + return m.Username + } + return "" +} + +func (m *Source) GetPassword() string { + if m != nil { + return m.Password + } + return "" +} + +func (m *Source) GetURL() string { + if m != nil { + return m.URL + } + return "" +} + +func (m *Source) GetDefault() bool { + if m != nil { + return m.Default + } + return false +} + +func (m *Source) GetTelegraf() string { + if m != nil { + return m.Telegraf + } + return "" +} + +func (m *Source) GetInsecureSkipVerify() bool { + if m != nil { + return m.InsecureSkipVerify + } + return false +} + +func (m *Source) GetMetaURL() string { + if m != nil { + return m.MetaURL + } + return "" +} + +func (m *Source) GetSharedSecret() string { + if m != nil { + return m.SharedSecret + } + return "" +} + +func (m *Source) GetOrganization() string { + if m != nil { + return m.Organization + } + return "" +} + +func (m *Source) GetRole() string { + if m != nil { + return m.Role + } + return "" +} + type Dashboard struct { ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` @@ -79,6 +173,20 @@ func (m *Dashboard) String() string { return proto.CompactTextString( func (*Dashboard) ProtoMessage() {} func (*Dashboard) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{1} } +func (m *Dashboard) GetID() int64 { + if m != nil { + return m.ID + } + return 0 +} + +func (m *Dashboard) GetName() string { + if m != nil { + return m.Name + } + return "" +} + func (m *Dashboard) GetCells() []*DashboardCell { if m != nil { return m.Cells @@ -93,6 +201,13 @@ func (m *Dashboard) GetTemplates() []*Template { return nil } +func (m *Dashboard) GetOrganization() string { + if m != nil { + return m.Organization + } + return "" +} + type DashboardCell struct { X int32 `protobuf:"varint,1,opt,name=x,proto3" json:"x,omitempty"` Y int32 `protobuf:"varint,2,opt,name=y,proto3" json:"y,omitempty"` @@ -111,6 +226,34 @@ func (m *DashboardCell) String() string { return proto.CompactTextStr func (*DashboardCell) ProtoMessage() {} func (*DashboardCell) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{2} } +func (m *DashboardCell) GetX() int32 { + if m != nil { + return m.X + } + return 0 +} + +func (m *DashboardCell) GetY() int32 { + if m != nil { + return m.Y + } + return 0 +} + +func (m *DashboardCell) GetW() int32 { + if m != nil { + return m.W + } + return 0 +} + +func (m *DashboardCell) GetH() int32 { + if m != nil { + return m.H + } + return 0 +} + func (m *DashboardCell) GetQueries() []*Query { if m != nil { return m.Queries @@ -118,6 +261,27 @@ func (m *DashboardCell) GetQueries() []*Query { return nil } +func (m *DashboardCell) GetName() string { + if m != nil { + return m.Name + } + return "" +} + +func (m *DashboardCell) GetType() string { + if m != nil { + return m.Type + } + return "" +} + +func (m *DashboardCell) GetID() string { + if m != nil { + return m.ID + } + return "" +} + func (m *DashboardCell) GetAxes() map[string]*Axis { if m != nil { return m.Axes @@ -145,8 +309,43 @@ func (m *Color) String() string { return proto.CompactTextString(m) } func (*Color) ProtoMessage() {} func (*Color) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{3} } +func (m *Color) GetID() string { + if m != nil { + return m.ID + } + return "" +} + +func (m *Color) GetType() string { + if m != nil { + return m.Type + } + return "" +} + +func (m *Color) GetHex() string { + if m != nil { + return m.Hex + } + return "" +} + +func (m *Color) GetName() string { + if m != nil { + return m.Name + } + return "" +} + +func (m *Color) GetValue() string { + if m != nil { + return m.Value + } + return "" +} + type Axis struct { - LegacyBounds []int64 `protobuf:"varint,1,rep,name=legacyBounds" json:"legacyBounds,omitempty"` + LegacyBounds []int64 `protobuf:"varint,1,rep,packed,name=legacyBounds" json:"legacyBounds,omitempty"` Bounds []string `protobuf:"bytes,2,rep,name=bounds" json:"bounds,omitempty"` Label string `protobuf:"bytes,3,opt,name=label,proto3" json:"label,omitempty"` Prefix string `protobuf:"bytes,4,opt,name=prefix,proto3" json:"prefix,omitempty"` @@ -160,6 +359,55 @@ func (m *Axis) String() string { return proto.CompactTextString(m) } func (*Axis) ProtoMessage() {} func (*Axis) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{4} } +func (m *Axis) GetLegacyBounds() []int64 { + if m != nil { + return m.LegacyBounds + } + return nil +} + +func (m *Axis) GetBounds() []string { + if m != nil { + return m.Bounds + } + return nil +} + +func (m *Axis) GetLabel() string { + if m != nil { + return m.Label + } + return "" +} + +func (m *Axis) GetPrefix() string { + if m != nil { + return m.Prefix + } + return "" +} + +func (m *Axis) GetSuffix() string { + if m != nil { + return m.Suffix + } + return "" +} + +func (m *Axis) GetBase() string { + if m != nil { + return m.Base + } + return "" +} + +func (m *Axis) GetScale() string { + if m != nil { + return m.Scale + } + return "" +} + type Template struct { ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` TempVar string `protobuf:"bytes,2,opt,name=temp_var,json=tempVar,proto3" json:"temp_var,omitempty"` @@ -174,6 +422,20 @@ func (m *Template) String() string { return proto.CompactTextString(m func (*Template) ProtoMessage() {} func (*Template) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{5} } +func (m *Template) GetID() string { + if m != nil { + return m.ID + } + return "" +} + +func (m *Template) GetTempVar() string { + if m != nil { + return m.TempVar + } + return "" +} + func (m *Template) GetValues() []*TemplateValue { if m != nil { return m.Values @@ -181,6 +443,20 @@ func (m *Template) GetValues() []*TemplateValue { return nil } +func (m *Template) GetType() string { + if m != nil { + return m.Type + } + return "" +} + +func (m *Template) GetLabel() string { + if m != nil { + return m.Label + } + return "" +} + func (m *Template) GetQuery() *TemplateQuery { if m != nil { return m.Query @@ -199,6 +475,27 @@ func (m *TemplateValue) String() string { return proto.CompactTextStr func (*TemplateValue) ProtoMessage() {} func (*TemplateValue) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{6} } +func (m *TemplateValue) GetType() string { + if m != nil { + return m.Type + } + return "" +} + +func (m *TemplateValue) GetValue() string { + if m != nil { + return m.Value + } + return "" +} + +func (m *TemplateValue) GetSelected() bool { + if m != nil { + return m.Selected + } + return false +} + type TemplateQuery struct { Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"` Db string `protobuf:"bytes,2,opt,name=db,proto3" json:"db,omitempty"` @@ -213,6 +510,48 @@ func (m *TemplateQuery) String() string { return proto.CompactTextStr func (*TemplateQuery) ProtoMessage() {} func (*TemplateQuery) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{7} } +func (m *TemplateQuery) GetCommand() string { + if m != nil { + return m.Command + } + return "" +} + +func (m *TemplateQuery) GetDb() string { + if m != nil { + return m.Db + } + return "" +} + +func (m *TemplateQuery) GetRp() string { + if m != nil { + return m.Rp + } + return "" +} + +func (m *TemplateQuery) GetMeasurement() string { + if m != nil { + return m.Measurement + } + return "" +} + +func (m *TemplateQuery) GetTagKey() string { + if m != nil { + return m.TagKey + } + return "" +} + +func (m *TemplateQuery) GetFieldKey() string { + if m != nil { + return m.FieldKey + } + return "" +} + type Server struct { ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` @@ -229,6 +568,62 @@ func (m *Server) String() string { return proto.CompactTextString(m) func (*Server) ProtoMessage() {} func (*Server) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{8} } +func (m *Server) GetID() int64 { + if m != nil { + return m.ID + } + return 0 +} + +func (m *Server) GetName() string { + if m != nil { + return m.Name + } + return "" +} + +func (m *Server) GetUsername() string { + if m != nil { + return m.Username + } + return "" +} + +func (m *Server) GetPassword() string { + if m != nil { + return m.Password + } + return "" +} + +func (m *Server) GetURL() string { + if m != nil { + return m.URL + } + return "" +} + +func (m *Server) GetSrcID() int64 { + if m != nil { + return m.SrcID + } + return 0 +} + +func (m *Server) GetActive() bool { + if m != nil { + return m.Active + } + return false +} + +func (m *Server) GetOrganization() string { + if m != nil { + return m.Organization + } + return "" +} + type Layout struct { ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Application string `protobuf:"bytes,2,opt,name=Application,proto3" json:"Application,omitempty"` @@ -242,6 +637,27 @@ func (m *Layout) String() string { return proto.CompactTextString(m) func (*Layout) ProtoMessage() {} func (*Layout) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{9} } +func (m *Layout) GetID() string { + if m != nil { + return m.ID + } + return "" +} + +func (m *Layout) GetApplication() string { + if m != nil { + return m.Application + } + return "" +} + +func (m *Layout) GetMeasurement() string { + if m != nil { + return m.Measurement + } + return "" +} + func (m *Layout) GetCells() []*Cell { if m != nil { return m.Cells @@ -249,6 +665,13 @@ func (m *Layout) GetCells() []*Cell { return nil } +func (m *Layout) GetAutoflow() bool { + if m != nil { + return m.Autoflow + } + return false +} + type Cell struct { X int32 `protobuf:"varint,1,opt,name=x,proto3" json:"x,omitempty"` Y int32 `protobuf:"varint,2,opt,name=y,proto3" json:"y,omitempty"` @@ -257,7 +680,7 @@ type Cell struct { Queries []*Query `protobuf:"bytes,5,rep,name=queries" json:"queries,omitempty"` I string `protobuf:"bytes,6,opt,name=i,proto3" json:"i,omitempty"` Name string `protobuf:"bytes,7,opt,name=name,proto3" json:"name,omitempty"` - Yranges []int64 `protobuf:"varint,8,rep,name=yranges" json:"yranges,omitempty"` + Yranges []int64 `protobuf:"varint,8,rep,packed,name=yranges" json:"yranges,omitempty"` Ylabels []string `protobuf:"bytes,9,rep,name=ylabels" json:"ylabels,omitempty"` Type string `protobuf:"bytes,10,opt,name=type,proto3" json:"type,omitempty"` Axes map[string]*Axis `protobuf:"bytes,11,rep,name=axes" json:"axes,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value"` @@ -268,6 +691,34 @@ func (m *Cell) String() string { return proto.CompactTextString(m) } func (*Cell) ProtoMessage() {} func (*Cell) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{10} } +func (m *Cell) GetX() int32 { + if m != nil { + return m.X + } + return 0 +} + +func (m *Cell) GetY() int32 { + if m != nil { + return m.Y + } + return 0 +} + +func (m *Cell) GetW() int32 { + if m != nil { + return m.W + } + return 0 +} + +func (m *Cell) GetH() int32 { + if m != nil { + return m.H + } + return 0 +} + func (m *Cell) GetQueries() []*Query { if m != nil { return m.Queries @@ -275,6 +726,41 @@ func (m *Cell) GetQueries() []*Query { return nil } +func (m *Cell) GetI() string { + if m != nil { + return m.I + } + return "" +} + +func (m *Cell) GetName() string { + if m != nil { + return m.Name + } + return "" +} + +func (m *Cell) GetYranges() []int64 { + if m != nil { + return m.Yranges + } + return nil +} + +func (m *Cell) GetYlabels() []string { + if m != nil { + return m.Ylabels + } + return nil +} + +func (m *Cell) GetType() string { + if m != nil { + return m.Type + } + return "" +} + func (m *Cell) GetAxes() map[string]*Axis { if m != nil { return m.Axes @@ -299,6 +785,48 @@ func (m *Query) String() string { return proto.CompactTextString(m) } func (*Query) ProtoMessage() {} func (*Query) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{11} } +func (m *Query) GetCommand() string { + if m != nil { + return m.Command + } + return "" +} + +func (m *Query) GetDB() string { + if m != nil { + return m.DB + } + return "" +} + +func (m *Query) GetRP() string { + if m != nil { + return m.RP + } + return "" +} + +func (m *Query) GetGroupBys() []string { + if m != nil { + return m.GroupBys + } + return nil +} + +func (m *Query) GetWheres() []string { + if m != nil { + return m.Wheres + } + return nil +} + +func (m *Query) GetLabel() string { + if m != nil { + return m.Label + } + return "" +} + func (m *Query) GetRange() *Range { if m != nil { return m.Range @@ -306,6 +834,13 @@ func (m *Query) GetRange() *Range { return nil } +func (m *Query) GetSource() string { + if m != nil { + return m.Source + } + return "" +} + func (m *Query) GetShifts() []*TimeShift { if m != nil { return m.Shifts @@ -324,6 +859,27 @@ func (m *TimeShift) String() string { return proto.CompactTextString( func (*TimeShift) ProtoMessage() {} func (*TimeShift) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{12} } +func (m *TimeShift) GetLabel() string { + if m != nil { + return m.Label + } + return "" +} + +func (m *TimeShift) GetUnit() string { + if m != nil { + return m.Unit + } + return "" +} + +func (m *TimeShift) GetQuantity() string { + if m != nil { + return m.Quantity + } + return "" +} + type Range struct { Upper int64 `protobuf:"varint,1,opt,name=Upper,proto3" json:"Upper,omitempty"` Lower int64 `protobuf:"varint,2,opt,name=Lower,proto3" json:"Lower,omitempty"` @@ -334,6 +890,20 @@ func (m *Range) String() string { return proto.CompactTextString(m) } func (*Range) ProtoMessage() {} func (*Range) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{13} } +func (m *Range) GetUpper() int64 { + if m != nil { + return m.Upper + } + return 0 +} + +func (m *Range) GetLower() int64 { + if m != nil { + return m.Lower + } + return 0 +} + type AlertRule struct { ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` JSON string `protobuf:"bytes,2,opt,name=JSON,proto3" json:"JSON,omitempty"` @@ -346,6 +916,34 @@ func (m *AlertRule) String() string { return proto.CompactTextString( func (*AlertRule) ProtoMessage() {} func (*AlertRule) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{14} } +func (m *AlertRule) GetID() string { + if m != nil { + return m.ID + } + return "" +} + +func (m *AlertRule) GetJSON() string { + if m != nil { + return m.JSON + } + return "" +} + +func (m *AlertRule) GetSrcID() int64 { + if m != nil { + return m.SrcID + } + return 0 +} + +func (m *AlertRule) GetKapaID() int64 { + if m != nil { + return m.KapaID + } + return 0 +} + type User struct { ID uint64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` @@ -360,6 +958,34 @@ func (m *User) String() string { return proto.CompactTextString(m) } func (*User) ProtoMessage() {} func (*User) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{15} } +func (m *User) GetID() uint64 { + if m != nil { + return m.ID + } + return 0 +} + +func (m *User) GetName() string { + if m != nil { + return m.Name + } + return "" +} + +func (m *User) GetProvider() string { + if m != nil { + return m.Provider + } + return "" +} + +func (m *User) GetScheme() string { + if m != nil { + return m.Scheme + } + return "" +} + func (m *User) GetRoles() []*Role { if m != nil { return m.Roles @@ -367,6 +993,13 @@ func (m *User) GetRoles() []*Role { return nil } +func (m *User) GetSuperAdmin() bool { + if m != nil { + return m.SuperAdmin + } + return false +} + type Role struct { Organization string `protobuf:"bytes,1,opt,name=Organization,proto3" json:"Organization,omitempty"` Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` @@ -377,8 +1010,22 @@ func (m *Role) String() string { return proto.CompactTextString(m) } func (*Role) ProtoMessage() {} func (*Role) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{16} } +func (m *Role) GetOrganization() string { + if m != nil { + return m.Organization + } + return "" +} + +func (m *Role) GetName() string { + if m != nil { + return m.Name + } + return "" +} + type Organization struct { - ID uint64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` DefaultRole string `protobuf:"bytes,3,opt,name=DefaultRole,proto3" json:"DefaultRole,omitempty"` Public bool `protobuf:"varint,4,opt,name=Public,proto3" json:"Public,omitempty"` @@ -389,6 +1036,90 @@ func (m *Organization) String() string { return proto.CompactTextStri func (*Organization) ProtoMessage() {} func (*Organization) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{17} } +func (m *Organization) GetID() string { + if m != nil { + return m.ID + } + return "" +} + +func (m *Organization) GetName() string { + if m != nil { + return m.Name + } + return "" +} + +func (m *Organization) GetDefaultRole() string { + if m != nil { + return m.DefaultRole + } + return "" +} + +func (m *Organization) GetPublic() bool { + if m != nil { + return m.Public + } + return false +} + +type Config struct { + Auth *AuthConfig `protobuf:"bytes,1,opt,name=Auth" json:"Auth,omitempty"` +} + +func (m *Config) Reset() { *m = Config{} } +func (m *Config) String() string { return proto.CompactTextString(m) } +func (*Config) ProtoMessage() {} +func (*Config) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{18} } + +func (m *Config) GetAuth() *AuthConfig { + if m != nil { + return m.Auth + } + return nil +} + +type AuthConfig struct { + SuperAdminNewUsers bool `protobuf:"varint,1,opt,name=SuperAdminNewUsers,proto3" json:"SuperAdminNewUsers,omitempty"` +} + +func (m *AuthConfig) Reset() { *m = AuthConfig{} } +func (m *AuthConfig) String() string { return proto.CompactTextString(m) } +func (*AuthConfig) ProtoMessage() {} +func (*AuthConfig) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{19} } + +func (m *AuthConfig) GetSuperAdminNewUsers() bool { + if m != nil { + return m.SuperAdminNewUsers + } + return false +} + +type BuildInfo struct { + Version string `protobuf:"bytes,1,opt,name=Version,proto3" json:"Version,omitempty"` + Commit string `protobuf:"bytes,2,opt,name=Commit,proto3" json:"Commit,omitempty"` +} + +func (m *BuildInfo) Reset() { *m = BuildInfo{} } +func (m *BuildInfo) String() string { return proto.CompactTextString(m) } +func (*BuildInfo) ProtoMessage() {} +func (*BuildInfo) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{20} } + +func (m *BuildInfo) GetVersion() string { + if m != nil { + return m.Version + } + return "" +} + +func (m *BuildInfo) GetCommit() string { + if m != nil { + return m.Commit + } + return "" +} + func init() { proto.RegisterType((*Source)(nil), "internal.Source") proto.RegisterType((*Dashboard)(nil), "internal.Dashboard") @@ -408,89 +1139,97 @@ func init() { proto.RegisterType((*User)(nil), "internal.User") proto.RegisterType((*Role)(nil), "internal.Role") proto.RegisterType((*Organization)(nil), "internal.Organization") + proto.RegisterType((*Config)(nil), "internal.Config") + proto.RegisterType((*AuthConfig)(nil), "internal.AuthConfig") + proto.RegisterType((*BuildInfo)(nil), "internal.BuildInfo") } func init() { proto.RegisterFile("internal.proto", fileDescriptorInternal) } var fileDescriptorInternal = []byte{ - // 1264 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0xbc, 0x57, 0xdf, 0x8e, 0xdb, 0xc4, - 0x17, 0x96, 0xe3, 0x38, 0xb1, 0x4f, 0xb6, 0xfd, 0x55, 0xf3, 0xab, 0xa8, 0x29, 0x12, 0x0a, 0x16, - 0x88, 0x45, 0xd0, 0x05, 0xb5, 0x42, 0x42, 0x5c, 0x20, 0x65, 0x37, 0xa8, 0x2c, 0xfd, 0xb7, 0x9d, - 0x74, 0xcb, 0x15, 0xaa, 0x26, 0xce, 0x49, 0x62, 0xd5, 0xb1, 0xcd, 0xd8, 0xde, 0x8d, 0x79, 0x18, - 0x24, 0x24, 0x9e, 0x00, 0x71, 0xcf, 0x2d, 0xe2, 0x96, 0x77, 0xe0, 0x15, 0xb8, 0x45, 0x67, 0x66, - 0xec, 0x38, 0x9b, 0x50, 0xf5, 0x02, 0x71, 0x37, 0xdf, 0x39, 0x93, 0x33, 0x67, 0xce, 0xf9, 0xce, - 0x37, 0x0e, 0x5c, 0x8f, 0x92, 0x02, 0x65, 0x22, 0xe2, 0xa3, 0x4c, 0xa6, 0x45, 0xca, 0xdc, 0x1a, - 0x07, 0x7f, 0x76, 0xa0, 0x37, 0x49, 0x4b, 0x19, 0x22, 0xbb, 0x0e, 0x9d, 0xd3, 0xb1, 0x6f, 0x0d, - 0xad, 0x43, 0x9b, 0x77, 0x4e, 0xc7, 0x8c, 0x41, 0xf7, 0xb1, 0x58, 0xa1, 0xdf, 0x19, 0x5a, 0x87, - 0x1e, 0x57, 0x6b, 0xb2, 0x3d, 0xab, 0x32, 0xf4, 0x6d, 0x6d, 0xa3, 0x35, 0xbb, 0x0d, 0xee, 0x79, - 0x4e, 0xd1, 0x56, 0xe8, 0x77, 0x95, 0xbd, 0xc1, 0xe4, 0x3b, 0x13, 0x79, 0x7e, 0x99, 0xca, 0x99, - 0xef, 0x68, 0x5f, 0x8d, 0xd9, 0x0d, 0xb0, 0xcf, 0xf9, 0x43, 0xbf, 0xa7, 0xcc, 0xb4, 0x64, 0x3e, - 0xf4, 0xc7, 0x38, 0x17, 0x65, 0x5c, 0xf8, 0xfd, 0xa1, 0x75, 0xe8, 0xf2, 0x1a, 0x52, 0x9c, 0x67, - 0x18, 0xe3, 0x42, 0x8a, 0xb9, 0xef, 0xea, 0x38, 0x35, 0x66, 0x47, 0xc0, 0x4e, 0x93, 0x1c, 0xc3, - 0x52, 0xe2, 0xe4, 0x65, 0x94, 0x3d, 0x47, 0x19, 0xcd, 0x2b, 0xdf, 0x53, 0x01, 0xf6, 0x78, 0xe8, - 0x94, 0x47, 0x58, 0x08, 0x3a, 0x1b, 0x54, 0xa8, 0x1a, 0xb2, 0x00, 0x0e, 0x26, 0x4b, 0x21, 0x71, - 0x36, 0xc1, 0x50, 0x62, 0xe1, 0x0f, 0x94, 0x7b, 0xcb, 0x46, 0x7b, 0x9e, 0xc8, 0x85, 0x48, 0xa2, - 0xef, 0x45, 0x11, 0xa5, 0x89, 0x7f, 0xa0, 0xf7, 0xb4, 0x6d, 0x54, 0x25, 0x9e, 0xc6, 0xe8, 0x5f, - 0xd3, 0x55, 0xa2, 0x75, 0xf0, 0x8b, 0x05, 0xde, 0x58, 0xe4, 0xcb, 0x69, 0x2a, 0xe4, 0xec, 0xb5, - 0x6a, 0x7d, 0x07, 0x9c, 0x10, 0xe3, 0x38, 0xf7, 0xed, 0xa1, 0x7d, 0x38, 0xb8, 0x7b, 0xeb, 0xa8, - 0x69, 0x62, 0x13, 0xe7, 0x04, 0xe3, 0x98, 0xeb, 0x5d, 0xec, 0x13, 0xf0, 0x0a, 0x5c, 0x65, 0xb1, - 0x28, 0x30, 0xf7, 0xbb, 0xea, 0x27, 0x6c, 0xf3, 0x93, 0x67, 0xc6, 0xc5, 0x37, 0x9b, 0x76, 0xae, - 0xe2, 0xec, 0x5e, 0x25, 0xf8, 0xa3, 0x03, 0xd7, 0xb6, 0x8e, 0x63, 0x07, 0x60, 0xad, 0x55, 0xe6, - 0x0e, 0xb7, 0xd6, 0x84, 0x2a, 0x95, 0xb5, 0xc3, 0xad, 0x8a, 0xd0, 0xa5, 0xe2, 0x86, 0xc3, 0xad, - 0x4b, 0x42, 0x4b, 0xc5, 0x08, 0x87, 0x5b, 0x4b, 0xf6, 0x01, 0xf4, 0xbf, 0x2b, 0x51, 0x46, 0x98, - 0xfb, 0x8e, 0xca, 0xee, 0x7f, 0x9b, 0xec, 0x9e, 0x96, 0x28, 0x2b, 0x5e, 0xfb, 0xa9, 0x1a, 0x8a, - 0x4d, 0x9a, 0x1a, 0x6a, 0x4d, 0xb6, 0x82, 0x98, 0xd7, 0xd7, 0x36, 0x5a, 0x9b, 0x2a, 0x6a, 0x3e, - 0x50, 0x15, 0x3f, 0x85, 0xae, 0x58, 0x63, 0xee, 0x7b, 0x2a, 0xfe, 0x3b, 0xff, 0x50, 0xb0, 0xa3, - 0xd1, 0x1a, 0xf3, 0x2f, 0x93, 0x42, 0x56, 0x5c, 0x6d, 0x67, 0xef, 0x43, 0x2f, 0x4c, 0xe3, 0x54, - 0xe6, 0x3e, 0x5c, 0x4d, 0xec, 0x84, 0xec, 0xdc, 0xb8, 0x6f, 0xdf, 0x07, 0xaf, 0xf9, 0x2d, 0xd1, - 0xf7, 0x25, 0x56, 0xaa, 0x12, 0x1e, 0xa7, 0x25, 0x7b, 0x17, 0x9c, 0x0b, 0x11, 0x97, 0xba, 0x8b, - 0x83, 0xbb, 0xd7, 0x37, 0x61, 0x46, 0xeb, 0x28, 0xe7, 0xda, 0xf9, 0x79, 0xe7, 0x33, 0x2b, 0x58, - 0x80, 0xa3, 0x22, 0xb7, 0x78, 0xe0, 0xd5, 0x3c, 0x50, 0xf3, 0xd5, 0x69, 0xcd, 0xd7, 0x0d, 0xb0, - 0xbf, 0xc2, 0xb5, 0x19, 0x39, 0x5a, 0x36, 0x6c, 0xe9, 0xb6, 0xd8, 0x72, 0x13, 0x9c, 0xe7, 0xea, - 0x70, 0xdd, 0x45, 0x0d, 0x82, 0x9f, 0x2d, 0xe8, 0xd2, 0xe1, 0xd4, 0xeb, 0x18, 0x17, 0x22, 0xac, - 0x8e, 0xd3, 0x32, 0x99, 0xe5, 0xbe, 0x35, 0xb4, 0x0f, 0x6d, 0xbe, 0x65, 0x63, 0x6f, 0x40, 0x6f, - 0xaa, 0xbd, 0x9d, 0xa1, 0x7d, 0xe8, 0x71, 0x83, 0x28, 0x74, 0x2c, 0xa6, 0x18, 0x9b, 0x14, 0x34, - 0xa0, 0xdd, 0x99, 0xc4, 0x79, 0xb4, 0x36, 0x69, 0x18, 0x44, 0xf6, 0xbc, 0x9c, 0x93, 0x5d, 0x67, - 0x62, 0x10, 0x25, 0x3d, 0x15, 0x79, 0xd3, 0x54, 0x5a, 0x53, 0xe4, 0x3c, 0x14, 0x71, 0xdd, 0x55, - 0x0d, 0x82, 0x5f, 0x2d, 0x9a, 0x76, 0xcd, 0xd2, 0x9d, 0x0a, 0xbd, 0x09, 0x2e, 0x31, 0xf8, 0xc5, - 0x85, 0x90, 0xa6, 0x4a, 0x7d, 0xc2, 0xcf, 0x85, 0x64, 0x1f, 0x43, 0x4f, 0x95, 0x78, 0xcf, 0xc4, - 0xd4, 0xe1, 0x54, 0x55, 0xb8, 0xd9, 0xd6, 0x70, 0xaa, 0xdb, 0xe2, 0x54, 0x73, 0x59, 0xa7, 0x7d, - 0xd9, 0x3b, 0xe0, 0x10, 0x39, 0x2b, 0x95, 0xfd, 0xde, 0xc8, 0x9a, 0xc2, 0x7a, 0x57, 0x70, 0x0e, - 0xd7, 0xb6, 0x4e, 0x6c, 0x4e, 0xb2, 0xb6, 0x4f, 0xda, 0xd0, 0xc5, 0x33, 0xf4, 0x20, 0xa5, 0xcb, - 0x31, 0xc6, 0xb0, 0xc0, 0x99, 0xaa, 0xb7, 0xcb, 0x1b, 0x1c, 0xfc, 0x68, 0x6d, 0xe2, 0xaa, 0xf3, - 0x48, 0xcb, 0xc2, 0x74, 0xb5, 0x12, 0xc9, 0xcc, 0x84, 0xae, 0x21, 0xd5, 0x6d, 0x36, 0x35, 0xa1, - 0x3b, 0xb3, 0x29, 0x61, 0x99, 0x99, 0x0e, 0x76, 0x64, 0xc6, 0x86, 0x30, 0x58, 0xa1, 0xc8, 0x4b, - 0x89, 0x2b, 0x4c, 0x0a, 0x53, 0x82, 0xb6, 0x89, 0xdd, 0x82, 0x7e, 0x21, 0x16, 0x2f, 0x88, 0xe4, - 0xa6, 0x93, 0x85, 0x58, 0x3c, 0xc0, 0x8a, 0xbd, 0x05, 0xde, 0x3c, 0xc2, 0x78, 0xa6, 0x5c, 0xba, - 0x9d, 0xae, 0x32, 0x3c, 0xc0, 0x2a, 0xf8, 0xcd, 0x82, 0xde, 0x04, 0xe5, 0x05, 0xca, 0xd7, 0x12, - 0xb9, 0xf6, 0xe3, 0x61, 0xbf, 0xe2, 0xf1, 0xe8, 0xee, 0x7f, 0x3c, 0x9c, 0xcd, 0xe3, 0x71, 0x13, - 0x9c, 0x89, 0x0c, 0x4f, 0xc7, 0x2a, 0x23, 0x9b, 0x6b, 0x40, 0x6c, 0x1c, 0x85, 0x45, 0x74, 0x81, - 0xe6, 0x45, 0x31, 0x68, 0x47, 0xfb, 0xdc, 0x3d, 0xda, 0xf7, 0x83, 0x05, 0xbd, 0x87, 0xa2, 0x4a, - 0xcb, 0x62, 0x87, 0x85, 0x43, 0x18, 0x8c, 0xb2, 0x2c, 0x8e, 0x42, 0xfd, 0x6b, 0x7d, 0xa3, 0xb6, - 0x89, 0x76, 0x3c, 0x6a, 0xd5, 0x57, 0xdf, 0xad, 0x6d, 0x22, 0xb9, 0x38, 0x51, 0xfa, 0xae, 0xc5, - 0xba, 0x25, 0x17, 0x5a, 0xd6, 0x95, 0x93, 0x8a, 0x30, 0x2a, 0x8b, 0x74, 0x1e, 0xa7, 0x97, 0xea, - 0xb6, 0x2e, 0x6f, 0x70, 0xf0, 0x7b, 0x07, 0xba, 0xff, 0x95, 0x26, 0x1f, 0x80, 0x15, 0x99, 0x66, - 0x5b, 0x51, 0xa3, 0xd0, 0xfd, 0x96, 0x42, 0xfb, 0xd0, 0xaf, 0xa4, 0x48, 0x16, 0x98, 0xfb, 0xae, - 0x52, 0x97, 0x1a, 0x2a, 0x8f, 0x9a, 0x23, 0x2d, 0xcd, 0x1e, 0xaf, 0x61, 0x33, 0x17, 0xd0, 0x9a, - 0x8b, 0x8f, 0x8c, 0x8a, 0x0f, 0x54, 0x46, 0xfe, 0x76, 0x59, 0xae, 0x8a, 0xf7, 0xbf, 0xa7, 0xc9, - 0x7f, 0x59, 0xe0, 0x34, 0x43, 0x75, 0xb2, 0x3d, 0x54, 0x27, 0x9b, 0xa1, 0x1a, 0x1f, 0xd7, 0x43, - 0x35, 0x3e, 0x26, 0xcc, 0xcf, 0xea, 0xa1, 0xe2, 0x67, 0xd4, 0xac, 0xfb, 0x32, 0x2d, 0xb3, 0xe3, - 0x4a, 0x77, 0xd5, 0xe3, 0x0d, 0x26, 0x26, 0x7e, 0xb3, 0x44, 0x69, 0x4a, 0xed, 0x71, 0x83, 0x88, - 0xb7, 0x0f, 0x95, 0xe0, 0xe8, 0xe2, 0x6a, 0xc0, 0xde, 0x03, 0x87, 0x53, 0xf1, 0x54, 0x85, 0xb7, - 0xfa, 0xa2, 0xcc, 0x5c, 0x7b, 0x29, 0xa8, 0xfe, 0x7a, 0x33, 0x04, 0xae, 0xbf, 0xe5, 0x3e, 0x84, - 0xde, 0x64, 0x19, 0xcd, 0x8b, 0xfa, 0x2d, 0xfc, 0x7f, 0x4b, 0xb0, 0xa2, 0x15, 0x2a, 0x1f, 0x37, - 0x5b, 0x82, 0xa7, 0xe0, 0x35, 0xc6, 0x4d, 0x3a, 0x56, 0x3b, 0x1d, 0x06, 0xdd, 0xf3, 0x24, 0x2a, - 0xea, 0xd1, 0xa5, 0x35, 0x5d, 0xf6, 0x69, 0x29, 0x92, 0x22, 0x2a, 0xaa, 0x7a, 0x74, 0x6b, 0x1c, - 0xdc, 0x33, 0xe9, 0x53, 0xb8, 0xf3, 0x2c, 0x43, 0x69, 0x64, 0x40, 0x03, 0x75, 0x48, 0x7a, 0x89, - 0x5a, 0xc1, 0x6d, 0xae, 0x41, 0xf0, 0x2d, 0x78, 0xa3, 0x18, 0x65, 0xc1, 0xcb, 0x18, 0xf7, 0xbd, - 0x8c, 0x5f, 0x4f, 0x9e, 0x3c, 0xae, 0x33, 0xa0, 0xf5, 0x66, 0xe4, 0xed, 0x2b, 0x23, 0xff, 0x40, - 0x64, 0xe2, 0x74, 0xac, 0x78, 0x6e, 0x73, 0x83, 0x82, 0x9f, 0x2c, 0xe8, 0x92, 0xb6, 0xb4, 0x42, - 0x77, 0x5f, 0xa5, 0x4b, 0x67, 0x32, 0xbd, 0x88, 0x66, 0x28, 0xeb, 0xcb, 0xd5, 0x58, 0x15, 0x3d, - 0x5c, 0x62, 0xf3, 0x00, 0x1b, 0x44, 0x5c, 0xa3, 0x4f, 0xbd, 0x7a, 0x96, 0x5a, 0x5c, 0x23, 0x33, - 0xd7, 0x4e, 0xf6, 0x36, 0xc0, 0xa4, 0xcc, 0x50, 0x8e, 0x66, 0xab, 0x28, 0x51, 0x4d, 0x77, 0x79, - 0xcb, 0x12, 0x7c, 0xa1, 0x3f, 0x1e, 0x77, 0x14, 0xca, 0xda, 0xff, 0xa1, 0x79, 0x35, 0xf3, 0x20, - 0xde, 0xfe, 0xdd, 0x6b, 0xdd, 0x76, 0x08, 0x03, 0xf3, 0xa5, 0xad, 0xbe, 0x5b, 0x8d, 0x58, 0xb5, - 0x4c, 0x74, 0xe7, 0xb3, 0x72, 0x1a, 0x47, 0xa1, 0xba, 0xb3, 0xcb, 0x0d, 0x9a, 0xf6, 0xd4, 0x1f, - 0x8a, 0x7b, 0x7f, 0x07, 0x00, 0x00, 0xff, 0xff, 0xe0, 0xc4, 0x7a, 0x3e, 0x62, 0x0c, 0x00, 0x00, + // 1342 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x57, 0xdd, 0x8e, 0xe3, 0xc4, + 0x12, 0x96, 0xe3, 0x38, 0xb1, 0x2b, 0xb3, 0x7b, 0x56, 0x3e, 0xab, 0xb3, 0x3e, 0x7b, 0xa4, 0xa3, + 0x60, 0x81, 0x08, 0x82, 0x1d, 0xd0, 0xac, 0x90, 0x10, 0x02, 0xa4, 0xcc, 0x04, 0x2d, 0xc3, 0xfe, + 0xcd, 0x76, 0x76, 0x86, 0x2b, 0xb4, 0xea, 0x38, 0x95, 0xc4, 0x5a, 0xc7, 0x36, 0x6d, 0x7b, 0x26, + 0xe6, 0x61, 0x90, 0x90, 0x78, 0x02, 0xc4, 0x3d, 0xb7, 0x88, 0x5b, 0xde, 0x81, 0x57, 0xe0, 0x16, + 0x55, 0x77, 0xfb, 0x27, 0x93, 0xb0, 0xda, 0x0b, 0xc4, 0x5d, 0x7f, 0x55, 0xed, 0xea, 0xfa, 0xf9, + 0xaa, 0xba, 0x0d, 0x37, 0xc3, 0x38, 0x47, 0x11, 0xf3, 0xe8, 0x30, 0x15, 0x49, 0x9e, 0xb8, 0x76, + 0x85, 0xfd, 0xdf, 0x3b, 0xd0, 0x9b, 0x26, 0x85, 0x08, 0xd0, 0xbd, 0x09, 0x9d, 0xd3, 0x89, 0x67, + 0x0c, 0x8d, 0x91, 0xc9, 0x3a, 0xa7, 0x13, 0xd7, 0x85, 0xee, 0x13, 0xbe, 0x46, 0xaf, 0x33, 0x34, + 0x46, 0x0e, 0x93, 0x6b, 0x92, 0x3d, 0x2f, 0x53, 0xf4, 0x4c, 0x25, 0xa3, 0xb5, 0x7b, 0x17, 0xec, + 0xf3, 0x8c, 0xac, 0xad, 0xd1, 0xeb, 0x4a, 0x79, 0x8d, 0x49, 0x77, 0xc6, 0xb3, 0xec, 0x2a, 0x11, + 0x73, 0xcf, 0x52, 0xba, 0x0a, 0xbb, 0xb7, 0xc0, 0x3c, 0x67, 0x8f, 0xbc, 0x9e, 0x14, 0xd3, 0xd2, + 0xf5, 0xa0, 0x3f, 0xc1, 0x05, 0x2f, 0xa2, 0xdc, 0xeb, 0x0f, 0x8d, 0x91, 0xcd, 0x2a, 0x48, 0x76, + 0x9e, 0x63, 0x84, 0x4b, 0xc1, 0x17, 0x9e, 0xad, 0xec, 0x54, 0xd8, 0x3d, 0x04, 0xf7, 0x34, 0xce, + 0x30, 0x28, 0x04, 0x4e, 0x5f, 0x86, 0xe9, 0x05, 0x8a, 0x70, 0x51, 0x7a, 0x8e, 0x34, 0xb0, 0x47, + 0x43, 0xa7, 0x3c, 0xc6, 0x9c, 0xd3, 0xd9, 0x20, 0x4d, 0x55, 0xd0, 0xf5, 0xe1, 0x60, 0xba, 0xe2, + 0x02, 0xe7, 0x53, 0x0c, 0x04, 0xe6, 0xde, 0x40, 0xaa, 0xb7, 0x64, 0xb4, 0xe7, 0xa9, 0x58, 0xf2, + 0x38, 0xfc, 0x96, 0xe7, 0x61, 0x12, 0x7b, 0x07, 0x6a, 0x4f, 0x5b, 0x46, 0x59, 0x62, 0x49, 0x84, + 0xde, 0x0d, 0x95, 0x25, 0x5a, 0xfb, 0x3f, 0x19, 0xe0, 0x4c, 0x78, 0xb6, 0x9a, 0x25, 0x5c, 0xcc, + 0x5f, 0x2b, 0xd7, 0xf7, 0xc0, 0x0a, 0x30, 0x8a, 0x32, 0xcf, 0x1c, 0x9a, 0xa3, 0xc1, 0xd1, 0x9d, + 0xc3, 0xba, 0x88, 0xb5, 0x9d, 0x13, 0x8c, 0x22, 0xa6, 0x76, 0xb9, 0x1f, 0x80, 0x93, 0xe3, 0x3a, + 0x8d, 0x78, 0x8e, 0x99, 0xd7, 0x95, 0x9f, 0xb8, 0xcd, 0x27, 0xcf, 0xb5, 0x8a, 0x35, 0x9b, 0x76, + 0x42, 0xb1, 0x76, 0x43, 0xf1, 0x7f, 0xeb, 0xc0, 0x8d, 0xad, 0xe3, 0xdc, 0x03, 0x30, 0x36, 0xd2, + 0x73, 0x8b, 0x19, 0x1b, 0x42, 0xa5, 0xf4, 0xda, 0x62, 0x46, 0x49, 0xe8, 0x4a, 0x72, 0xc3, 0x62, + 0xc6, 0x15, 0xa1, 0x95, 0x64, 0x84, 0xc5, 0x8c, 0x95, 0xfb, 0x0e, 0xf4, 0xbf, 0x29, 0x50, 0x84, + 0x98, 0x79, 0x96, 0xf4, 0xee, 0x5f, 0x8d, 0x77, 0xcf, 0x0a, 0x14, 0x25, 0xab, 0xf4, 0x94, 0x0d, + 0xc9, 0x26, 0x45, 0x0d, 0xb9, 0x26, 0x59, 0x4e, 0xcc, 0xeb, 0x2b, 0x19, 0xad, 0x75, 0x16, 0x15, + 0x1f, 0x28, 0x8b, 0x1f, 0x42, 0x97, 0x6f, 0x30, 0xf3, 0x1c, 0x69, 0xff, 0x8d, 0xbf, 0x48, 0xd8, + 0xe1, 0x78, 0x83, 0xd9, 0xe7, 0x71, 0x2e, 0x4a, 0x26, 0xb7, 0xbb, 0x6f, 0x43, 0x2f, 0x48, 0xa2, + 0x44, 0x64, 0x1e, 0x5c, 0x77, 0xec, 0x84, 0xe4, 0x4c, 0xab, 0xef, 0x3e, 0x00, 0xa7, 0xfe, 0x96, + 0xe8, 0xfb, 0x12, 0x4b, 0x99, 0x09, 0x87, 0xd1, 0xd2, 0x7d, 0x13, 0xac, 0x4b, 0x1e, 0x15, 0xaa, + 0x8a, 0x83, 0xa3, 0x9b, 0x8d, 0x99, 0xf1, 0x26, 0xcc, 0x98, 0x52, 0x7e, 0xdc, 0xf9, 0xc8, 0xf0, + 0x97, 0x60, 0x49, 0xcb, 0x2d, 0x1e, 0x38, 0x15, 0x0f, 0x64, 0x7f, 0x75, 0x5a, 0xfd, 0x75, 0x0b, + 0xcc, 0x2f, 0x70, 0xa3, 0x5b, 0x8e, 0x96, 0x35, 0x5b, 0xba, 0x2d, 0xb6, 0xdc, 0x06, 0xeb, 0x42, + 0x1e, 0xae, 0xaa, 0xa8, 0x80, 0xff, 0xa3, 0x01, 0x5d, 0x3a, 0x9c, 0x6a, 0x1d, 0xe1, 0x92, 0x07, + 0xe5, 0x71, 0x52, 0xc4, 0xf3, 0xcc, 0x33, 0x86, 0xe6, 0xc8, 0x64, 0x5b, 0x32, 0xf7, 0x3f, 0xd0, + 0x9b, 0x29, 0x6d, 0x67, 0x68, 0x8e, 0x1c, 0xa6, 0x11, 0x99, 0x8e, 0xf8, 0x0c, 0x23, 0xed, 0x82, + 0x02, 0xb4, 0x3b, 0x15, 0xb8, 0x08, 0x37, 0xda, 0x0d, 0x8d, 0x48, 0x9e, 0x15, 0x0b, 0x92, 0x2b, + 0x4f, 0x34, 0x22, 0xa7, 0x67, 0x3c, 0xab, 0x8b, 0x4a, 0x6b, 0xb2, 0x9c, 0x05, 0x3c, 0xaa, 0xaa, + 0xaa, 0x80, 0xff, 0xb3, 0x41, 0xdd, 0xae, 0x58, 0xba, 0x93, 0xa1, 0xff, 0x82, 0x4d, 0x0c, 0x7e, + 0x71, 0xc9, 0x85, 0xce, 0x52, 0x9f, 0xf0, 0x05, 0x17, 0xee, 0xfb, 0xd0, 0x93, 0x29, 0xde, 0xd3, + 0x31, 0x95, 0x39, 0x99, 0x15, 0xa6, 0xb7, 0xd5, 0x9c, 0xea, 0xb6, 0x38, 0x55, 0x07, 0x6b, 0xb5, + 0x83, 0xbd, 0x07, 0x16, 0x91, 0xb3, 0x94, 0xde, 0xef, 0xb5, 0xac, 0x28, 0xac, 0x76, 0xf9, 0xe7, + 0x70, 0x63, 0xeb, 0xc4, 0xfa, 0x24, 0x63, 0xfb, 0xa4, 0x86, 0x2e, 0x8e, 0xa6, 0x07, 0x4d, 0xba, + 0x0c, 0x23, 0x0c, 0x72, 0x9c, 0xcb, 0x7c, 0xdb, 0xac, 0xc6, 0xfe, 0xf7, 0x46, 0x63, 0x57, 0x9e, + 0x47, 0xb3, 0x2c, 0x48, 0xd6, 0x6b, 0x1e, 0xcf, 0xb5, 0xe9, 0x0a, 0x52, 0xde, 0xe6, 0x33, 0x6d, + 0xba, 0x33, 0x9f, 0x11, 0x16, 0xa9, 0xae, 0x60, 0x47, 0xa4, 0xee, 0x10, 0x06, 0x6b, 0xe4, 0x59, + 0x21, 0x70, 0x8d, 0x71, 0xae, 0x53, 0xd0, 0x16, 0xb9, 0x77, 0xa0, 0x9f, 0xf3, 0xe5, 0x0b, 0x22, + 0xb9, 0xae, 0x64, 0xce, 0x97, 0x0f, 0xb1, 0x74, 0xff, 0x07, 0xce, 0x22, 0xc4, 0x68, 0x2e, 0x55, + 0xaa, 0x9c, 0xb6, 0x14, 0x3c, 0xc4, 0xd2, 0xff, 0xc5, 0x80, 0xde, 0x14, 0xc5, 0x25, 0x8a, 0xd7, + 0x1a, 0x72, 0xed, 0xcb, 0xc3, 0x7c, 0xc5, 0xe5, 0xd1, 0xdd, 0x7f, 0x79, 0x58, 0xcd, 0xe5, 0x71, + 0x1b, 0xac, 0xa9, 0x08, 0x4e, 0x27, 0xd2, 0x23, 0x93, 0x29, 0x40, 0x6c, 0x1c, 0x07, 0x79, 0x78, + 0x89, 0xfa, 0x46, 0xd1, 0x68, 0x67, 0xf6, 0xd9, 0x7b, 0x66, 0xdf, 0x77, 0x06, 0xf4, 0x1e, 0xf1, + 0x32, 0x29, 0xf2, 0x1d, 0x16, 0x0e, 0x61, 0x30, 0x4e, 0xd3, 0x28, 0x0c, 0xd4, 0xd7, 0x2a, 0xa2, + 0xb6, 0x88, 0x76, 0x3c, 0x6e, 0xe5, 0x57, 0xc5, 0xd6, 0x16, 0xd1, 0xb8, 0x38, 0x91, 0xf3, 0x5d, + 0x0d, 0xeb, 0xd6, 0xb8, 0x50, 0x63, 0x5d, 0x2a, 0x29, 0x09, 0xe3, 0x22, 0x4f, 0x16, 0x51, 0x72, + 0x25, 0xa3, 0xb5, 0x59, 0x8d, 0xfd, 0x5f, 0x3b, 0xd0, 0xfd, 0xa7, 0x66, 0xf2, 0x01, 0x18, 0xa1, + 0x2e, 0xb6, 0x11, 0xd6, 0x13, 0xba, 0xdf, 0x9a, 0xd0, 0x1e, 0xf4, 0x4b, 0xc1, 0xe3, 0x25, 0x66, + 0x9e, 0x2d, 0xa7, 0x4b, 0x05, 0xa5, 0x46, 0xf6, 0x91, 0x1a, 0xcd, 0x0e, 0xab, 0x60, 0xdd, 0x17, + 0xd0, 0xea, 0x8b, 0xf7, 0xf4, 0x14, 0x1f, 0x48, 0x8f, 0xbc, 0xed, 0xb4, 0x5c, 0x1f, 0xde, 0x7f, + 0xdf, 0x4c, 0xfe, 0xc3, 0x00, 0xab, 0x6e, 0xaa, 0x93, 0xed, 0xa6, 0x3a, 0x69, 0x9a, 0x6a, 0x72, + 0x5c, 0x35, 0xd5, 0xe4, 0x98, 0x30, 0x3b, 0xab, 0x9a, 0x8a, 0x9d, 0x51, 0xb1, 0x1e, 0x88, 0xa4, + 0x48, 0x8f, 0x4b, 0x55, 0x55, 0x87, 0xd5, 0x98, 0x98, 0xf8, 0xd5, 0x0a, 0x85, 0x4e, 0xb5, 0xc3, + 0x34, 0x22, 0xde, 0x3e, 0x92, 0x03, 0x47, 0x25, 0x57, 0x01, 0xf7, 0x2d, 0xb0, 0x18, 0x25, 0x4f, + 0x66, 0x78, 0xab, 0x2e, 0x52, 0xcc, 0x94, 0x96, 0x8c, 0xaa, 0xd7, 0x9b, 0x26, 0x70, 0xf5, 0x96, + 0x7b, 0x17, 0x7a, 0xd3, 0x55, 0xb8, 0xc8, 0xab, 0xbb, 0xf0, 0xdf, 0xad, 0x81, 0x15, 0xae, 0x51, + 0xea, 0x98, 0xde, 0xe2, 0x3f, 0x03, 0xa7, 0x16, 0x36, 0xee, 0x18, 0x6d, 0x77, 0x5c, 0xe8, 0x9e, + 0xc7, 0x61, 0x5e, 0xb5, 0x2e, 0xad, 0x29, 0xd8, 0x67, 0x05, 0x8f, 0xf3, 0x30, 0x2f, 0xab, 0xd6, + 0xad, 0xb0, 0x7f, 0x5f, 0xbb, 0x4f, 0xe6, 0xce, 0xd3, 0x14, 0x85, 0x1e, 0x03, 0x0a, 0xc8, 0x43, + 0x92, 0x2b, 0x54, 0x13, 0xdc, 0x64, 0x0a, 0xf8, 0x5f, 0x83, 0x33, 0x8e, 0x50, 0xe4, 0xac, 0x88, + 0x70, 0xdf, 0xcd, 0xf8, 0xe5, 0xf4, 0xe9, 0x93, 0xca, 0x03, 0x5a, 0x37, 0x2d, 0x6f, 0x5e, 0x6b, + 0xf9, 0x87, 0x3c, 0xe5, 0xa7, 0x13, 0xc9, 0x73, 0x93, 0x69, 0xe4, 0xff, 0x60, 0x40, 0x97, 0x66, + 0x4b, 0xcb, 0x74, 0xf7, 0x55, 0x73, 0xe9, 0x4c, 0x24, 0x97, 0xe1, 0x1c, 0x45, 0x15, 0x5c, 0x85, + 0x65, 0xd2, 0x83, 0x15, 0xd6, 0x17, 0xb0, 0x46, 0xc4, 0x35, 0x7a, 0xea, 0x55, 0xbd, 0xd4, 0xe2, + 0x1a, 0x89, 0x99, 0x52, 0xba, 0xff, 0x07, 0x98, 0x16, 0x29, 0x8a, 0xf1, 0x7c, 0x1d, 0xc6, 0xb2, + 0xe8, 0x36, 0x6b, 0x49, 0xfc, 0xcf, 0xd4, 0xe3, 0x71, 0x67, 0x42, 0x19, 0xfb, 0x1f, 0x9a, 0xd7, + 0x3d, 0xf7, 0xa3, 0xed, 0xef, 0xf6, 0x25, 0x72, 0x27, 0xda, 0x21, 0x0c, 0xf4, 0x4b, 0x5b, 0xbe, + 0x5b, 0xf5, 0xb0, 0x6a, 0x89, 0x28, 0xe6, 0xb3, 0x62, 0x16, 0x85, 0x81, 0x8c, 0xd9, 0x66, 0x1a, + 0xf9, 0x47, 0xd0, 0x3b, 0x49, 0xe2, 0x45, 0xb8, 0x74, 0x47, 0xd0, 0x1d, 0x17, 0xf9, 0x4a, 0x9e, + 0x34, 0x38, 0xba, 0xdd, 0x6a, 0xb4, 0x22, 0x5f, 0xa9, 0x3d, 0x4c, 0xee, 0xf0, 0x3f, 0x01, 0x68, + 0x64, 0xf4, 0x7c, 0x6f, 0xa2, 0x7f, 0x82, 0x57, 0x54, 0xa2, 0x4c, 0x5a, 0xb1, 0xd9, 0x1e, 0x8d, + 0xff, 0x29, 0x38, 0xc7, 0x45, 0x18, 0xcd, 0x4f, 0xe3, 0x45, 0x42, 0xad, 0x7a, 0x81, 0x22, 0x6b, + 0xf2, 0x53, 0x41, 0x72, 0x98, 0xba, 0xb6, 0xe6, 0xac, 0x46, 0xb3, 0x9e, 0xfc, 0x03, 0xba, 0xff, + 0x67, 0x00, 0x00, 0x00, 0xff, 0xff, 0xbe, 0x23, 0x76, 0x8f, 0x13, 0x0d, 0x00, 0x00, } diff --git a/bolt/internal/internal.proto b/bolt/internal/internal.proto index 2e540a64a..87ea5a7f3 100644 --- a/bolt/internal/internal.proto +++ b/bolt/internal/internal.proto @@ -158,12 +158,25 @@ message Role { } message Organization { - uint64 ID = 1; // ID is the unique ID of the organization + string ID = 1; // ID is the unique ID of the organization string Name = 2; // Name is the organization's name string DefaultRole = 3; // DefaultRole is the name of the role that is the default for any users added to the organization bool Public = 4; // Public specifies that users must be explicitly added to the organization } +message Config { + AuthConfig Auth = 1; // Auth is the configuration for options that auth related +} + +message AuthConfig { + bool SuperAdminNewUsers = 1; // SuperAdminNewUsers configuration option that specifies which users will auto become super admin +} + +message BuildInfo { + string Version = 1; // Version is a descriptive git SHA identifier + string Commit = 2; // Commit is an abbreviated SHA +} + // The following is a vim modeline, it autoconfigures vim to have the // appropriate tabbing and whitespace management to edit this file // diff --git a/bolt/organizations.go b/bolt/organizations.go index 2a98769f3..84ee78792 100644 --- a/bolt/organizations.go +++ b/bolt/organizations.go @@ -13,12 +13,14 @@ import ( // Ensure OrganizationsStore implements chronograf.OrganizationsStore. var _ chronograf.OrganizationsStore = &OrganizationsStore{} -// OrganizationsBucket is the bucket where organizations are stored. -var OrganizationsBucket = []byte("OrganizationsV1") +var ( + // OrganizationsBucket is the bucket where organizations are stored. + OrganizationsBucket = []byte("OrganizationsV1") + // DefaultOrganizationID is the ID of the default organization. + DefaultOrganizationID = []byte("default") +) const ( - // DefaultOrganizationID is the ID of the default organization. - DefaultOrganizationID uint64 = 0 // DefaultOrganizationName is the Name of the default organization DefaultOrganizationName string = "Default" // DefaultOrganizationRole is the DefaultRole for the Default organization @@ -40,20 +42,20 @@ func (s *OrganizationsStore) Migrate(ctx context.Context) error { // CreateDefault does a findOrCreate on the default organization func (s *OrganizationsStore) CreateDefault(ctx context.Context) error { o := chronograf.Organization{ - ID: DefaultOrganizationID, + ID: string(DefaultOrganizationID), Name: DefaultOrganizationName, DefaultRole: DefaultOrganizationRole, Public: DefaultOrganizationPublic, } return s.client.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(OrganizationsBucket) - v := b.Get(u64tob(o.ID)) + v := b.Get(DefaultOrganizationID) if v != nil { return nil } if v, err := internal.MarshalOrganization(&o); err != nil { return err - } else if err := b.Put(u64tob(o.ID), v); err != nil { + } else if err := b.Put(DefaultOrganizationID, v); err != nil { return err } @@ -75,7 +77,7 @@ func (s *OrganizationsStore) nameIsUnique(ctx context.Context, name string) bool func (s *OrganizationsStore) DefaultOrganization(ctx context.Context) (*chronograf.Organization, error) { var org chronograf.Organization if err := s.client.db.View(func(tx *bolt.Tx) error { - v := tx.Bucket(OrganizationsBucket).Get(u64tob(DefaultOrganizationID)) + v := tx.Bucket(OrganizationsBucket).Get(DefaultOrganizationID) return internal.UnmarshalOrganization(v, &org) }); err != nil { return nil, err @@ -89,25 +91,23 @@ func (s *OrganizationsStore) Add(ctx context.Context, o *chronograf.Organization if !s.nameIsUnique(ctx, o.Name) { return nil, chronograf.ErrOrganizationAlreadyExists } - if err := s.client.db.Update(func(tx *bolt.Tx) error { + err := s.client.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(OrganizationsBucket) seq, err := b.NextSequence() if err != nil { return err } - o.ID = seq - if v, err := internal.MarshalOrganization(o); err != nil { - return err - } else if err := b.Put(u64tob(seq), v); err != nil { + o.ID = fmt.Sprintf("%d", seq) + + v, err := internal.MarshalOrganization(o) + if err != nil { return err } - return nil - }); err != nil { - return nil, err - } + return b.Put([]byte(o.ID), v) + }) - return o, nil + return o, err } // All returns all known organizations @@ -126,7 +126,7 @@ func (s *OrganizationsStore) All(ctx context.Context) ([]chronograf.Organization // Delete the organization from OrganizationsStore func (s *OrganizationsStore) Delete(ctx context.Context, o *chronograf.Organization) error { - if o.ID == DefaultOrganizationID { + if o.ID == string(DefaultOrganizationID) { return chronograf.ErrCannotDeleteDefaultOrganization } _, err := s.get(ctx, o.ID) @@ -134,19 +134,18 @@ func (s *OrganizationsStore) Delete(ctx context.Context, o *chronograf.Organizat return err } if err := s.client.db.Update(func(tx *bolt.Tx) error { - return tx.Bucket(OrganizationsBucket).Delete(u64tob(o.ID)) + return tx.Bucket(OrganizationsBucket).Delete([]byte(o.ID)) }); err != nil { return err } // Dependent Delete of all resources - org := fmt.Sprintf("%d", o.ID) // Each of the associated organization stores expects organization to be // set on the context. - ctx = context.WithValue(ctx, organizations.ContextKey, org) + ctx = context.WithValue(ctx, organizations.ContextKey, o.ID) - sourcesStore := organizations.NewSourcesStore(s.client.SourcesStore, org) + sourcesStore := organizations.NewSourcesStore(s.client.SourcesStore, o.ID) sources, err := sourcesStore.All(ctx) if err != nil { return err @@ -157,7 +156,7 @@ func (s *OrganizationsStore) Delete(ctx context.Context, o *chronograf.Organizat } } - serversStore := organizations.NewServersStore(s.client.ServersStore, org) + serversStore := organizations.NewServersStore(s.client.ServersStore, o.ID) servers, err := serversStore.All(ctx) if err != nil { return err @@ -168,7 +167,7 @@ func (s *OrganizationsStore) Delete(ctx context.Context, o *chronograf.Organizat } } - dashboardsStore := organizations.NewDashboardsStore(s.client.DashboardsStore, org) + dashboardsStore := organizations.NewDashboardsStore(s.client.DashboardsStore, o.ID) dashboards, err := dashboardsStore.All(ctx) if err != nil { return err @@ -179,7 +178,7 @@ func (s *OrganizationsStore) Delete(ctx context.Context, o *chronograf.Organizat } } - usersStore := organizations.NewUsersStore(s.client.UsersStore, org) + usersStore := organizations.NewUsersStore(s.client.UsersStore, o.ID) users, err := usersStore.All(ctx) if err != nil { return err @@ -193,10 +192,10 @@ func (s *OrganizationsStore) Delete(ctx context.Context, o *chronograf.Organizat return nil } -func (s *OrganizationsStore) get(ctx context.Context, id uint64) (*chronograf.Organization, error) { +func (s *OrganizationsStore) get(ctx context.Context, id string) (*chronograf.Organization, error) { var o chronograf.Organization err := s.client.db.View(func(tx *bolt.Tx) error { - v := tx.Bucket(OrganizationsBucket).Get(u64tob(id)) + v := tx.Bucket(OrganizationsBucket).Get([]byte(id)) if v == nil { return chronograf.ErrOrganizationNotFound } @@ -221,7 +220,6 @@ func (s *OrganizationsStore) each(ctx context.Context, fn func(*chronograf.Organ return nil }) }) - return nil } // Get returns a Organization if the id exists. @@ -270,7 +268,7 @@ func (s *OrganizationsStore) Update(ctx context.Context, o *chronograf.Organizat return s.client.db.Update(func(tx *bolt.Tx) error { if v, err := internal.MarshalOrganization(o); err != nil { return err - } else if err := tx.Bucket(OrganizationsBucket).Put(u64tob(o.ID), v); err != nil { + } else if err := tx.Bucket(OrganizationsBucket).Put([]byte(o.ID), v); err != nil { return err } return nil diff --git a/bolt/organizations_test.go b/bolt/organizations_test.go index 9ef54c3b2..eb30968d3 100644 --- a/bolt/organizations_test.go +++ b/bolt/organizations_test.go @@ -57,10 +57,6 @@ func TestOrganizationsStore_GetWithName(t *testing.T) { if err != nil { t.Fatal(err) } - - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() s := client.OrganizationsStore @@ -103,7 +99,7 @@ func TestOrganizationsStore_GetWithID(t *testing.T) { args: args{ ctx: context.Background(), org: &chronograf.Organization{ - ID: 1234, + ID: "1234", }, }, wantErr: true, @@ -129,10 +125,6 @@ func TestOrganizationsStore_GetWithID(t *testing.T) { if err != nil { t.Fatal(err) } - - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() s := client.OrganizationsStore @@ -188,11 +180,6 @@ func TestOrganizationsStore_All(t *testing.T) { }, }, want: []chronograf.Organization{ - { - Name: bolt.DefaultOrganizationName, - DefaultRole: bolt.DefaultOrganizationRole, - Public: bolt.DefaultOrganizationPublic, - }, { Name: "EE - Evil Empire", DefaultRole: roles.MemberRoleName, @@ -203,6 +190,11 @@ func TestOrganizationsStore_All(t *testing.T) { DefaultRole: roles.EditorRoleName, Public: true, }, + { + Name: bolt.DefaultOrganizationName, + DefaultRole: bolt.DefaultOrganizationRole, + Public: bolt.DefaultOrganizationPublic, + }, }, addFirst: true, }, @@ -214,10 +206,6 @@ func TestOrganizationsStore_All(t *testing.T) { if err != nil { t.Fatal(err) } - - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() s := client.OrganizationsStore @@ -265,7 +253,7 @@ func TestOrganizationsStore_Update(t *testing.T) { args: args{ ctx: context.Background(), initial: &chronograf.Organization{ - ID: 1234, + ID: "1234", Name: "The Okay Place", }, updates: &chronograf.Organization{}, @@ -399,10 +387,8 @@ func TestOrganizationsStore_Update(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() + s := client.OrganizationsStore for _, org := range tt.fields.orgs { @@ -462,7 +448,7 @@ func TestOrganizationStore_Delete(t *testing.T) { args: args{ ctx: context.Background(), org: &chronograf.Organization{ - ID: 10, + ID: "10", }, }, wantErr: true, @@ -483,10 +469,8 @@ func TestOrganizationStore_Delete(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() + s := client.OrganizationsStore if tt.addFirst { @@ -520,10 +504,8 @@ func TestOrganizationStore_DeleteDefaultOrg(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() + s := client.OrganizationsStore defaultOrg, err := s.DefaultOrganization(tt.args.ctx) @@ -574,10 +556,8 @@ func TestOrganizationsStore_Add(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() + s := client.OrganizationsStore for _, org := range tt.fields.orgs { @@ -635,7 +615,7 @@ func TestOrganizationsStore_DefaultOrganization(t *testing.T) { ctx: context.Background(), }, want: &chronograf.Organization{ - ID: bolt.DefaultOrganizationID, + ID: string(bolt.DefaultOrganizationID), Name: bolt.DefaultOrganizationName, DefaultRole: bolt.DefaultOrganizationRole, Public: bolt.DefaultOrganizationPublic, @@ -648,9 +628,6 @@ func TestOrganizationsStore_DefaultOrganization(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() s := client.OrganizationsStore diff --git a/bolt/servers.go b/bolt/servers.go index aef86045c..d3f280b89 100644 --- a/bolt/servers.go +++ b/bolt/servers.go @@ -2,7 +2,6 @@ package bolt import ( "context" - "fmt" "github.com/boltdb/bolt" "github.com/influxdata/chronograf" @@ -32,11 +31,9 @@ func (s *ServersStore) Migrate(ctx context.Context) error { return err } - defaultOrgID := fmt.Sprintf("%d", defaultOrg.ID) - for _, server := range servers { if server.Organization == "" { - server.Organization = defaultOrgID + server.Organization = defaultOrg.ID if err := s.Update(ctx, server); err != nil { return nil } diff --git a/bolt/servers_test.go b/bolt/servers_test.go index e43176aa3..7f8b8e201 100644 --- a/bolt/servers_test.go +++ b/bolt/servers_test.go @@ -14,10 +14,8 @@ func TestServerStore(t *testing.T) { if err != nil { t.Fatal(err) } - if err := c.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer c.Close() + s := c.ServersStore srcs := []chronograf.Server{ diff --git a/bolt/sources.go b/bolt/sources.go index 9a9b52bb5..8656a1b7b 100644 --- a/bolt/sources.go +++ b/bolt/sources.go @@ -2,7 +2,6 @@ package bolt import ( "context" - "fmt" "github.com/boltdb/bolt" "github.com/influxdata/chronograf" @@ -32,11 +31,9 @@ func (s *SourcesStore) Migrate(ctx context.Context) error { return err } - defaultOrgID := fmt.Sprintf("%d", defaultOrg.ID) - for _, source := range sources { if source.Organization == "" { - source.Organization = defaultOrgID + source.Organization = defaultOrg.ID } if source.Role == "" { source.Role = roles.ViewerRoleName diff --git a/bolt/sources_test.go b/bolt/sources_test.go index cde1af98c..f9aab65b5 100644 --- a/bolt/sources_test.go +++ b/bolt/sources_test.go @@ -15,10 +15,8 @@ func TestSourceStore(t *testing.T) { if err != nil { t.Fatal(err) } - if err := c.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer c.Close() + s := c.SourcesStore srcs := []chronograf.Source{ diff --git a/bolt/users_test.go b/bolt/users_test.go index 0386a8b39..aa6e97720 100644 --- a/bolt/users_test.go +++ b/bolt/users_test.go @@ -2,7 +2,6 @@ package bolt_test import ( "context" - "fmt" "testing" "github.com/google/go-cmp/cmp" @@ -62,9 +61,6 @@ func TestUsersStore_GetWithID(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() s := client.UsersStore @@ -134,9 +130,6 @@ func TestUsersStore_GetWithNameProviderScheme(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() s := client.UsersStore @@ -167,9 +160,6 @@ func TestUsersStore_GetInvalid(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() s := client.UsersStore @@ -242,10 +232,8 @@ func TestUsersStore_Add(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() + s := client.UsersStore if tt.args.addFirst { _, _ = s.Add(tt.args.ctx, tt.args.u) @@ -307,16 +295,12 @@ func TestUsersStore_Delete(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() + s := client.UsersStore if tt.addFirst { - var err error - tt.args.user, err = s.Add(tt.args.ctx, tt.args.user) - fmt.Println(err) + tt.args.user, _ = s.Add(tt.args.ctx, tt.args.user) } if err := s.Delete(tt.args.ctx, tt.args.user); (err != nil) != tt.wantErr { t.Errorf("%q. UsersStore.Delete() error = %v, wantErr %v", tt.name, err, tt.wantErr) @@ -408,10 +392,8 @@ func TestUsersStore_Update(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() + s := client.UsersStore if tt.addFirst { @@ -499,10 +481,8 @@ func TestUsersStore_All(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() + s := client.UsersStore if tt.addFirst { @@ -567,10 +547,8 @@ func TestUsersStore_Num(t *testing.T) { if err != nil { t.Fatal(err) } - if err := client.Open(context.TODO()); err != nil { - t.Fatal(err) - } defer client.Close() + s := client.UsersStore for _, u := range tt.users { diff --git a/chronograf.go b/chronograf.go index 411924a4d..29785e3fd 100644 --- a/chronograf.go +++ b/chronograf.go @@ -16,16 +16,20 @@ const ( ErrDashboardNotFound = Error("dashboard not found") ErrUserNotFound = Error("user not found") ErrLayoutInvalid = Error("layout is invalid") + ErrDashboardInvalid = Error("dashboard is invalid") + ErrSourceInvalid = Error("source is invalid") + ErrServerInvalid = Error("server is invalid") ErrAlertNotFound = Error("alert not found") ErrAuthentication = Error("user not authenticated") ErrUninitialized = Error("client uninitialized. Call Open() method") ErrInvalidAxis = Error("Unexpected axis in cell. Valid axes are 'x', 'y', and 'y2'") - ErrInvalidColorType = Error("Invalid color type. Valid color types are 'min', 'max', 'threshold'") + ErrInvalidColorType = Error("Invalid color type. Valid color types are 'min', 'max', 'threshold', 'text', and 'background'") ErrInvalidColor = Error("Invalid color. Accepted color format is #RRGGBB") ErrUserAlreadyExists = Error("user already exists") ErrOrganizationNotFound = Error("organization not found") ErrOrganizationAlreadyExists = Error("organization already exists") ErrCannotDeleteDefaultOrganization = Error("cannot delete default organization") + ErrConfigNotFound = Error("cannot find configuration") ) // Error is a domain error encountered while processing chronograf requests @@ -561,7 +565,7 @@ type LayoutsStore interface { // Organization is a group of resources under a common name type Organization struct { - ID uint64 `json:"id,string"` + ID string `json:"id"` Name string `json:"name"` // DefaultRole is the name of the role that is the default for any users added to the organization DefaultRole string `json:"defaultRole,omitempty"` @@ -575,7 +579,7 @@ type Organization struct { // It is expected that only one of ID or Name will be specified, but will prefer ID over Name if both are specified. type OrganizationQuery struct { // If an ID is provided in the query, the lookup time for an organization will be O(1). - ID *uint64 + ID *string // If Name is provided, the lookup time will be O(n). Name *string } @@ -604,3 +608,48 @@ type OrganizationsStore interface { // DefaultOrganization returns the DefaultOrganization DefaultOrganization(ctx context.Context) (*Organization, error) } + +// AuthConfig is the global application config section for auth parameters + +type AuthConfig struct { + // SuperAdminNewUsers should be true by default to give a seamless upgrade to + // 1.4.0 for legacy users. It means that all new users will by default receive + // SuperAdmin status. If a SuperAdmin wants to change this behavior, they + // can toggle it off via the Chronograf UI, in which case newly authenticating + // users will simply receive whatever role they would otherwise receive. + SuperAdminNewUsers bool `json:"superAdminNewUsers"` +} + +// Config is the global application Config for parameters that can be set via +// API, with different sections, such as Auth +type Config struct { + Auth AuthConfig `json:"auth"` +} + +// ConfigStore is the storage and retrieval of global application Config +type ConfigStore interface { + // Initialize creates the initial configuration + Initialize(context.Context) error + // Get retrieves the whole Config from the ConfigStore + Get(context.Context) (*Config, error) + // Update updates the whole Config in the ConfigStore + Update(context.Context, *Config) error +} + +// BuildInfo is sent to the usage client to track versions and commits +type BuildInfo struct { + Version string + Commit string +} + +// BuildStore is the storage and retrieval of Chronograf build information +type BuildStore interface { + Get(context.Context) (BuildInfo, error) + Update(context.Context, BuildInfo) error +} + +// Environement is the set of front-end exposed environment variables +// that were set on the server +type Environment struct { + TelegrafSystemInterval time.Duration `json:"telegrafSystemInterval"` +} diff --git a/cmd/chronograf/main.go b/cmd/chronograf/main.go index f5731616d..729ca7856 100644 --- a/cmd/chronograf/main.go +++ b/cmd/chronograf/main.go @@ -5,6 +5,7 @@ import ( "log" "os" + "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/server" flags "github.com/jessevdk/go-flags" ) @@ -17,7 +18,7 @@ var ( func main() { srv := server.Server{ - BuildInfo: server.BuildInfo{ + BuildInfo: chronograf.BuildInfo{ Version: version, Commit: commit, }, diff --git a/docs/proto.md b/docs/proto.md index e08cfd0e2..28fb74356 100644 --- a/docs/proto.md +++ b/docs/proto.md @@ -1,4 +1,7 @@ -download a binary here https://github.com/google/protobuf/releases/tag/v3.1.0 +Download the protobuf binary by either: +- `brew install protobuf` +- Download from protobuf [github release](https://github.com/google/protobuf/releases/tag/v3.1.0) and place in your $PATH + run the following 4 commands listed here https://github.com/gogo/protobuf ```sh diff --git a/canned/apps.go b/filestore/apps.go similarity index 99% rename from canned/apps.go rename to filestore/apps.go index f765d2bb9..f9bfac7e7 100644 --- a/canned/apps.go +++ b/filestore/apps.go @@ -1,4 +1,4 @@ -package canned +package filestore import ( "context" diff --git a/canned/apps_test.go b/filestore/apps_test.go similarity index 91% rename from canned/apps_test.go rename to filestore/apps_test.go index 596b99902..c3e2351ed 100644 --- a/canned/apps_test.go +++ b/filestore/apps_test.go @@ -1,4 +1,4 @@ -package canned_test +package filestore_test import ( "context" @@ -13,7 +13,7 @@ import ( "time" "github.com/influxdata/chronograf" - "github.com/influxdata/chronograf/canned" + "github.com/influxdata/chronograf/filestore" clog "github.com/influxdata/chronograf/log" ) @@ -47,7 +47,7 @@ func TestAll(t *testing.T) { apps, _ := MockApps(test.Existing, test.Err) layouts, err := apps.All(context.Background()) if err != test.Err { - t.Errorf("Test %d: Canned all error expected: %v; actual: %v", i, test.Err, err) + t.Errorf("Test %d: apps all error expected: %v; actual: %v", i, test.Err, err) } if !reflect.DeepEqual(layouts, test.Existing) { t.Errorf("Test %d: Layouts should be equal; expected %v; actual %v", i, test.Existing, layouts) @@ -99,7 +99,7 @@ func TestAdd(t *testing.T) { apps, _ := MockApps(test.Existing, test.Err) layout, err := apps.Add(context.Background(), test.Add) if err != test.Err { - t.Errorf("Test %d: Canned add error expected: %v; actual: %v", i, test.Err, err) + t.Errorf("Test %d: apps add error expected: %v; actual: %v", i, test.Err, err) } if layout.ID != test.ExpectedID { @@ -150,7 +150,7 @@ func TestDelete(t *testing.T) { apps, actual := MockApps(test.Existing, test.Err) err := apps.Delete(context.Background(), chronograf.Layout{ID: test.DeleteID}) if err != test.Err { - t.Errorf("Test %d: Canned delete error expected: %v; actual: %v", i, test.Err, err) + t.Errorf("Test %d: apps delete error expected: %v; actual: %v", i, test.Err, err) } if !reflect.DeepEqual(*actual, test.Expected) { t.Errorf("Test %d: Layouts should be equal; expected %v; actual %v", i, test.Expected, actual) @@ -199,7 +199,7 @@ func TestGet(t *testing.T) { apps, _ := MockApps(test.Existing, test.Err) layout, err := apps.Get(context.Background(), test.ID) if err != test.Err { - t.Errorf("Test %d: Canned get error expected: %v; actual: %v", i, test.Err, err) + t.Errorf("Test %d: Layouts get error expected: %v; actual: %v", i, test.Err, err) } if !reflect.DeepEqual(layout, test.Expected) { t.Errorf("Test %d: Layouts should be equal; expected %v; actual %v", i, test.Expected, layout) @@ -261,7 +261,7 @@ func TestUpdate(t *testing.T) { apps, actual := MockApps(test.Existing, test.Err) err := apps.Update(context.Background(), test.Update) if err != test.Err { - t.Errorf("Test %d: Canned get error expected: %v; actual: %v", i, test.Err, err) + t.Errorf("Test %d: Layouts get error expected: %v; actual: %v", i, test.Err, err) } if !reflect.DeepEqual(*actual, test.Expected) { t.Errorf("Test %d: Layouts should be equal; expected %v; actual %v", i, test.Expected, actual) @@ -312,7 +312,7 @@ func (m *MockID) Generate() (string, error) { return strconv.Itoa(m.id), nil } -func MockApps(existing []chronograf.Layout, expected error) (canned.Apps, *map[string]chronograf.Layout) { +func MockApps(existing []chronograf.Layout, expected error) (filestore.Apps, *map[string]chronograf.Layout) { layouts := map[string]chronograf.Layout{} fileName := func(dir string, layout chronograf.Layout) string { return path.Join(dir, layout.ID+".json") @@ -326,11 +326,11 @@ func MockApps(existing []chronograf.Layout, expected error) (canned.Apps, *map[s return chronograf.Layout{}, expected } - if l, ok := layouts[file]; !ok { + l, ok := layouts[file] + if !ok { return chronograf.Layout{}, chronograf.ErrLayoutNotFound - } else { - return l, nil } + return l, nil } create := func(file string, layout chronograf.Layout) error { @@ -346,7 +346,7 @@ func MockApps(existing []chronograf.Layout, expected error) (canned.Apps, *map[s return nil, expected } info := []os.FileInfo{} - for k, _ := range layouts { + for k := range layouts { info = append(info, &MockFileInfo{filepath.Base(k)}) } sort.Sort(MockFileInfos(info)) @@ -364,7 +364,7 @@ func MockApps(existing []chronograf.Layout, expected error) (canned.Apps, *map[s return nil } - return canned.Apps{ + return filestore.Apps{ Dir: dir, Load: load, Filename: fileName, diff --git a/filestore/dashboards.go b/filestore/dashboards.go new file mode 100644 index 000000000..9b8fc6578 --- /dev/null +++ b/filestore/dashboards.go @@ -0,0 +1,210 @@ +package filestore + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path" + "strconv" + + "github.com/influxdata/chronograf" +) + +// DashExt is the the file extension searched for in the directory for dashboard files +const DashExt = ".dashboard" + +var _ chronograf.DashboardsStore = &Dashboards{} + +// Dashboards are JSON dashboards stored in the filesystem +type Dashboards struct { + Dir string // Dir is the directory containing the dashboards. + Load func(string, interface{}) error // Load loads string name and dashbaord passed in as interface + Create func(string, interface{}) error // Create will write dashboard to file. + ReadDir func(dirname string) ([]os.FileInfo, error) // ReadDir reads the directory named by dirname and returns a list of directory entries sorted by filename. + Remove func(name string) error // Remove file + IDs chronograf.ID // IDs generate unique ids for new dashboards + Logger chronograf.Logger +} + +// NewDashboards constructs a dashboard store wrapping a file system directory +func NewDashboards(dir string, ids chronograf.ID, logger chronograf.Logger) chronograf.DashboardsStore { + return &Dashboards{ + Dir: dir, + Load: load, + Create: create, + ReadDir: ioutil.ReadDir, + Remove: os.Remove, + IDs: ids, + Logger: logger, + } +} + +func dashboardFile(dir string, dashboard chronograf.Dashboard) string { + base := fmt.Sprintf("%s%s", dashboard.Name, DashExt) + return path.Join(dir, base) +} + +func load(name string, resource interface{}) error { + octets, err := templatedFromEnv(name) + if err != nil { + return fmt.Errorf("resource %s not found", name) + } + + return json.Unmarshal(octets, resource) +} + +func create(file string, resource interface{}) error { + h, err := os.Create(file) + if err != nil { + return err + } + defer h.Close() + + octets, err := json.MarshalIndent(resource, " ", " ") + if err != nil { + return err + } + + _, err = h.Write(octets) + return err +} + +// All returns all dashboards from the directory +func (d *Dashboards) All(ctx context.Context) ([]chronograf.Dashboard, error) { + files, err := d.ReadDir(d.Dir) + if err != nil { + return nil, err + } + + dashboards := []chronograf.Dashboard{} + for _, file := range files { + if path.Ext(file.Name()) != DashExt { + continue + } + var dashboard chronograf.Dashboard + if err := d.Load(path.Join(d.Dir, file.Name()), &dashboard); err != nil { + continue // We want to load all files we can. + } else { + dashboards = append(dashboards, dashboard) + } + } + return dashboards, nil +} + +// Add creates a new dashboard within the directory +func (d *Dashboards) Add(ctx context.Context, dashboard chronograf.Dashboard) (chronograf.Dashboard, error) { + genID, err := d.IDs.Generate() + if err != nil { + d.Logger. + WithField("component", "dashboard"). + Error("Unable to generate ID") + return chronograf.Dashboard{}, err + } + + id, err := strconv.Atoi(genID) + if err != nil { + d.Logger. + WithField("component", "dashboard"). + Error("Unable to convert ID") + return chronograf.Dashboard{}, err + } + + dashboard.ID = chronograf.DashboardID(id) + + file := dashboardFile(d.Dir, dashboard) + if err = d.Create(file, dashboard); err != nil { + if err == chronograf.ErrDashboardInvalid { + d.Logger. + WithField("component", "dashboard"). + WithField("name", file). + Error("Invalid Dashboard: ", err) + } else { + d.Logger. + WithField("component", "dashboard"). + WithField("name", file). + Error("Unable to write dashboard:", err) + } + return chronograf.Dashboard{}, err + } + return dashboard, nil +} + +// Delete removes a dashboard file from the directory +func (d *Dashboards) Delete(ctx context.Context, dashboard chronograf.Dashboard) error { + _, file, err := d.idToFile(dashboard.ID) + if err != nil { + return err + } + + if err := d.Remove(file); err != nil { + d.Logger. + WithField("component", "dashboard"). + WithField("name", file). + Error("Unable to remove dashboard:", err) + return err + } + return nil +} + +// Get returns a dashboard file from the dashboard directory +func (d *Dashboards) Get(ctx context.Context, id chronograf.DashboardID) (chronograf.Dashboard, error) { + board, file, err := d.idToFile(id) + if err != nil { + if err == chronograf.ErrDashboardNotFound { + d.Logger. + WithField("component", "dashboard"). + WithField("name", file). + Error("Unable to read file") + } else if err == chronograf.ErrDashboardInvalid { + d.Logger. + WithField("component", "dashboard"). + WithField("name", file). + Error("File is not a dashboard") + } + return chronograf.Dashboard{}, err + } + return board, nil +} + +// Update replaces a dashboard from the file system directory +func (d *Dashboards) Update(ctx context.Context, dashboard chronograf.Dashboard) error { + board, _, err := d.idToFile(dashboard.ID) + if err != nil { + return err + } + + if err := d.Delete(ctx, board); err != nil { + return err + } + file := dashboardFile(d.Dir, dashboard) + return d.Create(file, dashboard) +} + +// idToFile takes an id and finds the associated filename +func (d *Dashboards) idToFile(id chronograf.DashboardID) (chronograf.Dashboard, string, error) { + // Because the entire dashboard information is not known at this point, we need + // to try to find the name of the file through matching the ID in the dashboard + // content with the ID passed. + files, err := d.ReadDir(d.Dir) + if err != nil { + return chronograf.Dashboard{}, "", err + } + + for _, f := range files { + if path.Ext(f.Name()) != DashExt { + continue + } + file := path.Join(d.Dir, f.Name()) + var dashboard chronograf.Dashboard + if err := d.Load(file, &dashboard); err != nil { + return chronograf.Dashboard{}, "", err + } + if dashboard.ID == id { + return dashboard, file, nil + } + } + + return chronograf.Dashboard{}, "", chronograf.ErrDashboardNotFound +} diff --git a/filestore/environ.go b/filestore/environ.go new file mode 100644 index 000000000..091e179e8 --- /dev/null +++ b/filestore/environ.go @@ -0,0 +1,24 @@ +package filestore + +import ( + "os" + "strings" +) + +var env map[string]string + +// environ returns a map of all environment variables in the running process +func environ() map[string]string { + if env == nil { + env = make(map[string]string) + envVars := os.Environ() + for _, envVar := range envVars { + kv := strings.SplitN(envVar, "=", 2) + if len(kv) != 2 { + continue + } + env[kv[0]] = kv[1] + } + } + return env +} diff --git a/filestore/environ_test.go b/filestore/environ_test.go new file mode 100644 index 000000000..689484806 --- /dev/null +++ b/filestore/environ_test.go @@ -0,0 +1,29 @@ +package filestore + +import ( + "os" + "testing" +) + +func Test_environ(t *testing.T) { + tests := []struct { + name string + key string + value string + }{ + { + name: "environment variable is returned", + key: "CHRONOGRAF_TEST_ENVIRON", + value: "howdy", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv(tt.key, tt.value) + got := environ() + if v, ok := got[tt.key]; !ok || v != tt.value { + t.Errorf("environ() = %v, want %v", v, tt.value) + } + }) + } +} diff --git a/filestore/kapacitors.go b/filestore/kapacitors.go new file mode 100644 index 000000000..eb4d55576 --- /dev/null +++ b/filestore/kapacitors.go @@ -0,0 +1,184 @@ +package filestore + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path" + "strconv" + + "github.com/influxdata/chronograf" +) + +// KapExt is the the file extension searched for in the directory for kapacitor files +const KapExt = ".kap" + +var _ chronograf.ServersStore = &Kapacitors{} + +// Kapacitors are JSON kapacitors stored in the filesystem +type Kapacitors struct { + Dir string // Dir is the directory containing the kapacitors. + Load func(string, interface{}) error // Load loads string name and dashbaord passed in as interface + Create func(string, interface{}) error // Create will write kapacitor to file. + ReadDir func(dirname string) ([]os.FileInfo, error) // ReadDir reads the directory named by dirname and returns a list of directory entries sorted by filename. + Remove func(name string) error // Remove file + IDs chronograf.ID // IDs generate unique ids for new kapacitors + Logger chronograf.Logger +} + +// NewKapacitors constructs a kapacitor store wrapping a file system directory +func NewKapacitors(dir string, ids chronograf.ID, logger chronograf.Logger) chronograf.ServersStore { + return &Kapacitors{ + Dir: dir, + Load: load, + Create: create, + ReadDir: ioutil.ReadDir, + Remove: os.Remove, + IDs: ids, + Logger: logger, + } +} + +func kapacitorFile(dir string, kapacitor chronograf.Server) string { + base := fmt.Sprintf("%s%s", kapacitor.Name, KapExt) + return path.Join(dir, base) +} + +// All returns all kapacitors from the directory +func (d *Kapacitors) All(ctx context.Context) ([]chronograf.Server, error) { + files, err := d.ReadDir(d.Dir) + if err != nil { + return nil, err + } + + kapacitors := []chronograf.Server{} + for _, file := range files { + if path.Ext(file.Name()) != KapExt { + continue + } + var kapacitor chronograf.Server + if err := d.Load(path.Join(d.Dir, file.Name()), &kapacitor); err != nil { + continue // We want to load all files we can. + } else { + kapacitors = append(kapacitors, kapacitor) + } + } + return kapacitors, nil +} + +// Add creates a new kapacitor within the directory +func (d *Kapacitors) Add(ctx context.Context, kapacitor chronograf.Server) (chronograf.Server, error) { + genID, err := d.IDs.Generate() + if err != nil { + d.Logger. + WithField("component", "kapacitor"). + Error("Unable to generate ID") + return chronograf.Server{}, err + } + + id, err := strconv.Atoi(genID) + if err != nil { + d.Logger. + WithField("component", "kapacitor"). + Error("Unable to convert ID") + return chronograf.Server{}, err + } + + kapacitor.ID = id + + file := kapacitorFile(d.Dir, kapacitor) + if err = d.Create(file, kapacitor); err != nil { + if err == chronograf.ErrServerInvalid { + d.Logger. + WithField("component", "kapacitor"). + WithField("name", file). + Error("Invalid Server: ", err) + } else { + d.Logger. + WithField("component", "kapacitor"). + WithField("name", file). + Error("Unable to write kapacitor:", err) + } + return chronograf.Server{}, err + } + return kapacitor, nil +} + +// Delete removes a kapacitor file from the directory +func (d *Kapacitors) Delete(ctx context.Context, kapacitor chronograf.Server) error { + _, file, err := d.idToFile(kapacitor.ID) + if err != nil { + return err + } + + if err := d.Remove(file); err != nil { + d.Logger. + WithField("component", "kapacitor"). + WithField("name", file). + Error("Unable to remove kapacitor:", err) + return err + } + return nil +} + +// Get returns a kapacitor file from the kapacitor directory +func (d *Kapacitors) Get(ctx context.Context, id int) (chronograf.Server, error) { + board, file, err := d.idToFile(id) + if err != nil { + if err == chronograf.ErrServerNotFound { + d.Logger. + WithField("component", "kapacitor"). + WithField("name", file). + Error("Unable to read file") + } else if err == chronograf.ErrServerInvalid { + d.Logger. + WithField("component", "kapacitor"). + WithField("name", file). + Error("File is not a kapacitor") + } + return chronograf.Server{}, err + } + return board, nil +} + +// Update replaces a kapacitor from the file system directory +func (d *Kapacitors) Update(ctx context.Context, kapacitor chronograf.Server) error { + board, _, err := d.idToFile(kapacitor.ID) + if err != nil { + return err + } + + if err := d.Delete(ctx, board); err != nil { + return err + } + file := kapacitorFile(d.Dir, kapacitor) + return d.Create(file, kapacitor) +} + +// idToFile takes an id and finds the associated filename +func (d *Kapacitors) idToFile(id int) (chronograf.Server, string, error) { + // Because the entire kapacitor information is not known at this point, we need + // to try to find the name of the file through matching the ID in the kapacitor + // content with the ID passed. + files, err := d.ReadDir(d.Dir) + if err != nil { + return chronograf.Server{}, "", err + } + + for _, f := range files { + if path.Ext(f.Name()) != KapExt { + continue + } + file := path.Join(d.Dir, f.Name()) + var kapacitor chronograf.Server + if err := d.Load(file, &kapacitor); err != nil { + return chronograf.Server{}, "", err + } + if kapacitor.ID == id { + return kapacitor, file, nil + } + } + + return chronograf.Server{}, "", chronograf.ErrServerNotFound +} diff --git a/filestore/organizations.go b/filestore/organizations.go new file mode 100644 index 000000000..6231a9696 --- /dev/null +++ b/filestore/organizations.go @@ -0,0 +1,122 @@ +package filestore + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path" + + "github.com/influxdata/chronograf" +) + +// OrgExt is the the file extension searched for in the directory for org files +const OrgExt = ".org" + +var _ chronograf.OrganizationsStore = &Organizations{} + +// Organizations are JSON orgs stored in the filesystem +type Organizations struct { + Dir string // Dir is the directory containing the orgs. + Load func(string, interface{}) error // Load loads string name and org passed in as interface + ReadDir func(dirname string) ([]os.FileInfo, error) // ReadDir reads the directory named by dirname and returns a list of directory entries sorted by filename. + Logger chronograf.Logger +} + +// NewOrganizations constructs a org store wrapping a file system directory +func NewOrganizations(dir string, logger chronograf.Logger) chronograf.OrganizationsStore { + return &Organizations{ + Dir: dir, + Load: load, + ReadDir: ioutil.ReadDir, + Logger: logger, + } +} + +func orgFile(dir string, org chronograf.Organization) string { + base := fmt.Sprintf("%s%s", org.Name, OrgExt) + return path.Join(dir, base) +} + +// All returns all orgs from the directory +func (o *Organizations) All(ctx context.Context) ([]chronograf.Organization, error) { + files, err := o.ReadDir(o.Dir) + if err != nil { + return nil, err + } + + orgs := []chronograf.Organization{} + for _, file := range files { + if path.Ext(file.Name()) != OrgExt { + continue + } + var org chronograf.Organization + if err := o.Load(path.Join(o.Dir, file.Name()), &org); err != nil { + continue // We want to load all files we can. + } else { + orgs = append(orgs, org) + } + } + return orgs, nil +} + +// Get returns a org file from the org directory +func (o *Organizations) Get(ctx context.Context, query chronograf.OrganizationQuery) (*chronograf.Organization, error) { + org, _, err := o.findOrg(query) + return org, err +} + +// Add is not allowed for the filesystem organization store +func (o *Organizations) Add(ctx context.Context, org *chronograf.Organization) (*chronograf.Organization, error) { + return nil, fmt.Errorf("unable to add organizations to the filesystem") +} + +// Delete is not allowed for the filesystem organization store +func (o *Organizations) Delete(ctx context.Context, org *chronograf.Organization) error { + return fmt.Errorf("unable to delete an organization from the filesystem") +} + +// Update is not allowed for the filesystem organization store +func (o *Organizations) Update(ctx context.Context, org *chronograf.Organization) error { + return fmt.Errorf("unable to update organizations on the filesystem") +} + +// CreateDefault is not allowed for the filesystem organization store +func (o *Organizations) CreateDefault(ctx context.Context) error { + return fmt.Errorf("unable to create default organizations on the filesystem") +} + +// DefaultOrganization is not allowed for the filesystem organization store +func (o *Organizations) DefaultOrganization(ctx context.Context) (*chronograf.Organization, error) { + return nil, fmt.Errorf("unable to get default organizations from the filestore") +} + +// findOrg takes an OrganizationQuery and finds the associated filename +func (o *Organizations) findOrg(query chronograf.OrganizationQuery) (*chronograf.Organization, string, error) { + // Because the entire org information is not known at this point, we need + // to try to find the name of the file through matching the ID or name in the org + // content with the ID passed. + files, err := o.ReadDir(o.Dir) + if err != nil { + return nil, "", err + } + + for _, f := range files { + if path.Ext(f.Name()) != OrgExt { + continue + } + file := path.Join(o.Dir, f.Name()) + var org chronograf.Organization + if err := o.Load(file, &org); err != nil { + return nil, "", err + } + if query.ID != nil && org.ID == *query.ID { + return &org, file, nil + } + if query.Name != nil && org.Name == *query.Name { + return &org, file, nil + } + } + + return nil, "", chronograf.ErrOrganizationNotFound +} diff --git a/filestore/sources.go b/filestore/sources.go new file mode 100644 index 000000000..bb374ca3e --- /dev/null +++ b/filestore/sources.go @@ -0,0 +1,184 @@ +package filestore + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path" + "strconv" + + "github.com/influxdata/chronograf" +) + +// SrcExt is the the file extension searched for in the directory for source files +const SrcExt = ".src" + +var _ chronograf.SourcesStore = &Sources{} + +// Sources are JSON sources stored in the filesystem +type Sources struct { + Dir string // Dir is the directory containing the sources. + Load func(string, interface{}) error // Load loads string name and dashbaord passed in as interface + Create func(string, interface{}) error // Create will write source to file. + ReadDir func(dirname string) ([]os.FileInfo, error) // ReadDir reads the directory named by dirname and returns a list of directory entries sorted by filename. + Remove func(name string) error // Remove file + IDs chronograf.ID // IDs generate unique ids for new sources + Logger chronograf.Logger +} + +// NewSources constructs a source store wrapping a file system directory +func NewSources(dir string, ids chronograf.ID, logger chronograf.Logger) chronograf.SourcesStore { + return &Sources{ + Dir: dir, + Load: load, + Create: create, + ReadDir: ioutil.ReadDir, + Remove: os.Remove, + IDs: ids, + Logger: logger, + } +} + +func sourceFile(dir string, source chronograf.Source) string { + base := fmt.Sprintf("%s%s", source.Name, SrcExt) + return path.Join(dir, base) +} + +// All returns all sources from the directory +func (d *Sources) All(ctx context.Context) ([]chronograf.Source, error) { + files, err := d.ReadDir(d.Dir) + if err != nil { + return nil, err + } + + sources := []chronograf.Source{} + for _, file := range files { + if path.Ext(file.Name()) != SrcExt { + continue + } + var source chronograf.Source + if err := d.Load(path.Join(d.Dir, file.Name()), &source); err != nil { + continue // We want to load all files we can. + } else { + sources = append(sources, source) + } + } + return sources, nil +} + +// Add creates a new source within the directory +func (d *Sources) Add(ctx context.Context, source chronograf.Source) (chronograf.Source, error) { + genID, err := d.IDs.Generate() + if err != nil { + d.Logger. + WithField("component", "source"). + Error("Unable to generate ID") + return chronograf.Source{}, err + } + + id, err := strconv.Atoi(genID) + if err != nil { + d.Logger. + WithField("component", "source"). + Error("Unable to convert ID") + return chronograf.Source{}, err + } + + source.ID = id + + file := sourceFile(d.Dir, source) + if err = d.Create(file, source); err != nil { + if err == chronograf.ErrSourceInvalid { + d.Logger. + WithField("component", "source"). + WithField("name", file). + Error("Invalid Source: ", err) + } else { + d.Logger. + WithField("component", "source"). + WithField("name", file). + Error("Unable to write source:", err) + } + return chronograf.Source{}, err + } + return source, nil +} + +// Delete removes a source file from the directory +func (d *Sources) Delete(ctx context.Context, source chronograf.Source) error { + _, file, err := d.idToFile(source.ID) + if err != nil { + return err + } + + if err := d.Remove(file); err != nil { + d.Logger. + WithField("component", "source"). + WithField("name", file). + Error("Unable to remove source:", err) + return err + } + return nil +} + +// Get returns a source file from the source directory +func (d *Sources) Get(ctx context.Context, id int) (chronograf.Source, error) { + board, file, err := d.idToFile(id) + if err != nil { + if err == chronograf.ErrSourceNotFound { + d.Logger. + WithField("component", "source"). + WithField("name", file). + Error("Unable to read file") + } else if err == chronograf.ErrSourceInvalid { + d.Logger. + WithField("component", "source"). + WithField("name", file). + Error("File is not a source") + } + return chronograf.Source{}, err + } + return board, nil +} + +// Update replaces a source from the file system directory +func (d *Sources) Update(ctx context.Context, source chronograf.Source) error { + board, _, err := d.idToFile(source.ID) + if err != nil { + return err + } + + if err := d.Delete(ctx, board); err != nil { + return err + } + file := sourceFile(d.Dir, source) + return d.Create(file, source) +} + +// idToFile takes an id and finds the associated filename +func (d *Sources) idToFile(id int) (chronograf.Source, string, error) { + // Because the entire source information is not known at this point, we need + // to try to find the name of the file through matching the ID in the source + // content with the ID passed. + files, err := d.ReadDir(d.Dir) + if err != nil { + return chronograf.Source{}, "", err + } + + for _, f := range files { + if path.Ext(f.Name()) != SrcExt { + continue + } + file := path.Join(d.Dir, f.Name()) + var source chronograf.Source + if err := d.Load(file, &source); err != nil { + return chronograf.Source{}, "", err + } + if source.ID == id { + return source, file, nil + } + } + + return chronograf.Source{}, "", chronograf.ErrSourceNotFound +} diff --git a/filestore/templates.go b/filestore/templates.go new file mode 100644 index 000000000..fc0e1ffc4 --- /dev/null +++ b/filestore/templates.go @@ -0,0 +1,28 @@ +package filestore + +import ( + "bytes" + "html/template" +) + +// templated returns all files templated using data +func templated(data interface{}, filenames ...string) ([]byte, error) { + t, err := template.ParseFiles(filenames...) + if err != nil { + return nil, err + } + var b bytes.Buffer + // If a key in the file exists but is not in the data we + // immediately fail with a missing key error + err = t.Option("missingkey=error").Execute(&b, data) + if err != nil { + return nil, err + } + + return b.Bytes(), nil +} + +// templatedFromEnv returns all files templated against environment variables +func templatedFromEnv(filenames ...string) ([]byte, error) { + return templated(environ(), filenames...) +} diff --git a/filestore/templates_test.go b/filestore/templates_test.go new file mode 100644 index 000000000..5d5b82f5d --- /dev/null +++ b/filestore/templates_test.go @@ -0,0 +1,64 @@ +package filestore + +import ( + "io/ioutil" + "os" + "reflect" + "testing" +) + +func Test_templated(t *testing.T) { + tests := []struct { + name string + content []string + data interface{} + want []byte + wantErr bool + }{ + { + name: "files with templates are rendered correctly", + content: []string{ + "{{ .MYVAR }}", + }, + data: map[string]string{ + "MYVAR": "howdy", + }, + want: []byte("howdy"), + }, + { + name: "missing key gives an error", + content: []string{ + "{{ .MYVAR }}", + }, + wantErr: true, + }, + { + name: "no files make me an error!", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filenames := make([]string, len(tt.content)) + for i, c := range tt.content { + f, err := ioutil.TempFile("", "") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte(c)); err != nil { + t.Fatal(err) + } + filenames[i] = f.Name() + defer os.Remove(f.Name()) + } + got, err := templated(tt.data, filenames...) + if (err != nil) != tt.wantErr { + t.Errorf("templated() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("templated() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/id/time.go b/id/time.go new file mode 100644 index 000000000..75687c7c8 --- /dev/null +++ b/id/time.go @@ -0,0 +1,25 @@ +package id + +import ( + "strconv" + "time" + + "github.com/influxdata/chronograf" +) + +// tm generates an id based on current time +type tm struct { + Now func() time.Time +} + +// NewTime builds a chronograf.ID generator based on current time +func NewTime() chronograf.ID { + return &tm{ + Now: time.Now, + } +} + +// Generate creates a string based on the current time as an integer +func (i *tm) Generate() (string, error) { + return strconv.Itoa(int(i.Now().Unix())), nil +} diff --git a/id/uuid.go b/id/uuid.go new file mode 100644 index 000000000..09eb1da40 --- /dev/null +++ b/id/uuid.go @@ -0,0 +1,16 @@ +package id + +import ( + "github.com/influxdata/chronograf" + uuid "github.com/satori/go.uuid" +) + +var _ chronograf.ID = &UUID{} + +// UUID generates a V4 uuid +type UUID struct{} + +// Generate creates a UUID v4 string +func (i *UUID) Generate() (string, error) { + return uuid.NewV4().String(), nil +} diff --git a/influx/templates.go b/influx/templates.go index 8c0ad0e28..4dfe76fe1 100644 --- a/influx/templates.go +++ b/influx/templates.go @@ -38,6 +38,12 @@ func RenderTemplate(query string, t chronograf.TemplateVar, now time.Time) (stri if len(t.Values) == 0 { return query, nil } + + // we only need to render the template if the template exists in the query + if !strings.Contains(query, t.Var) { + return query, nil + } + switch t.Values[0].Type { case "tagKey", "fieldKey", "measurement", "database": return strings.Replace(query, t.Var, `"`+t.Values[0].Value+`"`, -1), nil diff --git a/influx/templates_test.go b/influx/templates_test.go index 482a16dc5..c1e4d6277 100644 --- a/influx/templates_test.go +++ b/influx/templates_test.go @@ -62,10 +62,10 @@ func TestTemplateReplace(t *testing.T) { }, { name: "select with parameters and aggregates", - query: `SELECT mean($field) FROM "cpu" WHERE $tag = $value GROUP BY $tag`, + query: `SELECT mean(:field:) FROM "cpu" WHERE :tag: = :value: GROUP BY :tag:`, vars: []chronograf.TemplateVar{ chronograf.TemplateVar{ - Var: "$value", + Var: ":value:", Values: []chronograf.TemplateValue{ { Type: "tagValue", @@ -74,7 +74,7 @@ func TestTemplateReplace(t *testing.T) { }, }, chronograf.TemplateVar{ - Var: "$tag", + Var: ":tag:", Values: []chronograf.TemplateValue{ { Type: "tagKey", @@ -83,7 +83,7 @@ func TestTemplateReplace(t *testing.T) { }, }, chronograf.TemplateVar{ - Var: "$field", + Var: ":field:", Values: []chronograf.TemplateValue{ { Type: "fieldKey", @@ -96,25 +96,25 @@ func TestTemplateReplace(t *testing.T) { }, { name: "Non-existant parameters", - query: `SELECT $field FROM "cpu"`, - want: `SELECT $field FROM "cpu"`, + query: `SELECT :field: FROM "cpu"`, + want: `SELECT :field: FROM "cpu"`, }, { name: "var without a value", - query: `SELECT $field FROM "cpu"`, + query: `SELECT :field: FROM "cpu"`, vars: []chronograf.TemplateVar{ chronograf.TemplateVar{ - Var: "$field", + Var: ":field:", }, }, - want: `SELECT $field FROM "cpu"`, + want: `SELECT :field: FROM "cpu"`, }, { name: "var with unknown type", - query: `SELECT $field FROM "cpu"`, + query: `SELECT :field: FROM "cpu"`, vars: []chronograf.TemplateVar{ chronograf.TemplateVar{ - Var: "$field", + Var: ":field:", Values: []chronograf.TemplateValue{ { Type: "who knows?", @@ -123,7 +123,7 @@ func TestTemplateReplace(t *testing.T) { }, }, }, - want: `SELECT $field FROM "cpu"`, + want: `SELECT :field: FROM "cpu"`, }, { name: "auto group by", @@ -224,6 +224,71 @@ func TestTemplateReplace(t *testing.T) { }, want: `SELECT mean(usage_idle) FROM "cpu" WHERE time > now() - 1h GROUP BY time(93s)`, }, + { + name: "no template variables specified", + query: `SELECT mean(usage_idle) FROM "cpu" WHERE time > :dashboardTime: GROUP BY :interval:`, + want: `SELECT mean(usage_idle) FROM "cpu" WHERE time > :dashboardTime: GROUP BY :interval:`, + }, + { + name: "auto group by failing condition", + query: `SELECT mean(usage_idle) FROM "cpu" WHERE time > :dashboardTime: GROUP BY :interval:`, + vars: []chronograf.TemplateVar{ + { + Var: ":interval:", + Values: []chronograf.TemplateValue{ + { + Value: "115", + Type: "resolution", + }, + { + Value: "3", + Type: "pointsPerPixel", + }, + }, + }, + { + Var: ":dashboardTime:", + Values: []chronograf.TemplateValue{ + { + Value: "now() - 1h", + Type: "constant", + Selected: true, + }, + }, + }, + }, + want: `SELECT mean(usage_idle) FROM "cpu" WHERE time > now() - 1h GROUP BY time(93s)`, + }, + { + name: "query with no template variables contained should return query", + query: `SHOW DATABASES`, + vars: []chronograf.TemplateVar{ + { + Var: ":interval:", + Values: []chronograf.TemplateValue{ + { + Value: "115", + Type: "resolution", + }, + { + Value: "3", + Type: "pointsPerPixel", + }, + }, + }, + { + Var: ":dashboardTime:", + Values: []chronograf.TemplateValue{ + { + Value: "now() - 1h", + Type: "constant", + Selected: true, + }, + }, + }, + }, + want: `SHOW DATABASES`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -392,11 +457,16 @@ func Test_RenderTemplate(t *testing.T) { want: "SELECT mean(usage_idle) FROM cpu WHERE time > '1985-10-25T00:01:00Z' and time < '1985-10-25T00:02:00Z' GROUP BY time(179ms)", }, { - name: "absolute time with nano seconds and zero duraiton", + name: "absolute time with nano seconds and zero duration", query: "SELECT mean(usage_idle) FROM cpu WHERE time > '2017-07-24T15:33:42.994Z' and time < '2017-07-24T15:33:42.994Z' GROUP BY :interval:", resolution: 1000, want: "SELECT mean(usage_idle) FROM cpu WHERE time > '2017-07-24T15:33:42.994Z' and time < '2017-07-24T15:33:42.994Z' GROUP BY time(1ms)", }, + { + name: "query should be returned if there are no template variables", + query: "SHOW DATABASES", + want: "SHOW DATABASES", + }, } for _, tt := range gbvTests { @@ -426,5 +496,3 @@ func Test_RenderTemplate(t *testing.T) { }) } } - -// SELECT mean("numSeries") AS "mean_numSeries" FROM "_internal"."monitor"."database" WHERE time > now() - 1h GROUP BY :interval: FILL(null);SELECT mean("numSeries") AS "mean_numSeries_shifted__1__h" FROM "_internal"."monitor"."database" WHERE time > now() - 1h - 1h AND time < now() - 1h GROUP BY :interval: FILL(null) diff --git a/integrations/server_test.go b/integrations/server_test.go index 22e1bc149..1cc0fe2ef 100644 --- a/integrations/server_test.go +++ b/integrations/server_test.go @@ -10,13 +10,16 @@ import ( "encoding/json" "fmt" "io/ioutil" + "net/http" "testing" "time" "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/bolt" + "github.com/influxdata/chronograf/log" "github.com/influxdata/chronograf/oauth2" + "github.com/influxdata/chronograf/roles" "github.com/influxdata/chronograf/server" ) @@ -28,6 +31,7 @@ func TestServer(t *testing.T) { Servers []chronograf.Server Layouts []chronograf.Layout Dashboards []chronograf.Dashboard + Config *chronograf.Config } type args struct { server *server.Server @@ -49,6 +53,840 @@ func TestServer(t *testing.T) { args args wants wants }{ + { + name: "GET /sources/5000", + subName: "Get specific source; including Canned source", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + { + Name: "viewer", + Organization: "howdy", // from canned testdata + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/sources/5000", + principal: oauth2.Principal{ + Organization: "howdy", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "id": "5000", + "name": "Influx 1", + "type": "influx-enterprise", + "username": "user1", + "url": "http://localhost:8086", + "metaUrl": "http://metaurl.com", + "default": true, + "telegraf": "telegraf", + "organization": "howdy", + "links": { + "self": "/chronograf/v1/sources/5000", + "kapacitors": "/chronograf/v1/sources/5000/kapacitors", + "proxy": "/chronograf/v1/sources/5000/proxy", + "queries": "/chronograf/v1/sources/5000/queries", + "write": "/chronograf/v1/sources/5000/write", + "permissions": "/chronograf/v1/sources/5000/permissions", + "users": "/chronograf/v1/sources/5000/users", + "roles": "/chronograf/v1/sources/5000/roles", + "databases": "/chronograf/v1/sources/5000/dbs" + } +} +`, + }, + }, + { + name: "GET /sources/5000/kapacitors/5000", + subName: "Get specific kapacitors; including Canned kapacitors", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + { + Name: "viewer", + Organization: "howdy", // from canned testdata + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/sources/5000/kapacitors/5000", + principal: oauth2.Principal{ + Organization: "howdy", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "id": "5000", + "name": "Kapa 1", + "url": "http://localhost:9092", + "active": true, + "links": { + "proxy": "/chronograf/v1/sources/5000/kapacitors/5000/proxy", + "self": "/chronograf/v1/sources/5000/kapacitors/5000", + "rules": "/chronograf/v1/sources/5000/kapacitors/5000/rules", + "tasks": "/chronograf/v1/sources/5000/kapacitors/5000/proxy?path=/kapacitor/v1/tasks", + "ping": "/chronograf/v1/sources/5000/kapacitors/5000/proxy?path=/kapacitor/v1/ping" + } +} +`, + }, + }, + { + name: "GET /sources/5000/kapacitors", + subName: "Get all kapacitors; including Canned kapacitors", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + { + Name: "viewer", + Organization: "howdy", // from canned testdata + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/sources/5000/kapacitors", + principal: oauth2.Principal{ + Organization: "howdy", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "kapacitors": [ + { + "id": "5000", + "name": "Kapa 1", + "url": "http://localhost:9092", + "active": true, + "links": { + "proxy": "/chronograf/v1/sources/5000/kapacitors/5000/proxy", + "self": "/chronograf/v1/sources/5000/kapacitors/5000", + "rules": "/chronograf/v1/sources/5000/kapacitors/5000/rules", + "tasks": "/chronograf/v1/sources/5000/kapacitors/5000/proxy?path=/kapacitor/v1/tasks", + "ping": "/chronograf/v1/sources/5000/kapacitors/5000/proxy?path=/kapacitor/v1/ping" + } + } + ] +} +`, + }, + }, + { + name: "GET /sources", + subName: "Get all sources; including Canned sources", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + { + Name: "viewer", + Organization: "howdy", // from canned testdata + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/sources", + principal: oauth2.Principal{ + Organization: "howdy", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "sources": [ + { + "id": "5000", + "name": "Influx 1", + "type": "influx-enterprise", + "username": "user1", + "url": "http://localhost:8086", + "metaUrl": "http://metaurl.com", + "default": true, + "telegraf": "telegraf", + "organization": "howdy", + "links": { + "self": "/chronograf/v1/sources/5000", + "kapacitors": "/chronograf/v1/sources/5000/kapacitors", + "proxy": "/chronograf/v1/sources/5000/proxy", + "queries": "/chronograf/v1/sources/5000/queries", + "write": "/chronograf/v1/sources/5000/write", + "permissions": "/chronograf/v1/sources/5000/permissions", + "users": "/chronograf/v1/sources/5000/users", + "roles": "/chronograf/v1/sources/5000/roles", + "databases": "/chronograf/v1/sources/5000/dbs" + } + } + ] +} +`, + }, + }, + { + name: "GET /organizations", + subName: "Get all organizations; including Canned organization", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/organizations", + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/organizations" + }, + "organizations": [ + { + "links": { + "self": "/chronograf/v1/organizations/default" + }, + "id": "default", + "name": "Default", + "defaultRole": "member", + "public": true + }, + { + "links": { + "self": "/chronograf/v1/organizations/howdy" + }, + "id": "howdy", + "name": "An Organization", + "defaultRole": "viewer", + "public": false + } + ] +}`, + }, + }, + { + name: "GET /organizations/howdy", + subName: "Get specific organizations; Canned organization", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/organizations/howdy", + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/organizations/howdy" + }, + "id": "howdy", + "name": "An Organization", + "defaultRole": "viewer", + "public": false +}`, + }, + }, + { + name: "GET /dashboards/1000", + subName: "Get specific in the howdy organization; Using Canned testdata", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "howdy", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/dashboards/1000", + principal: oauth2.Principal{ + Organization: "howdy", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "id": 1000, + "cells": [ + { + "i": "8f61c619-dd9b-4761-8aa8-577f27247093", + "x": 0, + "y": 0, + "w": 11, + "h": 5, + "name": "Untitled Cell", + "queries": [ + { + "query": "SELECT mean(\"value\") AS \"mean_value\" FROM \"telegraf\".\"autogen\".\"cpg\" WHERE time > :dashboardTime: GROUP BY :interval: FILL(null)", + "queryConfig": { + "database": "telegraf", + "measurement": "cpg", + "retentionPolicy": "autogen", + "fields": [ + { + "value": "mean", + "type": "func", + "alias": "mean_value", + "args": [ + { + "value": "value", + "type": "field", + "alias": "" + } + ] + } + ], + "tags": {}, + "groupBy": { + "time": "auto", + "tags": [] + }, + "areTagsAccepted": false, + "fill": "null", + "rawText": null, + "range": null, + "shifts": null + }, + "source": "/chronograf/v1/sources/2" + } + ], + "axes": { + "x": { + "bounds": [], + "label": "", + "prefix": "", + "suffix": "", + "base": "10", + "scale": "linear" + }, + "y": { + "bounds": [], + "label": "", + "prefix": "", + "suffix": "", + "base": "10", + "scale": "linear" + }, + "y2": { + "bounds": [], + "label": "", + "prefix": "", + "suffix": "", + "base": "10", + "scale": "linear" + } + }, + "type": "line", + "colors": [ + { + "id": "0", + "type": "min", + "hex": "#00C9FF", + "name": "laser", + "value": "0" + }, + { + "id": "1", + "type": "max", + "hex": "#9394FF", + "name": "comet", + "value": "100" + } + ], + "links": { + "self": "/chronograf/v1/dashboards/1000/cells/8f61c619-dd9b-4761-8aa8-577f27247093" + } + } + ], + "templates": [ + { + "tempVar": ":dbs:", + "values": [ + { + "value": "_internal", + "type": "database", + "selected": true + }, + { + "value": "telegraf", + "type": "database", + "selected": false + }, + { + "value": "tensorflowdb", + "type": "database", + "selected": false + }, + { + "value": "pushgateway", + "type": "database", + "selected": false + }, + { + "value": "node_exporter", + "type": "database", + "selected": false + }, + { + "value": "mydb", + "type": "database", + "selected": false + }, + { + "value": "tiny", + "type": "database", + "selected": false + }, + { + "value": "blah", + "type": "database", + "selected": false + }, + { + "value": "test", + "type": "database", + "selected": false + }, + { + "value": "chronograf", + "type": "database", + "selected": false + }, + { + "value": "db_name", + "type": "database", + "selected": false + }, + { + "value": "demo", + "type": "database", + "selected": false + }, + { + "value": "eeg", + "type": "database", + "selected": false + }, + { + "value": "solaredge", + "type": "database", + "selected": false + }, + { + "value": "zipkin", + "type": "database", + "selected": false + } + ], + "id": "e7e498bf-5869-4874-9071-24628a2cda63", + "type": "databases", + "label": "", + "query": { + "influxql": "SHOW DATABASES", + "measurement": "", + "tagKey": "", + "fieldKey": "" + }, + "links": { + "self": "/chronograf/v1/dashboards/1000/templates/e7e498bf-5869-4874-9071-24628a2cda63" + } + } + ], + "name": "Name This Dashboard", + "organization": "howdy", + "links": { + "self": "/chronograf/v1/dashboards/1000", + "cells": "/chronograf/v1/dashboards/1000/cells", + "templates": "/chronograf/v1/dashboards/1000/templates" + } +}`, + }, + }, + { + name: "GET /dashboards", + subName: "Get all dashboards in the howdy organization; Using Canned testdata", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + { + Name: "admin", + Organization: "howdy", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/dashboards", + principal: oauth2.Principal{ + Organization: "howdy", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "dashboards": [ + { + "id": 1000, + "cells": [ + { + "i": "8f61c619-dd9b-4761-8aa8-577f27247093", + "x": 0, + "y": 0, + "w": 11, + "h": 5, + "name": "Untitled Cell", + "queries": [ + { + "query": "SELECT mean(\"value\") AS \"mean_value\" FROM \"telegraf\".\"autogen\".\"cpg\" WHERE time > :dashboardTime: GROUP BY :interval: FILL(null)", + "queryConfig": { + "database": "telegraf", + "measurement": "cpg", + "retentionPolicy": "autogen", + "fields": [ + { + "value": "mean", + "type": "func", + "alias": "mean_value", + "args": [ + { + "value": "value", + "type": "field", + "alias": "" + } + ] + } + ], + "tags": {}, + "groupBy": { + "time": "auto", + "tags": [] + }, + "areTagsAccepted": false, + "fill": "null", + "rawText": null, + "range": null, + "shifts": null + }, + "source": "/chronograf/v1/sources/2" + } + ], + "axes": { + "x": { + "bounds": [], + "label": "", + "prefix": "", + "suffix": "", + "base": "10", + "scale": "linear" + }, + "y": { + "bounds": [], + "label": "", + "prefix": "", + "suffix": "", + "base": "10", + "scale": "linear" + }, + "y2": { + "bounds": [], + "label": "", + "prefix": "", + "suffix": "", + "base": "10", + "scale": "linear" + } + }, + "type": "line", + "colors": [ + { + "id": "0", + "type": "min", + "hex": "#00C9FF", + "name": "laser", + "value": "0" + }, + { + "id": "1", + "type": "max", + "hex": "#9394FF", + "name": "comet", + "value": "100" + } + ], + "links": { + "self": "/chronograf/v1/dashboards/1000/cells/8f61c619-dd9b-4761-8aa8-577f27247093" + } + } + ], + "templates": [ + { + "tempVar": ":dbs:", + "values": [ + { + "value": "_internal", + "type": "database", + "selected": true + }, + { + "value": "telegraf", + "type": "database", + "selected": false + }, + { + "value": "tensorflowdb", + "type": "database", + "selected": false + }, + { + "value": "pushgateway", + "type": "database", + "selected": false + }, + { + "value": "node_exporter", + "type": "database", + "selected": false + }, + { + "value": "mydb", + "type": "database", + "selected": false + }, + { + "value": "tiny", + "type": "database", + "selected": false + }, + { + "value": "blah", + "type": "database", + "selected": false + }, + { + "value": "test", + "type": "database", + "selected": false + }, + { + "value": "chronograf", + "type": "database", + "selected": false + }, + { + "value": "db_name", + "type": "database", + "selected": false + }, + { + "value": "demo", + "type": "database", + "selected": false + }, + { + "value": "eeg", + "type": "database", + "selected": false + }, + { + "value": "solaredge", + "type": "database", + "selected": false + }, + { + "value": "zipkin", + "type": "database", + "selected": false + } + ], + "id": "e7e498bf-5869-4874-9071-24628a2cda63", + "type": "databases", + "label": "", + "query": { + "influxql": "SHOW DATABASES", + "measurement": "", + "tagKey": "", + "fieldKey": "" + }, + "links": { + "self": "/chronograf/v1/dashboards/1000/templates/e7e498bf-5869-4874-9071-24628a2cda63" + } + } + ], + "name": "Name This Dashboard", + "organization": "howdy", + "links": { + "self": "/chronograf/v1/dashboards/1000", + "cells": "/chronograf/v1/dashboards/1000/cells", + "templates": "/chronograf/v1/dashboards/1000/templates" + } + } + ] +}`, + }, + }, { name: "GET /users", subName: "User Not Found in the Default Organization", @@ -63,7 +901,7 @@ func TestServer(t *testing.T) { method: "GET", path: "/chronograf/v1/users", principal: oauth2.Principal{ - Organization: "0", + Organization: "default", Subject: "billibob", Issuer: "github", }, @@ -87,7 +925,7 @@ func TestServer(t *testing.T) { Roles: []chronograf.Role{ { Name: "admin", - Organization: "0", + Organization: "default", }, }, }, @@ -101,7 +939,7 @@ func TestServer(t *testing.T) { method: "GET", path: "/chronograf/v1/users", principal: oauth2.Principal{ - Organization: "0", + Organization: "default", Subject: "billibob", Issuer: "github", }, @@ -109,29 +947,501 @@ func TestServer(t *testing.T) { wants: wants{ statusCode: 200, body: ` +{ + "links": { + "self": "/chronograf/v1/users" + }, + "users": [ + { + "links": { + "self": "/chronograf/v1/users/1" + }, + "id": "1", + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "admin", + "organization": "default" + } + ] + } + ] +}`, + }, + }, + { + name: "POST /users", + subName: "Create a New User with SuperAdmin status; SuperAdminNewUsers is true (the default case); User on Principal is a SuperAdmin", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + Users: []chronograf.User{ { - "links": { - "self": "/chronograf/v1/users" - }, - "users": [ - { - "links": { - "self": "/chronograf/v1/users/1" - }, - "id": "1", - "name": "billibob", - "provider": "github", - "scheme": "oauth2", - "superAdmin": true, - "roles": [ - { - "name": "admin", - "organization": "0" - } - ] - } - ] - }`, + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "POST", + path: "/chronograf/v1/users", + payload: &chronograf.User{ + Name: "user", + Provider: "provider", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "default", + }, + }, + }, + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 201, + body: ` +{ + "links": { + "self": "/chronograf/v1/users/2" + }, + "id": "2", + "name": "user", + "provider": "provider", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "editor", + "organization": "default" + } + ] +}`, + }, + }, + { + name: "POST /users", + subName: "Create a New User with SuperAdmin status; SuperAdminNewUsers is false; User on Principal is a SuperAdmin", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "POST", + path: "/chronograf/v1/users", + payload: &chronograf.User{ + Name: "user", + Provider: "provider", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "default", + }, + }, + }, + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 201, + body: ` +{ + "links": { + "self": "/chronograf/v1/users/2" + }, + "id": "2", + "name": "user", + "provider": "provider", + "scheme": "oauth2", + "superAdmin": false, + "roles": [ + { + "name": "editor", + "organization": "default" + } + ] +}`, + }, + }, + { + name: "POST /users", + subName: "Create a New User with SuperAdmin status; SuperAdminNewUsers is false; User on Principal is Admin, but not a SuperAdmin", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "POST", + path: "/chronograf/v1/users", + payload: &chronograf.User{ + Name: "user", + Provider: "provider", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "default", + }, + }, + }, + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 201, + body: ` +{ + "links": { + "self": "/chronograf/v1/users/2" + }, + "id": "2", + "name": "user", + "provider": "provider", + "scheme": "oauth2", + "superAdmin": false, + "roles": [ + { + "name": "editor", + "organization": "default" + } + ] +}`, + }, + }, + { + name: "POST /users", + subName: "Create a New User with SuperAdmin status; SuperAdminNewUsers is true; User on Principal is Admin, but not a SuperAdmin", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "POST", + path: "/chronograf/v1/users", + payload: &chronograf.User{ + Name: "user", + Provider: "provider", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "default", + }, + }, + }, + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 401, + body: ` +{ + "code": 401, + "message": "User does not have authorization required to set SuperAdmin status. See https://github.com/influxdata/chronograf/issues/2601 for more information." +}`, + }, + }, + { + name: "PATCH /users/1", + subName: "SuperAdmin modifying their own status", + fields: fields{ + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "PATCH", + path: "/chronograf/v1/users/1", + payload: map[string]interface{}{ + "id": "1", + "superAdmin": false, + "roles": []interface{}{ + map[string]interface{}{ + "name": "admin", + "organization": "default", + }, + }, + }, + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: http.StatusUnauthorized, + body: ` +{ + "code": 401, + "message": "user cannot modify their own SuperAdmin status" +} +`, + }, + }, + { + name: "PUT /me", + subName: "Change SuperAdmins current organization to org they dont belong to", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "Sweet", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "PUT", + path: "/chronograf/v1/me", + payload: map[string]string{ + "organization": "1", + }, + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "id": "1", + "name": "billibob", + "roles": [ + { + "name": "admin", + "organization": "default" + }, + { + "name": "viewer", + "organization": "1" + } + ], + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "links": { + "self": "/chronograf/v1/users/1" + }, + "organizations": [ + { + "id": "1", + "name": "Sweet", + "defaultRole": "viewer", + "public": false + }, + { + "id": "default", + "name": "Default", + "defaultRole": "member", + "public": true + } + ], + "currentOrganization": { + "id": "1", + "name": "Sweet", + "defaultRole": "viewer", + "public": false + } +}`, + }, + }, + { + name: "PUT /me", + subName: "Change Admin current organization to org they dont belong to", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "Sweet", + DefaultRole: roles.ViewerRoleName, + }, + }, + Users: []chronograf.User{ + { + ID: 1, // This is artificial, but should be reflective of the users actual ID + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "PUT", + path: "/chronograf/v1/me", + payload: map[string]string{ + "organization": "1", + }, + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 403, + body: ` + { + "code": 403, + "message": "user not found" +}`, }, }, } @@ -145,6 +1455,9 @@ func TestServer(t *testing.T) { tt.args.server.Host = host tt.args.server.Port = port + // Use testdata directory for the canned data + tt.args.server.CannedPath = "testdata" + // This is so that we can use staticly generate jwts tt.args.server.TokenSecret = "secret" @@ -154,7 +1467,20 @@ func TestServer(t *testing.T) { // Prepopulate BoltDB Database for Server boltdb := bolt.NewClient() boltdb.Path = boltFile - _ = boltdb.Open(ctx) + + logger := log.New(log.ParseLevel("debug")) + build := chronograf.BuildInfo{ + Version: "pre-1.4.0.0", + Commit: "", + } + _ = boltdb.Open(ctx, logger, build) + + if tt.fields.Config != nil { + if err := boltdb.ConfigStore.Update(ctx, tt.fields.Config); err != nil { + t.Fatalf("failed to update global application config %v", err) + return + } + } // Populate Organizations for i, organization := range tt.fields.Organizations { @@ -222,7 +1548,7 @@ func TestServer(t *testing.T) { serverURL := fmt.Sprintf("http://%v:%v%v", host, port, tt.args.path) // Wait for the server to come online - timeout := time.Now().Add(100 * time.Millisecond) + timeout := time.Now().Add(5 * time.Second) for { _, err := http.Get(serverURL + "/swagger.json") if err == nil { diff --git a/integrations/testdata/example.kap b/integrations/testdata/example.kap new file mode 100644 index 000000000..fa05b025d --- /dev/null +++ b/integrations/testdata/example.kap @@ -0,0 +1,8 @@ +{ + "id": 5000, + "srcID": 5000, + "name": "Kapa 1", + "url": "http://localhost:9092", + "active": true, + "organization": "howdy" +} diff --git a/integrations/testdata/example.org b/integrations/testdata/example.org new file mode 100644 index 000000000..21031e50b --- /dev/null +++ b/integrations/testdata/example.org @@ -0,0 +1,5 @@ +{ + "id": "howdy", + "name": "An Organization", + "defaultRole": "viewer" +} diff --git a/integrations/testdata/example.src b/integrations/testdata/example.src new file mode 100644 index 000000000..2e92c7fc6 --- /dev/null +++ b/integrations/testdata/example.src @@ -0,0 +1,14 @@ +{ + "id": "5000", + "name": "Influx 1", + "username": "user1", + "password": "pass1", + "url": "http://localhost:8086", + "metaUrl": "http://metaurl.com", + "type": "influx-enterprise", + "insecureSkipVerify": false, + "default": true, + "telegraf": "telegraf", + "sharedSecret": "cubeapples", + "organization": "howdy" +} diff --git a/integrations/testdata/mydash.dashboard b/integrations/testdata/mydash.dashboard new file mode 100644 index 000000000..a555f2af9 --- /dev/null +++ b/integrations/testdata/mydash.dashboard @@ -0,0 +1,185 @@ +{ + "id": 1000, + "cells": [ + { + "i": "8f61c619-dd9b-4761-8aa8-577f27247093", + "x": 0, + "y": 0, + "w": 11, + "h": 5, + "name": "Untitled Cell", + "queries": [ + { + "query": "SELECT mean(\"value\") AS \"mean_value\" FROM \"telegraf\".\"autogen\".\"cpg\" WHERE time \u003e :dashboardTime: GROUP BY :interval: FILL(null)", + "queryConfig": { + "id": "b20baa61-bacb-4a17-b27d-b904a0d18114", + "database": "telegraf", + "measurement": "cpg", + "retentionPolicy": "autogen", + "fields": [ + { + "value": "mean", + "type": "func", + "alias": "mean_value", + "args": [ + { + "value": "value", + "type": "field", + "alias": "" + } + ] + } + ], + "tags": {}, + "groupBy": { + "time": "auto", + "tags": [] + }, + "areTagsAccepted": true, + "fill": "null", + "rawText": null, + "range": null, + "shifts": [] + }, + "source": "/chronograf/v1/sources/2" + } + ], + "axes": { + "x": { + "bounds": [], + "label": "", + "prefix": "", + "suffix": "", + "base": "10", + "scale": "linear" + }, + "y": { + "bounds": [], + "label": "", + "prefix": "", + "suffix": "", + "base": "10", + "scale": "linear" + }, + "y2": { + "bounds": [], + "label": "", + "prefix": "", + "suffix": "", + "base": "10", + "scale": "linear" + } + }, + "type": "line", + "colors": [ + { + "id": "0", + "type": "min", + "hex": "#00C9FF", + "name": "laser", + "value": "0" + }, + { + "id": "1", + "type": "max", + "hex": "#9394FF", + "name": "comet", + "value": "100" + } + ] + } + ], + "templates": [ + { + "tempVar": ":dbs:", + "values": [ + { + "value": "_internal", + "type": "database", + "selected": true + }, + { + "value": "telegraf", + "type": "database", + "selected": false + }, + { + "value": "tensorflowdb", + "type": "database", + "selected": false + }, + { + "value": "pushgateway", + "type": "database", + "selected": false + }, + { + "value": "node_exporter", + "type": "database", + "selected": false + }, + { + "value": "mydb", + "type": "database", + "selected": false + }, + { + "value": "tiny", + "type": "database", + "selected": false + }, + { + "value": "blah", + "type": "database", + "selected": false + }, + { + "value": "test", + "type": "database", + "selected": false + }, + { + "value": "chronograf", + "type": "database", + "selected": false + }, + { + "value": "db_name", + "type": "database", + "selected": false + }, + { + "value": "demo", + "type": "database", + "selected": false + }, + { + "value": "eeg", + "type": "database", + "selected": false + }, + { + "value": "solaredge", + "type": "database", + "selected": false + }, + { + "value": "zipkin", + "type": "database", + "selected": false + } + ], + "id": "e7e498bf-5869-4874-9071-24628a2cda63", + "type": "databases", + "label": "", + "query": { + "influxql": "SHOW DATABASES", + "measurement": "", + "tagKey": "", + "fieldKey": "" + } + } + ], + "name": "Name This Dashboard", + "organization": "howdy" + } diff --git a/kapacitor/client.go b/kapacitor/client.go index 146eac612..2ceda3a38 100644 --- a/kapacitor/client.go +++ b/kapacitor/client.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/influxdata/chronograf" - "github.com/influxdata/chronograf/uuid" + "github.com/influxdata/chronograf/id" client "github.com/influxdata/kapacitor/client/v1" ) @@ -44,7 +44,7 @@ func NewClient(url, username, password string, insecureSkipVerify bool) *Client Username: username, Password: password, InsecureSkipVerify: insecureSkipVerify, - ID: &uuid.V4{}, + ID: &id.UUID{}, Ticker: &Alert{}, kapaClient: NewKapaClient, } diff --git a/memdb/kapacitors.go b/memdb/kapacitors.go index 83f1fed32..f9440d19b 100644 --- a/memdb/kapacitors.go +++ b/memdb/kapacitors.go @@ -7,9 +7,8 @@ import ( "github.com/influxdata/chronograf" ) -// Ensure KapacitorStore and MultiKapacitorStore implements chronograf.ServersStore. +// Ensure KapacitorStore implements chronograf.ServersStore. var _ chronograf.ServersStore = &KapacitorStore{} -var _ chronograf.ServersStore = &MultiKapacitorStore{} // KapacitorStore implements the chronograf.ServersStore interface, and keeps // an in-memory Kapacitor according to startup configuration @@ -55,90 +54,3 @@ func (store *KapacitorStore) Update(ctx context.Context, kap chronograf.Server) store.Kapacitor = &kap return nil } - -// MultiKapacitorStore implements the chronograf.ServersStore interface, and -// delegates to all contained KapacitorStores -type MultiKapacitorStore struct { - Stores []chronograf.ServersStore -} - -// All concatenates the Kapacitors of all contained Stores -func (multi *MultiKapacitorStore) All(ctx context.Context) ([]chronograf.Server, error) { - all := []chronograf.Server{} - kapSet := map[int]struct{}{} - - ok := false - var err error - for _, store := range multi.Stores { - var kaps []chronograf.Server - kaps, err = store.All(ctx) - if err != nil { - // If this Store is unable to return an array of kapacitors, skip to the - // next Store. - continue - } - ok = true // We've received a response from at least one Store - for _, kap := range kaps { - // Enforce that the kapacitor has a unique ID - // If the ID has been seen before, ignore the kapacitor - if _, okay := kapSet[kap.ID]; !okay { // We have a new kapacitor - kapSet[kap.ID] = struct{}{} // We just care that the ID is unique - all = append(all, kap) - } - } - } - if !ok { - return nil, err - } - return all, nil -} - -// Add the kap to the first responsive Store -func (multi *MultiKapacitorStore) Add(ctx context.Context, kap chronograf.Server) (chronograf.Server, error) { - var err error - for _, store := range multi.Stores { - var k chronograf.Server - k, err = store.Add(ctx, kap) - if err == nil { - return k, nil - } - } - return chronograf.Server{}, nil -} - -// Delete delegates to all Stores, returns success if one Store is successful -func (multi *MultiKapacitorStore) Delete(ctx context.Context, kap chronograf.Server) error { - var err error - for _, store := range multi.Stores { - err = store.Delete(ctx, kap) - if err == nil { - return nil - } - } - return err -} - -// Get finds the Source by id among all contained Stores -func (multi *MultiKapacitorStore) Get(ctx context.Context, id int) (chronograf.Server, error) { - var err error - for _, store := range multi.Stores { - var k chronograf.Server - k, err = store.Get(ctx, id) - if err == nil { - return k, nil - } - } - return chronograf.Server{}, nil -} - -// Update the first responsive Store -func (multi *MultiKapacitorStore) Update(ctx context.Context, kap chronograf.Server) error { - var err error - for _, store := range multi.Stores { - err = store.Update(ctx, kap) - if err == nil { - return nil - } - } - return err -} diff --git a/memdb/kapacitors_test.go b/memdb/kapacitors_test.go index 393900d35..48bc0d99e 100644 --- a/memdb/kapacitors_test.go +++ b/memdb/kapacitors_test.go @@ -9,7 +9,6 @@ import ( func TestInterfaceImplementation(t *testing.T) { var _ chronograf.ServersStore = &KapacitorStore{} - var _ chronograf.ServersStore = &MultiKapacitorStore{} } func TestKapacitorStoreAll(t *testing.T) { diff --git a/memdb/sources.go b/memdb/sources.go index 4f1036335..fb4cc5c95 100644 --- a/memdb/sources.go +++ b/memdb/sources.go @@ -7,95 +7,8 @@ import ( "github.com/influxdata/chronograf" ) -// Ensure MultiSourcesStore and SourcesStore implements chronograf.SourcesStore. +// Ensure SourcesStore implements chronograf.SourcesStore. var _ chronograf.SourcesStore = &SourcesStore{} -var _ chronograf.SourcesStore = &MultiSourcesStore{} - -// MultiSourcesStore delegates to the SourcesStores that compose it -type MultiSourcesStore struct { - Stores []chronograf.SourcesStore -} - -// All concatenates the Sources of all contained Stores -func (multi *MultiSourcesStore) All(ctx context.Context) ([]chronograf.Source, error) { - all := []chronograf.Source{} - sourceSet := map[int]struct{}{} - - ok := false - var err error - for _, store := range multi.Stores { - var sources []chronograf.Source - sources, err = store.All(ctx) - if err != nil { - // If this Store is unable to return an array of sources, skip to the - // next Store. - continue - } - ok = true // We've received a response from at least one Store - for _, s := range sources { - // Enforce that the source has a unique ID - // If the source has been seen before, don't override what we already have - if _, okay := sourceSet[s.ID]; !okay { // We have a new Source! - sourceSet[s.ID] = struct{}{} // We just care that the ID is unique - all = append(all, s) - } - } - } - if !ok { - return nil, err - } - return all, nil -} - -// Add the src to the first Store to respond successfully -func (multi *MultiSourcesStore) Add(ctx context.Context, src chronograf.Source) (chronograf.Source, error) { - var err error - for _, store := range multi.Stores { - var s chronograf.Source - s, err = store.Add(ctx, src) - if err == nil { - return s, nil - } - } - return chronograf.Source{}, nil -} - -// Delete delegates to all stores, returns success if one Store is successful -func (multi *MultiSourcesStore) Delete(ctx context.Context, src chronograf.Source) error { - var err error - for _, store := range multi.Stores { - err = store.Delete(ctx, src) - if err == nil { - return nil - } - } - return err -} - -// Get finds the Source by id among all contained Stores -func (multi *MultiSourcesStore) Get(ctx context.Context, id int) (chronograf.Source, error) { - var err error - for _, store := range multi.Stores { - var s chronograf.Source - s, err = store.Get(ctx, id) - if err == nil { - return s, nil - } - } - return chronograf.Source{}, err -} - -// Update the first store to return a successful response -func (multi *MultiSourcesStore) Update(ctx context.Context, src chronograf.Source) error { - var err error - for _, store := range multi.Stores { - err = store.Update(ctx, src) - if err == nil { - return nil - } - } - return err -} // SourcesStore implements the chronograf.SourcesStore interface type SourcesStore struct { diff --git a/mocks/config.go b/mocks/config.go new file mode 100644 index 000000000..f46fa6f81 --- /dev/null +++ b/mocks/config.go @@ -0,0 +1,28 @@ +package mocks + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +// ConfigStore stores global application configuration +type ConfigStore struct { + Config *chronograf.Config +} + +// Initialize is noop in mocks store +func (c ConfigStore) Initialize(ctx context.Context) error { + return nil +} + +// Get returns the whole global application configuration +func (c ConfigStore) Get(ctx context.Context) (*chronograf.Config, error) { + return c.Config, nil +} + +// Update updates the whole global application configuration +func (c ConfigStore) Update(ctx context.Context, config *chronograf.Config) error { + c.Config = config + return nil +} diff --git a/mocks/logger.go b/mocks/logger.go index f2926e09a..0f9a93a54 100644 --- a/mocks/logger.go +++ b/mocks/logger.go @@ -8,6 +8,11 @@ import ( "github.com/influxdata/chronograf" ) +// NewLogger returns a mock logger that implements chronograf.Logger +func NewLogger() chronograf.Logger { + return &TestLogger{} +} + type LogMessage struct { Level string Body string diff --git a/mocks/store.go b/mocks/store.go index f207b87f1..ebc05ea49 100644 --- a/mocks/store.go +++ b/mocks/store.go @@ -14,6 +14,7 @@ type Store struct { UsersStore chronograf.UsersStore DashboardsStore chronograf.DashboardsStore OrganizationsStore chronograf.OrganizationsStore + ConfigStore chronograf.ConfigStore } func (s *Store) Sources(ctx context.Context) chronograf.SourcesStore { @@ -39,3 +40,7 @@ func (s *Store) Organizations(ctx context.Context) chronograf.OrganizationsStore func (s *Store) Dashboards(ctx context.Context) chronograf.DashboardsStore { return s.DashboardsStore } + +func (s *Store) Config(ctx context.Context) chronograf.ConfigStore { + return s.ConfigStore +} diff --git a/multistore/dashboards.go b/multistore/dashboards.go new file mode 100644 index 000000000..4380448e8 --- /dev/null +++ b/multistore/dashboards.go @@ -0,0 +1,97 @@ +package multistore + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +// Ensure DashboardsStore implements chronograf.DashboardsStore. +var _ chronograf.DashboardsStore = &DashboardsStore{} + +// DashboardsStore implements the chronograf.DashboardsStore interface, and +// delegates to all contained DashboardsStores +type DashboardsStore struct { + Stores []chronograf.DashboardsStore +} + +// All concatenates the Dashboards of all contained Stores +func (multi *DashboardsStore) All(ctx context.Context) ([]chronograf.Dashboard, error) { + all := []chronograf.Dashboard{} + boardSet := map[chronograf.DashboardID]struct{}{} + + ok := false + var err error + for _, store := range multi.Stores { + var boards []chronograf.Dashboard + boards, err = store.All(ctx) + if err != nil { + // If this Store is unable to return an array of dashboards, skip to the + // next Store. + continue + } + ok = true // We've received a response from at least one Store + for _, board := range boards { + // Enforce that the dashboard has a unique ID + // If the ID has been seen before, ignore the dashboard + if _, okay := boardSet[board.ID]; !okay { // We have a new dashboard + boardSet[board.ID] = struct{}{} // We just care that the ID is unique + all = append(all, board) + } + } + } + if !ok { + return nil, err + } + return all, nil +} + +// Add the dashboard to the first responsive Store +func (multi *DashboardsStore) Add(ctx context.Context, dashboard chronograf.Dashboard) (chronograf.Dashboard, error) { + var err error + for _, store := range multi.Stores { + var d chronograf.Dashboard + d, err = store.Add(ctx, dashboard) + if err == nil { + return d, nil + } + } + return chronograf.Dashboard{}, nil +} + +// Delete delegates to all Stores, returns success if one Store is successful +func (multi *DashboardsStore) Delete(ctx context.Context, dashboard chronograf.Dashboard) error { + var err error + for _, store := range multi.Stores { + err = store.Delete(ctx, dashboard) + if err == nil { + return nil + } + } + return err +} + +// Get finds the Dashboard by id among all contained Stores +func (multi *DashboardsStore) Get(ctx context.Context, id chronograf.DashboardID) (chronograf.Dashboard, error) { + var err error + for _, store := range multi.Stores { + var d chronograf.Dashboard + d, err = store.Get(ctx, id) + if err == nil { + return d, nil + } + } + return chronograf.Dashboard{}, nil +} + +// Update the first responsive Store +func (multi *DashboardsStore) Update(ctx context.Context, dashboard chronograf.Dashboard) error { + var err error + for _, store := range multi.Stores { + err = store.Update(ctx, dashboard) + if err == nil { + return nil + } + } + return err +} diff --git a/multistore/kapacitors.go b/multistore/kapacitors.go new file mode 100644 index 000000000..c1321d9ef --- /dev/null +++ b/multistore/kapacitors.go @@ -0,0 +1,97 @@ +package multistore + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +// Ensure KapacitorStore implements chronograf.ServersStore. +var _ chronograf.ServersStore = &KapacitorStore{} + +// KapacitorStore implements the chronograf.ServersStore interface, and +// delegates to all contained KapacitorStores +type KapacitorStore struct { + Stores []chronograf.ServersStore +} + +// All concatenates the Kapacitors of all contained Stores +func (multi *KapacitorStore) All(ctx context.Context) ([]chronograf.Server, error) { + all := []chronograf.Server{} + kapSet := map[int]struct{}{} + + ok := false + var err error + for _, store := range multi.Stores { + var kaps []chronograf.Server + kaps, err = store.All(ctx) + if err != nil { + // If this Store is unable to return an array of kapacitors, skip to the + // next Store. + continue + } + ok = true // We've received a response from at least one Store + for _, kap := range kaps { + // Enforce that the kapacitor has a unique ID + // If the ID has been seen before, ignore the kapacitor + if _, okay := kapSet[kap.ID]; !okay { // We have a new kapacitor + kapSet[kap.ID] = struct{}{} // We just care that the ID is unique + all = append(all, kap) + } + } + } + if !ok { + return nil, err + } + return all, nil +} + +// Add the kap to the first responsive Store +func (multi *KapacitorStore) Add(ctx context.Context, kap chronograf.Server) (chronograf.Server, error) { + var err error + for _, store := range multi.Stores { + var k chronograf.Server + k, err = store.Add(ctx, kap) + if err == nil { + return k, nil + } + } + return chronograf.Server{}, nil +} + +// Delete delegates to all Stores, returns success if one Store is successful +func (multi *KapacitorStore) Delete(ctx context.Context, kap chronograf.Server) error { + var err error + for _, store := range multi.Stores { + err = store.Delete(ctx, kap) + if err == nil { + return nil + } + } + return err +} + +// Get finds the Source by id among all contained Stores +func (multi *KapacitorStore) Get(ctx context.Context, id int) (chronograf.Server, error) { + var err error + for _, store := range multi.Stores { + var k chronograf.Server + k, err = store.Get(ctx, id) + if err == nil { + return k, nil + } + } + return chronograf.Server{}, nil +} + +// Update the first responsive Store +func (multi *KapacitorStore) Update(ctx context.Context, kap chronograf.Server) error { + var err error + for _, store := range multi.Stores { + err = store.Update(ctx, kap) + if err == nil { + return nil + } + } + return err +} diff --git a/multistore/kapacitors_test.go b/multistore/kapacitors_test.go new file mode 100644 index 000000000..266d179b5 --- /dev/null +++ b/multistore/kapacitors_test.go @@ -0,0 +1,11 @@ +package multistore + +import ( + "testing" + + "github.com/influxdata/chronograf" +) + +func TestInterfaceImplementation(t *testing.T) { + var _ chronograf.ServersStore = &KapacitorStore{} +} diff --git a/layouts/layouts.go b/multistore/layouts.go similarity index 76% rename from layouts/layouts.go rename to multistore/layouts.go index a9b21d8d8..7f002243e 100644 --- a/layouts/layouts.go +++ b/multistore/layouts.go @@ -1,4 +1,4 @@ -package layouts +package multistore import ( "context" @@ -6,15 +6,15 @@ import ( "github.com/influxdata/chronograf" ) -// MultiLayoutsStore is a LayoutsStore that contains multiple LayoutsStores +// Layouts is a LayoutsStore that contains multiple LayoutsStores // The All method will return the set of all Layouts. // Each method will be tried against the Stores slice serially. -type MultiLayoutsStore struct { +type Layouts struct { Stores []chronograf.LayoutsStore } // All returns the set of all layouts -func (s *MultiLayoutsStore) All(ctx context.Context) ([]chronograf.Layout, error) { +func (s *Layouts) All(ctx context.Context) ([]chronograf.Layout, error) { all := []chronograf.Layout{} layoutSet := map[string]chronograf.Layout{} ok := false @@ -43,7 +43,7 @@ func (s *MultiLayoutsStore) All(ctx context.Context) ([]chronograf.Layout, error } // Add creates a new dashboard in the LayoutsStore. Tries each store sequentially until success. -func (s *MultiLayoutsStore) Add(ctx context.Context, layout chronograf.Layout) (chronograf.Layout, error) { +func (s *Layouts) Add(ctx context.Context, layout chronograf.Layout) (chronograf.Layout, error) { var err error for _, store := range s.Stores { var l chronograf.Layout @@ -57,7 +57,7 @@ func (s *MultiLayoutsStore) Add(ctx context.Context, layout chronograf.Layout) ( // Delete the dashboard from the store. Searches through all stores to find Layout and // then deletes from that store. -func (s *MultiLayoutsStore) Delete(ctx context.Context, layout chronograf.Layout) error { +func (s *Layouts) Delete(ctx context.Context, layout chronograf.Layout) error { var err error for _, store := range s.Stores { err = store.Delete(ctx, layout) @@ -69,7 +69,7 @@ func (s *MultiLayoutsStore) Delete(ctx context.Context, layout chronograf.Layout } // Get retrieves Layout if `ID` exists. Searches through each store sequentially until success. -func (s *MultiLayoutsStore) Get(ctx context.Context, ID string) (chronograf.Layout, error) { +func (s *Layouts) Get(ctx context.Context, ID string) (chronograf.Layout, error) { var err error for _, store := range s.Stores { var l chronograf.Layout @@ -82,7 +82,7 @@ func (s *MultiLayoutsStore) Get(ctx context.Context, ID string) (chronograf.Layo } // Update the dashboard in the store. Searches through each store sequentially until success. -func (s *MultiLayoutsStore) Update(ctx context.Context, layout chronograf.Layout) error { +func (s *Layouts) Update(ctx context.Context, layout chronograf.Layout) error { var err error for _, store := range s.Stores { err = store.Update(ctx, layout) diff --git a/multistore/organizations.go b/multistore/organizations.go new file mode 100644 index 000000000..7f0ea0b71 --- /dev/null +++ b/multistore/organizations.go @@ -0,0 +1,129 @@ +package multistore + +import ( + "context" + "fmt" + "strings" + + "github.com/influxdata/chronograf" +) + +// Ensure OrganizationsStore implements chronograf.OrganizationsStore. +var _ chronograf.OrganizationsStore = &OrganizationsStore{} + +// OrganizationsStore implements the chronograf.OrganizationsStore interface, and +// delegates to all contained OrganizationsStores +type OrganizationsStore struct { + Stores []chronograf.OrganizationsStore +} + +// All concatenates the Organizations of all contained Stores +func (multi *OrganizationsStore) All(ctx context.Context) ([]chronograf.Organization, error) { + all := []chronograf.Organization{} + orgSet := map[string]struct{}{} + + ok := false + var err error + for _, store := range multi.Stores { + var orgs []chronograf.Organization + orgs, err = store.All(ctx) + if err != nil { + // If this Store is unable to return an array of orgs, skip to the + // next Store. + continue + } + ok = true // We've received a response from at least one Store + for _, org := range orgs { + // Enforce that the org has a unique ID + // If the ID has been seen before, ignore the org + if _, okay := orgSet[org.ID]; !okay { // We have a new org + orgSet[org.ID] = struct{}{} // We just care that the ID is unique + all = append(all, org) + } + } + } + if !ok { + return nil, err + } + return all, nil +} + +// Add the org to the first responsive Store +func (multi *OrganizationsStore) Add(ctx context.Context, org *chronograf.Organization) (*chronograf.Organization, error) { + errors := []string{} + for _, store := range multi.Stores { + var o *chronograf.Organization + o, err := store.Add(ctx, org) + if err == nil { + return o, nil + } + errors = append(errors, err.Error()) + } + return nil, fmt.Errorf("Unknown error while adding organization: %s", strings.Join(errors, " ")) +} + +// Delete delegates to all Stores, returns success if one Store is successful +func (multi *OrganizationsStore) Delete(ctx context.Context, org *chronograf.Organization) error { + errors := []string{} + for _, store := range multi.Stores { + err := store.Delete(ctx, org) + if err == nil { + return nil + } + errors = append(errors, err.Error()) + } + return fmt.Errorf("Unknown error while deleting organization: %s", strings.Join(errors, " ")) +} + +// Get finds the Organization by id among all contained Stores +func (multi *OrganizationsStore) Get(ctx context.Context, query chronograf.OrganizationQuery) (*chronograf.Organization, error) { + var err error + for _, store := range multi.Stores { + var o *chronograf.Organization + o, err = store.Get(ctx, query) + if err == nil { + return o, nil + } + } + return nil, chronograf.ErrOrganizationNotFound +} + +// Update the first responsive Store +func (multi *OrganizationsStore) Update(ctx context.Context, org *chronograf.Organization) error { + errors := []string{} + for _, store := range multi.Stores { + err := store.Update(ctx, org) + if err == nil { + return nil + } + errors = append(errors, err.Error()) + } + return fmt.Errorf("Unknown error while updating organization: %s", strings.Join(errors, " ")) +} + +// CreateDefault makes a default organization in the first responsive Store +func (multi *OrganizationsStore) CreateDefault(ctx context.Context) error { + errors := []string{} + for _, store := range multi.Stores { + err := store.CreateDefault(ctx) + if err == nil { + return nil + } + errors = append(errors, err.Error()) + } + return fmt.Errorf("Unknown error while creating default organization: %s", strings.Join(errors, " ")) +} + +// DefaultOrganization returns the first successful DefaultOrganization +func (multi *OrganizationsStore) DefaultOrganization(ctx context.Context) (*chronograf.Organization, error) { + errors := []string{} + for _, store := range multi.Stores { + org, err := store.DefaultOrganization(ctx) + if err == nil { + return org, nil + } + errors = append(errors, err.Error()) + } + return nil, fmt.Errorf("Unknown error while getting default organization: %s", strings.Join(errors, " ")) + +} diff --git a/multistore/sources.go b/multistore/sources.go new file mode 100644 index 000000000..52e007d37 --- /dev/null +++ b/multistore/sources.go @@ -0,0 +1,96 @@ +package multistore + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +// Ensure SourcesStore implements chronograf.SourcesStore. +var _ chronograf.SourcesStore = &SourcesStore{} + +// SourcesStore delegates to the SourcesStores that compose it +type SourcesStore struct { + Stores []chronograf.SourcesStore +} + +// All concatenates the Sources of all contained Stores +func (multi *SourcesStore) All(ctx context.Context) ([]chronograf.Source, error) { + all := []chronograf.Source{} + sourceSet := map[int]struct{}{} + + ok := false + var err error + for _, store := range multi.Stores { + var sources []chronograf.Source + sources, err = store.All(ctx) + if err != nil { + // If this Store is unable to return an array of sources, skip to the + // next Store. + continue + } + ok = true // We've received a response from at least one Store + for _, s := range sources { + // Enforce that the source has a unique ID + // If the source has been seen before, don't override what we already have + if _, okay := sourceSet[s.ID]; !okay { // We have a new Source! + sourceSet[s.ID] = struct{}{} // We just care that the ID is unique + all = append(all, s) + } + } + } + if !ok { + return nil, err + } + return all, nil +} + +// Add the src to the first Store to respond successfully +func (multi *SourcesStore) Add(ctx context.Context, src chronograf.Source) (chronograf.Source, error) { + var err error + for _, store := range multi.Stores { + var s chronograf.Source + s, err = store.Add(ctx, src) + if err == nil { + return s, nil + } + } + return chronograf.Source{}, nil +} + +// Delete delegates to all stores, returns success if one Store is successful +func (multi *SourcesStore) Delete(ctx context.Context, src chronograf.Source) error { + var err error + for _, store := range multi.Stores { + err = store.Delete(ctx, src) + if err == nil { + return nil + } + } + return err +} + +// Get finds the Source by id among all contained Stores +func (multi *SourcesStore) Get(ctx context.Context, id int) (chronograf.Source, error) { + var err error + for _, store := range multi.Stores { + var s chronograf.Source + s, err = store.Get(ctx, id) + if err == nil { + return s, nil + } + } + return chronograf.Source{}, err +} + +// Update the first store to return a successful response +func (multi *SourcesStore) Update(ctx context.Context, src chronograf.Source) error { + var err error + for _, store := range multi.Stores { + err = store.Update(ctx, src) + if err == nil { + return nil + } + } + return err +} diff --git a/noop/config.go b/noop/config.go new file mode 100644 index 000000000..1f3b180a5 --- /dev/null +++ b/noop/config.go @@ -0,0 +1,26 @@ +package noop + +import ( + "context" + "fmt" + + "github.com/influxdata/chronograf" +) + +// ensure ConfigStore implements chronograf.ConfigStore +var _ chronograf.ConfigStore = &ConfigStore{} + +type ConfigStore struct{} + +// TODO(desa): this really should be removed +func (s *ConfigStore) Initialize(context.Context) error { + return fmt.Errorf("cannot initialize") +} + +func (s *ConfigStore) Get(context.Context) (*chronograf.Config, error) { + return nil, chronograf.ErrConfigNotFound +} + +func (s *ConfigStore) Update(context.Context, *chronograf.Config) error { + return fmt.Errorf("cannot update conifg") +} diff --git a/organizations/organizations.go b/organizations/organizations.go index 6f9136b30..efa747c4d 100644 --- a/organizations/organizations.go +++ b/organizations/organizations.go @@ -66,13 +66,13 @@ func (s *OrganizationsStore) All(ctx context.Context) ([]chronograf.Organization return nil, err } - defaultOrgID := fmt.Sprintf("%d", defaultOrg.ID) + defaultOrgID := defaultOrg.ID // This filters organizations without allocating // https://github.com/golang/go/wiki/SliceTricks#filtering-without-allocating organizations := ds[:0] for _, d := range ds { - id := fmt.Sprintf("%d", d.ID) + id := d.ID switch id { case s.organization, defaultOrgID: organizations = append(organizations, d) @@ -117,7 +117,7 @@ func (s *OrganizationsStore) Get(ctx context.Context, q chronograf.OrganizationQ return nil, err } - if fmt.Sprintf("%d", d.ID) != s.organization { + if d.ID != s.organization { return nil, chronograf.ErrOrganizationNotFound } diff --git a/organizations/organizations_test.go b/organizations/organizations_test.go index e95e9ce27..99635c54a 100644 --- a/organizations/organizations_test.go +++ b/organizations/organizations_test.go @@ -44,7 +44,7 @@ func TestOrganizations_All(t *testing.T) { }, DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "Default", }, nil }, @@ -58,7 +58,7 @@ func TestOrganizations_All(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "Default", }, nil }, @@ -66,11 +66,11 @@ func TestOrganizations_All(t *testing.T) { return []chronograf.Organization{ { Name: "howdy", - ID: 1337, + ID: "1337", }, { Name: "doody", - ID: 1447, + ID: "1447", }, }, nil }, @@ -83,11 +83,11 @@ func TestOrganizations_All(t *testing.T) { want: []chronograf.Organization{ { Name: "howdy", - ID: 1337, + ID: "1337", }, { Name: "Default", - ID: 0, + ID: "0", }, }, }, @@ -133,7 +133,7 @@ func TestOrganizations_Add(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 1229, + ID: "1229", Name: "howdy", }, nil }, @@ -193,7 +193,7 @@ func TestOrganizations_Delete(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 1229, + ID: "1229", Name: "howdy", }, nil }, @@ -203,7 +203,7 @@ func TestOrganizations_Delete(t *testing.T) { organizationID: "1229", ctx: context.Background(), organization: &chronograf.Organization{ - ID: 1229, + ID: "1229", Name: "howdy", }, }, @@ -244,7 +244,7 @@ func TestOrganizations_Get(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "howdy", }, nil }, @@ -254,12 +254,12 @@ func TestOrganizations_Get(t *testing.T) { organizationID: "1337", ctx: context.Background(), organization: &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "howdy", }, }, want: &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "howdy", }, }, @@ -305,7 +305,7 @@ func TestOrganizations_Update(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 1229, + ID: "1229", Name: "doody", }, nil }, @@ -315,7 +315,7 @@ func TestOrganizations_Update(t *testing.T) { organizationID: "1229", ctx: context.Background(), organization: &chronograf.Organization{ - ID: 1229, + ID: "1229", Name: "howdy", }, name: "doody", diff --git a/organizations/users.go b/organizations/users.go index 0db6e2cd6..7c6600d7d 100644 --- a/organizations/users.go +++ b/organizations/users.go @@ -142,6 +142,20 @@ func (s *UsersStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.U // and the user that was found in the underlying store usr.Roles = append(roles, u.Roles...) + // u.SuperAdmin == true is logically equivalent to u.SuperAdmin, however + // it is more clear on a conceptual level to check equality + // + // TODO(desa): this should go away with https://github.com/influxdata/chronograf/issues/2207 + // I do not like checking super admin here. The organization users store should only be + // concerned about organizations. + // + // If the user being added already existed in a previous organization, and was already a SuperAdmin, + // then this ensures that they retain their SuperAdmin status. And if they weren't a SuperAdmin, and + // the user being added has been granted SuperAdmin status, they will be promoted + if u.SuperAdmin == true { + usr.SuperAdmin = true + } + // Update the user in the underlying store if err := s.store.Update(ctx, usr); err != nil { return nil, err diff --git a/organizations/users_test.go b/organizations/users_test.go index 0baa40a08..4f350adea 100644 --- a/organizations/users_test.go +++ b/organizations/users_test.go @@ -315,9 +315,62 @@ func TestUsersStore_Add(t *testing.T) { Scheme: "oauth2", Roles: []chronograf.Role{ { - Organization: "1337", - Name: "editor", + Organization: "1336", + Name: "admin", }, + }, + }, + }, + { + name: "Add non-new user with Role. Stored user is not super admin. Provided user is super admin", + fields: fields{ + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) { + return u, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1234, + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + Roles: []chronograf.Role{ + { + Organization: "1337", + Name: "editor", + }, + }, + }, nil + }, + }, + }, + args: args{ + ctx: context.Background(), + u: &chronograf.User{ + ID: 1234, + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Organization: "1336", + Name: "admin", + }, + }, + }, + orgID: "1336", + }, + want: &chronograf.User{ + Name: "docbrown", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ { Organization: "1336", Name: "admin", @@ -503,6 +556,9 @@ func TestUsersStore_Add(t *testing.T) { if got == nil && tt.want == nil { continue } + if diff := cmp.Diff(got, tt.want, userCmpOptions...); diff != "" { + t.Errorf("%q. UsersStore.Add():\n-got/+want\ndiff %s", tt.name, diff) + } } } diff --git a/server/auth.go b/server/auth.go index 6ce6b6430..b973a1938 100644 --- a/server/auth.go +++ b/server/auth.go @@ -99,19 +99,13 @@ func AuthorizedUser( Error(w, http.StatusForbidden, "User is not authorized", logger) return } - p.Organization = fmt.Sprintf("%d", defaultOrg.ID) + p.Organization = defaultOrg.ID } // validate that the organization exists - orgID, err := parseOrganizationID(p.Organization) + _, err = store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &p.Organization}) if err != nil { - log.Error("Failed to validate organization on context") - Error(w, http.StatusForbidden, "User is not authorized", logger) - return - } - _, err = store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &orgID}) - if err != nil { - log.Error(fmt.Sprintf("Failed to retrieve organization %d from organizations store", orgID)) + log.Error(fmt.Sprintf("Failed to retrieve organization %s from organizations store", p.Organization)) Error(w, http.StatusForbidden, "User is not authorized", logger) return } diff --git a/server/auth_test.go b/server/auth_test.go index 87a576834..06a8a5ebb 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -93,7 +93,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, }, @@ -133,7 +133,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -141,7 +141,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -189,7 +189,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -197,7 +197,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -245,7 +245,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -253,7 +253,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -301,7 +301,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -309,7 +309,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -353,7 +353,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -361,7 +361,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -409,7 +409,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -417,7 +417,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -465,7 +465,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -473,7 +473,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -517,7 +517,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -525,7 +525,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -569,7 +569,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -577,7 +577,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -620,7 +620,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -628,7 +628,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -667,7 +667,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -675,7 +675,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -714,7 +714,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -722,7 +722,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -765,7 +765,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -773,7 +773,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -819,7 +819,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -862,7 +862,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -870,7 +870,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -914,7 +914,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -922,7 +922,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -966,7 +966,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -974,7 +974,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -1018,7 +1018,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -1026,7 +1026,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -1071,7 +1071,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -1079,7 +1079,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -1128,7 +1128,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -1136,7 +1136,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -1185,7 +1185,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -1193,7 +1193,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -1242,7 +1242,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -1250,7 +1250,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -1298,7 +1298,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -1306,7 +1306,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -1346,7 +1346,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -1354,7 +1354,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -1397,7 +1397,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -1405,7 +1405,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, @@ -1449,7 +1449,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -1457,9 +1457,9 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } switch *q.ID { - case 1338: + case "1338": return &chronograf.Organization{ - ID: 1338, + ID: "1338", Name: "The ShillBillThrilliettas", }, nil default: @@ -1511,7 +1511,7 @@ func TestAuthorizedUser(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -1519,7 +1519,7 @@ func TestAuthorizedUser(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", }, nil }, diff --git a/server/builders.go b/server/builders.go index 144225fb9..b8b0ffad5 100644 --- a/server/builders.go +++ b/server/builders.go @@ -3,27 +3,28 @@ package server import ( "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/canned" - "github.com/influxdata/chronograf/layouts" + "github.com/influxdata/chronograf/filestore" "github.com/influxdata/chronograf/memdb" + "github.com/influxdata/chronograf/multistore" ) // LayoutBuilder is responsible for building Layouts type LayoutBuilder interface { - Build(chronograf.LayoutsStore) (*layouts.MultiLayoutsStore, error) + Build(chronograf.LayoutsStore) (*multistore.Layouts, error) } -// MultiLayoutBuilder implements LayoutBuilder and will return a MultiLayoutsStore +// MultiLayoutBuilder implements LayoutBuilder and will return a Layouts type MultiLayoutBuilder struct { Logger chronograf.Logger UUID chronograf.ID CannedPath string } -// Build will construct a MultiLayoutsStore of canned and db-backed personalized +// Build will construct a Layouts of canned and db-backed personalized // layouts -func (builder *MultiLayoutBuilder) Build(db chronograf.LayoutsStore) (*layouts.MultiLayoutsStore, error) { +func (builder *MultiLayoutBuilder) Build(db chronograf.LayoutsStore) (*multistore.Layouts, error) { // These apps are those handled from a directory - apps := canned.NewApps(builder.CannedPath, builder.UUID, builder.Logger) + apps := filestore.NewApps(builder.CannedPath, builder.UUID, builder.Logger) // These apps are statically compiled into chronograf binApps := &canned.BinLayoutsStore{ Logger: builder.Logger, @@ -31,7 +32,7 @@ func (builder *MultiLayoutBuilder) Build(db chronograf.LayoutsStore) (*layouts.M // Acts as a front-end to both the bolt layouts, filesystem layouts and binary statically compiled layouts. // The idea here is that these stores form a hierarchy in which each is tried sequentially until // the operation has success. So, the database is preferred over filesystem over binary data. - layouts := &layouts.MultiLayoutsStore{ + layouts := &multistore.Layouts{ Stores: []chronograf.LayoutsStore{ db, apps, @@ -42,9 +43,38 @@ func (builder *MultiLayoutBuilder) Build(db chronograf.LayoutsStore) (*layouts.M return layouts, nil } +// DashboardBuilder is responsible for building dashboards +type DashboardBuilder interface { + Build(chronograf.DashboardsStore) (*multistore.DashboardsStore, error) +} + +// MultiDashboardBuilder builds a DashboardsStore backed by bolt and the filesystem +type MultiDashboardBuilder struct { + Logger chronograf.Logger + ID chronograf.ID + Path string +} + +// Build will construct a Dashboard store of filesystem and db-backed dashboards +func (builder *MultiDashboardBuilder) Build(db chronograf.DashboardsStore) (*multistore.DashboardsStore, error) { + // These dashboards are those handled from a directory + files := filestore.NewDashboards(builder.Path, builder.ID, builder.Logger) + // Acts as a front-end to both the bolt dashboard and filesystem dashboards. + // The idea here is that these stores form a hierarchy in which each is tried sequentially until + // the operation has success. So, the database is preferred over filesystem + dashboards := &multistore.DashboardsStore{ + Stores: []chronograf.DashboardsStore{ + db, + files, + }, + } + + return dashboards, nil +} + // SourcesBuilder builds a MultiSourceStore type SourcesBuilder interface { - Build(chronograf.SourcesStore) (*memdb.MultiSourcesStore, error) + Build(chronograf.SourcesStore) (*multistore.SourcesStore, error) } // MultiSourceBuilder implements SourcesBuilder @@ -52,11 +82,18 @@ type MultiSourceBuilder struct { InfluxDBURL string InfluxDBUsername string InfluxDBPassword string + + Logger chronograf.Logger + ID chronograf.ID + Path string } // Build will return a MultiSourceStore -func (fs *MultiSourceBuilder) Build(db chronograf.SourcesStore) (*memdb.MultiSourcesStore, error) { - stores := []chronograf.SourcesStore{db} +func (fs *MultiSourceBuilder) Build(db chronograf.SourcesStore) (*multistore.SourcesStore, error) { + // These dashboards are those handled from a directory + files := filestore.NewSources(fs.Path, fs.ID, fs.Logger) + + stores := []chronograf.SourcesStore{db, files} if fs.InfluxDBURL != "" { influxStore := &memdb.SourcesStore{ @@ -71,7 +108,7 @@ func (fs *MultiSourceBuilder) Build(db chronograf.SourcesStore) (*memdb.MultiSou }} stores = append([]chronograf.SourcesStore{influxStore}, stores...) } - sources := &memdb.MultiSourcesStore{ + sources := &multistore.SourcesStore{ Stores: stores, } @@ -80,7 +117,7 @@ func (fs *MultiSourceBuilder) Build(db chronograf.SourcesStore) (*memdb.MultiSou // KapacitorBuilder builds a KapacitorStore type KapacitorBuilder interface { - Build(chronograf.ServersStore) (*memdb.MultiKapacitorStore, error) + Build(chronograf.ServersStore) (*multistore.KapacitorStore, error) } // MultiKapacitorBuilder implements KapacitorBuilder @@ -88,11 +125,19 @@ type MultiKapacitorBuilder struct { KapacitorURL string KapacitorUsername string KapacitorPassword string + + Logger chronograf.Logger + ID chronograf.ID + Path string } -// Build will return a MultiKapacitorStore -func (builder *MultiKapacitorBuilder) Build(db chronograf.ServersStore) (*memdb.MultiKapacitorStore, error) { - stores := []chronograf.ServersStore{db} +// Build will return a multistore facade KapacitorStore over memdb and bolt +func (builder *MultiKapacitorBuilder) Build(db chronograf.ServersStore) (*multistore.KapacitorStore, error) { + // These dashboards are those handled from a directory + files := filestore.NewKapacitors(builder.Path, builder.ID, builder.Logger) + + stores := []chronograf.ServersStore{db, files} + if builder.KapacitorURL != "" { memStore := &memdb.KapacitorStore{ Kapacitor: &chronograf.Server{ @@ -106,8 +151,36 @@ func (builder *MultiKapacitorBuilder) Build(db chronograf.ServersStore) (*memdb. } stores = append([]chronograf.ServersStore{memStore}, stores...) } - kapacitors := &memdb.MultiKapacitorStore{ + kapacitors := &multistore.KapacitorStore{ Stores: stores, } return kapacitors, nil } + +// OrganizationBuilder is responsible for building dashboards +type OrganizationBuilder interface { + Build(chronograf.OrganizationsStore) (*multistore.OrganizationsStore, error) +} + +// MultiOrganizationBuilder builds a OrganizationsStore backed by bolt and the filesystem +type MultiOrganizationBuilder struct { + Logger chronograf.Logger + Path string +} + +// Build will construct a Organization store of filesystem and db-backed dashboards +func (builder *MultiOrganizationBuilder) Build(db chronograf.OrganizationsStore) (*multistore.OrganizationsStore, error) { + // These organization are those handled from a directory + files := filestore.NewOrganizations(builder.Path, builder.Logger) + // Acts as a front-end to both the bolt org and filesystem orgs. + // The idea here is that these stores form a hierarchy in which each is tried sequentially until + // the operation has success. So, the database is preferred over filesystem + orgs := &multistore.OrganizationsStore{ + Stores: []chronograf.OrganizationsStore{ + db, + files, + }, + } + + return orgs, nil +} diff --git a/server/cells.go b/server/cells.go index e1d0c08fa..ca05fa524 100644 --- a/server/cells.go +++ b/server/cells.go @@ -7,7 +7,7 @@ import ( "github.com/bouk/httprouter" "github.com/influxdata/chronograf" - "github.com/influxdata/chronograf/uuid" + idgen "github.com/influxdata/chronograf/id" ) const ( @@ -26,45 +26,49 @@ type dashboardCellResponse struct { Links dashboardCellLinks `json:"links"` } -func newCellResponses(dID chronograf.DashboardID, dcells []chronograf.DashboardCell) []dashboardCellResponse { +func newCellResponse(dID chronograf.DashboardID, cell chronograf.DashboardCell) dashboardCellResponse { base := "/chronograf/v1/dashboards" + newCell := chronograf.DashboardCell{} + newCell.Queries = make([]chronograf.DashboardQuery, len(cell.Queries)) + copy(newCell.Queries, cell.Queries) + + newCell.CellColors = make([]chronograf.CellColor, len(cell.CellColors)) + copy(newCell.CellColors, cell.CellColors) + + // ensure x, y, and y2 axes always returned + labels := []string{"x", "y", "y2"} + newCell.Axes = make(map[string]chronograf.Axis, len(labels)) + + newCell.X = cell.X + newCell.Y = cell.Y + newCell.W = cell.W + newCell.H = cell.H + newCell.Name = cell.Name + newCell.ID = cell.ID + newCell.Type = cell.Type + + for _, lbl := range labels { + if axis, found := cell.Axes[lbl]; !found { + newCell.Axes[lbl] = chronograf.Axis{ + Bounds: []string{}, + } + } else { + newCell.Axes[lbl] = axis + } + } + + return dashboardCellResponse{ + DashboardCell: newCell, + Links: dashboardCellLinks{ + Self: fmt.Sprintf("%s/%d/cells/%s", base, dID, cell.ID), + }, + } +} + +func newCellResponses(dID chronograf.DashboardID, dcells []chronograf.DashboardCell) []dashboardCellResponse { cells := make([]dashboardCellResponse, len(dcells)) for i, cell := range dcells { - newCell := chronograf.DashboardCell{} - newCell.Queries = make([]chronograf.DashboardQuery, len(cell.Queries)) - copy(newCell.Queries, cell.Queries) - - newCell.CellColors = make([]chronograf.CellColor, len(cell.CellColors)) - copy(newCell.CellColors, cell.CellColors) - - // ensure x, y, and y2 axes always returned - labels := []string{"x", "y", "y2"} - newCell.Axes = make(map[string]chronograf.Axis, len(labels)) - - newCell.X = cell.X - newCell.Y = cell.Y - newCell.W = cell.W - newCell.H = cell.H - newCell.Name = cell.Name - newCell.ID = cell.ID - newCell.Type = cell.Type - - for _, lbl := range labels { - if axis, found := cell.Axes[lbl]; !found { - newCell.Axes[lbl] = chronograf.Axis{ - Bounds: []string{}, - } - } else { - newCell.Axes[lbl] = axis - } - } - - cells[i] = dashboardCellResponse{ - DashboardCell: newCell, - Links: dashboardCellLinks{ - Self: fmt.Sprintf("%s/%d/cells/%s", base, dID, cell.ID), - }, - } + cells[i] = newCellResponse(dID, cell) } return cells } @@ -112,7 +116,7 @@ func HasCorrectAxes(c *chronograf.DashboardCell) error { // HasCorrectColors verifies that the format of each color is correct func HasCorrectColors(c *chronograf.DashboardCell) error { for _, color := range c.CellColors { - if !oneOf(color.Type, "max", "min", "threshold") { + if !oneOf(color.Type, "max", "min", "threshold", "text", "background") { return chronograf.ErrInvalidColorType } if len(color.Hex) != 7 { @@ -210,7 +214,7 @@ func (s *Service) NewDashboardCell(w http.ResponseWriter, r *http.Request) { return } - ids := uuid.V4{} + ids := &idgen.UUID{} cid, err := ids.Generate() if err != nil { msg := fmt.Sprintf("Error creating cell ID of dashboard %d: %v", id, err) @@ -322,7 +326,7 @@ func (s *Service) ReplaceDashboardCell(w http.ResponseWriter, r *http.Request) { } } if cellid == -1 { - notFound(w, id, s.Logger) + notFound(w, cid, s.Logger) return } @@ -345,11 +349,6 @@ func (s *Service) ReplaceDashboardCell(w http.ResponseWriter, r *http.Request) { return } - boards := newDashboardResponse(dash) - for _, cell := range boards.Cells { - if cell.ID == cid { - encodeJSON(w, http.StatusOK, cell, s.Logger) - return - } - } + res := newCellResponse(dash.ID, cell) + encodeJSON(w, http.StatusOK, res, s.Logger) } diff --git a/server/cells_test.go b/server/cells_test.go index e90014af1..bc3fecc0b 100644 --- a/server/cells_test.go +++ b/server/cells_test.go @@ -1,11 +1,14 @@ -package server_test +package server import ( + "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" + "reflect" "strings" "testing" @@ -13,7 +16,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/mocks" - "github.com/influxdata/chronograf/server" ) func Test_Cells_CorrectAxis(t *testing.T) { @@ -126,7 +128,7 @@ func Test_Cells_CorrectAxis(t *testing.T) { for _, test := range axisTests { t.Run(test.name, func(tt *testing.T) { - if err := server.HasCorrectAxes(test.cell); err != nil && !test.shouldFail { + if err := HasCorrectAxes(test.cell); err != nil && !test.shouldFail { t.Errorf("%q: Unexpected error: err: %s", test.name, err) } else if err == nil && test.shouldFail { t.Errorf("%q: Expected error and received none", test.name) @@ -226,7 +228,7 @@ func Test_Service_DashboardCells(t *testing.T) { // setup mock DashboardCells store and logger tlog := &mocks.TestLogger{} - svc := &server.Service{ + svc := &Service{ Store: &mocks.Store{ DashboardsStore: &mocks.DashboardsStore{ GetF: func(ctx context.Context, id chronograf.DashboardID) (chronograf.Dashboard, error) { @@ -343,9 +345,490 @@ func TestHasCorrectColors(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := server.HasCorrectColors(tt.c); (err != nil) != tt.wantErr { + if err := HasCorrectColors(tt.c); (err != nil) != tt.wantErr { t.Errorf("HasCorrectColors() error = %v, wantErr %v", err, tt.wantErr) } }) } } + +func TestService_ReplaceDashboardCell(t *testing.T) { + tests := []struct { + name string + DashboardsStore chronograf.DashboardsStore + ID string + CID string + w *httptest.ResponseRecorder + r *http.Request + want string + }{ + { + name: "update cell retains query config", + ID: "1", + CID: "3c5c4102-fa40-4585-a8f9-917c77e37192", + DashboardsStore: &mocks.DashboardsStore{ + UpdateF: func(ctx context.Context, target chronograf.Dashboard) error { + return nil + }, + GetF: func(ctx context.Context, ID chronograf.DashboardID) (chronograf.Dashboard, error) { + return chronograf.Dashboard{ + ID: ID, + Cells: []chronograf.DashboardCell{ + { + ID: "3c5c4102-fa40-4585-a8f9-917c77e37192", + W: 4, + H: 4, + Name: "Untitled Cell", + Queries: []chronograf.DashboardQuery{ + { + Command: "SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time > :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)", + QueryConfig: chronograf.QueryConfig{ + ID: "3cd3eaa4-a4b8-44b3-b69e-0c7bf6b91d9e", + Database: "telegraf", + Measurement: "cpu", + RetentionPolicy: "autogen", + Fields: []chronograf.Field{ + { + Value: "mean", + Type: "func", + Alias: "mean_usage_user", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, + }, + }, + Tags: map[string][]string{ + "cpu": { + "ChristohersMBP2.lan", + }, + }, + GroupBy: chronograf.GroupBy{ + Time: "2s", + Tags: []string{}, + }, + AreTagsAccepted: true, + Fill: "null", + RawText: strPtr("SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time > :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)"), + Range: &chronograf.DurationRange{ + Lower: "now() - 15m"}, + Shifts: []chronograf.TimeShift{}, + }, + }, + }, + Axes: map[string]chronograf.Axis{ + "x": { + Bounds: []string{}, + }, + "y": { + Bounds: []string{}, + }, + "y2": { + Bounds: []string{}, + }, + }, + Type: "line", + CellColors: []chronograf.CellColor{ + { + ID: "0", + Type: "min", + Hex: "#00C9FF", + Name: "laser", + Value: "0", + }, + { + ID: "1", + Type: "max", + Hex: "#9394FF", + Name: "comet", + Value: "100", + }, + }, + }, + }, + }, nil + }, + }, + w: httptest.NewRecorder(), + r: httptest.NewRequest("POST", "/queries", bytes.NewReader([]byte(` + { + "i": "3c5c4102-fa40-4585-a8f9-917c77e37192", + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "name": "Untitled Cell", + "queries": [ + { + "queryConfig": { + "id": "3cd3eaa4-a4b8-44b3-b69e-0c7bf6b91d9e", + "database": "telegraf", + "measurement": "cpu", + "retentionPolicy": "autogen", + "fields": [ + { + "value": "mean", + "type": "func", + "alias": "mean_usage_user", + "args": [{"value": "usage_user", "type": "field", "alias": ""}] + } + ], + "tags": {"cpu": ["ChristohersMBP2.lan"]}, + "groupBy": {"time": "2s", "tags": []}, + "areTagsAccepted": true, + "fill": "null", + "rawText": + "SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time > :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)", + "range": {"upper": "", "lower": "now() - 15m"}, + "shifts": [] + }, + "query": + "SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time > :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)", + "source": null + } + ], + "axes": { + "x": { + "bounds": [], + "label": "", + "prefix": "", + "suffix": "", + "base": "", + "scale": "" + }, + "y": { + "bounds": [], + "label": "", + "prefix": "", + "suffix": "", + "base": "", + "scale": "" + }, + "y2": { + "bounds": [], + "label": "", + "prefix": "", + "suffix": "", + "base": "", + "scale": "" + } + }, + "type": "line", + "colors": [ + {"type": "min", "hex": "#00C9FF", "id": "0", "name": "laser", "value": "0"}, + { + "type": "max", + "hex": "#9394FF", + "id": "1", + "name": "comet", + "value": "100" + } + ], + "links": { + "self": + "/chronograf/v1/dashboards/6/cells/3c5c4102-fa40-4585-a8f9-917c77e37192" + } + } + `))), + want: `{"i":"3c5c4102-fa40-4585-a8f9-917c77e37192","x":0,"y":0,"w":4,"h":4,"name":"Untitled Cell","queries":[{"query":"SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time \u003e :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)","queryConfig":{"id":"3cd3eaa4-a4b8-44b3-b69e-0c7bf6b91d9e","database":"telegraf","measurement":"cpu","retentionPolicy":"autogen","fields":[{"value":"mean","type":"func","alias":"mean_usage_user","args":[{"value":"usage_user","type":"field","alias":""}]}],"tags":{"cpu":["ChristohersMBP2.lan"]},"groupBy":{"time":"2s","tags":[]},"areTagsAccepted":true,"fill":"null","rawText":"SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time \u003e :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)","range":{"upper":"","lower":"now() - 15m"},"shifts":[]},"source":""}],"axes":{"x":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""},"y":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""},"y2":{"bounds":[],"label":"","prefix":"","suffix":"","base":"","scale":""}},"type":"line","colors":[{"id":"0","type":"min","hex":"#00C9FF","name":"laser","value":"0"},{"id":"1","type":"max","hex":"#9394FF","name":"comet","value":"100"}],"links":{"self":"/chronograf/v1/dashboards/1/cells/3c5c4102-fa40-4585-a8f9-917c77e37192"}} +`, + }, + { + name: "dashboard doesn't exist", + ID: "1", + DashboardsStore: &mocks.DashboardsStore{ + GetF: func(ctx context.Context, ID chronograf.DashboardID) (chronograf.Dashboard, error) { + return chronograf.Dashboard{}, fmt.Errorf("doesn't exist") + }, + }, + w: httptest.NewRecorder(), + r: httptest.NewRequest("PUT", "/chronograf/v1/dashboards/1/cells/3c5c4102-fa40-4585-a8f9-917c77e37192", nil), + want: `{"code":404,"message":"ID 1 not found"}`, + }, + { + name: "cell doesn't exist", + ID: "1", + CID: "3c5c4102-fa40-4585-a8f9-917c77e37192", + DashboardsStore: &mocks.DashboardsStore{ + GetF: func(ctx context.Context, ID chronograf.DashboardID) (chronograf.Dashboard, error) { + return chronograf.Dashboard{}, nil + }, + }, + w: httptest.NewRecorder(), + r: httptest.NewRequest("PUT", "/chronograf/v1/dashboards/1/cells/3c5c4102-fa40-4585-a8f9-917c77e37192", nil), + want: `{"code":404,"message":"ID 3c5c4102-fa40-4585-a8f9-917c77e37192 not found"}`, + }, + { + name: "invalid query config", + ID: "1", + CID: "3c5c4102-fa40-4585-a8f9-917c77e37192", + DashboardsStore: &mocks.DashboardsStore{ + GetF: func(ctx context.Context, ID chronograf.DashboardID) (chronograf.Dashboard, error) { + return chronograf.Dashboard{ + ID: ID, + Cells: []chronograf.DashboardCell{ + { + ID: "3c5c4102-fa40-4585-a8f9-917c77e37192", + }, + }, + }, nil + }, + }, + w: httptest.NewRecorder(), + r: httptest.NewRequest("PUT", "/chronograf/v1/dashboards/1/cells/3c5c4102-fa40-4585-a8f9-917c77e37192", bytes.NewReader([]byte(`{ + "i": "3c5c4102-fa40-4585-a8f9-917c77e37192", + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "name": "Untitled Cell", + "queries": [ + { + "queryConfig": { + "fields": [ + { + "value": "invalid", + "type": "invalidType" + } + ] + } + } + ] + }`))), + want: `{"code":422,"message":"invalid field type \"invalidType\" ; expect func, field, integer, number, regex, wildcard"}`, + }, + { + name: "JSON is not parsable", + ID: "1", + CID: "3c5c4102-fa40-4585-a8f9-917c77e37192", + DashboardsStore: &mocks.DashboardsStore{ + GetF: func(ctx context.Context, ID chronograf.DashboardID) (chronograf.Dashboard, error) { + return chronograf.Dashboard{ + ID: ID, + Cells: []chronograf.DashboardCell{ + { + ID: "3c5c4102-fa40-4585-a8f9-917c77e37192", + }, + }, + }, nil + }, + }, + w: httptest.NewRecorder(), + r: httptest.NewRequest("PUT", "/chronograf/v1/dashboards/1/cells/3c5c4102-fa40-4585-a8f9-917c77e37192", nil), + want: `{"code":400,"message":"Unparsable JSON"}`, + }, + { + name: "not able to update store returns error message", + ID: "1", + CID: "3c5c4102-fa40-4585-a8f9-917c77e37192", + DashboardsStore: &mocks.DashboardsStore{ + UpdateF: func(ctx context.Context, target chronograf.Dashboard) error { + return fmt.Errorf("error") + }, + GetF: func(ctx context.Context, ID chronograf.DashboardID) (chronograf.Dashboard, error) { + return chronograf.Dashboard{ + ID: ID, + Cells: []chronograf.DashboardCell{ + { + ID: "3c5c4102-fa40-4585-a8f9-917c77e37192", + }, + }, + }, nil + }, + }, + w: httptest.NewRecorder(), + r: httptest.NewRequest("PUT", "/chronograf/v1/dashboards/1/cells/3c5c4102-fa40-4585-a8f9-917c77e37192", bytes.NewReader([]byte(`{ + "i": "3c5c4102-fa40-4585-a8f9-917c77e37192", + "x": 0, + "y": 0, + "w": 4, + "h": 4, + "name": "Untitled Cell", + "queries": [ + { + "queryConfig": { + "fields": [ + { + "value": "usage_user", + "type": "field" + } + ] + } + } + ] + }`))), + want: `{"code":500,"message":"Error updating cell 3c5c4102-fa40-4585-a8f9-917c77e37192 in dashboard 1: error"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + DashboardsStore: tt.DashboardsStore, + }, + Logger: &mocks.TestLogger{}, + } + tt.r = WithContext(tt.r.Context(), tt.r, map[string]string{ + "id": tt.ID, + "cid": tt.CID, + }) + s.ReplaceDashboardCell(tt.w, tt.r) + got := tt.w.Body.String() + if got != tt.want { + t.Errorf("ReplaceDashboardCell() = got/want\n%s\n%s\n", got, tt.want) + } + }) + } +} + +func strPtr(s string) *string { + return &s +} + +func Test_newCellResponses(t *testing.T) { + tests := []struct { + name string + dID chronograf.DashboardID + dcells []chronograf.DashboardCell + want []dashboardCellResponse + }{ + { + name: "foo", + dID: chronograf.DashboardID(1), + dcells: []chronograf.DashboardCell{ + chronograf.DashboardCell{ + ID: "445f8dc0-4d73-4168-8477-f628690d18a3", + X: 0, + Y: 0, + W: 4, + H: 4, + Name: "Untitled Cell", + Queries: []chronograf.DashboardQuery{ + { + Command: "SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time > :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)", + Label: "", + QueryConfig: chronograf.QueryConfig{ + ID: "8d5ec6da-13a5-423e-9026-7bc45649766c", + Database: "telegraf", + Measurement: "cpu", + RetentionPolicy: "autogen", + Fields: []chronograf.Field{ + { + Value: "mean", + Type: "func", + Alias: "mean_usage_user", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + Alias: "", + }, + }, + }, + }, + Tags: map[string][]string{"cpu": []string{"ChristohersMBP2.lan"}}, + GroupBy: chronograf.GroupBy{ + Time: "2s", + }, + AreTagsAccepted: true, + Fill: "null", + RawText: strPtr("SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time > :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)"), + Range: &chronograf.DurationRange{ + Lower: "now() - 15m", + }, + }, + Source: "", + }, + }, + Axes: map[string]chronograf.Axis{ + "x": chronograf.Axis{}, + "y": chronograf.Axis{}, + "y2": chronograf.Axis{}, + }, + Type: "line", + CellColors: []chronograf.CellColor{ + chronograf.CellColor{ID: "0", Type: "min", Hex: "#00C9FF", Name: "laser", Value: "0"}, + chronograf.CellColor{ID: "1", Type: "max", Hex: "#9394FF", Name: "comet", Value: "100"}, + }, + }, + }, + want: []dashboardCellResponse{ + { + DashboardCell: chronograf.DashboardCell{ + ID: "445f8dc0-4d73-4168-8477-f628690d18a3", + W: 4, + H: 4, + Name: "Untitled Cell", + Queries: []chronograf.DashboardQuery{ + { + Command: "SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time > :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)", + QueryConfig: chronograf.QueryConfig{ + ID: "8d5ec6da-13a5-423e-9026-7bc45649766c", + Database: "telegraf", + Measurement: "cpu", + RetentionPolicy: "autogen", + Fields: []chronograf.Field{ + { + Value: "mean", + Type: "func", + Alias: "mean_usage_user", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, + }, + }, + Tags: map[string][]string{"cpu": {"ChristohersMBP2.lan"}}, + GroupBy: chronograf.GroupBy{ + Time: "2s", + }, + AreTagsAccepted: true, + Fill: "null", + RawText: strPtr("SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\" WHERE time > :dashboardTime: AND \"cpu\"=:cpu: GROUP BY :interval: FILL(null)"), + Range: &chronograf.DurationRange{ + Lower: "now() - 15m", + }, + }, + }, + }, + Axes: map[string]chronograf.Axis{ + "x": {}, + "y": {}, + "y2": {}, + }, + Type: "line", + CellColors: []chronograf.CellColor{ + { + ID: "0", + Type: "min", + Hex: "#00C9FF", + Name: "laser", + Value: "0", + }, + { + ID: "1", + Type: "max", + Hex: "#9394FF", + Name: "comet", + Value: "100", + }, + }, + }, + Links: dashboardCellLinks{ + Self: "/chronograf/v1/dashboards/1/cells/445f8dc0-4d73-4168-8477-f628690d18a3"}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := newCellResponses(tt.dID, tt.dcells); !reflect.DeepEqual(got, tt.want) { + t.Errorf("newCellResponses() = got-/want+ %s", cmp.Diff(got, tt.want)) + } + }) + } +} diff --git a/server/config.go b/server/config.go new file mode 100644 index 000000000..68898c61f --- /dev/null +++ b/server/config.go @@ -0,0 +1,123 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" +) + +type configResponse struct { + Links selfLinks `json:"links"` + chronograf.Config +} + +func newConfigResponse(config chronograf.Config) *configResponse { + return &configResponse{ + Links: selfLinks{ + Self: "/chronograf/v1/config", + }, + Config: config, + } +} + +type authConfigResponse struct { + Links selfLinks `json:"links"` + chronograf.AuthConfig +} + +func newAuthConfigResponse(config chronograf.Config) *authConfigResponse { + return &authConfigResponse{ + Links: selfLinks{ + Self: "/chronograf/v1/config/auth", + }, + AuthConfig: config.Auth, + } +} + +// Config retrieves the global application configuration +func (s *Service) Config(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + config, err := s.Store.Config(ctx).Get(ctx) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + if config == nil { + Error(w, http.StatusBadRequest, "Configuration object was nil", s.Logger) + return + } + res := newConfigResponse(*config) + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// ConfigSection retrieves the section of the global application configuration +func (s *Service) ConfigSection(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + config, err := s.Store.Config(ctx).Get(ctx) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + if config == nil { + Error(w, http.StatusBadRequest, "Configuration object was nil", s.Logger) + return + } + + section := httprouter.GetParamFromContext(ctx, "section") + var res interface{} + switch section { + case "auth": + res = newAuthConfigResponse(*config) + default: + Error(w, http.StatusBadRequest, fmt.Sprintf("received unknown section %q", section), s.Logger) + return + } + + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// ReplaceConfigSection replaces a section of the global application configuration +func (s *Service) ReplaceConfigSection(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + config, err := s.Store.Config(ctx).Get(ctx) + if err != nil { + Error(w, http.StatusBadRequest, err.Error(), s.Logger) + return + } + + if config == nil { + Error(w, http.StatusBadRequest, "Configuration object was nil", s.Logger) + return + } + + section := httprouter.GetParamFromContext(ctx, "section") + var res interface{} + switch section { + case "auth": + var authConfig chronograf.AuthConfig + if err := json.NewDecoder(r.Body).Decode(&authConfig); err != nil { + invalidJSON(w, s.Logger) + return + } + config.Auth = authConfig + res = newAuthConfigResponse(*config) + default: + Error(w, http.StatusBadRequest, fmt.Sprintf("received unknown section %q", section), s.Logger) + return + } + + if err := s.Store.Config(ctx).Update(ctx, config); err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + + encodeJSON(w, http.StatusOK, res, s.Logger) +} diff --git a/server/config_test.go b/server/config_test.go new file mode 100644 index 000000000..8901dc3b8 --- /dev/null +++ b/server/config_test.go @@ -0,0 +1,290 @@ +package server + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http/httptest" + "testing" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/mocks" +) + +func TestConfig(t *testing.T) { + type fields struct { + ConfigStore chronograf.ConfigStore + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + wants wants + }{ + { + name: "Get global application configuration", + fields: fields{ + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + }, + wants: wants{ + statusCode: 200, + contentType: "application/json", + body: `{"auth": {"superAdminNewUsers": false}, "links": {"self": "/chronograf/v1/config"}}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + ConfigStore: tt.fields.ConfigStore, + }, + Logger: log.New(log.DebugLevel), + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://any.url", nil) + + s.Config(w, r) + + resp := w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf("%q. Config() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("%q. Config() = %v, want %v", tt.name, content, tt.wants.contentType) + } + if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq { + t.Errorf("%q. Config() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body) + } + }) + } +} + +func TestConfigSection(t *testing.T) { + type fields struct { + ConfigStore chronograf.ConfigStore + } + type args struct { + section string + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "Get auth configuration", + fields: fields{ + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + }, + args: args{ + section: "auth", + }, + wants: wants{ + statusCode: 200, + contentType: "application/json", + body: `{"superAdminNewUsers": false, "links": {"self": "/chronograf/v1/config/auth"}}`, + }, + }, + { + name: "Get unknown configuration", + fields: fields{ + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + }, + args: args{ + section: "unknown", + }, + wants: wants{ + statusCode: 400, + contentType: "application/json", + body: `{"code":400,"message":"received unknown section \"unknown\""}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + ConfigStore: tt.fields.ConfigStore, + }, + Logger: log.New(log.DebugLevel), + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://any.url", nil) + r = r.WithContext(httprouter.WithParams( + r.Context(), + httprouter.Params{ + { + Key: "section", + Value: tt.args.section, + }, + })) + + s.ConfigSection(w, r) + + resp := w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf("%q. Config() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("%q. Config() = %v, want %v", tt.name, content, tt.wants.contentType) + } + if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq { + t.Errorf("%q. Config() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body) + } + }) + } +} + +func TestReplaceConfigSection(t *testing.T) { + type fields struct { + ConfigStore chronograf.ConfigStore + } + type args struct { + section string + payload interface{} // expects JSON serializable struct + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "Set auth configuration", + fields: fields{ + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + }, + args: args{ + section: "auth", + payload: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + wants: wants{ + statusCode: 200, + contentType: "application/json", + body: `{"superAdminNewUsers": true, "links": {"self": "/chronograf/v1/config/auth"}}`, + }, + }, + { + name: "Set unknown configuration", + fields: fields{ + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + }, + args: args{ + section: "unknown", + payload: struct { + Data string `json:"data"` + }{ + Data: "stuff", + }, + }, + wants: wants{ + statusCode: 400, + contentType: "application/json", + body: `{"code":400,"message":"received unknown section \"unknown\""}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Store: &mocks.Store{ + ConfigStore: tt.fields.ConfigStore, + }, + Logger: log.New(log.DebugLevel), + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://any.url", nil) + r = r.WithContext(httprouter.WithParams( + r.Context(), + httprouter.Params{ + { + Key: "section", + Value: tt.args.section, + }, + })) + buf, _ := json.Marshal(tt.args.payload) + r.Body = ioutil.NopCloser(bytes.NewReader(buf)) + + s.ReplaceConfigSection(w, r) + + resp := w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf("%q. Config() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("%q. Config() = %v, want %v", tt.name, content, tt.wants.contentType) + } + if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq { + t.Errorf("%q. Config() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body) + } + }) + } +} diff --git a/server/dashboards.go b/server/dashboards.go index a3995ff92..8ca642ca7 100644 --- a/server/dashboards.go +++ b/server/dashboards.go @@ -101,7 +101,7 @@ func (s *Service) NewDashboard(w http.ResponseWriter, r *http.Request) { return } - if err := ValidDashboardRequest(&dashboard, fmt.Sprintf("%d", defaultOrg.ID)); err != nil { + if err := ValidDashboardRequest(&dashboard, defaultOrg.ID); err != nil { invalidData(w, err, s.Logger) return } @@ -168,7 +168,7 @@ func (s *Service) ReplaceDashboard(w http.ResponseWriter, r *http.Request) { return } - if err := ValidDashboardRequest(&req, fmt.Sprintf("%d", defaultOrg.ID)); err != nil { + if err := ValidDashboardRequest(&req, defaultOrg.ID); err != nil { invalidData(w, err, s.Logger) return } @@ -215,7 +215,7 @@ func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) { unknownErrorWithMessage(w, err, s.Logger) return } - if err := ValidDashboardRequest(&req, fmt.Sprintf("%d", defaultOrg.ID)); err != nil { + if err := ValidDashboardRequest(&req, defaultOrg.ID); err != nil { invalidData(w, err, s.Logger) return } diff --git a/server/env.go b/server/env.go new file mode 100644 index 000000000..9d58fd5c1 --- /dev/null +++ b/server/env.go @@ -0,0 +1,27 @@ +package server + +import ( + "net/http" + + "github.com/influxdata/chronograf" +) + +type envResponse struct { + Links selfLinks `json:"links"` + TelegrafSystemInterval string `json:"telegrafSystemInterval"` +} + +func newEnvResponse(env chronograf.Environment) *envResponse { + return &envResponse{ + Links: selfLinks{ + Self: "/chronograf/v1/env", + }, + TelegrafSystemInterval: env.TelegrafSystemInterval.String(), + } +} + +// Environment retrieves the global application configuration +func (s *Service) Environment(w http.ResponseWriter, r *http.Request) { + res := newEnvResponse(s.Env) + encodeJSON(w, http.StatusOK, res, s.Logger) +} diff --git a/server/env_test.go b/server/env_test.go new file mode 100644 index 000000000..22e379c5c --- /dev/null +++ b/server/env_test.go @@ -0,0 +1,70 @@ +package server + +import ( + "io/ioutil" + "net/http/httptest" + "testing" + "time" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/log" +) + +func TestEnvironment(t *testing.T) { + type fields struct { + Environment chronograf.Environment + } + type wants struct { + statusCode int + contentType string + body string + } + + tests := []struct { + name string + fields fields + wants wants + }{ + { + name: "Get environment", + fields: fields{ + Environment: chronograf.Environment{ + TelegrafSystemInterval: 1 * time.Minute, + }, + }, + wants: wants{ + statusCode: 200, + contentType: "application/json", + body: `{"links":{"self":"/chronograf/v1/env"},"telegrafSystemInterval":"1m0s"}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &Service{ + Env: tt.fields.Environment, + Logger: log.New(log.DebugLevel), + } + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "http://any.url", nil) + + s.Environment(w, r) + + resp := w.Result() + content := resp.Header.Get("Content-Type") + body, _ := ioutil.ReadAll(resp.Body) + + if resp.StatusCode != tt.wants.statusCode { + t.Errorf("%q. Config() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode) + } + if tt.wants.contentType != "" && content != tt.wants.contentType { + t.Errorf("%q. Config() = %v, want %v", tt.name, content, tt.wants.contentType) + } + if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq { + t.Errorf("%q. Config() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body) + } + }) + } +} diff --git a/server/kapacitors.go b/server/kapacitors.go index d86839830..ca6b1d171 100644 --- a/server/kapacitors.go +++ b/server/kapacitors.go @@ -87,7 +87,7 @@ func (s *Service) NewKapacitor(w http.ResponseWriter, r *http.Request) { return } - if err := req.Valid(fmt.Sprintf("%d", defaultOrg.ID)); err != nil { + if err := req.Valid(defaultOrg.ID); err != nil { invalidData(w, err, s.Logger) return } diff --git a/server/links.go b/server/links.go index 9f4f6e38a..3a3b3fd41 100644 --- a/server/links.go +++ b/server/links.go @@ -5,6 +5,11 @@ import ( "net/url" ) +type getConfigLinksResponse struct { + Self string `json:"self"` // Location of the whole global application configuration + Auth string `json:"auth"` // Location of the auth section of the global application configuration +} + type getExternalLinksResponse struct { StatusFeed *string `json:"statusFeed,omitempty"` // Location of the a JSON Feed for client's Status page News Feed CustomLinks []CustomLink `json:"custom,omitempty"` // Any custom external links for client's User menu diff --git a/server/me.go b/server/me.go index 1521afabd..52a36c6ff 100644 --- a/server/me.go +++ b/server/me.go @@ -95,12 +95,7 @@ func (s *Service) UpdateMe(auth oauth2.Authenticator) func(http.ResponseWriter, } // validate that the organization exists - orgID, err := parseOrganizationID(req.Organization) - if err != nil { - Error(w, http.StatusInternalServerError, err.Error(), s.Logger) - return - } - _, err = s.Store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &orgID}) + org, err := s.Store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &req.Organization}) if err != nil { Error(w, http.StatusBadRequest, err.Error(), s.Logger) return @@ -120,7 +115,7 @@ func (s *Service) UpdateMe(auth oauth2.Authenticator) func(http.ResponseWriter, unknownErrorWithMessage(w, err, s.Logger) return } - p.Organization = fmt.Sprintf("%d", defaultOrg.ID) + p.Organization = defaultOrg.ID } scheme, err := getScheme(ctx) if err != nil { @@ -133,11 +128,36 @@ func (s *Service) UpdateMe(auth oauth2.Authenticator) func(http.ResponseWriter, Scheme: &scheme, }) if err == chronograf.ErrUserNotFound { - // Since a user is not a part of this organization, we should tell them that they are Forbidden (403) from accessing this resource - Error(w, http.StatusForbidden, err.Error(), s.Logger) - return - } - if err != nil { + // If the user was not found, check to see if they are a super admin. If + // they are, add them to the organization. + u, err := s.Store.Users(serverCtx).Get(serverCtx, chronograf.UserQuery{ + Name: &p.Subject, + Provider: &p.Issuer, + Scheme: &scheme, + }) + if err != nil { + Error(w, http.StatusForbidden, err.Error(), s.Logger) + return + } + + if u.SuperAdmin == false { + // Since a user is not a part of this organization and not a super admin, + // we should tell them that they are Forbidden (403) from accessing this resource + Error(w, http.StatusForbidden, chronograf.ErrUserNotFound.Error(), s.Logger) + return + } + + // If the user is a super admin give them an admin role in the + // requested organization. + u.Roles = append(u.Roles, chronograf.Role{ + Organization: org.ID, + Name: org.DefaultRole, + }) + if err := s.Store.Users(serverCtx).Update(serverCtx, u); err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + } else if err != nil { Error(w, http.StatusBadRequest, err.Error(), s.Logger) return } @@ -186,7 +206,7 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { unknownErrorWithMessage(w, err, s.Logger) return } - p.Organization = fmt.Sprintf("%d", defaultOrg.ID) + p.Organization = defaultOrg.ID } usr, err := s.Store.Users(serverCtx).Get(serverCtx, chronograf.UserQuery{ @@ -206,17 +226,29 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { } if usr != nil { + + if defaultOrg.Public || usr.SuperAdmin == true { + // If the default organization is public, or the user is a super admin + // they will always have a role in the default organization + defaultOrgID := defaultOrg.ID + if !hasRoleInDefaultOrganization(usr, defaultOrgID) { + usr.Roles = append(usr.Roles, chronograf.Role{ + Organization: defaultOrgID, + Name: defaultOrg.DefaultRole, + }) + if err := s.Store.Users(serverCtx).Update(serverCtx, usr); err != nil { + unknownErrorWithMessage(w, err, s.Logger) + return + } + } + } + // If the default org is private and the user has no roles, they should not have access if !defaultOrg.Public && len(usr.Roles) == 0 { Error(w, http.StatusForbidden, "This organization is private. To gain access, you must be explicitly added by an administrator.", s.Logger) return } - orgID, err := parseOrganizationID(p.Organization) - if err != nil { - unknownErrorWithMessage(w, err, s.Logger) - return - } - currentOrg, err := s.Store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &orgID}) + currentOrg, err := s.Store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &p.Organization}) if err == chronograf.ErrOrganizationNotFound { // The intent is to force a the user to go through another auth flow Error(w, http.StatusForbidden, "user's current organization was not found", s.Logger) @@ -227,19 +259,6 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { return } - defaultOrgID := fmt.Sprintf("%d", defaultOrg.ID) - // If a user was added via the API, they might not yet be a member of the default organization - // Here we check to verify that they are a user in the default organization - if !hasRoleInDefaultOrganization(usr, defaultOrgID) { - usr.Roles = append(usr.Roles, chronograf.Role{ - Organization: defaultOrgID, - Name: defaultOrg.DefaultRole, - }) - if err := s.Store.Users(serverCtx).Update(serverCtx, usr); err != nil { - unknownErrorWithMessage(w, err, s.Logger) - return - } - } orgs, err := s.usersOrganizations(serverCtx, usr) if err != nil { unknownErrorWithMessage(w, err, s.Logger) @@ -271,7 +290,7 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { { Name: defaultOrg.DefaultRole, // This is the ID of the default organization - Organization: fmt.Sprintf("%d", defaultOrg.ID), + Organization: defaultOrg.ID, }, }, // TODO(desa): this needs a better name @@ -290,12 +309,7 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { unknownErrorWithMessage(w, err, s.Logger) return } - orgID, err := parseOrganizationID(p.Organization) - if err != nil { - unknownErrorWithMessage(w, err, s.Logger) - return - } - currentOrg, err := s.Store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &orgID}) + currentOrg, err := s.Store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &p.Organization}) if err != nil { unknownErrorWithMessage(w, err, s.Logger) return @@ -316,10 +330,21 @@ func (s *Service) firstUser() bool { return numUsers == 0 } func (s *Service) newUsersAreSuperAdmin() bool { + // It's not necessary to enforce that the first user is superAdmin here, since + // superAdminNewUsers defaults to true, but there's nothing else in the + // application that dictates that it must be true. + // So for that reason, we kept this here for now. We've discussed the + // future possibility of allowing users to override default values via CLI and + // this case could possibly happen then. if s.firstUser() { return true } - return !s.SuperAdminFirstUserOnly + serverCtx := serverContext(context.Background()) + cfg, err := s.Store.Config(serverCtx).Get(serverCtx) + if err != nil { + return false + } + return cfg.Auth.SuperAdminNewUsers } func (s *Service) usersOrganizations(ctx context.Context, u *chronograf.User) ([]chronograf.Organization, error) { @@ -335,8 +360,7 @@ func (s *Service) usersOrganizations(ctx context.Context, u *chronograf.User) ([ orgs := []chronograf.Organization{} for orgID, _ := range orgIDs { - id, err := parseOrganizationID(orgID) - org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) + org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &orgID}) if err != nil { return nil, err } diff --git a/server/me_test.go b/server/me_test.go index f92dc8a62..6f90e7d32 100644 --- a/server/me_test.go +++ b/server/me_test.go @@ -21,11 +21,11 @@ type MockUsers struct{} func TestService_Me(t *testing.T) { type fields struct { - UsersStore chronograf.UsersStore - OrganizationsStore chronograf.OrganizationsStore - Logger chronograf.Logger - UseAuth bool - SuperAdminFirstUserOnly bool + UsersStore chronograf.UsersStore + OrganizationsStore chronograf.OrganizationsStore + ConfigStore chronograf.ConfigStore + Logger chronograf.Logger + UseAuth bool } type args struct { w *httptest.ResponseRecorder @@ -47,13 +47,19 @@ func TestService_Me(t *testing.T) { r: httptest.NewRequest("GET", "http://example.com/foo", nil), }, fields: fields{ - UseAuth: true, - SuperAdminFirstUserOnly: true, - Logger: log.New(log.DebugLevel), + UseAuth: true, + Logger: log.New(log.DebugLevel), + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, Public: false, @@ -61,16 +67,16 @@ func TestService_Me(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { switch *q.ID { - case 0: + case "0": return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, Public: false, }, nil - case 1: + case "1": return &chronograf.Organization{ - ID: 1, + ID: "1", Name: "The Bad Place", Public: false, }, nil @@ -107,7 +113,7 @@ func TestService_Me(t *testing.T) { wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`, }, { - name: "Existing user", + name: "Existing user - private default org and user is a super admin", args: args{ w: httptest.NewRecorder(), r: httptest.NewRequest("GET", "http://example.com/foo", nil), @@ -118,7 +124,138 @@ func TestService_Me(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", + Name: "Default", + DefaultRole: roles.ViewerRoleName, + Public: false, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "0": + return &chronograf.Organization{ + ID: "0", + Name: "Default", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "The Bad Place", + Public: true, + }, nil + } + return nil, nil + }, + }, + UsersStore: &mocks.UsersStore{ + NumF: func(ctx context.Context) (int, error) { + // This function gets to verify that there is at least one first user + return 1, nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + Name: "me", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "me", + Issuer: "github", + }, + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"name":"me","roles":[{"name":"viewer","organization":"0"}],"provider":"github","scheme":"oauth2","superAdmin":true,"links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","defaultRole":"viewer","public":true}],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer","public":true}}`, + }, + { + name: "Existing user - private default org", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + Name: "Default", + DefaultRole: roles.ViewerRoleName, + Public: false, + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "0": + return &chronograf.Organization{ + ID: "0", + Name: "Default", + DefaultRole: roles.ViewerRoleName, + Public: true, + }, nil + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "The Bad Place", + Public: true, + }, nil + } + return nil, nil + }, + }, + UsersStore: &mocks.UsersStore{ + NumF: func(ctx context.Context) (int, error) { + // This function gets to verify that there is at least one first user + return 1, nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + if q.Name == nil || q.Provider == nil || q.Scheme == nil { + return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme") + } + return &chronograf.User{ + Name: "me", + Provider: "github", + Scheme: "oauth2", + }, nil + }, + UpdateF: func(ctx context.Context, u *chronograf.User) error { + return nil + }, + }, + }, + principal: oauth2.Principal{ + Subject: "me", + Issuer: "github", + }, + wantStatus: http.StatusForbidden, + wantContentType: "application/json", + wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`, + }, + { + name: "Existing user - default org public", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest("GET", "http://example.com/foo", nil), + }, + fields: fields{ + UseAuth: true, + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, Public: true, @@ -126,16 +263,16 @@ func TestService_Me(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { switch *q.ID { - case 0: + case "0": return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, Public: true, }, nil - case 1: + case "1": return &chronograf.Organization{ - ID: 1, + ID: "1", Name: "The Bad Place", Public: true, }, nil @@ -169,8 +306,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"me","roles":[{"name":"viewer","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","public":true,"defaultRole":"viewer"}],"currentOrganization":{"id":"0","defaultRole":"viewer","name":"Default","public":true}} -`, + wantBody: `{"name":"me","roles":[{"name":"viewer","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","defaultRole":"viewer","public":true}],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer","public":true}}`, }, { name: "Existing user - organization doesn't exist", @@ -184,7 +320,7 @@ func TestService_Me(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, Public: true, @@ -192,9 +328,9 @@ func TestService_Me(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { switch *q.ID { - case 0: + case "0": return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "Default", DefaultRole: roles.ViewerRoleName, Public: true, @@ -235,13 +371,19 @@ func TestService_Me(t *testing.T) { r: httptest.NewRequest("GET", "http://example.com/foo", nil), }, fields: fields{ - UseAuth: true, - SuperAdminFirstUserOnly: false, - Logger: log.New(log.DebugLevel), + UseAuth: true, + Logger: log.New(log.DebugLevel), + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, Public: true, @@ -249,7 +391,7 @@ func TestService_Me(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, Public: true, @@ -291,13 +433,19 @@ func TestService_Me(t *testing.T) { r: httptest.NewRequest("GET", "http://example.com/foo", nil), }, fields: fields{ - UseAuth: true, - SuperAdminFirstUserOnly: true, - Logger: log.New(log.DebugLevel), + UseAuth: true, + Logger: log.New(log.DebugLevel), + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, Public: true, @@ -305,7 +453,7 @@ func TestService_Me(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, Public: true, @@ -347,13 +495,19 @@ func TestService_Me(t *testing.T) { r: httptest.NewRequest("GET", "http://example.com/foo", nil), }, fields: fields{ - UseAuth: true, - SuperAdminFirstUserOnly: true, - Logger: log.New(log.DebugLevel), + UseAuth: true, + Logger: log.New(log.DebugLevel), + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, Public: true, @@ -361,7 +515,7 @@ func TestService_Me(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "The Gnarly Default", DefaultRole: roles.ViewerRoleName, Public: true, @@ -403,18 +557,24 @@ func TestService_Me(t *testing.T) { r: httptest.NewRequest("GET", "http://example.com/foo", nil), }, fields: fields{ - UseAuth: true, - SuperAdminFirstUserOnly: true, + UseAuth: true, + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Public: true, }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "The Bad Place", Public: true, }, nil @@ -452,9 +612,15 @@ func TestService_Me(t *testing.T) { r: httptest.NewRequest("GET", "http://example.com/foo", nil), }, fields: fields{ - UseAuth: false, - SuperAdminFirstUserOnly: true, - Logger: log.New(log.DebugLevel), + UseAuth: false, + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + Logger: log.New(log.DebugLevel), }, wantStatus: http.StatusOK, wantContentType: "application/json", @@ -468,9 +634,15 @@ func TestService_Me(t *testing.T) { r: httptest.NewRequest("GET", "http://example.com/foo", nil), }, fields: fields{ - UseAuth: true, - SuperAdminFirstUserOnly: true, - Logger: log.New(log.DebugLevel), + UseAuth: true, + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + Logger: log.New(log.DebugLevel), }, wantStatus: http.StatusUnprocessableEntity, principal: oauth2.Principal{ @@ -490,7 +662,7 @@ func TestService_Me(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "The Bad Place", DefaultRole: roles.MemberRoleName, Public: false, @@ -531,10 +703,10 @@ func TestService_Me(t *testing.T) { Store: &mocks.Store{ UsersStore: tt.fields.UsersStore, OrganizationsStore: tt.fields.OrganizationsStore, + ConfigStore: tt.fields.ConfigStore, }, - Logger: tt.fields.Logger, - UseAuth: tt.fields.UseAuth, - SuperAdminFirstUserOnly: tt.fields.SuperAdminFirstUserOnly, + Logger: tt.fields.Logger, + UseAuth: tt.fields.UseAuth, } s.Me(tt.args.w, tt.args.r) @@ -617,7 +789,7 @@ func TestService_UpdateMe(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "Default", DefaultRole: roles.AdminRoleName, Public: true, @@ -628,16 +800,16 @@ func TestService_UpdateMe(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } switch *q.ID { - case 0: + case "0": return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "Default", DefaultRole: roles.AdminRoleName, Public: true, }, nil - case 1337: + case "1337": return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", Public: true, }, nil @@ -652,7 +824,7 @@ func TestService_UpdateMe(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"},{"name":"admin","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","public":true,"defaultRole":"admin"},{"id":"1337","name":"The ShillBillThrilliettas","public":true}],"currentOrganization":{"id":"1337","name":"The ShillBillThrilliettas","public":true}}`, + wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"},{"name":"admin","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","defaultRole":"admin","public":true},{"id":"1337","name":"The ShillBillThrilliettas","public":true}],"currentOrganization":{"id":"1337","name":"The ShillBillThrilliettas","public":true}}`, }, { name: "Change the current User's organization", @@ -691,7 +863,7 @@ func TestService_UpdateMe(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "Default", DefaultRole: roles.EditorRoleName, Public: true, @@ -702,15 +874,15 @@ func TestService_UpdateMe(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } switch *q.ID { - case 1337: + case "1337": return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ThrillShilliettos", Public: false, }, nil - case 0: + case "0": return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "Default", DefaultRole: roles.EditorRoleName, Public: true, @@ -727,7 +899,7 @@ func TestService_UpdateMe(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"},{"name":"editor","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","public":true,"defaultRole":"editor"},{"id":"1337","name":"The ThrillShilliettos","public":false}],"currentOrganization":{"id":"1337","name":"The ThrillShilliettos","public":false}}`, + wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"},{"name":"editor","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"Default","defaultRole":"editor","public":true},{"id":"1337","name":"The ThrillShilliettos","public":false}],"currentOrganization":{"id":"1337","name":"The ThrillShilliettos","public":false}}`, }, { name: "Unable to find requested user in valid organization", @@ -766,7 +938,7 @@ func TestService_UpdateMe(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { @@ -774,7 +946,7 @@ func TestService_UpdateMe(t *testing.T) { return nil, fmt.Errorf("Invalid organization query: missing ID") } return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The ShillBillThrilliettas", Public: true, }, nil @@ -827,7 +999,7 @@ func TestService_UpdateMe(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", }, nil }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { diff --git a/server/mux.go b/server/mux.go index 2848fc593..aaa1648ea 100644 --- a/server/mux.go +++ b/server/mux.go @@ -237,6 +237,13 @@ func NewMux(opts MuxOpts, service Service) http.Handler { router.PUT("/chronograf/v1/sources/:id/dbs/:dbid/rps/:rpid", EnsureEditor(service.UpdateRetentionPolicy)) router.DELETE("/chronograf/v1/sources/:id/dbs/:dbid/rps/:rpid", EnsureEditor(service.DropRetentionPolicy)) + // Global application config for Chronograf + router.GET("/chronograf/v1/config", EnsureSuperAdmin(service.Config)) + router.GET("/chronograf/v1/config/:section", EnsureSuperAdmin(service.ConfigSection)) + router.PUT("/chronograf/v1/config/:section", EnsureSuperAdmin(service.ReplaceConfigSection)) + + router.GET("/chronograf/v1/env", EnsureViewer(service.Environment)) + allRoutes := &AllRoutes{ Logger: opts.Logger, StatusFeed: opts.StatusFeedURL, @@ -358,8 +365,8 @@ func unknownErrorWithMessage(w http.ResponseWriter, err error, logger chronograf Error(w, http.StatusInternalServerError, fmt.Sprintf("Unknown error: %v", err), logger) } -func notFound(w http.ResponseWriter, id int, logger chronograf.Logger) { - Error(w, http.StatusNotFound, fmt.Sprintf("ID %d not found", id), logger) +func notFound(w http.ResponseWriter, id interface{}, logger chronograf.Logger) { + Error(w, http.StatusNotFound, fmt.Sprintf("ID %v not found", id), logger) } func paramID(key string, r *http.Request) (int, error) { diff --git a/server/organizations.go b/server/organizations.go index ed8df2cb8..5b2227953 100644 --- a/server/organizations.go +++ b/server/organizations.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "strconv" "github.com/bouk/httprouter" "github.com/influxdata/chronograf" @@ -13,10 +12,6 @@ import ( "github.com/influxdata/chronograf/roles" ) -func parseOrganizationID(id string) (uint64, error) { - return strconv.ParseUint(id, 10, 64) -} - type organizationRequest struct { Name string `json:"name"` DefaultRole string `json:"defaultRole"` @@ -68,7 +63,7 @@ func newOrganizationResponse(o *chronograf.Organization) *organizationResponse { return &organizationResponse{ Organization: *o, Links: selfLinks{ - Self: fmt.Sprintf("/chronograf/v1/organizations/%d", o.ID), + Self: fmt.Sprintf("/chronograf/v1/organizations/%s", o.ID), }, } } @@ -144,15 +139,14 @@ func (s *Service) NewOrganization(w http.ResponseWriter, r *http.Request) { return } - orgID := fmt.Sprintf("%d", res.ID) user.Roles = []chronograf.Role{ { - Organization: orgID, + Organization: res.ID, Name: roles.AdminRoleName, }, } - orgCtx := context.WithValue(ctx, organizations.ContextKey, orgID) + orgCtx := context.WithValue(ctx, organizations.ContextKey, res.ID) _, err = s.Store.Users(orgCtx).Add(orgCtx, user) if err != nil { // Best attempt at cleanup the organization if there were any errors adding user to org @@ -171,12 +165,7 @@ func (s *Service) NewOrganization(w http.ResponseWriter, r *http.Request) { func (s *Service) OrganizationID(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - idStr := httprouter.GetParamFromContext(ctx, "id") - id, err := parseOrganizationID(idStr) - if err != nil { - Error(w, http.StatusBadRequest, fmt.Sprintf("invalid organization id: %s", err.Error()), s.Logger) - return - } + id := httprouter.GetParamFromContext(ctx, "id") org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) if err != nil { @@ -202,12 +191,7 @@ func (s *Service) UpdateOrganization(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - idStr := httprouter.GetParamFromContext(ctx, "id") - id, err := parseOrganizationID(idStr) - if err != nil { - Error(w, http.StatusBadRequest, fmt.Sprintf("invalid organization id: %s", err.Error()), s.Logger) - return - } + id := httprouter.GetParamFromContext(ctx, "id") org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) if err != nil { @@ -242,12 +226,7 @@ func (s *Service) UpdateOrganization(w http.ResponseWriter, r *http.Request) { // RemoveOrganization removes an organization in the organizations store func (s *Service) RemoveOrganization(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - idStr := httprouter.GetParamFromContext(ctx, "id") - id, err := parseOrganizationID(idStr) - if err != nil { - Error(w, http.StatusBadRequest, fmt.Sprintf("invalid organization id: %s", err.Error()), s.Logger) - return - } + id := httprouter.GetParamFromContext(ctx, "id") org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) if err != nil { diff --git a/server/organizations_test.go b/server/organizations_test.go index 8d4870149..3315d981b 100644 --- a/server/organizations_test.go +++ b/server/organizations_test.go @@ -50,9 +50,9 @@ func TestService_OrganizationID(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { switch *q.ID { - case 1337: + case "1337": return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The Good Place", Public: false, }, nil @@ -139,12 +139,12 @@ func TestService_Organizations(t *testing.T) { AllF: func(ctx context.Context) ([]chronograf.Organization, error) { return []chronograf.Organization{ chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The Good Place", Public: false, }, chronograf.Organization{ - ID: 100, + ID: "100", Name: "The Bad Place", Public: false, }, @@ -228,7 +228,7 @@ func TestService_UpdateOrganization(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The Good Place", DefaultRole: roles.ViewerRoleName, Public: false, @@ -262,7 +262,7 @@ func TestService_UpdateOrganization(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 0, + ID: "0", Name: "The Good Place", DefaultRole: roles.ViewerRoleName, Public: true, @@ -294,7 +294,7 @@ func TestService_UpdateOrganization(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The Good Place", DefaultRole: roles.ViewerRoleName, Public: true, @@ -328,7 +328,7 @@ func TestService_UpdateOrganization(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The Good Place", DefaultRole: roles.MemberRoleName, Public: false, @@ -475,9 +475,9 @@ func TestService_RemoveOrganization(t *testing.T) { }, GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { switch *q.ID { - case 1337: + case "1337": return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The Good Place", }, nil default: @@ -573,7 +573,7 @@ func TestService_NewOrganization(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ AddF: func(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The Good Place", Public: false, }, nil @@ -612,7 +612,7 @@ func TestService_NewOrganization(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ AddF: func(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The Good Place", }, nil }, @@ -654,7 +654,7 @@ func TestService_NewOrganization(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ AddF: func(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 1337, + ID: "1337", Name: "The Good Place", }, nil }, diff --git a/server/queries.go b/server/queries.go index fbd20635b..c48d5ef49 100644 --- a/server/queries.go +++ b/server/queries.go @@ -62,7 +62,6 @@ func (s *Service) Queries(w http.ResponseWriter, r *http.Request) { invalidJSON(w, s.Logger) return } - res := QueriesResponse{ Queries: make([]QueryResponse, len(req.Queries)), } diff --git a/server/routes.go b/server/routes.go index 109601d6e..993d4d749 100644 --- a/server/routes.go +++ b/server/routes.go @@ -35,7 +35,9 @@ type getRoutesResponse struct { Mappings string `json:"mappings"` // Location of the application mappings endpoint Sources string `json:"sources"` // Location of the sources endpoint Me string `json:"me"` // Location of the me endpoint + Environment string `json:"environment"` // Location of the environement endpoint Dashboards string `json:"dashboards"` // Location of the dashboards endpoint + Config getConfigLinksResponse `json:"config"` // Location of the config endpoint and its various sections Auth []AuthRoute `json:"auth"` // Location of all auth routes. Logout *string `json:"logout,omitempty"` // Location of the logout route for all auth routes ExternalLinks getExternalLinksResponse `json:"external"` // All external links for the client to use @@ -66,9 +68,14 @@ func (a *AllRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) { Users: "/chronograf/v1/users", Organizations: "/chronograf/v1/organizations", Me: "/chronograf/v1/me", + Environment: "/chronograf/v1/env", Mappings: "/chronograf/v1/mappings", Dashboards: "/chronograf/v1/dashboards", - Auth: make([]AuthRoute, len(a.AuthRoutes)), // We want to return at least an empty array, rather than null + Config: getConfigLinksResponse{ + Self: "/chronograf/v1/config", + Auth: "/chronograf/v1/config/auth", + }, + Auth: make([]AuthRoute, len(a.AuthRoutes)), // We want to return at least an empty array, rather than null ExternalLinks: getExternalLinksResponse{ StatusFeed: &a.StatusFeed, CustomLinks: customLinks, diff --git a/server/routes_test.go b/server/routes_test.go index f8aff689f..aeadcd584 100644 --- a/server/routes_test.go +++ b/server/routes_test.go @@ -29,7 +29,7 @@ func TestAllRoutes(t *testing.T) { if err := json.Unmarshal(body, &routes); err != nil { t.Error("TestAllRoutes not able to unmarshal JSON response") } - want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[],"external":{"statusFeed":""}} + want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[],"external":{"statusFeed":""}} ` if want != string(body) { t.Errorf("TestAllRoutes\nwanted\n*%s*\ngot\n*%s*", want, string(body)) @@ -67,7 +67,7 @@ func TestAllRoutesWithAuth(t *testing.T) { if err := json.Unmarshal(body, &routes); err != nil { t.Error("TestAllRoutesWithAuth not able to unmarshal JSON response") } - want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[{"name":"github","label":"GitHub","login":"/oauth/github/login","logout":"/oauth/github/logout","callback":"/oauth/github/callback"}],"logout":"/oauth/logout","external":{"statusFeed":""}} + want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[{"name":"github","label":"GitHub","login":"/oauth/github/login","logout":"/oauth/github/logout","callback":"/oauth/github/callback"}],"logout":"/oauth/logout","external":{"statusFeed":""}} ` if want != string(body) { t.Errorf("TestAllRoutesWithAuth\nwanted\n*%s*\ngot\n*%s*", want, string(body)) @@ -100,7 +100,7 @@ func TestAllRoutesWithExternalLinks(t *testing.T) { if err := json.Unmarshal(body, &routes); err != nil { t.Error("TestAllRoutesWithExternalLinks not able to unmarshal JSON response") } - want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","dashboards":"/chronograf/v1/dashboards","auth":[],"external":{"statusFeed":"http://pineapple.life/feed.json","custom":[{"name":"cubeapple","url":"https://cube.apple"}]}} + want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/users","organizations":"/chronograf/v1/organizations","mappings":"/chronograf/v1/mappings","sources":"/chronograf/v1/sources","me":"/chronograf/v1/me","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[],"external":{"statusFeed":"http://pineapple.life/feed.json","custom":[{"name":"cubeapple","url":"https://cube.apple"}]}} ` if want != string(body) { t.Errorf("TestAllRoutesWithExternalLinks\nwanted\n*%s*\ngot\n*%s*", want, string(body)) diff --git a/server/server.go b/server/server.go index 7687a4f87..ee06bada9 100644 --- a/server/server.go +++ b/server/server.go @@ -16,10 +16,10 @@ import ( "github.com/influxdata/chronograf" "github.com/influxdata/chronograf/bolt" + idgen "github.com/influxdata/chronograf/id" "github.com/influxdata/chronograf/influx" clog "github.com/influxdata/chronograf/log" "github.com/influxdata/chronograf/oauth2" - "github.com/influxdata/chronograf/uuid" client "github.com/influxdata/usage-client/v1" flags "github.com/jessevdk/go-flags" "github.com/tylerb/graceful" @@ -52,12 +52,11 @@ type Server struct { NewSources string `long:"new-sources" description:"Config for adding a new InfluxDB source and Kapacitor server, in JSON as an array of objects, and surrounded by single quotes. E.g. --new-sources='[{\"influxdb\":{\"name\":\"Influx 1\",\"username\":\"user1\",\"password\":\"pass1\",\"url\":\"http://localhost:8086\",\"metaUrl\":\"http://metaurl.com\",\"type\":\"influx-enterprise\",\"insecureSkipVerify\":false,\"default\":true,\"telegraf\":\"telegraf\",\"sharedSecret\":\"cubeapples\"},\"kapacitor\":{\"name\":\"Kapa 1\",\"url\":\"http://localhost:9092\",\"active\":true}}]'" env:"NEW_SOURCES" hidden:"true"` - Develop bool `short:"d" long:"develop" description:"Run server in develop mode."` - BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"` - CannedPath string `short:"c" long:"canned-path" description:"Path to directory of pre-canned application layouts (/usr/share/chronograf/canned)" env:"CANNED_PATH" default:"canned"` - TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` - AuthDuration time.Duration `long:"auth-duration" default:"720h" description:"Total duration of cookie life for authentication (in hours). 0 means authentication expires on browser close." env:"AUTH_DURATION"` - SuperAdminFirstUserOnly bool `long:"superadmin-first-user-only" description:"All new users will not be given the SuperAdmin status" env:"SUPERADMIN_FIRST_USER_ONLY"` + Develop bool `short:"d" long:"develop" description:"Run server in develop mode."` + BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"` + CannedPath string `short:"c" long:"canned-path" description:"Path to directory of pre-canned dashboards and application layouts (/usr/share/chronograf/canned)" env:"CANNED_PATH" default:"canned"` + TokenSecret string `short:"t" long:"token-secret" description:"Secret to sign tokens" env:"TOKEN_SECRET"` + AuthDuration time.Duration `long:"auth-duration" default:"720h" description:"Total duration of cookie life for authentication (in hours). 0 means authentication expires on browser close." env:"AUTH_DURATION"` GithubClientID string `short:"i" long:"github-client-id" description:"Github Client ID for OAuth 2 support" env:"GH_CLIENT_ID"` GithubClientSecret string `short:"s" long:"github-client-secret" description:"Github Client Secret for OAuth 2 support" env:"GH_CLIENT_SECRET"` @@ -87,15 +86,16 @@ type Server struct { Auth0ClientSecret string `long:"auth0-client-secret" description:"Auth0 Client Secret for OAuth2 support" env:"AUTH0_CLIENT_SECRET"` Auth0Organizations []string `long:"auth0-organizations" description:"Auth0 organizations permitted to access Chronograf (comma separated)" env:"AUTH0_ORGS" env-delim:","` - StatusFeedURL string `long:"status-feed-url" description:"URL of a JSON Feed to display as a News Feed on the client Status page." default:"https://www.influxdata.com/feed/json" env:"STATUS_FEED_URL"` - CustomLinks map[string]string `long:"custom-link" description:"Custom link to be added to the client User menu. Multiple links can be added by using multiple of the same flag with different 'name:url' values, or as an environment variable with comma-separated 'name:url' values. E.g. via flags: '--custom-link=InfluxData:https://www.influxdata.com --custom-link=Chronograf:https://github.com/influxdata/chronograf'. E.g. via environment variable: 'export CUSTOM_LINKS=InfluxData:https://www.influxdata.com,Chronograf:https://github.com/influxdata/chronograf'" env:"CUSTOM_LINKS" env-delim:","` + StatusFeedURL string `long:"status-feed-url" description:"URL of a JSON Feed to display as a News Feed on the client Status page." default:"https://www.influxdata.com/feed/json" env:"STATUS_FEED_URL"` + CustomLinks map[string]string `long:"custom-link" description:"Custom link to be added to the client User menu. Multiple links can be added by using multiple of the same flag with different 'name:url' values, or as an environment variable with comma-separated 'name:url' values. E.g. via flags: '--custom-link=InfluxData:https://www.influxdata.com --custom-link=Chronograf:https://github.com/influxdata/chronograf'. E.g. via environment variable: 'export CUSTOM_LINKS=InfluxData:https://www.influxdata.com,Chronograf:https://github.com/influxdata/chronograf'" env:"CUSTOM_LINKS" env-delim:","` + TelegrafSystemInterval time.Duration `long:"telegraf-system-interval" default:"1m" description:"Duration used in the GROUP BY time interval for the hosts list" env:"TELEGRAF_SYSTEM_INTERVAL"` 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:"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"` PrefixRoutes bool `long:"prefix-routes" description:"Force chronograf server to require that all requests to it are prefixed with the value set in --basepath" env:"PREFIX_ROUTES"` ShowVersion bool `short:"v" long:"version" description:"Show Chronograf version info"` - BuildInfo BuildInfo + BuildInfo chronograf.BuildInfo Listener net.Listener handler http.Handler } @@ -123,6 +123,7 @@ func (s *Server) UseHeroku() bool { return s.TokenSecret != "" && s.HerokuClientID != "" && s.HerokuSecret != "" } +// UseAuth0 validates the CLI parameters to enable Auth0 oauth support func (s *Server) UseAuth0() bool { return s.Auth0ClientID != "" && s.Auth0ClientSecret != "" } @@ -231,12 +232,6 @@ func (s *Server) genericRedirectURL() string { return publicURL.String() } -// BuildInfo is sent to the usage client to track versions and commits -type BuildInfo struct { - Version string - Commit string -} - func (s *Server) useAuth() bool { return s.UseGithub() || s.UseGoogle() || s.UseHeroku() || s.UseGenericOAuth2() || s.UseAuth0() } @@ -276,24 +271,52 @@ func (s *Server) NewListener() (net.Listener, error) { return listener, nil } +type builders struct { + Layouts LayoutBuilder + Sources SourcesBuilder + Kapacitors KapacitorBuilder + Dashboards DashboardBuilder + Organizations OrganizationBuilder +} + +func (s *Server) newBuilders(logger chronograf.Logger) builders { + return builders{ + Layouts: &MultiLayoutBuilder{ + Logger: logger, + UUID: &idgen.UUID{}, + CannedPath: s.CannedPath, + }, + Dashboards: &MultiDashboardBuilder{ + Logger: logger, + ID: idgen.NewTime(), + Path: s.CannedPath, + }, + Sources: &MultiSourceBuilder{ + InfluxDBURL: s.InfluxDBURL, + InfluxDBUsername: s.InfluxDBUsername, + InfluxDBPassword: s.InfluxDBPassword, + Logger: logger, + ID: idgen.NewTime(), + Path: s.CannedPath, + }, + Kapacitors: &MultiKapacitorBuilder{ + KapacitorURL: s.KapacitorURL, + KapacitorUsername: s.KapacitorUsername, + KapacitorPassword: s.KapacitorPassword, + Logger: logger, + ID: idgen.NewTime(), + Path: s.CannedPath, + }, + Organizations: &MultiOrganizationBuilder{ + Logger: logger, + Path: s.CannedPath, + }, + } +} + // Serve starts and runs the chronograf server func (s *Server) Serve(ctx context.Context) error { logger := clog.New(clog.ParseLevel(s.LogLevel)) - layoutBuilder := &MultiLayoutBuilder{ - Logger: logger, - UUID: &uuid.V4{}, - CannedPath: s.CannedPath, - } - sourcesBuilder := &MultiSourceBuilder{ - InfluxDBURL: s.InfluxDBURL, - InfluxDBUsername: s.InfluxDBUsername, - InfluxDBPassword: s.InfluxDBPassword, - } - kapacitorBuilder := &MultiKapacitorBuilder{ - KapacitorURL: s.KapacitorURL, - KapacitorUsername: s.KapacitorUsername, - KapacitorPassword: s.KapacitorPassword, - } _, err := NewCustomLinks(s.CustomLinks) if err != nil { logger. @@ -302,8 +325,10 @@ func (s *Server) Serve(ctx context.Context) error { Error(err) return err } - service := openService(ctx, s.BoltPath, layoutBuilder, sourcesBuilder, kapacitorBuilder, logger, s.useAuth()) - service.SuperAdminFirstUserOnly = s.SuperAdminFirstUserOnly + service := openService(ctx, s.BuildInfo, s.BoltPath, s.newBuilders(logger), logger, s.useAuth()) + service.Env = chronograf.Environment{ + TelegrafSystemInterval: s.TelegrafSystemInterval, + } if err := service.HandleNewSources(ctx, s.NewSources); err != nil { logger. WithField("component", "server"). @@ -398,17 +423,18 @@ func (s *Server) Serve(ctx context.Context) error { return nil } -func openService(ctx context.Context, boltPath string, lBuilder LayoutBuilder, sBuilder SourcesBuilder, kapBuilder KapacitorBuilder, logger chronograf.Logger, useAuth bool) Service { +func openService(ctx context.Context, buildInfo chronograf.BuildInfo, boltPath string, builder builders, logger chronograf.Logger, useAuth bool) Service { db := bolt.NewClient() db.Path = boltPath - if err := db.Open(ctx); err != nil { + + if err := db.Open(ctx, logger, buildInfo, bolt.WithBackup()); err != nil { logger. WithField("component", "boltstore"). - Error("Unable to open boltdb; is there a chronograf already running? ", err) + Error(err) os.Exit(1) } - layouts, err := lBuilder.Build(db.LayoutsStore) + layouts, err := builder.Layouts.Build(db.LayoutsStore) if err != nil { logger. WithField("component", "LayoutsStore"). @@ -416,7 +442,14 @@ func openService(ctx context.Context, boltPath string, lBuilder LayoutBuilder, s os.Exit(1) } - sources, err := sBuilder.Build(db.SourcesStore) + dashboards, err := builder.Dashboards.Build(db.DashboardsStore) + if err != nil { + logger. + WithField("component", "DashboardsStore"). + Error("Unable to construct a MultiDashboardsStore", err) + os.Exit(1) + } + sources, err := builder.Sources.Build(db.SourcesStore) if err != nil { logger. WithField("component", "SourcesStore"). @@ -424,7 +457,7 @@ func openService(ctx context.Context, boltPath string, lBuilder LayoutBuilder, s os.Exit(1) } - kapacitors, err := kapBuilder.Build(db.ServersStore) + kapacitors, err := builder.Kapacitors.Build(db.ServersStore) if err != nil { logger. WithField("component", "KapacitorStore"). @@ -432,16 +465,24 @@ func openService(ctx context.Context, boltPath string, lBuilder LayoutBuilder, s os.Exit(1) } + organizations, err := builder.Organizations.Build(db.OrganizationsStore) + if err != nil { + logger. + WithField("component", "OrganizationsStore"). + Error("Unable to construct a MultiOrganizationStore", err) + os.Exit(1) + } + return Service{ TimeSeriesClient: &InfluxClient{}, Store: &Store{ + LayoutsStore: layouts, + DashboardsStore: dashboards, SourcesStore: sources, ServersStore: kapacitors, + OrganizationsStore: organizations, UsersStore: db.UsersStore, - OrganizationsStore: db.OrganizationsStore, - LayoutsStore: layouts, - DashboardsStore: db.DashboardsStore, - //OrganizationUsersStore: organizations.NewUsersStore(db.UsersStore), + ConfigStore: db.ConfigStore, }, Logger: logger, UseAuth: useAuth, @@ -450,7 +491,7 @@ func openService(ctx context.Context, boltPath string, lBuilder LayoutBuilder, s } // reportUsageStats starts periodic server reporting. -func reportUsageStats(bi BuildInfo, logger chronograf.Logger) { +func reportUsageStats(bi chronograf.BuildInfo, logger chronograf.Logger) { rand.Seed(time.Now().UTC().UnixNano()) serverID := strconv.FormatUint(uint64(rand.Int63()), 10) reporter := client.New("") diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 000000000..3f6c11b16 --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,19 @@ +package server + +import ( + "context" + "net/http" + + "github.com/bouk/httprouter" +) + +func WithContext(ctx context.Context, r *http.Request, kv map[string]string) *http.Request { + params := make(httprouter.Params, 0, len(kv)) + for k, v := range kv { + params = append(params, httprouter.Param{ + Key: k, + Value: v, + }) + } + return r.WithContext(httprouter.WithParams(ctx, params)) +} diff --git a/server/service.go b/server/service.go index 3b127835d..e1df8da8e 100644 --- a/server/service.go +++ b/server/service.go @@ -11,12 +11,12 @@ import ( // Service handles REST calls to the persistence type Service struct { - Store DataStore - TimeSeriesClient TimeSeriesClient - Logger chronograf.Logger - UseAuth bool - SuperAdminFirstUserOnly bool - Databases chronograf.Databases + Store DataStore + TimeSeriesClient TimeSeriesClient + Logger chronograf.Logger + UseAuth bool + Env chronograf.Environment + Databases chronograf.Databases } // TimeSeriesClient returns the correct client for a time series database. diff --git a/server/sources.go b/server/sources.go index fe31039ed..82081cd29 100644 --- a/server/sources.go +++ b/server/sources.go @@ -78,7 +78,7 @@ func (s *Service) NewSource(w http.ResponseWriter, r *http.Request) { return } - if err := ValidSourceRequest(&src, fmt.Sprintf("%d", defaultOrg.ID)); err != nil { + if err := ValidSourceRequest(&src, defaultOrg.ID); err != nil { invalidData(w, err, s.Logger) return } @@ -271,7 +271,7 @@ func (s *Service) UpdateSource(w http.ResponseWriter, r *http.Request) { return } - if err := ValidSourceRequest(&src, fmt.Sprintf("%d", defaultOrg.ID)); err != nil { + if err := ValidSourceRequest(&src, defaultOrg.ID); err != nil { invalidData(w, err, s.Logger) return } @@ -346,7 +346,7 @@ func (s *Service) HandleNewSources(ctx context.Context, input string) error { } for _, sk := range srcsKaps { - if err := ValidSourceRequest(&sk.Source, fmt.Sprintf("%d", defaultOrg.ID)); err != nil { + if err := ValidSourceRequest(&sk.Source, defaultOrg.ID); err != nil { return err } // Add any new sources and kapacitors as specified via server flag diff --git a/server/stores.go b/server/stores.go index 2c9d811b7..7f9d8ac52 100644 --- a/server/stores.go +++ b/server/stores.go @@ -89,6 +89,7 @@ type DataStore interface { Users(ctx context.Context) chronograf.UsersStore Organizations(ctx context.Context) chronograf.OrganizationsStore Dashboards(ctx context.Context) chronograf.DashboardsStore + Config(ctx context.Context) chronograf.ConfigStore } // ensure that Store implements a DataStore @@ -102,6 +103,7 @@ type Store struct { UsersStore chronograf.UsersStore DashboardsStore chronograf.DashboardsStore OrganizationsStore chronograf.OrganizationsStore + ConfigStore chronograf.ConfigStore } // Sources returns a noop.SourcesStore if the context has no organization specified @@ -178,3 +180,14 @@ func (s *Store) Organizations(ctx context.Context) chronograf.OrganizationsStore } return &noop.OrganizationsStore{} } + +// Config returns the underlying ConfigStore. +func (s *Store) Config(ctx context.Context) chronograf.ConfigStore { + if isServer := hasServerContext(ctx); isServer { + return s.ConfigStore + } + if isSuperAdmin := hasSuperAdminContext(ctx); isSuperAdmin { + return s.ConfigStore + } + return &noop.ConfigStore{} +} diff --git a/server/stores_test.go b/server/stores_test.go index dc22daea2..441cc3929 100644 --- a/server/stores_test.go +++ b/server/stores_test.go @@ -237,7 +237,7 @@ func TestStore_OrganizationsAdd(t *testing.T) { OrganizationsStore chronograf.OrganizationsStore } type args struct { - orgID uint64 + orgID string serverContext bool organization string user *chronograf.User @@ -259,7 +259,7 @@ func TestStore_OrganizationsAdd(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 21, + ID: "21", Name: "my sweet name", DefaultRole: "viewer", }, nil @@ -268,11 +268,11 @@ func TestStore_OrganizationsAdd(t *testing.T) { }, args: args{ serverContext: true, - orgID: 21, + orgID: "21", }, wants: wants{ organization: &chronograf.Organization{ - ID: 21, + ID: "21", Name: "my sweet name", DefaultRole: "viewer", }, @@ -284,7 +284,7 @@ func TestStore_OrganizationsAdd(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 21, + ID: "21", Name: "my sweet name", DefaultRole: "viewer", }, nil @@ -299,11 +299,11 @@ func TestStore_OrganizationsAdd(t *testing.T) { Scheme: "oauth2", SuperAdmin: true, }, - orgID: 21, + orgID: "21", }, wants: wants{ organization: &chronograf.Organization{ - ID: 21, + ID: "21", Name: "my sweet name", DefaultRole: "viewer", }, @@ -315,7 +315,7 @@ func TestStore_OrganizationsAdd(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 21, + ID: "21", Name: "my sweet name", DefaultRole: "viewer", }, nil @@ -329,7 +329,7 @@ func TestStore_OrganizationsAdd(t *testing.T) { Provider: "github", Scheme: "oauth2", }, - orgID: 21, + orgID: "21", }, wants: wants{ err: true, @@ -341,7 +341,7 @@ func TestStore_OrganizationsAdd(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 21, + ID: "22", Name: "my sweet name", DefaultRole: "viewer", }, nil @@ -355,12 +355,12 @@ func TestStore_OrganizationsAdd(t *testing.T) { Provider: "github", Scheme: "oauth2", }, - organization: "21", - orgID: 21, + organization: "22", + orgID: "22", }, wants: wants{ organization: &chronograf.Organization{ - ID: 21, + ID: "22", Name: "my sweet name", DefaultRole: "viewer", }, @@ -372,7 +372,7 @@ func TestStore_OrganizationsAdd(t *testing.T) { OrganizationsStore: &mocks.OrganizationsStore{ GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { return &chronograf.Organization{ - ID: 22, + ID: "22", Name: "my sweet name", DefaultRole: "viewer", }, nil @@ -387,7 +387,7 @@ func TestStore_OrganizationsAdd(t *testing.T) { Scheme: "oauth2", }, organization: "21", - orgID: 21, + orgID: "21", }, wants: wants{ err: true, diff --git a/server/swagger.json b/server/swagger.json index 8204ea9d3..0dc228c95 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -3,7 +3,7 @@ "info": { "title": "Chronograf", "description": "API endpoints for Chronograf", - "version": "1.4.0.0-beta1" + "version": "1.4.0.0" }, "schemes": ["http"], "basePath": "/chronograf/v1", diff --git a/server/templates.go b/server/templates.go index f16a29eba..8b525f6ad 100644 --- a/server/templates.go +++ b/server/templates.go @@ -7,7 +7,7 @@ import ( "github.com/bouk/httprouter" "github.com/influxdata/chronograf" - "github.com/influxdata/chronograf/uuid" + idgen "github.com/influxdata/chronograf/id" ) // ValidTemplateRequest checks if the request sent to the server is the correct format. @@ -111,7 +111,7 @@ func (s *Service) NewTemplate(w http.ResponseWriter, r *http.Request) { return } - ids := uuid.V4{} + ids := idgen.UUID{} tid, err := ids.Generate() if err != nil { msg := fmt.Sprintf("Error creating template ID for dashboard %d: %v", id, err) diff --git a/server/users.go b/server/users.go index ab2c13bc8..3d82406d8 100644 --- a/server/users.go +++ b/server/users.go @@ -54,9 +54,6 @@ func (r *userRequest) ValidRoles() error { if r.Organization == "" { return fmt.Errorf("no organization was provided") } - if _, err := parseOrganizationID(r.Organization); err != nil { - return fmt.Errorf("failed to parse organization ID: %v", err) - } if _, ok := orgs[r.Organization]; ok { return fmt.Errorf("duplicate organization %q in roles", r.Organization) } @@ -157,6 +154,14 @@ func (s *Service) NewUser(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() + + serverCtx := serverContext(ctx) + cfg, err := s.Store.Config(serverCtx).Get(serverCtx) + if err != nil { + Error(w, http.StatusInternalServerError, err.Error(), s.Logger) + return + } + user := &chronograf.User{ Name: req.Name, Provider: req.Provider, @@ -164,6 +169,10 @@ func (s *Service) NewUser(w http.ResponseWriter, r *http.Request) { Roles: req.Roles, } + if cfg.Auth.SuperAdminNewUsers { + req.SuperAdmin = true + } + if err := setSuperAdmin(ctx, req, user); err != nil { Error(w, http.StatusUnauthorized, err.Error(), s.Logger) return @@ -264,6 +273,21 @@ func (s *Service) UpdateUser(w http.ResponseWriter, r *http.Request) { return } + // Don't allow SuperAdmins to modify their own SuperAdmin status. + // Allowing them to do so could result in an application where there + // are no super admins. + ctxUser, ok := hasUserContext(ctx) + if !ok { + Error(w, http.StatusInternalServerError, "failed to retrieve user from context", s.Logger) + return + } + // If the user being updated is the user making the request and they are + // changing their SuperAdmin status, return an unauthorized error + if ctxUser.ID == u.ID && u.SuperAdmin == true && req.SuperAdmin == false { + Error(w, http.StatusUnauthorized, "user cannot modify their own SuperAdmin status", s.Logger) + return + } + if err := setSuperAdmin(ctx, req, u); err != nil { Error(w, http.StatusUnauthorized, err.Error(), s.Logger) return @@ -312,7 +336,7 @@ func setSuperAdmin(ctx context.Context, req userRequest, user *chronograf.User) } else if !isSuperAdmin && (user.SuperAdmin != req.SuperAdmin) { // If req.SuperAdmin has been set, and the request was not made with the SuperAdmin // context, return error - return fmt.Errorf("User does not have authorization required to set SuperAdmin status") + return fmt.Errorf("User does not have authorization required to set SuperAdmin status. See https://github.com/influxdata/chronograf/issues/2601 for more information.") } return nil diff --git a/server/users_test.go b/server/users_test.go index 17155ecbe..7641d4922 100644 --- a/server/users_test.go +++ b/server/users_test.go @@ -112,8 +112,9 @@ func TestService_UserID(t *testing.T) { func TestService_NewUser(t *testing.T) { type fields struct { - UsersStore chronograf.UsersStore - Logger chronograf.Logger + UsersStore chronograf.UsersStore + ConfigStore chronograf.ConfigStore + Logger chronograf.Logger } type args struct { w *httptest.ResponseRecorder @@ -146,6 +147,13 @@ func TestService_NewUser(t *testing.T) { }, fields: fields{ Logger: log.New(log.DebugLevel), + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, UsersStore: &mocks.UsersStore{ AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { return &chronograf.User{ @@ -189,6 +197,13 @@ func TestService_NewUser(t *testing.T) { }, fields: fields{ Logger: log.New(log.DebugLevel), + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, UsersStore: &mocks.UsersStore{ AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { return &chronograf.User{ @@ -241,6 +256,13 @@ func TestService_NewUser(t *testing.T) { }, fields: fields{ Logger: log.New(log.DebugLevel), + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, UsersStore: &mocks.UsersStore{ AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { return &chronograf.User{ @@ -291,6 +313,13 @@ func TestService_NewUser(t *testing.T) { }, fields: fields{ Logger: log.New(log.DebugLevel), + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, UsersStore: &mocks.UsersStore{ AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { return &chronograf.User{ @@ -305,7 +334,7 @@ func TestService_NewUser(t *testing.T) { }, wantStatus: http.StatusUnauthorized, wantContentType: "application/json", - wantBody: `{"code":401,"message":"User does not have authorization required to set SuperAdmin status"}`, + wantBody: `{"code":401,"message":"User does not have authorization required to set SuperAdmin status. See https://github.com/influxdata/chronograf/issues/2601 for more information."}`, }, { name: "Create a new SuperAdmin User - as superadmin", @@ -332,6 +361,13 @@ func TestService_NewUser(t *testing.T) { }, fields: fields{ Logger: log.New(log.DebugLevel), + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, UsersStore: &mocks.UsersStore{ AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { return &chronograf.User{ @@ -349,13 +385,56 @@ func TestService_NewUser(t *testing.T) { wantContentType: "application/json", wantBody: `{"id":"1338","superAdmin":true,"name":"bob","provider":"github","scheme":"oauth2","roles":[],"links":{"self":"/chronograf/v1/users/1338"}}`, }, + { + name: "Create a new User with SuperAdminNewUsers: true in ConfigStore", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://any.url", + nil, + ), + user: &userRequest{ + Name: "bob", + Provider: "github", + Scheme: "oauth2", + }, + userKeyUser: &chronograf.User{ + ID: 0, + Name: "coolUser", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + }, + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { + user.ID = 1338 + return user, nil + }, + }, + }, + wantStatus: http.StatusCreated, + wantContentType: "application/json", + wantBody: `{"id":"1338","superAdmin":true,"name":"bob","provider":"github","scheme":"oauth2","roles":[],"links":{"self":"/chronograf/v1/users/1338"}}`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := &Service{ Store: &mocks.Store{ - UsersStore: tt.fields.UsersStore, + UsersStore: tt.fields.UsersStore, + ConfigStore: tt.fields.ConfigStore, }, Logger: tt.fields.Logger, } @@ -588,6 +667,13 @@ func TestService_UpdateUser(t *testing.T) { "http://any.url", nil, ), + userKeyUser: &chronograf.User{ + ID: 0, + Name: "coolUser", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + }, user: &userRequest{ ID: 1336, Roles: []chronograf.Role{ @@ -636,6 +722,13 @@ func TestService_UpdateUser(t *testing.T) { "http://any.url", nil, ), + userKeyUser: &chronograf.User{ + ID: 0, + Name: "coolUser", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + }, user: &userRequest{ ID: 1336, Roles: []chronograf.Role{ @@ -707,6 +800,119 @@ func TestService_UpdateUser(t *testing.T) { wantContentType: "application/json", wantBody: `{"code":422,"message":"duplicate organization \"1\" in roles"}`, }, + { + name: "SuperAdmin modifying their own SuperAdmin Status - user missing from context", + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + UpdateF: func(ctx context.Context, user *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + switch *q.ID { + case 1336: + return &chronograf.User{ + ID: 1336, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1", + }, + }, + }, nil + default: + return nil, fmt.Errorf("User with ID %d not found", *q.ID) + } + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil, + ), + user: &userRequest{ + ID: 1336, + SuperAdmin: false, + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + }, + }, + }, + id: "1336", + wantStatus: http.StatusInternalServerError, + wantContentType: "application/json", + wantBody: `{"code":500,"message":"failed to retrieve user from context"}`, + }, + { + name: "SuperAdmin modifying their own SuperAdmin Status", + fields: fields{ + Logger: log.New(log.DebugLevel), + UsersStore: &mocks.UsersStore{ + UpdateF: func(ctx context.Context, user *chronograf.User) error { + return nil + }, + GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) { + switch *q.ID { + case 1336: + return &chronograf.User{ + ID: 1336, + Name: "bobbetta", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1", + }, + }, + }, nil + default: + return nil, fmt.Errorf("User with ID %d not found", *q.ID) + } + }, + }, + }, + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "PATCH", + "http://any.url", + nil, + ), + user: &userRequest{ + ID: 1336, + SuperAdmin: false, + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + }, + }, + userKeyUser: &chronograf.User{ + ID: 1336, + Name: "coolUser", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + }, + }, + id: "1336", + wantStatus: http.StatusUnauthorized, + wantContentType: "application/json", + wantBody: `{"code":401,"message":"user cannot modify their own SuperAdmin status"}`, + }, { name: "Update a SuperAdmin's Roles - without super admin context", fields: fields{ @@ -821,7 +1027,7 @@ func TestService_UpdateUser(t *testing.T) { id: "1336", wantStatus: http.StatusUnauthorized, wantContentType: "application/json", - wantBody: `{"code":401,"message":"User does not have authorization required to set SuperAdmin status"}`, + wantBody: `{"code":401,"message":"User does not have authorization required to set SuperAdmin status. See https://github.com/influxdata/chronograf/issues/2601 for more information."}`, }, { name: "Update a Chronograf user to super admin - with super admin context", @@ -1077,25 +1283,6 @@ func TestUserRequest_ValidCreate(t *testing.T) { wantErr: false, err: nil, }, - { - name: "Invalid - bad organization", - args: args{ - u: &userRequest{ - ID: 1337, - Name: "billietta", - Provider: "auth0", - Scheme: "oauth2", - Roles: []chronograf.Role{ - { - Name: roles.EditorRoleName, - Organization: "l", // this is the character L not integer One - }, - }, - }, - }, - wantErr: true, - err: fmt.Errorf("failed to parse organization ID: strconv.ParseUint: parsing \"l\": invalid syntax"), - }, { name: "Invalid – Name missing", args: args{ @@ -1240,25 +1427,6 @@ func TestUserRequest_ValidUpdate(t *testing.T) { wantErr: true, err: fmt.Errorf("No Roles to update"), }, - { - name: "Invalid - bad organization", - args: args{ - u: &userRequest{ - ID: 1337, - Name: "billietta", - Provider: "auth0", - Scheme: "oauth2", - Roles: []chronograf.Role{ - { - Name: roles.EditorRoleName, - Organization: "l", // this is the character L not integer One - }, - }, - }, - }, - wantErr: true, - err: fmt.Errorf("failed to parse organization ID: strconv.ParseUint: parsing \"l\": invalid syntax"), - }, { name: "Invalid - bad role name", args: args{ diff --git a/ui/package.json b/ui/package.json index 57af01496..466255a7d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "chronograf-ui", - "version": "1.4.0-0beta1", + "version": "1.4.0-0", "private": false, "license": "AGPL-3.0", "description": "", @@ -103,7 +103,7 @@ "bootstrap": "^3.3.7", "calculate-size": "^1.1.1", "classnames": "^2.2.3", - "dygraphs": "influxdata/dygraphs", + "dygraphs": "2.1.0", "eslint-plugin-babel": "^4.1.2", "fast.js": "^0.1.1", "fixed-data-table": "^0.6.1", diff --git a/ui/spec/admin/reducers/influxdbSpec.js b/ui/spec/admin/reducers/influxdbSpec.js index adef72a72..2f5dee0a7 100644 --- a/ui/spec/admin/reducers/influxdbSpec.js +++ b/ui/spec/admin/reducers/influxdbSpec.js @@ -10,7 +10,7 @@ import { editUser, editRole, editDatabase, - editRetentionPolicy, + editRetentionPolicyRequested, loadRoles, loadPermissions, deleteRole, @@ -203,7 +203,10 @@ describe('Admin.InfluxDB.Reducers', () => { it('can edit a retention policy', () => { const updates = {name: 'rpOne', duration: '100y', replication: '42'} - const actual = reducer(state, editRetentionPolicy(db1, rp1, updates)) + const actual = reducer( + state, + editRetentionPolicyRequested(db1, rp1, updates) + ) const expected = [{...db1, retentionPolicies: [{...rp1, ...updates}]}] expect(actual.databases).to.deep.equal(expected) diff --git a/ui/spec/shared/presenters/presentersSpec.js b/ui/spec/shared/presenters/presentersSpec.js index d751e9cd4..29c837490 100644 --- a/ui/spec/shared/presenters/presentersSpec.js +++ b/ui/spec/shared/presenters/presentersSpec.js @@ -258,5 +258,12 @@ describe('Presenters', () => { expect(actual).to.equal('m1.derivative_mean_usage_system') }) + + it('returns a label of empty string if the query config is empty', () => { + const query = defaultQueryConfig({id: 1}) + const actual = buildDefaultYLabel(query) + + expect(actual).to.equal('') + }) }) }) diff --git a/ui/src/admin/actions/influxdb.js b/ui/src/admin/actions/influxdb.js index 954e408da..091124e2d 100644 --- a/ui/src/admin/actions/influxdb.js +++ b/ui/src/admin/actions/influxdb.js @@ -208,8 +208,27 @@ export const removeDatabaseDeleteCode = database => ({ }, }) -export const editRetentionPolicy = (database, retentionPolicy, updates) => ({ - type: 'INFLUXDB_EDIT_RETENTION_POLICY', +export const editRetentionPolicyRequested = ( + database, + retentionPolicy, + updates +) => ({ + type: 'INFLUXDB_EDIT_RETENTION_POLICY_REQUESTED', + payload: { + database, + retentionPolicy, + updates, + }, +}) + +export const editRetentionPolicyCompleted = syncRetentionPolicy + +export const editRetentionPolicyFailed = ( + database, + retentionPolicy, + updates +) => ({ + type: 'INFLUXDB_EDIT_RETENTION_POLICY_FAILED', payload: { database, retentionPolicy, @@ -334,23 +353,21 @@ export const createRetentionPolicyAsync = ( export const updateRetentionPolicyAsync = ( database, - retentionPolicy, - updates + oldRP, + newRP ) => async dispatch => { try { - dispatch(editRetentionPolicy(database, retentionPolicy, updates)) - const {data} = await updateRetentionPolicyAJAX( - retentionPolicy.links.self, - updates - ) + dispatch(editRetentionPolicyRequested(database, oldRP, newRP)) + const {data} = await updateRetentionPolicyAJAX(oldRP.links.self, newRP) + dispatch(editRetentionPolicyCompleted(database, oldRP, data)) dispatch( publishAutoDismissingNotification( 'success', 'Retention policy updated successfully' ) ) - dispatch(syncRetentionPolicy(database, retentionPolicy, data)) } catch (error) { + dispatch(editRetentionPolicyFailed(database, oldRP)) dispatch( errorThrown( error, diff --git a/ui/src/admin/components/chronograf/AdminTabs.js b/ui/src/admin/components/chronograf/AdminTabs.js index 0f592e61c..8d4be7f9f 100644 --- a/ui/src/admin/components/chronograf/AdminTabs.js +++ b/ui/src/admin/components/chronograf/AdminTabs.js @@ -7,44 +7,28 @@ import { } from 'src/auth/Authorized' import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'shared/components/Tabs' -import OrganizationsPage from 'src/admin/containers/OrganizationsPage' -import UsersTable from 'src/admin/components/chronograf/UsersTable' +import OrganizationsPage from 'src/admin/containers/chronograf/OrganizationsPage' +import UsersPage from 'src/admin/containers/chronograf/UsersPage' const ORGANIZATIONS_TAB_NAME = 'Organizations' const USERS_TAB_NAME = 'Users' const AdminTabs = ({ - meRole, - // UsersTable - users, - organization, - onCreateUser, - onUpdateUserRole, - onUpdateUserSuperAdmin, - onDeleteUser, - meID, - notify, + me: {currentOrganization: meCurrentOrganization, role: meRole, id: meID}, }) => { const tabs = [ { requiredRole: SUPERADMIN_ROLE, type: ORGANIZATIONS_TAB_NAME, - component: , + component: ( + + ), }, { requiredRole: ADMIN_ROLE, type: USERS_TAB_NAME, component: ( - + ), }, ].filter(t => isUserAuthorized(meRole, t.requiredRole)) @@ -69,39 +53,17 @@ const AdminTabs = ({ ) } -const {arrayOf, bool, func, shape, string} = PropTypes +const {shape, string} = PropTypes AdminTabs.propTypes = { - meRole: string.isRequired, - meID: string.isRequired, - // UsersTable - users: arrayOf( - shape({ - id: string, - links: shape({ - self: string.isRequired, - }), - name: string.isRequired, - provider: string.isRequired, - roles: arrayOf( - shape({ - name: string.isRequired, - organization: string.isRequired, - }) - ), - scheme: string.isRequired, - superAdmin: bool, - }) - ).isRequired, - organization: shape({ - name: string.isRequired, + me: shape({ id: string.isRequired, + role: string.isRequired, + currentOrganization: shape({ + name: string.isRequired, + id: string.isRequired, + }), }).isRequired, - onCreateUser: func.isRequired, - onUpdateUserRole: func.isRequired, - onUpdateUserSuperAdmin: func.isRequired, - onDeleteUser: func.isRequired, - notify: func.isRequired, } export default AdminTabs diff --git a/ui/src/admin/components/chronograf/EmptyUsersTable.js b/ui/src/admin/components/chronograf/EmptyUsersTable.js new file mode 100644 index 000000000..3ded5aea4 --- /dev/null +++ b/ui/src/admin/components/chronograf/EmptyUsersTable.js @@ -0,0 +1,46 @@ +import React from 'react' + +import UsersTableHeader from 'src/admin/components/chronograf/UsersTableHeader' + +import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' + +import {USERS_TABLE} from 'src/admin/constants/chronografTableSizing' + +const EmptyUsersTable = () => { + const { + colRole, + colSuperAdmin, + colProvider, + colScheme, + colActions, + } = USERS_TABLE + + return ( +
+ +
+ + + + + + + + + + + + + +
Username + Role + + SuperAdmin + ProviderScheme +
+
+
+ ) +} + +export default EmptyUsersTable diff --git a/ui/src/admin/components/chronograf/OrganizationsTable.js b/ui/src/admin/components/chronograf/OrganizationsTable.js index 997c7470c..272d57487 100644 --- a/ui/src/admin/components/chronograf/OrganizationsTable.js +++ b/ui/src/admin/components/chronograf/OrganizationsTable.js @@ -2,9 +2,12 @@ import React, {Component, PropTypes} from 'react' import uuid from 'node-uuid' +import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' + import OrganizationsTableRow from 'src/admin/components/chronograf/OrganizationsTableRow' import OrganizationsTableRowNew from 'src/admin/components/chronograf/OrganizationsTableRowNew' import QuestionMarkTooltip from 'shared/components/QuestionMarkTooltip' +import SlideToggle from 'shared/components/SlideToggle' import {PUBLIC_TOOLTIP} from 'src/admin/constants/index' @@ -39,6 +42,8 @@ class OrganizationsTable extends Component { onChooseDefaultRole, onTogglePublic, currentOrganization, + authConfig: {superAdminNewUsers}, + onChangeAuthConfig, } = this.props const {isCreatingOrganization} = this.state @@ -89,13 +94,35 @@ class OrganizationsTable extends Component { currentOrganization={currentOrganization} /> )} + + + + + + + + + + + + + +
Config +
+ + All new users are SuperAdmins
+
) } } -const {arrayOf, func, shape, string} = PropTypes +const {arrayOf, bool, func, shape, string} = PropTypes OrganizationsTable.propTypes = { organizations: arrayOf( @@ -113,5 +140,9 @@ OrganizationsTable.propTypes = { onRenameOrg: func.isRequired, onTogglePublic: func.isRequired, onChooseDefaultRole: func.isRequired, + onChangeAuthConfig: func.isRequired, + authConfig: shape({ + superAdminNewUsers: bool, + }), } export default OrganizationsTable diff --git a/ui/src/admin/components/chronograf/OrganizationsTableRowNew.js b/ui/src/admin/components/chronograf/OrganizationsTableRowNew.js index 9e62c38fe..09b2a541f 100644 --- a/ui/src/admin/components/chronograf/OrganizationsTableRowNew.js +++ b/ui/src/admin/components/chronograf/OrganizationsTableRowNew.js @@ -28,7 +28,7 @@ class OrganizationsTableRowNew extends Component { } handleInputChange = e => { - this.setState({name: e.target.value.trim()}) + this.setState({name: e.target.value}) } handleInputFocus = e => { @@ -39,7 +39,7 @@ class OrganizationsTableRowNew extends Component { const {onCreateOrganization} = this.props const {name, defaultRole} = this.state - onCreateOrganization({name, defaultRole}) + onCreateOrganization({name: name.trim(), defaultRole}) } handleChooseDefaultRole = role => { diff --git a/ui/src/admin/components/chronograf/UsersTable.js b/ui/src/admin/components/chronograf/UsersTable.js index 60ced87ec..9da6c5d8e 100644 --- a/ui/src/admin/components/chronograf/UsersTable.js +++ b/ui/src/admin/components/chronograf/UsersTable.js @@ -120,10 +120,27 @@ class UsersTable extends Component { } } -const {arrayOf, func, shape, string} = PropTypes +const {arrayOf, bool, func, shape, string} = PropTypes UsersTable.propTypes = { - users: arrayOf(shape()), + users: arrayOf( + shape({ + id: string, + links: shape({ + self: string.isRequired, + }), + name: string.isRequired, + provider: string.isRequired, + roles: arrayOf( + shape({ + name: string.isRequired, + organization: string.isRequired, + }) + ), + scheme: string.isRequired, + superAdmin: bool, + }) + ).isRequired, organization: shape({ name: string.isRequired, id: string.isRequired, @@ -135,4 +152,5 @@ UsersTable.propTypes = { meID: string.isRequired, notify: func.isRequired, } + export default UsersTable diff --git a/ui/src/admin/components/chronograf/UsersTableHeader.js b/ui/src/admin/components/chronograf/UsersTableHeader.js index 6ba33c7cb..48179cf15 100644 --- a/ui/src/admin/components/chronograf/UsersTableHeader.js +++ b/ui/src/admin/components/chronograf/UsersTableHeader.js @@ -1,5 +1,4 @@ import React, {Component, PropTypes} from 'react' -import Authorized, {ADMIN_ROLE} from 'src/auth/Authorized' class UsersTableHeader extends Component { constructor(props) { @@ -21,16 +20,14 @@ class UsersTableHeader extends Component {

{panelTitle} in {organization.name}

- - - + ) } @@ -38,13 +35,20 @@ class UsersTableHeader extends Component { const {bool, func, shape, string, number} = PropTypes +UsersTableHeader.defaultProps = { + numUsers: 0, + organization: { + name: '', + }, + isCreatingUser: false, +} + UsersTableHeader.propTypes = { numUsers: number.isRequired, - onClickCreateUser: func.isRequired, + onClickCreateUser: func, isCreatingUser: bool.isRequired, organization: shape({ name: string.isRequired, - id: string.isRequired, }), } diff --git a/ui/src/admin/components/chronograf/UsersTableRow.js b/ui/src/admin/components/chronograf/UsersTableRow.js index 283b06959..561ca9968 100644 --- a/ui/src/admin/components/chronograf/UsersTableRow.js +++ b/ui/src/admin/components/chronograf/UsersTableRow.js @@ -59,6 +59,7 @@ const UsersTableRow = ({ active={user.superAdmin} onToggle={onChangeSuperAdmin(user)} size="xs" + disabled={userIsMe} /> diff --git a/ui/src/admin/components/chronograf/UsersTableRowNew.js b/ui/src/admin/components/chronograf/UsersTableRowNew.js index 60aef7692..7b63c3e46 100644 --- a/ui/src/admin/components/chronograf/UsersTableRowNew.js +++ b/ui/src/admin/components/chronograf/UsersTableRowNew.js @@ -3,11 +3,9 @@ import React, {Component, PropTypes} from 'react' import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' import Dropdown from 'shared/components/Dropdown' -import SlideToggle from 'shared/components/SlideToggle' import {USERS_TABLE} from 'src/admin/constants/chronografTableSizing' import {USER_ROLES} from 'src/admin/constants/chronografAdmin' -import {MEMBER_ROLE} from 'src/auth/Authorized' class UsersTableRowNew extends Component { constructor(props) { @@ -17,8 +15,7 @@ class UsersTableRowNew extends Component { name: '', provider: '', scheme: 'oauth2', - role: MEMBER_ROLE, - superAdmin: false, + role: this.props.organization.defaultRole, } } @@ -55,10 +52,6 @@ class UsersTableRowNew extends Component { this.setState({role: newRole.text}) } - handleSelectSuperAdmin = superAdmin => { - this.setState({superAdmin}) - } - handleKeyDown = e => { const {name, provider} = this.state const preventCreate = !name || !provider @@ -87,7 +80,7 @@ class UsersTableRowNew extends Component { colActions, } = USERS_TABLE const {onBlur} = this.props - const {name, provider, scheme, role, superAdmin} = this.state + const {name, provider, scheme, role} = this.state const dropdownRolesItems = USER_ROLES.map(r => ({...r, text: r.name})) const preventCreate = !name || !provider @@ -117,11 +110,7 @@ class UsersTableRowNew extends Component { - + — diff --git a/ui/src/admin/constants/chronografAdmin.js b/ui/src/admin/constants/chronografAdmin.js index 69079c061..c3773e327 100644 --- a/ui/src/admin/constants/chronografAdmin.js +++ b/ui/src/admin/constants/chronografAdmin.js @@ -12,4 +12,4 @@ export const USER_ROLES = [ {name: ADMIN_ROLE}, ] -export const DEFAULT_ORG_ID = '0' +export const DEFAULT_ORG_ID = 'default' diff --git a/ui/src/admin/constants/index.js b/ui/src/admin/constants/index.js index 244f1bac5..64cf2d487 100644 --- a/ui/src/admin/constants/index.js +++ b/ui/src/admin/constants/index.js @@ -48,4 +48,4 @@ export const NEW_DEFAULT_DATABASE = { } export const PUBLIC_TOOLTIP = - 'If set to false, users cannot
authenticate unless an Admin explicitly
adds them to the organization.' + 'If turned off, new users cannot
authenticate unless an Admin explicitly
adds them to the organization.' diff --git a/ui/src/admin/containers/AdminChronografPage.js b/ui/src/admin/containers/AdminChronografPage.js deleted file mode 100644 index f5fa35be3..000000000 --- a/ui/src/admin/containers/AdminChronografPage.js +++ /dev/null @@ -1,150 +0,0 @@ -import React, {Component, PropTypes} from 'react' -import {connect} from 'react-redux' -import {bindActionCreators} from 'redux' - -import * as adminChronografActionCreators from 'src/admin/actions/chronograf' -import {publishAutoDismissingNotification} from 'shared/dispatchers' - -import AdminTabs from 'src/admin/components/chronograf/AdminTabs' -import FancyScrollbar from 'shared/components/FancyScrollbar' - -class AdminChronografPage extends Component { - // TODO: revisit this, possibly don't call setState if both are deep equal - componentWillReceiveProps(nextProps) { - const {meCurrentOrganization} = nextProps - - const hasChangedCurrentOrganization = - meCurrentOrganization.id !== this.props.meCurrentOrganization.id - - if (hasChangedCurrentOrganization) { - this.loadUsers() - } - } - - componentDidMount() { - this.loadUsers() - } - - loadUsers = () => { - const {links, actions: {loadUsersAsync}} = this.props - - loadUsersAsync(links.users) - } - - // SINGLE USER ACTIONS - handleCreateUser = user => { - const {links, actions: {createUserAsync}} = this.props - - createUserAsync(links.users, user) - } - - handleUpdateUserRole = (user, currentRole, {name}) => { - const {actions: {updateUserAsync}} = this.props - - const updatedRole = {...currentRole, name} - const newRoles = user.roles.map( - r => (r.organization === currentRole.organization ? updatedRole : r) - ) - - updateUserAsync(user, {...user, roles: newRoles}) - } - - handleUpdateUserSuperAdmin = (user, superAdmin) => { - const {actions: {updateUserAsync}} = this.props - - const updatedUser = {...user, superAdmin} - - updateUserAsync(user, updatedUser) - } - - handleDeleteUser = user => { - const {actions: {deleteUserAsync}} = this.props - - deleteUserAsync(user) - } - - render() { - const {users, meCurrentOrganization, meRole, meID, notify} = this.props - - return ( -
-
-
-
-

Chronograf Admin

-
-
-
- - {users - ?
-
- -
-
- :
} - -
- ) - } -} - -const {arrayOf, func, shape, string} = PropTypes - -AdminChronografPage.propTypes = { - links: shape({ - users: string.isRequired, - }), - users: arrayOf(shape), - meCurrentOrganization: shape({ - id: string.isRequired, - name: string.isRequired, - }).isRequired, - meRole: string.isRequired, - me: shape({ - name: string.isRequired, - id: string.isRequired, - }).isRequired, - meID: string.isRequired, - actions: shape({ - loadUsersAsync: func.isRequired, - createUserAsync: func.isRequired, - updateUserAsync: func.isRequired, - deleteUserAsync: func.isRequired, - }), - notify: func.isRequired, -} - -const mapStateToProps = ({ - links, - adminChronograf: {users}, - auth: { - me, - me: {currentOrganization: meCurrentOrganization, role: meRole, id: meID}, - }, -}) => ({ - links, - users, - meCurrentOrganization, - meRole, - me, - meID, -}) - -const mapDispatchToProps = dispatch => ({ - actions: bindActionCreators(adminChronografActionCreators, dispatch), - notify: bindActionCreators(publishAutoDismissingNotification, dispatch), -}) - -export default connect(mapStateToProps, mapDispatchToProps)(AdminChronografPage) diff --git a/ui/src/admin/containers/OrganizationsPage.js b/ui/src/admin/containers/OrganizationsPage.js deleted file mode 100644 index 9ae5c5a5d..000000000 --- a/ui/src/admin/containers/OrganizationsPage.js +++ /dev/null @@ -1,107 +0,0 @@ -import React, {Component, PropTypes} from 'react' -import {connect} from 'react-redux' -import {bindActionCreators} from 'redux' - -import * as adminChronografActionCreators from 'src/admin/actions/chronograf' -import {getMeAsync} from 'shared/actions/auth' - -import OrganizationsTable from 'src/admin/components/chronograf/OrganizationsTable' - -class OrganizationsPage extends Component { - componentDidMount() { - const {links, actions: {loadOrganizationsAsync}} = this.props - loadOrganizationsAsync(links.organizations) - } - - handleCreateOrganization = async organization => { - const {links, actions: {createOrganizationAsync}} = this.props - await createOrganizationAsync(links.organizations, organization) - this.refreshMe() - } - - handleRenameOrganization = async (organization, name) => { - const {actions: {updateOrganizationAsync}} = this.props - await updateOrganizationAsync(organization, {...organization, name}) - this.refreshMe() - } - - handleDeleteOrganization = organization => { - const {actions: {deleteOrganizationAsync}} = this.props - deleteOrganizationAsync(organization) - this.refreshMe() - } - - refreshMe = () => { - const {getMe} = this.props - getMe({shouldResetMe: false}) - } - - handleTogglePublic = organization => { - const {actions: {updateOrganizationAsync}} = this.props - updateOrganizationAsync(organization, { - ...organization, - public: !organization.public, - }) - } - - handleChooseDefaultRole = (organization, defaultRole) => { - const {actions: {updateOrganizationAsync}} = this.props - updateOrganizationAsync(organization, {...organization, defaultRole}) - // refreshMe is here to update the org's defaultRole in `me.organizations` - this.refreshMe() - } - - render() { - const {organizations, currentOrganization} = this.props - - return ( - - ) - } -} - -const {arrayOf, func, shape, string} = PropTypes - -OrganizationsPage.propTypes = { - links: shape({ - organizations: string.isRequired, - }), - organizations: arrayOf( - shape({ - id: string, // when optimistically created, it will not have an id - name: string.isRequired, - link: string, - }) - ), - actions: shape({ - loadOrganizationsAsync: func.isRequired, - createOrganizationAsync: func.isRequired, - updateOrganizationAsync: func.isRequired, - deleteOrganizationAsync: func.isRequired, - }), - getMe: func.isRequired, - currentOrganization: shape({ - name: string.isRequired, - id: string.isRequired, - }), -} - -const mapStateToProps = ({links, adminChronograf: {organizations}}) => ({ - links, - organizations, -}) - -const mapDispatchToProps = dispatch => ({ - actions: bindActionCreators(adminChronografActionCreators, dispatch), - getMe: bindActionCreators(getMeAsync, dispatch), -}) - -export default connect(mapStateToProps, mapDispatchToProps)(OrganizationsPage) diff --git a/ui/src/admin/containers/chronograf/AdminChronografPage.js b/ui/src/admin/containers/chronograf/AdminChronografPage.js new file mode 100644 index 000000000..8dc0dd6b9 --- /dev/null +++ b/ui/src/admin/containers/chronograf/AdminChronografPage.js @@ -0,0 +1,42 @@ +import React, {PropTypes} from 'react' +import {connect} from 'react-redux' + +import AdminTabs from 'src/admin/components/chronograf/AdminTabs' +import FancyScrollbar from 'shared/components/FancyScrollbar' + +const AdminChronografPage = ({me}) => +
+
+
+
+

Chronograf Admin

+
+
+
+ +
+
+ +
+
+
+
+ +const {shape, string} = PropTypes + +AdminChronografPage.propTypes = { + me: shape({ + id: string.isRequired, + role: string.isRequired, + currentOrganization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + }).isRequired, +} + +const mapStateToProps = ({auth: {me}}) => ({ + me, +}) + +export default connect(mapStateToProps, null)(AdminChronografPage) diff --git a/ui/src/admin/containers/chronograf/OrganizationsPage.js b/ui/src/admin/containers/chronograf/OrganizationsPage.js new file mode 100644 index 000000000..1324b6045 --- /dev/null +++ b/ui/src/admin/containers/chronograf/OrganizationsPage.js @@ -0,0 +1,160 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' + +import * as adminChronografActionCreators from 'src/admin/actions/chronograf' +import * as configActionCreators from 'shared/actions/config' +import {getMeAsync} from 'shared/actions/auth' + +import OrganizationsTable from 'src/admin/components/chronograf/OrganizationsTable' + +class OrganizationsPage extends Component { + componentDidMount() { + const { + links, + actionsAdmin: {loadOrganizationsAsync}, + actionsConfig: {getAuthConfigAsync}, + } = this.props + loadOrganizationsAsync(links.organizations) + getAuthConfigAsync(links.config.auth) + } + + handleCreateOrganization = async organization => { + const {links, actionsAdmin: {createOrganizationAsync}} = this.props + await createOrganizationAsync(links.organizations, organization) + this.refreshMe() + } + + handleRenameOrganization = async (organization, name) => { + const {actionsAdmin: {updateOrganizationAsync}} = this.props + await updateOrganizationAsync(organization, {...organization, name}) + this.refreshMe() + } + + handleDeleteOrganization = organization => { + const {actionsAdmin: {deleteOrganizationAsync}} = this.props + deleteOrganizationAsync(organization) + this.refreshMe() + } + + refreshMe = () => { + const {getMe} = this.props + getMe({shouldResetMe: false}) + } + + handleTogglePublic = organization => { + const {actionsAdmin: {updateOrganizationAsync}} = this.props + updateOrganizationAsync(organization, { + ...organization, + public: !organization.public, + }) + } + + handleChooseDefaultRole = (organization, defaultRole) => { + const {actionsAdmin: {updateOrganizationAsync}} = this.props + updateOrganizationAsync(organization, {...organization, defaultRole}) + // refreshMe is here to update the org's defaultRole in `me.organizations` + this.refreshMe() + } + + handleUpdateAuthConfig = fieldName => updatedValue => { + const { + actionsConfig: {updateAuthConfigAsync}, + authConfig, + links, + } = this.props + const updatedAuthConfig = { + ...authConfig, + [fieldName]: updatedValue, + } + updateAuthConfigAsync(links.config.auth, authConfig, updatedAuthConfig) + } + + render() { + const {meCurrentOrganization, organizations, authConfig, me} = this.props + + const organization = organizations.find( + o => o.id === meCurrentOrganization.id + ) + + return organizations.length + ? + :
+ } +} + +const {arrayOf, bool, func, shape, string} = PropTypes + +OrganizationsPage.propTypes = { + links: shape({ + organizations: string.isRequired, + config: shape({ + auth: string.isRequired, + }).isRequired, + }), + organizations: arrayOf( + shape({ + id: string, // when optimistically created, it will not have an id + name: string.isRequired, + link: string, + }) + ), + actionsAdmin: shape({ + loadOrganizationsAsync: func.isRequired, + createOrganizationAsync: func.isRequired, + updateOrganizationAsync: func.isRequired, + deleteOrganizationAsync: func.isRequired, + }), + actionsConfig: shape({ + getAuthConfigAsync: func.isRequired, + updateAuthConfigAsync: func.isRequired, + }), + getMe: func.isRequired, + meCurrentOrganization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + authConfig: shape({ + superAdminNewUsers: bool, + }), + me: shape({ + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + defaultRole: string.isRequired, + }) + ), + }), +} + +const mapStateToProps = ({ + links, + adminChronograf: {organizations}, + config: {auth: authConfig}, + auth: {me}, +}) => ({ + links, + organizations, + authConfig, + me, +}) + +const mapDispatchToProps = dispatch => ({ + actionsAdmin: bindActionCreators(adminChronografActionCreators, dispatch), + actionsConfig: bindActionCreators(configActionCreators, dispatch), + getMe: bindActionCreators(getMeAsync, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(OrganizationsPage) diff --git a/ui/src/admin/containers/chronograf/UsersPage.js b/ui/src/admin/containers/chronograf/UsersPage.js new file mode 100644 index 000000000..a8d21a051 --- /dev/null +++ b/ui/src/admin/containers/chronograf/UsersPage.js @@ -0,0 +1,128 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' + +import * as adminChronografActionCreators from 'src/admin/actions/chronograf' +import {publishAutoDismissingNotification} from 'shared/dispatchers' + +import EmptyUsersTable from 'src/admin/components/chronograf/EmptyUsersTable' +import UsersTable from 'src/admin/components/chronograf/UsersTable' + +class UsersPage extends Component { + constructor(props) { + super(props) + + this.state = { + isLoading: true, + } + } + + handleCreateUser = user => { + const {links, actions: {createUserAsync}} = this.props + createUserAsync(links.users, user) + } + + handleUpdateUserRole = (user, currentRole, {name}) => { + const {actions: {updateUserAsync}} = this.props + const updatedRole = {...currentRole, name} + const newRoles = user.roles.map( + r => (r.organization === currentRole.organization ? updatedRole : r) + ) + updateUserAsync(user, {...user, roles: newRoles}) + } + + handleUpdateUserSuperAdmin = (user, superAdmin) => { + const {actions: {updateUserAsync}} = this.props + const updatedUser = {...user, superAdmin} + updateUserAsync(user, updatedUser) + } + + handleDeleteUser = user => { + const {actions: {deleteUserAsync}} = this.props + deleteUserAsync(user) + } + + async componentWillMount() { + const { + links, + actions: {loadOrganizationsAsync, loadUsersAsync}, + } = this.props + + this.setState({isLoading: true}) + + await Promise.all([ + loadOrganizationsAsync(links.organizations), + loadUsersAsync(links.users), + ]) + + this.setState({isLoading: false}) + } + + render() { + const { + meCurrentOrganization, + organizations, + meID, + users, + notify, + } = this.props + const {isLoading} = this.state + + if (isLoading) { + return + } + + const organization = organizations.find( + o => o.id === meCurrentOrganization.id + ) + + return ( + + ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +UsersPage.propTypes = { + links: shape({ + users: string.isRequired, + }), + meID: string.isRequired, + meCurrentOrganization: shape({ + id: string.isRequired, + name: string.isRequired, + }).isRequired, + users: arrayOf(shape), + organizations: arrayOf(shape), + actions: shape({ + loadUsersAsync: func.isRequired, + loadOrganizationsAsync: func.isRequired, + createUserAsync: func.isRequired, + updateUserAsync: func.isRequired, + deleteUserAsync: func.isRequired, + }), + notify: func.isRequired, +} + +const mapStateToProps = ({links, adminChronograf: {organizations, users}}) => ({ + links, + organizations, + users, +}) + +const mapDispatchToProps = dispatch => ({ + actions: bindActionCreators(adminChronografActionCreators, dispatch), + notify: bindActionCreators(publishAutoDismissingNotification, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(UsersPage) diff --git a/ui/src/admin/index.js b/ui/src/admin/index.js index 5663e7aeb..d6c972663 100644 --- a/ui/src/admin/index.js +++ b/ui/src/admin/index.js @@ -1,5 +1,4 @@ import AdminInfluxDBPage from './containers/AdminInfluxDBPage' -import AdminChronografPage from './containers/AdminChronografPage' -import OrganizationsPage from './containers/OrganizationsPage' +import AdminChronografPage from './containers/chronograf/AdminChronografPage' -export {AdminChronografPage, AdminInfluxDBPage, OrganizationsPage} +export {AdminChronografPage, AdminInfluxDBPage} diff --git a/ui/src/admin/reducers/chronograf.js b/ui/src/admin/reducers/chronograf.js index 34d46dcce..621f7a7a4 100644 --- a/ui/src/admin/reducers/chronograf.js +++ b/ui/src/admin/reducers/chronograf.js @@ -3,6 +3,9 @@ import {isSameUser} from 'shared/reducers/helpers/auth' const initialState = { users: [], organizations: [], + authConfig: { + superAdminNewUsers: true, + }, } const adminChronograf = (state = initialState, action) => { diff --git a/ui/src/admin/reducers/influxdb.js b/ui/src/admin/reducers/influxdb.js index 23b86492b..ac6d43fbd 100644 --- a/ui/src/admin/reducers/influxdb.js +++ b/ui/src/admin/reducers/influxdb.js @@ -164,7 +164,7 @@ const adminInfluxDB = (state = initialState, action) => { return {...state, ...newState} } - case 'INFLUXDB_EDIT_RETENTION_POLICY': { + case 'INFLUXDB_EDIT_RETENTION_POLICY_REQUESTED': { const {database, retentionPolicy, updates} = action.payload const newState = { @@ -187,6 +187,29 @@ const adminInfluxDB = (state = initialState, action) => { return {...state, ...newState} } + case 'INFLUXDB_EDIT_RETENTION_POLICY_FAILED': { + const {database, retentionPolicy} = action.payload + + const newState = { + databases: state.databases.map( + db => + db.links.self === database.links.self + ? { + ...db, + retentionPolicies: db.retentionPolicies.map( + rp => + rp.links.self === retentionPolicy.links.self + ? {...rp, ...retentionPolicy} + : rp + ), + } + : db + ), + } + + return {...state, ...newState} + } + case 'INFLUXDB_DELETE_USER': { const {user} = action.payload const newState = { diff --git a/ui/src/auth/Login.js b/ui/src/auth/Login.js index 106469e6d..a341d417c 100644 --- a/ui/src/auth/Login.js +++ b/ui/src/auth/Login.js @@ -22,7 +22,7 @@ const Login = ({authData: {auth}}) => { auth.links.map(({name, login, label}) => - Login with {label} + Log in with {label} )} diff --git a/ui/src/auth/Purgatory.js b/ui/src/auth/Purgatory.js index 51ca6cb49..e190cdb08 100644 --- a/ui/src/auth/Purgatory.js +++ b/ui/src/auth/Purgatory.js @@ -90,7 +90,7 @@ class Purgatory extends Component {
:

You are a Lost Soul

} - Logout + Log out
diff --git a/ui/src/auth/PurgatoryAuthItem.js b/ui/src/auth/PurgatoryAuthItem.js index 5afabc98e..a1932f384 100644 --- a/ui/src/auth/PurgatoryAuthItem.js +++ b/ui/src/auth/PurgatoryAuthItem.js @@ -23,7 +23,7 @@ const PurgatoryAuthItem = ({roleAndOrg, onClickLogin, superAdmin}) => className="btn btn-sm btn-primary" onClick={onClickLogin(roleAndOrg.organization)} > - Login + Log in : Contact your Admin
for access diff --git a/ui/src/dashboards/components/AxesOptions.js b/ui/src/dashboards/components/AxesOptions.js index 8cd5849e7..072f6d0b5 100644 --- a/ui/src/dashboards/components/AxesOptions.js +++ b/ui/src/dashboards/components/AxesOptions.js @@ -40,7 +40,7 @@ const AxesOptions = ({
{ - const {colors} = this.state + const {colors, cellWorkingType} = this.state + const sortedColors = _.sortBy(colors, color => Number(color.value)) - if (colors.length <= MAX_THRESHOLDS) { - const randomColor = _.random(0, GAUGE_COLORS.length) + if (sortedColors.length <= MAX_THRESHOLDS) { + const randomColor = _.random(0, GAUGE_COLORS.length - 1) - const maxValue = Number( - colors.find(color => color.type === COLOR_TYPE_MAX).value - ) - const minValue = Number( - colors.find(color => color.type === COLOR_TYPE_MIN).value - ) + const maxValue = + cellWorkingType === 'gauge' + ? Number(sortedColors[sortedColors.length - 1].value) + : DEFAULT_VALUE_MAX + const minValue = + cellWorkingType === 'gauge' + ? Number(sortedColors[0].value) + : DEFAULT_VALUE_MIN const colorsValues = _.mapValues(colors, 'value') let randomValue @@ -135,31 +141,32 @@ class CellEditorOverlay extends Component { } handleValidateColorValue = (threshold, e) => { - const {colors} = this.state + const {colors, cellWorkingType} = this.state const sortedColors = _.sortBy(colors, color => Number(color.value)) + const thresholdValue = Number(threshold.value) const targetValueNumber = Number(e.target.value) - - const maxValue = Number( - colors.find(color => color.type === COLOR_TYPE_MAX).value - ) - const minValue = Number( - colors.find(color => color.type === COLOR_TYPE_MIN).value - ) - let allowedToUpdate = false - // If type === min, make sure it is less than the next threshold - if (threshold.type === COLOR_TYPE_MIN) { - const nextValue = Number(sortedColors[1].value) - allowedToUpdate = targetValueNumber < nextValue && targetValueNumber >= 0 + if (cellWorkingType === 'single-stat') { + // If type is single-stat then value only has to be unique + return !sortedColors.some(color => color.value === e.target.value) } - // If type === max, make sure it is greater than the previous threshold - if (threshold.type === COLOR_TYPE_MAX) { + + const minValue = Number(sortedColors[0].value) + const maxValue = Number(sortedColors[sortedColors.length - 1].value) + + // If lowest value, make sure it is less than the next threshold + if (thresholdValue === minValue) { + const nextValue = Number(sortedColors[1].value) + allowedToUpdate = targetValueNumber < nextValue + } + // If highest value, make sure it is greater than the previous threshold + if (thresholdValue === maxValue) { const previousValue = Number(sortedColors[sortedColors.length - 2].value) allowedToUpdate = previousValue < targetValueNumber } - // If type === threshold, make sure new value is greater than min, less than max, and unique - if (threshold.type === COLOR_TYPE_THRESHOLD) { + // If not min or max, make sure new value is greater than min, less than max, and unique + if (thresholdValue !== minValue && thresholdValue !== maxValue) { const greaterThanMin = targetValueNumber > minValue const lessThanMax = targetValueNumber < maxValue @@ -178,6 +185,33 @@ class CellEditorOverlay extends Component { return allowedToUpdate } + handleToggleSingleStatText = () => { + const {colors, colorSingleStatText} = this.state + const formattedColors = colors.map(color => ({ + ...color, + type: colorSingleStatText ? SINGLE_STAT_BG : SINGLE_STAT_TEXT, + })) + + this.setState({ + colorSingleStatText: !colorSingleStatText, + colors: formattedColors, + }) + } + + handleSetSuffix = e => { + const {axes} = this.state + + this.setState({ + axes: { + ...axes, + y: { + ...axes.y, + suffix: e.target.value, + }, + }, + }) + } + queryStateReducer = queryModifier => (queryID, ...payload) => { const {queriesWorkingDraft} = this.state const query = queriesWorkingDraft.find(q => q.id === queryID) @@ -287,7 +321,13 @@ class CellEditorOverlay extends Component { } handleSelectGraphType = graphType => () => { - this.setState({cellWorkingType: graphType}) + const {colors, colorSingleStatText} = this.state + const validatedColors = validateColors( + colors, + graphType, + colorSingleStatText + ) + this.setState({cellWorkingType: graphType, colors: validatedColors}) } handleClickDisplayOptionsTab = isDisplayOptionsTabActive => () => { @@ -419,6 +459,7 @@ class CellEditorOverlay extends Component { cellWorkingType, isDisplayOptionsTabActive, queriesWorkingDraft, + colorSingleStatText, } = this.state const queryActions = { @@ -472,12 +513,15 @@ class CellEditorOverlay extends Component { onUpdateColorValue={this.handleUpdateColorValue} onAddThreshold={this.handleAddThreshold} onDeleteThreshold={this.handleDeleteThreshold} + onToggleSingleStatText={this.handleToggleSingleStatText} + colorSingleStatText={colorSingleStatText} onSetBase={this.handleSetBase} onSetLabel={this.handleSetLabel} onSetScale={this.handleSetScale} queryConfigs={queriesWorkingDraft} selectedGraphType={cellWorkingType} onSetPrefixSuffix={this.handleSetPrefixSuffix} + onSetSuffix={this.handleSetSuffix} onSelectGraphType={this.handleSelectGraphType} onSetYAxisBoundMin={this.handleSetYAxisBoundMin} onSetYAxisBoundMax={this.handleSetYAxisBoundMax} diff --git a/ui/src/dashboards/components/DisplayOptions.js b/ui/src/dashboards/components/DisplayOptions.js index 4e67f79e7..cf9a93130 100644 --- a/ui/src/dashboards/components/DisplayOptions.js +++ b/ui/src/dashboards/components/DisplayOptions.js @@ -2,6 +2,7 @@ import React, {Component, PropTypes} from 'react' import GraphTypeSelector from 'src/dashboards/components/GraphTypeSelector' import GaugeOptions from 'src/dashboards/components/GaugeOptions' +import SingleStatOptions from 'src/dashboards/components/SingleStatOptions' import AxesOptions from 'src/dashboards/components/AxesOptions' import {buildDefaultYLabel} from 'shared/presenters' @@ -32,14 +33,13 @@ class DisplayOptions extends Component { : axes } - render() { + renderOptions = () => { const { colors, onSetBase, onSetScale, onSetLabel, selectedGraphType, - onSelectGraphType, onSetPrefixSuffix, onSetYAxisBoundMin, onSetYAxisBoundMax, @@ -48,10 +48,57 @@ class DisplayOptions extends Component { onChooseColor, onValidateColorValue, onUpdateColorValue, + colorSingleStatText, + onToggleSingleStatText, + onSetSuffix, } = this.props - const {axes} = this.state + const {axes, axes: {y: {suffix}}} = this.state - const isGauge = selectedGraphType === 'gauge' + switch (selectedGraphType) { + case 'gauge': + return ( + + ) + case 'single-stat': + return ( + + ) + default: + return ( + + ) + } + } + + render() { + const {selectedGraphType, onSelectGraphType} = this.props return (
@@ -59,30 +106,12 @@ class DisplayOptions extends Component { selectedGraphType={selectedGraphType} onSelectGraphType={onSelectGraphType} /> - {isGauge - ? - : } + {this.renderOptions()}
) } } -const {arrayOf, func, shape, string} = PropTypes +const {arrayOf, bool, func, shape, string} = PropTypes DisplayOptions.propTypes = { onAddThreshold: func.isRequired, @@ -93,6 +122,7 @@ DisplayOptions.propTypes = { selectedGraphType: string.isRequired, onSelectGraphType: func.isRequired, onSetPrefixSuffix: func.isRequired, + onSetSuffix: func.isRequired, onSetYAxisBoundMin: func.isRequired, onSetYAxisBoundMax: func.isRequired, onSetScale: func.isRequired, @@ -109,6 +139,8 @@ DisplayOptions.propTypes = { }).isRequired ), queryConfigs: arrayOf(shape()).isRequired, + colorSingleStatText: bool.isRequired, + onToggleSingleStatText: func.isRequired, } export default DisplayOptions diff --git a/ui/src/dashboards/components/GaugeOptions.js b/ui/src/dashboards/components/GaugeOptions.js index 297dec7e3..8a70c37bf 100644 --- a/ui/src/dashboards/components/GaugeOptions.js +++ b/ui/src/dashboards/components/GaugeOptions.js @@ -2,12 +2,11 @@ import React, {PropTypes} from 'react' import _ from 'lodash' import FancyScrollbar from 'shared/components/FancyScrollbar' -import GaugeThreshold from 'src/dashboards/components/GaugeThreshold' +import Threshold from 'src/dashboards/components/Threshold' import { MAX_THRESHOLDS, MIN_THRESHOLDS, - DEFAULT_COLORS, } from 'src/dashboards/constants/gaugeColors' const GaugeOptions = ({ @@ -19,9 +18,7 @@ const GaugeOptions = ({ onUpdateColorValue, }) => { const disableMaxColor = colors.length > MIN_THRESHOLDS - const disableAddThreshold = colors.length > MAX_THRESHOLDS - const sortedColors = _.sortBy(colors, color => Number(color.value)) return ( @@ -32,8 +29,20 @@ const GaugeOptions = ({
Gauge Controls
+ {sortedColors.map(color => - )} -
@@ -58,10 +60,6 @@ const GaugeOptions = ({ const {arrayOf, func, shape, string} = PropTypes -GaugeOptions.defaultProps = { - colors: DEFAULT_COLORS, -} - GaugeOptions.propTypes = { colors: arrayOf( shape({ diff --git a/ui/src/dashboards/components/SingleStatOptions.js b/ui/src/dashboards/components/SingleStatOptions.js new file mode 100644 index 000000000..2ee686c4e --- /dev/null +++ b/ui/src/dashboards/components/SingleStatOptions.js @@ -0,0 +1,113 @@ +import React, {PropTypes} from 'react' +import _ from 'lodash' + +import FancyScrollbar from 'shared/components/FancyScrollbar' +import Threshold from 'src/dashboards/components/Threshold' + +import {MAX_THRESHOLDS} from 'src/dashboards/constants/gaugeColors' + +const SingleStatOptions = ({ + suffix, + onSetSuffix, + colors, + onAddThreshold, + onDeleteThreshold, + onChooseColor, + onValidateColorValue, + onUpdateColorValue, + colorSingleStatText, + onToggleSingleStatText, +}) => { + const disableAddThreshold = colors.length > MAX_THRESHOLDS + + const sortedColors = _.sortBy(colors, color => Number(color.value)) + + return ( + +
+
Single Stat Controls
+
+ + {sortedColors.map(color => + + )} +
+
+
+ +
    +
  • + Background +
  • +
  • + Text +
  • +
+
+
+ + +
+
+
+
+ ) +} + +const {arrayOf, bool, func, shape, string} = PropTypes + +SingleStatOptions.defaultProps = { + colors: [], +} + +SingleStatOptions.propTypes = { + colors: arrayOf( + shape({ + type: string.isRequired, + hex: string.isRequired, + id: string.isRequired, + name: string.isRequired, + value: string.isRequired, + }).isRequired + ), + onAddThreshold: func.isRequired, + onDeleteThreshold: func.isRequired, + onChooseColor: func.isRequired, + onValidateColorValue: func.isRequired, + onUpdateColorValue: func.isRequired, + colorSingleStatText: bool.isRequired, + onToggleSingleStatText: func.isRequired, + onSetSuffix: func.isRequired, + suffix: string.isRequired, +} + +export default SingleStatOptions diff --git a/ui/src/dashboards/components/GaugeThreshold.js b/ui/src/dashboards/components/Threshold.js similarity index 75% rename from ui/src/dashboards/components/GaugeThreshold.js rename to ui/src/dashboards/components/Threshold.js index 19b9246df..d01d76c4c 100644 --- a/ui/src/dashboards/components/GaugeThreshold.js +++ b/ui/src/dashboards/components/Threshold.js @@ -2,13 +2,9 @@ import React, {Component, PropTypes} from 'react' import ColorDropdown from 'shared/components/ColorDropdown' -import { - COLOR_TYPE_MIN, - COLOR_TYPE_MAX, - GAUGE_COLORS, -} from 'src/dashboards/constants/gaugeColors' +import {GAUGE_COLORS} from 'src/dashboards/constants/gaugeColors' -class GaugeThreshold extends Component { +class Threshold extends Component { constructor(props) { super(props) @@ -36,27 +32,34 @@ class GaugeThreshold extends Component { render() { const { + visualizationType, threshold, - threshold: {type, hex, name}, + threshold: {hex, name}, disableMaxColor, onChooseColor, onDeleteThreshold, + isMin, + isMax, } = this.props const {workingValue, valid} = this.state const selectedColor = {hex, name} - const labelClass = - type === COLOR_TYPE_MIN || type === COLOR_TYPE_MAX - ? 'gauge-controls--label' - : 'gauge-controls--label-editable' - - const canBeDeleted = !(type === COLOR_TYPE_MIN || type === COLOR_TYPE_MAX) - let label = 'Threshold' - if (type === COLOR_TYPE_MIN) { + let labelClass = 'gauge-controls--label-editable' + let canBeDeleted = true + + if (visualizationType === 'gauge') { + labelClass = + isMin || isMax + ? 'gauge-controls--label' + : 'gauge-controls--label-editable' + canBeDeleted = !(isMin || isMax) + } + + if (isMin && visualizationType === 'gauge') { label = 'Minimum' } - if (type === COLOR_TYPE_MAX) { + if (isMax && visualizationType === 'gauge') { label = 'Maximum' } @@ -83,13 +86,12 @@ class GaugeThreshold extends Component { type="number" onChange={this.handleChangeWorkingValue} onBlur={this.handleBlur} - min={0} />
) @@ -98,7 +100,8 @@ class GaugeThreshold extends Component { const {bool, func, shape, string} = PropTypes -GaugeThreshold.propTypes = { +Threshold.propTypes = { + visualizationType: string.isRequired, threshold: shape({ type: string.isRequired, hex: string.isRequired, @@ -111,6 +114,8 @@ GaugeThreshold.propTypes = { onValidateColorValue: func.isRequired, onUpdateColorValue: func.isRequired, onDeleteThreshold: func.isRequired, + isMin: bool, + isMax: bool, } -export default GaugeThreshold +export default Threshold diff --git a/ui/src/dashboards/constants/gaugeColors.js b/ui/src/dashboards/constants/gaugeColors.js index bc0416f8a..7004dccbf 100644 --- a/ui/src/dashboards/constants/gaugeColors.js +++ b/ui/src/dashboards/constants/gaugeColors.js @@ -1,3 +1,5 @@ +import _ from 'lodash' + export const MAX_THRESHOLDS = 5 export const MIN_THRESHOLDS = 2 @@ -7,6 +9,9 @@ export const COLOR_TYPE_MAX = 'max' export const DEFAULT_VALUE_MAX = '100' export const COLOR_TYPE_THRESHOLD = 'threshold' +export const SINGLE_STAT_TEXT = 'text' +export const SINGLE_STAT_BG = 'background' + export const GAUGE_COLORS = [ { hex: '#BF3D5E', @@ -95,12 +100,27 @@ export const DEFAULT_COLORS = [ }, ] -export const validateColors = colors => { - if (!colors) { - return false +export const validateColors = (colors, type, colorSingleStatText) => { + if (type === 'single-stat') { + // Single stat colors should all have type of 'text' or 'background' + const colorType = colorSingleStatText ? SINGLE_STAT_TEXT : SINGLE_STAT_BG + return colors ? colors.map(color => ({...color, type: colorType})) : null + } + if (!colors || colors.length === 0) { + return DEFAULT_COLORS + } + if (type === 'gauge') { + // Gauge colors should have a type of min, any number of thresholds, and a max + const formatttedColors = _.sortBy(colors, color => + Number(color.value) + ).map(c => ({ + ...c, + type: COLOR_TYPE_THRESHOLD, + })) + formatttedColors[0].type = COLOR_TYPE_MIN + formatttedColors[formatttedColors.length - 1].type = COLOR_TYPE_MAX + return formatttedColors } - const hasMin = colors.some(color => color.type === COLOR_TYPE_MIN) - const hasMax = colors.some(color => color.type === COLOR_TYPE_MAX) - return hasMin && hasMax + return colors.length >= MIN_THRESHOLDS ? colors : DEFAULT_COLORS } diff --git a/ui/src/dashboards/containers/DashboardPage.js b/ui/src/dashboards/containers/DashboardPage.js index e21e37a86..91cde287a 100644 --- a/ui/src/dashboards/containers/DashboardPage.js +++ b/ui/src/dashboards/containers/DashboardPage.js @@ -1,5 +1,6 @@ import React, {PropTypes, Component} from 'react' import {connect} from 'react-redux' +import {withRouter} from 'react-router' import {bindActionCreators} from 'redux' import _ from 'lodash' @@ -15,6 +16,7 @@ import TemplateVariableManager from 'src/dashboards/components/template_variable import ManualRefresh from 'src/shared/components/ManualRefresh' import {errorThrown as errorThrownAction} from 'shared/actions/errors' +import {publishNotification} from 'shared/actions/notifications' import idNormalizer, {TYPE_ID} from 'src/normalizers/id' import * as dashboardActionCreators from 'src/dashboards/actions' @@ -57,6 +59,8 @@ class DashboardPage extends Component { source, meRole, isUsingAuth, + router, + notify, } = this.props const dashboards = await getDashboardsAsync() @@ -64,6 +68,11 @@ class DashboardPage extends Component { d => d.id === idNormalizer(TYPE_ID, dashboardID) ) + if (!dashboard) { + router.push(`/sources/${source.id}/dashboards`) + return notify('error', `Dashboard ${dashboardID} could not be found`) + } + // Refresh and persists influxql generated template variable values. // If using auth and role is Viewer, temp vars will be stale until dashboard // is refactored so as not to require a write operation (a PUT in this case) @@ -456,6 +465,8 @@ DashboardPage.propTypes = { onManualRefresh: func.isRequired, meRole: string, isUsingAuth: bool.isRequired, + router: shape().isRequired, + notify: func.isRequired, } const mapStateToProps = (state, {params: {dashboardID}}) => { @@ -503,8 +514,9 @@ const mapDispatchToProps = dispatch => ({ handleClickPresentationButton: presentationButtonDispatcher(dispatch), dashboardActions: bindActionCreators(dashboardActionCreators, dispatch), errorThrown: bindActionCreators(errorThrownAction, dispatch), + notify: bindActionCreators(publishNotification, dispatch), }) export default connect(mapStateToProps, mapDispatchToProps)( - ManualRefresh(DashboardPage) + ManualRefresh(withRouter(DashboardPage)) ) diff --git a/ui/src/data_explorer/actions/view/index.js b/ui/src/data_explorer/actions/view/index.js index f01d120e0..fb8daa06a 100644 --- a/ui/src/data_explorer/actions/view/index.js +++ b/ui/src/data_explorer/actions/view/index.js @@ -4,10 +4,10 @@ import {getQueryConfig} from 'shared/apis' import {errorThrown} from 'shared/actions/errors' -export const addQuery = () => ({ +export const addQuery = (queryID = uuid.v4()) => ({ type: 'DE_ADD_QUERY', payload: { - queryID: uuid.v4(), + queryID, }, }) diff --git a/ui/src/data_explorer/containers/DataExplorer.js b/ui/src/data_explorer/containers/DataExplorer.js index e1e129745..8ddc15376 100644 --- a/ui/src/data_explorer/containers/DataExplorer.js +++ b/ui/src/data_explorer/containers/DataExplorer.js @@ -21,6 +21,7 @@ import {setAutoRefresh} from 'shared/actions/app' import * as dataExplorerActionCreators from 'src/data_explorer/actions/view' import {writeLineProtocolAsync} from 'src/data_explorer/actions/view/write' import {buildRawText} from 'src/utils/influxql' +import defaultQueryConfig from 'src/utils/defaultQueryConfig' class DataExplorer extends Component { constructor(props) { @@ -33,8 +34,11 @@ class DataExplorer extends Component { getActiveQuery = () => { const {queryConfigs} = this.props + if (queryConfigs.length === 0) { - this.props.queryConfigActions.addQuery() + const qc = defaultQueryConfig() + this.props.queryConfigActions.addQuery(qc.id) + queryConfigs.push(qc) } return queryConfigs[0] diff --git a/ui/src/hosts/apis/index.js b/ui/src/hosts/apis/index.js index 584ca4b5a..413a6cadc 100644 --- a/ui/src/hosts/apis/index.js +++ b/ui/src/hosts/apis/index.js @@ -2,15 +2,19 @@ import {proxy} from 'utils/queryUrlGenerator' import AJAX from 'utils/ajax' import _ from 'lodash' -export function getCpuAndLoadForHosts(proxyLink, telegrafDB) { +export const getCpuAndLoadForHosts = ( + proxyLink, + telegrafDB, + telegrafSystemInterval +) => { return proxy({ source: proxyLink, query: `SELECT mean("usage_user") FROM cpu WHERE "cpu" = 'cpu-total' AND time > now() - 10m GROUP BY host; SELECT mean("load1") FROM "system" WHERE time > now() - 10m GROUP BY host; - SELECT non_negative_derivative(mean(uptime)) AS deltaUptime FROM "system" WHERE time > now() - 10m GROUP BY host, time(1m) fill(0); + SELECT non_negative_derivative(mean(uptime)) AS deltaUptime FROM "system" WHERE time > now() - ${telegrafSystemInterval} * 10 GROUP BY host, time(${telegrafSystemInterval}) fill(0); SELECT mean("Percent_Processor_Time") FROM win_cpu WHERE time > now() - 10m GROUP BY host; SELECT mean("Processor_Queue_Length") FROM win_system WHERE time > now() - 10s GROUP BY host; - SELECT non_negative_derivative(mean("System_Up_Time")) AS winDeltaUptime FROM win_system WHERE time > now() - 10m GROUP BY host, time(1m) fill(0); + SELECT non_negative_derivative(mean("System_Up_Time")) AS winDeltaUptime FROM win_system WHERE time > now() - ${telegrafSystemInterval} * 10 GROUP BY host, time(${telegrafSystemInterval}) fill(0); SHOW TAG VALUES WITH KEY = "host";`, db: telegrafDB, }).then(resp => { @@ -116,7 +120,7 @@ export const getLayouts = () => resource: 'layouts', }) -export function getAppsForHosts(proxyLink, hosts, appLayouts, telegrafDB) { +export const getAppsForHosts = (proxyLink, hosts, appLayouts, telegrafDB) => { const measurements = appLayouts.map(m => `^${m.measurement}$`).join('|') const measurementsToApps = _.zipObject( appLayouts.map(m => m.measurement), diff --git a/ui/src/hosts/containers/HostsPage.js b/ui/src/hosts/containers/HostsPage.js index b08e32444..a58c44680 100644 --- a/ui/src/hosts/containers/HostsPage.js +++ b/ui/src/hosts/containers/HostsPage.js @@ -1,10 +1,12 @@ import React, {PropTypes, Component} from 'react' +import {connect} from 'react-redux' import _ from 'lodash' import HostsTable from 'src/hosts/components/HostsTable' import SourceIndicator from 'shared/components/SourceIndicator' import {getCpuAndLoadForHosts, getLayouts, getAppsForHosts} from '../apis' +import {getEnv} from 'src/shared/apis/env' class HostsPage extends Component { constructor(props) { @@ -17,48 +19,72 @@ class HostsPage extends Component { } } - componentDidMount() { - const {source, addFlashMessage} = this.props - Promise.all([ - getCpuAndLoadForHosts(source.links.proxy, source.telegraf), - getLayouts(), - new Promise(resolve => { - this.setState({hostsLoading: true}) - resolve() - }), - ]) - .then(([hosts, {data: {layouts}}]) => { - this.setState({ - hosts, - hostsLoading: false, - }) - getAppsForHosts(source.links.proxy, hosts, layouts, source.telegraf) - .then(newHosts => { - this.setState({ - hosts: newHosts, - hostsError: '', - hostsLoading: false, - }) - }) - .catch(error => { - console.error(error) - const reason = 'Unable to get apps for hosts' - addFlashMessage({type: 'error', text: reason}) - this.setState({ - hostsError: reason, - hostsLoading: false, - }) - }) + async componentDidMount() { + const {source, links, addFlashMessage} = this.props + + const {telegrafSystemInterval} = await getEnv(links.environment) + + const hostsError = 'Unable to get apps for hosts' + let hosts, layouts + + try { + const [h, {data}] = await Promise.all([ + getCpuAndLoadForHosts( + source.links.proxy, + source.telegraf, + telegrafSystemInterval + ), + getLayouts(), + new Promise(resolve => { + this.setState({hostsLoading: true}) + resolve() + }), + ]) + + hosts = h + layouts = data.layouts + + this.setState({ + hosts, + hostsLoading: false, }) - .catch(reason => { - this.setState({ - hostsError: reason.toString(), - hostsLoading: false, - }) - // TODO: this isn't reachable at the moment, because getCpuAndLoadForHosts doesn't fail when it should. - // (like with a bogus proxy link). We should provide better messaging to the user in this catch after that's fixed. - console.error(reason) // eslint-disable-line no-console + } catch (error) { + this.setState({ + hostsError: error.toString(), + hostsLoading: false, }) + + console.error(error) + } + + if (!hosts || !layouts) { + addFlashMessage({type: 'error', text: hostsError}) + return this.setState({ + hostsError, + hostsLoading: false, + }) + } + + try { + const newHosts = await getAppsForHosts( + source.links.proxy, + hosts, + layouts, + source.telegraf + ) + this.setState({ + hosts: newHosts, + hostsError: '', + hostsLoading: false, + }) + } catch (error) { + console.error(error) + addFlashMessage({type: 'error', text: hostsError}) + this.setState({ + hostsError, + hostsLoading: false, + }) + } } render() { @@ -97,6 +123,12 @@ class HostsPage extends Component { const {func, shape, string} = PropTypes +const mapStateToProps = ({links}) => { + return { + links, + } +} + HostsPage.propTypes = { source: shape({ id: string.isRequired, @@ -107,7 +139,10 @@ HostsPage.propTypes = { }).isRequired, telegraf: string.isRequired, }), + links: shape({ + environment: string.isRequired, + }), addFlashMessage: func, } -export default HostsPage +export default connect(mapStateToProps, null)(HostsPage) diff --git a/ui/src/shared/actions/auth.js b/ui/src/shared/actions/auth.js index cf3202652..5e8308deb 100644 --- a/ui/src/shared/actions/auth.js +++ b/ui/src/shared/actions/auth.js @@ -5,6 +5,8 @@ import {linksReceived} from 'shared/actions/links' import {publishAutoDismissingNotification} from 'shared/dispatchers' import {errorThrown} from 'shared/actions/errors' +import {LONG_NOTIFICATION_DISMISS_DELAY} from 'shared/constants' + export const authExpired = auth => ({ type: 'AUTH_EXPIRED', payload: { @@ -59,12 +61,15 @@ export const getMeAsync = ({shouldResetMe = false} = {}) => async dispatch => { const { data: me, auth, - logoutLink, - external, users, - organizations, meLink, + config, + external, + logoutLink, + organizations, + environment, } = await getMeAJAX() + dispatch( meGetCompleted({ me, @@ -72,7 +77,17 @@ export const getMeAsync = ({shouldResetMe = false} = {}) => async dispatch => { logoutLink, }) ) - dispatch(linksReceived({external, users, organizations, me: meLink})) // TODO: put this before meGetCompleted... though for some reason it doesn't fire the first time then + + dispatch( + linksReceived({ + external, + users, + organizations, + me: meLink, + config, + environment, + }) + ) // TODO: put this before meGetCompleted... though for some reason it doesn't fire the first time then } catch (error) { dispatch(meGetFailed()) dispatch(errorThrown(error)) @@ -86,10 +101,15 @@ export const meChangeOrganizationAsync = ( dispatch(meChangeOrganizationRequested()) try { const {data: me, auth, logoutLink} = await updateMeAJAX(url, organization) + const currentRole = me.roles.find( + r => r.organization === me.currentOrganization.id + ) dispatch( publishAutoDismissingNotification( 'success', - `Now signed into ${me.currentOrganization.name}` + `Now logged in to '${me.currentOrganization + .name}' as '${currentRole.name}'`, + LONG_NOTIFICATION_DISMISS_DELAY ) ) dispatch(meChangeOrganizationCompleted()) diff --git a/ui/src/shared/actions/config.js b/ui/src/shared/actions/config.js new file mode 100644 index 000000000..7d4bc4b13 --- /dev/null +++ b/ui/src/shared/actions/config.js @@ -0,0 +1,67 @@ +import { + getAuthConfig as getAuthConfigAJAX, + updateAuthConfig as updateAuthConfigAJAX, +} from 'shared/apis/config' + +import {errorThrown} from 'shared/actions/errors' + +export const getAuthConfigRequested = () => ({ + type: 'CHRONOGRAF_GET_AUTH_CONFIG_REQUESTED', +}) + +export const getAuthConfigCompleted = authConfig => ({ + type: 'CHRONOGRAF_GET_AUTH_CONFIG_COMPLETED', + payload: { + authConfig, + }, +}) + +export const getAuthConfigFailed = () => ({ + type: 'CHRONOGRAF_GET_AUTH_CONFIG_FAILED', +}) + +export const updateAuthConfigRequested = authConfig => ({ + type: 'CHRONOGRAF_UPDATE_AUTH_CONFIG_REQUESTED', + payload: { + authConfig, + }, +}) + +export const updateAuthConfigCompleted = () => ({ + type: 'CHRONOGRAF_UPDATE_AUTH_CONFIG_COMPLETED', +}) + +export const updateAuthConfigFailed = authConfig => ({ + type: 'CHRONOGRAF_UPDATE_AUTH_CONFIG_FAILED', + payload: { + authConfig, + }, +}) + +// async actions (thunks) +export const getAuthConfigAsync = url => async dispatch => { + dispatch(getAuthConfigRequested()) + try { + const {data} = await getAuthConfigAJAX(url) + dispatch(getAuthConfigCompleted(data)) // TODO: change authConfig in actions & reducers to reflect final shape + } catch (error) { + dispatch(errorThrown(error)) + dispatch(getAuthConfigFailed()) + } +} + +export const updateAuthConfigAsync = ( + url, + oldAuthConfig, + updatedAuthConfig +) => async dispatch => { + const newAuthConfig = {...oldAuthConfig, ...updatedAuthConfig} + dispatch(updateAuthConfigRequested(newAuthConfig)) + try { + await updateAuthConfigAJAX(url, newAuthConfig) + dispatch(updateAuthConfigCompleted()) + } catch (error) { + dispatch(errorThrown(error)) + dispatch(updateAuthConfigFailed(oldAuthConfig)) + } +} diff --git a/ui/src/shared/apis/config.js b/ui/src/shared/apis/config.js new file mode 100644 index 000000000..134178408 --- /dev/null +++ b/ui/src/shared/apis/config.js @@ -0,0 +1,26 @@ +import AJAX from 'src/utils/ajax' + +export const getAuthConfig = async url => { + try { + return await AJAX({ + method: 'GET', + url, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const updateAuthConfig = async (url, authConfig) => { + try { + return await AJAX({ + method: 'PUT', + url, + data: authConfig, + }) + } catch (error) { + console.error(error) + throw error + } +} diff --git a/ui/src/shared/apis/env.js b/ui/src/shared/apis/env.js new file mode 100644 index 000000000..77070e9c8 --- /dev/null +++ b/ui/src/shared/apis/env.js @@ -0,0 +1,19 @@ +import AJAX from 'src/utils/ajax' + +const DEFAULT_ENVS = { + telegrafSystemInterval: '1m', +} + +export const getEnv = async url => { + try { + const {data} = await AJAX({ + method: 'GET', + url, + }) + + return data + } catch (error) { + console.error('Error retreieving envs: ', error) + return DEFAULT_ENVS + } +} diff --git a/ui/src/shared/components/OptIn.js b/ui/src/shared/components/OptIn.js index 7f1fee135..aa46aaed7 100644 --- a/ui/src/shared/components/OptIn.js +++ b/ui/src/shared/components/OptIn.js @@ -121,19 +121,21 @@ class OptIn extends Component { handleClickOutsideInput={this.handleClickOutsideInput} />
(this.grooveKnobContainer = el)} - onClick={this.handleClickToggle} >
(this.grooveKnob = el)} - /> -
-
- {fixedPlaceholder} + onClick={this.handleClickToggle} + > +
+
+
+ {fixedPlaceholder} +
) diff --git a/ui/src/shared/components/RefreshingGraph.js b/ui/src/shared/components/RefreshingGraph.js index b3f03b437..05b7917de 100644 --- a/ui/src/shared/components/RefreshingGraph.js +++ b/ui/src/shared/components/RefreshingGraph.js @@ -39,13 +39,16 @@ const RefreshingGraph = ({ } if (type === 'single-stat') { + const suffix = axes.y.suffix || '' return ( ) } diff --git a/ui/src/shared/components/SingleStat.js b/ui/src/shared/components/SingleStat.js index 5b6bcf969..3c3f3cc6b 100644 --- a/ui/src/shared/components/SingleStat.js +++ b/ui/src/shared/components/SingleStat.js @@ -1,17 +1,18 @@ -import React, {PropTypes, Component} from 'react' +import React, {PropTypes, PureComponent} from 'react' +import _ from 'lodash' import classnames from 'classnames' -import shallowCompare from 'react-addons-shallow-compare' import lastValues from 'shared/parsing/lastValues' -import {SMALL_CELL_HEIGHT} from 'src/shared/graphs/helpers' +import {SMALL_CELL_HEIGHT} from 'shared/graphs/helpers' +import {SINGLE_STAT_TEXT} from 'src/dashboards/constants/gaugeColors' +import {isBackgroundLight} from 'shared/constants/colorOperations' -class SingleStat extends Component { - shouldComponentUpdate(nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } +const darkText = '#292933' +const lightText = '#ffffff' +class SingleStat extends PureComponent { render() { - const {data, cellHeight, isFetchingInitially} = this.props + const {data, cellHeight, isFetchingInitially, colors, suffix} = this.props // If data for this graph is being fetched for the first time, show a graph-wide spinner. if (isFetchingInitially) { @@ -26,27 +27,65 @@ class SingleStat extends Component { const precision = 100.0 const roundedValue = Math.round(+lastValue * precision) / precision + let bgColor = null + let textColor = null + let className = 'single-stat' + + if (colors && colors.length > 0) { + className = 'single-stat single-stat--colored' + const sortedColors = _.sortBy(colors, color => Number(color.value)) + const nearestCrossedThreshold = sortedColors + .filter(color => lastValue > color.value) + .pop() + + const colorizeText = _.some(colors, {type: SINGLE_STAT_TEXT}) + + if (colorizeText) { + textColor = nearestCrossedThreshold + ? nearestCrossedThreshold.hex + : '#292933' + } else { + bgColor = nearestCrossedThreshold + ? nearestCrossedThreshold.hex + : '#292933' + textColor = isBackgroundLight(bgColor) ? darkText : lightText + } + } return ( -
+
{roundedValue} + {suffix}
) } } -const {arrayOf, bool, number, shape} = PropTypes +const {arrayOf, bool, number, shape, string} = PropTypes SingleStat.propTypes = { data: arrayOf(shape()).isRequired, isFetchingInitially: bool, cellHeight: number, + colors: arrayOf( + shape({ + type: string.isRequired, + hex: string.isRequired, + id: string.isRequired, + name: string.isRequired, + value: string.isRequired, + }).isRequired + ), + suffix: string, } export default SingleStat diff --git a/ui/src/shared/components/SlideToggle.js b/ui/src/shared/components/SlideToggle.js index f917ec7f8..fa84675f8 100644 --- a/ui/src/shared/components/SlideToggle.js +++ b/ui/src/shared/components/SlideToggle.js @@ -5,12 +5,20 @@ class SlideToggle extends Component { super(props) this.state = { - active: this.props.active, + active: props.active, } } + componentWillReceiveProps(nextProps) { + this.setState({active: nextProps.active}) + } + handleClick = () => { - const {onToggle} = this.props + const {onToggle, disabled} = this.props + + if (disabled) { + return + } this.setState({active: !this.state.active}, () => { onToggle(this.state.active) @@ -18,15 +26,15 @@ class SlideToggle extends Component { } render() { - const {size} = this.props + const {size, disabled} = this.props const {active} = this.state - const classNames = active - ? `slide-toggle slide-toggle__${size} active` - : `slide-toggle slide-toggle__${size}` + const className = `slide-toggle slide-toggle__${size} ${active + ? 'active' + : null} ${disabled ? 'disabled' : null}` return ( -
+
) @@ -42,6 +50,7 @@ SlideToggle.propTypes = { active: bool, size: string, onToggle: func.isRequired, + disabled: bool, } export default SlideToggle diff --git a/ui/src/shared/constants/colorOperations.js b/ui/src/shared/constants/colorOperations.js new file mode 100644 index 000000000..89258f6bf --- /dev/null +++ b/ui/src/shared/constants/colorOperations.js @@ -0,0 +1,24 @@ +const hexToRgb = hex => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null +} + +const averageRgbValues = valuesObject => { + const {r, g, b} = valuesObject + return (r + g + b) / 3 +} + +const trueNeutralGrey = 128 + +export const isBackgroundLight = backgroundColor => { + const averageBackground = averageRgbValues(hexToRgb(backgroundColor)) + const isLight = averageBackground > trueNeutralGrey + + return isLight +} diff --git a/ui/src/shared/constants/index.js b/ui/src/shared/constants/index.js index 1c095d420..0bc9e48ac 100644 --- a/ui/src/shared/constants/index.js +++ b/ui/src/shared/constants/index.js @@ -387,6 +387,7 @@ export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds. export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds. export const SHORT_NOTIFICATION_DISMISS_DELAY = 2000 // in milliseconds +export const LONG_NOTIFICATION_DISMISS_DELAY = 4000 // in milliseconds export const REVERT_STATE_DELAY = 1500 // ms diff --git a/ui/src/shared/middleware/errors.js b/ui/src/shared/middleware/errors.js index 012f3e28d..93510b1fb 100644 --- a/ui/src/shared/middleware/errors.js +++ b/ui/src/shared/middleware/errors.js @@ -1,3 +1,5 @@ +import _ from 'lodash' + import {authExpired} from 'shared/actions/auth' import {publishNotification as notify} from 'shared/actions/notifications' @@ -17,13 +19,11 @@ const errorsMiddleware = store => next => action => { const {auth: {me}} = store.getState() if (action.type === 'ERROR_THROWN') { - const { - error: {status, auth, data: {message}}, - altText, - alertType = 'error', - } = action + const {error, error: {status, auth}, altText, alertType = 'error'} = action if (status === HTTP_FORBIDDEN) { + const message = _.get(error, 'data.message', '') + const organizationWasRemoved = message === `user's current organization was not found` // eslint-disable-line quotes const wasSessionTimeout = me !== null @@ -48,7 +48,10 @@ const errorsMiddleware = store => next => action => { }, notificationsBlackoutDuration) } else if (wasSessionTimeout) { store.dispatch( - notify(alertType, 'Session timed out. Please login again.') + notify( + alertType, + 'Your session has timed out. Log in again to continue.' + ) ) allowNotifications = false diff --git a/ui/src/shared/parsing/getRangeForDygraph.js b/ui/src/shared/parsing/getRangeForDygraph.js index f3664899b..adeab371d 100644 --- a/ui/src/shared/parsing/getRangeForDygraph.js +++ b/ui/src/shared/parsing/getRangeForDygraph.js @@ -80,6 +80,11 @@ const getRange = ( } } + // prevents inversion of graph + if (min > max) { + return [min, min + 1] + } + return [min, max] } diff --git a/ui/src/shared/presenters/index.js b/ui/src/shared/presenters/index.js index 1c19b5e55..ccb096ba5 100644 --- a/ui/src/shared/presenters/index.js +++ b/ui/src/shared/presenters/index.js @@ -114,6 +114,11 @@ function getRolesForUser(roles, user) { export const buildDefaultYLabel = queryConfig => { const {measurement} = queryConfig const fields = _.get(queryConfig, ['fields', '0'], []) + const isEmpty = !measurement && !fields.length + + if (isEmpty) { + return '' + } const walkZerothArgs = f => { if (f.type === 'field') { diff --git a/ui/src/shared/reducers/config.js b/ui/src/shared/reducers/config.js new file mode 100644 index 000000000..08a731a00 --- /dev/null +++ b/ui/src/shared/reducers/config.js @@ -0,0 +1,22 @@ +const initialState = { + links: {}, + auth: {}, +} + +const config = (state = initialState, action) => { + switch (action.type) { + case 'CHRONOGRAF_GET_AUTH_CONFIG_COMPLETED': + case 'CHRONOGRAF_UPDATE_AUTH_CONFIG_REQUESTED': + case 'CHRONOGRAF_UPDATE_AUTH_CONFIG_FAILED': { + const {authConfig: auth} = action.payload + return { + ...state, + auth: {...auth}, + } + } + } + + return state +} + +export default config diff --git a/ui/src/shared/reducers/index.js b/ui/src/shared/reducers/index.js index b8824ab25..93b4126a7 100644 --- a/ui/src/shared/reducers/index.js +++ b/ui/src/shared/reducers/index.js @@ -1,5 +1,6 @@ import app from './app' import auth from './auth' +import config from './config' import errors from './errors' import links from './links' import {notifications, dismissedNotifications} from './notifications' @@ -8,6 +9,7 @@ import sources from './sources' export default { app, auth, + config, errors, links, notifications, diff --git a/ui/src/side_nav/components/UserNavBlock.js b/ui/src/side_nav/components/UserNavBlock.js index 532af0520..fe8f60b66 100644 --- a/ui/src/side_nav/components/UserNavBlock.js +++ b/ui/src/side_nav/components/UserNavBlock.js @@ -30,8 +30,6 @@ class UserNavBlock extends Component { const isSuperAdmin = role === SUPERADMIN_ROLE - const isSmallViewport = window.visualViewport.height < 850 - return (
@@ -40,125 +38,69 @@ class UserNavBlock extends Component { ? : null}
- {isSmallViewport - ?
- {customLinks - ?
Custom Links
- : null} - {customLinks - ? customLinks.map((link, i) => - - {link.name} - - ) - : null} -
Switch Organizations
- - {roles.map((r, i) => { - const isLinkCurrentOrg = - currentOrganization.id === r.organization - return ( - - { - organizations.find(o => o.id === r.organization).name - }{' '} - ({r.name}) - - ) - })} - -
Account
-
-
- {me.scheme} / {me.provider} -
+
+ {customLinks + ?
+ Custom Links
- - Logout - -
- {me.name} -
-
+ : null} + {customLinks + ? customLinks.map((link, i) => + + {link.name} + + ) + : null} +
+ Switch Organizations +
+ + {roles.map((r, i) => { + const isLinkCurrentOrg = currentOrganization.id === r.organization + return ( + + {organizations.find(o => o.id === r.organization).name}{' '} + ({r.name}) + + ) + })} + +
+ Account +
+
+
+ {me.scheme} / {me.provider}
- :
-
- {me.name} -
-
Account
-
-
- {me.scheme} / {me.provider} -
-
- - Logout - -
Switch Organizations
- - {roles.map((r, i) => { - const isLinkCurrentOrg = - currentOrganization.id === r.organization - return ( - - { - organizations.find(o => o.id === r.organization).name - }{' '} - ({r.name}) - - ) - })} - - {customLinks - ?
Custom Links
- : null} - {customLinks - ? customLinks.map((link, i) => - - {link.name} - - ) - : null} -
-
} +
+ + Log out + +
+ {me.name} +
+
+
) } diff --git a/ui/src/sources/components/InfluxTable.js b/ui/src/sources/components/InfluxTable.js index 500ed2002..8dcab239e 100644 --- a/ui/src/sources/components/InfluxTable.js +++ b/ui/src/sources/components/InfluxTable.js @@ -43,10 +43,16 @@ const kapacitorDropdown = ( selected = kapacitorItems[0].text } + const unauthorizedDropdown = ( +
+ {selected} +
+ ) + return ( @@ -138,7 +139,7 @@ const SourceForm = ({
: null} -
+
+
+ {isUsingAuth + ? + : null}
@@ -179,6 +187,7 @@ SourceForm.propTypes = { }), isUsingAuth: bool, isInitialSource: bool, + gotoPurgatory: func, } const mapStateToProps = ({auth: {isUsingAuth, me}}) => ({isUsingAuth, me}) diff --git a/ui/src/sources/containers/SourcePage.js b/ui/src/sources/containers/SourcePage.js index 25d160abe..861fc3cc3 100644 --- a/ui/src/sources/containers/SourcePage.js +++ b/ui/src/sources/containers/SourcePage.js @@ -10,6 +10,7 @@ import { import {publishNotification} from 'shared/actions/notifications' import {connect} from 'react-redux' +import Notifications from 'shared/components/Notifications' import SourceForm from 'src/sources/components/SourceForm' import FancyScrollbar from 'shared/components/FancyScrollbar' import SourceIndicator from 'shared/components/SourceIndicator' @@ -100,6 +101,11 @@ class SourcePage extends Component { notify('error', `${bannerText}: ${error}`) } + gotoPurgatory = () => { + const {router} = this.props + router.push('/purgatory') + } + _normalizeSource({source}) { const url = source.url.trim() if (source.url.startsWith('http')) { @@ -196,22 +202,23 @@ class SourcePage extends Component { return (
- {isInitialSource - ? null - :
-
-
-
-

- {editMode ? 'Edit Source' : 'Add a New Source'} -

-
-
- -
-
+ +
+
+
+
+

+ {editMode ? 'Edit Source' : 'Add a New Source'} +

-
} + {isInitialSource + ? null + :
+ +
} +
+
+
@@ -224,6 +231,7 @@ class SourcePage extends Component { onSubmit={this.handleSubmit} onBlurSourceURL={this.handleBlurSourceURL} isInitialSource={isInitialSource} + gotoPurgatory={this.gotoPurgatory} />
diff --git a/ui/src/style/components/ceo-display-options.scss b/ui/src/style/components/ceo-display-options.scss index c2c7ea7f0..4d5b47322 100644 --- a/ui/src/style/components/ceo-display-options.scss +++ b/ui/src/style/components/ceo-display-options.scss @@ -1,6 +1,6 @@ /* Cell Editor Overlay - Display Options - ------------------------------------------------------ + ------------------------------------------------------------------------------ */ $graph-type--gutter: 4px; @@ -200,7 +200,7 @@ $graph-type--gutter: 4px; /* Cell Editor Overlay - Gauge Controls - ------------------------------------------------------ + ------------------------------------------------------------------------------ */ .gauge-controls { width: 100%; @@ -212,7 +212,7 @@ $graph-type--gutter: 4px; flex-wrap: nowrap; align-items: center; height: 30px; - margin-bottom: 8px; + margin-top: 8px; } button.btn.btn-primary.btn-sm.gauge-controls--add-threshold { width: 100%; @@ -244,3 +244,18 @@ button.btn.btn-primary.btn-sm.gauge-controls--add-threshold { flex: 1 0 0; margin: 0 4px; } + +/* + Cell Editor Overlay - Single-Stat Controls + ------------------------------------------------------------------------------ +*/ +.single-stat-controls { + display: inline-block; + width: calc(100% + 12px); + margin: 30px -6px 0 -6px; + + > div.form-group { + padding-left: 6px; + padding-right: 6px; + } +} diff --git a/ui/src/style/components/dygraphs.scss b/ui/src/style/components/dygraphs.scss index 5f64c1ba3..1e46b2b08 100644 --- a/ui/src/style/components/dygraphs.scss +++ b/ui/src/style/components/dygraphs.scss @@ -75,10 +75,13 @@ /* Single Stat Cells */ .single-stat { position: absolute; - width: 100%; - height: 100%; + left: 2px; + width: calc(100% - 4px); + height: calc(100% - 2px); pointer-events: none; + border-radius: 3px; @include no-user-select(); + color: $c-laser; &.graph-single-stat { top: 0; @@ -89,19 +92,20 @@ height: 100% !important; } } +.single-stat.single-stat--colored { + transition: background-color 0.25s ease, color 0.25s ease; +} .single-stat--value { position: absolute; - top: calc(50% - 15px); + top: 50%; left: 50%; transform: translate(-50%,-50%); width: calc(100% - 32px); - // overflow: hidden; text-align: center; - // text-overflow: ellipsis; font-size: 54px; line-height: 54px; font-weight: 300; - color: $c-laser; + color: inherit; z-index: 1; &.single-stat--small { @@ -130,6 +134,7 @@ } + /* Legend Styles ------------------------------------------------------------------------------ diff --git a/ui/src/style/components/opt-in.scss b/ui/src/style/components/opt-in.scss index 6e45ea39f..1ec10a789 100644 --- a/ui/src/style/components/opt-in.scss +++ b/ui/src/style/components/opt-in.scss @@ -8,87 +8,87 @@ align-items: stretch; flex-wrap: nowrap; } -.opt-in--left-label { +.opt-in--container { + display: flex; + align-items: stretch; border: 2px solid $g5-pepper; border-left: 0; - background-color: $g2-kevlar; + border-radius: 0 4px 4px 0; +} +.opt-in--label { + user-select: none !important; + -moz-user-select: none !important; + -webkit-user-select: none !important; + -ms-user-select: none !important; + -o-user-select: none !important; color: $c-pool; + background-color: $g2-kevlar; font-family: $code-font; padding-right: 11px; - border-radius: 0 4px 4px 0; line-height: 24px; font-size: 13px; font-weight: 500; - cursor: default; - @include no-user-select(); transition: background-color 0.25s ease, color 0.25s ease; + &:hover { cursor: pointer; } + &:hover:active { + cursor: pointer; + color: $c-laser; + } } -.opt-in--groove-knob-container { - display: flex; - align-items: center; - border: 2px solid $g5-pepper; - border-left: 0; - border-right: 0; - position: relative; +.opt-in--groove-knob { + width: 48px; + position: relative; + background-color: $g2-kevlar; + + &, &:hover { + cursor: pointer; + } + + &:before, + &:after { + position: absolute; + top: 50%; + content: ''; + } // Groove - > div.opt-in--groove-knob { - margin: 0 10px; - z-index: 3; + &:before { + z-index: 2; width: 28px; height: 8px; border-radius: 4px; background-color: $g6-smoke; - position: relative; - - // Knob - &:after { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 14px; - height: 14px; - border-radius: 50%; - background-color: $c-pool; - transition: background-color 0.25s ease, transform 0.25s ease; - transform: translate(0%, -50%); - } + transform: translate(-50%,-50%); + left: 50%; } - - // Background Gradients - &:before, + // Knob &:after { - content: ''; - display: block; + z-index: 3; + left: 50%; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: $c-pool; + transition: background-color 0.25s ease, transform 0.25s ease; + transform: translate(0%, -50%); + } + // Gradient + .opt-in--gradient { + z-index: 1; position: absolute; - left: 0; width: 100%; height: 100%; - transition: opacity 0.25s ease; - } - // Left - &:before { - background-color: $g2-kevlar; - z-index: 2; - opacity: 1; - } - // Right - &:after { + top: 0; + left: 0; @include gradient-h($g2-kevlar,$g3-castle); - z-index: 1; - } - - &:hover { - cursor: pointer; - > div:after { - background-color: $c-laser; - } + transition: opacity 0.25s ease; + opacity: 0; } } + // Customize form input .opt-in > input.form-control { border-radius: 4px 0 0 4px; @@ -101,13 +101,21 @@ transform: translate(-100%, -50%); } // Fade out left, fade in right - .opt-in--groove-knob-container:before { - opacity: 0; + .opt-in--gradient { + opacity: 1; } // Make left label look disabled - .opt-in--left-label { + .opt-in--label { background-color: $g3-castle; color: $g8-storm; font-style: italic; + + &:hover { + color: $c-pool; + } + &:hover:active { + font-style: normal; + color: $c-laser; + } } } diff --git a/ui/src/style/components/organizations-table.scss b/ui/src/style/components/organizations-table.scss index 80551bbc4..308bf8689 100644 --- a/ui/src/style/components/organizations-table.scss +++ b/ui/src/style/components/organizations-table.scss @@ -163,3 +163,9 @@ input[type="text"].form-control.orgs-table--input { padding: 0 11px; } } + + +/* Config table beneath organizations table */ +.panel .panel-body table.table.superadmin-config { + margin-top: 60px; +} diff --git a/ui/src/style/components/react-tooltips.scss b/ui/src/style/components/react-tooltips.scss index 2c1e8540d..29f7eee49 100644 --- a/ui/src/style/components/react-tooltips.scss +++ b/ui/src/style/components/react-tooltips.scss @@ -26,6 +26,7 @@ $tooltip-code-color: $c-potassium; word-break: keep-all; border-radius: $tooltip-radius; text-transform: none; + text-align: left; cursor: default; &.type-dark { diff --git a/ui/src/style/components/slide-toggle.scss b/ui/src/style/components/slide-toggle.scss index 09f9b9c53..eec03eb93 100644 --- a/ui/src/style/components/slide-toggle.scss +++ b/ui/src/style/components/slide-toggle.scss @@ -29,6 +29,7 @@ } } +/* Active State */ .slide-toggle.active .slide-toggle--knob { background-color: $c-rainforest; transform: translate(100%,-50%); @@ -37,6 +38,23 @@ background-color: $c-honeydew; } +/* Disabled State */ +.slide-toggle.disabled { + &, + &:hover, + &.active, + &.active:hover { + background-color: $g6-smoke; + cursor: not-allowed; + } + .slide-toggle--knob, + &:hover .slide-toggle--knob, + &.active .slide-toggle--knob, + &.active:hover .slide-toggle--knob { + background-color: $g3-castle; + } +} + /* Size Modifiers */ .slide-toggle { diff --git a/ui/src/style/layout/sidebar.scss b/ui/src/style/layout/sidebar.scss index 1138f0b95..f9a3e529d 100644 --- a/ui/src/style/layout/sidebar.scss +++ b/ui/src/style/layout/sidebar.scss @@ -140,11 +140,6 @@ $sidebar-menu--gutter: 18px; transition: opacity 0.25s ease; display: none; flex-direction: column; - - &.sidebar-menu--inverse { - top: initial; - bottom: 0; - } } .sidebar-menu--heading, .sidebar-menu--item { @@ -204,19 +199,11 @@ $sidebar-menu--gutter: 18px; opacity: 0.6; } // Invisible triangle for easier mouse movement when navigating to sub items -.sidebar-menu--item + .sidebar-menu--triangle, -.sidebar-menu--inverse .sidebar-menu--triangle { +.sidebar-menu--triangle { position: absolute; z-index: -1; } -.sidebar-menu--item + .sidebar-menu--triangle { - width: 40px; - height: 40px; - top: $sidebar--width; - left: 0px; - transform: translate(-50%,-50%) rotate(45deg); -} -.sidebar-menu--inverse .sidebar-menu--triangle { +.sidebar-menu .sidebar-menu--triangle { width: 50px; height: 60px; bottom: 12px; @@ -244,11 +231,6 @@ $sidebar-menu--gutter: 18px; height: 2px; @include gradient-h($c-laser,$c-potassium); } - - &:first-child:after { - display: none; - border-top-right-radius: $radius; - } } // SuperAdminIndicator @@ -310,3 +292,53 @@ span.icon.sidebar--icon.sidebar--icon__superadmin { .fancy-scroll--thumb-h {display: none !important;} .fancy-scroll--thumb-v { @include gradient-v($g20-white,$c-neutrino); } } + +.sidebar-menu--user-nav { + top: initial; + bottom: 0; + + .sidebar-menu--section__custom-links { order: 0; } + .sidebar-menu--item__link-name { order: 1; } + .sidebar-menu--section__switch-orgs { order: 2; } + .sidebar-menu--scrollbar { order: 3; } + .sidebar-menu--section__account { order: 4; } + .sidebar-menu--provider { order: 5; } + .sidebar-menu--item__logout { order: 6; } + .sidebar-menu--heading { order: 7; } + .sidebar-menu--triangle { order: 8; } + + .sidebar-menu--section__custom-links:after { + display: none; + border-top-right-radius: $radius; + } +} + +@media only screen and (min-height: 800px) { + .sidebar-menu--user-nav { + top: 0; + bottom: initial; + + .sidebar-menu--heading { order: 0; } + .sidebar-menu--section__account { order: 1; } + .sidebar-menu--provider { order: 2; } + .sidebar-menu--item__logout { order: 3; } + .sidebar-menu--section__switch-orgs { order: 4; } + .sidebar-menu--scrollbar { order: 5; } + .sidebar-menu--section__custom-links { order: 6; } + .sidebar-menu--item__link-name { order: 7; } + .sidebar-menu--triangle { order: 8; } + + .sidebar-menu--section__custom-links:after { + display: initial; + border-top-right-radius: 0; + } + + .sidebar-menu--triangle { + width: 40px; + height: 40px; + top: $sidebar--width; + left: 0px; + transform: translate(-50%,-50%) rotate(45deg); + } + } +} \ No newline at end of file diff --git a/ui/src/style/pages/users.scss b/ui/src/style/pages/users.scss index 96b480018..9878d4b7e 100644 --- a/ui/src/style/pages/users.scss +++ b/ui/src/style/pages/users.scss @@ -104,11 +104,6 @@ table.table.chronograf-admin-table tbody tr.chronograf-admin-table--user td div. /* Styles for new user row */ table.table.chronograf-admin-table tbody tr.chronograf-admin-table--new-user { background-color: $g4-onyx; - - > td { - padding-top: 8px; - padding-bottom: 8px; - } } /* Highlight "Me" in the users table */ diff --git a/ui/src/style/unsorted.scss b/ui/src/style/unsorted.scss index 0527925ee..28eecff44 100644 --- a/ui/src/style/unsorted.scss +++ b/ui/src/style/unsorted.scss @@ -180,6 +180,11 @@ br { border-left: 2px solid $g5-pepper; width: 278px; } +.source-table--kapacitor__view-only { + @include no-user-select(); + font-size: 14px; + font-weight: 600; +} /* Styles for the Status Dashboard diff --git a/ui/src/utils/ajax.js b/ui/src/utils/ajax.js index 69ee8bf04..48e9591ef 100644 --- a/ui/src/utils/ajax.js +++ b/ui/src/utils/ajax.js @@ -10,7 +10,17 @@ const addBasepath = (url, excludeBasepath) => { } const generateResponseWithLinks = (response, newLinks) => { - const {auth, logout, external, users, organizations, me: meLink} = newLinks + const { + auth, + logout, + external, + users, + organizations, + me: meLink, + config, + environment, + } = newLinks + return { ...response, auth: {links: auth}, @@ -19,6 +29,8 @@ const generateResponseWithLinks = (response, newLinks) => { users, organizations, meLink, + config, + environment, } } diff --git a/ui/src/utils/defaultQueryConfig.js b/ui/src/utils/defaultQueryConfig.js index 16915dff9..47810d910 100644 --- a/ui/src/utils/defaultQueryConfig.js +++ b/ui/src/utils/defaultQueryConfig.js @@ -1,6 +1,10 @@ +import uuid from 'node-uuid' + import {NULL_STRING} from 'shared/constants/queryFillOptions' -const defaultQueryConfig = ({id, isKapacitorRule = false}) => { +const defaultQueryConfig = ( + {id, isKapacitorRule = false} = {id: uuid.v4()} +) => { const queryConfig = { id, database: null, diff --git a/ui/src/utils/formatting.js b/ui/src/utils/formatting.js index 49f8fd72f..881c1203e 100644 --- a/ui/src/utils/formatting.js +++ b/ui/src/utils/formatting.js @@ -136,13 +136,26 @@ export const formatRPDuration = duration => { } let adjustedTime = duration - const [_, hours, minutes, seconds] = duration.match(/(\d*)h(\d*)m(\d*)s/) // eslint-disable-line no-unused-vars + const durationMatcher = /(?:(\d*)d)?(?:(\d*)h)?(?:(\d*)m)?(?:(\d*)s)?/ + const [ + _match, // eslint-disable-line no-unused-vars + days = 0, + hours = 0, + minutes = 0, + seconds = 0, + ] = duration.match(durationMatcher) + const hoursInDay = 24 - if (hours > hoursInDay) { - const remainder = hours % hoursInDay - const days = (hours - remainder) / hoursInDay + if (days) { adjustedTime = `${days}d` - adjustedTime += +remainder === 0 ? '' : `${remainder}h` + adjustedTime += +hours === 0 ? '' : `${hours}h` + adjustedTime += +minutes === 0 ? '' : `${minutes}m` + adjustedTime += +seconds === 0 ? '' : `${seconds}s` + } else if (hours > hoursInDay) { + const hoursRemainder = hours % hoursInDay + const daysQuotient = (hours - hoursRemainder) / hoursInDay + adjustedTime = `${daysQuotient}d` + adjustedTime += +hoursRemainder === 0 ? '' : `${hoursRemainder}h` adjustedTime += +minutes === 0 ? '' : `${minutes}m` adjustedTime += +seconds === 0 ? '' : `${seconds}s` } else { diff --git a/ui/yarn.lock b/ui/yarn.lock index 966c0876e..a2b777e95 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -2524,9 +2524,9 @@ dot-prop@^3.0.0: dependencies: is-obj "^1.0.0" -dygraphs@influxdata/dygraphs: - version "2.0.0" - resolved "https://codeload.github.com/influxdata/dygraphs/tar.gz/9cc90443f58c11b45473516a97d4bb3a68a620c2" +dygraphs@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/dygraphs/-/dygraphs-2.1.0.tgz#2fbfd2c803ead02307df3faf8d4dd3ef55cb2075" ecc-jsbn@~0.1.1: version "0.1.1" diff --git a/uuid/v4.go b/uuid/v4.go deleted file mode 100644 index 75077df68..000000000 --- a/uuid/v4.go +++ /dev/null @@ -1,11 +0,0 @@ -package uuid - -import uuid "github.com/satori/go.uuid" - -// V4 implements chronograf.ID -type V4 struct{} - -// Generate creates a UUID v4 string -func (i *V4) Generate() (string, error) { - return uuid.NewV4().String(), nil -}