diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d4d84d67a..004b4f024 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.4.0.0 +current_version = 1.4.0.1 files = README.md server/swagger.json parse = (?P\d+)\.(?P\d+)\.(?P\d+)\.(?P\d+) serialize = {major}.{minor}.{patch}.{release} diff --git a/CHANGELOG.md b/CHANGELOG.md index f886c8a5e..867eb2472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,36 @@ ## v1.4.1.0 [unreleased] ### Features +1. [#2409](https://github.com/influxdata/chronograf/pull/2409): Allow adding multiple event handlers to a rule +1. [#2709](https://github.com/influxdata/chronograf/pull/2709): Add "send test alert" button to test kapacitor alert configurations +1. [#2708](https://github.com/influxdata/chronograf/pull/2708): Link to specified kapacitor config panel from rule builder alert handlers +1. [#2722](https://github.com/influxdata/chronograf/pull/2722): Add auto refresh widget to hosts list page +1. [#2765](https://github.com/influxdata/chronograf/pull/2765): Update to go 1.9.3 and node 6.12.3 for releases +1. [#2784](https://github.com/influxdata/chronograf/pull/2784): Update to go 1.9.4 +1. [#2703](https://github.com/influxdata/chronograf/pull/2703): Add global users page visible only to super admins +1. [#2777](https://github.com/influxdata/chronograf/pull/2777): Allow user to delete themselves + +1. [#2703](https://github.com/influxdata/chronograf/pull/2703): Add global users page visible only to super admins +1. [#2781](https://github.com/influxdata/chronograf/pull/2781): Add commands to users & create super admin ### UI Improvements +1. [#2698](https://github.com/influxdata/chronograf/pull/2698): Improve clarity of terminology surrounding InfluxDB & Kapacitor connections +1. [#2746](https://github.com/influxdata/chronograf/pull/2746): Separate saving TICKscript from exiting editor page +1. [#2774](https://github.com/influxdata/chronograf/pull/2774): Enable Save (⌘ + Enter) and Cancel (Escape) hotkeys in Cell Editor Overlay + +### Bug Fixes +1. [#2684](https://github.com/influxdata/chronograf/pull/2684): Fix TICKscript Sensu alerts when no group by tags selected +1. [#2735](https://github.com/influxdata/chronograf/pull/2735): Remove cli options from systemd service file +1. [#2757](https://github.com/influxdata/chronograf/pull/2757): Added "TO" field to kapacitor SMTP config, and improved error messages for config saving and testing +1. [#2761](https://github.com/influxdata/chronograf/pull/2761): Remove cli options from sysvinit service file +1. [#2780](https://github.com/influxdata/chronograf/pull/2780): Fix routing on alert save + +## v1.4.0.1 [2017-1-9] +### Features +1. [#2690](https://github.com/influxdata/chronograf/pull/2690): Add separate CLI flag for canned sources, kapacitors, dashboards, and organizations +1. [#2672](https://github.com/influxdata/chronograf/pull/2672): Add telegraf interval configuration + ### Bug Fixes 1. [#2689](https://github.com/influxdata/chronograf/pull/2689): Allow insecure (self-signed) certificates for kapacitor and influxdb - - -## v1.4.0.1 [unreleased] -### UI Improvements -1. [#2690](https://github.com/influxdata/chronograf/pull/2690): Add separate CLI flag for canned sources, kapacitors, dashboards, and organizations +1. [#2664](https://github.com/influxdata/chronograf/pull/2664): Fix positioning of custom time indicator ## v1.4.0.0 [2017-12-22] ### UI Improvements @@ -103,7 +126,6 @@ 1. [#2460](https://github.com/influxdata/chronograf/pull/2460): Update kapacitor alerts to cast to float before sending to influx 1. [#2479](https://github.com/influxdata/chronograf/pull/2479): Support authentication for Enterprise Meta Nodes 1. [#2477](https://github.com/influxdata/chronograf/pull/2477): Improve performance of hoverline rendering -1. [#2409](https://github.com/influxdata/chronograf/pull/2409): Add multiple event handlers to rules ### UI Improvements diff --git a/Gopkg.lock b/Gopkg.lock index af4f27d25..d8eee5654 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -30,7 +30,7 @@ branch = "master" name = "github.com/dustin/go-humanize" packages = ["."] - revision = "259d2a102b871d17f30e3cd9881a642961a1e486" + revision = "bb3d318650d48840a39aa21a027c6630e198e626" [[projects]] name = "github.com/elazarl/go-bindata-assetfs" @@ -39,18 +39,53 @@ [[projects]] name = "github.com/gogo/protobuf" - packages = ["gogoproto","jsonpb","plugin/compare","plugin/defaultcheck","plugin/description","plugin/embedcheck","plugin/enumstringer","plugin/equal","plugin/face","plugin/gostring","plugin/marshalto","plugin/oneofcheck","plugin/populate","plugin/size","plugin/stringer","plugin/testgen","plugin/union","plugin/unmarshal","proto","protoc-gen-gogo","protoc-gen-gogo/descriptor","protoc-gen-gogo/generator","protoc-gen-gogo/grpc","protoc-gen-gogo/plugin","vanity","vanity/command"] + packages = [ + "gogoproto", + "jsonpb", + "plugin/compare", + "plugin/defaultcheck", + "plugin/description", + "plugin/embedcheck", + "plugin/enumstringer", + "plugin/equal", + "plugin/face", + "plugin/gostring", + "plugin/marshalto", + "plugin/oneofcheck", + "plugin/populate", + "plugin/size", + "plugin/stringer", + "plugin/testgen", + "plugin/union", + "plugin/unmarshal", + "proto", + "protoc-gen-gogo", + "protoc-gen-gogo/descriptor", + "protoc-gen-gogo/generator", + "protoc-gen-gogo/grpc", + "protoc-gen-gogo/plugin", + "vanity", + "vanity/command" + ] revision = "6abcf94fd4c97dcb423fdafd42fe9f96ca7e421b" [[projects]] name = "github.com/golang/protobuf" packages = ["proto"] - revision = "8ee79997227bf9b34611aee7946ae64735e6fd93" + revision = "925541529c1fa6821df4e44ce2723319eb2be768" + version = "v1.0.0" [[projects]] name = "github.com/google/go-cmp" - packages = ["cmp","cmp/cmpopts"] - revision = "79b2d888f100ec053545168aa94bcfb322e8bfc8" + packages = [ + "cmp", + "cmp/cmpopts", + "cmp/internal/diff", + "cmp/internal/function", + "cmp/internal/value" + ] + revision = "8099a9787ce5dc5984ed879a3bda47dc730a8e97" + version = "v0.1.0" [[projects]] name = "github.com/google/go-github" @@ -58,19 +93,35 @@ revision = "1bc362c7737e51014af7299e016444b654095ad9" [[projects]] + branch = "master" name = "github.com/google/go-querystring" packages = ["query"] - revision = "9235644dd9e52eeae6fa48efd539fdc351a0af53" + revision = "53e6ce116135b80d037921a7fdd5138cf32d7a8a" [[projects]] name = "github.com/influxdata/influxdb" - packages = ["influxql","influxql/internal","influxql/neldermead","models","pkg/escape"] + packages = [ + "influxql", + "influxql/internal", + "influxql/neldermead", + "models", + "pkg/escape" + ] revision = "cd9363b52cac452113b95554d98a6be51beda24e" version = "v1.1.5" [[projects]] name = "github.com/influxdata/kapacitor" - packages = ["client/v1","pipeline","pipeline/tick","services/k8s/client","tick","tick/ast","tick/stateful","udf/agent"] + packages = [ + "client/v1", + "pipeline", + "pipeline/tick", + "services/k8s/client", + "tick", + "tick/ast", + "tick/stateful", + "udf/agent" + ] revision = "6de30070b39afde111fea5e041281126fe8aae31" [[projects]] @@ -84,15 +135,15 @@ revision = "4cc2832a6e6d1d3b815e2b9d544b2a4dfb3ce8fa" [[projects]] - name = "github.com/jteeuwen/go-bindata" + name = "github.com/kevinburke/go-bindata" packages = ["."] - revision = "a0ff2567cfb70903282db057e799fd826784d41d" + revision = "46eb4c183bfc1ebb527d9d19bcded39476302eb8" [[projects]] - branch = "master" name = "github.com/pkg/errors" packages = ["."] - revision = "ff09b135c25aae272398c51a07235b90a75aa4f0" + revision = "645ef00459ed84a119197bfb8d8205042c6df63d" + version = "v0.8.0" [[projects]] name = "github.com/satori/go.uuid" @@ -107,39 +158,60 @@ [[projects]] name = "github.com/tylerb/graceful" packages = ["."] - revision = "50a48b6e73fcc75b45e22c05b79629a67c79e938" - version = "v1.2.13" + revision = "4654dfbb6ad53cb5e27f37d99b02e16c1872fbbb" + version = "v1.2.15" [[projects]] name = "golang.org/x/net" - packages = ["context","context/ctxhttp"] + packages = [ + "context", + "context/ctxhttp" + ] revision = "749a502dd1eaf3e5bfd4f8956748c502357c0bbe" [[projects]] name = "golang.org/x/oauth2" - packages = [".","github","heroku","internal"] + packages = [ + ".", + "github", + "heroku", + "internal" + ] revision = "1e695b1c8febf17aad3bfa7bf0a819ef94b98ad5" [[projects]] branch = "master" name = "golang.org/x/sys" packages = ["unix"] - revision = "f3918c30c5c2cb527c0b071a27c35120a6c0719a" + revision = "37707fdb30a5b38865cfb95e5aab41707daec7fd" [[projects]] name = "google.golang.org/api" - packages = ["gensupport","googleapi","googleapi/internal/uritemplates","oauth2/v2"] + packages = [ + "gensupport", + "googleapi", + "googleapi/internal/uritemplates", + "oauth2/v2" + ] revision = "bc20c61134e1d25265dd60049f5735381e79b631" [[projects]] name = "google.golang.org/appengine" - packages = ["internal","internal/base","internal/datastore","internal/log","internal/remote_api","internal/urlfetch","urlfetch"] + packages = [ + "internal", + "internal/base", + "internal/datastore", + "internal/log", + "internal/remote_api", + "internal/urlfetch", + "urlfetch" + ] revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" version = "v1.0.0" [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "a5bd1aa82919723ff8ec5dd9d520329862de8181ca9dba75c6acb3a34df5f1a4" + inputs-digest = "11df631364d11bc05c8f71af1aa735360b5a40a793d32d47d1f1d8c694a55f6f" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 6d3735503..387b3b26c 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -1,4 +1,4 @@ -required = ["github.com/jteeuwen/go-bindata","github.com/gogo/protobuf/proto","github.com/gogo/protobuf/jsonpb","github.com/gogo/protobuf/protoc-gen-gogo","github.com/gogo/protobuf/gogoproto"] +required = ["github.com/kevinburke/go-bindata","github.com/gogo/protobuf/proto","github.com/gogo/protobuf/jsonpb","github.com/gogo/protobuf/protoc-gen-gogo","github.com/gogo/protobuf/gogoproto"] [[constraint]] name = "github.com/NYTimes/gziphandler" @@ -41,8 +41,8 @@ required = ["github.com/jteeuwen/go-bindata","github.com/gogo/protobuf/proto","g revision = "4cc2832a6e6d1d3b815e2b9d544b2a4dfb3ce8fa" [[constraint]] - name = "github.com/jteeuwen/go-bindata" - revision = "a0ff2567cfb70903282db057e799fd826784d41d" + name = "github.com/kevinburke/go-bindata" + revision = "46eb4c183bfc1ebb527d9d19bcded39476302eb8" [[constraint]] name = "github.com/satori/go.uuid" diff --git a/Makefile b/Makefile index cf866feb8..e226bb089 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --always --tags) COMMIT ?= $(shell git rev-parse --short=8 HEAD) -GOBINDATA := $(shell go list -f {{.Root}} github.com/jteeuwen/go-bindata 2> /dev/null) +GOBINDATA := $(shell go list -f {{.Root}} github.com/kevinburke/go-bindata 2> /dev/null) YARN := $(shell command -v yarn 2> /dev/null) SOURCES := $(shell find . -name '*.go' ! -name '*_gen.go' -not -path "./vendor/*" ) @@ -73,7 +73,7 @@ dep: .jsdep .godep .godep: ifndef GOBINDATA @echo "Installing go-bindata" - go get -u github.com/jteeuwen/go-bindata/... + go get -u github.com/kevinburke/go-bindata/... endif @touch .godep diff --git a/README.md b/README.md index ae8c8ab6d..1666ad28f 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ option. ## Versions The most recent version of Chronograf is -[v1.4.0.0](https://www.influxdata.com/downloads/). +[v1.4.0.1](https://www.influxdata.com/downloads/). Spotted a bug or have a feature request? Please open [an issue](https://github.com/influxdata/chronograf/issues/new)! @@ -156,7 +156,7 @@ The Chronograf team has identified and is working on the following issues: ## Installation Check out the -[INSTALLATION](https://docs.influxdata.com/chronograf/v1.3/introduction/installation/) +[INSTALLATION](https://docs.influxdata.com/chronograf/v1.4/introduction/installation/) guide to get up and running with Chronograf with as little configuration and code as possible. @@ -178,7 +178,7 @@ By default, chronograf runs on port `8888`. To get started right away with Docker, you can pull down our latest release: ```sh -docker pull chronograf:1.4.0.0 +docker pull chronograf:1.4.0.1 ``` ### From Source @@ -198,10 +198,10 @@ docker pull chronograf:1.4.0.0 ## Documentation -[Getting Started](https://docs.influxdata.com/chronograf/v1.3/introduction/getting-started/) +[Getting Started](https://docs.influxdata.com/chronograf/v1.4/introduction/getting-started/) will get you up and running with Chronograf with as little configuration and code as possible. See our -[guides](https://docs.influxdata.com/chronograf/v1.3/guides/) to get familiar +[guides](https://docs.influxdata.com/chronograf/v1.4/guides/) to get familiar with Chronograf's main features. Documentation for Telegraf, InfluxDB, and Kapacitor are available at diff --git a/bolt/config.go b/bolt/config.go index 98d6eaca2..432b964b5 100644 --- a/bolt/config.go +++ b/bolt/config.go @@ -34,7 +34,7 @@ func (s *ConfigStore) Migrate(ctx context.Context) error { func (s *ConfigStore) Initialize(ctx context.Context) error { cfg := chronograf.Config{ Auth: chronograf.AuthConfig{ - SuperAdminNewUsers: true, + SuperAdminNewUsers: false, }, } return s.Update(ctx, &cfg) diff --git a/bolt/config_test.go b/bolt/config_test.go index f23e1b736..3493891db 100644 --- a/bolt/config_test.go +++ b/bolt/config_test.go @@ -22,7 +22,7 @@ func TestConfig_Get(t *testing.T) { wants: wants{ config: &chronograf.Config{ Auth: chronograf.AuthConfig{ - SuperAdminNewUsers: true, + SuperAdminNewUsers: false, }, }, }, diff --git a/bolt/internal/internal.go b/bolt/internal/internal.go index d7e6985da..b75fc0e0c 100644 --- a/bolt/internal/internal.go +++ b/bolt/internal/internal.go @@ -274,6 +274,10 @@ func MarshalDashboard(d chronograf.Dashboard) ([]byte, error) { Type: c.Type, Axes: axes, Colors: colors, + Legend: &Legend{ + Type: c.Legend.Type, + Orientation: c.Legend.Orientation, + }, } } templates := make([]*Template, len(d.Templates)) @@ -394,6 +398,12 @@ func UnmarshalDashboard(data []byte, d *chronograf.Dashboard) error { } } + legend := chronograf.Legend{} + if c.Legend != nil { + legend.Type = c.Legend.Type + legend.Orientation = c.Legend.Orientation + } + cells[i] = chronograf.DashboardCell{ ID: c.ID, X: c.X, @@ -405,6 +415,7 @@ func UnmarshalDashboard(data []byte, d *chronograf.Dashboard) error { Type: c.Type, Axes: axes, CellColors: colors, + Legend: legend, } } @@ -570,10 +581,7 @@ func UnmarshalRole(data []byte, r *chronograf.Role) error { // UnmarshalRolePB decodes a role from binary protobuf data. func UnmarshalRolePB(data []byte, r *Role) error { - if err := proto.Unmarshal(data, r); err != nil { - return err - } - return nil + return proto.Unmarshal(data, r) } // MarshalOrganization encodes a organization to binary protobuf format. @@ -607,10 +615,7 @@ func UnmarshalOrganization(data []byte, o *chronograf.Organization) error { // UnmarshalOrganizationPB decodes a organization from binary protobuf data. func UnmarshalOrganizationPB(data []byte, o *Organization) error { - if err := proto.Unmarshal(data, o); err != nil { - return err - } - return nil + return proto.Unmarshal(data, o) } // MarshalConfig encodes a config to binary protobuf format. @@ -643,8 +648,5 @@ func UnmarshalConfig(data []byte, c *chronograf.Config) error { // 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 + return proto.Unmarshal(data, c) } diff --git a/bolt/internal/internal.pb.go b/bolt/internal/internal.pb.go index 8864e63fa..6e29bdcba 100644 --- a/bolt/internal/internal.pb.go +++ b/bolt/internal/internal.pb.go @@ -13,6 +13,7 @@ It has these top-level messages: Dashboard DashboardCell Color + Legend Axis Template TemplateValue @@ -219,6 +220,7 @@ type DashboardCell struct { ID string `protobuf:"bytes,8,opt,name=ID,proto3" json:"ID,omitempty"` Axes map[string]*Axis `protobuf:"bytes,9,rep,name=axes" json:"axes,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value"` Colors []*Color `protobuf:"bytes,10,rep,name=colors" json:"colors,omitempty"` + Legend *Legend `protobuf:"bytes,11,opt,name=legend" json:"legend,omitempty"` } func (m *DashboardCell) Reset() { *m = DashboardCell{} } @@ -296,6 +298,13 @@ func (m *DashboardCell) GetColors() []*Color { return nil } +func (m *DashboardCell) GetLegend() *Legend { + if m != nil { + return m.Legend + } + return nil +} + type Color struct { ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` Type string `protobuf:"bytes,2,opt,name=Type,proto3" json:"Type,omitempty"` @@ -344,6 +353,30 @@ func (m *Color) GetValue() string { return "" } +type Legend struct { + Type string `protobuf:"bytes,1,opt,name=Type,proto3" json:"Type,omitempty"` + Orientation string `protobuf:"bytes,2,opt,name=Orientation,proto3" json:"Orientation,omitempty"` +} + +func (m *Legend) Reset() { *m = Legend{} } +func (m *Legend) String() string { return proto.CompactTextString(m) } +func (*Legend) ProtoMessage() {} +func (*Legend) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{4} } + +func (m *Legend) GetType() string { + if m != nil { + return m.Type + } + return "" +} + +func (m *Legend) GetOrientation() string { + if m != nil { + return m.Orientation + } + return "" +} + type Axis struct { LegacyBounds []int64 `protobuf:"varint,1,rep,packed,name=legacyBounds" json:"legacyBounds,omitempty"` Bounds []string `protobuf:"bytes,2,rep,name=bounds" json:"bounds,omitempty"` @@ -357,7 +390,7 @@ type Axis struct { func (m *Axis) Reset() { *m = Axis{} } func (m *Axis) String() string { return proto.CompactTextString(m) } func (*Axis) ProtoMessage() {} -func (*Axis) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{4} } +func (*Axis) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{5} } func (m *Axis) GetLegacyBounds() []int64 { if m != nil { @@ -420,7 +453,7 @@ type Template struct { func (m *Template) Reset() { *m = Template{} } func (m *Template) String() string { return proto.CompactTextString(m) } func (*Template) ProtoMessage() {} -func (*Template) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{5} } +func (*Template) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{6} } func (m *Template) GetID() string { if m != nil { @@ -473,7 +506,7 @@ type TemplateValue struct { func (m *TemplateValue) Reset() { *m = TemplateValue{} } func (m *TemplateValue) String() string { return proto.CompactTextString(m) } func (*TemplateValue) ProtoMessage() {} -func (*TemplateValue) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{6} } +func (*TemplateValue) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{7} } func (m *TemplateValue) GetType() string { if m != nil { @@ -508,7 +541,7 @@ type TemplateQuery struct { func (m *TemplateQuery) Reset() { *m = TemplateQuery{} } func (m *TemplateQuery) String() string { return proto.CompactTextString(m) } func (*TemplateQuery) ProtoMessage() {} -func (*TemplateQuery) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{7} } +func (*TemplateQuery) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{8} } func (m *TemplateQuery) GetCommand() string { if m != nil { @@ -566,7 +599,7 @@ type Server struct { func (m *Server) Reset() { *m = Server{} } func (m *Server) String() string { return proto.CompactTextString(m) } func (*Server) ProtoMessage() {} -func (*Server) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{8} } +func (*Server) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{9} } func (m *Server) GetID() int64 { if m != nil { @@ -635,7 +668,7 @@ type Layout struct { func (m *Layout) Reset() { *m = Layout{} } func (m *Layout) String() string { return proto.CompactTextString(m) } func (*Layout) ProtoMessage() {} -func (*Layout) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{9} } +func (*Layout) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{10} } func (m *Layout) GetID() string { if m != nil { @@ -689,7 +722,7 @@ type Cell struct { func (m *Cell) Reset() { *m = Cell{} } func (m *Cell) String() string { return proto.CompactTextString(m) } func (*Cell) ProtoMessage() {} -func (*Cell) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{10} } +func (*Cell) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{11} } func (m *Cell) GetX() int32 { if m != nil { @@ -783,7 +816,7 @@ type Query struct { func (m *Query) Reset() { *m = Query{} } func (m *Query) String() string { return proto.CompactTextString(m) } func (*Query) ProtoMessage() {} -func (*Query) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{11} } +func (*Query) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{12} } func (m *Query) GetCommand() string { if m != nil { @@ -857,7 +890,7 @@ type TimeShift struct { func (m *TimeShift) Reset() { *m = TimeShift{} } func (m *TimeShift) String() string { return proto.CompactTextString(m) } func (*TimeShift) ProtoMessage() {} -func (*TimeShift) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{12} } +func (*TimeShift) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{13} } func (m *TimeShift) GetLabel() string { if m != nil { @@ -888,7 +921,7 @@ type Range struct { func (m *Range) Reset() { *m = Range{} } func (m *Range) String() string { return proto.CompactTextString(m) } func (*Range) ProtoMessage() {} -func (*Range) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{13} } +func (*Range) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{14} } func (m *Range) GetUpper() int64 { if m != nil { @@ -914,7 +947,7 @@ type AlertRule struct { func (m *AlertRule) Reset() { *m = AlertRule{} } func (m *AlertRule) String() string { return proto.CompactTextString(m) } func (*AlertRule) ProtoMessage() {} -func (*AlertRule) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{14} } +func (*AlertRule) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{15} } func (m *AlertRule) GetID() string { if m != nil { @@ -956,7 +989,7 @@ type User struct { func (m *User) Reset() { *m = User{} } func (m *User) String() string { return proto.CompactTextString(m) } func (*User) ProtoMessage() {} -func (*User) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{15} } +func (*User) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{16} } func (m *User) GetID() uint64 { if m != nil { @@ -1008,7 +1041,7 @@ type Role struct { func (m *Role) Reset() { *m = Role{} } func (m *Role) String() string { return proto.CompactTextString(m) } func (*Role) ProtoMessage() {} -func (*Role) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{16} } +func (*Role) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{17} } func (m *Role) GetOrganization() string { if m != nil { @@ -1034,7 +1067,7 @@ type Organization struct { func (m *Organization) Reset() { *m = Organization{} } func (m *Organization) String() string { return proto.CompactTextString(m) } func (*Organization) ProtoMessage() {} -func (*Organization) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{17} } +func (*Organization) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{18} } func (m *Organization) GetID() string { if m != nil { @@ -1071,7 +1104,7 @@ type Config struct { 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 (*Config) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{19} } func (m *Config) GetAuth() *AuthConfig { if m != nil { @@ -1087,7 +1120,7 @@ type AuthConfig struct { 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 (*AuthConfig) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{20} } func (m *AuthConfig) GetSuperAdminNewUsers() bool { if m != nil { @@ -1104,7 +1137,7 @@ type BuildInfo struct { 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 (*BuildInfo) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{21} } func (m *BuildInfo) GetVersion() string { if m != nil { @@ -1125,6 +1158,7 @@ func init() { proto.RegisterType((*Dashboard)(nil), "internal.Dashboard") proto.RegisterType((*DashboardCell)(nil), "internal.DashboardCell") proto.RegisterType((*Color)(nil), "internal.Color") + proto.RegisterType((*Legend)(nil), "internal.Legend") proto.RegisterType((*Axis)(nil), "internal.Axis") proto.RegisterType((*Template)(nil), "internal.Template") proto.RegisterType((*TemplateValue)(nil), "internal.TemplateValue") @@ -1147,89 +1181,92 @@ func init() { func init() { proto.RegisterFile("internal.proto", fileDescriptorInternal) } var fileDescriptorInternal = []byte{ - // 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, + // 1379 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x57, 0x5f, 0x8f, 0xdb, 0x44, + 0x10, 0x97, 0xe3, 0x38, 0x89, 0x27, 0xd7, 0x52, 0x99, 0x8a, 0x9a, 0x22, 0xa1, 0x60, 0x81, 0x08, + 0x82, 0x1e, 0xe8, 0x2a, 0x24, 0x84, 0xa0, 0x52, 0xee, 0x82, 0xca, 0xd1, 0x6b, 0x7b, 0xdd, 0xdc, + 0x1d, 0x4f, 0xa8, 0xda, 0x38, 0x93, 0xc4, 0xaa, 0x63, 0x9b, 0xb5, 0x7d, 0x17, 0xf3, 0x61, 0x90, + 0x90, 0xf8, 0x04, 0x88, 0x77, 0x5e, 0x11, 0x1f, 0x84, 0xaf, 0xc0, 0x13, 0x12, 0x9a, 0xdd, 0xf5, + 0x9f, 0x5c, 0x42, 0xd5, 0x07, 0xc4, 0xdb, 0xfe, 0x66, 0x36, 0xb3, 0xf3, 0xe7, 0x37, 0x33, 0x0e, + 0xdc, 0x0c, 0xa2, 0x0c, 0x45, 0xc4, 0xc3, 0xfd, 0x44, 0xc4, 0x59, 0xec, 0xf4, 0x4a, 0xec, 0xfd, + 0xd9, 0x82, 0xce, 0x24, 0xce, 0x85, 0x8f, 0xce, 0x4d, 0x68, 0x1d, 0x8f, 0x5d, 0x63, 0x60, 0x0c, + 0x4d, 0xd6, 0x3a, 0x1e, 0x3b, 0x0e, 0xb4, 0x9f, 0xf0, 0x15, 0xba, 0xad, 0x81, 0x31, 0xb4, 0x99, + 0x3c, 0x93, 0xec, 0xac, 0x48, 0xd0, 0x35, 0x95, 0x8c, 0xce, 0xce, 0x5d, 0xe8, 0x9d, 0xa7, 0x64, + 0x6d, 0x85, 0x6e, 0x5b, 0xca, 0x2b, 0x4c, 0xba, 0x53, 0x9e, 0xa6, 0x57, 0xb1, 0x98, 0xb9, 0x96, + 0xd2, 0x95, 0xd8, 0xb9, 0x05, 0xe6, 0x39, 0x3b, 0x71, 0x3b, 0x52, 0x4c, 0x47, 0xc7, 0x85, 0xee, + 0x18, 0xe7, 0x3c, 0x0f, 0x33, 0xb7, 0x3b, 0x30, 0x86, 0x3d, 0x56, 0x42, 0xb2, 0x73, 0x86, 0x21, + 0x2e, 0x04, 0x9f, 0xbb, 0x3d, 0x65, 0xa7, 0xc4, 0xce, 0x3e, 0x38, 0xc7, 0x51, 0x8a, 0x7e, 0x2e, + 0x70, 0xf2, 0x22, 0x48, 0x2e, 0x50, 0x04, 0xf3, 0xc2, 0xb5, 0xa5, 0x81, 0x1d, 0x1a, 0x7a, 0xe5, + 0x31, 0x66, 0x9c, 0xde, 0x06, 0x69, 0xaa, 0x84, 0x8e, 0x07, 0x7b, 0x93, 0x25, 0x17, 0x38, 0x9b, + 0xa0, 0x2f, 0x30, 0x73, 0xfb, 0x52, 0xbd, 0x21, 0xa3, 0x3b, 0x4f, 0xc5, 0x82, 0x47, 0xc1, 0x0f, + 0x3c, 0x0b, 0xe2, 0xc8, 0xdd, 0x53, 0x77, 0x9a, 0x32, 0xca, 0x12, 0x8b, 0x43, 0x74, 0x6f, 0xa8, + 0x2c, 0xd1, 0xd9, 0xfb, 0xd5, 0x00, 0x7b, 0xcc, 0xd3, 0xe5, 0x34, 0xe6, 0x62, 0xf6, 0x4a, 0xb9, + 0xbe, 0x07, 0x96, 0x8f, 0x61, 0x98, 0xba, 0xe6, 0xc0, 0x1c, 0xf6, 0x0f, 0xee, 0xec, 0x57, 0x45, + 0xac, 0xec, 0x1c, 0x61, 0x18, 0x32, 0x75, 0xcb, 0xf9, 0x04, 0xec, 0x0c, 0x57, 0x49, 0xc8, 0x33, + 0x4c, 0xdd, 0xb6, 0xfc, 0x89, 0x53, 0xff, 0xe4, 0x4c, 0xab, 0x58, 0x7d, 0x69, 0x2b, 0x14, 0x6b, + 0x3b, 0x14, 0xef, 0xef, 0x16, 0xdc, 0xd8, 0x78, 0xce, 0xd9, 0x03, 0x63, 0x2d, 0x3d, 0xb7, 0x98, + 0xb1, 0x26, 0x54, 0x48, 0xaf, 0x2d, 0x66, 0x14, 0x84, 0xae, 0x24, 0x37, 0x2c, 0x66, 0x5c, 0x11, + 0x5a, 0x4a, 0x46, 0x58, 0xcc, 0x58, 0x3a, 0x1f, 0x40, 0xf7, 0xfb, 0x1c, 0x45, 0x80, 0xa9, 0x6b, + 0x49, 0xef, 0x5e, 0xab, 0xbd, 0x7b, 0x96, 0xa3, 0x28, 0x58, 0xa9, 0xa7, 0x6c, 0x48, 0x36, 0x29, + 0x6a, 0xc8, 0x33, 0xc9, 0x32, 0x62, 0x5e, 0x57, 0xc9, 0xe8, 0xac, 0xb3, 0xa8, 0xf8, 0x40, 0x59, + 0xfc, 0x14, 0xda, 0x7c, 0x8d, 0xa9, 0x6b, 0x4b, 0xfb, 0xef, 0xfc, 0x4b, 0xc2, 0xf6, 0x47, 0x6b, + 0x4c, 0xbf, 0x8a, 0x32, 0x51, 0x30, 0x79, 0xdd, 0x79, 0x1f, 0x3a, 0x7e, 0x1c, 0xc6, 0x22, 0x75, + 0xe1, 0xba, 0x63, 0x47, 0x24, 0x67, 0x5a, 0xed, 0x0c, 0xa1, 0x13, 0xe2, 0x02, 0xa3, 0x99, 0x64, + 0x46, 0xff, 0xe0, 0x56, 0x7d, 0xf1, 0x44, 0xca, 0x99, 0xd6, 0xdf, 0x7d, 0x08, 0x76, 0xf5, 0x0a, + 0x11, 0xfd, 0x05, 0x16, 0x32, 0x67, 0x36, 0xa3, 0xa3, 0xf3, 0x2e, 0x58, 0x97, 0x3c, 0xcc, 0x55, + 0xbd, 0xfb, 0x07, 0x37, 0x6b, 0x3b, 0xa3, 0x75, 0x90, 0x32, 0xa5, 0xfc, 0xbc, 0xf5, 0x99, 0xe1, + 0x2d, 0xc0, 0x92, 0x3e, 0x34, 0x18, 0x63, 0x97, 0x8c, 0x91, 0x9d, 0xd8, 0x6a, 0x74, 0xe2, 0x2d, + 0x30, 0xbf, 0xc6, 0xb5, 0x6e, 0x4e, 0x3a, 0x56, 0xbc, 0x6a, 0x37, 0x78, 0x75, 0x1b, 0xac, 0x0b, + 0xf9, 0xb8, 0xaa, 0xb7, 0x02, 0xde, 0x03, 0xe8, 0xa8, 0x18, 0x2a, 0xcb, 0x46, 0xc3, 0xf2, 0x00, + 0xfa, 0x4f, 0x45, 0x80, 0x51, 0xa6, 0x98, 0xa2, 0x1e, 0x6d, 0x8a, 0xbc, 0x5f, 0x0c, 0x68, 0x93, + 0xf3, 0xc4, 0xaa, 0x10, 0x17, 0xdc, 0x2f, 0x0e, 0xe3, 0x3c, 0x9a, 0xa5, 0xae, 0x31, 0x30, 0x87, + 0x26, 0xdb, 0x90, 0x39, 0x6f, 0x40, 0x67, 0xaa, 0xb4, 0xad, 0x81, 0x39, 0xb4, 0x99, 0x46, 0xe4, + 0x5a, 0xc8, 0xa7, 0x18, 0xea, 0x10, 0x14, 0xa0, 0xdb, 0x89, 0xc0, 0x79, 0xb0, 0xd6, 0x61, 0x68, + 0x44, 0xf2, 0x34, 0x9f, 0x93, 0x5c, 0x45, 0xa2, 0x11, 0x05, 0x30, 0xe5, 0x69, 0x45, 0x1f, 0x3a, + 0x93, 0xe5, 0xd4, 0xe7, 0x61, 0xc9, 0x1f, 0x05, 0xbc, 0xdf, 0x0c, 0x9a, 0x2b, 0xaa, 0x1f, 0xb6, + 0x32, 0xfc, 0x26, 0xf4, 0xa8, 0x57, 0x9e, 0x5f, 0x72, 0xa1, 0x03, 0xee, 0x12, 0xbe, 0xe0, 0xc2, + 0xf9, 0x18, 0x3a, 0xb2, 0x44, 0x3b, 0x7a, 0xb3, 0x34, 0x27, 0xb3, 0xca, 0xf4, 0xb5, 0x8a, 0xbd, + 0xed, 0x06, 0x7b, 0xab, 0x60, 0xad, 0x66, 0xb0, 0xf7, 0xc0, 0xa2, 0x36, 0x28, 0xa4, 0xf7, 0x3b, + 0x2d, 0xab, 0x66, 0x51, 0xb7, 0xbc, 0x73, 0xb8, 0xb1, 0xf1, 0x62, 0xf5, 0x92, 0xb1, 0xf9, 0x52, + 0x4d, 0x37, 0x5b, 0xd3, 0x8b, 0x66, 0x6a, 0x8a, 0x21, 0xfa, 0x19, 0xce, 0x64, 0xbe, 0x7b, 0xac, + 0xc2, 0xde, 0x4f, 0x46, 0x6d, 0x57, 0xbe, 0x47, 0x53, 0xd3, 0x8f, 0x57, 0x2b, 0x1e, 0xcd, 0xb4, + 0xe9, 0x12, 0x52, 0xde, 0x66, 0x53, 0x6d, 0xba, 0x35, 0x9b, 0x12, 0x16, 0x89, 0xae, 0x60, 0x4b, + 0x24, 0xc4, 0x9d, 0x15, 0xf2, 0x34, 0x17, 0xb8, 0xc2, 0x28, 0xd3, 0x29, 0x68, 0x8a, 0x9c, 0x3b, + 0xd0, 0xcd, 0xf8, 0xe2, 0x39, 0x35, 0x89, 0xae, 0x64, 0xc6, 0x17, 0x8f, 0xb0, 0x70, 0xde, 0x02, + 0x7b, 0x1e, 0x60, 0x38, 0x93, 0x2a, 0x55, 0xce, 0x9e, 0x14, 0x3c, 0xc2, 0xc2, 0xfb, 0xdd, 0x80, + 0xce, 0x04, 0xc5, 0x25, 0x8a, 0x57, 0x1a, 0xa7, 0xcd, 0x35, 0x65, 0xbe, 0x64, 0x4d, 0xb5, 0x77, + 0xaf, 0x29, 0xab, 0x5e, 0x53, 0xb7, 0xc1, 0x9a, 0x08, 0xff, 0x78, 0x2c, 0x3d, 0x32, 0x99, 0x02, + 0xc4, 0xc6, 0x91, 0x9f, 0x05, 0x97, 0xa8, 0x77, 0x97, 0x46, 0x5b, 0x53, 0xb6, 0xb7, 0x63, 0xca, + 0xfe, 0x68, 0x40, 0xe7, 0x84, 0x17, 0x71, 0x9e, 0x6d, 0xb1, 0x70, 0x00, 0xfd, 0x51, 0x92, 0x84, + 0x81, 0xbf, 0xd1, 0x79, 0x0d, 0x11, 0xdd, 0x78, 0xdc, 0xc8, 0xaf, 0x8a, 0xad, 0x29, 0xa2, 0x71, + 0x73, 0x24, 0x37, 0x89, 0x5a, 0x0b, 0x8d, 0x71, 0xa3, 0x16, 0x88, 0x54, 0x52, 0x12, 0x46, 0x79, + 0x16, 0xcf, 0xc3, 0xf8, 0x4a, 0x46, 0xdb, 0x63, 0x15, 0xf6, 0xfe, 0x68, 0x41, 0xfb, 0xff, 0x9a, + 0xfe, 0x7b, 0x60, 0x04, 0xba, 0xd8, 0x46, 0x50, 0xed, 0x82, 0x6e, 0x63, 0x17, 0xb8, 0xd0, 0x2d, + 0x04, 0x8f, 0x16, 0x98, 0xba, 0x3d, 0x39, 0x5d, 0x4a, 0x28, 0x35, 0xb2, 0x8f, 0xd4, 0x12, 0xb0, + 0x59, 0x09, 0xab, 0xbe, 0x80, 0x46, 0x5f, 0x7c, 0xa4, 0xf7, 0x45, 0x5f, 0x7a, 0xe4, 0x6e, 0xa6, + 0xe5, 0xfa, 0x9a, 0xf8, 0xef, 0x66, 0xfa, 0x5f, 0x06, 0x58, 0x55, 0x53, 0x1d, 0x6d, 0x36, 0xd5, + 0x51, 0xdd, 0x54, 0xe3, 0xc3, 0xb2, 0xa9, 0xc6, 0x87, 0x84, 0xd9, 0x69, 0xd9, 0x54, 0xec, 0x94, + 0x8a, 0xf5, 0x50, 0xc4, 0x79, 0x72, 0x58, 0xa8, 0xaa, 0xda, 0xac, 0xc2, 0xc4, 0xc4, 0x6f, 0x97, + 0x28, 0x74, 0xaa, 0x6d, 0xa6, 0x11, 0xf1, 0xf6, 0x44, 0x0e, 0x1c, 0x95, 0x5c, 0x05, 0x9c, 0xf7, + 0xc0, 0x62, 0x94, 0x3c, 0x99, 0xe1, 0x8d, 0xba, 0x48, 0x31, 0x53, 0x5a, 0x32, 0xaa, 0xbe, 0x13, + 0x35, 0x81, 0xcb, 0xaf, 0xc6, 0x0f, 0xa1, 0x33, 0x59, 0x06, 0xf3, 0xac, 0xdc, 0xba, 0xaf, 0x37, + 0x06, 0x56, 0xb0, 0x42, 0xa9, 0x63, 0xfa, 0x8a, 0xf7, 0x0c, 0xec, 0x4a, 0x58, 0xbb, 0x63, 0x34, + 0xdd, 0x71, 0xa0, 0x7d, 0x1e, 0x05, 0x59, 0xd9, 0xba, 0x74, 0xa6, 0x60, 0x9f, 0xe5, 0x3c, 0xca, + 0x82, 0xac, 0x28, 0x5b, 0xb7, 0xc4, 0xde, 0x7d, 0xed, 0x3e, 0x99, 0x3b, 0x4f, 0x12, 0x14, 0x7a, + 0x0c, 0x28, 0x20, 0x1f, 0x89, 0xaf, 0x50, 0x4d, 0x70, 0x93, 0x29, 0xe0, 0x7d, 0x07, 0xf6, 0x28, + 0x44, 0x91, 0xb1, 0x3c, 0xc4, 0x5d, 0x9b, 0xf5, 0x9b, 0xc9, 0xd3, 0x27, 0xa5, 0x07, 0x74, 0xae, + 0x5b, 0xde, 0xbc, 0xd6, 0xf2, 0x8f, 0x78, 0xc2, 0x8f, 0xc7, 0x92, 0xe7, 0x26, 0xd3, 0xc8, 0xfb, + 0xd9, 0x80, 0x36, 0xcd, 0x96, 0x86, 0xe9, 0xf6, 0xcb, 0xe6, 0xd2, 0xa9, 0x88, 0x2f, 0x83, 0x19, + 0x8a, 0x32, 0xb8, 0x12, 0xcb, 0xa4, 0xfb, 0x4b, 0xac, 0x16, 0xb8, 0x46, 0xc4, 0x35, 0xfa, 0xa8, + 0x2c, 0x7b, 0xa9, 0xc1, 0x35, 0x12, 0x33, 0xa5, 0x74, 0xde, 0x06, 0x98, 0xe4, 0x09, 0x8a, 0xd1, + 0x6c, 0x15, 0x44, 0xb2, 0xe8, 0x3d, 0xd6, 0x90, 0x78, 0x0f, 0xd4, 0x67, 0xea, 0xd6, 0x84, 0x32, + 0x76, 0x7f, 0xd2, 0x5e, 0xf7, 0xdc, 0x0b, 0x37, 0x7f, 0xb7, 0x2b, 0x91, 0x5b, 0xd1, 0x0e, 0xa0, + 0xaf, 0xbf, 0xe9, 0xe5, 0x17, 0xb2, 0x1e, 0x56, 0x0d, 0x11, 0xc5, 0x7c, 0x9a, 0x4f, 0xc3, 0xc0, + 0x97, 0x31, 0xf7, 0x98, 0x46, 0xde, 0x01, 0x74, 0x8e, 0xe2, 0x68, 0x1e, 0x2c, 0x9c, 0x21, 0xb4, + 0x47, 0x79, 0xb6, 0x94, 0x2f, 0xf5, 0x0f, 0x6e, 0x37, 0x1a, 0x2d, 0xcf, 0x96, 0xea, 0x0e, 0x93, + 0x37, 0xbc, 0x2f, 0x00, 0x6a, 0x19, 0xfd, 0x51, 0xa8, 0xa3, 0x7f, 0x82, 0x57, 0x54, 0xa2, 0x54, + 0x5a, 0xe9, 0xb1, 0x1d, 0x1a, 0xef, 0x4b, 0xb0, 0x0f, 0xf3, 0x20, 0x9c, 0x1d, 0x47, 0xf3, 0x98, + 0x5a, 0xf5, 0x02, 0x45, 0x5a, 0xe7, 0xa7, 0x84, 0xe4, 0x30, 0x75, 0x6d, 0xc5, 0x59, 0x8d, 0xa6, + 0x1d, 0xf9, 0x5f, 0xeb, 0xfe, 0x3f, 0x01, 0x00, 0x00, 0xff, 0xff, 0x0f, 0x64, 0xa4, 0xff, 0x7d, + 0x0d, 0x00, 0x00, } diff --git a/bolt/internal/internal.proto b/bolt/internal/internal.proto index 87ea5a7f3..89504b776 100644 --- a/bolt/internal/internal.proto +++ b/bolt/internal/internal.proto @@ -36,6 +36,7 @@ message DashboardCell { string ID = 8; // id is the unique id of the dashboard. MIGRATED FIELD added in 1.2.0-beta6 map axes = 9; // Axes represent the graphical viewport for a cell's visualizations repeated Color colors = 10; // Colors represent encoding data values to color + Legend legend = 11; // Legend is summary information for a cell } message Color { @@ -46,6 +47,11 @@ message Color { string Value = 5; // Value is the data value mapped to this color } +message Legend { + string Type = 1; // Type is how the legend is used + string Orientation = 2; // Orientation is the location of the legend on the cell +} + message Axis { repeated int64 legacyBounds = 1; // legacyBounds are an ordered 2-tuple consisting of lower and upper axis extents, respectively repeated string bounds = 2; // bounds are an arbitrary list of client-defined bounds. diff --git a/bolt/internal/internal_test.go b/bolt/internal/internal_test.go index f659d6c96..47c7067b4 100644 --- a/bolt/internal/internal_test.go +++ b/bolt/internal/internal_test.go @@ -251,6 +251,10 @@ func Test_MarshalDashboard_WithLegacyBounds(t *testing.T) { Value: "100", }, }, + Legend: chronograf.Legend{ + Type: "static", + Orientation: "bottom", + }, Type: "line", }, }, @@ -301,6 +305,10 @@ func Test_MarshalDashboard_WithLegacyBounds(t *testing.T) { Value: "100", }, }, + Legend: chronograf.Legend{ + Type: "static", + Orientation: "bottom", + }, Type: "line", }, }, diff --git a/chronograf.go b/chronograf.go index e8ed5847e..fa29b560b 100644 --- a/chronograf.go +++ b/chronograf.go @@ -25,6 +25,9 @@ const ( 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', 'text', and 'background'") ErrInvalidColor = Error("Invalid color. Accepted color format is #RRGGBB") + ErrInvalidLegend = Error("Invalid legend. Both type and orientation must be set") + ErrInvalidLegendType = Error("Invalid legend type. Valid legend type is 'static'") + ErrInvalidLegendOrient = Error("Invalid orientation type. Valid orientation types are 'top', 'bottom', 'right', 'left'") ErrUserAlreadyExists = Error("user already exists") ErrOrganizationNotFound = Error("organization not found") ErrOrganizationAlreadyExists = Error("organization already exists") @@ -531,6 +534,12 @@ type CellColor struct { Value string `json:"value"` // Value is the data value mapped to this color } +// Legend represents the encoding of data into a legend +type Legend struct { + Type string `json:"type,omitempty"` + Orientation string `json:"orientation,omitempty"` +} + // DashboardCell holds visual and query information for a cell type DashboardCell struct { ID string `json:"i"` @@ -543,6 +552,7 @@ type DashboardCell struct { Axes map[string]Axis `json:"axes"` Type string `json:"type"` CellColors []CellColor `json:"colors"` + Legend Legend `json:"legend"` } // DashboardsStore is the storage and retrieval of dashboards diff --git a/circle.yml b/circle.yml index 597de9329..207d9d5d5 100644 --- a/circle.yml +++ b/circle.yml @@ -3,7 +3,7 @@ machine: services: - docker environment: - DOCKER_TAG: chronograf-20171027 + DOCKER_TAG: chronograf-20180207 dependencies: override: diff --git a/cmd/chronoctl/add.go b/cmd/chronoctl/add.go new file mode 100644 index 000000000..0a26c72c9 --- /dev/null +++ b/cmd/chronoctl/add.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + + "github.com/influxdata/chronograf" +) + +type AddCommand struct { + 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"` + ID *uint64 `short:"i" long:"id" description:"Users ID. Must be id for existing user"` + Username string `short:"n" long:"name" description:"Users name. Must be Oauth-able email address or username"` + Provider string `short:"p" long:"provider" description:"Name of the Auth provider (e.g. google, github, auth0, or generic)"` + Scheme string `short:"s" long:"scheme" description:"Authentication scheme that matches auth provider (e.g. oauth or ldap)"` + //Organizations string `short:"o" long:"orgs" description:"A comma separated list of organizations that the user should be added to"` +} + +var addCommand AddCommand + +func (l *AddCommand) Execute(args []string) error { + c, err := NewBoltClient(l.BoltPath) + if err != nil { + return err + } + defer c.Close() + + q := chronograf.UserQuery{ + Name: &l.Username, + Provider: &l.Provider, + Scheme: &l.Scheme, + } + + if l.ID != nil { + q.ID = l.ID + } + + ctx := context.Background() + + user, err := c.UsersStore.Get(ctx, q) + if err != nil && err != chronograf.ErrUserNotFound { + return err + } else if err == chronograf.ErrUserNotFound { + user = &chronograf.User{ + Name: l.Username, + Provider: l.Provider, + Scheme: l.Scheme, + SuperAdmin: true, + } + + user, err = c.UsersStore.Add(ctx, user) + if err != nil { + return err + } + } else { + user.SuperAdmin = true + if err = c.UsersStore.Update(ctx, user); err != nil { + return err + } + } + + // TODO(desa): Apply mapping to user and update their roles + // TODO(desa): Add a flag that allows the user to specify an organization to join + + w := NewTabWriter() + WriteHeaders(w) + WriteUser(w, user) + w.Flush() + + return nil +} + +func init() { + parser.AddCommand("add-superadmin", + "Creates a new superadmin user", + "The add-user command will create a new user with superadmin status", + &addCommand) +} diff --git a/cmd/chronoctl/list.go b/cmd/chronoctl/list.go new file mode 100644 index 000000000..6396359ad --- /dev/null +++ b/cmd/chronoctl/list.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" +) + +type ListCommand struct { + 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"` +} + +var listCommand ListCommand + +func (l *ListCommand) Execute(args []string) error { + c, err := NewBoltClient(l.BoltPath) + if err != nil { + return err + } + defer c.Close() + + ctx := context.Background() + users, err := c.UsersStore.All(ctx) + if err != nil { + return err + } + + w := NewTabWriter() + WriteHeaders(w) + for _, user := range users { + WriteUser(w, &user) + } + w.Flush() + + return nil +} + +func init() { + parser.AddCommand("list-users", + "Lists users", + "The list-users command will list all users in the chronograf boltdb instance", + &listCommand) +} diff --git a/cmd/chronoctl/main.go b/cmd/chronoctl/main.go new file mode 100644 index 000000000..b3260fb21 --- /dev/null +++ b/cmd/chronoctl/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "os" + + "github.com/jessevdk/go-flags" +) + +type Options struct { +} + +var options Options + +var parser = flags.NewParser(&options, flags.Default) + +func main() { + if _, err := parser.Parse(); err != nil { + if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp { + os.Exit(0) + } else { + os.Exit(1) + } + } + +} diff --git a/cmd/chronoctl/util.go b/cmd/chronoctl/util.go new file mode 100644 index 000000000..f40635dbd --- /dev/null +++ b/cmd/chronoctl/util.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/bolt" + "github.com/influxdata/chronograf/mocks" +) + +func NewBoltClient(path string) (*bolt.Client, error) { + c := bolt.NewClient() + c.Path = path + + ctx := context.Background() + logger := mocks.NewLogger() + var bi chronograf.BuildInfo + if err := c.Open(ctx, logger, bi); err != nil { + return nil, err + } + + return c, nil +} + +func NewTabWriter() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 0, 8, 1, '\t', 0) +} + +func WriteHeaders(w io.Writer) { + fmt.Fprintln(w, "ID\tName\tProvider\tScheme\tSuperAdmin\tOrganization(s)") +} + +func WriteUser(w io.Writer, user *chronograf.User) { + orgs := []string{} + for _, role := range user.Roles { + orgs = append(orgs, role.Organization) + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%t\t%s\n", user.ID, user.Name, user.Provider, user.Scheme, user.SuperAdmin, strings.Join(orgs, ",")) +} diff --git a/docs/slides/mnGo/Makefile b/docs/slides/mnGo/Makefile index a43b18e1c..51a07599b 100644 --- a/docs/slides/mnGo/Makefile +++ b/docs/slides/mnGo/Makefile @@ -3,7 +3,7 @@ VERSION ?= $(shell git describe --always --tags) COMMIT ?= $(shell git rev-parse --short=8 HEAD) GDM := $(shell command -v gdm 2> /dev/null) -GOBINDATA := $(shell go list -f {{.Root}} github.com/jteeuwen/go-bindata 2> /dev/null) +GOBINDATA := $(shell go list -f {{.Root}} github.com/kevinburke/go-bindata 2> /dev/null) YARN := $(shell command -v yarn 2> /dev/null) SOURCES := $(shell find . -name '*.go' ! -name '*_gen.go') @@ -63,7 +63,7 @@ ifndef GDM endif ifndef GOBINDATA @echo "Installing go-bindata" - go get -u github.com/jteeuwen/go-bindata/... + go get -u github.com/kevinburke/go-bindata/... endif gdm restore @touch .godep diff --git a/etc/Dockerfile_build b/etc/Dockerfile_build index af8deb426..8493143ec 100644 --- a/etc/Dockerfile_build +++ b/etc/Dockerfile_build @@ -12,13 +12,15 @@ RUN apt update && DEBIAN_FRONTEND=noninteractive apt install -y \ ruby-dev \ rpm \ zip \ - python-pip + python-pip \ + autoconf \ + libtool RUN pip install boto requests python-jose --upgrade RUN gem install fpm # Install node -ENV NODE_VERSION v6.11.5 +ENV NODE_VERSION v6.12.3 RUN wget -q https://nodejs.org/dist/latest-v6.x/node-${NODE_VERSION}-linux-x64.tar.gz; \ tar -xvf node-${NODE_VERSION}-linux-x64.tar.gz -C / --strip-components=1; \ rm -f node-${NODE_VERSION}-linux-x64.tar.gz @@ -35,7 +37,7 @@ RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ # Install go ENV GOPATH /root/go -ENV GO_VERSION 1.9.2 +ENV GO_VERSION 1.9.4 ENV GO_ARCH amd64 RUN wget https://storage.googleapis.com/golang/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz; \ tar -C /usr/local/ -xf /go${GO_VERSION}.linux-${GO_ARCH}.tar.gz ; \ diff --git a/etc/README.md b/etc/README.md index 6e541ecee..d94fd4905 100644 --- a/etc/README.md +++ b/etc/README.md @@ -10,3 +10,6 @@ After updating the Dockerfile_build run and push to quay with: `docker push quay.io/influxdb/builder:chronograf-$(date "+%Y%m%d")` + +### Update circle +Update DOCKER_TAG in circle.yml to the new container. diff --git a/etc/build.py b/etc/build.py index 076e8b676..5dc199454 100755 --- a/etc/build.py +++ b/etc/build.py @@ -24,6 +24,7 @@ DATA_DIR = "/var/lib/chronograf" SCRIPT_DIR = "/usr/lib/chronograf/scripts" LOGROTATE_DIR = "/etc/logrotate.d" CANNED_DIR = "/usr/share/chronograf/canned" +RESOURCES_DIR = "/usr/share/chronograf/resources" INIT_SCRIPT = "etc/scripts/init.sh" SYSTEMD_SCRIPT = "etc/scripts/chronograf.service" @@ -115,7 +116,8 @@ def create_package_fs(build_root): DATA_DIR[1:], SCRIPT_DIR[1:], LOGROTATE_DIR[1:], - CANNED_DIR[1:] + CANNED_DIR[1:], + RESOURCES_DIR[1:] ] for d in dirs: os.makedirs(os.path.join(build_root, d)) diff --git a/etc/scripts/chronograf.service b/etc/scripts/chronograf.service index ba5c973b1..272429f86 100644 --- a/etc/scripts/chronograf.service +++ b/etc/scripts/chronograf.service @@ -8,8 +8,12 @@ After=network-online.target [Service] User=chronograf Group=chronograf +Environment="HOST=0.0.0.0" +Environment="PORT=8888" +Environment="BOLT_PATH=/var/lib/chronograf/chronograf-v1.db" +Environment="CANNED_PATH=/usr/share/chronograf/canned" EnvironmentFile=-/etc/default/chronograf -ExecStart=/usr/bin/chronograf --host 0.0.0.0 --port 8888 -b /var/lib/chronograf/chronograf-v1.db -c /usr/share/chronograf/canned $CHRONOGRAF_OPTS +ExecStart=/usr/bin/chronograf $CHRONOGRAF_OPTS KillMode=control-group Restart=on-failure diff --git a/etc/scripts/init.sh b/etc/scripts/init.sh index 739a3d93c..6b52743f0 100755 --- a/etc/scripts/init.sh +++ b/etc/scripts/init.sh @@ -12,9 +12,13 @@ # Script to execute when starting SCRIPT="/usr/bin/chronograf" +export HOST="0.0.0.0" +export PORT="8888" +export BOLT_PATH="/var/lib/chronograf/chronograf-v1.db" +export CANNED_PATH="/usr/share/chronograf/canned" # Options to pass to the script on startup . /etc/default/chronograf -SCRIPT_OPTS="--host 0.0.0.0 --port 8888 -b /var/lib/chronograf/chronograf-v1.db -c /usr/share/chronograf/canned ${CHRONOGRAF_OPTS}" +SCRIPT_OPTS="${CHRONOGRAF_OPTS}" # User to run the process under RUNAS=chronograf diff --git a/integrations/server_test.go b/integrations/server_test.go index 153cf1112..caed26275 100644 --- a/integrations/server_test.go +++ b/integrations/server_test.go @@ -539,6 +539,10 @@ func TestServer(t *testing.T) { "value": "100" } ], + "legend":{ + "type": "static", + "orientation": "bottom" + }, "links": { "self": "/chronograf/v1/dashboards/1000/cells/8f61c619-dd9b-4761-8aa8-577f27247093" } @@ -778,6 +782,10 @@ func TestServer(t *testing.T) { "value": "100" } ], + "legend":{ + "type": "static", + "orientation": "bottom" + }, "links": { "self": "/chronograf/v1/dashboards/1000/cells/8f61c619-dd9b-4761-8aa8-577f27247093" } @@ -901,7 +909,7 @@ func TestServer(t *testing.T) { GithubClientSecret: "not empty", }, method: "GET", - path: "/chronograf/v1/users", + path: "/chronograf/v1/organizations/default/users", principal: oauth2.Principal{ Organization: "default", Subject: "billibob", @@ -933,6 +941,284 @@ func TestServer(t *testing.T) { }, }, }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/organizations/default/users", + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/organizations/default/users" + }, + "users": [ + { + "links": { + "self": "/chronograf/v1/organizations/default/users/1" + }, + "id": "1", + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "admin", + "organization": "default" + } + ] + } + ] +}`, + }, + }, + { + name: "GET /users", + subName: "Two users in two organizations; user making request is as SuperAdmin with out raw query param", + 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", + }, + }, + }, + { + ID: 2, // This is artificial, but should be reflective of the users actual ID + Name: "billietta", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "cool", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/organizations/default/users", + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/organizations/default/users" + }, + "users": [ + { + "links": { + "self": "/chronograf/v1/organizations/default/users/1" + }, + "id": "1", + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "admin", + "organization": "default" + } + ] + } + ] +} +`, + }, + }, + { + name: "POST /users", + subName: "User making request is as SuperAdmin with raw query param; being created has wildcard role", + 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", + }, + payload: &chronograf.User{ + Name: "user", + Provider: "provider", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "*", + Organization: "default", + }, + }, + }, + method: "POST", + path: "/chronograf/v1/users", + 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": "member", + "organization": "default" + } + ] +} +`, + }, + }, + { + name: "POST /users", + subName: "User making request is as SuperAdmin with raw query param; being created has no roles", + 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", + }, + payload: &chronograf.User{ + Name: "user", + Provider: "provider", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, + method: "POST", + path: "/chronograf/v1/users", + 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: "GET /users", + subName: "Two users in two organizations; user making request is as SuperAdmin with raw query param", + fields: fields{ + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + 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", + }, + }, + }, + { + ID: 2, // This is artificial, but should be reflective of the users actual ID + Name: "billietta", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "1", + }, + }, + }, + }, + }, args: args{ server: &server.Server{ GithubClientID: "not empty", @@ -969,9 +1255,93 @@ func TestServer(t *testing.T) { "organization": "default" } ] + }, + { + "links": { + "self": "/chronograf/v1/users/2" + }, + "id": "2", + "name": "billietta", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "admin", + "organization": "1" + } + ] } ] -}`, +} +`, + }, + }, + { + name: "GET /users", + subName: "Two users in two organizations; user making request is as not SuperAdmin with raw query param", + fields: fields{ + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + 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", + }, + }, + }, + { + ID: 2, // This is artificial, but should be reflective of the users actual ID + Name: "billietta", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + Roles: []chronograf.Role{ + { + Name: "admin", + Organization: "default", + }, + { + Name: "admin", + Organization: "1", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/users", + principal: oauth2.Principal{ + Organization: "default", + Subject: "billieta", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 403, + body: ` +{ + "code": 403, + "message": "User is not authorized" +} +`, }, }, { @@ -1005,7 +1375,7 @@ func TestServer(t *testing.T) { GithubClientSecret: "not empty", }, method: "POST", - path: "/chronograf/v1/users", + path: "/chronograf/v1/organizations/default/users", payload: &chronograf.User{ Name: "user", Provider: "provider", @@ -1028,7 +1398,7 @@ func TestServer(t *testing.T) { body: ` { "links": { - "self": "/chronograf/v1/users/2" + "self": "/chronograf/v1/organizations/default/users/2" }, "id": "2", "name": "user", @@ -1075,7 +1445,7 @@ func TestServer(t *testing.T) { GithubClientSecret: "not empty", }, method: "POST", - path: "/chronograf/v1/users", + path: "/chronograf/v1/organizations/default/users", payload: &chronograf.User{ Name: "user", Provider: "provider", @@ -1098,7 +1468,7 @@ func TestServer(t *testing.T) { body: ` { "links": { - "self": "/chronograf/v1/users/2" + "self": "/chronograf/v1/organizations/default/users/2" }, "id": "2", "name": "user", @@ -1145,7 +1515,7 @@ func TestServer(t *testing.T) { GithubClientSecret: "not empty", }, method: "POST", - path: "/chronograf/v1/users", + path: "/chronograf/v1/organizations/default/users", payload: &chronograf.User{ Name: "user", Provider: "provider", @@ -1168,7 +1538,7 @@ func TestServer(t *testing.T) { body: ` { "links": { - "self": "/chronograf/v1/users/2" + "self": "/chronograf/v1/organizations/default/users/2" }, "id": "2", "name": "user", @@ -1215,7 +1585,7 @@ func TestServer(t *testing.T) { GithubClientSecret: "not empty", }, method: "POST", - path: "/chronograf/v1/users", + path: "/chronograf/v1/organizations/default/users", payload: &chronograf.User{ Name: "user", Provider: "provider", @@ -1240,6 +1610,246 @@ func TestServer(t *testing.T) { { "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: "POST /users", + subName: "Create a New User with in multiple organizations; User on Principal is a SuperAdmin with raw query param", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + 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: "POST", + path: "/chronograf/v1/users", + payload: &chronograf.User{ + Name: "user", + Provider: "provider", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "default", + }, + { + Name: roles.EditorRoleName, + Organization: "1", + }, + }, + }, + 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": "editor", + "organization": "1" + } + ] +}`, + }, + }, + { + name: "PATCH /users", + subName: "Update user to have no roles", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + 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: "PATCH", + path: "/chronograf/v1/users/1", + payload: map[string]interface{}{ + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": []chronograf.Role{}, + }, + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/users/1" + }, + "id": "1", + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + ] +}`, + }, + }, + { + name: "PATCH /users", + subName: "Update user roles with wildcard", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + 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: "PATCH", + path: "/chronograf/v1/users/1", + payload: &chronograf.User{ + Name: "billibob", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: true, + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "default", + }, + { + Name: roles.WildcardRoleName, + Organization: "1", + }, + }, + }, + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/users/1" + }, + "id": "1", + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "admin", + "organization": "default" + }, + { + "name": "viewer", + "organization": "1" + } + ] }`, }, }, @@ -1269,7 +1879,7 @@ func TestServer(t *testing.T) { GithubClientSecret: "not empty", }, method: "PATCH", - path: "/chronograf/v1/users/1", + path: "/chronograf/v1/organizations/default/users/1", payload: map[string]interface{}{ "id": "1", "superAdmin": false, @@ -1293,6 +1903,74 @@ func TestServer(t *testing.T) { "code": 401, "message": "user cannot modify their own SuperAdmin status" } +`, + }, + }, + { + name: "GET /organization/default/users", + subName: "Organization not set explicitly on principal", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + Organizations: []chronograf.Organization{}, + 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/default/users", + principal: oauth2.Principal{ + Organization: "", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` +{ + "links": { + "self": "/chronograf/v1/organizations/default/users" + }, + "users": [ + { + "links": { + "self": "/chronograf/v1/organizations/default/users/1" + }, + "id": "1", + "name": "billibob", + "provider": "github", + "scheme": "oauth2", + "superAdmin": true, + "roles": [ + { + "name": "admin", + "organization": "default" + } + ] + } + ] +} `, }, }, @@ -1364,7 +2042,7 @@ func TestServer(t *testing.T) { "scheme": "oauth2", "superAdmin": true, "links": { - "self": "/chronograf/v1/users/1" + "self": "/chronograf/v1/organizations/1/users/1" }, "organizations": [ { @@ -1446,6 +2124,168 @@ func TestServer(t *testing.T) { }`, }, }, + { + name: "GET /", + subName: "signed into default org", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + 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: "GET", + path: "/chronograf/v1/", + principal: oauth2.Principal{ + Organization: "default", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` + { + "layouts": "/chronograf/v1/layouts", + "users": "/chronograf/v1/organizations/default/users", + "allUsers": "/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": "" + } + } + `, + }, + }, + { + name: "GET /", + subName: "signed into org 1", + fields: fields{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: true, + }, + }, + Organizations: []chronograf.Organization{ + { + ID: "1", + Name: "cool", + 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", + }, + { + Name: "member", + Organization: "1", + }, + }, + }, + }, + }, + args: args{ + server: &server.Server{ + GithubClientID: "not empty", + GithubClientSecret: "not empty", + }, + method: "GET", + path: "/chronograf/v1/", + principal: oauth2.Principal{ + Organization: "1", + Subject: "billibob", + Issuer: "github", + }, + }, + wants: wants{ + statusCode: 200, + body: ` + { + "layouts": "/chronograf/v1/layouts", + "users": "/chronograf/v1/organizations/1/users", + "allUsers": "/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": "" + } + } + `, + }, + }, } for _, tt := range tests { diff --git a/integrations/testdata/mydash.dashboard b/integrations/testdata/mydash.dashboard index a555f2af9..bb16f9e6d 100644 --- a/integrations/testdata/mydash.dashboard +++ b/integrations/testdata/mydash.dashboard @@ -86,7 +86,11 @@ "name": "comet", "value": "100" } - ] + ], + "legend": { + "type": "static", + "orientation": "bottom" + } } ], "templates": [ diff --git a/kapacitor/client_test.go b/kapacitor/client_test.go index 0e39bb966..056005846 100644 --- a/kapacitor/client_test.go +++ b/kapacitor/client_test.go @@ -1293,7 +1293,157 @@ func TestClient_Create(t *testing.T) { createTaskOptions *client.CreateTaskOptions }{ { - name: "create alert rule", + name: "create alert rule with tags", + fields: fields{ + kapaClient: func(url, username, password string, insecureSkipVerify bool) (KapaClient, error) { + return kapa, nil + }, + Ticker: &Alert{}, + ID: &MockID{ + ID: "howdy", + }, + }, + args: args{ + ctx: context.Background(), + rule: chronograf.AlertRule{ + ID: "howdy", + Name: "myname's", + Query: &chronograf.QueryConfig{ + Database: "db", + RetentionPolicy: "rp", + Measurement: "meas", + GroupBy: chronograf.GroupBy{ + Tags: []string{ + "tag1", + "tag2", + }, + }, + }, + Trigger: Deadman, + TriggerValues: chronograf.TriggerValues{ + Period: "1d", + }, + }, + }, + resTask: client.Task{ + ID: "chronograf-v1-howdy", + Status: client.Enabled, + Type: client.StreamTask, + DBRPs: []client.DBRP{ + { + Database: "db", + RetentionPolicy: "rp", + }, + }, + Link: client.Link{ + Href: "/kapacitor/v1/tasks/chronograf-v1-howdy", + }, + }, + createTaskOptions: &client.CreateTaskOptions{ + TICKscript: `var db = 'db' + +var rp = 'rp' + +var measurement = 'meas' + +var groupBy = ['tag1', 'tag2'] + +var whereFilter = lambda: TRUE + +var period = 1d + +var name = 'myname\'s' + +var idVar = name + ':{{.Group}}' + +var message = '' + +var idTag = 'alertID' + +var levelTag = 'level' + +var messageField = 'message' + +var durationField = 'duration' + +var outputDB = 'chronograf' + +var outputRP = 'autogen' + +var outputMeasurement = 'alerts' + +var triggerType = 'deadman' + +var threshold = 0.0 + +var data = stream + |from() + .database(db) + .retentionPolicy(rp) + .measurement(measurement) + .groupBy(groupBy) + .where(whereFilter) + +var trigger = data + |deadman(threshold, period) + .stateChangesOnly() + .message(message) + .id(idVar) + .idTag(idTag) + .levelTag(levelTag) + .messageField(messageField) + .durationField(durationField) + +trigger + |eval(lambda: "emitted") + .as('value') + .keep('value', messageField, durationField) + |eval(lambda: float("value")) + .as('value') + .keep() + |influxDBOut() + .create() + .database(outputDB) + .retentionPolicy(outputRP) + .measurement(outputMeasurement) + .tag('alertName', name) + .tag('triggerType', triggerType) + +trigger + |httpOut('output') +`, + + ID: "chronograf-v1-howdy", + Type: client.StreamTask, + Status: client.Enabled, + DBRPs: []client.DBRP{ + { + Database: "db", + RetentionPolicy: "rp", + }, + }, + }, + want: &Task{ + ID: "chronograf-v1-howdy", + Href: "/kapacitor/v1/tasks/chronograf-v1-howdy", + HrefOutput: "/kapacitor/v1/tasks/chronograf-v1-howdy/output", + Rule: chronograf.AlertRule{ + Type: "stream", + DBRPs: []chronograf.DBRP{ + { + + DB: "db", + RP: "rp", + }, + }, + Status: "enabled", + ID: "chronograf-v1-howdy", + Name: "chronograf-v1-howdy", + }, + }, + }, + { + name: "create alert rule with no tags", fields: fields{ kapaClient: func(url, username, password string, insecureSkipVerify bool) (KapaClient, error) { return kapa, nil @@ -1348,7 +1498,7 @@ var period = 1d var name = 'myname\'s' -var idVar = name + ':{{.Group}}' +var idVar = name var message = '' diff --git a/kapacitor/vars.go b/kapacitor/vars.go index 182150872..c55bfae21 100644 --- a/kapacitor/vars.go +++ b/kapacitor/vars.go @@ -123,7 +123,7 @@ func commonVars(rule chronograf.AlertRule) (string, error) { %s var name = '%s' - var idVar = name + ':{{.Group}}' + var idVar = %s var message = '%s' var idTag = '%s' var levelTag = '%s' @@ -143,6 +143,7 @@ func commonVars(rule chronograf.AlertRule) (string, error) { whereFilter(rule.Query), wind, Escape(rule.Name), + idVar(rule.Query), Escape(rule.Message), IDTag, LevelTag, @@ -197,6 +198,13 @@ func groupBy(q *chronograf.QueryConfig) string { return "[" + strings.Join(groups, ",") + "]" } +func idVar(q *chronograf.QueryConfig) string { + if len(q.GroupBy.Tags) > 0 { + return `name + ':{{.Group}}'` + } + return "name" +} + func field(q *chronograf.QueryConfig) (string, error) { if q == nil { return "", fmt.Errorf("No fields set in query") diff --git a/roles/roles.go b/roles/roles.go index 53b9b9632..c301a37d1 100644 --- a/roles/roles.go +++ b/roles/roles.go @@ -38,6 +38,9 @@ const ( EditorRoleName = "editor" AdminRoleName = "admin" SuperAdminStatus = "superadmin" + + // Indicatior that the server should retrieve the default role for the organization. + WildcardRoleName = "*" ) var ( diff --git a/server/auth.go b/server/auth.go index b973a1938..be1a08abf 100644 --- a/server/auth.go +++ b/server/auth.go @@ -11,6 +11,13 @@ import ( "github.com/influxdata/chronograf/roles" ) +// HasAuthorizedToken extracts the token from a request and validates it using the authenticator. +// It is used by routes that need access to the token to populate links request. +func HasAuthorizedToken(auth oauth2.Authenticator, r *http.Request) (oauth2.Principal, error) { + ctx := r.Context() + return auth.Validate(ctx, r) +} + // AuthorizedToken extracts the token and validates; if valid the next handler // will be run. The principal will be sent to the next handler via the request's // Context. It is up to the next handler to determine if the principal has access. @@ -49,6 +56,33 @@ func AuthorizedToken(auth oauth2.Authenticator, logger chronograf.Logger, next h }) } +// RawStoreAccess gives a super admin access to the data store without a facade. +func RawStoreAccess(logger chronograf.Logger, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if isServer := hasServerContext(ctx); isServer { + next(w, r) + return + } + + log := logger. + WithField("component", "raw_store"). + WithField("remote_addr", r.RemoteAddr). + WithField("method", r.Method). + WithField("url", r.URL) + + if isSuperAdmin := hasSuperAdminContext(ctx); isSuperAdmin { + r = r.WithContext(serverContext(ctx)) + } else { + log.Error("User making request is not a SuperAdmin") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + + next(w, r) + } +} + // AuthorizedUser extracts the user name and provider from context. If the // user and provider can be found on the context, we look up the user by their // name and provider. If the user is found, we verify that the user has at at @@ -181,6 +215,13 @@ func hasAuthorizedRole(u *chronograf.User, role string) bool { } switch role { + case roles.MemberRoleName: + for _, r := range u.Roles { + switch r.Name { + case roles.MemberRoleName, roles.ViewerRoleName, roles.EditorRoleName, roles.AdminRoleName: + return true + } + } case roles.ViewerRoleName: for _, r := range u.Roles { switch r.Name { diff --git a/server/auth_test.go b/server/auth_test.go index 06a8a5ebb..ff1afdf1f 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -108,6 +108,230 @@ func TestAuthorizedUser(t *testing.T) { hasServerContext: true, authorized: true, }, + { + name: "User with member role is member authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + 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{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.MemberRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: "1337", + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "member", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "User with viewer role is member authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + 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{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.ViewerRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: "1337", + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "member", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "User with editor role is member authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + 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{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.EditorRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: "1337", + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "member", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, + { + name: "User with admin role is member authorized", + fields: fields{ + UsersStore: &mocks.UsersStore{ + 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{ + ID: 1337, + Name: "billysteve", + Provider: "google", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1337", + }, + }, + }, nil + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "0", + }, nil + }, + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + if q.ID == nil { + return nil, fmt.Errorf("Invalid organization query: missing ID") + } + return &chronograf.Organization{ + ID: "1337", + Name: "The ShillBillThrilliettas", + }, nil + }, + }, + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + principal: &oauth2.Principal{ + Subject: "billysteve", + Issuer: "google", + Organization: "1337", + }, + scheme: "oauth2", + role: "member", + useAuth: true, + }, + authorized: true, + hasOrganizationContext: true, + hasSuperAdminContext: false, + hasRoleContext: true, + hasServerContext: false, + }, { name: "User with viewer role is viewer authorized", fields: fields{ @@ -1606,3 +1830,118 @@ func TestAuthorizedUser(t *testing.T) { }) } } + +func TestRawStoreAccess(t *testing.T) { + type fields struct { + Logger chronograf.Logger + } + type args struct { + principal *oauth2.Principal + serverContext bool + user *chronograf.User + } + type wants struct { + authorized bool + hasServerContext bool + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "middleware already has server context", + fields: fields{ + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + serverContext: true, + }, + wants: wants{ + authorized: true, + hasServerContext: true, + }, + }, + { + name: "user on context is a SuperAdmin", + fields: fields{ + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + user: &chronograf.User{ + SuperAdmin: true, + }, + }, + wants: wants{ + authorized: true, + hasServerContext: true, + }, + }, + { + name: "user on context is a not SuperAdmin", + fields: fields{ + Logger: clog.New(clog.DebugLevel), + }, + args: args{ + user: &chronograf.User{ + SuperAdmin: false, + }, + }, + wants: wants{ + authorized: false, + hasServerContext: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var authorized bool + var hasServerCtx bool + next := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + hasServerCtx = hasServerContext(ctx) + authorized = true + } + fn := RawStoreAccess( + tt.fields.Logger, + next, + ) + + w := httptest.NewRecorder() + url := "http://any.url" + r := httptest.NewRequest( + "GET", + url, + nil, + ) + if tt.args.principal == nil { + r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, nil)) + } else { + r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, *tt.args.principal)) + } + + if tt.args.serverContext { + r = r.WithContext(serverContext(r.Context())) + } + if tt.args.user != nil { + r = r.WithContext(context.WithValue(r.Context(), UserContextKey, tt.args.user)) + } + fn(w, r) + + if authorized != tt.wants.authorized { + t.Errorf("%q. RawStoreAccess() = %v, expected %v", tt.name, authorized, tt.wants.authorized) + } + + if !authorized && w.Code != http.StatusForbidden { + t.Errorf("%q. RawStoreAccess() Status Code = %v, expected %v", tt.name, w.Code, http.StatusForbidden) + } + + if hasServerCtx != tt.wants.hasServerContext { + t.Errorf("%q. RawStoreAccess().Context().Server = %v, expected %v", tt.name, hasServerCtx, tt.wants.hasServerContext) + } + + }) + } +} diff --git a/server/cells.go b/server/cells.go index ca05fa524..103ac89ef 100644 --- a/server/cells.go +++ b/server/cells.go @@ -28,37 +28,31 @@ type dashboardCellResponse struct { 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 - } + if cell.Queries == nil { + cell.Queries = []chronograf.DashboardQuery{} + } + if cell.CellColors == nil { + cell.CellColors = []chronograf.CellColor{} } + // Copy to handle race condition + newAxes := make(map[string]chronograf.Axis, len(cell.Axes)) + for k, v := range cell.Axes { + newAxes[k] = v + } + + // ensure x, y, and y2 axes always returned + for _, lbl := range []string{"x", "y", "y2"} { + if _, found := newAxes[lbl]; !found { + newAxes[lbl] = chronograf.Axis{ + Bounds: []string{}, + } + } + } + cell.Axes = newAxes + return dashboardCellResponse{ - DashboardCell: newCell, + DashboardCell: cell, Links: dashboardCellLinks{ Self: fmt.Sprintf("%s/%d/cells/%s", base, dID, cell.ID), }, @@ -91,7 +85,10 @@ func ValidDashboardCellRequest(c *chronograf.DashboardCell) error { if err != nil { return err } - return HasCorrectColors(c) + if err = HasCorrectColors(c); err != nil { + return err + } + return HasCorrectLegend(c) } // HasCorrectAxes verifies that only permitted axes exist within a DashboardCell @@ -126,6 +123,27 @@ func HasCorrectColors(c *chronograf.DashboardCell) error { return nil } +// HasCorrectLegend verifies that the format of the legend is correct +func HasCorrectLegend(c *chronograf.DashboardCell) error { + // No legend set + if c.Legend.Type == "" && c.Legend.Orientation == "" { + return nil + } + + if c.Legend.Type == "" || c.Legend.Orientation == "" { + return chronograf.ErrInvalidLegend + } + if !oneOf(c.Legend.Orientation, "top", "bottom", "right", "left") { + return chronograf.ErrInvalidLegendOrient + } + + // Remember! if we add other types, update ErrInvalidLegendType + if !oneOf(c.Legend.Type, "static") { + return chronograf.ErrInvalidLegendType + } + return nil +} + // oneOf reports whether a provided string is a member of a variadic list of // valid options func oneOf(prop string, validOpts ...string) bool { diff --git a/server/cells_test.go b/server/cells_test.go index bc3fecc0b..814d9c6ff 100644 --- a/server/cells_test.go +++ b/server/cells_test.go @@ -532,7 +532,7 @@ func TestService_ReplaceDashboardCell(t *testing.T) { } } `))), - 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"}} + 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"}],"legend":{},"links":{"self":"/chronograf/v1/dashboards/1/cells/3c5c4102-fa40-4585-a8f9-917c77e37192"}} `, }, { @@ -695,7 +695,7 @@ func Test_newCellResponses(t *testing.T) { want []dashboardCellResponse }{ { - name: "foo", + name: "all fields set", dID: chronograf.DashboardID(1), dcells: []chronograf.DashboardCell{ chronograf.DashboardCell{ @@ -752,6 +752,10 @@ func Test_newCellResponses(t *testing.T) { chronograf.CellColor{ID: "0", Type: "min", Hex: "#00C9FF", Name: "laser", Value: "0"}, chronograf.CellColor{ID: "1", Type: "max", Hex: "#9394FF", Name: "comet", Value: "100"}, }, + Legend: chronograf.Legend{ + Type: "static", + Orientation: "bottom", + }, }, }, want: []dashboardCellResponse{ @@ -817,6 +821,50 @@ func Test_newCellResponses(t *testing.T) { Value: "100", }, }, + Legend: chronograf.Legend{ + Type: "static", + Orientation: "bottom", + }, + }, + Links: dashboardCellLinks{ + Self: "/chronograf/v1/dashboards/1/cells/445f8dc0-4d73-4168-8477-f628690d18a3"}, + }, + }, + }, + { + name: "nothing set", + 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", + }, + }, + want: []dashboardCellResponse{ + { + DashboardCell: chronograf.DashboardCell{ + ID: "445f8dc0-4d73-4168-8477-f628690d18a3", + W: 4, + H: 4, + Name: "Untitled Cell", + Queries: []chronograf.DashboardQuery{}, + Axes: map[string]chronograf.Axis{ + "x": chronograf.Axis{ + Bounds: []string{}, + }, + "y": chronograf.Axis{ + Bounds: []string{}, + }, + "y2": chronograf.Axis{ + Bounds: []string{}, + }, + }, + CellColors: []chronograf.CellColor{}, + Legend: chronograf.Legend{}, }, Links: dashboardCellLinks{ Self: "/chronograf/v1/dashboards/1/cells/445f8dc0-4d73-4168-8477-f628690d18a3"}, @@ -832,3 +880,97 @@ func Test_newCellResponses(t *testing.T) { }) } } + +func TestHasCorrectLegend(t *testing.T) { + tests := []struct { + name string + c *chronograf.DashboardCell + wantErr bool + }{ + { + name: "empty legend is ok", + c: &chronograf.DashboardCell{}, + }, + { + name: "must have both an orientation and type", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "static", + }, + }, + wantErr: true, + }, + { + name: "must have both a type and orientation", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Orientation: "bottom", + }, + }, + wantErr: true, + }, + { + name: "invalid types", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "no such type", + Orientation: "bottom", + }, + }, + wantErr: true, + }, + { + name: "invalid orientation", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "static", + Orientation: "no such orientation", + }, + }, + wantErr: true, + }, + { + name: "orientation bottom valid", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "static", + Orientation: "bottom", + }, + }, + }, + { + name: "orientation top valid", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "static", + Orientation: "top", + }, + }, + }, + { + name: "orientation right valid", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "static", + Orientation: "right", + }, + }, + }, + { + name: "orientation left valid", + c: &chronograf.DashboardCell{ + Legend: chronograf.Legend{ + Type: "static", + Orientation: "left", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := HasCorrectLegend(tt.c); (err != nil) != tt.wantErr { + t.Errorf("HasCorrectLegend() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/server/me.go b/server/me.go index 52a36c6ff..1ec50d262 100644 --- a/server/me.go +++ b/server/me.go @@ -26,10 +26,11 @@ type meResponse struct { // If new user response is nil, return an empty meResponse because it // indicates authentication is not needed -func newMeResponse(usr *chronograf.User) meResponse { - base := "/chronograf/v1/users" +func newMeResponse(usr *chronograf.User, org string) meResponse { + base := "/chronograf/v1" name := "me" if usr != nil { + base = fmt.Sprintf("/chronograf/v1/organizations/%s/users", org) name = PathEscape(fmt.Sprintf("%d", usr.ID)) } @@ -181,7 +182,7 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !s.UseAuth { // If there's no authentication, return an empty user - res := newMeResponse(nil) + res := newMeResponse(nil, "") encodeJSON(w, http.StatusOK, res, s.Logger) return } @@ -264,7 +265,7 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { unknownErrorWithMessage(w, err, s.Logger) return } - res := newMeResponse(usr) + res := newMeResponse(usr, currentOrg.ID) res.Organizations = orgs res.CurrentOrganization = currentOrg encodeJSON(w, http.StatusOK, res, s.Logger) @@ -314,7 +315,7 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) { unknownErrorWithMessage(w, err, s.Logger) return } - res := newMeResponse(newUser) + res := newMeResponse(newUser, currentOrg.ID) res.Organizations = orgs res.CurrentOrganization = currentOrg encodeJSON(w, http.StatusOK, res, s.Logger) diff --git a/server/me_test.go b/server/me_test.go index 6f90e7d32..6733c44c3 100644 --- a/server/me_test.go +++ b/server/me_test.go @@ -176,7 +176,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","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}}`, + wantBody: `{"name":"me","roles":[{"name":"viewer","organization":"0"}],"provider":"github","scheme":"oauth2","superAdmin":true,"links":{"self":"/chronograf/v1/organizations/0/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", @@ -306,7 +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","defaultRole":"viewer","public":true}],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer","public":true}}`, + wantBody: `{"name":"me","roles":[{"name":"viewer","organization":"0"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/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", @@ -423,7 +423,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","defaultRole":"viewer","public":true}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer","public":true}} + wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","defaultRole":"viewer","public":true}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer","public":true}} `, }, { @@ -485,7 +485,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"secret","roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}} + wantBody: `{"name":"secret","roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}} `, }, { @@ -547,7 +547,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}} + wantBody: `{"name":"secret","superAdmin":true,"roles":[{"name":"viewer","organization":"0"}],"provider":"auth0","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","public":true,"defaultRole":"viewer"}} `, }, { @@ -624,7 +624,7 @@ func TestService_Me(t *testing.T) { }, wantStatus: http.StatusOK, wantContentType: "application/json", - wantBody: `{"links":{"self":"/chronograf/v1/users/me"}} + wantBody: `{"links":{"self":"/chronograf/v1/me"}} `, }, { @@ -824,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","defaultRole":"admin","public":true},{"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/organizations/1337/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", @@ -899,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","defaultRole":"editor","public":true},{"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/organizations/1337/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", diff --git a/server/middle.go b/server/middle.go new file mode 100644 index 000000000..7d2ec52c3 --- /dev/null +++ b/server/middle.go @@ -0,0 +1,57 @@ +package server + +import ( + "net/http" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" +) + +// RouteMatchesPrincipal checks that the organization on context matches the organization +// in the route. +func RouteMatchesPrincipal( + store DataStore, + useAuth bool, + logger chronograf.Logger, + next http.HandlerFunc, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !useAuth { + next(w, r) + return + } + + log := logger. + WithField("component", "org_match"). + WithField("remote_addr", r.RemoteAddr). + WithField("method", r.Method). + WithField("url", r.URL) + + orgID := httprouter.GetParamFromContext(ctx, "oid") + p, err := getValidPrincipal(ctx) + if err != nil { + log.Error("Failed to retrieve principal from context") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + + if p.Organization == "" { + defaultOrg, err := store.Organizations(ctx).DefaultOrganization(ctx) + if err != nil { + log.Error("Failed to look up default organization") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + p.Organization = defaultOrg.ID + } + + if orgID != p.Organization { + log.Error("Route organization does not match the organization on principal") + Error(w, http.StatusForbidden, "User is not authorized", logger) + return + } + + next(w, r) + } +} diff --git a/server/middle_test.go b/server/middle_test.go new file mode 100644 index 000000000..a57f56370 --- /dev/null +++ b/server/middle_test.go @@ -0,0 +1,196 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/log" + "github.com/influxdata/chronograf/mocks" + "github.com/influxdata/chronograf/oauth2" +) + +func TestRouteMatchesPrincipal(t *testing.T) { + type fields struct { + OrganizationsStore chronograf.OrganizationsStore + Logger chronograf.Logger + } + type args struct { + useAuth bool + principal *oauth2.Principal + routerParams *httprouter.Params + } + type wants struct { + matches bool + } + tests := []struct { + name string + fields fields + args args + wants wants + }{ + { + name: "route matches request params", + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "default", + }, nil + }, + }, + }, + args: args{ + useAuth: true, + principal: &oauth2.Principal{ + Subject: "user", + Issuer: "github", + Organization: "default", + }, + routerParams: &httprouter.Params{ + { + Key: "oid", + Value: "default", + }, + }, + }, + wants: wants{ + matches: true, + }, + }, + { + name: "route does not match request params", + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "default", + }, nil + }, + }, + }, + args: args{ + useAuth: true, + principal: &oauth2.Principal{ + Subject: "user", + Issuer: "github", + Organization: "default", + }, + routerParams: &httprouter.Params{ + { + Key: "oid", + Value: "other", + }, + }, + }, + wants: wants{ + matches: false, + }, + }, + { + name: "missing principal", + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "default", + }, nil + }, + }, + }, + args: args{ + useAuth: true, + principal: nil, + routerParams: &httprouter.Params{ + { + Key: "oid", + Value: "other", + }, + }, + }, + wants: wants{ + matches: false, + }, + }, + { + name: "not using auth", + fields: fields{ + Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) { + return &chronograf.Organization{ + ID: "default", + }, nil + }, + }, + }, + args: args{ + useAuth: false, + principal: &oauth2.Principal{ + Subject: "user", + Issuer: "github", + Organization: "default", + }, + routerParams: &httprouter.Params{ + { + Key: "oid", + Value: "other", + }, + }, + }, + wants: wants{ + matches: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := &mocks.Store{ + OrganizationsStore: tt.fields.OrganizationsStore, + } + var matches bool + next := func(w http.ResponseWriter, r *http.Request) { + matches = true + } + fn := RouteMatchesPrincipal( + store, + tt.args.useAuth, + tt.fields.Logger, + next, + ) + + w := httptest.NewRecorder() + url := "http://any.url" + r := httptest.NewRequest( + "GET", + url, + nil, + ) + if tt.args.routerParams != nil { + r = r.WithContext(httprouter.WithParams(r.Context(), *tt.args.routerParams)) + } + if tt.args.principal == nil { + r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, nil)) + } else { + r = r.WithContext(context.WithValue(r.Context(), oauth2.PrincipalKey, *tt.args.principal)) + } + fn(w, r) + + if matches != tt.wants.matches { + t.Errorf("%q. RouteMatchesPrincipal() = %v, expected %v", tt.name, matches, tt.wants.matches) + } + + if !matches && w.Code != http.StatusForbidden { + t.Errorf("%q. RouteMatchesPrincipal() Status Code = %v, expected %v", tt.name, w.Code, http.StatusForbidden) + } + + }) + } +} diff --git a/server/mux.go b/server/mux.go index 25c8214bd..91523772e 100644 --- a/server/mux.go +++ b/server/mux.go @@ -68,6 +68,16 @@ func NewMux(opts MuxOpts, service Service) http.Handler { hr.NotFound = http.StripPrefix(opts.Basepath, hr.NotFound) } + EnsureMember := func(next http.HandlerFunc) http.HandlerFunc { + return AuthorizedUser( + service.Store, + opts.UseAuth, + roles.MemberRoleName, + opts.Logger, + next, + ) + } + _ = EnsureMember EnsureViewer := func(next http.HandlerFunc) http.HandlerFunc { return AuthorizedUser( service.Store, @@ -105,6 +115,19 @@ func NewMux(opts MuxOpts, service Service) http.Handler { ) } + rawStoreAccess := func(next http.HandlerFunc) http.HandlerFunc { + return RawStoreAccess(opts.Logger, next) + } + + ensureOrgMatches := func(next http.HandlerFunc) http.HandlerFunc { + return RouteMatchesPrincipal( + service.Store, + opts.UseAuth, + opts.Logger, + next, + ) + } + /* Documentation */ router.GET("/swagger.json", Spec()) router.GET("/docs", Redoc("/swagger.json")) @@ -114,9 +137,9 @@ func NewMux(opts MuxOpts, service Service) http.Handler { router.GET("/chronograf/v1/organizations", EnsureAdmin(service.Organizations)) router.POST("/chronograf/v1/organizations", EnsureSuperAdmin(service.NewOrganization)) - router.GET("/chronograf/v1/organizations/:id", EnsureAdmin(service.OrganizationID)) - router.PATCH("/chronograf/v1/organizations/:id", EnsureSuperAdmin(service.UpdateOrganization)) - router.DELETE("/chronograf/v1/organizations/:id", EnsureSuperAdmin(service.RemoveOrganization)) + router.GET("/chronograf/v1/organizations/:oid", EnsureAdmin(service.OrganizationID)) + router.PATCH("/chronograf/v1/organizations/:oid", EnsureSuperAdmin(service.UpdateOrganization)) + router.DELETE("/chronograf/v1/organizations/:oid", EnsureSuperAdmin(service.RemoveOrganization)) // Sources router.GET("/chronograf/v1/sources", EnsureViewer(service.Sources)) @@ -201,12 +224,19 @@ func NewMux(opts MuxOpts, service Service) http.Handler { router.PUT("/chronograf/v1/me", service.UpdateMe(opts.Auth)) // TODO(desa): what to do about admin's being able to set superadmin - router.GET("/chronograf/v1/users", EnsureAdmin(service.Users)) - router.POST("/chronograf/v1/users", EnsureAdmin(service.NewUser)) + router.GET("/chronograf/v1/organizations/:oid/users", EnsureAdmin(ensureOrgMatches(service.Users))) + router.POST("/chronograf/v1/organizations/:oid/users", EnsureAdmin(ensureOrgMatches(service.NewUser))) - router.GET("/chronograf/v1/users/:id", EnsureAdmin(service.UserID)) - router.DELETE("/chronograf/v1/users/:id", EnsureAdmin(service.RemoveUser)) - router.PATCH("/chronograf/v1/users/:id", EnsureAdmin(service.UpdateUser)) + router.GET("/chronograf/v1/organizations/:oid/users/:id", EnsureAdmin(ensureOrgMatches(service.UserID))) + router.DELETE("/chronograf/v1/organizations/:oid/users/:id", EnsureAdmin(ensureOrgMatches(service.RemoveUser))) + router.PATCH("/chronograf/v1/organizations/:oid/users/:id", EnsureAdmin(ensureOrgMatches(service.UpdateUser))) + + router.GET("/chronograf/v1/users", EnsureSuperAdmin(rawStoreAccess(service.Users))) + router.POST("/chronograf/v1/users", EnsureSuperAdmin(rawStoreAccess(service.NewUser))) + + router.GET("/chronograf/v1/users/:id", EnsureSuperAdmin(rawStoreAccess(service.UserID))) + router.DELETE("/chronograf/v1/users/:id", EnsureSuperAdmin(rawStoreAccess(service.RemoveUser))) + router.PATCH("/chronograf/v1/users/:id", EnsureSuperAdmin(rawStoreAccess(service.UpdateUser))) // Dashboards router.GET("/chronograf/v1/dashboards", EnsureViewer(service.Dashboards)) @@ -257,6 +287,11 @@ func NewMux(opts MuxOpts, service Service) http.Handler { CustomLinks: opts.CustomLinks, } + getPrincipal := func(r *http.Request) oauth2.Principal { + p, _ := HasAuthorizedToken(opts.Auth, r) + return p + } + allRoutes.GetPrincipal = getPrincipal router.Handler("GET", "/chronograf/v1/", allRoutes) var out http.Handler diff --git a/server/organizations.go b/server/organizations.go index 5b2227953..a4debaa15 100644 --- a/server/organizations.go +++ b/server/organizations.go @@ -165,7 +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() - id := httprouter.GetParamFromContext(ctx, "id") + id := httprouter.GetParamFromContext(ctx, "oid") org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) if err != nil { @@ -191,7 +191,7 @@ func (s *Service) UpdateOrganization(w http.ResponseWriter, r *http.Request) { } ctx := r.Context() - id := httprouter.GetParamFromContext(ctx, "id") + id := httprouter.GetParamFromContext(ctx, "oid") org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &id}) if err != nil { @@ -226,7 +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() - id := httprouter.GetParamFromContext(ctx, "id") + id := httprouter.GetParamFromContext(ctx, "oid") 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 3315d981b..846f8ab81 100644 --- a/server/organizations_test.go +++ b/server/organizations_test.go @@ -82,7 +82,7 @@ func TestService_OrganizationID(t *testing.T) { context.Background(), httprouter.Params{ { - Key: "id", + Key: "oid", Value: tt.id, }, })) @@ -411,7 +411,7 @@ func TestService_UpdateOrganization(t *testing.T) { tt.args.r = tt.args.r.WithContext(httprouter.WithParams(context.Background(), httprouter.Params{ { - Key: "id", + Key: "oid", Value: tt.id, }, })) @@ -503,7 +503,7 @@ func TestService_RemoveOrganization(t *testing.T) { tt.args.r = tt.args.r.WithContext(httprouter.WithParams(context.Background(), httprouter.Params{ { - Key: "id", + Key: "oid", Value: tt.id, }, })) diff --git a/server/routes.go b/server/routes.go index 993d4d749..a74cd6b9e 100644 --- a/server/routes.go +++ b/server/routes.go @@ -1,9 +1,11 @@ package server import ( + "fmt" "net/http" "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/oauth2" ) // AuthRoute are the routes for each type of OAuth2 provider @@ -31,6 +33,7 @@ func (r *AuthRoutes) Lookup(provider string) (AuthRoute, bool) { type getRoutesResponse struct { Layouts string `json:"layouts"` // Location of the layouts endpoint Users string `json:"users"` // Location of the users endpoint + AllUsers string `json:"allUsers"` // Location of the raw users endpoint Organizations string `json:"organizations"` // Location of the organizations endpoint Mappings string `json:"mappings"` // Location of the application mappings endpoint Sources string `json:"sources"` // Location of the sources endpoint @@ -47,14 +50,15 @@ type getRoutesResponse struct { // external links for the client to know about, such as for JSON feeds or custom side nav buttons. // Optionally, routes for authentication can be returned. type AllRoutes struct { - AuthRoutes []AuthRoute // Location of all auth routes. If no auth, this can be empty. - LogoutLink string // Location of the logout route for all auth routes. If no auth, this can be empty. - StatusFeed string // External link to the JSON Feed for the News Feed on the client's Status Page - CustomLinks map[string]string // Custom external links for client's User menu, as passed in via CLI/ENV - Logger chronograf.Logger + GetPrincipal func(r *http.Request) oauth2.Principal // GetPrincipal is used to retrieve the principal on http request. + AuthRoutes []AuthRoute // Location of all auth routes. If no auth, this can be empty. + LogoutLink string // Location of the logout route for all auth routes. If no auth, this can be empty. + StatusFeed string // External link to the JSON Feed for the News Feed on the client's Status Page + CustomLinks map[string]string // Custom external links for client's User menu, as passed in via CLI/ENV + Logger chronograf.Logger } -// ServeHTTP returns all top level routes and external links within chronograf +// serveHTTP returns all top level routes and external links within chronograf func (a *AllRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) { customLinks, err := NewCustomLinks(a.CustomLinks) if err != nil { @@ -62,10 +66,20 @@ func (a *AllRoutes) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + org := "default" + if a.GetPrincipal != nil { + // If there is a principal, use the organization to populate the users routes + // otherwise use the default organization + if p := a.GetPrincipal(r); p.Organization != "" { + org = p.Organization + } + } + routes := getRoutesResponse{ Sources: "/chronograf/v1/sources", Layouts: "/chronograf/v1/layouts", - Users: "/chronograf/v1/users", + Users: fmt.Sprintf("/chronograf/v1/organizations/%s/users", org), + AllUsers: "/chronograf/v1/users", Organizations: "/chronograf/v1/organizations", Me: "/chronograf/v1/me", Environment: "/chronograf/v1/env", diff --git a/server/routes_test.go b/server/routes_test.go index aeadcd584..38ff7b8b3 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","environment":"/chronograf/v1/env","dashboards":"/chronograf/v1/dashboards","config":{"self":"/chronograf/v1/config","auth":"/chronograf/v1/config/auth"},"auth":[],"external":{"statusFeed":""}} + want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/organizations/default/users","allUsers":"/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","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":""}} + want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/organizations/default/users","allUsers":"/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","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"}]}} + want := `{"layouts":"/chronograf/v1/layouts","users":"/chronograf/v1/organizations/default/users","allUsers":"/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/swagger.json b/server/swagger.json index 109ad32f7..5eb12617c 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" + "version": "1.4.0.1" }, "schemes": ["http"], "basePath": "/chronograf/v1", @@ -3970,6 +3970,24 @@ "$ref": "#/definitions/DashboardColor" } }, + "legend": { + "description": + "Legend define encoding of the data into a cell's legend", + "type": "object", + "properties": { + "type": { + "description": "type is the style of the legend", + "type": "string", + "enum": ["static"] + }, + "orientation": { + "description": + "orientation is the location of the legend with respect to the cell graph", + "type": "string", + "enum": ["top", "bottom", "left", "right"] + } + } + }, "links": { "type": "object", "properties": { diff --git a/server/users.go b/server/users.go index 3d82406d8..f8b59955f 100644 --- a/server/users.go +++ b/server/users.go @@ -41,15 +41,15 @@ func (r *userRequest) ValidCreate() error { } func (r *userRequest) ValidUpdate() error { - if len(r.Roles) == 0 { + if r.Roles == nil { return fmt.Errorf("No Roles to update") } return r.ValidRoles() } func (r *userRequest) ValidRoles() error { - orgs := map[string]bool{} if len(r.Roles) > 0 { + orgs := map[string]bool{} for _, r := range r.Roles { if r.Organization == "" { return fmt.Errorf("no organization was provided") @@ -59,10 +59,10 @@ func (r *userRequest) ValidRoles() error { } orgs[r.Organization] = true switch r.Name { - case roles.MemberRoleName, roles.ViewerRoleName, roles.EditorRoleName, roles.AdminRoleName: + case roles.MemberRoleName, roles.ViewerRoleName, roles.EditorRoleName, roles.AdminRoleName, roles.WildcardRoleName: continue default: - return fmt.Errorf("Unknown role %s. Valid roles are 'member', 'viewer', 'editor', and 'admin'", r.Name) + return fmt.Errorf("Unknown role %s. Valid roles are 'member', 'viewer', 'editor', 'admin', and '*'", r.Name) } } } @@ -79,13 +79,19 @@ type userResponse struct { Roles []chronograf.Role `json:"roles"` } -func newUserResponse(u *chronograf.User) *userResponse { +func newUserResponse(u *chronograf.User, org string) *userResponse { // This ensures that any user response with no roles returns an empty array instead of // null when marshaled into JSON. That way, JavaScript doesn't need any guard on the // key existing and it can simply be iterated over. if u.Roles == nil { u.Roles = []chronograf.Role{} } + var selfLink string + if org != "" { + selfLink = fmt.Sprintf("/chronograf/v1/organizations/%s/users/%d", org, u.ID) + } else { + selfLink = fmt.Sprintf("/chronograf/v1/users/%d", u.ID) + } return &userResponse{ ID: u.ID, Name: u.Name, @@ -94,7 +100,7 @@ func newUserResponse(u *chronograf.User) *userResponse { Roles: u.Roles, SuperAdmin: u.SuperAdmin, Links: selfLinks{ - Self: fmt.Sprintf("/chronograf/v1/users/%d", u.ID), + Self: selfLink, }, } } @@ -104,18 +110,25 @@ type usersResponse struct { Users []*userResponse `json:"users"` } -func newUsersResponse(users []chronograf.User) *usersResponse { +func newUsersResponse(users []chronograf.User, org string) *usersResponse { usersResp := make([]*userResponse, len(users)) for i, user := range users { - usersResp[i] = newUserResponse(&user) + usersResp[i] = newUserResponse(&user, org) } sort.Slice(usersResp, func(i, j int) bool { return usersResp[i].ID < usersResp[j].ID }) + + var selfLink string + if org != "" { + selfLink = fmt.Sprintf("/chronograf/v1/organizations/%s/users", org) + } else { + selfLink = "/chronograf/v1/users" + } return &usersResponse{ Users: usersResp, Links: selfLinks{ - Self: "/chronograf/v1/users", + Self: selfLink, }, } } @@ -136,7 +149,9 @@ func (s *Service) UserID(w http.ResponseWriter, r *http.Request) { return } - res := newUserResponse(user) + orgID := httprouter.GetParamFromContext(ctx, "oid") + res := newUserResponse(user, orgID) + location(w, res.Links.Self) encodeJSON(w, http.StatusOK, res, s.Logger) } @@ -162,6 +177,11 @@ func (s *Service) NewUser(w http.ResponseWriter, r *http.Request) { return } + if err := s.validRoles(serverCtx, req.Roles); err != nil { + invalidData(w, err, s.Logger) + return + } + user := &chronograf.User{ Name: req.Name, Provider: req.Provider, @@ -184,7 +204,8 @@ func (s *Service) NewUser(w http.ResponseWriter, r *http.Request) { return } - cu := newUserResponse(res) + orgID := httprouter.GetParamFromContext(ctx, "oid") + cu := newUserResponse(res, orgID) location(w, cu.Links.Self) encodeJSON(w, http.StatusCreated, cu, s.Logger) } @@ -204,15 +225,6 @@ func (s *Service) RemoveUser(w http.ResponseWriter, r *http.Request) { Error(w, http.StatusNotFound, err.Error(), s.Logger) return } - ctxUser, ok := hasUserContext(ctx) - if !ok { - Error(w, http.StatusBadRequest, "failed to retrieve user from context", s.Logger) - return - } - if ctxUser.ID == u.ID { - Error(w, http.StatusForbidden, "user cannot delete themselves", s.Logger) - return - } if err := s.Store.Users(ctx).Delete(ctx, u); err != nil { Error(w, http.StatusBadRequest, err.Error(), s.Logger) return @@ -248,6 +260,12 @@ func (s *Service) UpdateUser(w http.ResponseWriter, r *http.Request) { return } + serverCtx := serverContext(ctx) + if err := s.validRoles(serverCtx, req.Roles); err != nil { + invalidData(w, err, s.Logger) + return + } + // ValidUpdate should ensure that req.Roles is not nil u.Roles = req.Roles @@ -299,7 +317,8 @@ func (s *Service) UpdateUser(w http.ResponseWriter, r *http.Request) { return } - cu := newUserResponse(u) + orgID := httprouter.GetParamFromContext(ctx, "oid") + cu := newUserResponse(u, orgID) location(w, cu.Links.Self) encodeJSON(w, http.StatusOK, cu, s.Logger) } @@ -314,7 +333,8 @@ func (s *Service) Users(w http.ResponseWriter, r *http.Request) { return } - res := newUsersResponse(users) + orgID := httprouter.GetParamFromContext(ctx, "oid") + res := newUsersResponse(users, orgID) encodeJSON(w, http.StatusOK, res, s.Logger) } @@ -341,3 +361,19 @@ func setSuperAdmin(ctx context.Context, req userRequest, user *chronograf.User) return nil } + +func (s *Service) validRoles(ctx context.Context, rs []chronograf.Role) error { + for i, role := range rs { + // verify that the organization exists + org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &role.Organization}) + if err != nil { + return err + } + if role.Name == roles.WildcardRoleName { + role.Name = org.DefaultRole + rs[i] = role + } + } + + return nil +} diff --git a/server/users_test.go b/server/users_test.go index 7641d4922..019b6453e 100644 --- a/server/users_test.go +++ b/server/users_test.go @@ -112,9 +112,10 @@ func TestService_UserID(t *testing.T) { func TestService_NewUser(t *testing.T) { type fields struct { - UsersStore chronograf.UsersStore - ConfigStore chronograf.ConfigStore - Logger chronograf.Logger + UsersStore chronograf.UsersStore + OrganizationsStore chronograf.OrganizationsStore + ConfigStore chronograf.ConfigStore + Logger chronograf.Logger } type args struct { w *httptest.ResponseRecorder @@ -204,6 +205,25 @@ func TestService_NewUser(t *testing.T) { }, }, }, + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + case "2": + return &chronograf.Organization{ + ID: "2", + Name: "another", + DefaultRole: roles.MemberRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { return &chronograf.User{ @@ -427,14 +447,93 @@ 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 Chronograf User with multiple roles with wildcard default role", + args: args{ + w: httptest.NewRecorder(), + r: httptest.NewRequest( + "POST", + "http://any.url", + nil, + ), + user: &userRequest{ + Name: "bob", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + { + Name: roles.WildcardRoleName, + Organization: "2", + }, + }, + }, + }, + fields: fields{ + Logger: log.New(log.DebugLevel), + ConfigStore: &mocks.ConfigStore{ + Config: &chronograf.Config{ + Auth: chronograf.AuthConfig{ + SuperAdminNewUsers: false, + }, + }, + }, + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + case "2": + return &chronograf.Organization{ + ID: "2", + Name: "another", + DefaultRole: roles.MemberRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, + UsersStore: &mocks.UsersStore{ + AddF: func(ctx context.Context, user *chronograf.User) (*chronograf.User, error) { + return &chronograf.User{ + ID: 1338, + Name: "bob", + Provider: "github", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: roles.AdminRoleName, + Organization: "1", + }, + { + Name: roles.MemberRoleName, + Organization: "2", + }, + }, + }, nil + }, + }, + }, + wantStatus: http.StatusCreated, + wantContentType: "application/json", + wantBody: `{"id":"1338","superAdmin":false,"name":"bob","provider":"github","scheme":"oauth2","roles":[{"name":"admin","organization":"1"},{"name":"member","organization":"2"}],"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, - ConfigStore: tt.fields.ConfigStore, + UsersStore: tt.fields.UsersStore, + ConfigStore: tt.fields.ConfigStore, + OrganizationsStore: tt.fields.OrganizationsStore, }, Logger: tt.fields.Logger, } @@ -564,8 +663,8 @@ func TestService_RemoveUser(t *testing.T) { }, id: "1339", }, - wantStatus: http.StatusForbidden, - wantBody: `{"code":403,"message":"user cannot delete themselves"}`, + wantStatus: http.StatusNoContent, + wantBody: ``, }, } for _, tt := range tests { @@ -613,8 +712,9 @@ func TestService_RemoveUser(t *testing.T) { func TestService_UpdateUser(t *testing.T) { type fields struct { - UsersStore chronograf.UsersStore - Logger chronograf.Logger + UsersStore chronograf.UsersStore + OrganizationsStore chronograf.OrganizationsStore + Logger chronograf.Logger } type args struct { w *httptest.ResponseRecorder @@ -631,10 +731,76 @@ func TestService_UpdateUser(t *testing.T) { wantContentType string wantBody string }{ + { + name: "Update a Chronograf user - no roles", + 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", + 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, + ), + userKeyUser: &chronograf.User{ + ID: 0, + Name: "coolUser", + Provider: "github", + Scheme: "oauth2", + SuperAdmin: false, + }, + user: &userRequest{ + ID: 1336, + Roles: []chronograf.Role{}, + }, + }, + id: "1336", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `{"id":"1336","superAdmin":false,"name":"bobbetta","provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/users/1336"},"roles":[]}`, + }, { name: "Update a Chronograf user", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -693,6 +859,25 @@ func TestService_UpdateUser(t *testing.T) { name: "Update a Chronograf user roles different orgs", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + case "2": + return &chronograf.Organization{ + ID: "2", + Name: "another", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -804,6 +989,19 @@ func TestService_UpdateUser(t *testing.T) { name: "SuperAdmin modifying their own SuperAdmin Status - user missing from context", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -857,6 +1055,19 @@ func TestService_UpdateUser(t *testing.T) { name: "SuperAdmin modifying their own SuperAdmin Status", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -917,6 +1128,19 @@ func TestService_UpdateUser(t *testing.T) { name: "Update a SuperAdmin's Roles - without super admin context", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -977,6 +1201,19 @@ func TestService_UpdateUser(t *testing.T) { name: "Update a Chronograf user to super admin - without super admin context", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -1033,6 +1270,19 @@ func TestService_UpdateUser(t *testing.T) { name: "Update a Chronograf user to super admin - with super admin context", fields: fields{ Logger: log.New(log.DebugLevel), + OrganizationsStore: &mocks.OrganizationsStore{ + GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) { + switch *q.ID { + case "1": + return &chronograf.Organization{ + ID: "1", + Name: "org", + DefaultRole: roles.ViewerRoleName, + }, nil + } + return nil, fmt.Errorf("org not found") + }, + }, UsersStore: &mocks.UsersStore{ UpdateF: func(ctx context.Context, user *chronograf.User) error { return nil @@ -1090,7 +1340,8 @@ func TestService_UpdateUser(t *testing.T) { t.Run(tt.name, func(t *testing.T) { s := &Service{ Store: &mocks.Store{ - UsersStore: tt.fields.UsersStore, + UsersStore: tt.fields.UsersStore, + OrganizationsStore: tt.fields.OrganizationsStore, }, Logger: tt.fields.Logger, } @@ -1354,7 +1605,7 @@ func TestUserRequest_ValidCreate(t *testing.T) { }, }, wantErr: true, - err: fmt.Errorf("Unknown role BilliettaSpecialRole. Valid roles are 'member', 'viewer', 'editor', and 'admin'"), + err: fmt.Errorf("Unknown role BilliettaSpecialRole. Valid roles are 'member', 'viewer', 'editor', 'admin', and '*'"), }, { name: "Invalid roles - missing organization", @@ -1444,7 +1695,39 @@ func TestUserRequest_ValidUpdate(t *testing.T) { }, }, wantErr: true, - err: fmt.Errorf("Unknown role BillietaSpecialOrg. Valid roles are 'member', 'viewer', 'editor', and 'admin'"), + err: fmt.Errorf("Unknown role BillietaSpecialOrg. Valid roles are 'member', 'viewer', 'editor', 'admin', and '*'"), + }, + { + name: "Valid – roles empty", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Provider: "auth0", + Scheme: "oauth2", + Roles: []chronograf.Role{}, + }, + }, + wantErr: false, + }, + { + name: "Invalid - bad role name", + args: args{ + u: &userRequest{ + ID: 1337, + Name: "billietta", + Provider: "auth0", + Scheme: "oauth2", + Roles: []chronograf.Role{ + { + Name: "BillietaSpecialOrg", + Organization: "0", + }, + }, + }, + }, + wantErr: true, + err: fmt.Errorf("Unknown role BillietaSpecialOrg. Valid roles are 'member', 'viewer', 'editor', 'admin', and '*'"), }, { name: "Invalid - duplicate organization", diff --git a/ui/package.json b/ui/package.json index 22f7f2b06..9ba4ad077 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "chronograf-ui", - "version": "1.4.0-0", + "version": "1.4.0-1", "private": false, "license": "AGPL-3.0", "description": "", diff --git a/ui/spec/shared/reducers/linksSpec.js b/ui/spec/shared/reducers/linksSpec.js index 58fd7ebb1..cea30d175 100644 --- a/ui/spec/shared/reducers/linksSpec.js +++ b/ui/spec/shared/reducers/linksSpec.js @@ -2,7 +2,7 @@ import _ from 'lodash' import linksReducer from 'shared/reducers/links' -import {linksReceived} from 'shared/actions/links' +import {linksGetCompleted} from 'shared/actions/links' import {noop} from 'shared/actions/app' const links = { @@ -25,11 +25,10 @@ const links = { } describe('Shared.Reducers.linksReducer', () => { - it('can handle LINKS_RECEIVED', () => { + it('can handle LINKS_GET_COMPLETED', () => { const initial = linksReducer(undefined, noop()) - const actual = linksReducer(initial, linksReceived(links)) + const actual = linksReducer(initial, linksGetCompleted(links)) const expected = links - expect(_.isEqual(actual, expected)).to.equal(true) }) }) diff --git a/ui/src/admin/actions/chronograf.js b/ui/src/admin/actions/chronograf.js index d7a64731e..0ad0204b1 100644 --- a/ui/src/admin/actions/chronograf.js +++ b/ui/src/admin/actions/chronograf.js @@ -131,7 +131,11 @@ export const createUserAsync = (url, user) => async dispatch => { } } -export const updateUserAsync = (user, updatedUser) => async dispatch => { +export const updateUserAsync = ( + user, + updatedUser, + successMessage +) => async dispatch => { dispatch(updateUser(user, updatedUser)) try { // currently the request will be rejected if name, provider, or scheme, or @@ -145,12 +149,7 @@ export const updateUserAsync = (user, updatedUser) => async dispatch => { provider: null, scheme: null, }) - dispatch( - publishAutoDismissingNotification( - 'success', - `User updated: ${user.scheme}::${user.provider}::${user.name}` - ) - ) + dispatch(publishAutoDismissingNotification('success', successMessage)) // it's not necessary to syncUser again but it's useful for good // measure and for the clarity of insight in the redux story dispatch(syncUser(user, data)) @@ -160,14 +159,19 @@ export const updateUserAsync = (user, updatedUser) => async dispatch => { } } -export const deleteUserAsync = user => async dispatch => { +export const deleteUserAsync = ( + user, + {isAbsoluteDelete} = {} +) => async dispatch => { dispatch(removeUser(user)) try { await deleteUserAJAX(user) dispatch( publishAutoDismissingNotification( 'success', - `User removed from organization: ${user.scheme}::${user.provider}::${user.name}` + `${user.name} has been removed from ${isAbsoluteDelete + ? 'all organizations and deleted' + : 'the current organization'}` ) ) } catch (error) { diff --git a/ui/src/admin/components/chronograf/AdminTabs.js b/ui/src/admin/components/chronograf/AdminTabs.js index 8d4be7f9f..f52b02cb2 100644 --- a/ui/src/admin/components/chronograf/AdminTabs.js +++ b/ui/src/admin/components/chronograf/AdminTabs.js @@ -9,9 +9,11 @@ import { import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'shared/components/Tabs' import OrganizationsPage from 'src/admin/containers/chronograf/OrganizationsPage' import UsersPage from 'src/admin/containers/chronograf/UsersPage' +import AllUsersPage from 'src/admin/containers/chronograf/AllUsersPage' const ORGANIZATIONS_TAB_NAME = 'Organizations' -const USERS_TAB_NAME = 'Users' +const CURRENT_ORG_USERS_TAB_NAME = 'Current Org Users' +const ALL_USERS_TAB_NAME = 'All Users' const AdminTabs = ({ me: {currentOrganization: meCurrentOrganization, role: meRole, id: meID}, @@ -26,11 +28,16 @@ const AdminTabs = ({ }, { requiredRole: ADMIN_ROLE, - type: USERS_TAB_NAME, + type: CURRENT_ORG_USERS_TAB_NAME, component: ( ), }, + { + requiredRole: SUPERADMIN_ROLE, + type: ALL_USERS_TAB_NAME, + component: , + }, ].filter(t => isUserAuthorized(meRole, t.requiredRole)) return ( diff --git a/ui/src/admin/components/chronograf/AllUsersTable.js b/ui/src/admin/components/chronograf/AllUsersTable.js new file mode 100644 index 000000000..29de6b8cf --- /dev/null +++ b/ui/src/admin/components/chronograf/AllUsersTable.js @@ -0,0 +1,215 @@ +import React, {Component, PropTypes} from 'react' + +import uuid from 'node-uuid' + +import AllUsersTableHeader from 'src/admin/components/chronograf/AllUsersTableHeader' +import AllUsersTableRowNew from 'src/admin/components/chronograf/AllUsersTableRowNew' +import AllUsersTableRow from 'src/admin/components/chronograf/AllUsersTableRow' + +import {ALL_USERS_TABLE} from 'src/admin/constants/chronografTableSizing' +const { + colOrganizations, + colProvider, + colScheme, + colSuperAdmin, + colActions, +} = ALL_USERS_TABLE + +class AllUsersTable extends Component { + constructor(props) { + super(props) + + this.state = { + isCreatingUser: false, + } + } + + handleUpdateAuthConfig = fieldName => updatedValue => { + const { + actionsConfig: {updateAuthConfigAsync}, + authConfig, + links, + } = this.props + const updatedAuthConfig = { + ...authConfig, + [fieldName]: updatedValue, + } + updateAuthConfigAsync(links.config.auth, authConfig, updatedAuthConfig) + } + + handleAddToOrganization = user => organization => { + // '*' tells the server to fill in the current defaultRole of that org + const newRoles = user.roles.concat({ + organization: organization.id, + name: '*', + }) + this.props.onUpdateUserRoles( + user, + newRoles, + `${user.name} has been added to ${organization.name}` + ) + } + + handleRemoveFromOrganization = user => role => { + const newRoles = user.roles.filter( + r => r.organization !== role.organization + ) + const {name} = this.props.organizations.find( + o => o.id === role.organization + ) + this.props.onUpdateUserRoles( + user, + newRoles, + `${user.name} has been removed from ${name}` + ) + } + + handleChangeSuperAdmin = user => newStatus => { + this.props.onUpdateUserSuperAdmin(user, newStatus) + } + + handleClickCreateUser = () => { + this.setState({isCreatingUser: true}) + } + + handleBlurCreateUserRow = () => { + this.setState({isCreatingUser: false}) + } + + render() { + const { + users, + organizations, + onCreateUser, + authConfig, + meID, + notify, + onDeleteUser, + isLoading, + } = this.props + + const {isCreatingUser} = this.state + if (isLoading) { + return ( +
+
+
+
+
+ ) + } + return ( +
+ +
+ + + + + + + + + + + + {users.length + ? users.map(user => + + ) + : + + } + {isCreatingUser + ? + : null} + +
Username + Organizations + ProviderScheme + SuperAdmin + +
+

No Users to display

+
+
+
+ ) + } +} + +const {arrayOf, bool, func, shape, string} = PropTypes + +AllUsersTable.propTypes = { + links: shape({ + config: shape({ + auth: string.isRequired, + }).isRequired, + }).isRequired, + 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, + organizations: arrayOf( + shape({ + name: string.isRequired, + id: string.isRequired, + }) + ), + onCreateUser: func.isRequired, + onUpdateUserRoles: func.isRequired, + onUpdateUserSuperAdmin: func.isRequired, + onDeleteUser: func.isRequired, + actionsConfig: shape({ + getAuthConfigAsync: func.isRequired, + updateAuthConfigAsync: func.isRequired, + }), + authConfig: shape({ + superAdminNewUsers: bool, + }), + meID: string.isRequired, + notify: func.isRequired, + isLoading: bool.isRequired, +} + +export default AllUsersTable diff --git a/ui/src/admin/components/chronograf/AllUsersTableHeader.js b/ui/src/admin/components/chronograf/AllUsersTableHeader.js new file mode 100644 index 000000000..7c222af67 --- /dev/null +++ b/ui/src/admin/components/chronograf/AllUsersTableHeader.js @@ -0,0 +1,65 @@ +import React, {PropTypes} from 'react' + +import SlideToggle from 'shared/components/SlideToggle' + +const AllUsersTableHeader = ({ + numUsers, + numOrganizations, + onClickCreateUser, + isCreatingUser, + authConfig: {superAdminNewUsers}, + onChangeAuthConfig, +}) => { + const numUsersString = `${numUsers} User${numUsers === 1 ? '' : 's'}` + const numOrganizationsString = `${numOrganizations} Org${numOrganizations === + 1 + ? '' + : 's'}` + + return ( +
+

+ {numUsersString} in {numOrganizationsString} +

+
+
+ + All new users are SuperAdmins +
+ +
+
+ ) +} + +const {bool, func, number, shape} = PropTypes + +AllUsersTableHeader.defaultProps = { + numUsers: 0, + numOrganizations: 0, + isCreatingUser: false, +} + +AllUsersTableHeader.propTypes = { + numUsers: number.isRequired, + numOrganizations: number.isRequired, + onClickCreateUser: func, + isCreatingUser: bool.isRequired, + onChangeAuthConfig: func.isRequired, + authConfig: shape({ + superAdminNewUsers: bool, + }), +} + +export default AllUsersTableHeader diff --git a/ui/src/admin/components/chronograf/AllUsersTableRow.js b/ui/src/admin/components/chronograf/AllUsersTableRow.js new file mode 100644 index 000000000..5c8d5cbbe --- /dev/null +++ b/ui/src/admin/components/chronograf/AllUsersTableRow.js @@ -0,0 +1,114 @@ +import React, {PropTypes} from 'react' + +import Tags from 'shared/components/Tags' +import SlideToggle from 'shared/components/SlideToggle' +import ConfirmButton from 'shared/components/ConfirmButton' + +import {ALL_USERS_TABLE} from 'src/admin/constants/chronografTableSizing' +const { + colOrganizations, + colProvider, + colScheme, + colSuperAdmin, + colActions, +} = ALL_USERS_TABLE + +const AllUsersTableRow = ({ + organizations, + user, + onAddToOrganization, + onRemoveFromOrganization, + onChangeSuperAdmin, + onDelete, + meID, +}) => { + const dropdownOrganizationsItems = organizations + .filter(o => !user.roles.find(role => role.organization === o.id)) + .map(o => ({ + ...o, + text: o.name, + })) + + const userIsMe = user.id === meID + + const userOrganizations = user.roles.map(r => ({ + ...r, + name: organizations.find(o => r.organization === o.id).name, + })) + + const wrappedDelete = () => onDelete(user) + + const removeWarning = userIsMe + ? 'Delete your user record\nand log yourself out?' + : 'Delete this user?' + + return ( + + + {userIsMe + ? + + {user.name} + + : + {user.name} + } + + + + + + {user.provider} + + + {user.scheme} + + + + + + + + + ) +} + +const {arrayOf, func, shape, string} = PropTypes + +AllUsersTableRow.propTypes = { + user: shape(), + organization: shape({ + name: string.isRequired, + id: string.isRequired, + }), + onAddToOrganization: func.isRequired, + onRemoveFromOrganization: func.isRequired, + onChangeSuperAdmin: func.isRequired, + onDelete: func.isRequired, + meID: string.isRequired, + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + }) + ), +} + +export default AllUsersTableRow diff --git a/ui/src/admin/components/chronograf/AllUsersTableRowNew.js b/ui/src/admin/components/chronograf/AllUsersTableRowNew.js new file mode 100644 index 000000000..b20d53d19 --- /dev/null +++ b/ui/src/admin/components/chronograf/AllUsersTableRowNew.js @@ -0,0 +1,183 @@ +import React, {Component, PropTypes} from 'react' + +import Dropdown from 'shared/components/Dropdown' + +import {ALL_USERS_TABLE} from 'src/admin/constants/chronografTableSizing' +const { + colOrganizations, + colProvider, + colScheme, + colSuperAdmin, + colActions, +} = ALL_USERS_TABLE + +const nullOrganization = {id: undefined, name: 'None'} +const nullRole = {name: '*', organization: undefined} + +class AllUsersTableRowNew extends Component { + constructor(props) { + super(props) + + this.state = { + name: '', + provider: '', + scheme: 'oauth2', + role: { + ...nullRole, + }, + } + } + + handleInputChange = fieldName => e => { + this.setState({[fieldName]: e.target.value.trim()}) + } + + handleConfirmCreateUser = () => { + const {onBlur, onCreateUser} = this.props + const {name, provider, scheme, role, superAdmin} = this.state + const newUser = { + name, + provider, + scheme, + superAdmin, + // since you can only choose one organization, there is only one role in a new row + // if no organization is selected ie the "None" organization, + // then set roles to an empty array + roles: role.organization === undefined ? [] : [role], + } + onCreateUser(newUser) + onBlur() + } + + handleInputFocus = e => { + e.target.select() + } + + handleSelectOrganization = newOrganization => { + // if "None" was selected for organization, create a "null role" from the predefined null role + // else create a new role with the organization as the newOrganization's id + const newRole = + newOrganization.id === undefined + ? { + ...nullRole, + } + : { + organization: newOrganization.id, + name: '*', // '*' causes the server to determine the current defaultRole of the selected organization + } + this.setState({role: newRole}) + } + + handleKeyDown = e => { + const {name, provider} = this.state + const preventCreate = !name || !provider + + if (e.key === 'Escape') { + this.props.onBlur() + } + + if (e.key === 'Enter') { + if (preventCreate) { + return this.props.notify( + 'warning', + 'User must have a name and provider' + ) + } + this.handleConfirmCreateUser() + } + } + + render() { + const {organizations, onBlur} = this.props + const {name, provider, scheme, role} = this.state + + const dropdownOrganizationsItems = [ + {...nullOrganization}, + ...organizations, + ].map(o => ({ + ...o, + text: o.name, + })) + const selectedRole = dropdownOrganizationsItems.find( + o => role.organization === o.id + ) + + const preventCreate = !name || !provider + + return ( + + + + + + + + + + + + + + + — + + + + + + + ) + } +} + +const {arrayOf, func, shape, string} = PropTypes + +AllUsersTableRowNew.propTypes = { + organizations: arrayOf( + shape({ + id: string.isRequired, + name: string.isRequired, + }) + ), + onBlur: func.isRequired, + onCreateUser: func.isRequired, + notify: func.isRequired, +} + +export default AllUsersTableRowNew diff --git a/ui/src/admin/components/chronograf/EmptyUsersTable.js b/ui/src/admin/components/chronograf/EmptyUsersTable.js deleted file mode 100644 index 3ded5aea4..000000000 --- a/ui/src/admin/components/chronograf/EmptyUsersTable.js +++ /dev/null @@ -1,46 +0,0 @@ -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 272d57487..301093af0 100644 --- a/ui/src/admin/components/chronograf/OrganizationsTable.js +++ b/ui/src/admin/components/chronograf/OrganizationsTable.js @@ -2,12 +2,9 @@ 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' @@ -42,8 +39,6 @@ class OrganizationsTable extends Component { onChooseDefaultRole, onTogglePublic, currentOrganization, - authConfig: {superAdminNewUsers}, - onChangeAuthConfig, } = this.props const {isCreatingOrganization} = this.state @@ -52,6 +47,15 @@ class OrganizationsTable extends Component { ? '' : 's'}` + if (!organizations.length) { + return ( +
+
+
+
+
+ ) + } return (
@@ -94,35 +98,13 @@ class OrganizationsTable extends Component { currentOrganization={currentOrganization} /> )} - - - - - - - - - - - - - -
Config -
- - All new users are SuperAdmins
-
) } } -const {arrayOf, bool, func, shape, string} = PropTypes +const {arrayOf, func, shape, string} = PropTypes OrganizationsTable.propTypes = { organizations: arrayOf( @@ -140,9 +122,5 @@ 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/UsersTable.js b/ui/src/admin/components/chronograf/UsersTable.js index 9da6c5d8e..d0f026cee 100644 --- a/ui/src/admin/components/chronograf/UsersTable.js +++ b/ui/src/admin/components/chronograf/UsersTable.js @@ -2,8 +2,6 @@ import React, {Component, PropTypes} from 'react' import uuid from 'node-uuid' -import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' - import UsersTableHeader from 'src/admin/components/chronograf/UsersTableHeader' import UsersTableRowNew from 'src/admin/components/chronograf/UsersTableRowNew' import UsersTableRow from 'src/admin/components/chronograf/UsersTableRow' @@ -23,10 +21,6 @@ class UsersTable extends Component { this.props.onUpdateUserRole(user, currentRole, newRole) } - handleChangeSuperAdmin = user => newStatus => { - this.props.onUpdateUserSuperAdmin(user, newStatus) - } - handleDeleteUser = user => { this.props.onDeleteUser(user) } @@ -40,17 +34,27 @@ class UsersTable extends Component { } render() { - const {organization, users, onCreateUser, meID, notify} = this.props + const { + organization, + users, + onCreateUser, + meID, + notify, + isLoading, + } = this.props const {isCreatingUser} = this.state - const { - colRole, - colSuperAdmin, - colProvider, - colScheme, - colActions, - } = USERS_TABLE + const {colRole, colProvider, colScheme, colActions} = USERS_TABLE + if (isLoading) { + return ( +
+
+
+
+
+ ) + } return (
Role - - - SuperAdmin - - Provider Scheme @@ -86,31 +85,21 @@ class UsersTable extends Component { notify={notify} /> : null} - {users.length || !isCreatingUser + {users.length ? users.map(user => ) : - -

No Users to display

- - } - > - -

No Users to display

- -
+ +

No Users to display

+ } @@ -138,7 +127,6 @@ UsersTable.propTypes = { }) ), scheme: string.isRequired, - superAdmin: bool, }) ).isRequired, organization: shape({ @@ -147,10 +135,10 @@ UsersTable.propTypes = { }), onCreateUser: func.isRequired, onUpdateUserRole: func.isRequired, - onUpdateUserSuperAdmin: func.isRequired, onDeleteUser: func.isRequired, meID: string.isRequired, notify: func.isRequired, + isLoading: bool.isRequired, } export default UsersTable diff --git a/ui/src/admin/components/chronograf/UsersTableHeader.js b/ui/src/admin/components/chronograf/UsersTableHeader.js index 48179cf15..608a61fbc 100644 --- a/ui/src/admin/components/chronograf/UsersTableHeader.js +++ b/ui/src/admin/components/chronograf/UsersTableHeader.js @@ -26,7 +26,7 @@ class UsersTableHeader extends Component { disabled={isCreatingUser || !onClickCreateUser} > - Create User + Add User
) diff --git a/ui/src/admin/components/chronograf/UsersTableRow.js b/ui/src/admin/components/chronograf/UsersTableRow.js index 561ca9968..ca02b56d6 100644 --- a/ui/src/admin/components/chronograf/UsersTableRow.js +++ b/ui/src/admin/components/chronograf/UsersTableRow.js @@ -1,9 +1,6 @@ import React, {PropTypes} from 'react' -import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' - import Dropdown from 'shared/components/Dropdown' -import SlideToggle from 'shared/components/SlideToggle' import DeleteConfirmTableCell from 'shared/components/DeleteConfirmTableCell' import {USER_ROLES} from 'src/admin/constants/chronografAdmin' @@ -13,11 +10,10 @@ const UsersTableRow = ({ user, organization, onChangeUserRole, - onChangeSuperAdmin, onDelete, meID, }) => { - const {colRole, colSuperAdmin, colProvider, colScheme} = USERS_TABLE + const {colRole, colProvider, colScheme} = USERS_TABLE const dropdownRolesItems = USER_ROLES.map(r => ({ ...r, @@ -53,16 +49,6 @@ const UsersTableRow = ({ /> - - - - - {user.provider} @@ -89,7 +75,6 @@ UsersTableRow.propTypes = { id: string.isRequired, }), onChangeUserRole: func.isRequired, - onChangeSuperAdmin: func.isRequired, onDelete: func.isRequired, meID: string.isRequired, } diff --git a/ui/src/admin/components/chronograf/UsersTableRowNew.js b/ui/src/admin/components/chronograf/UsersTableRowNew.js index 7b63c3e46..4af835e08 100644 --- a/ui/src/admin/components/chronograf/UsersTableRowNew.js +++ b/ui/src/admin/components/chronograf/UsersTableRowNew.js @@ -1,7 +1,5 @@ import React, {Component, PropTypes} from 'react' -import Authorized, {SUPERADMIN_ROLE} from 'src/auth/Authorized' - import Dropdown from 'shared/components/Dropdown' import {USERS_TABLE} from 'src/admin/constants/chronografTableSizing' @@ -25,13 +23,12 @@ class UsersTableRowNew extends Component { handleConfirmCreateUser = () => { const {onBlur, onCreateUser, organization} = this.props - const {name, provider, scheme, role, superAdmin} = this.state + const {name, provider, scheme, role} = this.state const newUser = { name, provider, scheme, - superAdmin, roles: [ { name: role, @@ -72,13 +69,7 @@ class UsersTableRowNew extends Component { } render() { - const { - colRole, - colProvider, - colScheme, - colSuperAdmin, - colActions, - } = USERS_TABLE + const {colRole, colProvider, colScheme, colActions} = USERS_TABLE const {onBlur} = this.props const {name, provider, scheme, role} = this.state @@ -108,11 +99,6 @@ class UsersTableRowNew extends Component { className="dropdown-stretch" /> - - - — - - { + const {links, actionsAdmin: {createUserAsync}} = this.props + createUserAsync(links.allUsers, user) + } + + handleUpdateUserRoles = (user, roles, successMessage) => { + const {actionsAdmin: {updateUserAsync}} = this.props + const updatedUser = {...user, roles} + updateUserAsync(user, updatedUser, successMessage) + } + + handleUpdateUserSuperAdmin = (user, superAdmin) => { + const {actionsAdmin: {updateUserAsync}} = this.props + const updatedUser = {...user, superAdmin} + updateUserAsync( + user, + updatedUser, + `${user.name}'s SuperAdmin status has been updated` + ) + } + + handleDeleteUser = user => { + const {actionsAdmin: {deleteUserAsync}} = this.props + deleteUserAsync(user, {isAbsoluteDelete: true}) + } + + async componentWillMount() { + const { + links, + actionsAdmin: {loadOrganizationsAsync, loadUsersAsync}, + } = this.props + + this.setState({isLoading: true}) + + await Promise.all([ + loadOrganizationsAsync(links.organizations), + loadUsersAsync(links.allUsers), + ]) + + this.setState({isLoading: false}) + } + + render() { + const { + organizations, + meID, + users, + authConfig, + actionsConfig, + links, + notify, + } = this.props + + return ( + + ) + } +} + +const {arrayOf, bool, func, shape, string} = PropTypes + +AllUsersPage.propTypes = { + links: shape({ + users: string.isRequired, + config: shape({ + auth: string.isRequired, + }).isRequired, + }), + meID: string.isRequired, + users: arrayOf(shape), + organizations: arrayOf(shape), + actionsAdmin: shape({ + loadUsersAsync: func.isRequired, + loadOrganizationsAsync: func.isRequired, + createUserAsync: func.isRequired, + updateUserAsync: func.isRequired, + deleteUserAsync: func.isRequired, + }), + actionsConfig: shape({ + getAuthConfigAsync: func.isRequired, + updateAuthConfigAsync: func.isRequired, + }), + authConfig: shape({ + superAdminNewUsers: bool, + }), + notify: func.isRequired, +} + +const mapStateToProps = ({ + links, + adminChronograf: {organizations, users}, + config: {auth: authConfig}, +}) => ({ + links, + organizations, + users, + authConfig, +}) + +const mapDispatchToProps = dispatch => ({ + actionsAdmin: bindActionCreators(adminChronografActionCreators, dispatch), + actionsConfig: bindActionCreators(configActionCreators, dispatch), + notify: bindActionCreators(publishAutoDismissingNotification, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(AllUsersPage) diff --git a/ui/src/admin/containers/chronograf/OrganizationsPage.js b/ui/src/admin/containers/chronograf/OrganizationsPage.js index 1324b6045..e9a6b3da3 100644 --- a/ui/src/admin/containers/chronograf/OrganizationsPage.js +++ b/ui/src/admin/containers/chronograf/OrganizationsPage.js @@ -3,20 +3,14 @@ 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 + const {links, actionsAdmin: {loadOrganizationsAsync}} = this.props loadOrganizationsAsync(links.organizations) - getAuthConfigAsync(links.config.auth) } handleCreateOrganization = async organization => { @@ -57,44 +51,29 @@ class OrganizationsPage extends Component { 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 {meCurrentOrganization, organizations, me} = this.props const organization = organizations.find( o => o.id === meCurrentOrganization.id ) - return organizations.length - ? - :
+ return ( + + ) } } -const {arrayOf, bool, func, shape, string} = PropTypes +const {arrayOf, func, shape, string} = PropTypes OrganizationsPage.propTypes = { links: shape({ @@ -116,18 +95,11 @@ OrganizationsPage.propTypes = { 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({ @@ -142,18 +114,15 @@ OrganizationsPage.propTypes = { 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), }) diff --git a/ui/src/admin/containers/chronograf/UsersPage.js b/ui/src/admin/containers/chronograf/UsersPage.js index a8d21a051..986deadde 100644 --- a/ui/src/admin/containers/chronograf/UsersPage.js +++ b/ui/src/admin/containers/chronograf/UsersPage.js @@ -5,7 +5,6 @@ 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 { @@ -28,18 +27,16 @@ class UsersPage extends Component { 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) + updateUserAsync( + user, + {...user, roles: newRoles}, + `${user.name} is now a ${name}` + ) } handleDeleteUser = user => { const {actions: {deleteUserAsync}} = this.props - deleteUserAsync(user) + deleteUserAsync(user, {isAbsoluteDelete: false}) } async componentWillMount() { @@ -68,10 +65,6 @@ class UsersPage extends Component { } = this.props const {isLoading} = this.state - if (isLoading) { - return - } - const organization = organizations.find( o => o.id === meCurrentOrganization.id ) @@ -83,9 +76,9 @@ class UsersPage extends Component { organization={organization} onCreateUser={this.handleCreateUser} onUpdateUserRole={this.handleUpdateUserRole} - onUpdateUserSuperAdmin={this.handleUpdateUserSuperAdmin} onDeleteUser={this.handleDeleteUser} notify={notify} + isLoading={isLoading} /> ) } diff --git a/ui/src/admin/reducers/chronograf.js b/ui/src/admin/reducers/chronograf.js index 621f7a7a4..8f465cb45 100644 --- a/ui/src/admin/reducers/chronograf.js +++ b/ui/src/admin/reducers/chronograf.js @@ -4,7 +4,7 @@ const initialState = { users: [], organizations: [], authConfig: { - superAdminNewUsers: true, + superAdminNewUsers: false, }, } @@ -20,7 +20,7 @@ const adminChronograf = (state = initialState, action) => { case 'CHRONOGRAF_ADD_USER': { const {user} = action.payload - return {...state, users: [user, ...state.users]} + return {...state, users: [...state.users, user]} } case 'CHRONOGRAF_UPDATE_USER': { diff --git a/ui/src/dashboards/components/CellEditorOverlay.js b/ui/src/dashboards/components/CellEditorOverlay.js index c3bdb55a1..8432c8f31 100644 --- a/ui/src/dashboards/components/CellEditorOverlay.js +++ b/ui/src/dashboards/components/CellEditorOverlay.js @@ -76,6 +76,10 @@ class CellEditorOverlay extends Component { } } + componentDidMount = () => { + this.overlayRef.focus() + } + handleAddThreshold = () => { const {colors, cellWorkingType} = this.state const sortedColors = _.sortBy(colors, color => Number(color.value)) @@ -442,6 +446,23 @@ class CellEditorOverlay extends Component { return prevQuery.source } + handleKeyDown = e => { + if (e.key === 'Enter' && e.metaKey && e.target === this.overlayRef) { + this.handleSaveCell() + } + if (e.key === 'Enter' && e.metaKey && e.target !== this.overlayRef) { + e.target.blur() + setTimeout(this.handleSaveCell, 50) + } + if (e.key === 'Escape' && e.target === this.overlayRef) { + this.props.onCancel() + } + if (e.key === 'Escape' && e.target !== this.overlayRef) { + e.target.blur() + this.overlayRef.focus() + } + } + render() { const { onCancel, @@ -472,7 +493,12 @@ class CellEditorOverlay extends Component { !!query.rawText return ( -
+
(this.overlayRef = r)} + > { - this.setState({hostsLoading: true}) - resolve() - }), - ]) - - hosts = h - layouts = data.layouts - - this.setState({ - hosts, - hostsLoading: false, - }) - } 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, - }) - } - + const hostsError = 'Unable to get hosts' try { + const hosts = await getCpuAndLoadForHosts( + source.links.proxy, + source.telegraf, + telegrafSystemInterval + ) + if (!hosts) { + throw new Error(hostsError) + } const newHosts = await getAppsForHosts( source.links.proxy, hosts, - layouts, + this.layouts, source.telegraf ) + this.setState({ hosts: newHosts, hostsError: '', @@ -87,8 +58,50 @@ class HostsPage extends Component { } } + async componentDidMount() { + const {addFlashMessage, autoRefresh} = this.props + + this.setState({hostsLoading: true}) // Only print this once + const {data} = await getLayouts() + this.layouts = data.layouts + if (!this.layouts) { + const layoutError = 'Unable to get apps for hosts' + addFlashMessage({type: 'error', text: layoutError}) + this.setState({ + hostsError: layoutError, + hostsLoading: false, + }) + return + } + await this.fetchHostsData() + if (autoRefresh) { + this.intervalID = setInterval(() => this.fetchHostsData(), autoRefresh) + } + } + + componentWillReceiveProps(nextProps) { + if (this.props.manualRefresh !== nextProps.manualRefresh) { + this.fetchHostsData() + } + if (this.props.autoRefresh !== nextProps.autoRefresh) { + clearInterval(this.intervalID) + + if (nextProps.autoRefresh) { + this.intervalID = setInterval( + () => this.fetchHostsData(), + nextProps.autoRefresh + ) + } + } + } + render() { - const {source} = this.props + const { + source, + autoRefresh, + onChooseAutoRefresh, + onManualRefresh, + } = this.props const {hosts, hostsLoading, hostsError} = this.state return (
@@ -99,6 +112,12 @@ class HostsPage extends Component {
+
@@ -119,13 +138,20 @@ class HostsPage extends Component {
) } + + componentWillUnmount() { + clearInterval(this.intervalID) + this.intervalID = false + } } -const {func, shape, string} = PropTypes +const {func, shape, string, number} = PropTypes -const mapStateToProps = ({links}) => { +const mapStateToProps = state => { + const {app: {persisted: {autoRefresh}}, links} = state return { links, + autoRefresh, } } @@ -143,6 +169,20 @@ HostsPage.propTypes = { environment: string.isRequired, }), addFlashMessage: func, + autoRefresh: number.isRequired, + manualRefresh: number, + onChooseAutoRefresh: func.isRequired, + onManualRefresh: func.isRequired, } -export default connect(mapStateToProps, null)(HostsPage) +HostsPage.defaultProps = { + manualRefresh: 0, +} + +const mapDispatchToProps = dispatch => ({ + onChooseAutoRefresh: bindActionCreators(setAutoRefresh, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)( + ManualRefresh(HostsPage) +) diff --git a/ui/src/index.js b/ui/src/index.js index 287d24a73..3370a1ce5 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -35,6 +35,7 @@ import {AdminChronografPage, AdminInfluxDBPage} from 'src/admin' import {SourcePage, ManageSources} from 'src/sources' import NotFound from 'shared/components/NotFound' +import {getLinksAsync} from 'shared/actions/links' import {getMeAsync} from 'shared/actions/auth' import {disablePresentationMode} from 'shared/actions/app' @@ -73,11 +74,20 @@ window.addEventListener('keyup', event => { const history = syncHistoryWithStore(browserHistory, store) const Root = React.createClass({ - componentWillMount() { + async componentWillMount() { this.flushErrorsQueue() - this.checkAuth() + + try { + await this.getLinks() + this.checkAuth() + } catch (error) { + dispatch(errorThrown(error)) + } }, + getLinks: bindActionCreators(getLinksAsync, dispatch), + getMe: bindActionCreators(getMeAsync, dispatch), + async checkAuth() { try { await this.performHeartbeat({shouldResetMe: true}) @@ -86,8 +96,6 @@ const Root = React.createClass({ } }, - getMe: bindActionCreators(getMeAsync, dispatch), - async performHeartbeat({shouldResetMe = false} = {}) { await this.getMe({shouldResetMe}) @@ -133,6 +141,10 @@ const Root = React.createClass({ + diff --git a/ui/src/kapacitor/actions/view/index.js b/ui/src/kapacitor/actions/view/index.js index bba7b407f..bbeef0d8c 100644 --- a/ui/src/kapacitor/actions/view/index.js +++ b/ui/src/kapacitor/actions/view/index.js @@ -195,16 +195,10 @@ export const updateRuleStatus = (rule, status) => dispatch => { }) } -export const createTask = ( - kapacitor, - task, - router, - sourceID -) => async dispatch => { +export const createTask = (kapacitor, task) => async dispatch => { try { const {data} = await createTaskAJAX(kapacitor, task) - router.push(`/sources/${sourceID}/alert-rules`) - dispatch(publishNotification('success', 'You made a TICKscript!')) + dispatch(publishNotification('success', 'TICKscript successfully created')) return data } catch (error) { if (!error) { @@ -220,20 +214,17 @@ export const updateTask = ( kapacitor, task, ruleID, - router, sourceID ) => async dispatch => { try { const {data} = await updateTaskAJAX(kapacitor, task, ruleID, sourceID) - router.push(`/sources/${sourceID}/alert-rules`) - dispatch(publishNotification('success', 'TICKscript updated successully')) + dispatch(publishNotification('success', 'TICKscript saved')) return data } catch (error) { if (!error) { dispatch(errorThrown('Could not communicate with server')) return } - return error.data } } diff --git a/ui/src/kapacitor/components/AlertTabs.js b/ui/src/kapacitor/components/AlertTabs.js index 58467c486..6aac73d01 100644 --- a/ui/src/kapacitor/components/AlertTabs.js +++ b/ui/src/kapacitor/components/AlertTabs.js @@ -2,7 +2,11 @@ import React, {Component, PropTypes} from 'react' import _ from 'lodash' import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'shared/components/Tabs' -import {getKapacitorConfig, updateKapacitorConfigSection} from 'shared/apis' +import { + getKapacitorConfig, + updateKapacitorConfigSection, + testAlertOutput, +} from 'shared/apis' import { AlertaConfig, @@ -23,7 +27,6 @@ class AlertTabs extends Component { super(props) this.state = { - selectedHandler: 'smtp', configSections: null, } } @@ -38,18 +41,17 @@ class AlertTabs extends Component { } } - refreshKapacitorConfig = kapacitor => { - getKapacitorConfig(kapacitor) - .then(({data: {sections}}) => { - this.setState({configSections: sections}) - }) - .catch(() => { - this.setState({configSections: null}) - this.props.addFlashMessage({ - type: 'error', - text: 'There was an error getting the Kapacitor config', - }) + refreshKapacitorConfig = async kapacitor => { + try { + const {data: {sections}} = await getKapacitorConfig(kapacitor) + this.setState({configSections: sections}) + } catch (error) { + this.setState({configSections: null}) + this.props.addFlashMessage({ + type: 'error', + text: 'There was an error getting the Kapacitor config', }) + } } getSection = (sections, section) => { @@ -68,23 +70,53 @@ class AlertTabs extends Component { return this.getSection(sections, section) } - handleSaveConfig = section => properties => { + handleSaveConfig = section => async properties => { if (section !== '') { const propsToSend = this.sanitizeProperties(section, properties) - updateKapacitorConfigSection(this.props.kapacitor, section, propsToSend) - .then(() => { - this.refreshKapacitorConfig(this.props.kapacitor) - this.props.addFlashMessage({ - type: 'success', - text: `Alert for ${section} successfully saved`, - }) + try { + await updateKapacitorConfigSection( + this.props.kapacitor, + section, + propsToSend + ) + this.refreshKapacitorConfig(this.props.kapacitor) + this.props.addFlashMessage({ + type: 'success', + text: `Alert configuration for ${section} successfully saved.`, }) - .catch(() => { - this.props.addFlashMessage({ - type: 'error', - text: 'There was an error saving the kapacitor config', - }) + return true + } catch ({data: {error}}) { + const errorMsg = _.join(_.drop(_.split(error, ': '), 2), ': ') + this.props.addFlashMessage({ + type: 'error', + text: `There was an error saving the alert configuration for ${section}: ${errorMsg}`, }) + return false + } + } + } + + handleTestConfig = section => async e => { + e.preventDefault() + + try { + const {data} = await testAlertOutput(this.props.kapacitor, section) + if (data.success) { + this.props.addFlashMessage({ + type: 'success', + text: `Successfully triggered an alert to ${section}. If the alert does not reach its destination, please check your configuration settings.`, + }) + } else { + this.props.addFlashMessage({ + type: 'error', + text: `There was an error sending an alert to ${section}: ${data.message}`, + }) + } + } catch (error) { + this.props.addFlashMessage({ + type: 'error', + text: `There was an error sending an alert to ${section}.`, + }) } } @@ -102,8 +134,14 @@ class AlertTabs extends Component { return cleanProps } + getInitialIndex = (supportedConfigs, hash) => { + const index = _.indexOf(_.keys(supportedConfigs), _.replace(hash, '#', '')) + return index >= 0 ? index : 0 + } + render() { const {configSections} = this.state + const {hash} = this.props if (!configSections) { return null @@ -116,6 +154,8 @@ class AlertTabs extends Component { , }, hipchat: { @@ -125,6 +165,8 @@ class AlertTabs extends Component { , }, opsgenie: { @@ -134,6 +176,8 @@ class AlertTabs extends Component { , }, pagerduty: { @@ -143,6 +187,8 @@ class AlertTabs extends Component { , }, pushover: { @@ -152,6 +198,8 @@ class AlertTabs extends Component { , }, sensu: { @@ -161,6 +209,8 @@ class AlertTabs extends Component { , }, slack: { @@ -170,6 +220,8 @@ class AlertTabs extends Component { , }, smtp: { @@ -179,6 +231,8 @@ class AlertTabs extends Component { , }, talk: { @@ -188,6 +242,8 @@ class AlertTabs extends Component { , }, telegram: { @@ -197,6 +253,8 @@ class AlertTabs extends Component { , }, victorops: { @@ -206,10 +264,11 @@ class AlertTabs extends Component { , }, } - return (
@@ -218,7 +277,10 @@ class AlertTabs extends Component {
- + {_.reduce( configSections, @@ -269,6 +331,7 @@ AlertTabs.propTypes = { }).isRequired, }), addFlashMessage: func.isRequired, + hash: string.isRequired, } export default AlertTabs diff --git a/ui/src/kapacitor/components/HandlerOptions.js b/ui/src/kapacitor/components/HandlerOptions.js index 9a8861326..5b2c6fdae 100644 --- a/ui/src/kapacitor/components/HandlerOptions.js +++ b/ui/src/kapacitor/components/HandlerOptions.js @@ -65,7 +65,7 @@ class HandlerOptions extends Component { ) @@ -85,7 +85,7 @@ class HandlerOptions extends Component { ) @@ -94,7 +94,7 @@ class HandlerOptions extends Component { ) @@ -103,7 +103,7 @@ class HandlerOptions extends Component { ) @@ -112,7 +112,7 @@ class HandlerOptions extends Component { ) @@ -121,7 +121,7 @@ class HandlerOptions extends Component { ) @@ -130,7 +130,7 @@ class HandlerOptions extends Component { ) @@ -139,7 +139,7 @@ class HandlerOptions extends Component { ) @@ -148,7 +148,7 @@ class HandlerOptions extends Component { ) @@ -157,7 +157,7 @@ class HandlerOptions extends Component { ) diff --git a/ui/src/kapacitor/components/KapacitorForm.js b/ui/src/kapacitor/components/KapacitorForm.js index 55a3cbb88..c98326a4c 100644 --- a/ui/src/kapacitor/components/KapacitorForm.js +++ b/ui/src/kapacitor/components/KapacitorForm.js @@ -6,14 +6,15 @@ import FancyScrollbar from 'shared/components/FancyScrollbar' class KapacitorForm extends Component { render() { const {onInputChange, onReset, kapacitor, onSubmit, exists} = this.props - const {url, name, username, password} = kapacitor - + const {url: kapaUrl, name, username, password} = kapacitor return (
-

Configure Kapacitor

+

{`${exists + ? 'Configure' + : 'Add a New'} Kapacitor Connection`}

@@ -29,13 +30,13 @@ class KapacitorForm extends Component {
- + @@ -60,7 +61,7 @@ class KapacitorForm extends Component { id="username" name="username" placeholder="username" - value={username} + value={username || ''} onChange={onInputChange} spellCheck="false" /> @@ -73,7 +74,7 @@ class KapacitorForm extends Component { type="password" name="password" placeholder="password" - value={password} + value={password || ''} onChange={onInputChange} spellCheck="false" /> @@ -108,7 +109,7 @@ class KapacitorForm extends Component { // TODO: move these to another page. they dont belong on this page renderAlertOutputs() { - const {exists, kapacitor, addFlashMessage, source} = this.props + const {exists, kapacitor, addFlashMessage, source, hash} = this.props if (exists) { return ( @@ -116,6 +117,7 @@ class KapacitorForm extends Component { source={source} kapacitor={kapacitor} addFlashMessage={addFlashMessage} + hash={hash} /> ) } @@ -153,6 +155,7 @@ KapacitorForm.propTypes = { source: shape({}).isRequired, addFlashMessage: func.isRequired, exists: bool.isRequired, + hash: string.isRequired, } export default KapacitorForm diff --git a/ui/src/kapacitor/components/KapacitorRule.js b/ui/src/kapacitor/components/KapacitorRule.js index bde83ec22..af6d48d9f 100644 --- a/ui/src/kapacitor/components/KapacitorRule.js +++ b/ui/src/kapacitor/components/KapacitorRule.js @@ -25,7 +25,7 @@ class KapacitorRule extends Component { this.setState({timeRange}) } - handleCreate = link => { + handleCreate = pathname => { const { addFlashMessage, queryConfigs, @@ -42,7 +42,7 @@ class KapacitorRule extends Component { createRule(kapacitor, newRule) .then(() => { - router.push(link || `/sources/${source.id}/alert-rules`) + router.push(pathname || `/sources/${source.id}/alert-rules`) addFlashMessage({type: 'success', text: 'Rule successfully created'}) }) .catch(() => { @@ -53,7 +53,7 @@ class KapacitorRule extends Component { }) } - handleEdit = link => { + handleEdit = pathname => { const {addFlashMessage, queryConfigs, rule, router, source} = this.props const updatedRule = Object.assign({}, rule, { query: queryConfigs[rule.queryID], @@ -61,7 +61,7 @@ class KapacitorRule extends Component { editRule(updatedRule) .then(() => { - router.push(link || `/sources/${source.id}/alert-rules`) + router.push(pathname || `/sources/${source.id}/alert-rules`) addFlashMessage({ type: 'success', text: `${rule.name} successfully saved!`, @@ -75,14 +75,28 @@ class KapacitorRule extends Component { }) } - handleSaveToConfig = () => { - const {rule, configLink, router} = this.props - if (this.validationError()) { - router.push(configLink) - } else if (rule.id === DEFAULT_RULE_ID) { - this.handleCreate(configLink) + handleSave = () => { + const {rule} = this.props + if (rule.id === DEFAULT_RULE_ID) { + this.handleCreate() } else { - this.handleEdit(configLink) + this.handleEdit() + } + } + + handleSaveToConfig = configName => () => { + const {rule, configLink, router} = this.props + const pathname = `${configLink}#${configName}` + if (this.validationError()) { + router.push({ + pathname, + }) + return + } + if (rule.id === DEFAULT_RULE_ID) { + this.handleCreate(pathname) + } else { + this.handleEdit(pathname) } } @@ -157,13 +171,12 @@ class KapacitorRule extends Component { } = this.props const {chooseTrigger, updateRuleValues} = ruleActions const {timeRange} = this.state + return (
diff --git a/ui/src/kapacitor/components/LogsToggle.js b/ui/src/kapacitor/components/LogsToggle.js index eedddafbf..9820b621b 100644 --- a/ui/src/kapacitor/components/LogsToggle.js +++ b/ui/src/kapacitor/components/LogsToggle.js @@ -1,16 +1,16 @@ import React, {PropTypes} from 'react' -const LogsToggle = ({areLogsVisible, onToggleLogsVisbility}) => +const LogsToggle = ({areLogsVisible, onToggleLogsVisibility}) =>
  • Editor
  • Editor + Logs
  • @@ -20,7 +20,7 @@ const {bool, func} = PropTypes LogsToggle.propTypes = { areLogsVisible: bool, - onToggleLogsVisbility: func.isRequired, + onToggleLogsVisibility: func.isRequired, } export default LogsToggle diff --git a/ui/src/kapacitor/components/Tickscript.js b/ui/src/kapacitor/components/Tickscript.js index 2a54dd093..8cec26829 100644 --- a/ui/src/kapacitor/components/Tickscript.js +++ b/ui/src/kapacitor/components/Tickscript.js @@ -8,25 +8,29 @@ import LogsTable from 'src/kapacitor/components/LogsTable' const Tickscript = ({ onSave, + onExit, task, logs, - validation, + consoleMessage, onSelectDbrps, onChangeScript, onChangeType, onChangeID, + unsavedChanges, isNewTickscript, areLogsVisible, areLogsEnabled, - onToggleLogsVisbility, + onToggleLogsVisibility, }) =>
    @@ -38,11 +42,14 @@ const Tickscript = ({ onChangeID={onChangeID} task={task} /> - +
    {areLogsVisible ? : null}
    @@ -53,12 +60,13 @@ const {arrayOf, bool, func, shape, string} = PropTypes Tickscript.propTypes = { logs: arrayOf(shape()).isRequired, onSave: func.isRequired, + onExit: func.isRequired, source: shape({ id: string, }), areLogsVisible: bool, areLogsEnabled: bool, - onToggleLogsVisbility: func.isRequired, + onToggleLogsVisibility: func.isRequired, task: shape({ id: string, script: string, @@ -66,10 +74,11 @@ Tickscript.propTypes = { }).isRequired, onChangeScript: func.isRequired, onSelectDbrps: func.isRequired, - validation: string, + consoleMessage: string, onChangeType: func.isRequired, onChangeID: func.isRequired, isNewTickscript: bool.isRequired, + unsavedChanges: bool, } export default Tickscript diff --git a/ui/src/kapacitor/components/TickscriptEditorConsole.js b/ui/src/kapacitor/components/TickscriptEditorConsole.js index 5b9ccf5fd..9940bf92c 100644 --- a/ui/src/kapacitor/components/TickscriptEditorConsole.js +++ b/ui/src/kapacitor/components/TickscriptEditorConsole.js @@ -1,22 +1,31 @@ import React, {PropTypes} from 'react' -const TickscriptEditorConsole = ({validation}) => -
    -
    - {validation - ?

    - {validation} -

    - :

    - Save your TICKscript to validate it -

    } -
    -
    +const TickscriptEditorConsole = ({consoleMessage, unsavedChanges}) => { + let consoleOutput = 'TICKscript is valid' + let consoleClass = 'tickscript-console--valid' -const {string} = PropTypes + if (consoleMessage) { + consoleOutput = consoleMessage + consoleClass = 'tickscript-console--error' + } else if (unsavedChanges) { + consoleOutput = 'You have unsaved changes, save to validate TICKscript' + consoleClass = 'tickscript-console--default' + } + + return ( +
    +

    + {consoleOutput} +

    +
    + ) +} + +const {bool, string} = PropTypes TickscriptEditorConsole.propTypes = { - validation: string, + consoleMessage: string, + unsavedChanges: bool, } export default TickscriptEditorConsole diff --git a/ui/src/kapacitor/components/TickscriptHeader.js b/ui/src/kapacitor/components/TickscriptHeader.js index c830887ae..babe033d8 100644 --- a/ui/src/kapacitor/components/TickscriptHeader.js +++ b/ui/src/kapacitor/components/TickscriptHeader.js @@ -2,14 +2,17 @@ import React, {PropTypes} from 'react' import SourceIndicator from 'shared/components/SourceIndicator' import LogsToggle from 'src/kapacitor/components/LogsToggle' +import ConfirmButton from 'src/shared/components/ConfirmButton' const TickscriptHeader = ({ task: {id}, onSave, + onExit, + unsavedChanges, areLogsVisible, areLogsEnabled, isNewTickscript, - onToggleLogsVisbility, + onToggleLogsVisibility, }) =>
    @@ -20,18 +23,40 @@ const TickscriptHeader = ({ }
    - + {isNewTickscript + ? + : } + {unsavedChanges + ? + : }
    @@ -41,9 +66,10 @@ const {arrayOf, bool, func, shape, string} = PropTypes TickscriptHeader.propTypes = { isNewTickscript: bool, onSave: func, + onExit: func.isRequired, areLogsVisible: bool, areLogsEnabled: bool, - onToggleLogsVisbility: func.isRequired, + onToggleLogsVisibility: func.isRequired, task: shape({ dbrps: arrayOf( shape({ @@ -52,6 +78,7 @@ TickscriptHeader.propTypes = { }) ), }), + unsavedChanges: bool, } export default TickscriptHeader diff --git a/ui/src/kapacitor/components/config/AlertaConfig.js b/ui/src/kapacitor/components/config/AlertaConfig.js index 6a06559f8..492bc1d13 100644 --- a/ui/src/kapacitor/components/config/AlertaConfig.js +++ b/ui/src/kapacitor/components/config/AlertaConfig.js @@ -5,9 +5,12 @@ import RedactedInput from './RedactedInput' class AlertaConfig extends Component { constructor(props) { super(props) + this.state = { + testEnabled: this.props.enabled, + } } - handleSaveAlert = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -17,7 +20,14 @@ class AlertaConfig extends Component { url: this.url.value, } - this.props.onSave(properties) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } + } + + disableTest = () => { + this.setState({testEnabled: false}) } handleTokenRef = r => (this.token = r) @@ -26,7 +36,7 @@ class AlertaConfig extends Component { const {environment, origin, token, url} = this.props.config.options return ( - +
    (this.environment = r)} defaultValue={environment || ''} + onChange={this.disableTest} />
    @@ -46,6 +57,7 @@ class AlertaConfig extends Component { type="text" ref={r => (this.origin = r)} defaultValue={origin || ''} + onChange={this.disableTest} />
@@ -55,6 +67,7 @@ class AlertaConfig extends Component { defaultValue={token} id="token" refFunc={this.handleTokenRef} + disableTest={this.disableTest} />
@@ -66,12 +79,26 @@ class AlertaConfig extends Component { type="text" ref={r => (this.url = r)} defaultValue={url || ''} + onChange={this.disableTest} />
- +
@@ -91,6 +118,8 @@ AlertaConfig.propTypes = { }).isRequired, }).isRequired, onSave: func.isRequired, + onTest: func.isRequired, + enabled: bool.isRequired, } export default AlertaConfig diff --git a/ui/src/kapacitor/components/config/HipChatConfig.js b/ui/src/kapacitor/components/config/HipChatConfig.js index c8effd340..a8ebe9e54 100644 --- a/ui/src/kapacitor/components/config/HipChatConfig.js +++ b/ui/src/kapacitor/components/config/HipChatConfig.js @@ -7,9 +7,12 @@ import RedactedInput from './RedactedInput' class HipchatConfig extends Component { constructor(props) { super(props) + this.state = { + testEnabled: this.props.enabled, + } } - handleSaveAlert = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -18,7 +21,14 @@ class HipchatConfig extends Component { token: this.token.value, } - this.props.onSave(properties) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } + } + + disableTest = () => { + this.setState({testEnabled: false}) } handleTokenRef = r => (this.token = r) @@ -32,7 +42,7 @@ class HipchatConfig extends Component { .replace('.hipchat.com/v2/room', '') return ( -
+
(this.url = r)} defaultValue={subdomain && subdomain.length ? subdomain : ''} + onChange={this.disableTest} />
@@ -54,6 +65,7 @@ class HipchatConfig extends Component { placeholder="your-hipchat-room" ref={r => (this.room = r)} defaultValue={room || ''} + onChange={this.disableTest} />
@@ -66,12 +78,26 @@ class HipchatConfig extends Component { defaultValue={token} id="token" refFunc={this.handleTokenRef} + disableTest={this.disableTest} />
- +
@@ -90,6 +116,8 @@ HipchatConfig.propTypes = { }).isRequired, }).isRequired, onSave: func.isRequired, + onTest: func.isRequired, + enabled: bool.isRequired, } export default HipchatConfig diff --git a/ui/src/kapacitor/components/config/OpsGenieConfig.js b/ui/src/kapacitor/components/config/OpsGenieConfig.js index bfce371d3..db9e00c58 100644 --- a/ui/src/kapacitor/components/config/OpsGenieConfig.js +++ b/ui/src/kapacitor/components/config/OpsGenieConfig.js @@ -1,7 +1,7 @@ import React, {PropTypes, Component} from 'react' -import _ from 'lodash' import RedactedInput from './RedactedInput' +import TagInput from 'shared/components/TagInput' class OpsGenieConfig extends Component { constructor(props) { @@ -12,10 +12,11 @@ class OpsGenieConfig extends Component { this.state = { currentTeams: teams || [], currentRecipients: recipients || [], + testEnabled: this.props.enabled, } } - handleSaveAlert = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -24,7 +25,14 @@ class OpsGenieConfig extends Component { recipients: this.state.currentRecipients, } - this.props.onSave(properties) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } + } + + disableTest = () => { + this.setState({testEnabled: false}) } handleAddTeam = team => { @@ -37,13 +45,13 @@ class OpsGenieConfig extends Component { }) } - handleDeleteTeam = team => () => { + handleDeleteTeam = team => { this.setState({ currentTeams: this.state.currentTeams.filter(t => t !== team), }) } - handleDeleteRecipient = recipient => () => { + handleDeleteRecipient = recipient => { this.setState({ currentRecipients: this.state.currentRecipients.filter( r => r !== recipient @@ -59,13 +67,14 @@ class OpsGenieConfig extends Component { const {currentTeams, currentRecipients} = this.state return ( -
+
@@ -74,17 +83,32 @@ class OpsGenieConfig extends Component { onAddTag={this.handleAddTeam} onDeleteTag={this.handleDeleteTeam} tags={currentTeams} + disableTest={this.disableTest} />
- +
@@ -92,7 +116,7 @@ class OpsGenieConfig extends Component { } } -const {array, arrayOf, bool, func, shape, string} = PropTypes +const {array, bool, func, shape} = PropTypes OpsGenieConfig.propTypes = { config: shape({ @@ -103,84 +127,8 @@ OpsGenieConfig.propTypes = { }).isRequired, }).isRequired, onSave: func.isRequired, -} - -class TagInput extends Component { - constructor(props) { - super(props) - } - - handleAddTag = e => { - if (e.key === 'Enter') { - e.preventDefault() - const newItem = e.target.value.trim() - const {tags, onAddTag} = this.props - if (!this.shouldAddToList(newItem, tags)) { - return - } - - this.input.value = '' - onAddTag(newItem) - } - } - - shouldAddToList(item, tags) { - return !_.isEmpty(item) && !tags.find(l => l === item) - } - - render() { - const {title, tags, onDeleteTag} = this.props - - return ( -
- - (this.input = r)} - onKeyDown={this.handleAddTag} - /> - -
- ) - } -} - -TagInput.propTypes = { - onAddTag: func.isRequired, - onDeleteTag: func.isRequired, - tags: arrayOf(string).isRequired, - title: string.isRequired, -} - -const Tags = ({tags, onDeleteTag}) => -
- {tags.map(item => { - return - })} -
- -Tags.propTypes = { - tags: arrayOf(string), - onDeleteTag: func, -} - -const Tag = ({item, onDelete}) => - - - {item} - - - - -Tag.propTypes = { - item: string, - onDelete: func, + onTest: func.isRequired, + enabled: bool.isRequired, } export default OpsGenieConfig diff --git a/ui/src/kapacitor/components/config/PagerDutyConfig.js b/ui/src/kapacitor/components/config/PagerDutyConfig.js index 51e89136c..ecf79dbd9 100644 --- a/ui/src/kapacitor/components/config/PagerDutyConfig.js +++ b/ui/src/kapacitor/components/config/PagerDutyConfig.js @@ -4,9 +4,12 @@ import RedactedInput from './RedactedInput' class PagerDutyConfig extends Component { constructor(props) { super(props) + this.state = { + testEnabled: this.props.enabled, + } } - handleSaveAlert = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -14,7 +17,14 @@ class PagerDutyConfig extends Component { url: this.url.value, } - this.props.onSave(properties) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } + } + + disableTest = () => { + this.setState({testEnabled: false}) } render() { @@ -23,13 +33,14 @@ class PagerDutyConfig extends Component { const serviceKey = options['service-key'] const refFunc = r => (this.serviceKey = r) return ( -
+
@@ -41,12 +52,26 @@ class PagerDutyConfig extends Component { type="text" ref={r => (this.url = r)} defaultValue={url || ''} + onChange={this.disableTest} />
- +
@@ -64,6 +89,8 @@ PagerDutyConfig.propTypes = { }).isRequired, }).isRequired, onSave: func.isRequired, + onTest: func.isRequired, + enabled: bool.isRequired, } export default PagerDutyConfig diff --git a/ui/src/kapacitor/components/config/PushoverConfig.js b/ui/src/kapacitor/components/config/PushoverConfig.js index e1d17e6f6..5ad4665ac 100644 --- a/ui/src/kapacitor/components/config/PushoverConfig.js +++ b/ui/src/kapacitor/components/config/PushoverConfig.js @@ -8,9 +8,12 @@ import {PUSHOVER_DOCS_LINK} from 'src/kapacitor/copy' class PushoverConfig extends Component { constructor(props) { super(props) + this.state = { + testEnabled: this.props.enabled, + } } - handleSaveAlert = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -19,7 +22,14 @@ class PushoverConfig extends Component { 'user-key': this.userKey.value, } - this.props.onSave(properties) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } + } + + disableTest = () => { + this.setState({testEnabled: false}) } handleUserKeyRef = r => (this.userKey = r) @@ -32,7 +42,7 @@ class PushoverConfig extends Component { const userKey = options['user-key'] return ( -
+
@@ -60,6 +71,7 @@ class PushoverConfig extends Component { defaultValue={token} id="token" refFunc={this.handleTokenRef} + disableTest={this.disableTest} />
@@ -71,12 +83,26 @@ class PushoverConfig extends Component { type="text" ref={r => (this.url = r)} defaultValue={url || ''} + onChange={this.disableTest} />
- +
@@ -95,6 +121,8 @@ PushoverConfig.propTypes = { }).isRequired, }).isRequired, onSave: func.isRequired, + onTest: func.isRequired, + enabled: bool.isRequired, } export default PushoverConfig diff --git a/ui/src/kapacitor/components/config/RedactedInput.js b/ui/src/kapacitor/components/config/RedactedInput.js index b82459b15..8296be50d 100644 --- a/ui/src/kapacitor/components/config/RedactedInput.js +++ b/ui/src/kapacitor/components/config/RedactedInput.js @@ -13,7 +13,7 @@ class RedactedInput extends Component { } render() { - const {defaultValue, id, refFunc} = this.props + const {defaultValue, id, refFunc, disableTest} = this.props const {editing} = this.state if (defaultValue === true && !editing) { @@ -43,6 +43,7 @@ class RedactedInput extends Component { type="text" ref={refFunc} defaultValue={''} + onChange={disableTest} /> ) } @@ -54,6 +55,7 @@ RedactedInput.propTypes = { id: string.isRequired, defaultValue: bool, refFunc: func.isRequired, + disableTest: func, } export default RedactedInput diff --git a/ui/src/kapacitor/components/config/SMTPConfig.js b/ui/src/kapacitor/components/config/SMTPConfig.js index 48ed0407f..b77c0a111 100644 --- a/ui/src/kapacitor/components/config/SMTPConfig.js +++ b/ui/src/kapacitor/components/config/SMTPConfig.js @@ -3,27 +3,37 @@ import React, {PropTypes, Component} from 'react' class SMTPConfig extends Component { constructor(props) { super(props) + this.state = { + testEnabled: this.props.enabled, + } } - handleSaveAlert = e => { + handleSubmit = async e => { e.preventDefault() const properties = { host: this.host.value, port: this.port.value, from: this.from.value, + to: this.to.value ? [this.to.value] : [], username: this.username.value, password: this.password.value, } + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } + } - this.props.onSave(properties) + disableTest = () => { + this.setState({testEnabled: false}) } render() { - const {host, port, from, username, password} = this.props.config.options + const {host, port, from, username, password, to} = this.props.config.options return ( -
+
(this.host = r)} defaultValue={host || ''} + onChange={this.disableTest} />
@@ -43,10 +54,11 @@ class SMTPConfig extends Component { type="text" ref={r => (this.port = r)} defaultValue={port || ''} + onChange={this.disableTest} /> -
+
(this.from = r)} defaultValue={from || ''} + onChange={this.disableTest} + /> +
+ +
+ + (this.to = r)} + defaultValue={to || ''} + onChange={this.disableTest} />
@@ -66,6 +92,7 @@ class SMTPConfig extends Component { type="text" ref={r => (this.username = r)} defaultValue={username || ''} + onChange={this.disableTest} />
@@ -77,12 +104,26 @@ class SMTPConfig extends Component { type="password" ref={r => (this.password = r)} defaultValue={`${password}`} + onChange={this.disableTest} />
- +
@@ -103,6 +144,8 @@ SMTPConfig.propTypes = { }).isRequired, }).isRequired, onSave: func.isRequired, + onTest: func.isRequired, + enabled: bool.isRequired, } export default SMTPConfig diff --git a/ui/src/kapacitor/components/config/SensuConfig.js b/ui/src/kapacitor/components/config/SensuConfig.js index 9eaa34f5a..11c9626b1 100644 --- a/ui/src/kapacitor/components/config/SensuConfig.js +++ b/ui/src/kapacitor/components/config/SensuConfig.js @@ -3,9 +3,12 @@ import React, {PropTypes, Component} from 'react' class SensuConfig extends Component { constructor(props) { super(props) + this.state = { + testEnabled: this.props.enabled, + } } - handleSaveAlert = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -13,14 +16,21 @@ class SensuConfig extends Component { addr: this.addr.value, } - this.props.onSave(properties) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } + } + + disableTest = () => { + this.setState({testEnabled: false}) } render() { const {source, addr} = this.props.config.options return ( -
+
(this.source = r)} defaultValue={source || ''} + onChange={this.disableTest} />
@@ -40,12 +51,26 @@ class SensuConfig extends Component { type="text" ref={r => (this.addr = r)} defaultValue={addr || ''} + onChange={this.disableTest} />
- +
@@ -53,7 +78,7 @@ class SensuConfig extends Component { } } -const {func, shape, string} = PropTypes +const {bool, func, shape, string} = PropTypes SensuConfig.propTypes = { config: shape({ @@ -63,6 +88,8 @@ SensuConfig.propTypes = { }).isRequired, }).isRequired, onSave: func.isRequired, + onTest: func.isRequired, + enabled: bool.isRequired, } export default SensuConfig diff --git a/ui/src/kapacitor/components/config/SlackConfig.js b/ui/src/kapacitor/components/config/SlackConfig.js index 839b02cf7..e26e560a4 100644 --- a/ui/src/kapacitor/components/config/SlackConfig.js +++ b/ui/src/kapacitor/components/config/SlackConfig.js @@ -6,25 +6,23 @@ class SlackConfig extends Component { constructor(props) { super(props) this.state = { - testEnabled: !!this.props.config.options.url, + testEnabled: this.props.enabled, } } - componentWillReceiveProps(nextProps) { - this.setState({ - testEnabled: !!nextProps.config.options.url, - }) - } - - handleSaveAlert = e => { + handleSubmit = async e => { e.preventDefault() - const properties = { url: this.url.value, channel: this.channel.value, } - - this.props.onSave(properties) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } + } + disableTest = () => { + this.setState({testEnabled: false}) } handleUrlRef = r => (this.url = r) @@ -33,7 +31,7 @@ class SlackConfig extends Component { const {url, channel} = this.props.config.options return ( -
+
@@ -58,14 +57,30 @@ class SlackConfig extends Component { placeholder="#alerts" ref={r => (this.channel = r)} defaultValue={channel || ''} + onChange={this.disableTest} />
- +
+
+
) } @@ -81,6 +96,8 @@ SlackConfig.propTypes = { }).isRequired, }).isRequired, onSave: func.isRequired, + onTest: func.isRequired, + enabled: bool.isRequired, } export default SlackConfig diff --git a/ui/src/kapacitor/components/config/TalkConfig.js b/ui/src/kapacitor/components/config/TalkConfig.js index abab7b601..573766fea 100644 --- a/ui/src/kapacitor/components/config/TalkConfig.js +++ b/ui/src/kapacitor/components/config/TalkConfig.js @@ -5,9 +5,12 @@ import RedactedInput from './RedactedInput' class TalkConfig extends Component { constructor(props) { super(props) + this.state = { + testEnabled: this.props.enabled, + } } - handleSaveAlert = e => { + handleSubmit = async e => { e.preventDefault() const properties = { @@ -15,7 +18,14 @@ class TalkConfig extends Component { author_name: this.author.value, } - this.props.onSave(properties) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } + } + + disableTest = () => { + this.setState({testEnabled: false}) } handleUrlRef = r => (this.url = r) @@ -24,13 +34,14 @@ class TalkConfig extends Component { const {url, author_name: author} = this.props.config.options return ( -
+
@@ -42,12 +53,26 @@ class TalkConfig extends Component { type="text" ref={r => (this.author = r)} defaultValue={author || ''} + onChange={this.disableTest} />
- +
@@ -65,6 +90,8 @@ TalkConfig.propTypes = { }).isRequired, }).isRequired, onSave: func.isRequired, + onTest: func.isRequired, + enabled: bool.isRequired, } export default TalkConfig diff --git a/ui/src/kapacitor/components/config/TelegramConfig.js b/ui/src/kapacitor/components/config/TelegramConfig.js index 0792bf28c..e4f4114f8 100644 --- a/ui/src/kapacitor/components/config/TelegramConfig.js +++ b/ui/src/kapacitor/components/config/TelegramConfig.js @@ -7,9 +7,12 @@ import RedactedInput from './RedactedInput' class TelegramConfig extends Component { constructor(props) { super(props) + this.state = { + testEnabled: this.props.enabled, + } } - handleSaveAlert = e => { + handleSubmit = async e => { e.preventDefault() let parseMode @@ -28,7 +31,14 @@ class TelegramConfig extends Component { token: this.token.value, } - this.props.onSave(properties) + const success = await this.props.onSave(properties) + if (success) { + this.setState({testEnabled: true}) + } + } + + disableTest = () => { + this.setState({testEnabled: false}) } handleTokenRef = r => (this.token = r) @@ -42,7 +52,7 @@ class TelegramConfig extends Component { const parseMode = options['parse-mode'] return ( -
+
@@ -68,6 +78,7 @@ class TelegramConfig extends Component { defaultValue={token} id="token" refFunc={this.handleTokenRef} + disableTest={this.disableTest} />
@@ -86,6 +97,7 @@ class TelegramConfig extends Component { placeholder="your-telegram-chat-id" ref={r => (this.chatID = r)} defaultValue={chatID || ''} + onChange={this.disableTest} />
@@ -100,6 +112,7 @@ class TelegramConfig extends Component { value="markdown" defaultChecked={parseMode !== 'HTML'} ref={r => (this.parseModeMarkdown = r)} + onChange={this.disableTest} /> @@ -111,6 +124,7 @@ class TelegramConfig extends Component { value="html" defaultChecked={parseMode === 'HTML'} ref={r => (this.parseModeHTML = r)} + onChange={this.disableTest} /> @@ -124,6 +138,7 @@ class TelegramConfig extends Component { type="checkbox" defaultChecked={disableWebPagePreview} ref={r => (this.disableWebPagePreview = r)} + onChange={this.disableTest} />