Merge branch 'master' into feature/annotationz-pre-pl-with-master

pull/10616/head
Alex P 2018-02-08 16:38:47 -08:00
commit f2cc269a33
119 changed files with 5110 additions and 974 deletions

View File

@ -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<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<release>\d+)
serialize = {major}.{minor}.{patch}.{release}

View File

@ -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

112
Gopkg.lock generated
View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -22,7 +22,7 @@ func TestConfig_Get(t *testing.T) {
wants: wants{
config: &chronograf.Config{
Auth: chronograf.AuthConfig{
SuperAdminNewUsers: true,
SuperAdminNewUsers: false,
},
},
},

View File

@ -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)
}

View File

@ -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,
}

View File

@ -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<string, Axis> 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.

View File

@ -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",
},
},

View File

@ -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

View File

@ -3,7 +3,7 @@ machine:
services:
- docker
environment:
DOCKER_TAG: chronograf-20171027
DOCKER_TAG: chronograf-20180207
dependencies:
override:

77
cmd/chronoctl/add.go Normal file
View File

@ -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)
}

41
cmd/chronoctl/list.go Normal file
View File

@ -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)
}

25
cmd/chronoctl/main.go Normal file
View File

@ -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)
}
}
}

44
cmd/chronoctl/util.go Normal file
View File

@ -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, ","))
}

View File

@ -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

View File

@ -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 ; \

View File

@ -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.

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -86,7 +86,11 @@
"name": "comet",
"value": "100"
}
]
],
"legend": {
"type": "static",
"orientation": "bottom"
}
}
],
"templates": [

View File

@ -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 = ''

View File

@ -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")

View File

@ -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 (

View File

@ -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 {

View File

@ -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)
}
})
}
}

View File

@ -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 {

View File

@ -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)
}
})
}
}

View File

@ -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)

View File

@ -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",

57
server/middle.go Normal file
View File

@ -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)
}
}

196
server/middle_test.go Normal file
View File

@ -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)
}
})
}
}

View File

@ -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

View File

@ -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 {

View File

@ -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,
},
}))

View File

@ -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",

View File

@ -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))

View File

@ -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": {

View File

@ -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
}

View File

@ -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",

View File

@ -1,6 +1,6 @@
{
"name": "chronograf-ui",
"version": "1.4.0-0",
"version": "1.4.0-1",
"private": false,
"license": "AGPL-3.0",
"description": "",

View File

@ -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)
})
})

View File

@ -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) {

View File

@ -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: (
<UsersPage meID={meID} meCurrentOrganization={meCurrentOrganization} />
),
},
{
requiredRole: SUPERADMIN_ROLE,
type: ALL_USERS_TAB_NAME,
component: <AllUsersPage meID={meID} />,
},
].filter(t => isUserAuthorized(meRole, t.requiredRole))
return (

View File

@ -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 (
<div className="panel panel-default">
<div className="panel-body">
<div className="page-spinner" />
</div>
</div>
)
}
return (
<div className="panel panel-default">
<AllUsersTableHeader
numUsers={users.length}
numOrganizations={organizations.length}
onClickCreateUser={this.handleClickCreateUser}
isCreatingUser={isCreatingUser}
authConfig={authConfig}
onChangeAuthConfig={this.handleUpdateAuthConfig}
/>
<div className="panel-body">
<table className="table table-highlight v-center chronograf-admin-table">
<thead>
<tr>
<th>Username</th>
<th
style={{width: colOrganizations}}
className="align-with-col-text"
>
Organizations
</th>
<th style={{width: colProvider}}>Provider</th>
<th style={{width: colScheme}}>Scheme</th>
<th style={{width: colSuperAdmin}} className="text-center">
SuperAdmin
</th>
<th className="text-right" style={{width: colActions}} />
</tr>
</thead>
<tbody>
{users.length
? users.map(user =>
<AllUsersTableRow
user={user}
key={uuid.v4()}
organizations={organizations}
onAddToOrganization={this.handleAddToOrganization}
onRemoveFromOrganization={
this.handleRemoveFromOrganization
}
onChangeSuperAdmin={this.handleChangeSuperAdmin}
onDelete={onDeleteUser}
meID={meID}
/>
)
: <tr className="table-empty-state">
<th colSpan="6">
<p>No Users to display</p>
</th>
</tr>}
{isCreatingUser
? <AllUsersTableRowNew
organizations={organizations}
onBlur={this.handleBlurCreateUserRow}
onCreateUser={onCreateUser}
notify={notify}
/>
: null}
</tbody>
</table>
</div>
</div>
)
}
}
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

View File

@ -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 (
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<h2 className="panel-title">
{numUsersString} in {numOrganizationsString}
</h2>
<div style={{display: 'flex', alignItems: 'center'}}>
<div className="all-users-admin-toggle">
<SlideToggle
size="xs"
active={superAdminNewUsers}
onToggle={onChangeAuthConfig('superAdminNewUsers')}
/>
<span>All new users are SuperAdmins</span>
</div>
<button
className="btn btn-primary btn-sm"
onClick={onClickCreateUser}
disabled={isCreatingUser || !onClickCreateUser}
>
<span className="icon plus" />
Add User
</button>
</div>
</div>
)
}
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

View File

@ -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 (
<tr className={'chronograf-admin-table--user'}>
<td>
{userIsMe
? <strong className="chronograf-user--me">
<span className="icon user" />
{user.name}
</strong>
: <strong>
{user.name}
</strong>}
</td>
<td style={{width: colOrganizations}}>
<Tags
tags={userOrganizations}
onDeleteTag={onRemoveFromOrganization(user)}
emptyStateText="None"
addMenuItems={dropdownOrganizationsItems}
addMenuChoose={onAddToOrganization(user)}
/>
</td>
<td style={{width: colProvider}}>
{user.provider}
</td>
<td style={{width: colScheme}}>
{user.scheme}
</td>
<td style={{width: colSuperAdmin}} className="text-center">
<SlideToggle
active={user.superAdmin}
onToggle={onChangeSuperAdmin(user)}
size="xs"
disabled={userIsMe}
/>
</td>
<td style={{textAlign: 'right', width: colActions}}>
<ConfirmButton
confirmText={removeWarning}
confirmAction={wrappedDelete}
size="btn-xs"
text="Remove"
customClass="table--show-on-row-hover"
/>
</td>
</tr>
)
}
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

View File

@ -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 (
<tr className="chronograf-admin-table--new-user">
<td>
<input
className="form-control input-xs"
type="text"
placeholder="OAuth Username..."
autoFocus={true}
value={name}
onChange={this.handleInputChange('name')}
onKeyDown={this.handleKeyDown}
/>
</td>
<td style={{width: colOrganizations}}>
<Dropdown
items={dropdownOrganizationsItems}
selected={selectedRole.text}
onChoose={this.handleSelectOrganization}
buttonColor="btn-primary"
buttonSize="btn-xs"
className="dropdown-stretch"
/>
</td>
<td style={{width: colProvider}}>
<input
className="form-control input-xs"
type="text"
placeholder="OAuth Provider..."
value={provider}
onChange={this.handleInputChange('provider')}
onKeyDown={this.handleKeyDown}
/>
</td>
<td style={{width: colScheme}}>
<input
className="form-control input-xs disabled"
type="text"
disabled={true}
placeholder="OAuth Scheme..."
value={scheme}
/>
</td>
<td style={{width: colSuperAdmin}} className="text-center">
&mdash;
</td>
<td className="text-right" style={{width: colActions}}>
<button className="btn btn-xs btn-square btn-info" onClick={onBlur}>
<span className="icon remove" />
</button>
<button
className="btn btn-xs btn-square btn-success"
disabled={preventCreate}
onClick={this.handleConfirmCreateUser}
>
<span className="icon checkmark" />
</button>
</td>
</tr>
)
}
}
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

