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

pull/2825/head
Luke Morris 2018-02-15 12:03:10 -08:00
commit 59b6979812
103 changed files with 4437 additions and 1131 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 1.4.0.1
current_version = 1.4.1.3
files = README.md server/swagger.json
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<release>\d+)
serialize = {major}.{minor}.{patch}.{release}

1
.gitignore vendored
View File

@ -6,6 +6,7 @@ backup/
# Binaries
/chronograf
/chronoctl
# Dotfiles
.pull-request

View File

@ -1,29 +1,46 @@
## v1.4.1.0 [unreleased]
## v1.4.2.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
### Bug Fixes
## v1.4.1.3 [2018-02-14]
### Bug Fixes
1. [#2818](https://github.com/influxdata/chronograf/pull/2818): Allow self-signed certificates for Enterprise InfluxDB Meta nodes
## v1.4.1.2 [2018-02-13]
### Bug Fixes
1. [9321336](https://github.com/influxdata/chronograf/commit/9321336): Respect basepath when fetching server api routes
1. [#2812](https://github.com/influxdata/chronograf/pull/2812): Set default tempVar :interval: with data explorer csv download call.
1. [#2811](https://github.com/influxdata/chronograf/pull/2811): Display series with value of 0 in a cell legend
## v1.4.1.1 [2018-02-12]
### Features
1. [#2409](https://github.com/influxdata/chronograf/pull/2409): Allow multiple event handlers per 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 kapacitor config panel from alert rule builder
1. [#2722](https://github.com/influxdata/chronograf/pull/2722): Add auto refresh widget to hosts list page
1. [#2784](https://github.com/influxdata/chronograf/pull/2784): Update go from 1.9.3 to 1.9.4
1. [#2765](https://github.com/influxdata/chronograf/pull/2765): Update to go 1.9.3 and node 6.12.3 for releases
1. [#2777](https://github.com/influxdata/chronograf/pull/2777): Allow user to delete themselves
1. [#2703](https://github.com/influxdata/chronograf/pull/2703): Add All Users page, visible only to super admins
1. [#2781](https://github.com/influxdata/chronograf/pull/2781): Introduce chronoctl binary for user CRUD operations
1. [#2699](https://github.com/influxdata/chronograf/pull/2699): Introduce Mappings to allow control over new user organization assignments
### UI Improvements
1. [#2698](https://github.com/influxdata/chronograf/pull/2698): Clarify 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
1. [#2788](https://github.com/influxdata/chronograf/pull/2788): Enable customization of Single Stat "Base Color"
### 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. [#2756](https://github.com/influxdata/chronograf/pull/2756): Display 200 most-recent TICKscript log messages; prevent overlapping
1. [#2757](https://github.com/influxdata/chronograf/pull/2757): Add "TO" to kapacitor SMTP config; improve config update error messages
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
1. [#2735](https://github.com/influxdata/chronograf/pull/2735): Remove cli options from systemd service file
1. [#2788](https://github.com/influxdata/chronograf/pull/2788): Fix disappearance of text in Single Stat graphs during editing
1. [#2780](https://github.com/influxdata/chronograf/pull/2780): Redirect to Alerts page after saving Alert Rule
## v1.4.0.1 [2017-1-9]
## v1.4.0.1 [2018-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

View File

@ -5,6 +5,7 @@ RUN apk add --update ca-certificates && \
rm /var/cache/apk/*
ADD chronograf /usr/bin/chronograf
ADD chronoctl /usr/bin/chronoctl
ADD canned/*.json /usr/share/chronograf/canned/
ADD LICENSE /usr/share/chronograf/LICENSE
ADD agpl-3.0.md /usr/share/chronograf/agpl-3.0.md

View File

@ -11,6 +11,7 @@ UISOURCES := $(shell find ui -type f -not \( -path ui/build/\* -o -path ui/node_
unexport LDFLAGS
LDFLAGS=-ldflags "-s -X main.version=${VERSION} -X main.commit=${COMMIT}"
BINARY=chronograf
CTLBINARY=chronoctl
.DEFAULT_GOAL := all
@ -22,6 +23,7 @@ dev: dep dev-assets ${BINARY}
${BINARY}: $(SOURCES) .bindata .jsdep .godep
go build -o ${BINARY} ${LDFLAGS} ./cmd/chronograf/main.go
go build -o ${CTLBINARY} ${LDFLAGS} ./cmd/chronoctl
define CHRONOGIRAFFE
._ o o

View File

@ -136,7 +136,7 @@ option.
## Versions
The most recent version of Chronograf is
[v1.4.0.1](https://www.influxdata.com/downloads/).
[v1.4.1.3](https://www.influxdata.com/downloads/).
Spotted a bug or have a feature request? Please open
[an issue](https://github.com/influxdata/chronograf/issues/new)!
@ -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.1
docker pull chronograf:1.4.1.3
```
### From Source

View File

@ -41,6 +41,7 @@ type Client struct {
UsersStore *UsersStore
OrganizationsStore *OrganizationsStore
ConfigStore *ConfigStore
MappingsStore *MappingsStore
}
// NewClient initializes all stores
@ -60,6 +61,7 @@ func NewClient() *Client {
c.UsersStore = &UsersStore{client: c}
c.OrganizationsStore = &OrganizationsStore{client: c}
c.ConfigStore = &ConfigStore{client: c}
c.MappingsStore = &MappingsStore{client: c}
return c
}
@ -151,6 +153,10 @@ func (c *Client) initialize(ctx context.Context) error {
if _, err := tx.CreateBucketIfNotExists(BuildBucket); err != nil {
return err
}
// Always create Mapping bucket.
if _, err := tx.CreateBucketIfNotExists(MappingsBucket); err != nil {
return err
}
return nil
}); err != nil {
return err
@ -184,6 +190,9 @@ func (c *Client) migrate(ctx context.Context, build chronograf.BuildInfo) error
if err := c.BuildStore.Migrate(ctx, build); err != nil {
return err
}
if err := c.MappingsStore.Migrate(ctx); err != nil {
return err
}
}
return nil
}

View File

@ -586,11 +586,11 @@ func UnmarshalRolePB(data []byte, r *Role) error {
// MarshalOrganization encodes a organization to binary protobuf format.
func MarshalOrganization(o *chronograf.Organization) ([]byte, error) {
return MarshalOrganizationPB(&Organization{
ID: o.ID,
Name: o.Name,
DefaultRole: o.DefaultRole,
Public: o.Public,
})
}
@ -608,7 +608,6 @@ func UnmarshalOrganization(data []byte, o *chronograf.Organization) error {
o.ID = pb.ID
o.Name = pb.Name
o.DefaultRole = pb.DefaultRole
o.Public = pb.Public
return nil
}
@ -650,3 +649,41 @@ func UnmarshalConfig(data []byte, c *chronograf.Config) error {
func UnmarshalConfigPB(data []byte, c *Config) error {
return proto.Unmarshal(data, c)
}
// MarshalMapping encodes a mapping to binary protobuf format.
func MarshalMapping(m *chronograf.Mapping) ([]byte, error) {
return MarshalMappingPB(&Mapping{
Provider: m.Provider,
Scheme: m.Scheme,
ProviderOrganization: m.ProviderOrganization,
ID: m.ID,
Organization: m.Organization,
})
}
// MarshalMappingPB encodes a mapping to binary protobuf format.
func MarshalMappingPB(m *Mapping) ([]byte, error) {
return proto.Marshal(m)
}
// UnmarshalMapping decodes a mapping from binary protobuf data.
func UnmarshalMapping(data []byte, m *chronograf.Mapping) error {
var pb Mapping
if err := UnmarshalMappingPB(data, &pb); err != nil {
return err
}
m.Provider = pb.Provider
m.Scheme = pb.Scheme
m.ProviderOrganization = pb.ProviderOrganization
m.Organization = pb.Organization
m.ID = pb.ID
return nil
}
// UnmarshalMappingPB decodes a mapping from binary protobuf data.
func UnmarshalMappingPB(data []byte, m *Mapping) error {
return proto.Unmarshal(data, m)
}

View File

@ -1,6 +1,5 @@
// Code generated by protoc-gen-gogo.
// Code generated by protoc-gen-gogo. DO NOT EDIT.
// source: internal.proto
// DO NOT EDIT!
/*
Package internal is a generated protocol buffer package.
@ -27,6 +26,7 @@ It has these top-level messages:
AlertRule
User
Role
Mapping
Organization
Config
AuthConfig
@ -1057,17 +1057,64 @@ func (m *Role) GetName() string {
return ""
}
type Mapping struct {
Provider string `protobuf:"bytes,1,opt,name=Provider,proto3" json:"Provider,omitempty"`
Scheme string `protobuf:"bytes,2,opt,name=Scheme,proto3" json:"Scheme,omitempty"`
ProviderOrganization string `protobuf:"bytes,3,opt,name=ProviderOrganization,proto3" json:"ProviderOrganization,omitempty"`
ID string `protobuf:"bytes,4,opt,name=ID,proto3" json:"ID,omitempty"`
Organization string `protobuf:"bytes,5,opt,name=Organization,proto3" json:"Organization,omitempty"`
}
func (m *Mapping) Reset() { *m = Mapping{} }
func (m *Mapping) String() string { return proto.CompactTextString(m) }
func (*Mapping) ProtoMessage() {}
func (*Mapping) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{18} }
func (m *Mapping) GetProvider() string {
if m != nil {
return m.Provider
}
return ""
}
func (m *Mapping) GetScheme() string {
if m != nil {
return m.Scheme
}
return ""
}
func (m *Mapping) GetProviderOrganization() string {
if m != nil {
return m.ProviderOrganization
}
return ""
}
func (m *Mapping) GetID() string {
if m != nil {
return m.ID
}
return ""
}
func (m *Mapping) GetOrganization() string {
if m != nil {
return m.Organization
}
return ""
}
type Organization struct {
ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"`
Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"`
DefaultRole string `protobuf:"bytes,3,opt,name=DefaultRole,proto3" json:"DefaultRole,omitempty"`
Public bool `protobuf:"varint,4,opt,name=Public,proto3" json:"Public,omitempty"`
}
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{18} }
func (*Organization) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{19} }
func (m *Organization) GetID() string {
if m != nil {
@ -1090,13 +1137,6 @@ func (m *Organization) GetDefaultRole() string {
return ""
}
func (m *Organization) GetPublic() bool {
if m != nil {
return m.Public
}
return false
}
type Config struct {
Auth *AuthConfig `protobuf:"bytes,1,opt,name=Auth" json:"Auth,omitempty"`
}
@ -1104,7 +1144,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{19} }
func (*Config) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{20} }
func (m *Config) GetAuth() *AuthConfig {
if m != nil {
@ -1120,7 +1160,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{20} }
func (*AuthConfig) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{21} }
func (m *AuthConfig) GetSuperAdminNewUsers() bool {
if m != nil {
@ -1137,7 +1177,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{21} }
func (*BuildInfo) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{22} }
func (m *BuildInfo) GetVersion() string {
if m != nil {
@ -1172,6 +1212,7 @@ func init() {
proto.RegisterType((*AlertRule)(nil), "internal.AlertRule")
proto.RegisterType((*User)(nil), "internal.User")
proto.RegisterType((*Role)(nil), "internal.Role")
proto.RegisterType((*Mapping)(nil), "internal.Mapping")
proto.RegisterType((*Organization)(nil), "internal.Organization")
proto.RegisterType((*Config)(nil), "internal.Config")
proto.RegisterType((*AuthConfig)(nil), "internal.AuthConfig")
@ -1181,92 +1222,93 @@ func init() {
func init() { proto.RegisterFile("internal.proto", fileDescriptorInternal) }
var fileDescriptorInternal = []byte{
// 1379 bytes of a gzipped FileDescriptorProto
// 1406 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,
0x10, 0x97, 0x63, 0x3b, 0x89, 0x27, 0xd7, 0x52, 0x99, 0x13, 0x35, 0x45, 0x42, 0xc1, 0x02, 0x11,
0x04, 0x3d, 0xd0, 0x55, 0x48, 0x08, 0x41, 0xa5, 0xdc, 0x05, 0x95, 0xa3, 0xd7, 0xf6, 0xba, 0xb9,
0x3b, 0x9e, 0x50, 0xb5, 0x97, 0x4c, 0x12, 0xab, 0x8e, 0x6d, 0xd6, 0xf6, 0x5d, 0xcc, 0x87, 0x41,
0x42, 0x82, 0x2f, 0x80, 0x78, 0xe7, 0x15, 0xf1, 0x41, 0xf8, 0x0a, 0x3c, 0x21, 0xa1, 0xd9, 0x5d,
0xff, 0xc9, 0x25, 0xad, 0xfa, 0x80, 0x78, 0xdb, 0xdf, 0xcc, 0x66, 0x76, 0xfe, 0xfc, 0x66, 0xc6,
0x81, 0x9b, 0x41, 0x94, 0xa1, 0x88, 0x78, 0xb8, 0x97, 0x88, 0x38, 0x8b, 0xdd, 0x6e, 0x89, 0xfd,
0xbf, 0x5a, 0xd0, 0x1e, 0xc7, 0xb9, 0x98, 0xa0, 0x7b, 0x13, 0x5a, 0x47, 0x23, 0xcf, 0xe8, 0x1b,
0x03, 0x93, 0xb5, 0x8e, 0x46, 0xae, 0x0b, 0xd6, 0x63, 0xbe, 0x44, 0xaf, 0xd5, 0x37, 0x06, 0x0e,
0x93, 0x67, 0x92, 0x9d, 0x16, 0x09, 0x7a, 0xa6, 0x92, 0xd1, 0xd9, 0xbd, 0x03, 0xdd, 0xb3, 0x94,
0xac, 0x2d, 0xd1, 0xb3, 0xa4, 0xbc, 0xc2, 0xa4, 0x3b, 0xe1, 0x69, 0x7a, 0x15, 0x8b, 0xa9, 0x67,
0x2b, 0x5d, 0x89, 0xdd, 0x5b, 0x60, 0x9e, 0xb1, 0x63, 0xaf, 0x2d, 0xc5, 0x74, 0x74, 0x3d, 0xe8,
0x8c, 0x70, 0xc6, 0xf3, 0x30, 0xf3, 0x3a, 0x7d, 0x63, 0xd0, 0x65, 0x25, 0x24, 0x3b, 0xa7, 0x18,
0xe2, 0x5c, 0xf0, 0x99, 0xd7, 0x55, 0x76, 0x4a, 0xec, 0xee, 0x81, 0x7b, 0x14, 0xa5, 0x38, 0xc9,
0x05, 0x8e, 0x9f, 0x07, 0xc9, 0x39, 0x8a, 0x60, 0x56, 0x78, 0x8e, 0x34, 0xb0, 0x45, 0x43, 0xaf,
0x3c, 0xc2, 0x8c, 0xd3, 0xdb, 0x20, 0x4d, 0x95, 0xd0, 0xf5, 0x61, 0x67, 0xbc, 0xe0, 0x02, 0xa7,
0x63, 0x9c, 0x08, 0xcc, 0xbc, 0x9e, 0x54, 0xaf, 0xc9, 0xe8, 0xce, 0x13, 0x31, 0xe7, 0x51, 0xf0,
0x03, 0xcf, 0x82, 0x38, 0xf2, 0x76, 0xd4, 0x9d, 0xa6, 0x8c, 0xb2, 0xc4, 0xe2, 0x10, 0xbd, 0x1b,
0x2a, 0x4b, 0x74, 0xf6, 0x7f, 0x33, 0xc0, 0x19, 0xf1, 0x74, 0x71, 0x11, 0x73, 0x31, 0x7d, 0xa5,
0x5c, 0xdf, 0x05, 0x7b, 0x82, 0x61, 0x98, 0x7a, 0x66, 0xdf, 0x1c, 0xf4, 0xf6, 0x6f, 0xef, 0x55,
0x45, 0xac, 0xec, 0x1c, 0x62, 0x18, 0x32, 0x75, 0xcb, 0xfd, 0x04, 0x9c, 0x0c, 0x97, 0x49, 0xc8,
0x33, 0x4c, 0x3d, 0x4b, 0xfe, 0xc4, 0xad, 0x7f, 0x72, 0xaa, 0x55, 0xac, 0xbe, 0xb4, 0x11, 0x8a,
0xbd, 0x19, 0x8a, 0xff, 0x4f, 0x0b, 0x6e, 0xac, 0x3d, 0xe7, 0xee, 0x80, 0xb1, 0x92, 0x9e, 0xdb,
0xcc, 0x58, 0x11, 0x2a, 0xa4, 0xd7, 0x36, 0x33, 0x0a, 0x42, 0x57, 0x92, 0x1b, 0x36, 0x33, 0xae,
0x08, 0x2d, 0x24, 0x23, 0x6c, 0x66, 0x2c, 0xdc, 0x0f, 0xa0, 0xf3, 0x7d, 0x8e, 0x22, 0xc0, 0xd4,
0xb3, 0xa5, 0x77, 0xaf, 0xd5, 0xde, 0x3d, 0xcd, 0x51, 0x14, 0xac, 0xd4, 0x53, 0x36, 0x24, 0x9b,
0x14, 0x35, 0xe4, 0x99, 0x64, 0x19, 0x31, 0xaf, 0xa3, 0x64, 0x74, 0xd6, 0x59, 0x54, 0x7c, 0xa0,
0x2c, 0x7e, 0x0a, 0x16, 0x5f, 0x61, 0xea, 0x39, 0xd2, 0xfe, 0x3b, 0x2f, 0x48, 0xd8, 0xde, 0x70,
0x85, 0xe9, 0x57, 0x51, 0x26, 0x0a, 0x26, 0xaf, 0xbb, 0xef, 0x43, 0x7b, 0x12, 0x87, 0xb1, 0x48,
0x3d, 0xb8, 0xee, 0xd8, 0x21, 0xc9, 0x99, 0x56, 0xbb, 0x03, 0x68, 0x87, 0x38, 0xc7, 0x68, 0x2a,
0x99, 0xd1, 0xdb, 0xbf, 0x55, 0x5f, 0x3c, 0x96, 0x72, 0xa6, 0xf5, 0x77, 0x1e, 0x80, 0x53, 0xbd,
0x42, 0x44, 0x7f, 0x8e, 0x85, 0xcc, 0x99, 0xc3, 0xe8, 0xe8, 0xbe, 0x0b, 0xf6, 0x25, 0x0f, 0x73,
0x55, 0xef, 0xde, 0xfe, 0xcd, 0xda, 0xce, 0x70, 0x15, 0xa4, 0x4c, 0x29, 0x3f, 0x6f, 0x7d, 0x66,
0xf8, 0x73, 0xb0, 0xa5, 0x0f, 0x0d, 0xc6, 0x38, 0x25, 0x63, 0x64, 0x27, 0xb6, 0x1a, 0x9d, 0x78,
0x0b, 0xcc, 0xaf, 0x71, 0xa5, 0x9b, 0x93, 0x8e, 0x15, 0xaf, 0xac, 0x06, 0xaf, 0x76, 0xc1, 0x3e,
0x97, 0x8f, 0xab, 0x7a, 0x2b, 0xe0, 0xdf, 0x87, 0xb6, 0x8a, 0xa1, 0xb2, 0x6c, 0x34, 0x2c, 0xf7,
0xa1, 0xf7, 0x44, 0x04, 0x18, 0x65, 0x8a, 0x29, 0xea, 0xd1, 0xa6, 0xc8, 0xff, 0xd5, 0x00, 0x8b,
0x9c, 0x27, 0x56, 0x85, 0x38, 0xe7, 0x93, 0xe2, 0x20, 0xce, 0xa3, 0x69, 0xea, 0x19, 0x7d, 0x73,
0x60, 0xb2, 0x35, 0x99, 0xfb, 0x06, 0xb4, 0x2f, 0x94, 0xb6, 0xd5, 0x37, 0x07, 0x0e, 0xd3, 0x88,
0x5c, 0x0b, 0xf9, 0x05, 0x86, 0x3a, 0x04, 0x05, 0xe8, 0x76, 0x22, 0x70, 0x16, 0xac, 0x74, 0x18,
0x1a, 0x91, 0x3c, 0xcd, 0x67, 0x24, 0x57, 0x91, 0x68, 0x44, 0x01, 0x5c, 0xf0, 0xb4, 0xa2, 0x0f,
0x9d, 0xc9, 0x72, 0x3a, 0xe1, 0x61, 0xc9, 0x1f, 0x05, 0xfc, 0xdf, 0x0d, 0x9a, 0x2b, 0xaa, 0x1f,
0x36, 0x32, 0xfc, 0x26, 0x74, 0xa9, 0x57, 0x9e, 0x5d, 0x72, 0xa1, 0x03, 0xee, 0x10, 0x3e, 0xe7,
0xc2, 0xfd, 0x18, 0xda, 0xb2, 0x44, 0x5b, 0x7a, 0xb3, 0x34, 0x27, 0xb3, 0xca, 0xf4, 0xb5, 0x8a,
0xbd, 0x56, 0x83, 0xbd, 0x55, 0xb0, 0x76, 0x33, 0xd8, 0xbb, 0x60, 0x53, 0x1b, 0x14, 0xd2, 0xfb,
0xad, 0x96, 0x55, 0xb3, 0xa8, 0x5b, 0xfe, 0x19, 0xdc, 0x58, 0x7b, 0xb1, 0x7a, 0xc9, 0x58, 0x7f,
0xa9, 0xa6, 0x9b, 0xa3, 0xe9, 0x45, 0x33, 0x35, 0xc5, 0x10, 0x27, 0x19, 0x4e, 0x65, 0xbe, 0xbb,
0xac, 0xc2, 0xfe, 0x4f, 0x46, 0x6d, 0x57, 0xbe, 0x47, 0x53, 0x73, 0x12, 0x2f, 0x97, 0x3c, 0x9a,
0x6a, 0xd3, 0x25, 0xa4, 0xbc, 0x4d, 0x2f, 0xb4, 0xe9, 0xd6, 0xf4, 0x82, 0xb0, 0x48, 0x74, 0x05,
0x5b, 0x22, 0x21, 0xee, 0x2c, 0x91, 0xa7, 0xb9, 0xc0, 0x25, 0x46, 0x99, 0x4e, 0x41, 0x53, 0xe4,
0xde, 0x86, 0x4e, 0xc6, 0xe7, 0xcf, 0xa8, 0x49, 0x74, 0x25, 0x33, 0x3e, 0x7f, 0x88, 0x85, 0xfb,
0x16, 0x38, 0xb3, 0x00, 0xc3, 0xa9, 0x54, 0xa9, 0x72, 0x76, 0xa5, 0xe0, 0x21, 0x16, 0xfe, 0x1f,
0x06, 0xb4, 0xc7, 0x28, 0x2e, 0x51, 0xbc, 0xd2, 0x38, 0x6d, 0xae, 0x29, 0xf3, 0x25, 0x6b, 0xca,
0xda, 0xbe, 0xa6, 0xec, 0x7a, 0x4d, 0xed, 0x82, 0x3d, 0x16, 0x93, 0xa3, 0x91, 0xf4, 0xc8, 0x64,
0x0a, 0x10, 0x1b, 0x87, 0x93, 0x2c, 0xb8, 0x44, 0xbd, 0xbb, 0x34, 0xda, 0x98, 0xb2, 0xdd, 0x2d,
0x53, 0xf6, 0x47, 0x03, 0xda, 0xc7, 0xbc, 0x88, 0xf3, 0x6c, 0x83, 0x85, 0x7d, 0xe8, 0x0d, 0x93,
0x24, 0x0c, 0x26, 0x6b, 0x9d, 0xd7, 0x10, 0xd1, 0x8d, 0x47, 0x8d, 0xfc, 0xaa, 0xd8, 0x9a, 0x22,
0x1a, 0x37, 0x87, 0x72, 0x93, 0xa8, 0xb5, 0xd0, 0x18, 0x37, 0x6a, 0x81, 0x48, 0x25, 0x25, 0x61,
0x98, 0x67, 0xf1, 0x2c, 0x8c, 0xaf, 0x64, 0xb4, 0x5d, 0x56, 0x61, 0xff, 0xcf, 0x16, 0x58, 0xff,
0xd7, 0xf4, 0xdf, 0x01, 0x23, 0xd0, 0xc5, 0x36, 0x82, 0x6a, 0x17, 0x74, 0x1a, 0xbb, 0xc0, 0x83,
0x4e, 0x21, 0x78, 0x34, 0xc7, 0xd4, 0xeb, 0xca, 0xe9, 0x52, 0x42, 0xa9, 0x91, 0x7d, 0xa4, 0x96,
0x80, 0xc3, 0x4a, 0x58, 0xf5, 0x05, 0x34, 0xfa, 0xe2, 0x23, 0xbd, 0x2f, 0x7a, 0xd2, 0x23, 0x6f,
0x3d, 0x2d, 0xd7, 0xd7, 0xc4, 0x7f, 0x37, 0xd3, 0xff, 0x36, 0xc0, 0xae, 0x9a, 0xea, 0x70, 0xbd,
0xa9, 0x0e, 0xeb, 0xa6, 0x1a, 0x1d, 0x94, 0x4d, 0x35, 0x3a, 0x20, 0xcc, 0x4e, 0xca, 0xa6, 0x62,
0x27, 0x54, 0xac, 0x07, 0x22, 0xce, 0x93, 0x83, 0x42, 0x55, 0xd5, 0x61, 0x15, 0x26, 0x26, 0x7e,
0xbb, 0x40, 0xa1, 0x53, 0xed, 0x30, 0x8d, 0x88, 0xb7, 0xc7, 0x72, 0xe0, 0xa8, 0xe4, 0x2a, 0xe0,
0xbe, 0x07, 0x36, 0xa3, 0xe4, 0xc9, 0x0c, 0xaf, 0xd5, 0x45, 0x8a, 0x99, 0xd2, 0x92, 0x51, 0xf5,
0x9d, 0xa8, 0x09, 0x5c, 0x7e, 0x35, 0x7e, 0x08, 0xed, 0xf1, 0x22, 0x98, 0x65, 0xe5, 0xd6, 0x7d,
0xbd, 0x31, 0xb0, 0x82, 0x25, 0x4a, 0x1d, 0xd3, 0x57, 0xfc, 0xa7, 0xe0, 0x54, 0xc2, 0xda, 0x1d,
0xa3, 0xe9, 0x8e, 0x0b, 0xd6, 0x59, 0x14, 0x64, 0x65, 0xeb, 0xd2, 0x99, 0x82, 0x7d, 0x9a, 0xf3,
0x28, 0x0b, 0xb2, 0xa2, 0x6c, 0xdd, 0x12, 0xfb, 0xf7, 0xb4, 0xfb, 0x64, 0xee, 0x2c, 0x49, 0x50,
0xe8, 0x31, 0xa0, 0x80, 0x7c, 0x24, 0xbe, 0x42, 0x35, 0xc1, 0x4d, 0xa6, 0x80, 0xff, 0x1d, 0x38,
0xc3, 0x10, 0x45, 0xc6, 0xf2, 0x10, 0xb7, 0x6d, 0xd6, 0x6f, 0xc6, 0x4f, 0x1e, 0x97, 0x1e, 0xd0,
0xb9, 0x6e, 0x79, 0xf3, 0x5a, 0xcb, 0x3f, 0xe4, 0x09, 0x3f, 0x1a, 0x49, 0x9e, 0x9b, 0x4c, 0x23,
0xff, 0x67, 0x03, 0x2c, 0x9a, 0x2d, 0x0d, 0xd3, 0xd6, 0xcb, 0xe6, 0xd2, 0x89, 0x88, 0x2f, 0x83,
0x29, 0x8a, 0x32, 0xb8, 0x12, 0xcb, 0xa4, 0x4f, 0x16, 0x58, 0x2d, 0x70, 0x8d, 0x88, 0x6b, 0xf4,
0x51, 0x59, 0xf6, 0x52, 0x83, 0x6b, 0x24, 0x66, 0x4a, 0xe9, 0xbe, 0x0d, 0x30, 0xce, 0x13, 0x14,
0xc3, 0xe9, 0x32, 0x88, 0x64, 0xd1, 0xbb, 0xac, 0x21, 0xf1, 0xef, 0xab, 0xcf, 0xd4, 0x8d, 0x09,
0x65, 0x6c, 0xff, 0xa4, 0xbd, 0xee, 0xb9, 0xff, 0x8b, 0x01, 0x9d, 0x47, 0x3c, 0x49, 0x82, 0x68,
0xbe, 0x16, 0x85, 0xf1, 0xc2, 0x28, 0x5a, 0x6b, 0x51, 0xec, 0xc3, 0x6e, 0x79, 0x67, 0xed, 0x7d,
0x95, 0x85, 0xad, 0x3a, 0x9d, 0x51, 0xab, 0x2a, 0xd6, 0xab, 0x7c, 0xc3, 0x9e, 0xae, 0xdf, 0xd9,
0x56, 0xf0, 0x8d, 0xaa, 0xf4, 0xa1, 0xa7, 0xff, 0x7b, 0xc8, 0x2f, 0x79, 0x3d, 0x54, 0x1b, 0x22,
0x7f, 0x1f, 0xda, 0x87, 0x71, 0x34, 0x0b, 0xe6, 0xee, 0x00, 0xac, 0x61, 0x9e, 0x2d, 0xa4, 0xc5,
0xde, 0xfe, 0x6e, 0xa3, 0xf1, 0xf3, 0x6c, 0xa1, 0xee, 0x30, 0x79, 0xc3, 0xff, 0x02, 0xa0, 0x96,
0xd1, 0x1f, 0x97, 0xba, 0x1a, 0x8f, 0xf1, 0x8a, 0x28, 0x93, 0x4a, 0x2b, 0x5d, 0xb6, 0x45, 0xe3,
0x7f, 0x09, 0xce, 0x41, 0x1e, 0x84, 0xd3, 0xa3, 0x68, 0x16, 0xd3, 0xe8, 0x38, 0x47, 0x91, 0xd6,
0xf5, 0x2a, 0x21, 0xa5, 0x9b, 0xa6, 0x48, 0xd5, 0x43, 0x1a, 0x5d, 0xb4, 0xe5, 0x7f, 0xbf, 0x7b,
0xff, 0x06, 0x00, 0x00, 0xff, 0xff, 0xfe, 0xe9, 0xd1, 0x8f, 0x0d, 0x0e, 0x00, 0x00,
}

View File

@ -159,15 +159,22 @@ message User {
}
message Role {
string Organization = 1; // Organization is the ID of the organization that this user has a role in
string Name = 2; // Name is the name of the role of this user in the respective organization
string Organization = 1; // Organization is the ID of the organization that this user has a role in
string Name = 2; // Name is the name of the role of this user in the respective organization
}
message Mapping {
string Provider = 1; // Provider is the provider that certifies and issues this user's authentication, e.g. GitHub
string Scheme = 2; // Scheme is the scheme used to perform this user's authentication, e.g. OAuth2 or LDAP
string ProviderOrganization = 3; // ProviderOrganization is the group or organizations that you are a part of in an auth provider
string ID = 4; // ID is the unique ID for the mapping
string Organization = 5; // Organization is the organization ID that resource belongs to
}
message Organization {
string ID = 1; // ID is the unique ID of the organization
string Name = 2; // Name is the organization's name
string DefaultRole = 3; // DefaultRole is the name of the role that is the default for any users added to the organization
bool Public = 4; // Public specifies that users must be explicitly added to the organization
string ID = 1; // ID is the unique ID of the organization
string Name = 2; // Name is the organization's name
string DefaultRole = 3; // DefaultRole is the name of the role that is the default for any users added to the organization
}
message Config {
@ -179,8 +186,8 @@ message AuthConfig {
}
message BuildInfo {
string Version = 1; // Version is a descriptive git SHA identifier
string Commit = 2; // Commit is an abbreviated SHA
string Version = 1; // Version is a descriptive git SHA identifier
string Commit = 2; // Commit is an abbreviated SHA
}
// The following is a vim modeline, it autoconfigures vim to have the

128
bolt/mapping.go Normal file
View File

@ -0,0 +1,128 @@
package bolt
import (
"context"
"fmt"
"github.com/boltdb/bolt"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/bolt/internal"
)
// Ensure MappingsStore implements chronograf.MappingsStore.
var _ chronograf.MappingsStore = &MappingsStore{}
var (
// MappingsBucket is the bucket where organizations are stored.
MappingsBucket = []byte("MappingsV1")
)
// MappingsStore uses bolt to store and retrieve Mappings
type MappingsStore struct {
client *Client
}
// Migrate sets the default organization at runtime
func (s *MappingsStore) Migrate(ctx context.Context) error {
return nil
}
// Add creates a new Mapping in the MappingsStore
func (s *MappingsStore) Add(ctx context.Context, o *chronograf.Mapping) (*chronograf.Mapping, error) {
err := s.client.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(MappingsBucket)
seq, err := b.NextSequence()
if err != nil {
return err
}
o.ID = fmt.Sprintf("%d", seq)
v, err := internal.MarshalMapping(o)
if err != nil {
return err
}
return b.Put([]byte(o.ID), v)
})
if err != nil {
return nil, err
}
return o, nil
}
// All returns all known organizations
func (s *MappingsStore) All(ctx context.Context) ([]chronograf.Mapping, error) {
var mappings []chronograf.Mapping
err := s.each(ctx, func(m *chronograf.Mapping) {
mappings = append(mappings, *m)
})
if err != nil {
return nil, err
}
return mappings, nil
}
// Delete the organization from MappingsStore
func (s *MappingsStore) Delete(ctx context.Context, o *chronograf.Mapping) error {
_, err := s.get(ctx, o.ID)
if err != nil {
return err
}
if err := s.client.db.Update(func(tx *bolt.Tx) error {
return tx.Bucket(MappingsBucket).Delete([]byte(o.ID))
}); err != nil {
return err
}
return nil
}
func (s *MappingsStore) get(ctx context.Context, id string) (*chronograf.Mapping, error) {
var o chronograf.Mapping
err := s.client.db.View(func(tx *bolt.Tx) error {
v := tx.Bucket(MappingsBucket).Get([]byte(id))
if v == nil {
return chronograf.ErrMappingNotFound
}
return internal.UnmarshalMapping(v, &o)
})
if err != nil {
return nil, err
}
return &o, nil
}
func (s *MappingsStore) each(ctx context.Context, fn func(*chronograf.Mapping)) error {
return s.client.db.View(func(tx *bolt.Tx) error {
return tx.Bucket(MappingsBucket).ForEach(func(k, v []byte) error {
var m chronograf.Mapping
if err := internal.UnmarshalMapping(v, &m); err != nil {
return err
}
fn(&m)
return nil
})
})
}
// Get returns a Mapping if the id exists.
func (s *MappingsStore) Get(ctx context.Context, id string) (*chronograf.Mapping, error) {
return s.get(ctx, id)
}
// Update the organization in MappingsStore
func (s *MappingsStore) Update(ctx context.Context, o *chronograf.Mapping) error {
return s.client.db.Update(func(tx *bolt.Tx) error {
if v, err := internal.MarshalMapping(o); err != nil {
return err
} else if err := tx.Bucket(MappingsBucket).Put([]byte(o.ID), v); err != nil {
return err
}
return nil
})
}

483
bolt/mapping_test.go Normal file
View File

@ -0,0 +1,483 @@
package bolt_test
import (
"context"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/influxdata/chronograf"
)
var mappingCmpOptions = cmp.Options{
cmpopts.IgnoreFields(chronograf.Mapping{}, "ID"),
cmpopts.EquateEmpty(),
}
func TestMappingStore_Add(t *testing.T) {
type fields struct {
mappings []*chronograf.Mapping
}
type args struct {
mapping *chronograf.Mapping
}
type wants struct {
mapping *chronograf.Mapping
err error
}
tests := []struct {
name string
fields fields
args args
wants wants
}{
{
name: "default with wildcards",
args: args{
mapping: &chronograf.Mapping{
Organization: "default",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
},
wants: wants{
mapping: &chronograf.Mapping{
Organization: "default",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
},
},
{
name: "simple",
args: args{
mapping: &chronograf.Mapping{
Organization: "default",
Provider: "github",
Scheme: "oauth2",
ProviderOrganization: "idk",
},
},
wants: wants{
mapping: &chronograf.Mapping{
Organization: "default",
Provider: "github",
Scheme: "oauth2",
ProviderOrganization: "idk",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := NewTestClient()
if err != nil {
t.Fatal(err)
}
defer client.Close()
s := client.MappingsStore
ctx := context.Background()
for _, mapping := range tt.fields.mappings {
// YOLO database prepopulation
_, _ = s.Add(ctx, mapping)
}
tt.args.mapping, err = s.Add(ctx, tt.args.mapping)
if (err != nil) != (tt.wants.err != nil) {
t.Errorf("MappingsStore.Add() error = %v, want error %v", err, tt.wants.err)
return
}
got, err := s.Get(ctx, tt.args.mapping.ID)
if err != nil {
t.Fatalf("failed to get mapping: %v", err)
return
}
if diff := cmp.Diff(got, tt.wants.mapping, mappingCmpOptions...); diff != "" {
t.Errorf("MappingStore.Add():\n-got/+want\ndiff %s", diff)
return
}
})
}
}
func TestMappingStore_All(t *testing.T) {
type fields struct {
mappings []*chronograf.Mapping
}
type args struct {
}
type wants struct {
mappings []chronograf.Mapping
err error
}
tests := []struct {
name string
fields fields
args args
wants wants
}{
{
name: "simple",
fields: fields{
mappings: []*chronograf.Mapping{
&chronograf.Mapping{
Organization: "0",
Provider: "google",
Scheme: "ldap",
ProviderOrganization: "*",
},
},
},
wants: wants{
mappings: []chronograf.Mapping{
chronograf.Mapping{
Organization: "0",
Provider: "google",
Scheme: "ldap",
ProviderOrganization: "*",
},
chronograf.Mapping{
Organization: "default",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := NewTestClient()
if err != nil {
t.Fatal(err)
}
defer client.Close()
s := client.MappingsStore
ctx := context.Background()
for _, mapping := range tt.fields.mappings {
// YOLO database prepopulation
_, _ = s.Add(ctx, mapping)
}
got, err := s.All(ctx)
if (err != nil) != (tt.wants.err != nil) {
t.Errorf("MappingsStore.All() error = %v, want error %v", err, tt.wants.err)
return
}
if diff := cmp.Diff(got, tt.wants.mappings, mappingCmpOptions...); diff != "" {
t.Errorf("MappingStore.All():\n-got/+want\ndiff %s", diff)
return
}
})
}
}
func TestMappingStore_Delete(t *testing.T) {
type fields struct {
mappings []*chronograf.Mapping
}
type args struct {
mapping *chronograf.Mapping
}
type wants struct {
err error
}
tests := []struct {
name string
fields fields
args args
wants wants
}{
{
name: "simple",
fields: fields{
mappings: []*chronograf.Mapping{
&chronograf.Mapping{
Organization: "default",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
&chronograf.Mapping{
Organization: "0",
Provider: "google",
Scheme: "ldap",
ProviderOrganization: "*",
},
},
},
args: args{
mapping: &chronograf.Mapping{
ID: "1",
Organization: "default",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
},
wants: wants{
err: nil,
},
},
{
name: "mapping not found",
fields: fields{
mappings: []*chronograf.Mapping{
&chronograf.Mapping{
Organization: "default",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
&chronograf.Mapping{
Organization: "0",
Provider: "google",
Scheme: "ldap",
ProviderOrganization: "*",
},
},
},
args: args{
mapping: &chronograf.Mapping{
ID: "0",
Organization: "default",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
},
wants: wants{
err: chronograf.ErrMappingNotFound,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := NewTestClient()
if err != nil {
t.Fatal(err)
}
defer client.Close()
s := client.MappingsStore
ctx := context.Background()
for _, mapping := range tt.fields.mappings {
// YOLO database prepopulation
_, _ = s.Add(ctx, mapping)
}
err = s.Delete(ctx, tt.args.mapping)
if (err != nil) != (tt.wants.err != nil) {
t.Errorf("MappingsStore.Delete() error = %v, want error %v", err, tt.wants.err)
return
}
})
}
}
func TestMappingStore_Get(t *testing.T) {
type fields struct {
mappings []*chronograf.Mapping
}
type args struct {
mappingID string
}
type wants struct {
mapping *chronograf.Mapping
err error
}
tests := []struct {
name string
fields fields
args args
wants wants
}{
{
name: "simple",
fields: fields{
mappings: []*chronograf.Mapping{
&chronograf.Mapping{
Organization: "default",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
&chronograf.Mapping{
Organization: "0",
Provider: "google",
Scheme: "ldap",
ProviderOrganization: "*",
},
},
},
args: args{
mappingID: "1",
},
wants: wants{
mapping: &chronograf.Mapping{
ID: "1",
Organization: "default",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
err: nil,
},
},
{
name: "mapping not found",
fields: fields{
mappings: []*chronograf.Mapping{
&chronograf.Mapping{
Organization: "default",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
&chronograf.Mapping{
Organization: "0",
Provider: "google",
Scheme: "ldap",
ProviderOrganization: "*",
},
},
},
args: args{
mappingID: "0",
},
wants: wants{
err: chronograf.ErrMappingNotFound,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := NewTestClient()
if err != nil {
t.Fatal(err)
}
defer client.Close()
s := client.MappingsStore
ctx := context.Background()
for _, mapping := range tt.fields.mappings {
// YOLO database prepopulation
_, _ = s.Add(ctx, mapping)
}
got, err := s.Get(ctx, tt.args.mappingID)
if (err != nil) != (tt.wants.err != nil) {
t.Errorf("MappingsStore.Get() error = %v, want error %v", err, tt.wants.err)
return
}
if diff := cmp.Diff(got, tt.wants.mapping, mappingCmpOptions...); diff != "" {
t.Errorf("MappingStore.Get():\n-got/+want\ndiff %s", diff)
return
}
})
}
}
func TestMappingStore_Update(t *testing.T) {
type fields struct {
mappings []*chronograf.Mapping
}
type args struct {
mapping *chronograf.Mapping
}
type wants struct {
mapping *chronograf.Mapping
err error
}
tests := []struct {
name string
fields fields
args args
wants wants
}{
{
name: "simple",
fields: fields{
mappings: []*chronograf.Mapping{
&chronograf.Mapping{
Organization: "default",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
&chronograf.Mapping{
Organization: "0",
Provider: "google",
Scheme: "ldap",
ProviderOrganization: "*",
},
},
},
args: args{
mapping: &chronograf.Mapping{
ID: "1",
Organization: "default",
Provider: "cool",
Scheme: "it",
ProviderOrganization: "works",
},
},
wants: wants{
mapping: &chronograf.Mapping{
ID: "1",
Organization: "default",
Provider: "cool",
Scheme: "it",
ProviderOrganization: "works",
},
err: nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := NewTestClient()
if err != nil {
t.Fatal(err)
}
defer client.Close()
s := client.MappingsStore
ctx := context.Background()
for _, mapping := range tt.fields.mappings {
// YOLO database prepopulation
_, _ = s.Add(ctx, mapping)
}
err = s.Update(ctx, tt.args.mapping)
if (err != nil) != (tt.wants.err != nil) {
t.Errorf("MappingsStore.Update() error = %v, want error %v", err, tt.wants.err)
return
}
if diff := cmp.Diff(tt.args.mapping, tt.wants.mapping, mappingCmpOptions...); diff != "" {
t.Errorf("MappingStore.Update():\n-got/+want\ndiff %s", diff)
return
}
})
}
}

View File

@ -25,8 +25,6 @@ const (
DefaultOrganizationName string = "Default"
// DefaultOrganizationRole is the DefaultRole for the Default organization
DefaultOrganizationRole string = "member"
// DefaultOrganizationPublic is the Public setting for the Default organization.
DefaultOrganizationPublic bool = true
)
// OrganizationsStore uses bolt to store and retrieve Organizations
@ -45,7 +43,14 @@ func (s *OrganizationsStore) CreateDefault(ctx context.Context) error {
ID: string(DefaultOrganizationID),
Name: DefaultOrganizationName,
DefaultRole: DefaultOrganizationRole,
Public: DefaultOrganizationPublic,
}
m := chronograf.Mapping{
ID: string(DefaultOrganizationID),
Organization: string(DefaultOrganizationID),
Provider: chronograf.MappingWildcard,
Scheme: chronograf.MappingWildcard,
ProviderOrganization: chronograf.MappingWildcard,
}
return s.client.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(OrganizationsBucket)
@ -59,6 +64,17 @@ func (s *OrganizationsStore) CreateDefault(ctx context.Context) error {
return err
}
b = tx.Bucket(MappingsBucket)
v = b.Get(DefaultOrganizationID)
if v != nil {
return nil
}
if v, err := internal.MarshalMapping(&m); err != nil {
return err
} else if err := b.Put(DefaultOrganizationID, v); err != nil {
return err
}
return nil
})
}
@ -189,6 +205,18 @@ func (s *OrganizationsStore) Delete(ctx context.Context, o *chronograf.Organizat
}
}
mappings, err := s.client.MappingsStore.All(ctx)
if err != nil {
return err
}
for _, mapping := range mappings {
if mapping.Organization == o.ID {
if err := s.client.MappingsStore.Delete(ctx, &mapping); err != nil {
return err
}
}
}
return nil
}

View File

@ -170,12 +170,10 @@ func TestOrganizationsStore_All(t *testing.T) {
{
Name: "EE - Evil Empire",
DefaultRole: roles.MemberRoleName,
Public: true,
},
{
Name: "The Good Place",
DefaultRole: roles.EditorRoleName,
Public: true,
},
},
},
@ -183,17 +181,14 @@ func TestOrganizationsStore_All(t *testing.T) {
{
Name: "EE - Evil Empire",
DefaultRole: roles.MemberRoleName,
Public: true,
},
{
Name: "The Good Place",
DefaultRole: roles.EditorRoleName,
Public: true,
},
{
Name: bolt.DefaultOrganizationName,
DefaultRole: bolt.DefaultOrganizationRole,
Public: bolt.DefaultOrganizationPublic,
},
},
addFirst: true,
@ -316,52 +311,63 @@ func TestOrganizationsStore_Update(t *testing.T) {
addFirst: true,
},
{
name: "Update organization name, role, public",
name: "Update organization name, role",
fields: fields{},
args: args{
ctx: context.Background(),
initial: &chronograf.Organization{
Name: "The Good Place",
DefaultRole: roles.ViewerRoleName,
Public: false,
},
updates: &chronograf.Organization{
Name: "The Bad Place",
Public: true,
DefaultRole: roles.AdminRoleName,
},
},
want: &chronograf.Organization{
Name: "The Bad Place",
Public: true,
DefaultRole: roles.AdminRoleName,
},
addFirst: true,
},
{
name: "Update organization name and public",
name: "Update organization name",
fields: fields{},
args: args{
ctx: context.Background(),
initial: &chronograf.Organization{
Name: "The Good Place",
DefaultRole: roles.EditorRoleName,
Public: false,
},
updates: &chronograf.Organization{
Name: "The Bad Place",
Public: true,
Name: "The Bad Place",
},
},
want: &chronograf.Organization{
Name: "The Bad Place",
DefaultRole: roles.EditorRoleName,
Public: true,
},
addFirst: true,
},
{
name: "Update organization name - organization already exists",
name: "Update organization name",
fields: fields{},
args: args{
ctx: context.Background(),
initial: &chronograf.Organization{
Name: "The Good Place",
},
updates: &chronograf.Organization{
Name: "The Bad Place",
},
},
want: &chronograf.Organization{
Name: "The Bad Place",
},
addFirst: true,
},
{
name: "Update organization name - name already taken",
fields: fields{
orgs: []chronograf.Organization{
{
@ -409,10 +415,6 @@ func TestOrganizationsStore_Update(t *testing.T) {
tt.args.initial.DefaultRole = tt.args.updates.DefaultRole
}
if tt.args.updates.Public != tt.args.initial.Public {
tt.args.initial.Public = tt.args.updates.Public
}
if err := s.Update(tt.args.ctx, tt.args.initial); (err != nil) != tt.wantErr {
t.Errorf("%q. OrganizationsStore.Update() error = %v, wantErr %v", tt.name, err, tt.wantErr)
}
@ -618,7 +620,6 @@ func TestOrganizationsStore_DefaultOrganization(t *testing.T) {
ID: string(bolt.DefaultOrganizationID),
Name: bolt.DefaultOrganizationName,
DefaultRole: bolt.DefaultOrganizationRole,
Public: bolt.DefaultOrganizationPublic,
},
wantErr: false,
},

View File

@ -30,6 +30,7 @@ const (
ErrInvalidLegendOrient = Error("Invalid orientation type. Valid orientation types are 'top', 'bottom', 'right', 'left'")
ErrUserAlreadyExists = Error("user already exists")
ErrOrganizationNotFound = Error("organization not found")
ErrMappingNotFound = Error("mapping not found")
ErrOrganizationAlreadyExists = Error("organization already exists")
ErrCannotDeleteDefaultOrganization = Error("cannot delete default organization")
ErrConfigNotFound = Error("cannot find configuration")
@ -415,7 +416,7 @@ type User struct {
Name string `json:"name"`
Passwd string `json:"password,omitempty"`
Permissions Permissions `json:"permissions,omitempty"`
Roles []Role `json:"roles,omitempty"`
Roles []Role `json:"roles"`
Provider string `json:"provider,omitempty"`
Scheme string `json:"scheme,omitempty"`
SuperAdmin bool `json:"superAdmin,omitempty"`
@ -605,15 +606,52 @@ type LayoutsStore interface {
Update(context.Context, Layout) error
}
// MappingWildcard is the wildcard value for mappings
const MappingWildcard string = "*"
// A Mapping is the structure that is used to determine a users
// role within an organization. The high level idea is to grant
// certain roles to certain users without them having to be given
// explicit role within the organization.
//
// One can think of a mapping like so:
// Provider:Scheme:Group -> Organization
// github:oauth2:influxdata -> Happy
// beyondcorp:ldap:influxdata -> TheBillHilliettas
//
// Any of Provider, Scheme, or Group may be provided as a wildcard *
// github:oauth2:* -> MyOrg
// *:*:* -> AllOrg
type Mapping struct {
ID string `json:"id"`
Organization string `json:"organizationId"`
Provider string `json:"provider"`
Scheme string `json:"scheme"`
ProviderOrganization string `json:"providerOrganization"`
}
// MappingsStore is the storage and retrieval of Mappings
type MappingsStore interface {
// Add creates a new Mapping.
// The Created mapping is returned back to the user with the
// ID field populated.
Add(context.Context, *Mapping) (*Mapping, error)
// All lists all Mapping in the MappingsStore
All(context.Context) ([]Mapping, error)
// Delete removes an Mapping from the MappingsStore
Delete(context.Context, *Mapping) error
// Get retrieves an Mapping from the MappingsStore
Get(context.Context, string) (*Mapping, error)
// Update updates an Mapping in the MappingsStore
Update(context.Context, *Mapping) error
}
// Organization is a group of resources under a common name
type Organization struct {
ID string `json:"id"`
Name string `json:"name"`
// DefaultRole is the name of the role that is the default for any users added to the organization
DefaultRole string `json:"defaultRole,omitempty"`
// Public specifies whether users must be explicitly added to the organization.
// It is currently only used by the default organization, but that may change in the future.
Public bool `json:"public"`
}
// OrganizationQuery represents the attributes that a organization may be retrieved by.

View File

@ -31,6 +31,7 @@ deployment:
--upload
- sudo chown -R ubuntu:ubuntu /home/ubuntu
- cp build/linux/static_amd64/chronograf .
- cp build/linux/static_amd64/chronoctl .
- docker build -t chronograf .
- docker login -e $QUAY_EMAIL -u "$QUAY_USER" -p $QUAY_PASS quay.io
- docker tag chronograf quay.io/influxdb/chronograf:${CIRCLE_SHA1:0:7}
@ -52,6 +53,7 @@ deployment:
--bucket dl.influxdata.com/chronograf/releases
- sudo chown -R ubuntu:ubuntu /home/ubuntu
- cp build/linux/static_amd64/chronograf .
- cp build/linux/static_amd64/chronoctl .
- docker build -t chronograf .
- docker login -e $QUAY_EMAIL -u "$QUAY_USER" -p $QUAY_PASS quay.io
- docker tag chronograf quay.io/influxdb/chronograf:${CIRCLE_SHA1:0:7}
@ -75,6 +77,7 @@ deployment:
--bucket dl.influxdata.com/chronograf/releases
- sudo chown -R ubuntu:ubuntu /home/ubuntu
- cp build/linux/static_amd64/chronograf .
- cp build/linux/static_amd64/chronoctl .
- docker build -t chronograf .
- docker login -e $QUAY_EMAIL -u "$QUAY_USER" -p $QUAY_PASS quay.io
- docker tag chronograf quay.io/influxdb/chronograf:${CIRCLE_SHA1:0:7}

View File

@ -2,17 +2,18 @@ package main
import (
"context"
"strings"
"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"`
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. oauth2)" default:"oauth2"`
Organizations string `short:"o" long:"orgs" description:"A comma separated list of organizations that the user should be added to" default:"default"`
}
var addCommand AddCommand
@ -41,9 +42,15 @@ func (l *AddCommand) Execute(args []string) error {
return err
} else if err == chronograf.ErrUserNotFound {
user = &chronograf.User{
Name: l.Username,
Provider: l.Provider,
Scheme: l.Scheme,
Name: l.Username,
Provider: l.Provider,
Scheme: l.Scheme,
Roles: []chronograf.Role{
{
Name: "member",
Organization: "default",
},
},
SuperAdmin: true,
}
@ -53,13 +60,49 @@ func (l *AddCommand) Execute(args []string) error {
}
} else {
user.SuperAdmin = true
if len(user.Roles) == 0 {
user.Roles = []chronograf.Role{
{
Name: "member",
Organization: "default",
},
}
}
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
roles := []chronograf.Role{}
OrgLoop:
for _, org := range strings.Split(l.Organizations, ",") {
// Check to see is user is already a part of the organization
for _, r := range user.Roles {
if r.Organization == org {
continue OrgLoop
}
}
orgQuery := chronograf.OrganizationQuery{
ID: &org,
}
o, err := c.OrganizationsStore.Get(ctx, orgQuery)
if err != nil {
return err
}
role := chronograf.Role{
Organization: org,
Name: o.DefaultRole,
}
roles = append(roles, role)
}
user.Roles = append(user.Roles, roles...)
if err = c.UsersStore.Update(ctx, user); err != nil {
return err
}
w := NewTabWriter()
WriteHeaders(w)

View File

@ -1,6 +1,7 @@
package main
import (
"fmt"
"os"
"github.com/jessevdk/go-flags"
@ -18,8 +19,9 @@ func main() {
if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp {
os.Exit(0)
} else {
fmt.Fprintln(os.Stdout)
parser.WriteHelp(os.Stdout)
os.Exit(1)
}
}
}

View File

@ -51,13 +51,13 @@ type Client struct {
}
// NewClientWithTimeSeries initializes a Client with a known set of TimeSeries.
func NewClientWithTimeSeries(lg chronograf.Logger, mu string, authorizer influx.Authorizer, tls bool, series ...chronograf.TimeSeries) (*Client, error) {
func NewClientWithTimeSeries(lg chronograf.Logger, mu string, authorizer influx.Authorizer, tls, insecure bool, series ...chronograf.TimeSeries) (*Client, error) {
metaURL, err := parseMetaURL(mu, tls)
if err != nil {
return nil, err
}
ctrl := NewMetaClient(metaURL, authorizer)
ctrl := NewMetaClient(metaURL, insecure, authorizer)
c := &Client{
Ctrl: ctrl,
UsersStore: &UserStore{
@ -85,13 +85,13 @@ func NewClientWithTimeSeries(lg chronograf.Logger, mu string, authorizer influx.
// varieties. TLS is used when the URL contains "https" or when the TLS
// parameter is set. authorizer will add the correct `Authorization` headers
// on the out-bound request.
func NewClientWithURL(mu string, authorizer influx.Authorizer, tls bool, lg chronograf.Logger) (*Client, error) {
func NewClientWithURL(mu string, authorizer influx.Authorizer, tls bool, insecure bool, lg chronograf.Logger) (*Client, error) {
metaURL, err := parseMetaURL(mu, tls)
if err != nil {
return nil, err
}
ctrl := NewMetaClient(metaURL, authorizer)
ctrl := NewMetaClient(metaURL, insecure, authorizer)
return &Client{
Ctrl: ctrl,
UsersStore: &UserStore{

View File

@ -84,6 +84,7 @@ func Test_Enterprise_AdvancesDataNodes(t *testing.T) {
Password: "thelake",
},
false,
false,
chronograf.TimeSeries(m1),
chronograf.TimeSeries(m2))
if err != nil {
@ -114,23 +115,53 @@ func Test_Enterprise_NewClientWithURL(t *testing.T) {
t.Parallel()
urls := []struct {
url string
username string
password string
tls bool
shouldErr bool
name string
url string
username string
password string
tls bool
insecureSkipVerify bool
wantErr bool
}{
{"http://localhost:8086", "", "", false, false},
{"https://localhost:8086", "", "", false, false},
{"http://localhost:8086", "username", "password", false, false},
{"http://localhost:8086", "", "", true, false},
{"https://localhost:8086", "", "", true, false},
{"localhost:8086", "", "", false, false},
{"localhost:8086", "", "", true, false},
{":http", "", "", false, true},
{
name: "no tls should have no error",
url: "http://localhost:8086",
},
{
name: "tls sholuld have no error",
url: "https://localhost:8086",
},
{
name: "no tls but with basic auth",
url: "http://localhost:8086",
username: "username",
password: "password",
},
{
name: "tls request but url is not tls should not error",
url: "http://localhost:8086",
tls: true,
},
{
name: "https with tls and with insecureSkipVerify should not error",
url: "https://localhost:8086",
tls: true,
insecureSkipVerify: true,
},
{
name: "URL does not require http or https",
url: "localhost:8086",
},
{
name: "URL with TLS request should not error",
url: "localhost:8086",
tls: true,
},
{
name: "invalid URL causes error",
url: ":http",
wantErr: true,
},
}
for _, testURL := range urls {
@ -141,10 +172,11 @@ func Test_Enterprise_NewClientWithURL(t *testing.T) {
Password: testURL.password,
},
testURL.tls,
testURL.insecureSkipVerify,
log.New(log.DebugLevel))
if err != nil && !testURL.shouldErr {
if err != nil && !testURL.wantErr {
t.Errorf("Unexpected error creating Client with URL %s and TLS preference %t. err: %s", testURL.url, testURL.tls, err.Error())
} else if err == nil && testURL.shouldErr {
} else if err == nil && testURL.wantErr {
t.Errorf("Expected error creating Client with URL %s and TLS preference %t", testURL.url, testURL.tls)
}
}
@ -159,7 +191,7 @@ func Test_Enterprise_ComplainsIfNotOpened(t *testing.T) {
Username: "docbrown",
Password: "1.21 gigawatts",
},
false, chronograf.TimeSeries(m1))
false, false, chronograf.TimeSeries(m1))
if err != nil {
t.Error("Expected ErrUnitialized, but was this err:", err)
}

View File

@ -3,6 +3,7 @@ package enterprise
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
@ -14,6 +15,14 @@ import (
"github.com/influxdata/chronograf/influx"
)
// Shared transports for all clients to prevent leaking connections
var (
skipVerifyTransport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
defaultTransport = &http.Transport{}
)
type client interface {
Do(URL *url.URL, path, method string, authorizer influx.Authorizer, params map[string]string, body io.Reader) (*http.Response, error)
}
@ -26,10 +35,12 @@ type MetaClient struct {
}
// NewMetaClient represents a meta node in an Influx Enterprise cluster
func NewMetaClient(url *url.URL, authorizer influx.Authorizer) *MetaClient {
func NewMetaClient(url *url.URL, InsecureSkipVerify bool, authorizer influx.Authorizer) *MetaClient {
return &MetaClient{
URL: url,
client: &defaultClient{},
URL: url,
client: &defaultClient{
InsecureSkipVerify: InsecureSkipVerify,
},
authorizer: authorizer,
}
}
@ -399,7 +410,8 @@ func (m *MetaClient) Post(ctx context.Context, path string, action interface{},
}
type defaultClient struct {
Leader string
Leader string
InsecureSkipVerify bool
}
// Do is a helper function to interface with Influx Enterprise's Meta API
@ -438,6 +450,12 @@ func (d *defaultClient) Do(URL *url.URL, path, method string, authorizer influx.
CheckRedirect: d.AuthedCheckRedirect,
}
if d.InsecureSkipVerify {
client.Transport = skipVerifyTransport
} else {
client.Transport = defaultTransport
}
res, err := client.Do(req)
if err != nil {
return nil, err

View File

@ -37,7 +37,7 @@ func (c *UserStore) Delete(ctx context.Context, u *chronograf.User) error {
return c.Ctrl.DeleteUser(ctx, u.Name)
}
// Number of users in Influx
// Num of users in Influx
func (c *UserStore) Num(ctx context.Context) (int, error) {
all, err := c.All(ctx)
if err != nil {

View File

@ -75,6 +75,7 @@ for f in CONFIGURATION_FILES:
targets = {
'chronograf' : './cmd/chronograf',
'chronoctl' : './cmd/chronoctl',
}
supported_builds = {

View File

@ -26,6 +26,7 @@ import (
func TestServer(t *testing.T) {
type fields struct {
Organizations []chronograf.Organization
Mappings []chronograf.Mapping
Users []chronograf.User
Sources []chronograf.Source
Servers []chronograf.Server
@ -352,8 +353,7 @@ func TestServer(t *testing.T) {
},
"id": "default",
"name": "Default",
"defaultRole": "member",
"public": true
"defaultRole": "member"
},
{
"links": {
@ -361,8 +361,7 @@ func TestServer(t *testing.T) {
},
"id": "howdy",
"name": "An Organization",
"defaultRole": "viewer",
"public": false
"defaultRole": "viewer"
}
]
}`,
@ -410,8 +409,7 @@ func TestServer(t *testing.T) {
},
"id": "howdy",
"name": "An Organization",
"defaultRole": "viewer",
"public": false
"defaultRole": "viewer"
}`,
},
},
@ -2045,24 +2043,21 @@ func TestServer(t *testing.T) {
"self": "/chronograf/v1/organizations/1/users/1"
},
"organizations": [
{
"id": "1",
"name": "Sweet",
"defaultRole": "viewer",
"public": false
},
{
"id": "default",
"name": "Default",
"defaultRole": "member",
"public": true
}
{
"id": "1",
"name": "Sweet",
"defaultRole": "viewer"
},
{
"id": "default",
"name": "Default",
"defaultRole": "member"
}
],
"currentOrganization": {
"id": "1",
"name": "Sweet",
"defaultRole": "viewer",
"public": false
"defaultRole": "viewer"
}
}`,
},
@ -2124,6 +2119,502 @@ func TestServer(t *testing.T) {
}`,
},
},
{
name: "GET /me",
subName: "New user hits me for the first time",
fields: fields{
Config: &chronograf.Config{
Auth: chronograf.AuthConfig{
SuperAdminNewUsers: false,
},
},
Mappings: []chronograf.Mapping{
{
ID: "1",
Organization: "1",
Provider: "*",
Scheme: "*",
ProviderOrganization: "influxdata",
},
{
ID: "1",
Organization: "1",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
{
ID: "2",
Organization: "2",
Provider: "github",
Scheme: "*",
ProviderOrganization: "*",
},
{
ID: "3",
Organization: "3",
Provider: "auth0",
Scheme: "ldap",
ProviderOrganization: "*",
},
},
Organizations: []chronograf.Organization{
{
ID: "1",
Name: "Sweet",
DefaultRole: roles.ViewerRoleName,
},
{
ID: "2",
Name: "What",
DefaultRole: roles.EditorRoleName,
},
{
ID: "3",
Name: "Okay",
DefaultRole: roles.AdminRoleName,
},
},
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{},
},
},
},
args: args{
server: &server.Server{
GithubClientID: "not empty",
GithubClientSecret: "not empty",
},
method: "GET",
path: "/chronograf/v1/me",
principal: oauth2.Principal{
Subject: "billietta",
Issuer: "github",
Group: "influxdata,idk,mimi",
},
},
wants: wants{
statusCode: 200,
body: `
{
"id": "2",
"name": "billietta",
"roles": [
{
"name": "viewer",
"organization": "1"
},
{
"name": "editor",
"organization": "2"
},
{
"name": "member",
"organization": "default"
}
],
"provider": "github",
"scheme": "oauth2",
"links": {
"self": "/chronograf/v1/organizations/default/users/2"
},
"organizations": [
{
"id": "1",
"name": "Sweet",
"defaultRole": "viewer"
},
{
"id": "2",
"name": "What",
"defaultRole": "editor"
},
{
"id": "default",
"name": "Default",
"defaultRole": "member"
}
],
"currentOrganization": {
"id": "default",
"name": "Default",
"defaultRole": "member"
}
}
`,
},
},
{
name: "GET /mappings",
subName: "get all mappings",
fields: fields{
Config: &chronograf.Config{
Auth: chronograf.AuthConfig{
SuperAdminNewUsers: false,
},
},
Mappings: []chronograf.Mapping{
{
ID: "1",
Organization: "1",
Provider: "*",
Scheme: "*",
ProviderOrganization: "influxdata",
},
{
ID: "1",
Organization: "1",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
{
ID: "2",
Organization: "2",
Provider: "github",
Scheme: "*",
ProviderOrganization: "*",
},
{
ID: "3",
Organization: "3",
Provider: "auth0",
Scheme: "ldap",
ProviderOrganization: "*",
},
},
Organizations: []chronograf.Organization{
{
ID: "1",
Name: "Sweet",
DefaultRole: roles.ViewerRoleName,
},
{
ID: "2",
Name: "What",
DefaultRole: roles.EditorRoleName,
},
{
ID: "3",
Name: "Okay",
DefaultRole: roles.AdminRoleName,
},
},
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,
},
},
},
args: args{
server: &server.Server{
GithubClientID: "not empty",
GithubClientSecret: "not empty",
},
method: "GET",
path: "/chronograf/v1/mappings",
principal: oauth2.Principal{
Subject: "billibob",
Issuer: "github",
Group: "influxdata,idk,mimi",
},
},
wants: wants{
statusCode: 200,
body: `
{
"links": {
"self": "/chronograf/v1/mappings"
},
"mappings": [
{
"links": {
"self": "/chronograf/v1/mappings/1"
},
"id": "1",
"organizationId": "1",
"provider": "*",
"scheme": "*",
"providerOrganization": "influxdata"
},
{
"links": {
"self": "/chronograf/v1/mappings/2"
},
"id": "2",
"organizationId": "1",
"provider": "*",
"scheme": "*",
"providerOrganization": "*"
},
{
"links": {
"self": "/chronograf/v1/mappings/3"
},
"id": "3",
"organizationId": "2",
"provider": "github",
"scheme": "*",
"providerOrganization": "*"
},
{
"links": {
"self": "/chronograf/v1/mappings/4"
},
"id": "4",
"organizationId": "3",
"provider": "auth0",
"scheme": "ldap",
"providerOrganization": "*"
},
{
"links": {
"self": "/chronograf/v1/mappings/default"
},
"id": "default",
"organizationId": "default",
"provider": "*",
"scheme": "*",
"providerOrganization": "*"
}
]
}
`,
},
},
{
name: "GET /mappings",
subName: "get all mappings - user is not super admin",
fields: fields{
Config: &chronograf.Config{
Auth: chronograf.AuthConfig{
SuperAdminNewUsers: false,
},
},
Mappings: []chronograf.Mapping{
{
ID: "1",
Organization: "1",
Provider: "*",
Scheme: "*",
ProviderOrganization: "influxdata",
},
{
ID: "1",
Organization: "1",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
{
ID: "2",
Organization: "2",
Provider: "github",
Scheme: "*",
ProviderOrganization: "*",
},
{
ID: "3",
Organization: "3",
Provider: "auth0",
Scheme: "ldap",
ProviderOrganization: "*",
},
},
Organizations: []chronograf.Organization{
{
ID: "1",
Name: "Sweet",
DefaultRole: roles.ViewerRoleName,
},
{
ID: "2",
Name: "What",
DefaultRole: roles.EditorRoleName,
},
{
ID: "3",
Name: "Okay",
DefaultRole: roles.AdminRoleName,
},
},
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,
},
},
},
args: args{
server: &server.Server{
GithubClientID: "not empty",
GithubClientSecret: "not empty",
},
method: "GET",
path: "/chronograf/v1/mappings",
principal: oauth2.Principal{
Subject: "billibob",
Issuer: "github",
Group: "influxdata,idk,mimi",
},
},
wants: wants{
statusCode: 403,
body: `
{
"code": 403,
"message": "User is not authorized"
}
`,
},
},
{
name: "POST /mappings",
subName: "create new mapping",
fields: fields{
Config: &chronograf.Config{
Auth: chronograf.AuthConfig{
SuperAdminNewUsers: false,
},
},
Mappings: []chronograf.Mapping{},
Organizations: []chronograf.Organization{
{
ID: "1",
Name: "Sweet",
DefaultRole: roles.ViewerRoleName,
},
},
Users: []chronograf.User{
{
ID: 1, // This is artificial, but should be reflective of the users actual ID
Name: "billibob",
Provider: "github",
Scheme: "oauth2",
SuperAdmin: true,
},
},
},
args: args{
server: &server.Server{
GithubClientID: "not empty",
GithubClientSecret: "not empty",
},
method: "POST",
path: "/chronograf/v1/mappings",
payload: &chronograf.Mapping{
ID: "1",
Organization: "1",
Provider: "*",
Scheme: "*",
ProviderOrganization: "influxdata",
},
principal: oauth2.Principal{
Subject: "billibob",
Issuer: "github",
Group: "influxdata,idk,mimi",
},
},
wants: wants{
statusCode: 201,
body: `
{
"links": {
"self": "/chronograf/v1/mappings/1"
},
"id": "1",
"organizationId": "1",
"provider": "*",
"scheme": "*",
"providerOrganization": "influxdata"
}
`,
},
},
{
name: "PUT /mappings",
subName: "update new mapping",
fields: fields{
Config: &chronograf.Config{
Auth: chronograf.AuthConfig{
SuperAdminNewUsers: false,
},
},
Mappings: []chronograf.Mapping{
chronograf.Mapping{
ID: "1",
Organization: "1",
Provider: "*",
Scheme: "*",
ProviderOrganization: "influxdata",
},
},
Organizations: []chronograf.Organization{
{
ID: "1",
Name: "Sweet",
DefaultRole: roles.ViewerRoleName,
},
},
Users: []chronograf.User{
{
ID: 1, // This is artificial, but should be reflective of the users actual ID
Name: "billibob",
Provider: "github",
Scheme: "oauth2",
SuperAdmin: true,
},
},
},
args: args{
server: &server.Server{
GithubClientID: "not empty",
GithubClientSecret: "not empty",
},
method: "PUT",
path: "/chronograf/v1/mappings/1",
payload: &chronograf.Mapping{
ID: "1",
Organization: "1",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
principal: oauth2.Principal{
Subject: "billibob",
Issuer: "github",
Group: "influxdata,idk,mimi",
},
},
wants: wants{
statusCode: 200,
body: `
{
"links": {
"self": "/chronograf/v1/mappings/1"
},
"id": "1",
"organizationId": "1",
"provider": "*",
"scheme": "*",
"providerOrganization": "*"
}
`,
},
},
{
name: "GET /",
subName: "signed into default org",
@ -2325,6 +2816,16 @@ func TestServer(t *testing.T) {
}
}
// Populate Organizations
for i, mapping := range tt.fields.Mappings {
o, err := boltdb.MappingsStore.Add(ctx, &mapping)
if err != nil {
t.Fatalf("failed to add mapping: %v", err)
return
}
tt.fields.Mappings[i] = *o
}
// Populate Organizations
for i, organization := range tt.fields.Organizations {
o, err := boltdb.OrganizationsStore.Add(ctx, &organization)

35
mocks/mapping.go Normal file
View File

@ -0,0 +1,35 @@
package mocks
import (
"context"
"github.com/influxdata/chronograf"
)
type MappingsStore struct {
AddF func(context.Context, *chronograf.Mapping) (*chronograf.Mapping, error)
AllF func(context.Context) ([]chronograf.Mapping, error)
DeleteF func(context.Context, *chronograf.Mapping) error
UpdateF func(context.Context, *chronograf.Mapping) error
GetF func(context.Context, string) (*chronograf.Mapping, error)
}
func (s *MappingsStore) Add(ctx context.Context, m *chronograf.Mapping) (*chronograf.Mapping, error) {
return s.AddF(ctx, m)
}
func (s *MappingsStore) All(ctx context.Context) ([]chronograf.Mapping, error) {
return s.AllF(ctx)
}
func (s *MappingsStore) Delete(ctx context.Context, m *chronograf.Mapping) error {
return s.DeleteF(ctx, m)
}
func (s *MappingsStore) Get(ctx context.Context, id string) (*chronograf.Mapping, error) {
return s.GetF(ctx, id)
}
func (s *MappingsStore) Update(ctx context.Context, m *chronograf.Mapping) error {
return s.UpdateF(ctx, m)
}

View File

@ -9,6 +9,7 @@ import (
// Store is a server.DataStore
type Store struct {
SourcesStore chronograf.SourcesStore
MappingsStore chronograf.MappingsStore
ServersStore chronograf.ServersStore
LayoutsStore chronograf.LayoutsStore
UsersStore chronograf.UsersStore
@ -36,6 +37,9 @@ func (s *Store) Users(ctx context.Context) chronograf.UsersStore {
func (s *Store) Organizations(ctx context.Context) chronograf.OrganizationsStore {
return s.OrganizationsStore
}
func (s *Store) Mappings(ctx context.Context) chronograf.MappingsStore {
return s.MappingsStore
}
func (s *Store) Dashboards(ctx context.Context) chronograf.DashboardsStore {
return s.DashboardsStore

33
noop/mappings.go Normal file
View File

@ -0,0 +1,33 @@
package noop
import (
"context"
"fmt"
"github.com/influxdata/chronograf"
)
// ensure MappingsStore implements chronograf.MappingsStore
var _ chronograf.MappingsStore = &MappingsStore{}
type MappingsStore struct{}
func (s *MappingsStore) All(context.Context) ([]chronograf.Mapping, error) {
return nil, fmt.Errorf("no mappings found")
}
func (s *MappingsStore) Add(context.Context, *chronograf.Mapping) (*chronograf.Mapping, error) {
return nil, fmt.Errorf("failed to add mapping")
}
func (s *MappingsStore) Delete(context.Context, *chronograf.Mapping) error {
return fmt.Errorf("failed to delete mapping")
}
func (s *MappingsStore) Get(ctx context.Context, ID string) (*chronograf.Mapping, error) {
return nil, chronograf.ErrMappingNotFound
}
func (s *MappingsStore) Update(context.Context, *chronograf.Mapping) error {
return fmt.Errorf("failed to update mapping")
}

View File

@ -8,6 +8,8 @@ import (
"github.com/influxdata/chronograf"
)
var _ Provider = &Auth0{}
type Auth0 struct {
Generic
Organizations map[string]bool // the set of allowed organizations users may belong to
@ -41,6 +43,26 @@ func (a *Auth0) PrincipalID(provider *http.Client) (string, error) {
return act.Email, nil
}
func (a *Auth0) Group(provider *http.Client) (string, error) {
type Account struct {
Email string `json:"email"`
Organization string `json:"organization"`
}
resp, err := provider.Get(a.Generic.APIURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
act := Account{}
if err = json.NewDecoder(resp.Body).Decode(&act); err != nil {
return "", err
}
return act.Organization, nil
}
func NewAuth0(auth0Domain, clientID, clientSecret, redirectURL string, organizations []string, logger chronograf.Logger) (Auth0, error) {
domain, err := url.Parse(auth0Domain)
if err != nil {

View File

@ -111,6 +111,31 @@ func (g *Generic) PrincipalID(provider *http.Client) (string, error) {
return email, nil
}
// Group returns the domain that a user belongs to in the
// the generic OAuth.
func (g *Generic) Group(provider *http.Client) (string, error) {
res := struct {
Email string `json:"email"`
}{}
r, err := provider.Get(g.APIURL)
if err != nil {
return "", err
}
defer r.Body.Close()
if err = json.NewDecoder(r.Body).Decode(&res); err != nil {
return "", err
}
email := strings.Split(res.Email, "@")
if len(email) != 2 {
return "", fmt.Errorf("malformed email address, expected %q to contain @ symbol", res.Email)
}
return email[1], nil
}
// UserEmail represents user's email address
type UserEmail struct {
Email *string `json:"email,omitempty"`

View File

@ -6,6 +6,7 @@ import (
"errors"
"io"
"net/http"
"strings"
"github.com/google/go-github/github"
"github.com/influxdata/chronograf"
@ -44,9 +45,9 @@ func (g *Github) Secret() string {
// we are filtering by organizations.
func (g *Github) Scopes() []string {
scopes := []string{"user:email"}
if len(g.Orgs) > 0 {
scopes = append(scopes, "read:org")
}
// In order to access a users orgs, we need the "read:org" scope
// even if g.Orgs == 0
scopes = append(scopes, "read:org")
return scopes
}
@ -84,6 +85,26 @@ func (g *Github) PrincipalID(provider *http.Client) (string, error) {
return email, nil
}
// Group returns a comma delimited string of Github organizations
// that a user belongs to in Github
func (g *Github) Group(provider *http.Client) (string, error) {
client := github.NewClient(provider)
orgs, err := getOrganizations(client, g.Logger)
if err != nil {
return "", err
}
groups := []string{}
for _, org := range orgs {
if org.Login != nil {
groups = append(groups, *org.Login)
continue
}
}
return strings.Join(groups, ","), nil
}
func randomString(length int) string {
k := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, k); err != nil {

View File

@ -88,3 +88,19 @@ func (g *Google) PrincipalID(provider *http.Client) (string, error) {
g.Logger.Error("Domain '", info.Hd, "' is not a member of required Google domain(s): ", g.Domains)
return "", fmt.Errorf("Not in required domain")
}
// Group returns the string of domain a user belongs to in Google
func (g *Google) Group(provider *http.Client) (string, error) {
srv, err := goauth2.New(provider)
if err != nil {
g.Logger.Error("Unable to communicate with Google ", err.Error())
return "", err
}
info, err := srv.Userinfo.Get().Do()
if err != nil {
g.Logger.Error("Unable to retrieve Google email ", err.Error())
return "", err
}
return info.Hd, nil
}

View File

@ -88,6 +88,34 @@ func (h *Heroku) PrincipalID(provider *http.Client) (string, error) {
return account.Email, nil
}
// Group returns the Heroku organization that user belongs to.
func (h *Heroku) Group(provider *http.Client) (string, error) {
type DefaultOrg struct {
ID string `json:"id"`
Name string `json:"name"`
}
type Account struct {
Email string `json:"email"`
DefaultOrganization DefaultOrg `json:"default_organization"`
}
resp, err := provider.Get(HerokuAccountRoute)
if err != nil {
h.Logger.Error("Unable to communicate with Heroku. err:", err)
return "", err
}
defer resp.Body.Close()
d := json.NewDecoder(resp.Body)
var account Account
if err := d.Decode(&account); err != nil {
h.Logger.Error("Unable to decode response from Heroku. err:", err)
return "", err
}
return account.DefaultOrganization.Name, nil
}
// Scopes for heroku is "identity" which grants access to user account
// information. This will grant us access to the user's email address which is
// used as the Principal's identifier.

View File

@ -31,9 +31,18 @@ var _ gojwt.Claims = &Claims{}
// Claims extends jwt.StandardClaims' Valid to make sure claims has a subject.
type Claims struct {
gojwt.StandardClaims
// We were unable to find a standard claim at https://www.iana.org/assignments/jwt/jwt.xhtmldd
// We were unable to find a standard claim at https://www.iana.org/assignments/jwt/jwt.xhtml
// that felt appropriate for Organization. As a result, we added a custom `org` field.
Organization string `json:"org,omitempty"`
// We were unable to find a standard claim at https://www.iana.org/assignments/jwt/jwt.xhtml
// that felt appropriate for a users Group(s). As a result we added a custom `grp` field.
// Multiple groups may be specified by comma delimiting the various group.
//
// The singlular `grp` was chosen over the `grps` to keep consistent with the JWT naming
// convention (it is common for singlularly named values to actually be arrays, see `given_name`,
// `family_name`, and `middle_name` in the iana link provided above). I should add the discalimer
// I'm currently sick, so this thought process might be off.
Group string `json:"grp,omitempty"`
}
// Valid adds an empty subject test to the StandardClaims checks.
@ -99,6 +108,7 @@ func (j *JWT) ValidClaims(jwtToken Token, lifespan time.Duration, alg gojwt.Keyf
Subject: claims.Subject,
Issuer: claims.Issuer,
Organization: claims.Organization,
Group: claims.Group,
ExpiresAt: exp,
IssuedAt: iat,
}, nil
@ -117,6 +127,7 @@ func (j *JWT) Create(ctx context.Context, user Principal) (Token, error) {
NotBefore: user.IssuedAt.Unix(),
},
Organization: user.Organization,
Group: user.Group,
}
token := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims)
// Sign and get the complete encoded token as a string using the secret

View File

@ -23,8 +23,8 @@ func NewAuthMux(p Provider, a Authenticator, t Tokenizer, basepath string, l chr
Tokens: t,
SuccessURL: path.Join(basepath, "/"),
FailureURL: path.Join(basepath, "/login"),
Now: DefaultNowTime,
Logger: l,
Now: DefaultNowTime,
Logger: l,
}
}
@ -125,9 +125,17 @@ func (j *AuthMux) Callback() http.Handler {
return
}
group, err := j.Provider.Group(oauthClient)
if err != nil {
log.Error("Unable to get OAuth Group", err.Error())
http.Redirect(w, r, j.FailureURL, http.StatusTemporaryRedirect)
return
}
p := Principal{
Subject: id,
Issuer: j.Provider.Name(),
Group: group,
}
ctx := r.Context()
err = j.Auth.Authorize(ctx, w, p)

View File

@ -27,7 +27,11 @@ func setupMuxTest(selector func(*AuthMux) http.Handler) (*http.Client, *httptest
now := func() time.Time {
return testTime
}
mp := &MockProvider{"biff@example.com", provider.URL}
mp := &MockProvider{
Email: "biff@example.com",
ProviderURL: provider.URL,
Orgs: "",
}
mt := &YesManTokenizer{}
auth := &cookie{
Name: DefaultCookieName,

View File

@ -33,6 +33,7 @@ type Principal struct {
Subject string
Issuer string
Organization string
Group string
ExpiresAt time.Time
IssuedAt time.Time
}
@ -53,6 +54,10 @@ type Provider interface {
PrincipalID(provider *http.Client) (string, error)
// Name is the name of the Provider
Name() string
// Group is a comma delimited list of groups and organizations for a provider
// TODO: This will break if there are any group names that contain commas.
// I think this is okay, but I'm not 100% certain.
Group(provider *http.Client) (string, error)
}
// Mux is a collection of handlers responsible for servicing an Oauth2 interaction between a browser and a provider

View File

@ -16,6 +16,7 @@ var _ Provider = &MockProvider{}
type MockProvider struct {
Email string
Orgs string
ProviderURL string
}
@ -44,6 +45,10 @@ func (mp *MockProvider) PrincipalID(provider *http.Client) (string, error) {
return mp.Email, nil
}
func (mp *MockProvider) Group(provider *http.Client) (string, error) {
return mp.Orgs, nil
}
func (mp *MockProvider) Scopes() []string {
return []string{}
}

250
server/mapping.go Normal file
View File

@ -0,0 +1,250 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/oauth2"
)
func (s *Service) mapPrincipalToRoles(ctx context.Context, p oauth2.Principal) ([]chronograf.Role, error) {
mappings, err := s.Store.Mappings(ctx).All(ctx)
if err != nil {
return nil, err
}
roles := []chronograf.Role{}
MappingsLoop:
for _, mapping := range mappings {
if applyMapping(mapping, p) {
org, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &mapping.Organization})
if err != nil {
continue MappingsLoop
}
for _, role := range roles {
if role.Organization == org.ID {
continue MappingsLoop
}
}
roles = append(roles, chronograf.Role{Organization: org.ID, Name: org.DefaultRole})
}
}
return roles, nil
}
func applyMapping(m chronograf.Mapping, p oauth2.Principal) bool {
switch m.Provider {
case chronograf.MappingWildcard, p.Issuer:
default:
return false
}
switch m.Scheme {
case chronograf.MappingWildcard, "oauth2":
default:
return false
}
if m.ProviderOrganization == chronograf.MappingWildcard {
return true
}
groups := strings.Split(p.Group, ",")
return matchGroup(m.ProviderOrganization, groups)
}
func matchGroup(match string, groups []string) bool {
for _, group := range groups {
if match == group {
return true
}
}
return false
}
type mappingsRequest chronograf.Mapping
// Valid determines if a mapping request is valid
func (m *mappingsRequest) Valid() error {
if m.Provider == "" {
return fmt.Errorf("mapping must specify provider")
}
if m.Scheme == "" {
return fmt.Errorf("mapping must specify scheme")
}
if m.ProviderOrganization == "" {
return fmt.Errorf("mapping must specify group")
}
return nil
}
type mappingResponse struct {
Links selfLinks `json:"links"`
chronograf.Mapping
}
func newMappingResponse(m chronograf.Mapping) *mappingResponse {
return &mappingResponse{
Links: selfLinks{
Self: fmt.Sprintf("/chronograf/v1/mappings/%s", m.ID),
},
Mapping: m,
}
}
type mappingsResponse struct {
Links selfLinks `json:"links"`
Mappings []*mappingResponse `json:"mappings"`
}
func newMappingsResponse(ms []chronograf.Mapping) *mappingsResponse {
mappings := []*mappingResponse{}
for _, m := range ms {
mappings = append(mappings, newMappingResponse(m))
}
return &mappingsResponse{
Links: selfLinks{
Self: "/chronograf/v1/mappings",
},
Mappings: mappings,
}
}
// Mappings retrives all mappings
func (s *Service) Mappings(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
mappings, err := s.Store.Mappings(ctx).All(ctx)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to retrieve mappings from database", s.Logger)
return
}
fmt.Printf("mappings: %#v\n", mappings)
res := newMappingsResponse(mappings)
encodeJSON(w, http.StatusOK, res, s.Logger)
}
// NewMapping adds a new mapping
func (s *Service) NewMapping(w http.ResponseWriter, r *http.Request) {
var req mappingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, s.Logger)
return
}
if err := req.Valid(); err != nil {
invalidData(w, err, s.Logger)
return
}
ctx := r.Context()
// validate that the organization exists
if !s.organizationExists(ctx, req.Organization) {
invalidData(w, fmt.Errorf("organization does not exist"), s.Logger)
return
}
mapping := &chronograf.Mapping{
Organization: req.Organization,
Scheme: req.Scheme,
Provider: req.Provider,
ProviderOrganization: req.ProviderOrganization,
}
m, err := s.Store.Mappings(ctx).Add(ctx, mapping)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to add mapping to database", s.Logger)
return
}
cu := newMappingResponse(*m)
location(w, cu.Links.Self)
encodeJSON(w, http.StatusCreated, cu, s.Logger)
}
// UpdateMapping updates a mapping
func (s *Service) UpdateMapping(w http.ResponseWriter, r *http.Request) {
var req mappingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, s.Logger)
return
}
if err := req.Valid(); err != nil {
invalidData(w, err, s.Logger)
return
}
ctx := r.Context()
// validate that the organization exists
if !s.organizationExists(ctx, req.Organization) {
invalidData(w, fmt.Errorf("organization does not exist"), s.Logger)
return
}
mapping := &chronograf.Mapping{
ID: req.ID,
Organization: req.Organization,
Scheme: req.Scheme,
Provider: req.Provider,
ProviderOrganization: req.ProviderOrganization,
}
err := s.Store.Mappings(ctx).Update(ctx, mapping)
if err != nil {
Error(w, http.StatusInternalServerError, "failed to update mapping in database", s.Logger)
return
}
cu := newMappingResponse(*mapping)
location(w, cu.Links.Self)
encodeJSON(w, http.StatusOK, cu, s.Logger)
}
// RemoveMapping removes a mapping
func (s *Service) RemoveMapping(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := httprouter.GetParamFromContext(ctx, "id")
m, err := s.Store.Mappings(ctx).Get(ctx, id)
if err == chronograf.ErrMappingNotFound {
Error(w, http.StatusNotFound, err.Error(), s.Logger)
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "failed to retrieve mapping from database", s.Logger)
return
}
if err := s.Store.Mappings(ctx).Delete(ctx, m); err != nil {
Error(w, http.StatusInternalServerError, "failed to remove mapping from database", s.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Service) organizationExists(ctx context.Context, orgID string) bool {
if _, err := s.Store.Organizations(ctx).Get(ctx, chronograf.OrganizationQuery{ID: &orgID}); err != nil {
return false
}
return true
}

360
server/mapping_test.go Normal file
View File

@ -0,0 +1,360 @@
package server
import (
"bytes"
"context"
"encoding/json"
"io/ioutil"
"net/http/httptest"
"testing"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/log"
"github.com/influxdata/chronograf/mocks"
"github.com/influxdata/chronograf/roles"
)
func TestMappings_All(t *testing.T) {
type fields struct {
MappingsStore chronograf.MappingsStore
}
type args struct {
}
type wants struct {
statusCode int
contentType string
body string
}
tests := []struct {
name string
fields fields
args args
wants wants
}{
{
name: "get all mappings",
fields: fields{
MappingsStore: &mocks.MappingsStore{
AllF: func(ctx context.Context) ([]chronograf.Mapping, error) {
return []chronograf.Mapping{
{
Organization: "0",
Provider: chronograf.MappingWildcard,
Scheme: chronograf.MappingWildcard,
ProviderOrganization: chronograf.MappingWildcard,
},
}, nil
},
},
},
wants: wants{
statusCode: 200,
contentType: "application/json",
body: `{"links":{"self":"/chronograf/v1/mappings"},"mappings":[{"links":{"self":"/chronograf/v1/mappings/"},"id":"","organizationId":"0","provider":"*","scheme":"*","providerOrganization":"*"}]}`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &Service{
Store: &mocks.Store{
MappingsStore: tt.fields.MappingsStore,
},
Logger: log.New(log.DebugLevel),
}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "http://any.url", nil)
s.Mappings(w, r)
resp := w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wants.statusCode {
t.Errorf("%q. Mappings() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode)
}
if tt.wants.contentType != "" && content != tt.wants.contentType {
t.Errorf("%q. Mappings() = %v, want %v", tt.name, content, tt.wants.contentType)
}
if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq {
t.Errorf("%q. Mappings() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body)
}
})
}
}
func TestMappings_Add(t *testing.T) {
type fields struct {
MappingsStore chronograf.MappingsStore
OrganizationsStore chronograf.OrganizationsStore
}
type args struct {
mapping *chronograf.Mapping
}
type wants struct {
statusCode int
contentType string
body string
}
tests := []struct {
name string
fields fields
args args
wants wants
}{
{
name: "create new mapping",
fields: fields{
OrganizationsStore: &mocks.OrganizationsStore{
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "The Gnarly Default",
DefaultRole: roles.ViewerRoleName,
}, nil
},
},
MappingsStore: &mocks.MappingsStore{
AddF: func(ctx context.Context, m *chronograf.Mapping) (*chronograf.Mapping, error) {
m.ID = "0"
return m, nil
},
},
},
args: args{
mapping: &chronograf.Mapping{
Organization: "0",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
},
wants: wants{
statusCode: 201,
contentType: "application/json",
body: `{"links":{"self":"/chronograf/v1/mappings/0"},"id":"0","organizationId":"0","provider":"*","scheme":"*","providerOrganization":"*"}`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &Service{
Store: &mocks.Store{
MappingsStore: tt.fields.MappingsStore,
OrganizationsStore: tt.fields.OrganizationsStore,
},
Logger: log.New(log.DebugLevel),
}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "http://any.url", nil)
buf, _ := json.Marshal(tt.args.mapping)
r.Body = ioutil.NopCloser(bytes.NewReader(buf))
s.NewMapping(w, r)
resp := w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wants.statusCode {
t.Errorf("%q. Add() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode)
}
if tt.wants.contentType != "" && content != tt.wants.contentType {
t.Errorf("%q. Add() = %v, want %v", tt.name, content, tt.wants.contentType)
}
if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq {
t.Errorf("%q. Add() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body)
}
})
}
}
func TestMappings_Update(t *testing.T) {
type fields struct {
MappingsStore chronograf.MappingsStore
OrganizationsStore chronograf.OrganizationsStore
}
type args struct {
mapping *chronograf.Mapping
}
type wants struct {
statusCode int
contentType string
body string
}
tests := []struct {
name string
fields fields
args args
wants wants
}{
{
name: "update new mapping",
fields: fields{
OrganizationsStore: &mocks.OrganizationsStore{
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "The Gnarly Default",
DefaultRole: roles.ViewerRoleName,
}, nil
},
},
MappingsStore: &mocks.MappingsStore{
UpdateF: func(ctx context.Context, m *chronograf.Mapping) error {
return nil
},
},
},
args: args{
mapping: &chronograf.Mapping{
ID: "1",
Organization: "0",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
},
},
wants: wants{
statusCode: 200,
contentType: "application/json",
body: `{"links":{"self":"/chronograf/v1/mappings/1"},"id":"1","organizationId":"0","provider":"*","scheme":"*","providerOrganization":"*"}`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &Service{
Store: &mocks.Store{
MappingsStore: tt.fields.MappingsStore,
OrganizationsStore: tt.fields.OrganizationsStore,
},
Logger: log.New(log.DebugLevel),
}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "http://any.url", nil)
buf, _ := json.Marshal(tt.args.mapping)
r.Body = ioutil.NopCloser(bytes.NewReader(buf))
r = r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.args.mapping.ID,
},
}))
s.UpdateMapping(w, r)
resp := w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wants.statusCode {
t.Errorf("%q. Add() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode)
}
if tt.wants.contentType != "" && content != tt.wants.contentType {
t.Errorf("%q. Add() = %v, want %v", tt.name, content, tt.wants.contentType)
}
if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq {
t.Errorf("%q. Add() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body)
}
})
}
}
func TestMappings_Remove(t *testing.T) {
type fields struct {
MappingsStore chronograf.MappingsStore
}
type args struct {
id string
}
type wants struct {
statusCode int
contentType string
body string
}
tests := []struct {
name string
fields fields
args args
wants wants
}{
{
name: "remove mapping",
fields: fields{
MappingsStore: &mocks.MappingsStore{
GetF: func(ctx context.Context, id string) (*chronograf.Mapping, error) {
return &chronograf.Mapping{
ID: "1",
Organization: "0",
Provider: "*",
Scheme: "*",
ProviderOrganization: "*",
}, nil
},
DeleteF: func(ctx context.Context, m *chronograf.Mapping) error {
return nil
},
},
},
args: args{},
wants: wants{
statusCode: 204,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &Service{
Store: &mocks.Store{
MappingsStore: tt.fields.MappingsStore,
},
Logger: log.New(log.DebugLevel),
}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "http://any.url", nil)
r = r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.args.id,
},
}))
s.RemoveMapping(w, r)
resp := w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wants.statusCode {
t.Errorf("%q. Remove() = %v, want %v", tt.name, resp.StatusCode, tt.wants.statusCode)
}
if tt.wants.contentType != "" && content != tt.wants.contentType {
t.Errorf("%q. Remove() = %v, want %v", tt.name, content, tt.wants.contentType)
}
if eq, _ := jsonEqual(string(body), tt.wants.body); tt.wants.body != "" && !eq {
t.Errorf("%q. Remove() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wants.body)
}
})
}
}

View File

@ -20,10 +20,22 @@ type meLinks struct {
type meResponse struct {
*chronograf.User
Links meLinks `json:"links"`
Organizations []chronograf.Organization `json:"organizations,omitempty"`
Organizations []chronograf.Organization `json:"organizations"`
CurrentOrganization *chronograf.Organization `json:"currentOrganization,omitempty"`
}
type noAuthMeResponse struct {
Links meLinks `json:"links"`
}
func newNoAuthMeResponse() noAuthMeResponse {
return noAuthMeResponse{
Links: meLinks{
Self: "/chronograf/v1/me",
},
}
}
// If new user response is nil, return an empty meResponse because it
// indicates authentication is not needed
func newMeResponse(usr *chronograf.User, org string) meResponse {
@ -182,7 +194,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 := newNoAuthMeResponse()
encodeJSON(w, http.StatusOK, res, s.Logger)
return
}
@ -201,12 +213,13 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) {
ctx = context.WithValue(ctx, organizations.ContextKey, p.Organization)
serverCtx := serverContext(ctx)
defaultOrg, err := s.Store.Organizations(serverCtx).DefaultOrganization(serverCtx)
if err != nil {
unknownErrorWithMessage(w, err, s.Logger)
return
}
if p.Organization == "" {
defaultOrg, err := s.Store.Organizations(serverCtx).DefaultOrganization(serverCtx)
if err != nil {
unknownErrorWithMessage(w, err, s.Logger)
return
}
p.Organization = defaultOrg.ID
}
@ -220,35 +233,8 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) {
return
}
defaultOrg, err := s.Store.Organizations(serverCtx).DefaultOrganization(serverCtx)
if err != nil {
unknownErrorWithMessage(w, err, s.Logger)
return
}
// user exists
if usr != nil {
if defaultOrg.Public || usr.SuperAdmin == true {
// If the default organization is public, or the user is a super admin
// they will always have a role in the default organization
defaultOrgID := defaultOrg.ID
if !hasRoleInDefaultOrganization(usr, defaultOrgID) {
usr.Roles = append(usr.Roles, chronograf.Role{
Organization: defaultOrgID,
Name: defaultOrg.DefaultRole,
})
if err := s.Store.Users(serverCtx).Update(serverCtx, usr); err != nil {
unknownErrorWithMessage(w, err, s.Logger)
return
}
}
}
// If the default org is private and the user has no roles, they should not have access
if !defaultOrg.Public && len(usr.Roles) == 0 {
Error(w, http.StatusForbidden, "This organization is private. To gain access, you must be explicitly added by an administrator.", s.Logger)
return
}
currentOrg, err := s.Store.Organizations(serverCtx).Get(serverCtx, chronograf.OrganizationQuery{ID: &p.Organization})
if err == chronograf.ErrOrganizationNotFound {
// The intent is to force a the user to go through another auth flow
@ -265,6 +251,7 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) {
unknownErrorWithMessage(w, err, s.Logger)
return
}
res := newMeResponse(usr, currentOrg.ID)
res.Organizations = orgs
res.CurrentOrganization = currentOrg
@ -272,13 +259,6 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) {
return
}
// If users must be explicitly added to the default organization, respond with 403
// forbidden
if !defaultOrg.Public {
Error(w, http.StatusForbidden, "This organization is private. To gain access, you must be explicitly added by an administrator.", s.Logger)
return
}
// Because we didnt find a user, making a new one
user := &chronograf.User{
Name: p.Subject,
@ -287,17 +267,23 @@ func (s *Service) Me(w http.ResponseWriter, r *http.Request) {
// support OAuth2. This hard-coding should be removed whenever we add
// support for other authentication schemes.
Scheme: scheme,
Roles: []chronograf.Role{
{
Name: defaultOrg.DefaultRole,
// This is the ID of the default organization
Organization: defaultOrg.ID,
},
},
// TODO(desa): this needs a better name
SuperAdmin: s.newUsersAreSuperAdmin(),
}
roles, err := s.mapPrincipalToRoles(serverCtx, p)
if err != nil {
Error(w, http.StatusInternalServerError, err.Error(), s.Logger)
return
}
if len(roles) == 0 {
Error(w, http.StatusForbidden, "This Chronograf is private. To gain access, you must be explicitly added by an administrator.", s.Logger)
return
}
user.Roles = roles
newUser, err := s.Store.Users(serverCtx).Add(serverCtx, user)
if err != nil {
msg := fmt.Errorf("error storing user %s: %v", user.Name, err)

View File

@ -23,6 +23,7 @@ func TestService_Me(t *testing.T) {
type fields struct {
UsersStore chronograf.UsersStore
OrganizationsStore chronograf.OrganizationsStore
MappingsStore chronograf.MappingsStore
ConfigStore chronograf.ConfigStore
Logger chronograf.Logger
UseAuth bool
@ -56,13 +57,24 @@ func TestService_Me(t *testing.T) {
},
},
},
MappingsStore: &mocks.MappingsStore{
AllF: func(ctx context.Context) ([]chronograf.Mapping, error) {
return []chronograf.Mapping{
{
Organization: "0",
Provider: chronograf.MappingWildcard,
Scheme: chronograf.MappingWildcard,
ProviderOrganization: chronograf.MappingWildcard,
},
}, nil
},
},
OrganizationsStore: &mocks.OrganizationsStore{
DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "Default",
DefaultRole: roles.ViewerRoleName,
Public: false,
}, nil
},
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
@ -72,13 +84,11 @@ func TestService_Me(t *testing.T) {
ID: "0",
Name: "Default",
DefaultRole: roles.ViewerRoleName,
Public: false,
}, nil
case "1":
return &chronograf.Organization{
ID: "1",
Name: "The Bad Place",
Public: false,
ID: "1",
Name: "The Bad Place",
}, nil
}
return nil, nil
@ -108,12 +118,12 @@ func TestService_Me(t *testing.T) {
Subject: "me",
Issuer: "github",
},
wantStatus: http.StatusForbidden,
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`,
wantBody: `{"name":"me","roles":null,"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer"}}`,
},
{
name: "Existing user - private default org and user is a super admin",
name: "Existing superadmin - not member of any organization",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
@ -121,13 +131,17 @@ func TestService_Me(t *testing.T) {
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
MappingsStore: &mocks.MappingsStore{
AllF: func(ctx context.Context) ([]chronograf.Mapping, error) {
return []chronograf.Mapping{}, nil
},
},
OrganizationsStore: &mocks.OrganizationsStore{
DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "Default",
DefaultRole: roles.ViewerRoleName,
Public: false,
}, nil
},
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
@ -137,13 +151,11 @@ func TestService_Me(t *testing.T) {
ID: "0",
Name: "Default",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
case "1":
return &chronograf.Organization{
ID: "1",
Name: "The Bad Place",
Public: true,
ID: "1",
Name: "The Bad Place",
}, nil
}
return nil, nil
@ -176,137 +188,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/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",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
OrganizationsStore: &mocks.OrganizationsStore{
DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "Default",
DefaultRole: roles.ViewerRoleName,
Public: false,
}, nil
},
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
switch *q.ID {
case "0":
return &chronograf.Organization{
ID: "0",
Name: "Default",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
case "1":
return &chronograf.Organization{
ID: "1",
Name: "The Bad Place",
Public: true,
}, nil
}
return nil, nil
},
},
UsersStore: &mocks.UsersStore{
NumF: func(ctx context.Context) (int, error) {
// This function gets to verify that there is at least one first user
return 1, nil
},
GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) {
if q.Name == nil || q.Provider == nil || q.Scheme == nil {
return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme")
}
return &chronograf.User{
Name: "me",
Provider: "github",
Scheme: "oauth2",
}, nil
},
UpdateF: func(ctx context.Context, u *chronograf.User) error {
return nil
},
},
},
principal: oauth2.Principal{
Subject: "me",
Issuer: "github",
},
wantStatus: http.StatusForbidden,
wantContentType: "application/json",
wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`,
},
{
name: "Existing user - default org public",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
OrganizationsStore: &mocks.OrganizationsStore{
DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "Default",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
},
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
switch *q.ID {
case "0":
return &chronograf.Organization{
ID: "0",
Name: "Default",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
case "1":
return &chronograf.Organization{
ID: "1",
Name: "The Bad Place",
Public: true,
}, nil
}
return nil, nil
},
},
UsersStore: &mocks.UsersStore{
NumF: func(ctx context.Context) (int, error) {
// This function gets to verify that there is at least one first user
return 1, nil
},
GetF: func(ctx context.Context, q chronograf.UserQuery) (*chronograf.User, error) {
if q.Name == nil || q.Provider == nil || q.Scheme == nil {
return nil, fmt.Errorf("Invalid user query: missing Name, Provider, and/or Scheme")
}
return &chronograf.User{
Name: "me",
Provider: "github",
Scheme: "oauth2",
}, nil
},
UpdateF: func(ctx context.Context, u *chronograf.User) error {
return nil
},
},
},
principal: oauth2.Principal{
Subject: "me",
Issuer: "github",
},
wantStatus: http.StatusOK,
wantContentType: "application/json",
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}}`,
wantBody: `{"name":"me","roles":null,"provider":"github","scheme":"oauth2","superAdmin":true,"links":{"self":"/chronograf/v1/organizations/0/users/0"},"organizations":[],"currentOrganization":{"id":"0","name":"Default","defaultRole":"viewer"}}`,
},
{
name: "Existing user - organization doesn't exist",
@ -317,13 +199,17 @@ func TestService_Me(t *testing.T) {
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
MappingsStore: &mocks.MappingsStore{
AllF: func(ctx context.Context) ([]chronograf.Mapping, error) {
return []chronograf.Mapping{}, nil
},
},
OrganizationsStore: &mocks.OrganizationsStore{
DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "Default",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
},
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
@ -333,7 +219,6 @@ func TestService_Me(t *testing.T) {
ID: "0",
Name: "Default",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
}
return nil, chronograf.ErrOrganizationNotFound
@ -365,7 +250,7 @@ func TestService_Me(t *testing.T) {
wantBody: `{"code":403,"message":"user's current organization was not found"}`,
},
{
name: "new user - default org is public",
name: "default mapping applies to new user",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
@ -380,13 +265,24 @@ func TestService_Me(t *testing.T) {
},
},
},
MappingsStore: &mocks.MappingsStore{
AllF: func(ctx context.Context) ([]chronograf.Mapping, error) {
return []chronograf.Mapping{
{
Organization: "0",
Provider: chronograf.MappingWildcard,
Scheme: chronograf.MappingWildcard,
ProviderOrganization: chronograf.MappingWildcard,
},
}, nil
},
},
OrganizationsStore: &mocks.OrganizationsStore{
DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "The Gnarly Default",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
},
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
@ -394,7 +290,15 @@ func TestService_Me(t *testing.T) {
ID: "0",
Name: "The Gnarly Default",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
},
AllF: func(ctx context.Context) ([]chronograf.Organization, error) {
return []chronograf.Organization{
chronograf.Organization{
ID: "0",
Name: "The Gnarly Default",
DefaultRole: roles.ViewerRoleName,
},
}, nil
},
},
@ -423,8 +327,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/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}}
`,
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"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}}`,
},
{
name: "New user - New users not super admin, not first user",
@ -442,13 +345,24 @@ func TestService_Me(t *testing.T) {
},
},
},
MappingsStore: &mocks.MappingsStore{
AllF: func(ctx context.Context) ([]chronograf.Mapping, error) {
return []chronograf.Mapping{
{
Organization: "0",
Provider: chronograf.MappingWildcard,
Scheme: chronograf.MappingWildcard,
ProviderOrganization: chronograf.MappingWildcard,
},
}, nil
},
},
OrganizationsStore: &mocks.OrganizationsStore{
DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "The Gnarly Default",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
},
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
@ -456,7 +370,15 @@ func TestService_Me(t *testing.T) {
ID: "0",
Name: "The Gnarly Default",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
},
AllF: func(ctx context.Context) ([]chronograf.Organization, error) {
return []chronograf.Organization{
chronograf.Organization{
ID: "0",
Name: "The Gnarly Default",
DefaultRole: roles.ViewerRoleName,
},
}, nil
},
},
@ -485,8 +407,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/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"}}
`,
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","defaultRole":"viewer"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}}`,
},
{
name: "New user - New users not super admin, first user",
@ -504,13 +425,24 @@ func TestService_Me(t *testing.T) {
},
},
},
MappingsStore: &mocks.MappingsStore{
AllF: func(ctx context.Context) ([]chronograf.Mapping, error) {
return []chronograf.Mapping{
{
Organization: "0",
Provider: chronograf.MappingWildcard,
Scheme: chronograf.MappingWildcard,
ProviderOrganization: chronograf.MappingWildcard,
},
}, nil
},
},
OrganizationsStore: &mocks.OrganizationsStore{
DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "The Gnarly Default",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
},
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
@ -518,7 +450,15 @@ func TestService_Me(t *testing.T) {
ID: "0",
Name: "The Gnarly Default",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
},
AllF: func(ctx context.Context) ([]chronograf.Organization, error) {
return []chronograf.Organization{
chronograf.Organization{
ID: "0",
Name: "The Gnarly Default",
DefaultRole: roles.ViewerRoleName,
},
}, nil
},
},
@ -547,8 +487,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/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"}}
`,
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"}],"currentOrganization":{"id":"0","name":"The Gnarly Default","defaultRole":"viewer"}}`,
},
{
name: "Error adding user",
@ -565,18 +504,31 @@ func TestService_Me(t *testing.T) {
},
},
},
MappingsStore: &mocks.MappingsStore{
AllF: func(ctx context.Context) ([]chronograf.Mapping, error) {
return []chronograf.Mapping{}, nil
},
},
OrganizationsStore: &mocks.OrganizationsStore{
DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Public: true,
ID: "0",
Name: "The Bad Place",
}, nil
},
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "The Bad Place",
Public: true,
ID: "0",
Name: "The Bad Place",
}, nil
},
AllF: func(ctx context.Context) ([]chronograf.Organization, error) {
return []chronograf.Organization{
chronograf.Organization{
ID: "0",
Name: "The Bad Place",
DefaultRole: roles.ViewerRoleName,
},
}, nil
},
},
@ -601,9 +553,9 @@ func TestService_Me(t *testing.T) {
Subject: "secret",
Issuer: "heroku",
},
wantStatus: http.StatusInternalServerError,
wantStatus: http.StatusForbidden,
wantContentType: "application/json",
wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`,
wantBody: `{"code":403,"message":"This Chronograf is private. To gain access, you must be explicitly added by an administrator."}`,
},
{
name: "No Auth",
@ -624,8 +576,7 @@ func TestService_Me(t *testing.T) {
},
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"links":{"self":"/chronograf/v1/me"}}
`,
wantBody: `{"links":{"self":"/chronograf/v1/me"}}`,
},
{
name: "Empty Principal",
@ -659,13 +610,24 @@ func TestService_Me(t *testing.T) {
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
ConfigStore: mocks.ConfigStore{
Config: &chronograf.Config{
Auth: chronograf.AuthConfig{
SuperAdminNewUsers: false,
},
},
},
MappingsStore: &mocks.MappingsStore{
AllF: func(ctx context.Context) ([]chronograf.Mapping, error) {
return []chronograf.Mapping{}, nil
},
},
OrganizationsStore: &mocks.OrganizationsStore{
DefaultOrganizationF: func(ctx context.Context) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "The Bad Place",
DefaultRole: roles.MemberRoleName,
Public: false,
}, nil
},
},
@ -694,7 +656,7 @@ func TestService_Me(t *testing.T) {
},
wantStatus: http.StatusForbidden,
wantContentType: "application/json",
wantBody: `{"code":403,"message":"This organization is private. To gain access, you must be explicitly added by an administrator."}`,
wantBody: `{"code":403,"message":"This Chronograf is private. To gain access, you must be explicitly added by an administrator."}`,
},
}
for _, tt := range tests {
@ -703,12 +665,14 @@ func TestService_Me(t *testing.T) {
Store: &mocks.Store{
UsersStore: tt.fields.UsersStore,
OrganizationsStore: tt.fields.OrganizationsStore,
MappingsStore: tt.fields.MappingsStore,
ConfigStore: tt.fields.ConfigStore,
},
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
}
fmt.Println(tt.name)
s.Me(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
@ -792,7 +756,6 @@ func TestService_UpdateMe(t *testing.T) {
ID: "0",
Name: "Default",
DefaultRole: roles.AdminRoleName,
Public: true,
}, nil
},
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
@ -805,13 +768,11 @@ func TestService_UpdateMe(t *testing.T) {
ID: "0",
Name: "Default",
DefaultRole: roles.AdminRoleName,
Public: true,
}, nil
case "1337":
return &chronograf.Organization{
ID: "1337",
Name: "The ShillBillThrilliettas",
Public: true,
ID: "1337",
Name: "The ShillBillThrilliettas",
}, nil
}
return nil, nil
@ -824,7 +785,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/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}}`,
wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/1337/users/0"},"organizations":[{"id":"1337","name":"The ShillBillThrilliettas"}],"currentOrganization":{"id":"1337","name":"The ShillBillThrilliettas"}}`,
},
{
name: "Change the current User's organization",
@ -866,7 +827,6 @@ func TestService_UpdateMe(t *testing.T) {
ID: "0",
Name: "Default",
DefaultRole: roles.EditorRoleName,
Public: true,
}, nil
},
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
@ -876,16 +836,14 @@ func TestService_UpdateMe(t *testing.T) {
switch *q.ID {
case "1337":
return &chronograf.Organization{
ID: "1337",
Name: "The ThrillShilliettos",
Public: false,
ID: "1337",
Name: "The ThrillShilliettos",
}, nil
case "0":
return &chronograf.Organization{
ID: "0",
Name: "Default",
DefaultRole: roles.EditorRoleName,
Public: true,
}, nil
}
return nil, nil
@ -899,7 +857,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/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}}`,
wantBody: `{"name":"me","roles":[{"name":"admin","organization":"1337"}],"provider":"github","scheme":"oauth2","links":{"self":"/chronograf/v1/organizations/1337/users/0"},"organizations":[{"id":"1337","name":"The ThrillShilliettos"}],"currentOrganization":{"id":"1337","name":"The ThrillShilliettos"}}`,
},
{
name: "Unable to find requested user in valid organization",
@ -946,9 +904,8 @@ func TestService_UpdateMe(t *testing.T) {
return nil, fmt.Errorf("Invalid organization query: missing ID")
}
return &chronograf.Organization{
ID: "1337",
Name: "The ShillBillThrilliettas",
Public: true,
ID: "1337",
Name: "The ShillBillThrilliettas",
}, nil
},
},

View File

@ -141,6 +141,13 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
router.PATCH("/chronograf/v1/organizations/:oid", EnsureSuperAdmin(service.UpdateOrganization))
router.DELETE("/chronograf/v1/organizations/:oid", EnsureSuperAdmin(service.RemoveOrganization))
// Mappings
router.GET("/chronograf/v1/mappings", EnsureSuperAdmin(service.Mappings))
router.POST("/chronograf/v1/mappings", EnsureSuperAdmin(service.NewMapping))
router.PUT("/chronograf/v1/mappings/:id", EnsureSuperAdmin(service.UpdateMapping))
router.DELETE("/chronograf/v1/mappings/:id", EnsureSuperAdmin(service.RemoveMapping))
// Sources
router.GET("/chronograf/v1/sources", EnsureViewer(service.Sources))
router.POST("/chronograf/v1/sources", EnsureEditor(service.NewSource))

View File

@ -15,7 +15,6 @@ import (
type organizationRequest struct {
Name string `json:"name"`
DefaultRole string `json:"defaultRole"`
Public *bool `json:"public"`
}
func (r *organizationRequest) ValidCreate() error {
@ -27,7 +26,7 @@ func (r *organizationRequest) ValidCreate() error {
}
func (r *organizationRequest) ValidUpdate() error {
if r.Name == "" && r.DefaultRole == "" && r.Public == nil {
if r.Name == "" && r.DefaultRole == "" {
return fmt.Errorf("No fields to update")
}
@ -119,10 +118,6 @@ func (s *Service) NewOrganization(w http.ResponseWriter, r *http.Request) {
DefaultRole: req.DefaultRole,
}
if req.Public != nil {
org.Public = *req.Public
}
res, err := s.Store.Organizations(ctx).Add(ctx, org)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), s.Logger)
@ -207,10 +202,6 @@ func (s *Service) UpdateOrganization(w http.ResponseWriter, r *http.Request) {
org.DefaultRole = req.DefaultRole
}
if req.Public != nil {
org.Public = *req.Public
}
err = s.Store.Organizations(ctx).Update(ctx, org)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), s.Logger)

View File

@ -52,9 +52,8 @@ func TestService_OrganizationID(t *testing.T) {
switch *q.ID {
case "1337":
return &chronograf.Organization{
ID: "1337",
Name: "The Good Place",
Public: false,
ID: "1337",
Name: "The Good Place",
}, nil
default:
return nil, fmt.Errorf("Organization with ID %s not found", *q.ID)
@ -65,7 +64,38 @@ func TestService_OrganizationID(t *testing.T) {
id: "1337",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","public":false}`,
wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place"}`,
},
{
name: "Get Single Organization",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://any.url", // can be any valid URL as we are bypassing mux
nil,
),
},
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 "1337":
return &chronograf.Organization{
ID: "1337",
Name: "The Good Place",
}, nil
default:
return nil, fmt.Errorf("Organization with ID %s not found", *q.ID)
}
},
},
},
id: "1337",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"id":"1337","name":"The Good Place","links":{"self":"/chronograf/v1/organizations/1337"}}`,
},
}
@ -124,7 +154,7 @@ func TestService_Organizations(t *testing.T) {
wantBody string
}{
{
name: "Get Single Organization",
name: "Get Organizations",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
@ -139,14 +169,12 @@ func TestService_Organizations(t *testing.T) {
AllF: func(ctx context.Context) ([]chronograf.Organization, error) {
return []chronograf.Organization{
chronograf.Organization{
ID: "1337",
Name: "The Good Place",
Public: false,
ID: "1337",
Name: "The Good Place",
},
chronograf.Organization{
ID: "100",
Name: "The Bad Place",
Public: false,
ID: "100",
Name: "The Bad Place",
},
}, nil
},
@ -154,7 +182,7 @@ func TestService_Organizations(t *testing.T) {
},
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"links":{"self":"/chronograf/v1/organizations"},"organizations":[{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","public":false},{"links":{"self":"/chronograf/v1/organizations/100"},"id":"100","name":"The Bad Place","public":false}]}`,
wantBody: `{"links":{"self":"/chronograf/v1/organizations"},"organizations":[{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place"},{"links":{"self":"/chronograf/v1/organizations/100"},"id":"100","name":"The Bad Place"}]}`,
},
}
@ -195,7 +223,6 @@ func TestService_UpdateOrganization(t *testing.T) {
w *httptest.ResponseRecorder
r *http.Request
org *organizationRequest
public bool
setPtr bool
}
tests := []struct {
@ -231,7 +258,6 @@ func TestService_UpdateOrganization(t *testing.T) {
ID: "1337",
Name: "The Good Place",
DefaultRole: roles.ViewerRoleName,
Public: false,
}, nil
},
},
@ -239,41 +265,7 @@ func TestService_UpdateOrganization(t *testing.T) {
id: "1337",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"id":"1337","name":"The Bad Place","defaultRole":"viewer","links":{"self":"/chronograf/v1/organizations/1337"},"public":false}`,
},
{
name: "Update Organization public",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://any.url", // can be any valid URL as we are bypassing mux
nil,
),
org: &organizationRequest{},
public: false,
setPtr: true,
},
fields: fields{
Logger: log.New(log.DebugLevel),
OrganizationsStore: &mocks.OrganizationsStore{
UpdateF: func(ctx context.Context, o *chronograf.Organization) error {
return nil
},
GetF: func(ctx context.Context, q chronograf.OrganizationQuery) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "0",
Name: "The Good Place",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
},
},
},
id: "0",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"id":"0","name":"The Good Place","defaultRole":"viewer","public":false,"links":{"self":"/chronograf/v1/organizations/0"}}`,
wantBody: `{"id":"1337","name":"The Bad Place","defaultRole":"viewer","links":{"self":"/chronograf/v1/organizations/1337"}}`,
},
{
name: "Update Organization - nothing to update",
@ -297,7 +289,6 @@ func TestService_UpdateOrganization(t *testing.T) {
ID: "1337",
Name: "The Good Place",
DefaultRole: roles.ViewerRoleName,
Public: true,
}, nil
},
},
@ -331,7 +322,6 @@ func TestService_UpdateOrganization(t *testing.T) {
ID: "1337",
Name: "The Good Place",
DefaultRole: roles.MemberRoleName,
Public: false,
}, nil
},
},
@ -339,7 +329,7 @@ func TestService_UpdateOrganization(t *testing.T) {
id: "1337",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","defaultRole":"viewer","public":false}`,
wantBody: `{"links":{"self":"/chronograf/v1/organizations/1337"},"id":"1337","name":"The Good Place","defaultRole":"viewer"}`,
},
{
name: "Update Organization - invalid update",
@ -416,10 +406,6 @@ func TestService_UpdateOrganization(t *testing.T) {
},
}))
if tt.args.setPtr {
tt.args.org.Public = &tt.args.public
}
buf, _ := json.Marshal(tt.args.org)
tt.args.r.Body = ioutil.NopCloser(bytes.NewReader(buf))
s.UpdateOrganization(tt.args.w, tt.args.r)
@ -573,16 +559,54 @@ func TestService_NewOrganization(t *testing.T) {
OrganizationsStore: &mocks.OrganizationsStore{
AddF: func(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) {
return &chronograf.Organization{
ID: "1337",
Name: "The Good Place",
Public: false,
ID: "1337",
Name: "The Good Place",
}, nil
},
},
},
wantStatus: http.StatusCreated,
wantContentType: "application/json",
wantBody: `{"id":"1337","public":false,"name":"The Good Place","links":{"self":"/chronograf/v1/organizations/1337"}}`,
wantBody: `{"id":"1337","name":"The Good Place","links":{"self":"/chronograf/v1/organizations/1337"}}`,
},
{
name: "Fail to create Organization - no org name",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://any.url", // can be any valid URL as we are bypassing mux
nil,
),
user: &chronograf.User{
ID: 1,
Name: "bobetta",
Provider: "github",
Scheme: "oauth2",
},
org: &organizationRequest{},
},
fields: fields{
Logger: log.New(log.DebugLevel),
UsersStore: &mocks.UsersStore{
AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
return &chronograf.User{
ID: 1,
Name: "bobetta",
Provider: "github",
Scheme: "oauth2",
}, nil
},
},
OrganizationsStore: &mocks.OrganizationsStore{
AddF: func(ctx context.Context, o *chronograf.Organization) (*chronograf.Organization, error) {
return nil, nil
},
},
},
wantStatus: http.StatusUnprocessableEntity,
wantContentType: "application/json",
wantBody: `{"code":422,"message":"Name required on Chronograf Organization request body"}`,
},
{
name: "Create Organization - no user on context",

View File

@ -484,6 +484,7 @@ func openService(ctx context.Context, buildInfo chronograf.BuildInfo, boltPath s
OrganizationsStore: organizations,
UsersStore: db.UsersStore,
ConfigStore: db.ConfigStore,
MappingsStore: db.MappingsStore,
},
Logger: logger,
UseAuth: useAuth,

View File

@ -48,7 +48,8 @@ func (c *InfluxClient) New(src chronograf.Source, logger chronograf.Logger) (chr
}
if src.Type == chronograf.InfluxEnterprise && src.MetaURL != "" {
tls := strings.Contains(src.MetaURL, "https")
return enterprise.NewClientWithTimeSeries(logger, src.MetaURL, influx.DefaultAuthorization(&src), tls, client)
insecure := src.InsecureSkipVerify
return enterprise.NewClientWithTimeSeries(logger, src.MetaURL, influx.DefaultAuthorization(&src), tls, insecure, client)
}
return client, nil
}

View File

@ -88,6 +88,7 @@ type DataStore interface {
Layouts(ctx context.Context) chronograf.LayoutsStore
Users(ctx context.Context) chronograf.UsersStore
Organizations(ctx context.Context) chronograf.OrganizationsStore
Mappings(ctx context.Context) chronograf.MappingsStore
Dashboards(ctx context.Context) chronograf.DashboardsStore
Config(ctx context.Context) chronograf.ConfigStore
}
@ -102,6 +103,7 @@ type Store struct {
LayoutsStore chronograf.LayoutsStore
UsersStore chronograf.UsersStore
DashboardsStore chronograf.DashboardsStore
MappingsStore chronograf.MappingsStore
OrganizationsStore chronograf.OrganizationsStore
ConfigStore chronograf.ConfigStore
}
@ -191,3 +193,14 @@ func (s *Store) Config(ctx context.Context) chronograf.ConfigStore {
}
return &noop.ConfigStore{}
}
// Mappings returns the underlying MappingsStore.
func (s *Store) Mappings(ctx context.Context) chronograf.MappingsStore {
if isServer := hasServerContext(ctx); isServer {
return s.MappingsStore
}
if isSuperAdmin := hasSuperAdminContext(ctx); isSuperAdmin {
return s.MappingsStore
}
return &noop.MappingsStore{}
}

View File

@ -3,7 +3,7 @@
"info": {
"title": "Chronograf",
"description": "API endpoints for Chronograf",
"version": "1.4.0.1"
"version": "1.4.1.3"
},
"schemes": ["http"],
"basePath": "/chronograf/v1",

View File

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

View File

@ -36,13 +36,15 @@ class CheckSources extends Component {
}
async componentWillMount() {
const {auth: {isUsingAuth, me}} = this.props
const {router, auth: {isUsingAuth, me}} = this.props
if (!isUsingAuth || isUserAuthorized(me.role, VIEWER_ROLE)) {
await this.props.getSources()
this.setState({isFetching: false})
} else {
router.push('/purgatory')
return
}
this.setState({isFetching: false})
}
shouldComponentUpdate(nextProps) {
@ -66,7 +68,7 @@ class CheckSources extends Component {
params,
errorThrown,
sources,
auth: {isUsingAuth, me, me: {organizations, currentOrganization}},
auth: {isUsingAuth, me, me: {organizations = [], currentOrganization}},
notify,
getSources,
} = nextProps
@ -81,6 +83,14 @@ class CheckSources extends Component {
return router.push('/')
}
if (!isFetching && isUsingAuth && !organizations.length) {
notify(
'error',
'You have been removed from all organizations. Please contact your administrator.'
)
return router.push('/purgatory')
}
if (
me.superAdmin &&
!organizations.find(o => o.id === currentOrganization.id)

View File

@ -10,6 +10,10 @@ import {
createOrganization as createOrganizationAJAX,
updateOrganization as updateOrganizationAJAX,
deleteOrganization as deleteOrganizationAJAX,
getMappings as getMappingsAJAX,
createMapping as createMappingAJAX,
updateMapping as updateMappingAJAX,
deleteMapping as deleteMappingAJAX,
} from 'src/admin/apis/chronograf'
import {publishAutoDismissingNotification} from 'shared/dispatchers'
@ -94,6 +98,35 @@ export const removeOrganization = organization => ({
},
})
export const loadMappings = ({mappings}) => ({
type: 'CHRONOGRAF_LOAD_MAPPINGS',
payload: {
mappings,
},
})
export const updateMapping = (staleMapping, updatedMapping) => ({
type: 'CHRONOGRAF_UPDATE_MAPPING',
payload: {
staleMapping,
updatedMapping,
},
})
export const addMapping = mapping => ({
type: 'CHRONOGRAF_ADD_MAPPING',
payload: {
mapping,
},
})
export const removeMapping = mapping => ({
type: 'CHRONOGRAF_REMOVE_MAPPING',
payload: {
mapping,
},
})
// async actions (thunks)
export const loadUsersAsync = url => async dispatch => {
try {
@ -113,6 +146,62 @@ export const loadOrganizationsAsync = url => async dispatch => {
}
}
export const loadMappingsAsync = () => async dispatch => {
try {
const {data} = await getMappingsAJAX()
dispatch(loadMappings(data))
} catch (error) {
dispatch(errorThrown(error))
}
}
export const createMappingAsync = (url, mapping) => async dispatch => {
const mappingWithTempId = {...mapping, _tempID: uuid.v4()}
dispatch(addMapping(mappingWithTempId))
try {
const {data} = await createMappingAJAX(url, mapping)
dispatch(updateMapping(mappingWithTempId, data))
} catch (error) {
const message = `${_.upperFirst(
_.toLower(error.data.message)
)}: Scheme: ${mapping.scheme} Provider: ${mapping.provider}`
dispatch(errorThrown(error, message))
setTimeout(
() => dispatch(removeMapping(mappingWithTempId)),
REVERT_STATE_DELAY
)
}
}
export const deleteMappingAsync = mapping => async dispatch => {
dispatch(removeMapping(mapping))
try {
await deleteMappingAJAX(mapping)
dispatch(
publishAutoDismissingNotification(
'success',
`Mapping deleted: ${mapping.id} ${mapping.scheme}`
)
)
} catch (error) {
dispatch(errorThrown(error))
dispatch(addMapping(mapping))
}
}
export const updateMappingAsync = (
staleMapping,
updatedMapping
) => async dispatch => {
dispatch(updateMapping(staleMapping, updatedMapping))
try {
await updateMappingAJAX(updatedMapping)
} catch (error) {
dispatch(errorThrown(error))
dispatch(updateMapping(updatedMapping, staleMapping))
}
}
export const createUserAsync = (url, user) => async dispatch => {
// temp uuid is added to be able to disambiguate a created user that has the
// same scheme, provider, and name as an existing user

View File

@ -102,3 +102,54 @@ export const deleteOrganization = async organization => {
throw error
}
}
// Mappings
export const createMapping = async (url, mapping) => {
try {
return await AJAX({
method: 'POST',
resource: 'mappings',
data: mapping,
})
} catch (error) {
console.error(error)
throw error
}
}
export const getMappings = async () => {
try {
return await AJAX({
method: 'GET',
resource: 'mappings',
})
} catch (error) {
console.error(error)
throw error
}
}
export const updateMapping = async mapping => {
try {
return await AJAX({
method: 'PUT',
url: mapping.links.self,
data: mapping,
})
} catch (error) {
console.error(error)
throw error
}
}
export const deleteMapping = async mapping => {
try {
return await AJAX({
method: 'DELETE',
url: mapping.links.self,
})
} catch (error) {
console.error(error)
throw error
}
}

View File

@ -9,23 +9,18 @@ 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 ProvidersPage from 'src/admin/containers/ProvidersPage'
import AllUsersPage from 'src/admin/containers/chronograf/AllUsersPage'
const ORGANIZATIONS_TAB_NAME = 'Organizations'
const CURRENT_ORG_USERS_TAB_NAME = 'Current Org Users'
const ORGANIZATIONS_TAB_NAME = 'All Orgs'
const PROVIDERS_TAB_NAME = 'Org Mappings'
const CURRENT_ORG_USERS_TAB_NAME = 'Current Org'
const ALL_USERS_TAB_NAME = 'All Users'
const AdminTabs = ({
me: {currentOrganization: meCurrentOrganization, role: meRole, id: meID},
}) => {
const tabs = [
{
requiredRole: SUPERADMIN_ROLE,
type: ORGANIZATIONS_TAB_NAME,
component: (
<OrganizationsPage meCurrentOrganization={meCurrentOrganization} />
),
},
{
requiredRole: ADMIN_ROLE,
type: CURRENT_ORG_USERS_TAB_NAME,
@ -38,6 +33,18 @@ const AdminTabs = ({
type: ALL_USERS_TAB_NAME,
component: <AllUsersPage meID={meID} />,
},
{
requiredRole: SUPERADMIN_ROLE,
type: ORGANIZATIONS_TAB_NAME,
component: (
<OrganizationsPage meCurrentOrganization={meCurrentOrganization} />
),
},
{
requiredRole: SUPERADMIN_ROLE,
type: PROVIDERS_TAB_NAME,
component: <ProvidersPage />,
},
].filter(t => isUserAuthorized(meRole, t.requiredRole))
return (

View File

@ -19,7 +19,7 @@ const AllUsersTableHeader = ({
return (
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<h2 className="panel-title">
{numUsersString} in {numOrganizationsString}
{numUsersString} across {numOrganizationsString}
</h2>
<div style={{display: 'flex', alignItems: 'center'}}>
<div className="all-users-admin-toggle">

View File

@ -4,9 +4,6 @@ import uuid from 'node-uuid'
import OrganizationsTableRow from 'src/admin/components/chronograf/OrganizationsTableRow'
import OrganizationsTableRowNew from 'src/admin/components/chronograf/OrganizationsTableRowNew'
import QuestionMarkTooltip from 'shared/components/QuestionMarkTooltip'
import {PUBLIC_TOOLTIP} from 'src/admin/constants/index'
class OrganizationsTable extends Component {
constructor(props) {
@ -37,7 +34,6 @@ class OrganizationsTable extends Component {
onDeleteOrg,
onRenameOrg,
onChooseDefaultRole,
onTogglePublic,
currentOrganization,
} = this.props
const {isCreatingOrganization} = this.state
@ -71,15 +67,13 @@ class OrganizationsTable extends Component {
</button>
</div>
<div className="panel-body">
<div className="orgs-table--org-labels">
<div className="orgs-table--active" />
<div className="orgs-table--name">Name</div>
<div className="orgs-table--public">
Public{' '}
<QuestionMarkTooltip tipID="public" tipContent={PUBLIC_TOOLTIP} />
<div className="fancytable--labels">
<div className="fancytable--th orgs-table--active" />
<div className="fancytable--th orgs-table--name">Name</div>
<div className="fancytable--th orgs-table--default-role">
Default Role
</div>
<div className="orgs-table--default-role">Default Role</div>
<div className="orgs-table--delete" />
<div className="fancytable--th orgs-table--delete" />
</div>
{isCreatingOrganization
? <OrganizationsTableRowNew
@ -91,7 +85,6 @@ class OrganizationsTable extends Component {
<OrganizationsTableRow
key={uuid.v4()}
organization={org}
onTogglePublic={onTogglePublic}
onDelete={onDeleteOrg}
onRename={onRenameOrg}
onChooseDefaultRole={onChooseDefaultRole}
@ -120,7 +113,6 @@ OrganizationsTable.propTypes = {
onCreateOrg: func.isRequired,
onDeleteOrg: func.isRequired,
onRenameOrg: func.isRequired,
onTogglePublic: func.isRequired,
onChooseDefaultRole: func.isRequired,
}
export default OrganizationsTable

View File

@ -3,9 +3,9 @@ import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import {withRouter} from 'react-router'
import SlideToggle from 'shared/components/SlideToggle'
import ConfirmButtons from 'shared/components/ConfirmButtons'
import Dropdown from 'shared/components/Dropdown'
import InputClickToEdit from 'shared/components/InputClickToEdit'
import {meChangeOrganizationAsync} from 'shared/actions/auth'
@ -32,9 +32,7 @@ class OrganizationsTableRow extends Component {
super(props)
this.state = {
isEditing: false,
isDeleting: false,
workingName: this.props.organization.name,
}
}
@ -44,55 +42,10 @@ class OrganizationsTableRow extends Component {
await meChangeOrganization(links.me, {organization: organization.id})
router.push('')
}
handleNameClick = () => {
this.setState({isEditing: true})
handleUpdateOrgName = newName => {
const {organization, onRename} = this.props
onRename(organization, newName)
}
handleConfirmRename = () => {
const {onRename, organization} = this.props
const {workingName} = this.state
onRename(organization, workingName)
this.setState({workingName, isEditing: false})
}
handleCancelRename = () => {
const {organization} = this.props
this.setState({
workingName: organization.name,
isEditing: false,
})
}
handleInputChange = e => {
this.setState({workingName: e.target.value})
}
handleInputBlur = () => {
const {organization} = this.props
const {workingName} = this.state
if (organization.name === workingName) {
this.handleCancelRename()
} else {
this.handleConfirmRename()
}
}
handleKeyDown = e => {
if (e.key === 'Enter') {
this.handleInputBlur()
} else if (e.key === 'Escape') {
this.handleCancelRename()
}
}
handleFocus = e => {
e.target.select()
}
handleDeleteClick = () => {
this.setState({isDeleting: true})
}
@ -106,18 +59,13 @@ class OrganizationsTableRow extends Component {
onDelete(organization)
}
handleTogglePublic = () => {
const {organization, onTogglePublic} = this.props
onTogglePublic(organization)
}
handleChooseDefaultRole = role => {
const {organization, onChooseDefaultRole} = this.props
onChooseDefaultRole(organization, role.name)
}
render() {
const {workingName, isEditing, isDeleting} = this.state
const {isDeleting} = this.state
const {organization, currentOrganization} = this.props
const dropdownRolesItems = USER_ROLES.map(role => ({
@ -126,12 +74,12 @@ class OrganizationsTableRow extends Component {
}))
const defaultRoleClassName = isDeleting
? 'orgs-table--default-role editing'
: 'orgs-table--default-role'
? 'fancytable--td orgs-table--default-role deleting'
: 'fancytable--td orgs-table--default-role'
return (
<div className="orgs-table--org">
<div className="orgs-table--active">
<div className="fancytable--row">
<div className="fancytable--td orgs-table--active">
{organization.id === currentOrganization.id
? <button className="btn btn-sm btn-success">
<span className="icon checkmark" /> Current
@ -143,32 +91,11 @@ class OrganizationsTableRow extends Component {
<span className="icon shuffle" /> Switch to
</button>}
</div>
{isEditing
? <input
type="text"
className="form-control input-sm orgs-table--input"
defaultValue={workingName}
onChange={this.handleInputChange}
onBlur={this.handleInputBlur}
onKeyDown={this.handleKeyDown}
placeholder="Name this Organization..."
autoFocus={true}
onFocus={this.handleFocus}
ref={r => (this.inputRef = r)}
/>
: <div className="orgs-table--name" onClick={this.handleNameClick}>
{workingName}
<span className="icon pencil" />
</div>}
{organization.id === DEFAULT_ORG_ID
? <div className="orgs-table--public">
<SlideToggle
size="xs"
active={organization.public}
onToggle={this.handleTogglePublic}
/>
</div>
: <div className="orgs-table--public disabled">&mdash;</div>}
<InputClickToEdit
value={organization.name}
wrapperClass="fancytable--td orgs-table--name"
onUpdate={this.handleUpdateOrgName}
/>
<div className={defaultRoleClassName}>
<Dropdown
items={dropdownRolesItems}
@ -204,7 +131,6 @@ OrganizationsTableRow.propTypes = {
}).isRequired,
onDelete: func.isRequired,
onRename: func.isRequired,
onTogglePublic: func.isRequired,
onChooseDefaultRole: func.isRequired,
currentOrganization: shape({
name: string.isRequired,

View File

@ -58,20 +58,22 @@ class OrganizationsTableRowNew extends Component {
}))
return (
<div className="orgs-table--org orgs-table--new-org">
<div className="orgs-table--active">&mdash;</div>
<input
type="text"
className="form-control input-sm orgs-table--input"
value={name}
onKeyDown={this.handleKeyDown}
onChange={this.handleInputChange}
onFocus={this.handleInputFocus}
placeholder="Name this Organization..."
autoFocus={true}
ref={r => (this.inputRef = r)}
/>
<div className="orgs-table--default-role editing">
<div className="fancytable--row">
<div className="fancytable--td orgs-table--active">&mdash;</div>
<div className="fancytable--td orgs-table--name">
<input
type="text"
className="form-control input-sm"
value={name}
onKeyDown={this.handleKeyDown}
onChange={this.handleInputChange}
onFocus={this.handleInputFocus}
placeholder="Name this Organization..."
autoFocus={true}
ref={r => (this.inputRef = r)}
/>
</div>
<div className="fancytable--td orgs-table--default-role deleting">
<Dropdown
items={dropdownRolesItems}
onChoose={this.handleChooseDefaultRole}

View File

@ -0,0 +1,149 @@
import React, {Component, PropTypes} from 'react'
import uuid from 'node-uuid'
import ProvidersTableRow from 'src/admin/components/chronograf/ProvidersTableRow'
import ProvidersTableRowNew from 'src/admin/components/chronograf/ProvidersTableRowNew'
class ProvidersTable extends Component {
constructor(props) {
super(props)
this.state = {
isCreatingMap: false,
}
}
handleClickCreateMap = () => {
this.setState({isCreatingMap: true})
}
handleCancelCreateMap = () => {
this.setState({isCreatingMap: false})
}
handleCreateMap = newMap => {
this.props.onCreateMap(newMap)
this.setState({isCreatingMap: false})
}
render() {
const {
mappings = [],
organizations,
onUpdateMap,
onDeleteMap,
isLoading,
} = this.props
const {isCreatingMap} = this.state
const tableTitle =
mappings.length === 1 ? '1 Map' : `${mappings.length} Maps`
// define scheme options
const SCHEMES = [{text: '*'}, {text: 'oauth2'}]
if (isLoading) {
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">
<h2 className="panel-title">
{tableTitle}
</h2>
<button
className="btn btn-sm btn-primary"
onClick={this.handleClickCreateMap}
disabled={isCreatingMap}
>
<span className="icon plus" /> Create Mapping
</button>
</div>
{mappings.length || isCreatingMap
? <div className="panel-body">
<div className="fancytable--labels">
<div className="fancytable--th provider--scheme">Scheme</div>
<div className="fancytable--th provider--provider">
Provider
</div>
<div className="fancytable--th provider--providerorg">
Provider Org
</div>
<div className="fancytable--th provider--arrow" />
<div className="fancytable--th provider--redirect">
Organization
</div>
<div className="fancytable--th" />
<div className="fancytable--th provider--delete" />
</div>
{mappings.map((mapping, i) =>
<ProvidersTableRow
key={uuid.v4()}
mapping={mapping}
organizations={organizations}
schemes={SCHEMES}
onDelete={onDeleteMap}
onUpdate={onUpdateMap}
rowIndex={i + 1}
/>
)}
{isCreatingMap
? <ProvidersTableRowNew
organizations={organizations}
schemes={SCHEMES}
onCreate={this.handleCreateMap}
onCancel={this.handleCancelCreateMap}
rowIndex={mappings.length + 1}
/>
: null}
</div>
: <div className="panel-body">
<div className="generic-empty-state">
<h4 style={{margin: '50px 0'}}>
Looks like you have no mappings<br />
New users will not be able to sign up automatically
</h4>
<button
className="btn btn-sm btn-primary"
onClick={this.handleClickCreateMap}
disabled={isCreatingMap}
>
<span className="icon plus" /> Create Mapping
</button>
</div>
</div>}
</div>
)
}
}
const {arrayOf, bool, func, shape, string} = PropTypes
ProvidersTable.propTypes = {
mappings: arrayOf(
shape({
id: string,
scheme: string,
provider: string,
providerOrganization: string,
organizationId: string,
})
).isRequired,
organizations: arrayOf(
shape({
id: string, // when optimistically created, organization will not have an id
name: string.isRequired,
})
).isRequired,
onCreateMap: func.isRequired,
onUpdateMap: func.isRequired,
onDeleteMap: func.isRequired,
isLoading: bool.isRequired,
}
export default ProvidersTable

View File

@ -0,0 +1,150 @@
import React, {Component, PropTypes} from 'react'
import ConfirmButtons from 'shared/components/ConfirmButtons'
import Dropdown from 'shared/components/Dropdown'
import InputClickToEdit from 'shared/components/InputClickToEdit'
import {DEFAULT_MAPPING_ID} from 'src/admin/constants/chronografAdmin'
class ProvidersTableRow extends Component {
constructor(props) {
super(props)
this.state = {
...this.props.mapping,
isDeleting: false,
}
}
handleDeleteClick = () => {
this.setState({isDeleting: true})
}
handleDismissDeleteConfirmation = () => {
this.setState({isDeleting: false})
}
handleDeleteMap = mapping => {
const {onDelete} = this.props
this.setState({isDeleting: false})
onDelete(mapping)
}
handleUpdateMapping = changes => {
const {onUpdate, mapping} = this.props
const newState = {...mapping, ...changes}
this.setState(newState)
onUpdate(mapping, newState)
}
handleChangeProvider = provider => this.handleUpdateMapping({provider})
handleChangeProviderOrg = providerOrganization =>
this.handleUpdateMapping({providerOrganization})
handleChooseOrganization = ({id: organizationId}) =>
this.handleUpdateMapping({organizationId})
handleChooseScheme = ({text: scheme}) => this.handleUpdateMapping({scheme})
render() {
const {
scheme,
provider,
providerOrganization,
organizationId,
isDeleting,
} = this.state
const {organizations, mapping, schemes, rowIndex} = this.props
const selectedOrg = organizations.find(o => o.id === organizationId)
const orgDropdownItems = organizations.map(role => ({
...role,
text: role.name,
}))
const organizationIdClassName = isDeleting
? 'fancytable--td provider--redirect deleting'
: 'fancytable--td provider--redirect'
const isDefaultMapping = DEFAULT_MAPPING_ID === mapping.id
return (
<div className="fancytable--row">
<Dropdown
items={schemes}
onChoose={this.handleChooseScheme}
selected={scheme}
className="fancytable--td provider--scheme"
disabled={isDefaultMapping}
/>
<InputClickToEdit
value={provider}
wrapperClass="fancytable--td provider--provider"
onUpdate={this.handleChangeProvider}
disabled={isDefaultMapping}
tabIndex={rowIndex}
/>
<InputClickToEdit
value={providerOrganization}
wrapperClass="fancytable--td provider--providerorg"
onUpdate={this.handleChangeProviderOrg}
disabled={isDefaultMapping}
tabIndex={rowIndex}
/>
<div className="fancytable--td provider--arrow">
<span />
</div>
<div className={organizationIdClassName}>
<Dropdown
items={orgDropdownItems}
onChoose={this.handleChooseOrganization}
selected={selectedOrg.name}
className="dropdown-stretch"
disabled={isDefaultMapping}
/>
</div>
{isDeleting
? <ConfirmButtons
item={mapping}
onCancel={this.handleDismissDeleteConfirmation}
onConfirm={this.handleDeleteMap}
onClickOutside={this.handleDismissDeleteConfirmation}
/>
: <button
className="btn btn-sm btn-default btn-square"
onClick={this.handleDeleteClick}
>
<span className="icon trash" />
</button>}
</div>
)
}
}
const {arrayOf, func, number, shape, string} = PropTypes
ProvidersTableRow.propTypes = {
mapping: shape({
id: string,
scheme: string,
provider: string,
providerOrganization: string,
organizationId: string,
}),
organizations: arrayOf(
shape({
id: string.isRequired,
name: string.isRequired,
})
),
schemes: arrayOf(
shape({
text: string.isRequired,
})
),
rowIndex: number,
onDelete: func.isRequired,
onUpdate: func.isRequired,
}
export default ProvidersTableRow

View File

@ -0,0 +1,116 @@
import React, {Component, PropTypes} from 'react'
import ConfirmButtons from 'shared/components/ConfirmButtons'
import Dropdown from 'shared/components/Dropdown'
import InputClickToEdit from 'shared/components/InputClickToEdit'
class ProvidersTableRowNew extends Component {
constructor(props) {
super(props)
this.state = {
scheme: '*',
provider: null,
providerOrganization: null,
organizationId: 'default',
}
}
handleChooseScheme = scheme => {
this.setState({scheme: scheme.text})
}
handleChangeProvider = provider => {
this.setState({provider})
}
handleChangeProviderOrg = providerOrganization => {
this.setState({providerOrganization})
}
handleChooseOrganization = org => {
this.setState({organizationId: org.id})
}
handleSaveNewMapping = () => {
const {onCreate} = this.props
onCreate(this.state)
}
render() {
const {scheme, provider, providerOrganization, organizationId} = this.state
const {organizations, onCancel, schemes, rowIndex} = this.props
const selectedOrg = organizations.find(o => o.id === organizationId)
const dropdownItems = organizations.map(role => ({
...role,
text: role.name,
}))
const preventCreate = !provider || !providerOrganization
return (
<div className="fancytable--row">
<Dropdown
items={schemes}
onChoose={this.handleChooseScheme}
selected={scheme}
className={'fancytable--td provider--scheme'}
/>
<InputClickToEdit
value={provider}
wrapperClass="fancytable--td provider--provider"
onUpdate={this.handleChangeProvider}
tabIndex={rowIndex}
placeholder="google"
/>
<InputClickToEdit
value={providerOrganization}
wrapperClass="fancytable--td provider--providerorg"
onUpdate={this.handleChangeProviderOrg}
tabIndex={rowIndex}
placeholder="*"
/>
<div className="fancytable--td provider--arrow">
<span />
</div>
<div className="fancytable--td provider--redirect deleting">
<Dropdown
items={dropdownItems}
onChoose={this.handleChooseOrganization}
selected={selectedOrg.name}
className="dropdown-stretch"
/>
</div>
<ConfirmButtons
onCancel={onCancel}
onConfirm={this.handleSaveNewMapping}
isDisabled={preventCreate}
/>
</div>
)
}
}
const {arrayOf, func, number, shape, string} = PropTypes
ProvidersTableRowNew.propTypes = {
organizations: arrayOf(
shape({
id: string.isRequired,
name: string.isRequired,
})
).isRequired,
schemes: arrayOf(
shape({
text: string.isRequired,
})
),
rowIndex: number,
onCreate: func.isRequired,
onCancel: func.isRequired,
}
export default ProvidersTableRowNew

View File

@ -13,3 +13,4 @@ export const USER_ROLES = [
]
export const DEFAULT_ORG_ID = 'default'
export const DEFAULT_MAPPING_ID = 'default'

View File

@ -46,6 +46,3 @@ export const NEW_DEFAULT_DATABASE = {
isNew: true,
retentionPolicies: [NEW_DEFAULT_RP],
}
export const PUBLIC_TOOLTIP =
'If turned off, new users cannot<br/>authenticate unless an <strong>Admin</strong> explicitly<br/>adds them to the organization.'

View File

@ -0,0 +1,101 @@
import React, {Component, PropTypes} from 'react'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import * as adminChronografActionCreators from 'src/admin/actions/chronograf'
import {publishAutoDismissingNotification} from 'shared/dispatchers'
import ProvidersTable from 'src/admin/components/chronograf/ProvidersTable'
class ProvidersPage extends Component {
constructor(props) {
super(props)
this.state = {isLoading: true}
}
async componentDidMount() {
const {
links,
actions: {loadOrganizationsAsync, loadMappingsAsync},
} = this.props
await Promise.all([
loadOrganizationsAsync(links.organizations),
loadMappingsAsync(links.mappings),
])
this.setState({isLoading: false})
}
handleCreateMap = mapping => {
this.props.actions.createMappingAsync(this.props.links.mappings, mapping)
}
handleUpdateMap = (staleMap, updatedMap) => {
this.props.actions.updateMappingAsync(staleMap, updatedMap)
}
handleDeleteMap = mapping => {
this.props.actions.deleteMappingAsync(mapping)
}
render() {
const {organizations, mappings = []} = this.props
const {isLoading} = this.state
return (
<ProvidersTable
mappings={mappings}
organizations={organizations}
onCreateMap={this.handleCreateMap}
onUpdateMap={this.handleUpdateMap}
onDeleteMap={this.handleDeleteMap}
isLoading={isLoading}
/>
)
}
}
const {arrayOf, func, shape, string} = PropTypes
ProvidersPage.propTypes = {
links: shape({
organizations: string.isRequired,
}),
organizations: arrayOf(
shape({
id: string.isRequired,
name: string.isRequired,
})
),
mappings: arrayOf(
shape({
id: string,
scheme: string,
provider: string,
providerOrganization: string,
organizationId: string,
})
),
actions: shape({
loadOrganizationsAsync: func.isRequired,
}),
notify: func.isRequired,
}
const mapStateToProps = ({
links,
adminChronograf: {organizations, mappings},
}) => ({
links,
organizations,
mappings,
})
const mapDispatchToProps = dispatch => ({
actions: bindActionCreators(adminChronografActionCreators, dispatch),
notify: bindActionCreators(publishAutoDismissingNotification, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(ProvidersPage)

View File

@ -36,14 +36,6 @@ class OrganizationsPage extends Component {
getMe({shouldResetMe: false})
}
handleTogglePublic = organization => {
const {actionsAdmin: {updateOrganizationAsync}} = this.props
updateOrganizationAsync(organization, {
...organization,
public: !organization.public,
})
}
handleChooseDefaultRole = (organization, defaultRole) => {
const {actionsAdmin: {updateOrganizationAsync}} = this.props
updateOrganizationAsync(organization, {...organization, defaultRole})
@ -65,7 +57,6 @@ class OrganizationsPage extends Component {
onCreateOrg={this.handleCreateOrganization}
onDeleteOrg={this.handleDeleteOrganization}
onRenameOrg={this.handleRenameOrganization}
onTogglePublic={this.handleTogglePublic}
onChooseDefaultRole={this.handleChooseDefaultRole}
me={me}
/>

View File

@ -3,6 +3,7 @@ import {isSameUser} from 'shared/reducers/helpers/auth'
const initialState = {
users: [],
organizations: [],
mappings: [],
authConfig: {
superAdminNewUsers: false,
},
@ -58,7 +59,10 @@ const adminChronograf = (state = initialState, action) => {
case 'CHRONOGRAF_ADD_ORGANIZATION': {
const {organization} = action.payload
return {...state, organizations: [organization, ...state.organizations]}
return {
...state,
organizations: [organization, ...state.organizations],
}
}
case 'CHRONOGRAF_RENAME_ORGANIZATION': {
@ -94,9 +98,57 @@ const adminChronograf = (state = initialState, action) => {
),
}
}
case 'CHRONOGRAF_LOAD_MAPPINGS': {
const {mappings} = action.payload
return {
...state,
mappings,
}
}
case 'CHRONOGRAF_UPDATE_MAPPING': {
const {staleMapping, updatedMapping} = action.payload
return {
...state,
mappings: state.mappings.map(m =>
replaceMapping(m, staleMapping, updatedMapping)
),
}
}
case 'CHRONOGRAF_ADD_MAPPING': {
const {mapping} = action.payload
return {
...state,
mappings: [...state.mappings, mapping],
}
}
case 'CHRONOGRAF_REMOVE_MAPPING': {
const {mapping} = action.payload
return {
...state,
mappings: state.mappings.filter(
m =>
mapping._tempID
? m._tempID !== mapping._tempID
: m.id !== mapping.id
),
}
}
}
return state
}
function replaceMapping(m, staleMapping, updatedMapping) {
if (staleMapping._tempID && m._tempID === staleMapping._tempID) {
return {...updatedMapping}
} else if (m.id === staleMapping.id) {
return {...updatedMapping}
}
return m
}
export default adminChronograf

View File

@ -28,9 +28,10 @@ import {
DEFAULT_VALUE_MIN,
DEFAULT_VALUE_MAX,
GAUGE_COLORS,
SINGLE_STAT_TEXT,
SINGLE_STAT_BG,
validateColors,
validateGaugeColors,
validateSingleStatColors,
getSingleStatType,
stringifyColorValues,
} from 'src/dashboards/constants/gaugeColors'
class CellEditorOverlay extends Component {
@ -49,7 +50,8 @@ class CellEditorOverlay extends Component {
source,
}))
)
const colorsTypeContainsText = _.some(colors, {type: SINGLE_STAT_TEXT})
const singleStatType = getSingleStatType(colors)
this.state = {
cellWorkingName: name,
@ -58,8 +60,9 @@ class CellEditorOverlay extends Component {
activeQueryIndex: 0,
isDisplayOptionsTabActive: false,
axes,
colorSingleStatText: colorsTypeContainsText,
colors: validateColors(colors, type, colorsTypeContainsText),
singleStatType,
gaugeColors: validateGaugeColors(colors),
singleStatColors: validateSingleStatColors(colors, singleStatType),
}
}
@ -80,27 +83,21 @@ class CellEditorOverlay extends Component {
this.overlayRef.focus()
}
handleAddThreshold = () => {
const {colors, cellWorkingType} = this.state
const sortedColors = _.sortBy(colors, color => Number(color.value))
handleAddGaugeThreshold = () => {
const {gaugeColors} = this.state
const sortedColors = _.sortBy(gaugeColors, color => color.value)
if (sortedColors.length <= MAX_THRESHOLDS) {
const randomColor = _.random(0, GAUGE_COLORS.length - 1)
const maxValue =
cellWorkingType === 'gauge'
? Number(sortedColors[sortedColors.length - 1].value)
: DEFAULT_VALUE_MAX
const minValue =
cellWorkingType === 'gauge'
? Number(sortedColors[0].value)
: DEFAULT_VALUE_MIN
const maxValue = sortedColors[sortedColors.length - 1].value
const minValue = sortedColors[0].value
const colorsValues = _.mapValues(colors, 'value')
const colorsValues = _.mapValues(gaugeColors, 'value')
let randomValue
do {
randomValue = `${_.round(_.random(minValue, maxValue, true), 2)}`
randomValue = _.round(_.random(minValue, maxValue, true), 2)
} while (_.includes(colorsValues, randomValue))
const newThreshold = {
@ -111,68 +108,134 @@ class CellEditorOverlay extends Component {
name: GAUGE_COLORS[randomColor].name,
}
this.setState({colors: [...colors, newThreshold]})
this.setState({gaugeColors: [...gaugeColors, newThreshold]})
}
}
handleAddSingleStatThreshold = () => {
const {singleStatColors, singleStatType} = this.state
const randomColor = _.random(0, GAUGE_COLORS.length - 1)
const maxValue = DEFAULT_VALUE_MIN
const minValue = DEFAULT_VALUE_MAX
let randomValue = _.round(_.random(minValue, maxValue, true), 2)
if (singleStatColors.length > 0) {
const colorsValues = _.mapValues(singleStatColors, 'value')
do {
randomValue = _.round(_.random(minValue, maxValue, true), 2)
} while (_.includes(colorsValues, randomValue))
}
const newThreshold = {
type: singleStatType,
id: uuid.v4(),
value: randomValue,
hex: GAUGE_COLORS[randomColor].hex,
name: GAUGE_COLORS[randomColor].name,
}
this.setState({singleStatColors: [...singleStatColors, newThreshold]})
}
handleDeleteThreshold = threshold => () => {
const {colors} = this.state
const {cellWorkingType} = this.state
const newColors = colors.filter(color => color.id !== threshold.id)
if (cellWorkingType === 'gauge') {
const gaugeColors = this.state.gaugeColors.filter(
color => color.id !== threshold.id
)
this.setState({colors: newColors})
this.setState({gaugeColors})
}
if (cellWorkingType === 'single-stat') {
const singleStatColors = this.state.singleStatColors.filter(
color => color.id !== threshold.id
)
this.setState({singleStatColors})
}
}
handleChooseColor = threshold => chosenColor => {
const {colors} = this.state
const {cellWorkingType} = this.state
const newColors = colors.map(
color =>
color.id === threshold.id
? {...color, hex: chosenColor.hex, name: chosenColor.name}
: color
)
if (cellWorkingType === 'gauge') {
const gaugeColors = this.state.gaugeColors.map(
color =>
color.id === threshold.id
? {...color, hex: chosenColor.hex, name: chosenColor.name}
: color
)
this.setState({colors: newColors})
this.setState({gaugeColors})
}
if (cellWorkingType === 'single-stat') {
const singleStatColors = this.state.singleStatColors.map(
color =>
color.id === threshold.id
? {...color, hex: chosenColor.hex, name: chosenColor.name}
: color
)
this.setState({singleStatColors})
}
}
handleUpdateColorValue = (threshold, newValue) => {
const {colors} = this.state
const newColors = colors.map(
color => (color.id === threshold.id ? {...color, value: newValue} : color)
)
this.setState({colors: newColors})
handleUpdateColorValue = (threshold, value) => {
const {cellWorkingType} = this.state
if (cellWorkingType === 'gauge') {
const gaugeColors = this.state.gaugeColors.map(
color => (color.id === threshold.id ? {...color, value} : color)
)
this.setState({gaugeColors})
}
if (cellWorkingType === 'single-stat') {
const singleStatColors = this.state.singleStatColors.map(
color => (color.id === threshold.id ? {...color, value} : color)
)
this.setState({singleStatColors})
}
}
handleValidateColorValue = (threshold, e) => {
const {colors, cellWorkingType} = this.state
const sortedColors = _.sortBy(colors, color => Number(color.value))
const thresholdValue = Number(threshold.value)
const targetValueNumber = Number(e.target.value)
handleValidateColorValue = (threshold, targetValue) => {
const {gaugeColors, singleStatColors, cellWorkingType} = this.state
const thresholdValue = threshold.value
let allowedToUpdate = false
if (cellWorkingType === 'single-stat') {
// If type is single-stat then value only has to be unique
return !sortedColors.some(color => color.value === e.target.value)
const sortedColors = _.sortBy(singleStatColors, color => color.value)
return !sortedColors.some(color => color.value === targetValue)
}
const minValue = Number(sortedColors[0].value)
const maxValue = Number(sortedColors[sortedColors.length - 1].value)
const sortedColors = _.sortBy(gaugeColors, color => color.value)
const minValue = sortedColors[0].value
const maxValue = sortedColors[sortedColors.length - 1].value
// If lowest value, make sure it is less than the next threshold
if (thresholdValue === minValue) {
const nextValue = Number(sortedColors[1].value)
allowedToUpdate = targetValueNumber < nextValue
const nextValue = sortedColors[1].value
allowedToUpdate = targetValue < nextValue
}
// If highest value, make sure it is greater than the previous threshold
if (thresholdValue === maxValue) {
const previousValue = Number(sortedColors[sortedColors.length - 2].value)
allowedToUpdate = previousValue < targetValueNumber
const previousValue = sortedColors[sortedColors.length - 2].value
allowedToUpdate = previousValue < targetValue
}
// If not min or max, make sure new value is greater than min, less than max, and unique
if (thresholdValue !== minValue && thresholdValue !== maxValue) {
const greaterThanMin = targetValueNumber > minValue
const lessThanMax = targetValueNumber < maxValue
const greaterThanMin = targetValue > minValue
const lessThanMax = targetValue < maxValue
const colorsWithoutMinOrMax = sortedColors.slice(
1,
@ -180,7 +243,7 @@ class CellEditorOverlay extends Component {
)
const isUnique = !colorsWithoutMinOrMax.some(
color => color.value === e.target.value
color => color.value === targetValue
)
allowedToUpdate = greaterThanMin && lessThanMax && isUnique
@ -189,16 +252,15 @@ class CellEditorOverlay extends Component {
return allowedToUpdate
}
handleToggleSingleStatText = () => {
const {colors, colorSingleStatText} = this.state
const formattedColors = colors.map(color => ({
handleToggleSingleStatType = type => () => {
const singleStatColors = this.state.singleStatColors.map(color => ({
...color,
type: colorSingleStatText ? SINGLE_STAT_BG : SINGLE_STAT_TEXT,
type,
}))
this.setState({
colorSingleStatText: !colorSingleStatText,
colors: formattedColors,
singleStatType: type,
singleStatColors,
})
}
@ -298,7 +360,8 @@ class CellEditorOverlay extends Component {
cellWorkingType: type,
cellWorkingName: name,
axes,
colors,
gaugeColors,
singleStatColors,
} = this.state
const {cell} = this.props
@ -314,6 +377,13 @@ class CellEditorOverlay extends Component {
}
})
let colors = []
if (type === 'gauge') {
colors = stringifyColorValues(gaugeColors)
} else if (type === 'single-stat' || type === 'line-plus-single-stat') {
colors = stringifyColorValues(singleStatColors)
}
this.props.onSave({
...cell,
name,
@ -324,14 +394,8 @@ class CellEditorOverlay extends Component {
})
}
handleSelectGraphType = graphType => () => {
const {colors, colorSingleStatText} = this.state
const validatedColors = validateColors(
colors,
graphType,
colorSingleStatText
)
this.setState({cellWorkingType: graphType, colors: validatedColors})
handleSelectGraphType = cellWorkingType => () => {
this.setState({cellWorkingType})
}
handleClickDisplayOptionsTab = isDisplayOptionsTabActive => () => {
@ -474,13 +538,14 @@ class CellEditorOverlay extends Component {
const {
axes,
colors,
gaugeColors,
singleStatColors,
activeQueryIndex,
cellWorkingName,
cellWorkingType,
isDisplayOptionsTabActive,
queriesWorkingDraft,
colorSingleStatText,
singleStatType,
} = this.state
const queryActions = {
@ -492,6 +557,9 @@ class CellEditorOverlay extends Component {
(!!query.measurement && !!query.database && !!query.fields.length) ||
!!query.rawText
const visualizationColors =
cellWorkingType === 'gauge' ? gaugeColors : singleStatColors
return (
<div
className={OVERLAY_TECHNOLOGY}
@ -508,7 +576,7 @@ class CellEditorOverlay extends Component {
>
<Visualization
axes={axes}
colors={colors}
colors={visualizationColors}
type={cellWorkingType}
name={cellWorkingName}
timeRange={timeRange}
@ -533,14 +601,16 @@ class CellEditorOverlay extends Component {
{isDisplayOptionsTabActive
? <DisplayOptions
axes={axes}
colors={colors}
gaugeColors={gaugeColors}
singleStatColors={singleStatColors}
onChooseColor={this.handleChooseColor}
onValidateColorValue={this.handleValidateColorValue}
onUpdateColorValue={this.handleUpdateColorValue}
onAddThreshold={this.handleAddThreshold}
onAddGaugeThreshold={this.handleAddGaugeThreshold}
onAddSingleStatThreshold={this.handleAddSingleStatThreshold}
onDeleteThreshold={this.handleDeleteThreshold}
onToggleSingleStatText={this.handleToggleSingleStatText}
colorSingleStatText={colorSingleStatText}
onToggleSingleStatType={this.handleToggleSingleStatType}
singleStatType={singleStatType}
onSetBase={this.handleSetBase}
onSetLabel={this.handleSetLabel}
onSetScale={this.handleSetScale}

View File

@ -35,7 +35,8 @@ class DisplayOptions extends Component {
renderOptions = () => {
const {
colors,
gaugeColors,
singleStatColors,
onSetBase,
onSetScale,
onSetLabel,
@ -43,13 +44,14 @@ class DisplayOptions extends Component {
onSetPrefixSuffix,
onSetYAxisBoundMin,
onSetYAxisBoundMax,
onAddThreshold,
onAddGaugeThreshold,
onAddSingleStatThreshold,
onDeleteThreshold,
onChooseColor,
onValidateColorValue,
onUpdateColorValue,
colorSingleStatText,
onToggleSingleStatText,
singleStatType,
onToggleSingleStatType,
onSetSuffix,
} = this.props
const {axes, axes: {y: {suffix}}} = this.state
@ -58,27 +60,27 @@ class DisplayOptions extends Component {
case 'gauge':
return (
<GaugeOptions
colors={colors}
colors={gaugeColors}
onChooseColor={onChooseColor}
onValidateColorValue={onValidateColorValue}
onUpdateColorValue={onUpdateColorValue}
onAddThreshold={onAddThreshold}
onAddThreshold={onAddGaugeThreshold}
onDeleteThreshold={onDeleteThreshold}
/>
)
case 'single-stat':
return (
<SingleStatOptions
colors={colors}
colors={singleStatColors}
suffix={suffix}
onSetSuffix={onSetSuffix}
onChooseColor={onChooseColor}
onValidateColorValue={onValidateColorValue}
onUpdateColorValue={onUpdateColorValue}
onAddThreshold={onAddThreshold}
onAddThreshold={onAddSingleStatThreshold}
onDeleteThreshold={onDeleteThreshold}
colorSingleStatText={colorSingleStatText}
onToggleSingleStatText={onToggleSingleStatText}
singleStatType={singleStatType}
onToggleSingleStatType={onToggleSingleStatType}
/>
)
default:
@ -111,10 +113,11 @@ class DisplayOptions extends Component {
)
}
}
const {arrayOf, bool, func, shape, string} = PropTypes
const {arrayOf, func, number, shape, string} = PropTypes
DisplayOptions.propTypes = {
onAddThreshold: func.isRequired,
onAddGaugeThreshold: func.isRequired,
onAddSingleStatThreshold: func.isRequired,
onDeleteThreshold: func.isRequired,
onChooseColor: func.isRequired,
onValidateColorValue: func.isRequired,
@ -129,18 +132,27 @@ DisplayOptions.propTypes = {
onSetLabel: func.isRequired,
onSetBase: func.isRequired,
axes: shape({}).isRequired,
colors: arrayOf(
gaugeColors: arrayOf(
shape({
type: string.isRequired,
hex: string.isRequired,
id: string.isRequired,
name: string.isRequired,
value: string.isRequired,
value: number.isRequired,
}).isRequired
),
singleStatColors: arrayOf(
shape({
type: string.isRequired,
hex: string.isRequired,
id: string.isRequired,
name: string.isRequired,
value: number.isRequired,
}).isRequired
),
queryConfigs: arrayOf(shape()).isRequired,
colorSingleStatText: bool.isRequired,
onToggleSingleStatText: func.isRequired,
singleStatType: string.isRequired,
onToggleSingleStatType: func.isRequired,
}
export default DisplayOptions

View File

@ -19,7 +19,7 @@ const GaugeOptions = ({
}) => {
const disableMaxColor = colors.length > MIN_THRESHOLDS
const disableAddThreshold = colors.length > MAX_THRESHOLDS
const sortedColors = _.sortBy(colors, color => Number(color.value))
const sortedColors = _.sortBy(colors, color => color.value)
return (
<FancyScrollbar
@ -58,7 +58,7 @@ const GaugeOptions = ({
)
}
const {arrayOf, func, shape, string} = PropTypes
const {arrayOf, func, number, shape, string} = PropTypes
GaugeOptions.propTypes = {
colors: arrayOf(
@ -67,7 +67,7 @@ GaugeOptions.propTypes = {
hex: string.isRequired,
id: string.isRequired,
name: string.isRequired,
value: string.isRequired,
value: number.isRequired,
}).isRequired
),
onAddThreshold: func.isRequired,

View File

@ -3,9 +3,20 @@ import _ from 'lodash'
import FancyScrollbar from 'shared/components/FancyScrollbar'
import Threshold from 'src/dashboards/components/Threshold'
import ColorDropdown from 'shared/components/ColorDropdown'
import {MAX_THRESHOLDS} from 'src/dashboards/constants/gaugeColors'
import {
GAUGE_COLORS,
MAX_THRESHOLDS,
SINGLE_STAT_BASE,
SINGLE_STAT_TEXT,
SINGLE_STAT_BG,
} from 'src/dashboards/constants/gaugeColors'
const formatColor = color => {
const {hex, name} = color
return {hex, name}
}
const SingleStatOptions = ({
suffix,
onSetSuffix,
@ -15,12 +26,12 @@ const SingleStatOptions = ({
onChooseColor,
onValidateColorValue,
onUpdateColorValue,
colorSingleStatText,
onToggleSingleStatText,
singleStatType,
onToggleSingleStatType,
}) => {
const disableAddThreshold = colors.length > MAX_THRESHOLDS
const sortedColors = _.sortBy(colors, color => Number(color.value))
const sortedColors = _.sortBy(colors, color => color.value)
return (
<FancyScrollbar
@ -37,16 +48,27 @@ const SingleStatOptions = ({
>
<span className="icon plus" /> Add Threshold
</button>
{sortedColors.map(color =>
<Threshold
visualizationType="single-stat"
threshold={color}
key={color.id}
onChooseColor={onChooseColor}
onValidateColorValue={onValidateColorValue}
onUpdateColorValue={onUpdateColorValue}
onDeleteThreshold={onDeleteThreshold}
/>
{sortedColors.map(
color =>
color.id === SINGLE_STAT_BASE
? <div className="gauge-controls--section" key={color.id}>
<div className="gauge-controls--label">Base Color</div>
<ColorDropdown
colors={GAUGE_COLORS}
selected={formatColor(color)}
onChoose={onChooseColor(color)}
stretchToFit={true}
/>
</div>
: <Threshold
visualizationType="single-stat"
threshold={color}
key={color.id}
onChooseColor={onChooseColor}
onValidateColorValue={onValidateColorValue}
onUpdateColorValue={onUpdateColorValue}
onDeleteThreshold={onDeleteThreshold}
/>
)}
</div>
<div className="single-stat-controls">
@ -54,14 +76,18 @@ const SingleStatOptions = ({
<label>Coloring</label>
<ul className="nav nav-tablist nav-tablist-sm">
<li
className={colorSingleStatText ? null : 'active'}
onClick={onToggleSingleStatText}
className={`${singleStatType === SINGLE_STAT_BG
? 'active'
: ''}`}
onClick={onToggleSingleStatType(SINGLE_STAT_BG)}
>
Background
</li>
<li
className={colorSingleStatText ? 'active' : null}
onClick={onToggleSingleStatText}
className={`${singleStatType === SINGLE_STAT_TEXT
? 'active'
: ''}`}
onClick={onToggleSingleStatType(SINGLE_STAT_TEXT)}
>
Text
</li>
@ -83,7 +109,7 @@ const SingleStatOptions = ({
)
}
const {arrayOf, bool, func, shape, string} = PropTypes
const {arrayOf, func, number, shape, string} = PropTypes
SingleStatOptions.defaultProps = {
colors: [],
@ -96,7 +122,7 @@ SingleStatOptions.propTypes = {
hex: string.isRequired,
id: string.isRequired,
name: string.isRequired,
value: string.isRequired,
value: number.isRequired,
}).isRequired
),
onAddThreshold: func.isRequired,
@ -104,8 +130,8 @@ SingleStatOptions.propTypes = {
onChooseColor: func.isRequired,
onValidateColorValue: func.isRequired,
onUpdateColorValue: func.isRequired,
colorSingleStatText: bool.isRequired,
onToggleSingleStatText: func.isRequired,
singleStatType: string.isRequired,
onToggleSingleStatType: func.isRequired,
onSetSuffix: func.isRequired,
suffix: string.isRequired,
}

View File

@ -16,14 +16,15 @@ class Threshold extends Component {
handleChangeWorkingValue = e => {
const {threshold, onValidateColorValue, onUpdateColorValue} = this.props
const targetValue = Number(e.target.value)
const valid = onValidateColorValue(threshold, e)
const valid = onValidateColorValue(threshold, targetValue)
if (valid) {
onUpdateColorValue(threshold, e.target.value)
onUpdateColorValue(threshold, targetValue)
}
this.setState({valid, workingValue: e.target.value})
this.setState({valid, workingValue: targetValue})
}
handleBlur = () => {
@ -98,7 +99,7 @@ class Threshold extends Component {
}
}
const {bool, func, shape, string} = PropTypes
const {bool, func, number, shape, string} = PropTypes
Threshold.propTypes = {
visualizationType: string.isRequired,
@ -107,7 +108,7 @@ Threshold.propTypes = {
hex: string.isRequired,
id: string.isRequired,
name: string.isRequired,
value: string.isRequired,
value: number.isRequired,
}).isRequired,
disableMaxColor: bool,
onChooseColor: func.isRequired,

View File

@ -3,6 +3,8 @@ import RefreshingGraph from 'shared/components/RefreshingGraph'
import buildQueries from 'utils/buildQueriesForGraphs'
import VisualizationName from 'src/dashboards/components/VisualizationName'
import {stringifyColorValues} from 'src/dashboards/constants/gaugeColors'
const DashVisualization = (
{
axes,
@ -23,7 +25,7 @@ const DashVisualization = (
<VisualizationName defaultName={name} onCellRename={onCellRename} />
<div className="graph-container">
<RefreshingGraph
colors={colors}
colors={stringifyColorValues(colors)}
axes={axes}
type={type}
queries={buildQueries(proxy, queryConfigs, timeRange)}
@ -66,8 +68,8 @@ DashVisualization.propTypes = {
hex: string.isRequired,
id: string.isRequired,
name: string.isRequired,
value: string.isRequired,
}).isRequired
value: number.isRequired,
})
),
}

View File

@ -4,13 +4,14 @@ export const MAX_THRESHOLDS = 5
export const MIN_THRESHOLDS = 2
export const COLOR_TYPE_MIN = 'min'
export const DEFAULT_VALUE_MIN = '0'
export const DEFAULT_VALUE_MIN = 0
export const COLOR_TYPE_MAX = 'max'
export const DEFAULT_VALUE_MAX = '100'
export const DEFAULT_VALUE_MAX = 100
export const COLOR_TYPE_THRESHOLD = 'threshold'
export const SINGLE_STAT_TEXT = 'text'
export const SINGLE_STAT_BG = 'background'
export const SINGLE_STAT_BASE = 'base'
export const GAUGE_COLORS = [
{
@ -81,9 +82,13 @@ export const GAUGE_COLORS = [
hex: '#545667',
name: 'graphite',
},
{
hex: '#ffffff',
name: 'white',
},
]
export const DEFAULT_COLORS = [
export const DEFAULT_GAUGE_COLORS = [
{
type: COLOR_TYPE_MIN,
hex: GAUGE_COLORS[11].hex,
@ -100,27 +105,73 @@ export const DEFAULT_COLORS = [
},
]
export const validateColors = (colors, type, colorSingleStatText) => {
if (type === 'single-stat') {
// Single stat colors should all have type of 'text' or 'background'
const colorType = colorSingleStatText ? SINGLE_STAT_TEXT : SINGLE_STAT_BG
return colors ? colors.map(color => ({...color, type: colorType})) : null
}
export const DEFAULT_SINGLESTAT_COLORS = [
{
type: SINGLE_STAT_TEXT,
hex: GAUGE_COLORS[11].hex,
id: SINGLE_STAT_BASE,
name: GAUGE_COLORS[11].name,
value: 0,
},
]
export const validateSingleStatColors = (colors, type) => {
if (!colors || colors.length === 0) {
return DEFAULT_COLORS
}
if (type === 'gauge') {
// Gauge colors should have a type of min, any number of thresholds, and a max
const formatttedColors = _.sortBy(colors, color =>
Number(color.value)
).map(c => ({
...c,
type: COLOR_TYPE_THRESHOLD,
}))
formatttedColors[0].type = COLOR_TYPE_MIN
formatttedColors[formatttedColors.length - 1].type = COLOR_TYPE_MAX
return formatttedColors
return DEFAULT_SINGLESTAT_COLORS
}
return colors.length >= MIN_THRESHOLDS ? colors : DEFAULT_COLORS
let containsBaseColor = false
const formattedColors = colors.map(color => {
if (color.id === SINGLE_STAT_BASE) {
// Check for existance of base color
containsBaseColor = true
return {...color, value: Number(color.value), type}
}
// Single stat colors should all have type of 'text' or 'background'
return {...color, value: Number(color.value), type}
})
const formattedColorsWithBase = [
...formattedColors,
DEFAULT_SINGLESTAT_COLORS[0],
]
return containsBaseColor ? formattedColors : formattedColorsWithBase
}
export const getSingleStatType = colors => {
const type = _.get(colors, ['0', 'type'], false)
if (type) {
if (_.includes([SINGLE_STAT_TEXT, SINGLE_STAT_BG], type)) {
return type
}
}
return SINGLE_STAT_TEXT
}
export const validateGaugeColors = colors => {
if (!colors || colors.length < MIN_THRESHOLDS) {
return DEFAULT_GAUGE_COLORS
}
// Gauge colors should have a type of min, any number of thresholds, and a max
const formattedColors = _.sortBy(colors, color =>
Number(color.value)
).map(color => ({
...color,
value: Number(color.value),
type: COLOR_TYPE_THRESHOLD,
}))
formattedColors[0].type = COLOR_TYPE_MIN
formattedColors[formattedColors.length - 1].type = COLOR_TYPE_MAX
return formattedColors
}
export const stringifyColorValues = colors => {
return colors.map(color => ({...color, value: `${color.value}`}))
}

View File

@ -5,10 +5,15 @@ import _ from 'lodash'
import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries'
import {resultsToCSV} from 'src/shared/parsing/resultsToCSV.js'
import download from 'src/external/download.js'
import {TEMPLATES} from 'src/data_explorer/constants'
const getCSV = (query, errorThrown) => async () => {
try {
const {results} = await fetchTimeSeriesAsync({source: query.host, query})
const {results} = await fetchTimeSeriesAsync({
source: query.host,
query,
tempVars: TEMPLATES,
})
const {flag, name, CSVString} = resultsToCSV(results)
if (flag === 'no_data') {
errorThrown('no data', 'There are no data to download.')

View File

@ -74,12 +74,19 @@ window.addEventListener('keyup', event => {
const history = syncHistoryWithStore(browserHistory, store)
const Root = React.createClass({
getInitialState() {
return {
ready: false,
}
},
async componentWillMount() {
this.flushErrorsQueue()
try {
await this.getLinks()
this.checkAuth()
await this.checkAuth()
this.setState({ready: true})
} catch (error) {
dispatch(errorThrown(error))
}
@ -115,48 +122,66 @@ const Root = React.createClass({
},
render() {
return (
<Provider store={store}>
<Router history={history}>
<Route path="/" component={UserIsAuthenticated(CheckSources)} />
<Route path="/login" component={UserIsNotAuthenticated(Login)} />
<Route path="/purgatory" component={UserIsAuthenticated(Purgatory)} />
<Route
path="/sources/new"
component={UserIsAuthenticated(SourcePage)}
/>
<Route path="/sources/:sourceID" component={UserIsAuthenticated(App)}>
<Route component={CheckSources}>
<Route path="status" component={StatusPage} />
<Route path="hosts" component={HostsPage} />
<Route path="hosts/:hostID" component={HostPage} />
<Route path="chronograf/data-explorer" component={DataExplorer} />
<Route path="dashboards" component={DashboardsPage} />
<Route path="dashboards/:dashboardID" component={DashboardPage} />
<Route path="alerts" component={AlertsApp} />
<Route path="alert-rules" component={KapacitorRulesPage} />
<Route path="alert-rules/:ruleID" component={KapacitorRulePage} />
<Route path="alert-rules/new" component={KapacitorRulePage} />
<Route path="tickscript/new" component={TickscriptPage} />
<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} />
<Route path="manage-sources" component={ManageSources} />
<Route path="manage-sources/new" component={SourcePage} />
<Route path="manage-sources/:id/edit" component={SourcePage} />
return !this.state.ready // eslint-disable-line no-negated-condition
? <div className="page-spinner" />
: <Provider store={store}>
<Router history={history}>
<Route path="/" component={UserIsAuthenticated(CheckSources)} />
<Route path="/login" component={UserIsNotAuthenticated(Login)} />
<Route
path="/purgatory"
component={UserIsAuthenticated(Purgatory)}
/>
<Route
path="/sources/new"
component={UserIsAuthenticated(SourcePage)}
/>
<Route
path="/sources/:sourceID"
component={UserIsAuthenticated(App)}
>
<Route component={CheckSources}>
<Route path="status" component={StatusPage} />
<Route path="hosts" component={HostsPage} />
<Route path="hosts/:hostID" component={HostPage} />
<Route
path="chronograf/data-explorer"
component={DataExplorer}
/>
<Route path="dashboards" component={DashboardsPage} />
<Route
path="dashboards/:dashboardID"
component={DashboardPage}
/>
<Route path="alerts" component={AlertsApp} />
<Route path="alert-rules" component={KapacitorRulesPage} />
<Route
path="alert-rules/:ruleID"
component={KapacitorRulePage}
/>
<Route path="alert-rules/new" component={KapacitorRulePage} />
<Route path="tickscript/new" component={TickscriptPage} />
<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} />
<Route path="manage-sources" component={ManageSources} />
<Route path="manage-sources/new" component={SourcePage} />
<Route path="manage-sources/:id/edit" component={SourcePage} />
</Route>
</Route>
</Route>
<Route path="*" component={NotFound} />
</Router>
</Provider>
)
<Route path="*" component={NotFound} />
</Router>
</Provider>
},
})

View File

@ -10,7 +10,7 @@ const LogItemHTTPError = ({logItem}) =>
</div>
<div className="logs-table--details">
<div className="logs-table--service error">HTTP Server</div>
<div className="logs-table--blah">
<div className="logs-table--columns">
<div className="logs-table--key-values error">
ERROR: {logItem.msg}
</div>

View File

@ -10,7 +10,7 @@ const LogItemInfluxDBDebug = ({logItem}) =>
</div>
<div className="logs-table--details">
<div className="logs-table--service debug">InfluxDB</div>
<div className="logs-table--blah">
<div className="logs-table--columns">
<div className="logs-table--key-values debug">
DEBUG: {logItem.msg}
<br />

View File

@ -10,7 +10,7 @@ const LogItemKapacitorDebug = ({logItem}) =>
</div>
<div className="logs-table--details">
<div className="logs-table--service debug">Kapacitor</div>
<div className="logs-table--blah">
<div className="logs-table--columns">
<div className="logs-table--key-values debug">
DEBUG: {logItem.msg}
</div>

View File

@ -10,7 +10,7 @@ const LogItemKapacitorError = ({logItem}) =>
</div>
<div className="logs-table--details">
<div className="logs-table--service error">Kapacitor</div>
<div className="logs-table--blah">
<div className="logs-table--columns">
<div className="logs-table--key-values error">
ERROR: {logItem.msg}
</div>

View File

@ -1,18 +1,26 @@
import React, {PropTypes} from 'react'
const renderKeysAndValues = object => {
const renderKeysAndValues = (object, name) => {
if (!object) {
return <span className="logs-table--empty-cell">--</span>
}
const objKeys = Object.keys(object)
const objValues = Object.values(object)
const objElements = objKeys.map((objKey, i) =>
<div key={i} className="logs-table--key-value">
{objKey}: <span>{objValues[i]}</span>
const sortedObjKeys = Object.keys(object).sort()
return (
<div className="logs-table--column">
<h1>
{`${sortedObjKeys.length} ${name}`}
</h1>
<div className="logs-table--scrollbox">
{sortedObjKeys.map(objKey =>
<div key={objKey} className="logs-table--key-value">
{objKey}: <span>{object[objKey]}</span>
</div>
)}
</div>
</div>
)
return objElements
}
const LogItemKapacitorPoint = ({logItem}) =>
<div className="logs-table--row">
@ -24,15 +32,9 @@ const LogItemKapacitorPoint = ({logItem}) =>
</div>
<div className="logs-table--details">
<div className="logs-table--service">Kapacitor Point</div>
<div className="logs-table--blah">
<div className="logs-table--key-values">
TAGS<br />
{renderKeysAndValues(logItem.tag)}
</div>
<div className="logs-table--key-values">
FIELDS<br />
{renderKeysAndValues(logItem.field)}
</div>
<div className="logs-table--columns">
{renderKeysAndValues(logItem.tag, 'Tags')}
{renderKeysAndValues(logItem.field, 'Fields')}
</div>
</div>
</div>

View File

@ -1,24 +1,23 @@
import React, {PropTypes} from 'react'
import InfiniteScroll from 'shared/components/InfiniteScroll'
import LogsTableRow from 'src/kapacitor/components/LogsTableRow'
import FancyScrollbar from 'src/shared/components/FancyScrollbar'
const numLogsToRender = 200
const LogsTable = ({logs}) =>
<div className="logs-table--container">
<div className="logs-table">
<div className="logs-table--header">
<h2 className="panel-title">Logs</h2>
</div>
<div className="logs-table--panel fancy-scroll--kapacitor">
{logs.length
? <InfiniteScroll
className="logs-table"
itemHeight={87}
items={logs.map((log, i) =>
<LogsTableRow key={log.key} logItem={log} index={i} />
)}
/>
: <div className="page-spinner" />}
{`${numLogsToRender} Most Recent Logs`}
</div>
<FancyScrollbar
autoHide={false}
className="logs-table--container fancy-scroll--kapacitor"
>
{logs
.slice(0, numLogsToRender)
.map(log => <LogsTableRow key={log.key} logItem={log} />)}
</FancyScrollbar>
</div>
const {arrayOf, shape, string} = PropTypes

View File

@ -8,31 +8,31 @@ import LogItemKapacitorError from 'src/kapacitor/components/LogItemKapacitorErro
import LogItemKapacitorDebug from 'src/kapacitor/components/LogItemKapacitorDebug'
import LogItemInfluxDBDebug from 'src/kapacitor/components/LogItemInfluxDBDebug'
const LogsTableRow = ({logItem, index}) => {
const LogsTableRow = ({logItem}) => {
if (logItem.service === 'sessions') {
return <LogItemSession logItem={logItem} key={index} />
return <LogItemSession logItem={logItem} />
}
if (logItem.service === 'http' && logItem.msg === 'http request') {
return <LogItemHTTP logItem={logItem} key={index} />
return <LogItemHTTP logItem={logItem} />
}
if (logItem.service === 'kapacitor' && logItem.msg === 'point') {
return <LogItemKapacitorPoint logItem={logItem} key={index} />
return <LogItemKapacitorPoint logItem={logItem} />
}
if (logItem.service === 'httpd_server_errors' && logItem.lvl === 'error') {
return <LogItemHTTPError logItem={logItem} key={index} />
return <LogItemHTTPError logItem={logItem} />
}
if (logItem.service === 'kapacitor' && logItem.lvl === 'error') {
return <LogItemKapacitorError logItem={logItem} key={index} />
return <LogItemKapacitorError logItem={logItem} />
}
if (logItem.service === 'kapacitor' && logItem.lvl === 'debug') {
return <LogItemKapacitorDebug logItem={logItem} key={index} />
return <LogItemKapacitorDebug logItem={logItem} />
}
if (logItem.service === 'influxdb' && logItem.lvl === 'debug') {
return <LogItemInfluxDBDebug logItem={logItem} key={index} />
return <LogItemInfluxDBDebug logItem={logItem} />
}
return (
<div className="logs-table--row" key={index}>
<div className="logs-table--row">
<div className="logs-table--divider">
<div className={`logs-table--level ${logItem.lvl}`} />
<div className="logs-table--timestamp">
@ -43,7 +43,7 @@ const LogsTableRow = ({logItem, index}) => {
<div className="logs-table--service">
{logItem.service || '--'}
</div>
<div className="logs-table--blah">
<div className="logs-table--columns">
<div className="logs-table--key-values">
{logItem.msg || '--'}
</div>
@ -53,7 +53,7 @@ const LogsTableRow = ({logItem, index}) => {
)
}
const {number, shape, string} = PropTypes
const {shape, string} = PropTypes
LogsTableRow.propTypes = {
logItem: shape({
@ -62,7 +62,6 @@ LogsTableRow.propTypes = {
lvl: string.isRequired,
msg: string.isRequired,
}).isRequired,
index: number,
}
export default LogsTableRow

View File

@ -34,7 +34,10 @@ const Tickscript = ({
isNewTickscript={isNewTickscript}
/>
<div className="page-contents--split">
<div className="tickscript">
<div
className="tickscript"
style={areLogsVisible ? {maxWidth: '50%'} : null}
>
<TickscriptEditorControls
isNewTickscript={isNewTickscript}
onSelectDbrps={onSelectDbrps}

View File

@ -33,11 +33,12 @@ class ColorDropdown extends Component {
render() {
const {visible} = this.state
const {colors, selected, disabled} = this.props
const {colors, selected, disabled, stretchToFit} = this.props
const dropdownClassNames = visible
? 'color-dropdown open'
: 'color-dropdown'
const dropdownClassNames = classnames('color-dropdown', {
open: visible,
'color-dropdown--stretch': stretchToFit,
})
const toggleClassNames = classnames(
'btn btn-sm btn-default color-dropdown--toggle',
{active: visible, 'color-dropdown__disabled': disabled}
@ -103,6 +104,7 @@ ColorDropdown.propTypes = {
name: string.isRequired,
})
).isRequired,
stretchToFit: bool,
disabled: bool,
}

View File

@ -132,7 +132,7 @@ class DygraphLegend extends Component {
isFilterVisible,
} = this.state
const withValues = legend.series.filter(s => s.y)
const withValues = legend.series.filter(s => !_.isNil(s.y))
const sorted = _.sortBy(
withValues,
({y, label}) => (sortType === 'numeric' ? y : label)
@ -158,6 +158,7 @@ class DygraphLegend extends Component {
<div className="sort-btn--bottom">Z</div>
</div>
)
const renderSortNum = (
<button
className={classnames('sort-btn btn btn-sm btn-square', {

View File

@ -2,7 +2,10 @@ import React, {PropTypes, PureComponent} from 'react'
import lastValues from 'shared/parsing/lastValues'
import Gauge from 'shared/components/Gauge'
import {DEFAULT_COLORS} from 'src/dashboards/constants/gaugeColors'
import {
DEFAULT_GAUGE_COLORS,
stringifyColorValues,
} from 'src/dashboards/constants/gaugeColors'
import {DASHBOARD_LAYOUT_ROW_HEIGHT} from 'shared/constants'
class GaugeChart extends PureComponent {
@ -60,7 +63,7 @@ class GaugeChart extends PureComponent {
const {arrayOf, bool, number, shape, string} = PropTypes
GaugeChart.defaultProps = {
colors: DEFAULT_COLORS,
colors: stringifyColorValues(DEFAULT_GAUGE_COLORS),
}
GaugeChart.propTypes = {

View File

@ -0,0 +1,97 @@
import React, {Component, PropTypes} from 'react'
class InputClickToEdit extends Component {
constructor(props) {
super(props)
this.state = {
isEditing: null,
value: this.props.value,
}
}
handleCancel = () => {
this.setState({
isEditing: false,
value: this.props.value,
})
}
handleInputClick = () => {
this.setState({isEditing: true})
}
handleInputBlur = e => {
const {onUpdate, value} = this.props
if (value !== e.target.value) {
onUpdate(e.target.value)
}
this.setState({isEditing: false, value: e.target.value})
}
handleKeyDown = e => {
if (e.key === 'Enter') {
this.handleInputBlur(e)
}
if (e.key === 'Escape') {
this.handleCancel()
}
}
handleFocus = e => {
e.target.select()
}
render() {
const {isEditing, value} = this.state
const {wrapperClass, disabled, tabIndex, placeholder} = this.props
const divStyle = value ? 'input-cte' : 'input-cte__empty'
return disabled
? <div className={wrapperClass}>
<div className="input-cte__disabled">
{value}
</div>
</div>
: <div className={wrapperClass}>
{isEditing
? <input
type="text"
className="form-control input-sm provider--input"
defaultValue={value}
onBlur={this.handleInputBlur}
onKeyDown={this.handleKeyDown}
autoFocus={true}
onFocus={this.handleFocus}
ref={r => (this.inputRef = r)}
tabIndex={tabIndex}
placeholder={placeholder}
/>
: <div
className={divStyle}
onClick={this.handleInputClick}
onFocus={this.handleInputClick}
tabIndex={tabIndex}
>
{value || placeholder}
<span className="icon pencil" />
</div>}
</div>
}
}
const {func, bool, number, string} = PropTypes
InputClickToEdit.propTypes = {
wrapperClass: string.isRequired,
value: string,
onUpdate: func.isRequired,
disabled: bool,
tabIndex: number,
placeholder: string,
}
export default InputClickToEdit

View File

@ -40,6 +40,7 @@ class LineGraph extends Component {
axes,
cell,
title,
colors,
onZoom,
queries,
timeRange,
@ -91,6 +92,14 @@ class LineGraph extends Component {
top: '8px',
}
let prefix
let suffix
if (axes) {
prefix = axes.y.prefix
suffix = axes.y.suffix
}
return (
<div className="dygraph graph--hasYLabel" style={{height: '100%'}}>
{isRefreshing ? <GraphLoadingDots /> : null}
@ -114,7 +123,14 @@ class LineGraph extends Component {
isGraphFilled={showSingleStat ? false : isGraphFilled}
/>
{showSingleStat
? <SingleStat data={data} cellHeight={cellHeight} />
? <SingleStat
prefix={prefix}
suffix={suffix}
data={data}
lineGraph={true}
colors={colors}
cellHeight={cellHeight}
/>
: null}
</div>
)
@ -178,6 +194,15 @@ LineGraph.propTypes = {
resizeCoords: shape(),
queries: arrayOf(shape({}).isRequired).isRequired,
data: arrayOf(shape({}).isRequired).isRequired,
colors: arrayOf(
shape({
type: string.isRequired,
hex: string.isRequired,
id: string.isRequired,
name: string.isRequired,
value: string.isRequired,
}).isRequired
),
}
export default LineGraph

View File

@ -76,6 +76,7 @@ const RefreshingGraph = ({
return (
<RefreshingLineGraph
axes={axes}
colors={colors}
onZoom={onZoom}
queries={queries}
key={manualRefresh}

View File

@ -1,18 +1,22 @@
import React, {PropTypes, PureComponent} from 'react'
import _ from 'lodash'
import classnames from 'classnames'
import lastValues from 'shared/parsing/lastValues'
import {SMALL_CELL_HEIGHT} from 'shared/graphs/helpers'
import {SINGLE_STAT_TEXT} from 'src/dashboards/constants/gaugeColors'
import {isBackgroundLight} from 'shared/constants/colorOperations'
const darkText = '#292933'
const lightText = '#ffffff'
import {generateSingleStatHexs} from 'shared/constants/colorOperations'
class SingleStat extends PureComponent {
render() {
const {data, cellHeight, isFetchingInitially, colors, suffix} = this.props
const {
data,
cellHeight,
isFetchingInitially,
colors,
prefix,
suffix,
lineGraph,
} = this.props
// If data for this graph is being fetched for the first time, show a graph-wide spinner.
if (isFetchingInitially) {
@ -24,37 +28,20 @@ class SingleStat extends PureComponent {
}
const lastValue = lastValues(data)[1]
const precision = 100.0
const roundedValue = Math.round(+lastValue * precision) / precision
let bgColor = null
let textColor = null
let className = 'single-stat'
const colorizeText = !!colors.find(color => color.type === SINGLE_STAT_TEXT)
if (colors && colors.length > 0) {
className = 'single-stat single-stat--colored'
const sortedColors = _.sortBy(colors, color => Number(color.value))
const nearestCrossedThreshold = sortedColors
.filter(color => lastValue > color.value)
.pop()
const colorizeText = _.some(colors, {type: SINGLE_STAT_TEXT})
if (colorizeText) {
textColor = nearestCrossedThreshold
? nearestCrossedThreshold.hex
: '#292933'
} else {
bgColor = nearestCrossedThreshold
? nearestCrossedThreshold.hex
: '#292933'
textColor = isBackgroundLight(bgColor) ? darkText : lightText
}
}
const {bgColor, textColor} = generateSingleStatHexs(
colors,
lineGraph,
colorizeText,
lastValue
)
return (
<div
className={className}
className="single-stat"
style={{backgroundColor: bgColor, color: textColor}}
>
<span
@ -62,8 +49,10 @@ class SingleStat extends PureComponent {
'single-stat--small': cellHeight === SMALL_CELL_HEIGHT,
})}
>
{prefix}
{roundedValue}
{suffix}
{lineGraph && <div className="single-stat--shadow" />}
</span>
</div>
)
@ -85,7 +74,9 @@ SingleStat.propTypes = {
value: string.isRequired,
}).isRequired
),
prefix: string,
suffix: string,
lineGraph: bool,
}
export default SingleStat

View File

@ -1,3 +1,9 @@
import _ from 'lodash'
import {
GAUGE_COLORS,
SINGLE_STAT_BASE,
} from 'src/dashboards/constants/gaugeColors'
const hexToRgb = hex => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
@ -16,9 +22,93 @@ const averageRgbValues = valuesObject => {
const trueNeutralGrey = 128
export const isBackgroundLight = backgroundColor => {
const averageBackground = averageRgbValues(hexToRgb(backgroundColor))
const isLight = averageBackground > trueNeutralGrey
const getLegibleTextColor = bgColorHex => {
const averageBackground = averageRgbValues(hexToRgb(bgColorHex))
const isBackgroundLight = averageBackground > trueNeutralGrey
return isLight
const darkText = '#292933'
const lightText = '#ffffff'
return isBackgroundLight ? darkText : lightText
}
const findNearestCrossedThreshold = (colors, lastValue) => {
const sortedColors = _.sortBy(colors, color => Number(color.value))
const nearestCrossedThreshold = sortedColors
.filter(color => lastValue > color.value)
.pop()
return nearestCrossedThreshold
}
export const generateSingleStatHexs = (
colors,
containsLineGraph,
colorizeText,
lastValue
) => {
const defaultColoring = {bgColor: null, textColor: GAUGE_COLORS[11].hex}
if (!colors.length || !lastValue) {
return defaultColoring
}
// baseColor is expected in all cases
const baseColor = colors.find(color => (color.id = SINGLE_STAT_BASE)) || {
hex: defaultColoring.textColor,
}
// If the single stat is above a line graph never have a background color
if (containsLineGraph) {
return baseColor
? {bgColor: null, textColor: baseColor.hex}
: defaultColoring
}
// When there is only a base color and it's applied to the text
if (colorizeText && colors.length === 1) {
return baseColor
? {bgColor: null, textColor: baseColor.hex}
: defaultColoring
}
// When there's multiple colors and they're applied to the text
if (colorizeText && colors.length > 1) {
const nearestCrossedThreshold = findNearestCrossedThreshold(
colors,
lastValue
)
const bgColor = null
const textColor = nearestCrossedThreshold.hex
return {bgColor, textColor}
}
// When there is only a base color and it's applued to the background
if (colors.length === 1) {
const bgColor = baseColor.hex
const textColor = getLegibleTextColor(bgColor)
return {bgColor, textColor}
}
// When there are multiple colors and they're applied to the background
if (colors.length > 1) {
const nearestCrossedThreshold = findNearestCrossedThreshold(
colors,
lastValue
)
const bgColor = nearestCrossedThreshold
? nearestCrossedThreshold.hex
: baseColor.hex
const textColor = getLegibleTextColor(bgColor)
return {bgColor, textColor}
}
// If all else fails, use safe default
const bgColor = null
const textColor = baseColor.hex
return {bgColor, textColor}
}

View File

@ -37,6 +37,7 @@
@import 'components/custom-time-range';
@import 'components/dygraphs';
@import 'components/fancy-scrollbars';
@import 'components/fancy-table';
@import 'components/fill-query';
@import 'components/flip-toggle';
@import 'components/function-selector';
@ -70,6 +71,7 @@
@import 'pages/admin';
@import 'pages/users';
@import 'pages/tickscript-editor';
@import 'pages/manage-providers';
// TODO
@import 'unsorted';

View File

@ -242,8 +242,16 @@ button.btn.btn-primary.btn-sm.gauge-controls--add-threshold {
.gauge-controls--input {
flex: 1 0 0;
margin: 0 4px;
margin: 0 0 0 4px;
}
.gauge-controls--section .color-dropdown {
margin-left: 4px;
}
.gauge-controls--section .color-dropdown.color-dropdown--stretch {
width: auto;
flex: 1 0 0;
}
/*
Cell Editor Overlay - Single-Stat Controls

View File

@ -23,10 +23,17 @@
.CodeMirror-vscrollbar {
@include custom-scrollbar-round($g2-kevlar,$g6-smoke);
}
.CodeMirror-hscrollbar {
@include custom-scrollbar-round($g0-obsidian,$g6-smoke);
}
.cm-s-material .CodeMirror-gutters {
background-color: fade-out($g4-onyx, 0.7);
@include gradient-v($g2-kevlar, $g0-obsidian)
border: none;
}
.cm-s-material .CodeMirror-gutters .CodeMirror-gutter {
background-color: fade-out($g4-onyx, 0.75);
height: calc(100% + 30px);
}
.CodeMirror-gutter.CodeMirror-linenumbers {
width: 60px;
}

View File

@ -11,6 +11,10 @@ $color-dropdown--circle: 14px;
position: relative;
}
.color-dropdown.color-dropdown--stretch {
width: 100%;
}
.color-dropdown--toggle {
width: 100%;
position: relative;

View File

@ -80,6 +80,7 @@
height: calc(100% - 2px);
pointer-events: none;
border-radius: 3px;
transition: background-color 0.25s ease, color 0.25s ease;
@include no-user-select();
color: $c-laser;
@ -92,15 +93,13 @@
height: 100% !important;
}
}
.single-stat.single-stat--colored {
transition: background-color 0.25s ease, color 0.25s ease;
}
.single-stat--value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
width: calc(100% - 32px);
width: auto;
max-width: calc(100% - 32px);
text-align: center;
font-size: 54px;
line-height: 54px;
@ -115,15 +114,18 @@
}
}
.single-stat--shadow {
position: relative;
display: inline-block;
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.single-stat--shadow:after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 110%;
width: 90%;
height: 0;
transform: translate(-50%,-50%);
box-shadow: fade-out($g2-kevlar, 0.3) 0 0 50px 30px;

View File

@ -0,0 +1,51 @@
/*
Styles for Fancy Tables
------------------------------------------------------------------------------
*/
$fancytable--table--margin: 4px;
.fancytable--row,
.fancytable--labels {
width: 100%;
display: flex;
flex-wrap: nowrap;
align-items: center;
> div:not(.confirm-buttons) {
margin-right: $fancytable--table--margin;
}
}
.fancytable--row {
margin-bottom: $fancytable--table--margin;
position: relative;
&:last-of-type {
margin-bottom: 0;
}
}
.fancytable--labels {
border-bottom: 2px solid $g5-pepper;
margin-bottom: 10px;
@include no-user-select();
}
.fancytable--th,
.fancytable--td {
font-weight: 500;
height: 34px;
line-height: 34px;
font-size: 13px;
}
.fancytable--th {
color: $g17-whisper;
padding: 0 11px;
&:last-of-type {
margin-right: 0;
}
}
.fancytable--td {
display: flex;
align-items: center;
color: $g13-mist;
}

View File

@ -45,9 +45,20 @@
> span.icon {opacity: 1;}
}
}
.input-cte__disabled {
border-color: $g4-onyx;
background-color: $g4-onyx;
font-style: italic;
color: $g9-mountain;
}
.input-cte__empty {
@extend .input-cte;
font-style: italic;
color: $g9-mountain;
&:hover {
color: $g9-mountain;
}
}

View File

@ -9,11 +9,10 @@ $logs-row-indent: 6px;
$logs-level-dot: 8px;
$logs-margin: 4px;
.logs-table--container {
.logs-table {
width: 50%;
position: relative;
height: 100%;
@include gradient-v($g3-castle,$g1-raven);
}
.logs-table--header {
display: flex;
@ -23,28 +22,28 @@ $logs-margin: 4px;
height: $logs-table-header-height;
padding: 0 $logs-table-padding 0 ($logs-table-padding / 2);
background-color: $g4-onyx;
white-space: nowrap;
font-size: 17px;
@include no-user-select();
letter-spacing: 0.015em;
font-weight: 500;
}
.logs-table--panel {
.logs-table--container {
position: absolute !important;
top: $logs-table-header-height;
left: 0;
width: 100%;
height: calc(100% - #{$logs-table-header-height}) !important;
@include gradient-v(mix($g3-castle, $g2-kevlar),mix($g1-raven, $g0-obsidian));
}
.logs-table {
height: 100%;
}
.logs-table--row {
height: 87px; // Fixed height, required for Infinite Scroll, allows for 2 tags / fields per line
position: relative;
padding: 8px ($logs-table-padding - 16px) 8px ($logs-table-padding / 2);
border-bottom: 2px solid $g3-castle;
transition: background-color 0.25s ease;
&:hover {
background-color: $g4-onyx;
}
&:first-child {
&:last-of-type {
border-bottom: none;
}
}
@ -62,21 +61,22 @@ $logs-margin: 4px;
&.debug {background-color: $c-comet;}
&.info {background-color: $g6-smoke;}
&.warn {background-color: $c-pineapple;}
&.ok {background-color: $c-rainforest;}
&.ok {background-color: $c-pool;}
&.error {background-color: $c-dreamsicle;}
}
.logs-table--timestamp {
font-family: $code-font;
font-weight: 500;
font-size: 11px;
font-size: 13px;
color: $g9-mountain;
flex: 1 0 0;
}
.logs-table--details {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
font-size: 13px;
color: $g13-mist;
color: $g11-sidewalk;
font-weight: 600;
padding-left: ($logs-level-dot + $logs-row-indent);
@ -85,6 +85,10 @@ $logs-margin: 4px;
}
/* Logs Table Item Types */
.logs-table--service,
.logs-table--column h1 {
margin-top: 2px;
}
.logs-table--session {
text-transform: capitalize;
font-style: italic;
@ -92,16 +96,33 @@ $logs-margin: 4px;
.logs-table--service {
width: 140px;
}
.logs-table--blah {
.logs-table--columns {
display: flex;
flex: 1 0 0;
flex-wrap: wrap;
}
.logs-table--key-values {
.logs-table--column {
color: $g11-sidewalk;
flex: 1 0 50%;
}
.logs-table--column h1 {
font-size: 13px;
font-weight: 700;
margin: 0;
letter-spacing: normal;
line-height: 1.42857143em;
text-transform: uppercase;
color: $g16-pearl;
}
.logs-table--key-value {
white-space: nowrap;
span {
color: $c-rainforest;
}
}
.logs-table--key-value span {
color: $c-pool;
.logs-table--scrollbox {
width: 100%;
max-height: 300px;
overflow-y: auto;
@include custom-scrollbar-round($g0-obsidian,$c-rainforest);
}

View File

@ -4,91 +4,45 @@
Is not actually a table
*/
.orgs-table--org {
$orgs-table--active-width: 102px;
$orgs-table--public-width: 90px;
$orgs-table--default-role-width: 130px;
$orgs-table--delete-width: 30px;
.orgs-table--name {
flex: 1 0 0;
}
.orgs-table--public {
width: $orgs-table--public-width;
text-align: center;
}
.orgs-table--default-role {
width: $orgs-table--default-role-width;
}
.orgs-table--delete {
width: $orgs-table--delete-width;
}
.orgs-table--active {
width: $orgs-table--active-width;
justify-content: center;
@include no-user-select();
.btn {width: 100%;}
}
.orgs-table--default-role.deleting {
width: (
$orgs-table--default-role-width - $fancytable--table--margin -
$orgs-table--delete-width
);
}
.orgs-table--public-toggle {
height: 30px;
width: 100%;
display: flex;
align-items: center;
margin-bottom: 8px;
position: relative;
&:last-of-type {
margin-bottom: 0;
}
}
.orgs-table--id {
padding: 0 11px;
width: 60px;
height: 30px;
line-height: 30px;
font-size: 13px;
color: $g13-mist;
font-weight: 500;
}
.orgs-table--active {
padding: 0 4px 0 0;
width: 102px;
height: 30px;
button.btn {
width: 100%;
}
}
.orgs-table--name,
.orgs-table--name-disabled,
input[type="text"].form-control.orgs-table--input {
flex: 1 0 0;
font-weight: 600;
font-size: 13px;
margin-right: 4px;
}
.orgs-table--name,
.orgs-table--name-disabled {
@include no-user-select();
padding: 0 11px;
border-radius: 4px;
height: 30px;
line-height: 28px;
border-style: solid;
border-width: 2px;
}
.orgs-table--name {
border-color: $g2-kevlar;
background-color: $g2-kevlar;
color: $g13-mist;
position: relative;
transition: color 0.4s ease, background-color 0.4s ease,
border-color 0.4s ease;
> span.icon {
position: absolute;
top: 50%;
right: 11px;
transform: translateY(-50%);
color: $g8-storm;
opacity: 0;
transition: opacity 0.25s ease;
}
&:hover {
color: $g20-white;
background-color: $g5-pepper;
border-color: $g5-pepper;
cursor: text;
> span.icon {
opacity: 1;
}
}
}
.orgs-table--public {
height: 30px;
margin-right: 4px;
text-align: center;
width: 88px;
justify-content: center;
background-color: $g4-onyx;
border-radius: 4px;
line-height: 30px;
position: relative;
> .slide-toggle {
@ -103,69 +57,3 @@ input[type="text"].form-control.orgs-table--input {
@include no-user-select();
}
}
.orgs-table--default-role,
.orgs-table--default-role-disabled {
width: 100px;
height: 30px;
margin-right: 4px;
}
.orgs-table--default-role.editing {
width: 96px;
}
.orgs-table--default-role-disabled {
background-color: $g4-onyx;
font-style: italic;
color: $g9-mountain;
padding: 0 11px;
line-height: 30px;
font-size: 13px;
font-weight: 600;
@include no-user-select();
}
.orgs-table--delete {
height: 30px;
width: 30px;
}
/* Table Headers */
.orgs-table--org-labels {
display: flex;
align-items: center;
border-bottom: 2px solid $g5-pepper;
margin-bottom: 10px;
width: 100%;
@include no-user-select();
> .orgs-table--name,
> .orgs-table--name:hover,
> .orgs-table--public,
> .orgs-table--active {
transition: none;
background-color: transparent;
border-color: transparent;
}
> .orgs-table--id,
> .orgs-table--name,
> .orgs-table--name:hover,
> .orgs-table--default-role,
> .orgs-table--public,
> .orgs-table--active {
color: $g17-whisper;
font-weight: 500;
}
> .orgs-table--default-role,
> .orgs-table--public,
> .orgs-table--active {
line-height: 30px;
font-size: 13px;
padding: 0 11px;
}
}
/* Config table beneath organizations table */
.panel .panel-body table.table.superadmin-config {
margin-top: 60px;
}

View File

@ -71,16 +71,18 @@ $scrollbar-offset: 3px;
width: $scrollbar-width;
border-top-right-radius: $radius;
border-top-left-radius: $radius;
border-bottom-right-radius: $radius;
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
&-button {
background-color: $trackColor;
}
&-track {
background-color: $trackColor;
border-top-right-radius: $radius;
border-bottom-right-radius: $radius;
border-top-right-radius: ($scrollbar-width / 2);
border-top-left-radius: ($scrollbar-width / 2);
border-bottom-left-radius: ($scrollbar-width / 2);
border-bottom-right-radius: ($scrollbar-width / 2);
}
&-track-piece {
background-color: $trackColor;

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