View File

@ -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 (
<div className="panel panel-default">
<UsersTableHeader />
<div className="panel-body">
<table className="table table-highlight v-center chronograf-admin-table">
<thead>
<tr>
<th>Username</th>
<th style={{width: colRole}} className="align-with-col-text">
Role
</th>
<Authorized requiredRole={SUPERADMIN_ROLE}>
<th style={{width: colSuperAdmin}} className="text-center">
SuperAdmin
</th>
</Authorized>
<th style={{width: colProvider}}>Provider</th>
<th style={{width: colScheme}}>Scheme</th>
<th className="text-right" style={{width: colActions}} />
</tr>
</thead>
<tbody />
</table>
</div>
</div>
)
}
export default EmptyUsersTable

View File

@ -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 (
<div className="panel panel-default">
<div className="panel-body">
<div className="page-spinner" />
</div>
</div>
)
}
return (
<div className="panel panel-default">
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
@ -94,35 +98,13 @@ class OrganizationsTable extends Component {
currentOrganization={currentOrganization}
/>
)}
<Authorized requiredRole={SUPERADMIN_ROLE}>
<table className="table v-center superadmin-config">
<thead>
<tr>
<th style={{width: 70}}>Config</th>
<th />
</tr>
</thead>
<tbody>
<tr>
<td style={{width: 70}}>
<SlideToggle
size="xs"
active={superAdminNewUsers}
onToggle={onChangeAuthConfig('superAdminNewUsers')}
/>
</td>
<td>All new users are SuperAdmins</td>
</tr>
</tbody>
</table>
</Authorized>
</div>
</div>
)
}
}
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

View File

@ -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 (
<div className="panel panel-default">
<div className="panel-body">
<div className="page-spinner" />
</div>
</div>
)
}
return (
<div className="panel panel-default">
<UsersTableHeader
@ -67,11 +71,6 @@ class UsersTable extends Component {
<th style={{width: colRole}} className="align-with-col-text">
Role
</th>
<Authorized requiredRole={SUPERADMIN_ROLE}>
<th style={{width: colSuperAdmin}} className="text-center">
SuperAdmin
</th>
</Authorized>
<th style={{width: colProvider}}>Provider</th>
<th style={{width: colScheme}}>Scheme</th>
<th className="text-right" style={{width: colActions}} />
@ -86,31 +85,21 @@ class UsersTable extends Component {
notify={notify}
/>
: null}
{users.length || !isCreatingUser
{users.length
? users.map(user =>
<UsersTableRow
user={user}
key={uuid.v4()}
organization={organization}
onChangeUserRole={this.handleChangeUserRole}
onChangeSuperAdmin={this.handleChangeSuperAdmin}
onDelete={this.handleDeleteUser}
meID={meID}
/>
)
: <tr className="table-empty-state">
<Authorized
requiredRole={SUPERADMIN_ROLE}
replaceWithIfNotAuthorized={
<th colSpan="5">
<p>No Users to display</p>
</th>
}
>
<th colSpan="6">
<p>No Users to display</p>
</th>
</Authorized>
<th colSpan="5">
<p>No Users to display</p>
</th>
</tr>}
</tbody>
</table>
@ -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

View File

@ -26,7 +26,7 @@ class UsersTableHeader extends Component {
disabled={isCreatingUser || !onClickCreateUser}
>
<span className="icon plus" />
Create User
Add User
</button>
</div>
)

View File

@ -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 = ({
/>
</span>
</td>
<Authorized requiredRole={SUPERADMIN_ROLE}>
<td style={{width: colSuperAdmin}} className="text-center">
<SlideToggle
active={user.superAdmin}
onToggle={onChangeSuperAdmin(user)}
size="xs"
disabled={userIsMe}
/>
</td>
</Authorized>
<td style={{width: colProvider}}>
{user.provider}
</td>
@ -89,7 +75,6 @@ UsersTableRow.propTypes = {
id: string.isRequired,
}),
onChangeUserRole: func.isRequired,
onChangeSuperAdmin: func.isRequired,
onDelete: func.isRequired,
meID: string.isRequired,
}

View File

@ -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"
/>
</td>
<Authorized requiredRole={SUPERADMIN_ROLE}>
<td style={{width: colSuperAdmin}} className="text-center">
&mdash;
</td>
</Authorized>
<td style={{width: colProvider}}>
<input
className="form-control input-xs"

View File

@ -1,7 +1,16 @@
export const USERS_TABLE = {
colRole: 120,
colOrganizations: 200,
colSuperAdmin: 90,
colProvider: 170,
colScheme: 90,
colActions: 80,
}
export const ALL_USERS_TABLE = {
colOrganizations: 320,
colSuperAdmin: 90,
colProvider: 100,
colScheme: 90,
colActions: 50,
}

View File

@ -0,0 +1,143 @@
import React, {Component, PropTypes} from 'react'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import * as adminChronografActionCreators from 'src/admin/actions/chronograf'
import * as configActionCreators from 'shared/actions/config'
import {publishAutoDismissingNotification} from 'shared/dispatchers'
import AllUsersTable from 'src/admin/components/chronograf/AllUsersTable'
class AllUsersPage extends Component {
constructor(props) {
super(props)
this.state = {
isLoading: true,
}
}
componentDidMount() {
const {links, actionsConfig: {getAuthConfigAsync}} = this.props
getAuthConfigAsync(links.config.auth)
}
handleCreateUser = user => {
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 (
<AllUsersTable
meID={meID}
users={users}
organizations={organizations}
onCreateUser={this.handleCreateUser}
onUpdateUserRoles={this.handleUpdateUserRoles}
onUpdateUserSuperAdmin={this.handleUpdateUserSuperAdmin}
onDeleteUser={this.handleDeleteUser}
links={links}
authConfig={authConfig}
actionsConfig={actionsConfig}
notify={notify}
isLoading={this.state.isLoading}
/>
)
}
}
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)

View File

@ -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
? <OrganizationsTable
organizations={organizations}
currentOrganization={organization}
onCreateOrg={this.handleCreateOrganization}
onDeleteOrg={this.handleDeleteOrganization}
onRenameOrg={this.handleRenameOrganization}
onTogglePublic={this.handleTogglePublic}
onChooseDefaultRole={this.handleChooseDefaultRole}
authConfig={authConfig}
onChangeAuthConfig={this.handleUpdateAuthConfig}
me={me}
/>
: <div className="page-spinner" />
return (
<OrganizationsTable
organizations={organizations}
currentOrganization={organization}
onCreateOrg={this.handleCreateOrganization}
onDeleteOrg={this.handleDeleteOrganization}
onRenameOrg={this.handleRenameOrganization}
onTogglePublic={this.handleTogglePublic}
onChooseDefaultRole={this.handleChooseDefaultRole}
me={me}
/>
)
}
}
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),
})

View File

@ -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 <EmptyUsersTable />
}
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}
/>
)
}

View File

@ -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': {

View File

@ -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 (
<div className={OVERLAY_TECHNOLOGY}>
<div
className={OVERLAY_TECHNOLOGY}
onKeyDown={this.handleKeyDown}
tabIndex="0"
ref={r => (this.overlayRef = r)}
>
<ResizeContainer
containerClass="resizer--full-size"
minTopHeight={MINIMUM_HEIGHTS.visualization}

View File

@ -1,12 +1,16 @@
import React, {PropTypes, Component} from 'react'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import _ from 'lodash'
import HostsTable from 'src/hosts/components/HostsTable'
import SourceIndicator from 'shared/components/SourceIndicator'
import AutoRefreshDropdown from 'shared/components/AutoRefreshDropdown'
import ManualRefresh from 'src/shared/components/ManualRefresh'
import {getCpuAndLoadForHosts, getLayouts, getAppsForHosts} from '../apis'
import {getEnv} from 'src/shared/apis/env'
import {setAutoRefresh} from 'shared/actions/app'
class HostsPage extends Component {
constructor(props) {
@ -19,59 +23,26 @@ class HostsPage extends Component {
}
}
async componentDidMount() {
async fetchHostsData() {
const {source, links, addFlashMessage} = this.props
const {telegrafSystemInterval} = await getEnv(links.environment)
const hostsError = 'Unable to get apps for hosts'
let hosts, layouts
try {
const [h, {data}] = await Promise.all([
getCpuAndLoadForHosts(
source.links.proxy,
source.telegraf,
telegrafSystemInterval
),
getLayouts(),
new Promise(resolve => {
this.setState({hostsLoading: true})
resolve()
}),
])
hosts = h
layouts = data.layouts
this.setState({
hosts,
hostsLoading: false,
})
} catch (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 (
<div className="page hosts-list-page">
@ -99,6 +112,12 @@ class HostsPage extends Component {
</div>
<div className="page-header__right">
<SourceIndicator />
<AutoRefreshDropdown
iconName="refresh"
selected={autoRefresh}
onChoose={onChooseAutoRefresh}
onManualRefresh={onManualRefresh}
/>
</div>
</div>
</div>
@ -119,13 +138,20 @@ class HostsPage extends Component {
</div>
)
}
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)
)

View File

@ -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({
<Route path="tickscript/:ruleID" component={TickscriptPage} />
<Route path="kapacitors/new" component={KapacitorPage} />
<Route path="kapacitors/:id/edit" component={KapacitorPage} />
<Route
path="kapacitors/:id/edit:hash"
component={KapacitorPage}
/>
<Route path="kapacitor-tasks" component={KapacitorTasksPage} />
<Route path="admin-chronograf" component={AdminChronografPage} />
<Route path="admin-influxdb" component={AdminInfluxDBPage} />

View File

@ -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
}
}

View File

@ -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 {
<AlertaConfig
onSave={this.handleSaveConfig('alerta')}
config={this.getSection(configSections, 'alerta')}
onTest={this.handleTestConfig('alerta')}
enabled={this.getEnabled(configSections, 'alerta')}
/>,
},
hipchat: {
@ -125,6 +165,8 @@ class AlertTabs extends Component {
<HipChatConfig
onSave={this.handleSaveConfig('hipchat')}
config={this.getSection(configSections, 'hipchat')}
onTest={this.handleTestConfig('hipchat')}
enabled={this.getEnabled(configSections, 'hipchat')}
/>,
},
opsgenie: {
@ -134,6 +176,8 @@ class AlertTabs extends Component {
<OpsGenieConfig
onSave={this.handleSaveConfig('opsgenie')}
config={this.getSection(configSections, 'opsgenie')}
onTest={this.handleTestConfig('opsgenie')}
enabled={this.getEnabled(configSections, 'opsgenie')}
/>,
},
pagerduty: {
@ -143,6 +187,8 @@ class AlertTabs extends Component {
<PagerDutyConfig
onSave={this.handleSaveConfig('pagerduty')}
config={this.getSection(configSections, 'pagerduty')}
onTest={this.handleTestConfig('pagerduty')}
enabled={this.getEnabled(configSections, 'pagerduty')}
/>,
},
pushover: {
@ -152,6 +198,8 @@ class AlertTabs extends Component {
<PushoverConfig
onSave={this.handleSaveConfig('pushover')}
config={this.getSection(configSections, 'pushover')}
onTest={this.handleTestConfig('pushover')}
enabled={this.getEnabled(configSections, 'pushover')}
/>,
},
sensu: {
@ -161,6 +209,8 @@ class AlertTabs extends Component {
<SensuConfig
onSave={this.handleSaveConfig('sensu')}
config={this.getSection(configSections, 'sensu')}
onTest={this.handleTestConfig('sensu')}
enabled={this.getEnabled(configSections, 'sensu')}
/>,
},
slack: {
@ -170,6 +220,8 @@ class AlertTabs extends Component {
<SlackConfig
onSave={this.handleSaveConfig('slack')}
config={this.getSection(configSections, 'slack')}
onTest={this.handleTestConfig('slack')}
enabled={this.getEnabled(configSections, 'slack')}
/>,
},
smtp: {
@ -179,6 +231,8 @@ class AlertTabs extends Component {
<SMTPConfig
onSave={this.handleSaveConfig('smtp')}
config={this.getSection(configSections, 'smtp')}
onTest={this.handleTestConfig('smtp')}
enabled={this.getEnabled(configSections, 'smtp')}
/>,
},
talk: {
@ -188,6 +242,8 @@ class AlertTabs extends Component {
<TalkConfig
onSave={this.handleSaveConfig('talk')}
config={this.getSection(configSections, 'talk')}
onTest={this.handleTestConfig('talk')}
enabled={this.getEnabled(configSections, 'talk')}
/>,
},
telegram: {
@ -197,6 +253,8 @@ class AlertTabs extends Component {
<TelegramConfig
onSave={this.handleSaveConfig('telegram')}
config={this.getSection(configSections, 'telegram')}
onTest={this.handleTestConfig('telegram')}
enabled={this.getEnabled(configSections, 'telegram')}
/>,
},
victorops: {
@ -206,10 +264,11 @@ class AlertTabs extends Component {
<VictorOpsConfig
onSave={this.handleSaveConfig('victorops')}
config={this.getSection(configSections, 'victorops')}
onTest={this.handleTestConfig('victorops')}
enabled={this.getEnabled(configSections, 'victorops')}
/>,
},
}
return (
<div>
<div className="panel panel-minimal">
@ -218,7 +277,10 @@ class AlertTabs extends Component {
</div>
</div>
<Tabs tabContentsClass="config-endpoint">
<Tabs
tabContentsClass="config-endpoint"
initialIndex={this.getInitialIndex(supportedConfigs, hash)}
>
<TabList customClass="config-endpoint--tabs">
{_.reduce(
configSections,
@ -269,6 +331,7 @@ AlertTabs.propTypes = {
}).isRequired,
}),
addFlashMessage: func.isRequired,
hash: string.isRequired,
}
export default AlertTabs

View File

@ -65,7 +65,7 @@ class HandlerOptions extends Component {
<EmailHandler
selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig}
onGoToConfig={onGoToConfig('smtp')}
validationError={validationError}
updateDetails={updateDetails}
rule={rule}
@ -76,7 +76,7 @@ class HandlerOptions extends Component {
<AlertaHandler
selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig}
onGoToConfig={onGoToConfig('alerta')}
validationError={validationError}
/>
)
@ -85,7 +85,7 @@ class HandlerOptions extends Component {
<HipchatHandler
selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig}
onGoToConfig={onGoToConfig('hipchat')}
validationError={validationError}
/>
)
@ -94,7 +94,7 @@ class HandlerOptions extends Component {
<OpsgenieHandler
selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig}
onGoToConfig={onGoToConfig('opsgenie')}
validationError={validationError}
/>
)
@ -103,7 +103,7 @@ class HandlerOptions extends Component {
<PagerdutyHandler
selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig}
onGoToConfig={onGoToConfig('pagerduty')}
validationError={validationError}
/>
)
@ -112,7 +112,7 @@ class HandlerOptions extends Component {
<PushoverHandler
selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig}
onGoToConfig={onGoToConfig('pushover')}
validationError={validationError}
/>
)
@ -121,7 +121,7 @@ class HandlerOptions extends Component {
<SensuHandler
selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig}
onGoToConfig={onGoToConfig('sensu')}
validationError={validationError}
/>
)
@ -130,7 +130,7 @@ class HandlerOptions extends Component {
<SlackHandler
selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig}
onGoToConfig={onGoToConfig('slack')}
validationError={validationError}
/>
)
@ -139,7 +139,7 @@ class HandlerOptions extends Component {
<TalkHandler
selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig}
onGoToConfig={onGoToConfig('talk')}
validationError={validationError}
/>
)
@ -148,7 +148,7 @@ class HandlerOptions extends Component {
<TelegramHandler
selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig}
onGoToConfig={onGoToConfig('telegram')}
validationError={validationError}
/>
)
@ -157,7 +157,7 @@ class HandlerOptions extends Component {
<VictoropsHandler
selectedHandler={selectedHandler}
handleModifyHandler={handleModifyHandler}
onGoToConfig={onGoToConfig}
onGoToConfig={onGoToConfig('victorops')}
validationError={validationError}
/>
)

View File

@ -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 (
<div className="page">
<div className="page-header">
<div className="page-header__container">
<div className="page-header__left">
<h1 className="page-header__title">Configure Kapacitor</h1>
<h1 className="page-header__title">{`${exists
? 'Configure'
: 'Add a New'} Kapacitor Connection`}</h1>
</div>
</div>
</div>
@ -29,13 +30,13 @@ class KapacitorForm extends Component {
<form onSubmit={onSubmit}>
<div>
<div className="form-group">
<label htmlFor="url">Kapacitor URL</label>
<label htmlFor="kapaUrl">Kapacitor URL</label>
<input
className="form-control"
id="url"
name="url"
placeholder={url}
value={url}
id="kapaUrl"
name="kapaUrl"
placeholder={kapaUrl}
value={kapaUrl}
onChange={onInputChange}
spellCheck="false"
/>
@ -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

View File

@ -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 (
<div className="page">
<RuleHeader
source={source}
onSave={
rule.id === DEFAULT_RULE_ID ? this.handleCreate : this.handleEdit
}
onSave={this.handleSave}
validationError={this.validationError()}
/>
<FancyScrollbar className="page-contents fancy-scroll--kapacitor">

View File

@ -1,16 +1,16 @@
import React, {PropTypes} from 'react'
const LogsToggle = ({areLogsVisible, onToggleLogsVisbility}) =>
const LogsToggle = ({areLogsVisible, onToggleLogsVisibility}) =>
<ul className="nav nav-tablist nav-tablist-sm nav-tablist-malachite logs-toggle">
<li
className={areLogsVisible ? null : 'active'}
onClick={onToggleLogsVisbility}
onClick={onToggleLogsVisibility}
>
Editor
</li>
<li
className={areLogsVisible ? 'active' : null}
onClick={onToggleLogsVisbility}
onClick={onToggleLogsVisibility}
>
Editor + Logs
</li>
@ -20,7 +20,7 @@ const {bool, func} = PropTypes
LogsToggle.propTypes = {
areLogsVisible: bool,
onToggleLogsVisbility: func.isRequired,
onToggleLogsVisibility: func.isRequired,
}
export default LogsToggle

View File

@ -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,
}) =>
<div className="page">
<TickscriptHeader
task={task}
onSave={onSave}
onExit={onExit}
unsavedChanges={unsavedChanges}
areLogsVisible={areLogsVisible}
areLogsEnabled={areLogsEnabled}
onToggleLogsVisbility={onToggleLogsVisbility}
onToggleLogsVisibility={onToggleLogsVisibility}
isNewTickscript={isNewTickscript}
/>
<div className="page-contents--split">
@ -38,11 +42,14 @@ const Tickscript = ({
onChangeID={onChangeID}
task={task}
/>
<TickscriptEditorConsole validation={validation} />
<TickscriptEditor
script={task.tickscript}
onChangeScript={onChangeScript}
/>
<TickscriptEditorConsole
consoleMessage={consoleMessage}
unsavedChanges={unsavedChanges}
/>
</div>
{areLogsVisible ? <LogsTable logs={logs} /> : null}
</div>
@ -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

View File

@ -1,22 +1,31 @@
import React, {PropTypes} from 'react'
const TickscriptEditorConsole = ({validation}) =>
<div className="tickscript-console">
<div className="tickscript-console--output">
{validation
? <p>
{validation}
</p>
: <p className="tickscript-console--default">
Save your TICKscript to validate it
</p>}
</div>
</div>
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 (
<div className="tickscript-console">
<p className={consoleClass}>
{consoleOutput}
</p>
</div>
)
}
const {bool, string} = PropTypes
TickscriptEditorConsole.propTypes = {
validation: string,
consoleMessage: string,
unsavedChanges: bool,
}
export default TickscriptEditorConsole

View File

@ -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,
}) =>
<div className="page-header full-width">
<div className="page-header__container">
@ -20,18 +23,40 @@ const TickscriptHeader = ({
<LogsToggle
areLogsVisible={areLogsVisible}
areLogsEnabled={areLogsEnabled}
onToggleLogsVisbility={onToggleLogsVisbility}
onToggleLogsVisibility={onToggleLogsVisibility}
/>}
<div className="page-header__right">
<SourceIndicator />
<button
className="btn btn-success btn-sm"
title={id ? '' : 'ID your TICKscript to save'}
onClick={onSave}
disabled={!id}
>
{isNewTickscript ? 'Save New TICKscript' : 'Save TICKscript'}
</button>
{isNewTickscript
? <button
className="btn btn-success btn-sm"
title="Name your TICKscript to save"
onClick={onSave}
disabled={!id}
>
Save New TICKscript
</button>
: <button
className="btn btn-success btn-sm"
title="You have unsaved changes"
onClick={onSave}
disabled={!unsavedChanges}
>
Save Changes
</button>}
{unsavedChanges
? <ConfirmButton
text="Exit"
confirmText="Discard unsaved changes?"
confirmAction={onExit}
/>
: <button
className="btn btn-default btn-sm"
title="Return to Alert Rules"
onClick={onExit}
>
Exit
</button>}
</div>
</div>
</div>
@ -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

View File

@ -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 (
<form onSubmit={this.handleSaveAlert}>
<form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12">
<label htmlFor="environment">Environment</label>
<input
@ -35,6 +45,7 @@ class AlertaConfig extends Component {
type="text"
ref={r => (this.environment = r)}
defaultValue={environment || ''}
onChange={this.disableTest}
/>
</div>
@ -46,6 +57,7 @@ class AlertaConfig extends Component {
type="text"
ref={r => (this.origin = r)}
defaultValue={origin || ''}
onChange={this.disableTest}
/>
</div>
@ -55,6 +67,7 @@ class AlertaConfig extends Component {
defaultValue={token}
id="token"
refFunc={this.handleTokenRef}
disableTest={this.disableTest}
/>
</div>
@ -66,12 +79,26 @@ class AlertaConfig extends Component {
type="text"
ref={r => (this.url = r)}
defaultValue={url || ''}
onChange={this.disableTest}
/>
</div>
<div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit">
Update Alerta Config
<button
className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button>
</div>
</form>
@ -91,6 +118,8 @@ AlertaConfig.propTypes = {
}).isRequired,
}).isRequired,
onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
}
export default AlertaConfig

View File

@ -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 (
<form onSubmit={this.handleSaveAlert}>
<form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12">
<label htmlFor="url">Subdomain</label>
<input
@ -42,6 +52,7 @@ class HipchatConfig extends Component {
placeholder="your-subdomain"
ref={r => (this.url = r)}
defaultValue={subdomain && subdomain.length ? subdomain : ''}
onChange={this.disableTest}
/>
</div>
@ -54,6 +65,7 @@ class HipchatConfig extends Component {
placeholder="your-hipchat-room"
ref={r => (this.room = r)}
defaultValue={room || ''}
onChange={this.disableTest}
/>
</div>
@ -66,12 +78,26 @@ class HipchatConfig extends Component {
defaultValue={token}
id="token"
refFunc={this.handleTokenRef}
disableTest={this.disableTest}
/>
</div>
<div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit">
Update HipChat Config
<button
className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button>
</div>
</form>
@ -90,6 +116,8 @@ HipchatConfig.propTypes = {
}).isRequired,
}).isRequired,
onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
}
export default HipchatConfig

View File

@ -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 (
<form onSubmit={this.handleSaveAlert}>
<form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12">
<label htmlFor="api-key">API Key</label>
<RedactedInput
defaultValue={apiKey}
id="api-key"
refFunc={this.handleApiKeyRef}
disableTest={this.disableTest}
/>
</div>
@ -74,17 +83,32 @@ class OpsGenieConfig extends Component {
onAddTag={this.handleAddTeam}
onDeleteTag={this.handleDeleteTeam}
tags={currentTeams}
disableTest={this.disableTest}
/>
<TagInput
title="Recipients"
onAddTag={this.handleAddRecipient}
onDeleteTag={this.handleDeleteRecipient}
tags={currentRecipients}
disableTest={this.disableTest}
/>
<div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit">
Update OpsGenie Config
<button
className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button>
</div>
</form>
@ -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 (
<div className="form-group col-xs-12">
<label htmlFor={title}>
{title}
</label>
<input
placeholder={`Type and hit 'Enter' to add to list of ${title}`}
autoComplete="off"
className="form-control"
id={title}
type="text"
ref={r => (this.input = r)}
onKeyDown={this.handleAddTag}
/>
<Tags tags={tags} onDeleteTag={onDeleteTag} />
</div>
)
}
}
TagInput.propTypes = {
onAddTag: func.isRequired,
onDeleteTag: func.isRequired,
tags: arrayOf(string).isRequired,
title: string.isRequired,
}
const Tags = ({tags, onDeleteTag}) =>
<div className="input-tag-list">
{tags.map(item => {
return <Tag key={item} item={item} onDelete={onDeleteTag} />
})}
</div>
Tags.propTypes = {
tags: arrayOf(string),
onDeleteTag: func,
}
const Tag = ({item, onDelete}) =>
<span key={item} className="input-tag-item">
<span>
{item}
</span>
<span className="icon remove" onClick={onDelete(item)} />
</span>
Tag.propTypes = {
item: string,
onDelete: func,
onTest: func.isRequired,
enabled: bool.isRequired,
}
export default OpsGenieConfig

View File

@ -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 (
<form onSubmit={this.handleSaveAlert}>
<form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12">
<label htmlFor="service-key">Service Key</label>
<RedactedInput
defaultValue={serviceKey || ''}
id="service-key"
refFunc={refFunc}
disableTest={this.disableTest}
/>
</div>
@ -41,12 +52,26 @@ class PagerDutyConfig extends Component {
type="text"
ref={r => (this.url = r)}
defaultValue={url || ''}
onChange={this.disableTest}
/>
</div>
<div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit">
Update PagerDuty Config
<button
className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button>
</div>
</form>
@ -64,6 +89,8 @@ PagerDutyConfig.propTypes = {
}).isRequired,
}).isRequired,
onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
}
export default PagerDutyConfig

View File

@ -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 (
<form onSubmit={this.handleSaveAlert}>
<form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12">
<label htmlFor="user-key">
User Key
@ -45,6 +55,7 @@ class PushoverConfig extends Component {
defaultValue={userKey}
id="user-key"
refFunc={this.handleUserKeyRef}
disableTest={this.disableTest}
/>
</div>
@ -60,6 +71,7 @@ class PushoverConfig extends Component {
defaultValue={token}
id="token"
refFunc={this.handleTokenRef}
disableTest={this.disableTest}
/>
</div>
@ -71,12 +83,26 @@ class PushoverConfig extends Component {
type="text"
ref={r => (this.url = r)}
defaultValue={url || ''}
onChange={this.disableTest}
/>
</div>
<div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit">
Update Pushover Config
<button
className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button>
</div>
</form>
@ -95,6 +121,8 @@ PushoverConfig.propTypes = {
}).isRequired,
}).isRequired,
onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
}
export default PushoverConfig

View File

@ -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

View File

@ -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 (
<form onSubmit={this.handleSaveAlert}>
<form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12 col-md-6">
<label htmlFor="smtp-host">SMTP Host</label>
<input
@ -32,6 +42,7 @@ class SMTPConfig extends Component {
type="text"
ref={r => (this.host = r)}
defaultValue={host || ''}
onChange={this.disableTest}
/>
</div>
@ -43,10 +54,11 @@ class SMTPConfig extends Component {
type="text"
ref={r => (this.port = r)}
defaultValue={port || ''}
onChange={this.disableTest}
/>
</div>
<div className="form-group col-xs-12">
<div className="form-group col-xs-6">
<label htmlFor="smtp-from">From Email</label>
<input
className="form-control"
@ -55,6 +67,20 @@ class SMTPConfig extends Component {
type="text"
ref={r => (this.from = r)}
defaultValue={from || ''}
onChange={this.disableTest}
/>
</div>
<div className="form-group col-xs-12 col-md-6">
<label htmlFor="smtp-to">To Email</label>
<input
className="form-control"
id="smtp-to"
placeholder="email@domain.com"
type="text"
ref={r => (this.to = r)}
defaultValue={to || ''}
onChange={this.disableTest}
/>
</div>
@ -66,6 +92,7 @@ class SMTPConfig extends Component {
type="text"
ref={r => (this.username = r)}
defaultValue={username || ''}
onChange={this.disableTest}
/>
</div>
@ -77,12 +104,26 @@ class SMTPConfig extends Component {
type="password"
ref={r => (this.password = r)}
defaultValue={`${password}`}
onChange={this.disableTest}
/>
</div>
<div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit">
Update SMTP Config
<button
className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button>
</div>
</form>
@ -103,6 +144,8 @@ SMTPConfig.propTypes = {
}).isRequired,
}).isRequired,
onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
}
export default SMTPConfig

View File

@ -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 (
<form onSubmit={this.handleSaveAlert}>
<form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12 col-md-6">
<label htmlFor="source">Source</label>
<input
@ -29,6 +39,7 @@ class SensuConfig extends Component {
type="text"
ref={r => (this.source = r)}
defaultValue={source || ''}
onChange={this.disableTest}
/>
</div>
@ -40,12 +51,26 @@ class SensuConfig extends Component {
type="text"
ref={r => (this.addr = r)}
defaultValue={addr || ''}
onChange={this.disableTest}
/>
</div>
<div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit">
Update Sensu Config
<button
className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button>
</div>
</form>
@ -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

View File

@ -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 (
<form onSubmit={this.handleSaveAlert}>
<form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12">
<label htmlFor="slack-url">
Slack Webhook URL (
@ -46,6 +44,7 @@ class SlackConfig extends Component {
defaultValue={url}
id="url"
refFunc={this.handleUrlRef}
disableTest={this.disableTest}
/>
</div>
@ -58,14 +57,30 @@ class SlackConfig extends Component {
placeholder="#alerts"
ref={r => (this.channel = r)}
defaultValue={channel || ''}
onChange={this.disableTest}
/>
</div>
<div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit">
Update Slack Config
<button
className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button>
</div>
<br />
<br />
</form>
)
}
@ -81,6 +96,8 @@ SlackConfig.propTypes = {
}).isRequired,
}).isRequired,
onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
}
export default SlackConfig

View File

@ -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 (
<form onSubmit={this.handleSaveAlert}>
<form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12">
<label htmlFor="url">URL</label>
<RedactedInput
defaultValue={url}
id="url"
refFunc={this.handleUrlRef}
disableTest={this.disableTest}
/>
</div>
@ -42,12 +53,26 @@ class TalkConfig extends Component {
type="text"
ref={r => (this.author = r)}
defaultValue={author || ''}
onChange={this.disableTest}
/>
</div>
<div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit">
Update Talk Config
<button
className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button>
</div>
</form>
@ -65,6 +90,8 @@ TalkConfig.propTypes = {
}).isRequired,
}).isRequired,
onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
}
export default TalkConfig

View File

@ -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 (
<form onSubmit={this.handleSaveAlert}>
<form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12">
<div className="alert alert-warning alert-icon no-user-select">
<span className="icon triangle" />
@ -68,6 +78,7 @@ class TelegramConfig extends Component {
defaultValue={token}
id="token"
refFunc={this.handleTokenRef}
disableTest={this.disableTest}
/>
</div>
@ -86,6 +97,7 @@ class TelegramConfig extends Component {
placeholder="your-telegram-chat-id"
ref={r => (this.chatID = r)}
defaultValue={chatID || ''}
onChange={this.disableTest}
/>
</div>
@ -100,6 +112,7 @@ class TelegramConfig extends Component {
value="markdown"
defaultChecked={parseMode !== 'HTML'}
ref={r => (this.parseModeMarkdown = r)}
onChange={this.disableTest}
/>
<label htmlFor="parseModeMarkdown">Markdown</label>
</div>
@ -111,6 +124,7 @@ class TelegramConfig extends Component {
value="html"
defaultChecked={parseMode === 'HTML'}
ref={r => (this.parseModeHTML = r)}
onChange={this.disableTest}
/>
<label htmlFor="parseModeHTML">HTML</label>
</div>
@ -124,6 +138,7 @@ class TelegramConfig extends Component {
type="checkbox"
defaultChecked={disableWebPagePreview}
ref={r => (this.disableWebPagePreview = r)}
onChange={this.disableTest}
/>
<label htmlFor="disableWebPagePreview">
Disable{' '}
@ -142,6 +157,7 @@ class TelegramConfig extends Component {
type="checkbox"
defaultChecked={disableNotification}
ref={r => (this.disableNotification = r)}
onChange={this.disableTest}
/>
<label htmlFor="disableNotification">
Disable notifications on iOS devices and disable sounds on Android
@ -151,8 +167,21 @@ class TelegramConfig extends Component {
</div>
<div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit">
Update Telegram Config
<button
className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button>
</div>
</form>
@ -173,6 +202,8 @@ TelegramConfig.propTypes = {
}).isRequired,
}).isRequired,
onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
}
export default TelegramConfig

View File

@ -5,9 +5,12 @@ import RedactedInput from './RedactedInput'
class VictorOpsConfig extends Component {
constructor(props) {
super(props)
this.state = {
testEnabled: this.props.enabled,
}
}
handleSaveAlert = e => {
handleSubmit = async e => {
e.preventDefault()
const properties = {
@ -16,7 +19,14 @@ class VictorOpsConfig 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})
}
handleApiRef = r => (this.apiKey = r)
@ -28,13 +38,14 @@ class VictorOpsConfig extends Component {
const {url} = options
return (
<form onSubmit={this.handleSaveAlert}>
<form onSubmit={this.handleSubmit}>
<div className="form-group col-xs-12">
<label htmlFor="api-key">API Key</label>
<RedactedInput
defaultValue={apiKey}
id="api-key"
refFunc={this.handleApiRef}
disableTest={this.disableTest}
/>
</div>
@ -46,6 +57,7 @@ class VictorOpsConfig extends Component {
type="text"
ref={r => (this.routingKey = r)}
defaultValue={routingKey || ''}
onChange={this.disableTest}
/>
</div>
@ -57,12 +69,26 @@ class VictorOpsConfig extends Component {
type="text"
ref={r => (this.url = r)}
defaultValue={url || ''}
onChange={this.disableTest}
/>
</div>
<div className="form-group-submit col-xs-12 text-center">
<button className="btn btn-primary" type="submit">
Update VictorOps Config
<button
className="btn btn-primary"
type="submit"
disabled={this.state.testEnabled}
>
<span className="icon checkmark" />
Save Changes
</button>
<button
className="btn btn-primary"
disabled={!this.state.testEnabled}
onClick={this.props.onTest}
>
<span className="icon pulse-c" />
Send Test Alert
</button>
</div>
</form>
@ -81,6 +107,8 @@ VictorOpsConfig.propTypes = {
}).isRequired,
}).isRequired,
onSave: func.isRequired,
onTest: func.isRequired,
enabled: bool.isRequired,
}
export default VictorOpsConfig

View File

@ -55,7 +55,7 @@ class KapacitorPage extends Component {
const {value, name} = e.target
this.setState(prevState => {
const update = {[name]: value.trim()}
const update = {[name]: value}
return {kapacitor: {...prevState.kapacitor, ...update}}
})
}
@ -70,7 +70,7 @@ class KapacitorPage extends Component {
router,
} = this.props
const {kapacitor} = this.state
kapacitor.name = kapacitor.name.trim()
const isNameTaken = kapacitors.some(k => k.name === kapacitor.name)
const isNew = !params.id
@ -136,9 +136,9 @@ class KapacitorPage extends Component {
}
render() {
const {source, addFlashMessage} = this.props
const {source, addFlashMessage, location, params} = this.props
const hash = (location && location.hash) || (params && params.hash) || ''
const {kapacitor, exists} = this.state
return (
<KapacitorForm
onSubmit={this.handleSubmit}
@ -148,6 +148,7 @@ class KapacitorPage extends Component {
source={source}
addFlashMessage={addFlashMessage}
exists={exists}
hash={hash}
/>
)
}
@ -168,6 +169,7 @@ KapacitorPage.propTypes = {
url: string.isRequired,
kapacitors: array,
}),
location: shape({pathname: string, hash: string}).isRequired,
}
export default withRouter(KapacitorPage)

View File

@ -24,11 +24,12 @@ class TickscriptPage extends Component {
dbrps: [],
type: 'stream',
},
validation: '',
consoleMessage: '',
isEditingID: true,
logs: [],
areLogsEnabled: false,
failStr: '',
unsavedChanges: false,
}
}
@ -172,9 +173,10 @@ class TickscriptPage extends Component {
} else {
response = await createTask(kapacitor, task, router, sourceID)
}
if (response && response.code === 500) {
return this.setState({validation: response.message})
if (response.code) {
this.setState({unsavedChanges: true, consoleMessage: response.message})
} else {
this.setState({unsavedChanges: false, consoleMessage: ''})
}
} catch (error) {
console.error(error)
@ -182,37 +184,57 @@ class TickscriptPage extends Component {
}
}
handleExit = () => {
const {source: {id: sourceID}, router} = this.props
return router.push(`/sources/${sourceID}/alert-rules`)
}
handleChangeScript = tickscript => {
this.setState({task: {...this.state.task, tickscript}})
this.setState({
task: {...this.state.task, tickscript},
unsavedChanges: true,
})
}
handleSelectDbrps = dbrps => {
this.setState({task: {...this.state.task, dbrps}})
this.setState({task: {...this.state.task, dbrps}, unsavedChanges: true})
}
handleChangeType = type => () => {
this.setState({task: {...this.state.task, type}})
this.setState({task: {...this.state.task, type}, unsavedChanges: true})
}
handleChangeID = e => {
this.setState({task: {...this.state.task, id: e.target.value}})
this.setState({
task: {...this.state.task, id: e.target.value},
unsavedChanges: true,
})
}
handleToggleLogsVisbility = () => {
handleToggleLogsVisibility = () => {
this.setState({areLogsVisible: !this.state.areLogsVisible})
}
render() {
const {source} = this.props
const {task, validation, logs, areLogsVisible, areLogsEnabled} = this.state
const {
task,
logs,
areLogsVisible,
areLogsEnabled,
unsavedChanges,
consoleMessage,
} = this.state
return (
<Tickscript
task={task}
logs={logs}
source={source}
validation={validation}
consoleMessage={consoleMessage}
onSave={this.handleSave}
unsavedChanges={unsavedChanges}
onExit={this.handleExit}
isNewTickscript={!this._isEditing()}
onSelectDbrps={this.handleSelectDbrps}
onChangeScript={this.handleChangeScript}
@ -220,7 +242,7 @@ class TickscriptPage extends Component {
onChangeID={this.handleChangeID}
areLogsVisible={areLogsVisible}
areLogsEnabled={areLogsEnabled}
onToggleLogsVisbility={this.handleToggleLogsVisbility}
onToggleLogsVisibility={this.handleToggleLogsVisibility}
/>
)
}

View File

@ -1,11 +1,11 @@
import {getMe as getMeAJAX, updateMe as updateMeAJAX} from 'shared/apis/auth'
import {linksReceived} from 'shared/actions/links'
import {getLinksAsync} from 'shared/actions/links'
import {publishAutoDismissingNotification} from 'shared/dispatchers'
import {errorThrown} from 'shared/actions/errors'
import {LONG_NOTIFICATION_DISMISS_DELAY} from 'shared/constants'
import {NOTIFICATION_DISMISS_DELAY} from 'shared/constants'
export const authExpired = auth => ({
type: 'AUTH_EXPIRED',
@ -58,18 +58,8 @@ export const getMeAsync = ({shouldResetMe = false} = {}) => async dispatch => {
}
try {
// These non-me objects are added to every response by some AJAX trickery
const {
data: me,
auth,
users,
meLink,
config,
external,
logoutLink,
organizations,
environment,
} = await getMeAJAX()
const {data: me, auth, logoutLink} = await getMeAJAX()
// TODO: eventually, get the links for auth and logout out of here and into linksGetCompleted
dispatch(
meGetCompleted({
me,
@ -77,23 +67,20 @@ export const getMeAsync = ({shouldResetMe = false} = {}) => async dispatch => {
logoutLink,
})
)
dispatch(
linksReceived({
external,
users,
organizations,
me: meLink,
config,
environment,
})
) // TODO: put this before meGetCompleted... though for some reason it doesn't fire the first time then
} catch (error) {
dispatch(meGetFailed())
dispatch(errorThrown(error))
}
}
// meChangeOrganizationAsync is for switching the user's current organization.
//
// Global links state also needs to be refreshed upon organization change so
// that Admin Chronograf / Current Org User tab's link is valid, but this is
// happening automatically because we are using a browser redirect to reload
// the application. If at some point we stop using a redirect and instead
// make it a seamless SPA experience, a la issue #2463, we'll need to make sure
// links are still refreshed.
export const meChangeOrganizationAsync = (
url,
organization
@ -109,11 +96,15 @@ export const meChangeOrganizationAsync = (
'success',
`Now logged in to '${me.currentOrganization
.name}' as '${currentRole.name}'`,
LONG_NOTIFICATION_DISMISS_DELAY
NOTIFICATION_DISMISS_DELAY
)
)
dispatch(meChangeOrganizationCompleted())
dispatch(meGetCompleted({me, auth, logoutLink}))
// refresh links after every successful meChangeOrganization to refresh
// /organizations/:id/users link for Admin / Current Org Users page to load
dispatch(getLinksAsync())
// TODO: reload sources upon me change org if non-refresh behavior preferred
// instead of current behavior on both invocations of meChangeOrganization,
// which is to refresh index via router.push('')

View File

@ -1,6 +1,30 @@
import * as actionTypes from 'shared/constants/actionTypes'
import {getLinks as getLinksAJAX} from 'shared/apis/links'
export const linksReceived = links => ({
type: actionTypes.LINKS_RECEIVED,
import {errorThrown} from 'shared/actions/errors'
import {linksLink} from 'shared/constants'
const linksGetRequested = () => ({
type: 'LINKS_GET_REQUESTED',
})
export const linksGetCompleted = links => ({
type: 'LINKS_GET_COMPLETED',
payload: {links},
})
const linksGetFailed = () => ({
type: 'LINKS_GET_FAILED',
})
export const getLinksAsync = () => async dispatch => {
dispatch(linksGetRequested())
try {
const {data} = await getLinksAJAX()
dispatch(linksGetCompleted(data))
} catch (error) {
const message = `Failed to retrieve api links from ${linksLink}`
dispatch(errorThrown(error, message))
dispatch(linksGetFailed())
}
}

View File

@ -13,7 +13,7 @@ export const getEnv = async url => {
return data
} catch (error) {
console.error('Error retreieving envs: ', error)
console.error('Error retrieving envs: ', error)
return DEFAULT_ENVS
}
}

View File

@ -152,20 +152,18 @@ export function updateKapacitorConfigSection(kapacitor, section, properties) {
})
}
export function testAlertOutput(kapacitor, outputName, properties) {
return kapacitorProxy(
kapacitor,
'GET',
'/kapacitor/v1/service-tests'
).then(({data: {services}}) => {
const service = services.find(s => s.name === outputName)
return kapacitorProxy(
export const testAlertOutput = async (kapacitor, outputName) => {
try {
const {data: {services}} = await kapacitorProxy(
kapacitor,
'POST',
service.link.href,
Object.assign({}, service.options, properties)
'GET',
'/kapacitor/v1/service-tests'
)
})
const service = services.find(s => s.name === outputName)
return kapacitorProxy(kapacitor, 'POST', service.link.href, {})
} catch (error) {
console.error(error)
}
}
export function createKapacitorTask(kapacitor, id, type, dbrps, script) {

View File

@ -0,0 +1,17 @@
import {getAJAX, setAJAXLinks} from 'utils/ajax'
import {linksLink} from 'shared/constants'
export const getLinks = async () => {
try {
const response = await getAJAX(linksLink)
// TODO: Remove use of links entirely from within AJAX function so that
// call to setAJAXLinks is not necessary. See issue #1486.
setAJAXLinks({updatedLinks: response.data})
return response
} catch (error) {
console.error(error)
throw error
}
}

View File

@ -0,0 +1,110 @@
import React, {Component, PropTypes} from 'react'
import OnClickOutside from 'shared/components/OnClickOutside'
class ConfirmButton extends Component {
constructor(props) {
super(props)
this.state = {
expanded: false,
}
}
handleButtonClick = () => {
if (this.props.disabled) {
return
}
this.setState({expanded: !this.state.expanded})
}
handleConfirmClick = () => {
this.setState({expanded: false})
this.props.confirmAction()
}
handleClickOutside = () => {
this.setState({expanded: false})
}
calculatePosition = () => {
if (!this.buttonDiv || !this.tooltipDiv) {
return ''
}
const windowWidth = window.innerWidth
const buttonRect = this.buttonDiv.getBoundingClientRect()
const tooltipRect = this.tooltipDiv.getBoundingClientRect()
const rightGap = windowWidth - buttonRect.right
if (tooltipRect.width / 2 > rightGap) {
return 'left'
}
return 'bottom'
}
render() {
const {
text,
confirmText,
type,
size,
square,
icon,
disabled,
customClass,
} = this.props
const {expanded} = this.state
const customClassString = customClass ? ` ${customClass}` : ''
const squareString = square ? ' btn-square' : ''
const expandedString = expanded ? ' active' : ''
const disabledString = disabled ? ' disabled' : ''
const classname = `confirm-button btn ${type} ${size}${customClassString}${squareString}${expandedString}${disabledString}`
return (
<div
className={classname}
onClick={this.handleButtonClick}
ref={r => (this.buttonDiv = r)}
>
{icon && <span className={`icon ${icon}`} />}
{text && text}
<div className={`confirm-button--tooltip ${this.calculatePosition()}`}>
<div
className="confirm-button--confirmation"
onClick={this.handleConfirmClick}
ref={r => (this.tooltipDiv = r)}
>
{confirmText}
</div>
</div>
</div>
)
}
}
const {bool, func, string} = PropTypes
ConfirmButton.defaultProps = {
confirmText: 'Confirm',
type: 'btn-default',
size: 'btn-sm',
square: false,
}
ConfirmButton.propTypes = {
text: string,
confirmText: string,
confirmAction: func.isRequired,
type: string,
size: string,
square: bool,
icon: string,
disabled: bool,
customClass: string,
}
export default OnClickOutside(ConfirmButton)

View File

@ -31,7 +31,7 @@ class SlideToggle extends Component {
const className = `slide-toggle slide-toggle__${size} ${active
? 'active'
: null} ${disabled ? 'disabled' : null}`
: ''} ${disabled ? 'disabled' : ''}`
return (
<div className={className} onClick={this.handleClick}>

View File

@ -0,0 +1,63 @@
import React, {Component, PropTypes} from 'react'
import _ from 'lodash'
import Tags from 'shared/components/Tags'
class TagInput extends Component {
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)
this.props.disableTest()
}
}
handleDeleteTag = item => {
this.props.onDeleteTag(item)
}
shouldAddToList(item, tags) {
return !_.isEmpty(item) && !tags.find(l => l === item)
}
render() {
const {title, tags} = this.props
return (
<div className="form-group col-xs-12">
<label htmlFor={title}>
{title}
</label>
<input
placeholder={`Type and hit 'Enter' to add to list of ${title}`}
autoComplete="off"
className="form-control tag-input"
id={title}
type="text"
ref={r => (this.input = r)}
onKeyDown={this.handleAddTag}
/>
<Tags tags={tags} onDeleteTag={this.handleDeleteTag} />
</div>
)
}
}
const {arrayOf, func, string} = PropTypes
TagInput.propTypes = {
onAddTag: func.isRequired,
onDeleteTag: func.isRequired,
tags: arrayOf(string).isRequired,
title: string.isRequired,
disableTest: func,
}
export default TagInput

View File

@ -0,0 +1,61 @@
import React, {Component, PropTypes} from 'react'
import TagsAddButton from 'src/shared/components/TagsAddButton'
import ConfirmButton from 'src/shared/components/ConfirmButton'
const Tags = ({tags, onDeleteTag, addMenuItems, addMenuChoose}) =>
<div className="input-tag-list">
{tags.map(item => {
return (
<Tag
key={item.text || item.name || item}
item={item}
onDelete={onDeleteTag}
/>
)
})}
{addMenuItems.length && addMenuChoose
? <TagsAddButton items={addMenuItems} onChoose={addMenuChoose} />
: null}
</div>
class Tag extends Component {
handleClickDelete = item => () => {
this.props.onDelete(item)
}
render() {
const {item} = this.props
return (
<span key={item} className="input-tag-item">
<span>
{item.text || item.name || item}
</span>
{
<ConfirmButton
icon="remove"
size="btn-xs"
customClass="btn-xxs"
confirmText="Remove user from organization?"
confirmAction={this.handleClickDelete(item)}
/>
}
</span>
)
}
}
const {arrayOf, func, oneOfType, shape, string} = PropTypes
Tags.propTypes = {
tags: arrayOf(oneOfType([shape(), string])),
onDeleteTag: func,
addMenuItems: arrayOf(shape({})),
addMenuChoose: func,
}
Tag.propTypes = {
item: oneOfType([shape(), string]),
onDelete: func,
}
export default Tags

View File

@ -0,0 +1,57 @@
import React, {Component, PropTypes} from 'react'
import OnClickOutside from 'shared/components/OnClickOutside'
import uuid from 'node-uuid'
class TagsAddButton extends Component {
constructor(props) {
super(props)
this.state = {open: false}
}
handleButtonClick = () => {
this.setState({open: !this.state.open})
}
handleMenuClick = item => () => {
this.setState({open: false})
this.props.onChoose(item)
}
handleClickOutside = () => {
this.setState({open: false})
}
render() {
const {open} = this.state
const {items} = this.props
const classname = `tags-add${open ? ' open' : ''}`
return (
<div className={classname} onClick={this.handleButtonClick}>
<span className="icon plus" />
<div className="tags-add--menu">
{items.map(item =>
<div
key={uuid.v4()}
className="tags-add--menu-item"
onClick={this.handleMenuClick(item)}
>
{item.text}
</div>
)}
</div>
</div>
)
}
}
const {array, func} = PropTypes
TagsAddButton.propTypes = {
items: array.isRequired,
onChoose: func.isRequired,
}
export default OnClickOutside(TagsAddButton)

Some files were not shown because too many files have changed in this diff Show More