diff --git a/CHANGELOG.md b/CHANGELOG.md index bd0ee92418..accd47b24b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,13 @@ ### Features + 1. [#1292](https://github.com/influxdata/chronograf/pull/1292): Introduce Template Variable Manager 1. [#1232](https://github.com/influxdata/chronograf/pull/1232): Fuse the query builder and raw query editor 1. [#1265](https://github.com/influxdata/chronograf/pull/1265): Refactor the router to use auth and force /login route when auth expires 1. [#1286](https://github.com/influxdata/chronograf/pull/1286): Add refreshing JWTs for authentication + 1. [#1316](https://github.com/influxdata/chronograf/pull/1316): Add templates API scoped within a dashboard + 1. [#1311](https://github.com/influxdata/chronograf/pull/1311): Display currently selected values in TVControlBar + 1. [#1315](https://github.com/influxdata/chronograf/pull/1315): Send selected TV values to proxy 1. [#1302](https://github.com/influxdata/chronograf/pull/1302): Add support for multiple Kapacitors per InfluxDB source ### UI Improvements diff --git a/LICENSE_OF_DEPENDENCIES.md b/LICENSE_OF_DEPENDENCIES.md index edff0fea66..78e1310378 100644 --- a/LICENSE_OF_DEPENDENCIES.md +++ b/LICENSE_OF_DEPENDENCIES.md @@ -676,7 +676,7 @@ * node-libs-browser 0.6.0 [MIT](https://github.com/webpack/node-libs-browser) * node-notifier 4.6.1 [MIT](ssh://git@github.com/mikaelbr/node-notifier) * node-pre-gyp 0.6.29 [BSD-3-Clause](http://github.com/mapbox/node-pre-gyp) -* node-sass 3.11.3 [MIT](https://github.com/sass/node-sass) +* node-sass 4.5.2 [MIT](https://github.com/sass/node-sass) * node-uuid 1.4.7 [MIT](https://github.com/broofa/node-uuid) * nopt 3.0.6 [ISC](https://github.com/npm/nopt) * normalize-package-data 2.3.5 [BSD;BSD-2-Clause](http://github.com/npm/normalize-package-data) diff --git a/Makefile b/Makefile index d06c9265c8..8190a2c93d 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ define CHRONOGIRAFFE ,"" _\_ ," ## | 0 0. ," ## ,-\__ `. - ," / `--._;) - "HAI, I'm Chronogiraffe. Will you be my friend?" + ," / `--._;) - "HAI, I'm Chronogiraffe. Let's be friends!" ," ## / ," ## / endef diff --git a/bolt/internal/internal.go b/bolt/internal/internal.go index eab0a13bbe..69525931ac 100644 --- a/bolt/internal/internal.go +++ b/bolt/internal/internal.go @@ -190,11 +190,41 @@ func MarshalDashboard(d chronograf.Dashboard) ([]byte, error) { Type: c.Type, } } + templates := make([]*Template, len(d.Templates)) + for i, t := range d.Templates { + vals := make([]*TemplateValue, len(t.Values)) + for j, v := range t.Values { + vals[j] = &TemplateValue{ + Selected: v.Selected, + Type: v.Type, + Value: v.Value, + } + } + template := &Template{ + ID: string(t.ID), + TempVar: t.Var, + Values: vals, + Type: t.Type, + Label: t.Label, + } + if t.Query != nil { + template.Query = &TemplateQuery{ + Command: t.Query.Command, + Db: t.Query.DB, + Rp: t.Query.RP, + Measurement: t.Query.Measurement, + TagKey: t.Query.TagKey, + FieldKey: t.Query.FieldKey, + } + } + templates[i] = template + } return proto.Marshal(&Dashboard{ - ID: int64(d.ID), - Cells: cells, - Name: d.Name, + ID: int64(d.ID), + Cells: cells, + Templates: templates, + Name: d.Name, }) } @@ -232,8 +262,44 @@ func UnmarshalDashboard(data []byte, d *chronograf.Dashboard) error { Type: c.Type, } } + + templates := make([]chronograf.Template, len(pb.Templates)) + for i, t := range pb.Templates { + vals := make([]chronograf.TemplateValue, len(t.Values)) + for j, v := range t.Values { + vals[j] = chronograf.TemplateValue{ + Selected: v.Selected, + Type: v.Type, + Value: v.Value, + } + } + + template := chronograf.Template{ + ID: chronograf.TemplateID(t.ID), + TemplateVar: chronograf.TemplateVar{ + Var: t.TempVar, + Values: vals, + }, + Type: t.Type, + Label: t.Label, + } + + if t.Query != nil { + template.Query = &chronograf.TemplateQuery{ + Command: t.Query.Command, + DB: t.Query.Db, + RP: t.Query.Rp, + Measurement: t.Query.Measurement, + TagKey: t.Query.TagKey, + FieldKey: t.Query.FieldKey, + } + } + templates[i] = template + } + d.ID = chronograf.DashboardID(pb.ID) d.Cells = cells + d.Templates = templates d.Name = pb.Name return nil } @@ -300,7 +366,7 @@ func UnmarshalUser(data []byte, u *chronograf.User) error { return nil } -// UnmarshalUser decodes a user from binary protobuf data. +// UnmarshalUserPB decodes a user from binary protobuf data. // We are ignoring the password for now. func UnmarshalUserPB(data []byte, u *User) error { if err := proto.Unmarshal(data, u); err != nil { diff --git a/bolt/internal/internal.pb.go b/bolt/internal/internal.pb.go index 2f061886bd..ac2b29b1a4 100644 --- a/bolt/internal/internal.pb.go +++ b/bolt/internal/internal.pb.go @@ -12,6 +12,9 @@ It has these top-level messages: Source Dashboard DashboardCell + Template + TemplateValue + TemplateQuery Server Layout Cell @@ -56,9 +59,10 @@ func (*Source) ProtoMessage() {} func (*Source) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{0} } type Dashboard struct { - ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` - Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` - Cells []*DashboardCell `protobuf:"bytes,3,rep,name=cells" json:"cells,omitempty"` + ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` + Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` + Cells []*DashboardCell `protobuf:"bytes,3,rep,name=cells" json:"cells,omitempty"` + Templates []*Template `protobuf:"bytes,4,rep,name=templates" json:"templates,omitempty"` } func (m *Dashboard) Reset() { *m = Dashboard{} } @@ -73,6 +77,13 @@ func (m *Dashboard) GetCells() []*DashboardCell { return nil } +func (m *Dashboard) GetTemplates() []*Template { + if m != nil { + return m.Templates + } + return nil +} + type DashboardCell struct { X int32 `protobuf:"varint,1,opt,name=x,proto3" json:"x,omitempty"` Y int32 `protobuf:"varint,2,opt,name=y,proto3" json:"y,omitempty"` @@ -96,6 +107,59 @@ func (m *DashboardCell) GetQueries() []*Query { return nil } +type Template struct { + ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` + TempVar string `protobuf:"bytes,2,opt,name=temp_var,json=tempVar,proto3" json:"temp_var,omitempty"` + Values []*TemplateValue `protobuf:"bytes,3,rep,name=values" json:"values,omitempty"` + Type string `protobuf:"bytes,4,opt,name=type,proto3" json:"type,omitempty"` + Label string `protobuf:"bytes,5,opt,name=label,proto3" json:"label,omitempty"` + Query *TemplateQuery `protobuf:"bytes,6,opt,name=query" json:"query,omitempty"` +} + +func (m *Template) Reset() { *m = Template{} } +func (m *Template) String() string { return proto.CompactTextString(m) } +func (*Template) ProtoMessage() {} +func (*Template) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{3} } + +func (m *Template) GetValues() []*TemplateValue { + if m != nil { + return m.Values + } + return nil +} + +func (m *Template) GetQuery() *TemplateQuery { + if m != nil { + return m.Query + } + return nil +} + +type TemplateValue struct { + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + Selected bool `protobuf:"varint,3,opt,name=selected,proto3" json:"selected,omitempty"` +} + +func (m *TemplateValue) Reset() { *m = TemplateValue{} } +func (m *TemplateValue) String() string { return proto.CompactTextString(m) } +func (*TemplateValue) ProtoMessage() {} +func (*TemplateValue) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{4} } + +type TemplateQuery struct { + Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"` + Db string `protobuf:"bytes,2,opt,name=db,proto3" json:"db,omitempty"` + Rp string `protobuf:"bytes,3,opt,name=rp,proto3" json:"rp,omitempty"` + Measurement string `protobuf:"bytes,4,opt,name=measurement,proto3" json:"measurement,omitempty"` + TagKey string `protobuf:"bytes,5,opt,name=tag_key,json=tagKey,proto3" json:"tag_key,omitempty"` + FieldKey string `protobuf:"bytes,6,opt,name=field_key,json=fieldKey,proto3" json:"field_key,omitempty"` +} + +func (m *TemplateQuery) Reset() { *m = TemplateQuery{} } +func (m *TemplateQuery) String() string { return proto.CompactTextString(m) } +func (*TemplateQuery) ProtoMessage() {} +func (*TemplateQuery) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{5} } + type Server struct { ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"` @@ -109,7 +173,7 @@ type Server struct { func (m *Server) Reset() { *m = Server{} } func (m *Server) String() string { return proto.CompactTextString(m) } func (*Server) ProtoMessage() {} -func (*Server) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{3} } +func (*Server) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{6} } type Layout struct { ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` @@ -122,7 +186,7 @@ type Layout struct { func (m *Layout) Reset() { *m = Layout{} } func (m *Layout) String() string { return proto.CompactTextString(m) } func (*Layout) ProtoMessage() {} -func (*Layout) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{4} } +func (*Layout) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{7} } func (m *Layout) GetCells() []*Cell { if m != nil { @@ -147,7 +211,7 @@ type Cell struct { func (m *Cell) Reset() { *m = Cell{} } func (m *Cell) String() string { return proto.CompactTextString(m) } func (*Cell) ProtoMessage() {} -func (*Cell) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{5} } +func (*Cell) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{8} } func (m *Cell) GetQueries() []*Query { if m != nil { @@ -169,7 +233,7 @@ type Query struct { func (m *Query) Reset() { *m = Query{} } func (m *Query) String() string { return proto.CompactTextString(m) } func (*Query) ProtoMessage() {} -func (*Query) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{6} } +func (*Query) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{9} } func (m *Query) GetRange() *Range { if m != nil { @@ -186,7 +250,7 @@ type Range struct { func (m *Range) Reset() { *m = Range{} } func (m *Range) String() string { return proto.CompactTextString(m) } func (*Range) ProtoMessage() {} -func (*Range) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{7} } +func (*Range) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{10} } type AlertRule struct { ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` @@ -198,7 +262,7 @@ type AlertRule struct { func (m *AlertRule) Reset() { *m = AlertRule{} } func (m *AlertRule) String() string { return proto.CompactTextString(m) } func (*AlertRule) ProtoMessage() {} -func (*AlertRule) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{8} } +func (*AlertRule) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{11} } type User struct { ID uint64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` @@ -208,12 +272,15 @@ type User struct { func (m *User) Reset() { *m = User{} } func (m *User) String() string { return proto.CompactTextString(m) } func (*User) ProtoMessage() {} -func (*User) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{9} } +func (*User) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{12} } func init() { proto.RegisterType((*Source)(nil), "internal.Source") proto.RegisterType((*Dashboard)(nil), "internal.Dashboard") proto.RegisterType((*DashboardCell)(nil), "internal.DashboardCell") + proto.RegisterType((*Template)(nil), "internal.Template") + proto.RegisterType((*TemplateValue)(nil), "internal.TemplateValue") + proto.RegisterType((*TemplateQuery)(nil), "internal.TemplateQuery") proto.RegisterType((*Server)(nil), "internal.Server") proto.RegisterType((*Layout)(nil), "internal.Layout") proto.RegisterType((*Cell)(nil), "internal.Cell") @@ -226,47 +293,59 @@ func init() { func init() { proto.RegisterFile("internal.proto", fileDescriptorInternal) } var fileDescriptorInternal = []byte{ - // 670 bytes of a gzipped FileDescriptorProto - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0xbc, 0x54, 0xcd, 0x6e, 0xd3, 0x4a, - 0x14, 0xd6, 0xc4, 0x76, 0x7e, 0x4e, 0x7b, 0x7b, 0xaf, 0x46, 0x57, 0x30, 0x62, 0x15, 0x59, 0x20, - 0x05, 0x24, 0xba, 0xa0, 0x4f, 0x90, 0xd6, 0x12, 0x0a, 0xb4, 0xa5, 0x4c, 0x5a, 0x58, 0x81, 0x34, - 0x4d, 0x4f, 0x1a, 0x0b, 0xc7, 0x36, 0x63, 0xbb, 0xa9, 0x5f, 0x81, 0x87, 0x60, 0xc5, 0x8a, 0x25, - 0xaf, 0xc2, 0x0b, 0xa1, 0x33, 0x33, 0x76, 0x52, 0x28, 0xa8, 0x2b, 0x76, 0xe7, 0x3b, 0xc7, 0x39, - 0x3f, 0xdf, 0xf7, 0x4d, 0x60, 0x27, 0x4e, 0x4b, 0xd4, 0xa9, 0x4a, 0x76, 0x73, 0x9d, 0x95, 0x19, - 0xef, 0x37, 0x38, 0xfc, 0xd4, 0x81, 0xee, 0x34, 0xab, 0xf4, 0x0c, 0xf9, 0x0e, 0x74, 0x26, 0x91, - 0x60, 0x43, 0x36, 0xf2, 0x64, 0x67, 0x12, 0x71, 0x0e, 0xfe, 0xb1, 0x5a, 0xa2, 0xe8, 0x0c, 0xd9, - 0x68, 0x20, 0x4d, 0x4c, 0xb9, 0xd3, 0x3a, 0x47, 0xe1, 0xd9, 0x1c, 0xc5, 0xfc, 0x01, 0xf4, 0xcf, - 0x0a, 0xea, 0xb6, 0x44, 0xe1, 0x9b, 0x7c, 0x8b, 0xa9, 0x76, 0xa2, 0x8a, 0x62, 0x95, 0xe9, 0x0b, - 0x11, 0xd8, 0x5a, 0x83, 0xf9, 0x7f, 0xe0, 0x9d, 0xc9, 0x43, 0xd1, 0x35, 0x69, 0x0a, 0xb9, 0x80, - 0x5e, 0x84, 0x73, 0x55, 0x25, 0xa5, 0xe8, 0x0d, 0xd9, 0xa8, 0x2f, 0x1b, 0x48, 0x7d, 0x4e, 0x31, - 0xc1, 0x4b, 0xad, 0xe6, 0xa2, 0x6f, 0xfb, 0x34, 0x98, 0xef, 0x02, 0x9f, 0xa4, 0x05, 0xce, 0x2a, - 0x8d, 0xd3, 0x0f, 0x71, 0xfe, 0x06, 0x75, 0x3c, 0xaf, 0xc5, 0xc0, 0x34, 0xb8, 0xa5, 0x42, 0x53, - 0x8e, 0xb0, 0x54, 0x34, 0x1b, 0x4c, 0xab, 0x06, 0x86, 0xef, 0x61, 0x10, 0xa9, 0x62, 0x71, 0x9e, - 0x29, 0x7d, 0x71, 0x27, 0x3a, 0x9e, 0x42, 0x30, 0xc3, 0x24, 0x29, 0x84, 0x37, 0xf4, 0x46, 0x5b, - 0xcf, 0xee, 0xef, 0xb6, 0x3c, 0xb7, 0x7d, 0x0e, 0x30, 0x49, 0xa4, 0xfd, 0x2a, 0xfc, 0xca, 0xe0, - 0x9f, 0x1b, 0x05, 0xbe, 0x0d, 0xec, 0xda, 0xcc, 0x08, 0x24, 0xbb, 0x26, 0x54, 0x9b, 0xfe, 0x81, - 0x64, 0x35, 0xa1, 0x95, 0x21, 0x3a, 0x90, 0x6c, 0x45, 0x68, 0x61, 0xe8, 0x0d, 0x24, 0x5b, 0xf0, - 0xc7, 0xd0, 0xfb, 0x58, 0xa1, 0x8e, 0xb1, 0x10, 0x81, 0x19, 0xfd, 0xef, 0x7a, 0xf4, 0xeb, 0x0a, - 0x75, 0x2d, 0x9b, 0x3a, 0xed, 0x6d, 0xa4, 0xb1, 0x3c, 0x9b, 0x98, 0x72, 0x25, 0xc9, 0xd8, 0xb3, - 0x39, 0x8a, 0xdd, 0xbd, 0x96, 0xdc, 0xce, 0x24, 0x0a, 0xbf, 0x30, 0xe8, 0x4e, 0x51, 0x5f, 0xa1, - 0xbe, 0x13, 0x15, 0x9b, 0x2e, 0xf0, 0xfe, 0xe0, 0x02, 0xff, 0x76, 0x17, 0x04, 0x6b, 0x17, 0xfc, - 0x0f, 0xc1, 0x54, 0xcf, 0x26, 0x91, 0xd9, 0xd8, 0x93, 0x16, 0xf0, 0x7b, 0xd0, 0x1d, 0xcf, 0xca, - 0xf8, 0x0a, 0x9d, 0x35, 0x1c, 0x0a, 0x3f, 0x33, 0xe8, 0x1e, 0xaa, 0x3a, 0xab, 0xca, 0x8d, 0x35, - 0xcd, 0x05, 0x7c, 0x08, 0x5b, 0xe3, 0x3c, 0x4f, 0xe2, 0x99, 0x2a, 0xe3, 0x2c, 0x75, 0xdb, 0x6e, - 0xa6, 0xe8, 0x8b, 0x23, 0x54, 0x45, 0xa5, 0x71, 0x89, 0x69, 0xe9, 0xf6, 0xde, 0x4c, 0xf1, 0x87, - 0x10, 0x1c, 0x18, 0x85, 0x7d, 0x43, 0xf3, 0xce, 0x9a, 0x66, 0x2b, 0xac, 0x29, 0xd2, 0x81, 0xe3, - 0xaa, 0xcc, 0xe6, 0x49, 0xb6, 0x32, 0x97, 0xf4, 0x65, 0x8b, 0xc3, 0xef, 0x0c, 0xfc, 0xbf, 0xa5, - 0xf5, 0x36, 0xb0, 0xd8, 0x09, 0xcd, 0xe2, 0x56, 0xf9, 0xde, 0x86, 0xf2, 0x02, 0x7a, 0xb5, 0x56, - 0xe9, 0x25, 0x16, 0xa2, 0x3f, 0xf4, 0x46, 0x9e, 0x6c, 0xa0, 0xa9, 0x24, 0xea, 0x1c, 0x93, 0x42, - 0x0c, 0x86, 0x1e, 0x3d, 0x0b, 0x07, 0x5b, 0xb7, 0xc0, 0xda, 0x2d, 0xe1, 0x37, 0x06, 0x81, 0x19, - 0x4e, 0xbf, 0x3b, 0xc8, 0x96, 0x4b, 0x95, 0x5e, 0x38, 0xea, 0x1b, 0x48, 0x7a, 0x44, 0xfb, 0x8e, - 0xf6, 0x4e, 0xb4, 0x4f, 0x58, 0x9e, 0x38, 0x92, 0x3b, 0xf2, 0x84, 0x58, 0x7b, 0xae, 0xb3, 0x2a, - 0xdf, 0xaf, 0x2d, 0xbd, 0x03, 0xd9, 0x62, 0x92, 0xfb, 0xed, 0x02, 0xb5, 0xbb, 0x79, 0x20, 0x1d, - 0x22, 0x73, 0x1c, 0xd2, 0x56, 0xee, 0x4a, 0x0b, 0xf8, 0x23, 0x08, 0x24, 0x5d, 0x61, 0x4e, 0xbd, - 0x41, 0x90, 0x49, 0x4b, 0x5b, 0x0d, 0xf7, 0xdc, 0x67, 0xd4, 0xe5, 0x2c, 0xcf, 0x51, 0x3b, 0x4f, - 0x5b, 0x60, 0x7a, 0x67, 0x2b, 0xd4, 0x66, 0x65, 0x4f, 0x5a, 0x10, 0xbe, 0x83, 0xc1, 0x38, 0x41, - 0x5d, 0xca, 0x2a, 0xc1, 0x5f, 0x2c, 0xc6, 0xc1, 0x7f, 0x31, 0x7d, 0x75, 0xdc, 0xbc, 0x04, 0x8a, - 0xd7, 0xfe, 0xf5, 0x7e, 0xf2, 0xef, 0x4b, 0x95, 0xab, 0x49, 0x64, 0x84, 0xf5, 0xa4, 0x43, 0xe1, - 0x13, 0xf0, 0xe9, 0x9d, 0x6c, 0x74, 0xf6, 0x7f, 0xf7, 0xc6, 0xce, 0xbb, 0xe6, 0xdf, 0x7b, 0xef, - 0x47, 0x00, 0x00, 0x00, 0xff, 0xff, 0x48, 0xbe, 0xb0, 0xc3, 0xcf, 0x05, 0x00, 0x00, + // 858 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xbc, 0x55, 0xdd, 0x6e, 0xe3, 0x44, + 0x14, 0xd6, 0xc4, 0x76, 0x62, 0x9f, 0xee, 0x16, 0x34, 0x5a, 0xb1, 0x06, 0x6e, 0x22, 0x0b, 0xa4, + 0x82, 0x44, 0x41, 0xec, 0x13, 0xb4, 0xb5, 0x84, 0x42, 0xbb, 0x4b, 0x99, 0xb4, 0xe5, 0x0a, 0xad, + 0x26, 0xc9, 0x49, 0x6b, 0xed, 0x24, 0x36, 0x63, 0xbb, 0x59, 0xbf, 0x02, 0x57, 0x3c, 0x01, 0x12, + 0x12, 0x57, 0x5c, 0xf2, 0x02, 0x3c, 0x04, 0x2f, 0x84, 0xce, 0xcc, 0xf8, 0x27, 0x6c, 0x41, 0x7b, + 0xb5, 0x77, 0xf3, 0x9d, 0x33, 0xf9, 0xe6, 0xfc, 0x7c, 0x9f, 0x03, 0x87, 0xd9, 0xb6, 0x42, 0xbd, + 0x95, 0xea, 0xb8, 0xd0, 0x79, 0x95, 0xf3, 0xb0, 0xc5, 0xc9, 0xcf, 0x23, 0x18, 0xcf, 0xf3, 0x5a, + 0x2f, 0x91, 0x1f, 0xc2, 0x68, 0x96, 0xc6, 0x6c, 0xca, 0x8e, 0x3c, 0x31, 0x9a, 0xa5, 0x9c, 0x83, + 0xff, 0x42, 0x6e, 0x30, 0x1e, 0x4d, 0xd9, 0x51, 0x24, 0xcc, 0x99, 0x62, 0x57, 0x4d, 0x81, 0xb1, + 0x67, 0x63, 0x74, 0xe6, 0x1f, 0x41, 0x78, 0x5d, 0x12, 0xdb, 0x06, 0x63, 0xdf, 0xc4, 0x3b, 0x4c, + 0xb9, 0x4b, 0x59, 0x96, 0xbb, 0x5c, 0xaf, 0xe2, 0xc0, 0xe6, 0x5a, 0xcc, 0xdf, 0x07, 0xef, 0x5a, + 0x5c, 0xc4, 0x63, 0x13, 0xa6, 0x23, 0x8f, 0x61, 0x92, 0xe2, 0x5a, 0xd6, 0xaa, 0x8a, 0x27, 0x53, + 0x76, 0x14, 0x8a, 0x16, 0x12, 0xcf, 0x15, 0x2a, 0xbc, 0xd5, 0x72, 0x1d, 0x87, 0x96, 0xa7, 0xc5, + 0xfc, 0x18, 0xf8, 0x6c, 0x5b, 0xe2, 0xb2, 0xd6, 0x38, 0x7f, 0x95, 0x15, 0x37, 0xa8, 0xb3, 0x75, + 0x13, 0x47, 0x86, 0xe0, 0x81, 0x0c, 0xbd, 0xf2, 0x1c, 0x2b, 0x49, 0x6f, 0x83, 0xa1, 0x6a, 0x61, + 0xf2, 0x0b, 0x83, 0x28, 0x95, 0xe5, 0xdd, 0x22, 0x97, 0x7a, 0xf5, 0x56, 0xf3, 0xf8, 0x02, 0x82, + 0x25, 0x2a, 0x55, 0xc6, 0xde, 0xd4, 0x3b, 0x3a, 0xf8, 0xfa, 0xe9, 0x71, 0x37, 0xe8, 0x8e, 0xe7, + 0x0c, 0x95, 0x12, 0xf6, 0x16, 0xff, 0x0a, 0xa2, 0x0a, 0x37, 0x85, 0x92, 0x15, 0x96, 0xb1, 0x6f, + 0x7e, 0xc2, 0xfb, 0x9f, 0x5c, 0xb9, 0x94, 0xe8, 0x2f, 0x25, 0x7f, 0x30, 0x78, 0xbc, 0x47, 0xc5, + 0x1f, 0x01, 0x7b, 0x6d, 0xaa, 0x0a, 0x04, 0x7b, 0x4d, 0xa8, 0x31, 0x15, 0x05, 0x82, 0x35, 0x84, + 0x76, 0x66, 0x37, 0x81, 0x60, 0x3b, 0x42, 0x77, 0x66, 0x23, 0x81, 0x60, 0x77, 0xfc, 0x33, 0x98, + 0xfc, 0x54, 0xa3, 0xce, 0xb0, 0x8c, 0x03, 0xf3, 0xf2, 0x7b, 0xfd, 0xcb, 0xdf, 0xd7, 0xa8, 0x1b, + 0xd1, 0xe6, 0xa9, 0x53, 0xb3, 0x4d, 0xbb, 0x1a, 0x73, 0xa6, 0x58, 0x45, 0x9b, 0x9f, 0xd8, 0x18, + 0x9d, 0xdd, 0x84, 0xec, 0x3e, 0x46, 0xb3, 0x34, 0xf9, 0x8b, 0xd1, 0x9a, 0x6c, 0xe9, 0x83, 0xf1, + 0x99, 0x24, 0xff, 0x10, 0x42, 0x6a, 0xeb, 0xe5, 0xbd, 0xd4, 0x6e, 0x84, 0x13, 0xc2, 0x37, 0x52, + 0xf3, 0x2f, 0x61, 0x7c, 0x2f, 0x55, 0x8d, 0x0f, 0x8c, 0xb1, 0xa5, 0xbb, 0xa1, 0xbc, 0x70, 0xd7, + 0xba, 0x62, 0xfc, 0x41, 0x31, 0x4f, 0x20, 0x50, 0x72, 0x81, 0xca, 0xe9, 0xcc, 0x02, 0x5a, 0x10, + 0x75, 0xd5, 0x98, 0x5e, 0x1e, 0x64, 0xb6, 0xbd, 0xdb, 0x5b, 0xc9, 0x35, 0x3c, 0xde, 0x7b, 0xb1, + 0x7b, 0x89, 0xed, 0xbf, 0x64, 0xea, 0x70, 0x6d, 0x58, 0x40, 0x12, 0x2d, 0x51, 0xe1, 0xb2, 0xc2, + 0x95, 0x59, 0x41, 0x28, 0x3a, 0x9c, 0xfc, 0xc6, 0x7a, 0x5e, 0xf3, 0x1e, 0x89, 0x70, 0x99, 0x6f, + 0x36, 0x72, 0xbb, 0x72, 0xd4, 0x2d, 0xa4, 0xb9, 0xad, 0x16, 0x8e, 0x7a, 0xb4, 0x5a, 0x10, 0xd6, + 0x85, 0x33, 0xdc, 0x48, 0x17, 0x7c, 0x0a, 0x07, 0x1b, 0x94, 0x65, 0xad, 0x71, 0x83, 0xdb, 0xca, + 0x8d, 0x60, 0x18, 0xe2, 0x4f, 0x61, 0x52, 0xc9, 0xdb, 0x97, 0xaf, 0xb0, 0x71, 0xb3, 0x18, 0x57, + 0xf2, 0xf6, 0x1c, 0x1b, 0xfe, 0x31, 0x44, 0xeb, 0x0c, 0xd5, 0xca, 0xa4, 0xec, 0x72, 0x43, 0x13, + 0x38, 0xc7, 0x26, 0xf9, 0x9d, 0xc1, 0x78, 0x8e, 0xfa, 0x1e, 0xf5, 0x5b, 0x29, 0x7f, 0xe8, 0x7a, + 0xef, 0x7f, 0x5c, 0xef, 0x3f, 0xec, 0xfa, 0xa0, 0x77, 0xfd, 0x13, 0x08, 0xe6, 0x7a, 0x39, 0x4b, + 0x4d, 0x45, 0x9e, 0xb0, 0x80, 0x7f, 0x00, 0xe3, 0x93, 0x65, 0x95, 0xdd, 0xa3, 0xfb, 0x14, 0x38, + 0x94, 0xfc, 0xca, 0x60, 0x7c, 0x21, 0x9b, 0xbc, 0xae, 0xde, 0x50, 0xd8, 0x14, 0x0e, 0x4e, 0x8a, + 0x42, 0x65, 0x4b, 0x59, 0x65, 0xf9, 0xd6, 0x55, 0x3b, 0x0c, 0xd1, 0x8d, 0xe7, 0x83, 0xd9, 0xd9, + 0xba, 0x87, 0x21, 0xfe, 0x09, 0x04, 0x67, 0xc6, 0xd0, 0xd6, 0x9d, 0x87, 0xbd, 0x5e, 0xac, 0x8f, + 0x4d, 0x92, 0x1a, 0x3c, 0xa9, 0xab, 0x7c, 0xad, 0xf2, 0x9d, 0xe9, 0x24, 0x14, 0x1d, 0x4e, 0xfe, + 0x66, 0xe0, 0xbf, 0x2b, 0xa3, 0x3e, 0x02, 0x96, 0xb9, 0x45, 0xb2, 0xac, 0xb3, 0xed, 0x64, 0x60, + 0xdb, 0x18, 0x26, 0x8d, 0x96, 0xdb, 0x5b, 0x2c, 0xe3, 0x70, 0xea, 0x1d, 0x79, 0xa2, 0x85, 0x26, + 0x63, 0x3c, 0x52, 0xc6, 0xd1, 0xd4, 0x23, 0x05, 0x3a, 0xd8, 0x69, 0x1e, 0x7a, 0xcd, 0x27, 0x7f, + 0x32, 0x08, 0x3a, 0xe5, 0x9e, 0xed, 0x2b, 0xf7, 0xac, 0x57, 0x6e, 0x7a, 0xda, 0x2a, 0x37, 0x3d, + 0x25, 0x2c, 0x2e, 0x5b, 0xe5, 0x8a, 0x4b, 0x9a, 0xda, 0x37, 0x3a, 0xaf, 0x8b, 0xd3, 0xc6, 0x8e, + 0x37, 0x12, 0x1d, 0xa6, 0x75, 0xff, 0x70, 0x87, 0xda, 0xf5, 0x1c, 0x09, 0x87, 0x48, 0x1c, 0x17, + 0xc6, 0xd5, 0xb6, 0x4b, 0x0b, 0xf8, 0xa7, 0x10, 0x08, 0xea, 0xc2, 0xb4, 0xba, 0x37, 0x20, 0x13, + 0x16, 0x36, 0x9b, 0x3c, 0x73, 0xd7, 0x88, 0xe5, 0xba, 0x28, 0x50, 0x3b, 0x4d, 0x5b, 0x60, 0xb8, + 0xf3, 0x1d, 0xda, 0xcf, 0x91, 0x27, 0x2c, 0x48, 0x7e, 0x84, 0xe8, 0x44, 0xa1, 0xae, 0x44, 0xad, + 0xde, 0xfc, 0x88, 0x71, 0xf0, 0xbf, 0x9d, 0x7f, 0xf7, 0xa2, 0x75, 0x02, 0x9d, 0x7b, 0xfd, 0x7a, + 0xff, 0xd2, 0xef, 0xb9, 0x2c, 0xe4, 0x2c, 0x35, 0x8b, 0xf5, 0x84, 0x43, 0xc9, 0xe7, 0xe0, 0x93, + 0x4f, 0x06, 0xcc, 0xfe, 0x7f, 0x79, 0x6c, 0x31, 0x36, 0xff, 0xd6, 0xcf, 0xfe, 0x09, 0x00, 0x00, + 0xff, 0xff, 0xa7, 0xc6, 0x53, 0x22, 0xbf, 0x07, 0x00, 0x00, } diff --git a/bolt/internal/internal.proto b/bolt/internal/internal.proto index 54bfd4f645..40a305718e 100644 --- a/bolt/internal/internal.proto +++ b/bolt/internal/internal.proto @@ -18,6 +18,7 @@ message Dashboard { int64 ID = 1; // ID is the unique ID of the dashboard string Name = 2; // Name is the user-defined name of the dashboard repeated DashboardCell cells = 3; // a representation of all visual data required for rendering the dashboard + repeated Template templates = 4; // Templates replace template variables within InfluxQL } message DashboardCell { @@ -31,6 +32,30 @@ message DashboardCell { string ID = 8; // id is the unique id of the dashboard. MIGRATED FIELD added in 1.2.0-beta6 } +message Template { + string ID = 1; // ID is the unique ID associated with this template + string temp_var = 2; + repeated TemplateValue values = 3; + string type = 4; // Type can be fieldKeys, tagKeys, tagValues, CSV, constant, query, measurements, databases + string label = 5; // Label is a user-facing description of the Template + TemplateQuery query = 6; // Query is used to generate the choices for a template +} + +message TemplateValue { + string type = 1; // Type can be tagKey, tagValue, fieldKey, csv, measurement, database, constant + string value = 2; // Value is the specific value used to replace a template in an InfluxQL query + bool selected = 3; // Selected states that this variable has been picked to use for replacement +} + +message TemplateQuery { + string command = 1; // Command is the query itself + string db = 2; // DB the database for the query (optional) + string rp = 3; // RP is a retention policy and optional; + string measurement = 4; // Measurement is the optinally selected measurement for the query + string tag_key = 5; // TagKey is the optionally selected tag key for the query + string field_key = 6; // FieldKey is the optionally selected field key for the query +} + message Server { int64 ID = 1; // ID is the unique ID of the server string Name = 2; // Name is the user-defined name for the server diff --git a/chronograf.go b/chronograf.go index d9c24c5aea..245b00a87e 100644 --- a/chronograf.go +++ b/chronograf.go @@ -123,15 +123,59 @@ type Range struct { Lower int64 `json:"lower"` // Lower is the lower bound } +// TemplateValue is a value use to replace a template in an InfluxQL query +type TemplateValue struct { + Value string `json:"value"` // Value is the specific value used to replace a template in an InfluxQL query + Type string `json:"type"` // Type can be tagKey, tagValue, fieldKey, csv, measurement, database, constant + Selected bool `json:"selected"` // Selected states that this variable has been picked to use for replacement +} + +// TemplateVar is a named variable within an InfluxQL query to be replaced with Values +type TemplateVar struct { + Var string `json:"tempVar"` // Var is the string to replace within InfluxQL + Values []TemplateValue `json:"values"` // Values are the replacement values within InfluxQL +} + +// String converts the template variable into a correct InfluxQL string based +// on its type +func (t TemplateVar) String() string { + if len(t.Values) == 0 { + return "" + } + switch t.Values[0].Type { + case "tagKey", "fieldKey", "measurement", "database": + return `"` + t.Values[0].Value + `"` + case "tagValue": + return `'` + t.Values[0].Value + `'` + case "csv", "constant": + return t.Values[0].Value + default: + return "" + } +} + +// TemplateID is the unique ID used to identify a template +type TemplateID string + +// Template represents a series of choices to replace TemplateVars within InfluxQL +type Template struct { + TemplateVar + ID TemplateID `json:"id"` // ID is the unique ID associated with this template + Type string `json:"type"` // Type can be fieldKeys, tagKeys, tagValues, CSV, constant, query, measurements, databases + Label string `json:"label"` // Label is a user-facing description of the Template + Query *TemplateQuery `json:"query,omitempty"` // Query is used to generate the choices for a template +} + // Query retrieves a Response from a TimeSeries. type Query struct { - Command string `json:"query"` // Command is the query itself - DB string `json:"db,omitempty"` // DB is optional and if empty will not be used. - RP string `json:"rp,omitempty"` // RP is a retention policy and optional; if empty will not be used. - Wheres []string `json:"wheres,omitempty"` // Wheres restricts the query to certain attributes - GroupBys []string `json:"groupbys,omitempty"` // GroupBys collate the query by these tags - Label string `json:"label,omitempty"` // Label is the Y-Axis label for the data - Range *Range `json:"range,omitempty"` // Range is the default Y-Axis range for the data + Command string `json:"query"` // Command is the query itself + DB string `json:"db,omitempty"` // DB is optional and if empty will not be used. + RP string `json:"rp,omitempty"` // RP is a retention policy and optional; if empty will not be used. + TemplateVars []TemplateVar `json:"tempVars,omitempty"` // TemplateVars are template variables to replace within an InfluxQL query + Wheres []string `json:"wheres,omitempty"` // Wheres restricts the query to certain attributes + GroupBys []string `json:"groupbys,omitempty"` // GroupBys collate the query by these tags + Label string `json:"label,omitempty"` // Label is the Y-Axis label for the data + Range *Range `json:"range,omitempty"` // Range is the default Y-Axis range for the data } // DashboardQuery includes state for the query builder. This is a transition @@ -143,6 +187,16 @@ type DashboardQuery struct { QueryConfig QueryConfig `json:"queryConfig,omitempty"` // QueryConfig represents the query state that is understood by the data explorer } +// TemplateQuery is used to retrieve choices for template replacement +type TemplateQuery struct { + Command string `json:"influxql"` // Command is the query itself + DB string `json:"db,omitempty"` // DB is optional and if empty will not be used. + RP string `json:"rp,omitempty"` // RP is a retention policy and optional; if empty will not be used. + Measurement string `json:"measurement"` // Measurement is the optinally selected measurement for the query + TagKey string `json:"tagKey"` // TagKey is the optionally selected tag key for the query + FieldKey string `json:"fieldKey"` // FieldKey is the optionally selected field key for the query +} + // Response is the result of a query against a TimeSeries type Response interface { MarshalJSON() ([]byte, error) @@ -374,9 +428,10 @@ type DashboardID int // Dashboard represents all visual and query data for a dashboard type Dashboard struct { - ID DashboardID `json:"id"` - Cells []DashboardCell `json:"cells"` - Name string `json:"name"` + ID DashboardID `json:"id"` + Cells []DashboardCell `json:"cells"` + Templates []Template `json:"templates"` + Name string `json:"name"` } // DashboardCell holds visual and query information for a cell diff --git a/influx/influx.go b/influx/influx.go index f01234af5b..c39eb844a0 100644 --- a/influx/influx.go +++ b/influx/influx.go @@ -68,17 +68,20 @@ func (c *Client) query(u *url.URL, q chronograf.Query) (chronograf.Response, err return nil, err } req.Header.Set("Content-Type", "application/json") - - c.Logger. + command := q.Command + if len(q.TemplateVars) > 0 { + command = TemplateReplace(q.Command, q.TemplateVars) + } + logs := c.Logger. WithField("component", "proxy"). WithField("host", req.Host). - WithField("command", q.Command). + WithField("command", command). WithField("db", q.DB). - WithField("rp", q.RP). - Debug("query") + WithField("rp", q.RP) + logs.Debug("query") params := req.URL.Query() - params.Set("q", q.Command) + params.Set("q", command) params.Set("db", q.DB) params.Set("rp", q.RP) params.Set("epoch", "ms") @@ -111,13 +114,7 @@ func (c *Client) query(u *url.URL, q chronograf.Query) (chronograf.Response, err // If we got a valid decode error, send that back if decErr != nil { - c.Logger. - WithField("component", "proxy"). - WithField("host", req.Host). - WithField("command", q.Command). - WithField("db", q.DB). - WithField("rp", q.RP). - WithField("influx_status", resp.StatusCode). + logs.WithField("influx_status", resp.StatusCode). Error("Error parsing results from influxdb: err:", decErr) return nil, decErr } @@ -125,12 +122,7 @@ func (c *Client) query(u *url.URL, q chronograf.Query) (chronograf.Response, err // If we don't have an error in our json response, and didn't get statusOK // then send back an error if resp.StatusCode != http.StatusOK && response.Err != "" { - c.Logger. - WithField("component", "proxy"). - WithField("host", req.Host). - WithField("command", q.Command). - WithField("db", q.DB). - WithField("rp", q.RP). + logs. WithField("influx_status", resp.StatusCode). Error("Received non-200 response from influxdb") diff --git a/influx/influx_test.go b/influx/influx_test.go index 6fa4a859fa..535fd97447 100644 --- a/influx/influx_test.go +++ b/influx/influx_test.go @@ -82,6 +82,7 @@ func Test_Influx_HTTPS_Failure(t *testing.T) { func Test_Influx_HTTPS_InsecureSkipVerify(t *testing.T) { t.Parallel() called := false + q := "" ts := httptest.NewTLSServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusOK) rw.Write([]byte(`{}`)) @@ -89,6 +90,8 @@ func Test_Influx_HTTPS_InsecureSkipVerify(t *testing.T) { if path := r.URL.Path; path != "/query" { t.Error("Expected the path to contain `/query` but was", path) } + values := r.URL.Query() + q = values.Get("q") })) defer ts.Close() @@ -118,6 +121,34 @@ func Test_Influx_HTTPS_InsecureSkipVerify(t *testing.T) { if called == false { t.Error("Expected http request to Influx but there was none") } + called = false + q = "" + query = chronograf.Query{ + Command: "select $field from cpu", + TemplateVars: []chronograf.TemplateVar{ + { + Var: "$field", + Values: []chronograf.TemplateValue{ + { + Value: "usage_user", + Type: "fieldKey", + }, + }, + }, + }, + } + _, err = series.Query(ctx, query) + if err != nil { + t.Fatal("Expected no error but was", err) + } + + if called == false { + t.Error("Expected http request to Influx but there was none") + } + + if q != `select "usage_user" from cpu` { + t.Errorf("Unexpected query: %s", q) + } } func Test_Influx_CancelsInFlightRequests(t *testing.T) { diff --git a/influx/templates.go b/influx/templates.go new file mode 100644 index 0000000000..7017a38456 --- /dev/null +++ b/influx/templates.go @@ -0,0 +1,22 @@ +package influx + +import ( + "strings" + + "github.com/influxdata/chronograf" +) + +// TemplateReplace replaces templates with values within the query string +func TemplateReplace(query string, templates []chronograf.TemplateVar) string { + replacements := []string{} + for _, v := range templates { + newVal := v.String() + if newVal != "" { + replacements = append(replacements, v.Var, newVal) + } + } + + replacer := strings.NewReplacer(replacements...) + replaced := replacer.Replace(query) + return replaced +} diff --git a/influx/templates_test.go b/influx/templates_test.go new file mode 100644 index 0000000000..b66c8dc2f0 --- /dev/null +++ b/influx/templates_test.go @@ -0,0 +1,133 @@ +package influx + +import ( + "testing" + + "github.com/influxdata/chronograf" +) + +func TestTemplateReplace(t *testing.T) { + tests := []struct { + name string + query string + vars []chronograf.TemplateVar + want string + }{ + { + name: "select with parameters", + query: "$METHOD field1, $field FROM $measurement WHERE temperature > $temperature", + vars: []chronograf.TemplateVar{ + { + Var: "$temperature", + Values: []chronograf.TemplateValue{ + { + Type: "csv", + Value: "10", + }, + }, + }, + { + Var: "$field", + Values: []chronograf.TemplateValue{ + { + Type: "fieldKey", + Value: "field2", + }, + }, + }, + { + Var: "$METHOD", + Values: []chronograf.TemplateValue{ + { + Type: "csv", + Value: "SELECT", + }, + }, + }, + { + Var: "$measurement", + Values: []chronograf.TemplateValue{ + { + Type: "csv", + Value: `"cpu"`, + }, + }, + }, + }, + want: `SELECT field1, "field2" FROM "cpu" WHERE temperature > 10`, + }, + { + name: "select with parameters and aggregates", + query: `SELECT mean($field) FROM "cpu" WHERE $tag = $value GROUP BY $tag`, + vars: []chronograf.TemplateVar{ + { + Var: "$value", + Values: []chronograf.TemplateValue{ + { + Type: "tagValue", + Value: "howdy.com", + }, + }, + }, + { + Var: "$tag", + Values: []chronograf.TemplateValue{ + { + Type: "tagKey", + Value: "host", + }, + }, + }, + { + Var: "$field", + Values: []chronograf.TemplateValue{ + { + Type: "fieldKey", + Value: "field", + }, + }, + }, + }, + want: `SELECT mean("field") FROM "cpu" WHERE "host" = 'howdy.com' GROUP BY "host"`, + }, + { + name: "Non-existant parameters", + query: `SELECT $field FROM "cpu"`, + want: `SELECT $field FROM "cpu"`, + }, + { + name: "var without a value", + query: `SELECT $field FROM "cpu"`, + vars: []chronograf.TemplateVar{ + { + Var: "$field", + }, + }, + want: `SELECT $field FROM "cpu"`, + }, + { + name: "var with unknown type", + query: `SELECT $field FROM "cpu"`, + vars: []chronograf.TemplateVar{ + { + Var: "$field", + Values: []chronograf.TemplateValue{ + { + Type: "who knows?", + Value: "field", + }, + }, + }, + }, + want: `SELECT $field FROM "cpu"`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := TemplateReplace(tt.query, tt.vars) + if got != tt.want { + t.Errorf("TestParse %s =\n%s\nwant\n%s", tt.name, got, tt.want) + } + }) + } +} diff --git a/server/cells.go b/server/cells.go new file mode 100644 index 0000000000..b6a79290b6 --- /dev/null +++ b/server/cells.go @@ -0,0 +1,261 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/uuid" +) + +const ( + // DefaultWidth is used if not specified + DefaultWidth = 4 + // DefaultHeight is used if not specified + DefaultHeight = 4 +) + +type dashboardCellLinks struct { + Self string `json:"self"` // Self link mapping to this resource +} + +type dashboardCellResponse struct { + chronograf.DashboardCell + Links dashboardCellLinks `json:"links"` +} + +func newCellResponses(dID chronograf.DashboardID, dcells []chronograf.DashboardCell) []dashboardCellResponse { + base := "/chronograf/v1/dashboards" + cells := make([]dashboardCellResponse, len(dcells)) + for i, cell := range dcells { + if len(cell.Queries) == 0 { + cell.Queries = make([]chronograf.DashboardQuery, 0) + } + cells[i] = dashboardCellResponse{ + DashboardCell: cell, + Links: dashboardCellLinks{ + Self: fmt.Sprintf("%s/%d/cells/%s", base, dID, cell.ID), + }, + } + } + return cells +} + +// ValidDashboardCellRequest verifies that the dashboard cells have a query +func ValidDashboardCellRequest(c *chronograf.DashboardCell) error { + CorrectWidthHeight(c) + return nil +} + +// CorrectWidthHeight changes the cell to have at least the +// minimum width and height +func CorrectWidthHeight(c *chronograf.DashboardCell) { + if c.W < 1 { + c.W = DefaultWidth + } + if c.H < 1 { + c.H = DefaultHeight + } +} + +// AddQueryConfig updates a cell by converting InfluxQL into queryconfigs +// If influxql cannot be represented by a full query config, then, the +// query config's raw text is set to the command. +func AddQueryConfig(c *chronograf.DashboardCell) { + for i, q := range c.Queries { + qc := ToQueryConfig(q.Command) + q.QueryConfig = qc + c.Queries[i] = q + } +} + +// DashboardCells returns all cells from a dashboard within the store +func (s *Service) DashboardCells(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + ctx := r.Context() + e, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + boards := newDashboardResponse(e) + cells := boards.Cells + encodeJSON(w, http.StatusOK, cells, s.Logger) +} + +// NewDashboardCell adds a cell to an existing dashboard +func (s *Service) NewDashboardCell(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + ctx := r.Context() + dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + var cell chronograf.DashboardCell + if err := json.NewDecoder(r.Body).Decode(&cell); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := ValidDashboardCellRequest(&cell); err != nil { + invalidData(w, err, s.Logger) + return + } + + ids := uuid.V4{} + cid, err := ids.Generate() + if err != nil { + msg := fmt.Sprintf("Error creating cell ID of dashboard %d: %v", id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + cell.ID = cid + + dash.Cells = append(dash.Cells, cell) + if err := s.DashboardsStore.Update(ctx, dash); err != nil { + msg := fmt.Sprintf("Error adding cell %s to dashboard %d: %v", cid, id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + + boards := newDashboardResponse(dash) + for _, cell := range boards.Cells { + if cell.ID == cid { + encodeJSON(w, http.StatusOK, cell, s.Logger) + return + } + } +} + +// DashboardCellID gets a specific cell from an existing dashboard +func (s *Service) DashboardCellID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + boards := newDashboardResponse(dash) + cid := httprouter.GetParamFromContext(ctx, "cid") + for _, cell := range boards.Cells { + if cell.ID == cid { + encodeJSON(w, http.StatusOK, cell, s.Logger) + return + } + } + notFound(w, id, s.Logger) +} + +// RemoveDashboardCell removes a specific cell from an existing dashboard +func (s *Service) RemoveDashboardCell(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + ctx := r.Context() + dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + cid := httprouter.GetParamFromContext(ctx, "cid") + cellid := -1 + for i, cell := range dash.Cells { + if cell.ID == cid { + cellid = i + break + } + } + if cellid == -1 { + notFound(w, id, s.Logger) + return + } + + dash.Cells = append(dash.Cells[:cellid], dash.Cells[cellid+1:]...) + if err := s.DashboardsStore.Update(ctx, dash); err != nil { + msg := fmt.Sprintf("Error removing cell %s from dashboard %d: %v", cid, id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ReplaceDashboardCell replaces a cell entirely within an existing dashboard +func (s *Service) ReplaceDashboardCell(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + ctx := r.Context() + dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + cid := httprouter.GetParamFromContext(ctx, "cid") + cellid := -1 + for i, cell := range dash.Cells { + if cell.ID == cid { + cellid = i + break + } + } + if cellid == -1 { + notFound(w, id, s.Logger) + return + } + + var cell chronograf.DashboardCell + if err := json.NewDecoder(r.Body).Decode(&cell); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := ValidDashboardCellRequest(&cell); err != nil { + invalidData(w, err, s.Logger) + return + } + cell.ID = cid + + dash.Cells[cellid] = cell + if err := s.DashboardsStore.Update(ctx, dash); err != nil { + msg := fmt.Sprintf("Error updating cell %s in dashboard %d: %v", cid, id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + + boards := newDashboardResponse(dash) + for _, cell := range boards.Cells { + if cell.ID == cid { + encodeJSON(w, http.StatusOK, cell, s.Logger) + return + } + } +} diff --git a/server/dashboards.go b/server/dashboards.go index 03d40c16f7..ee9bb8a602 100644 --- a/server/dashboards.go +++ b/server/dashboards.go @@ -5,37 +5,21 @@ import ( "fmt" "net/http" - "github.com/bouk/httprouter" "github.com/influxdata/chronograf" - "github.com/influxdata/chronograf/uuid" -) - -const ( - // DefaultWidth is used if not specified - DefaultWidth = 4 - // DefaultHeight is used if not specified - DefaultHeight = 4 ) type dashboardLinks struct { - Self string `json:"self"` // Self link mapping to this resource - Cells string `json:"cells"` // Cells link to the cells endpoint -} - -type dashboardCellLinks struct { - Self string `json:"self"` // Self link mapping to this resource -} - -type dashboardCellResponse struct { - chronograf.DashboardCell - Links dashboardCellLinks `json:"links"` + Self string `json:"self"` // Self link mapping to this resource + Cells string `json:"cells"` // Cells link to the cells endpoint + Templates string `json:"templates"` // Templates link to the templates endpoint } type dashboardResponse struct { - ID chronograf.DashboardID `json:"id"` - Cells []dashboardCellResponse `json:"cells"` - Name string `json:"name"` - Links dashboardLinks `json:"links"` + ID chronograf.DashboardID `json:"id"` + Cells []dashboardCellResponse `json:"cells"` + Templates []templateResponse `json:"templates"` + Name string `json:"name"` + Links dashboardLinks `json:"links"` } type getDashboardsResponse struct { @@ -46,25 +30,18 @@ func newDashboardResponse(d chronograf.Dashboard) *dashboardResponse { base := "/chronograf/v1/dashboards" DashboardDefaults(&d) AddQueryConfigs(&d) - cells := make([]dashboardCellResponse, len(d.Cells)) - for i, cell := range d.Cells { - if len(cell.Queries) == 0 { - cell.Queries = make([]chronograf.DashboardQuery, 0) - } - cells[i] = dashboardCellResponse{ - DashboardCell: cell, - Links: dashboardCellLinks{ - Self: fmt.Sprintf("%s/%d/cells/%s", base, d.ID, cell.ID), - }, - } - } + cells := newCellResponses(d.ID, d.Cells) + templates := newTemplateResponses(d.ID, d.Templates) + return &dashboardResponse{ - ID: d.ID, - Name: d.Name, - Cells: cells, + ID: d.ID, + Name: d.Name, + Cells: cells, + Templates: templates, Links: dashboardLinks{ - Self: fmt.Sprintf("%s/%d", base, d.ID), - Cells: fmt.Sprintf("%s/%d/cells", base, d.ID), + Self: fmt.Sprintf("%s/%d", base, d.ID), + Cells: fmt.Sprintf("%s/%d/cells", base, d.ID), + Templates: fmt.Sprintf("%s/%d/templates", base, d.ID), }, } } @@ -85,7 +62,6 @@ func (s *Service) Dashboards(w http.ResponseWriter, r *http.Request) { for _, dashboard := range dashboards { res.Dashboards = append(res.Dashboards, newDashboardResponse(dashboard)) } - encodeJSON(w, http.StatusOK, res, s.Logger) } @@ -243,19 +219,20 @@ func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) { // ValidDashboardRequest verifies that the dashboard cells have a query func ValidDashboardRequest(d *chronograf.Dashboard) error { for i, c := range d.Cells { - CorrectWidthHeight(&c) + if err := ValidDashboardCellRequest(&c); err != nil { + return err + } d.Cells[i] = c } + for _, t := range d.Templates { + if err := ValidTemplateRequest(&t); err != nil { + return err + } + } DashboardDefaults(d) return nil } -// ValidDashboardCellRequest verifies that the dashboard cells have a query -func ValidDashboardCellRequest(c *chronograf.DashboardCell) error { - CorrectWidthHeight(c) - return nil -} - // DashboardDefaults updates the dashboard with the default values // if none are specified func DashboardDefaults(d *chronograf.Dashboard) { @@ -265,17 +242,6 @@ func DashboardDefaults(d *chronograf.Dashboard) { } } -// CorrectWidthHeight changes the cell to have at least the -// minimum width and height -func CorrectWidthHeight(c *chronograf.DashboardCell) { - if c.W < 1 { - c.W = DefaultWidth - } - if c.H < 1 { - c.H = DefaultHeight - } -} - // AddQueryConfigs updates all the celsl in the dashboard to have query config // objects corresponding to their influxql queries. func AddQueryConfigs(d *chronograf.Dashboard) { @@ -284,203 +250,3 @@ func AddQueryConfigs(d *chronograf.Dashboard) { d.Cells[i] = c } } - -// AddQueryConfig updates a cell by converting InfluxQL into queryconfigs -// If influxql cannot be represented by a full query config, then, the -// query config's raw text is set to the command. -func AddQueryConfig(c *chronograf.DashboardCell) { - for i, q := range c.Queries { - qc := ToQueryConfig(q.Command) - q.QueryConfig = qc - c.Queries[i] = q - } -} - -// DashboardCells returns all cells from a dashboard within the store -func (s *Service) DashboardCells(w http.ResponseWriter, r *http.Request) { - id, err := paramID("id", r) - if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) - return - } - - ctx := r.Context() - e, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) - if err != nil { - notFound(w, id, s.Logger) - return - } - - boards := newDashboardResponse(e) - cells := boards.Cells - encodeJSON(w, http.StatusOK, cells, s.Logger) -} - -// NewDashboardCell adds a cell to an existing dashboard -func (s *Service) NewDashboardCell(w http.ResponseWriter, r *http.Request) { - id, err := paramID("id", r) - if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) - return - } - - ctx := r.Context() - dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) - if err != nil { - notFound(w, id, s.Logger) - return - } - var cell chronograf.DashboardCell - if err := json.NewDecoder(r.Body).Decode(&cell); err != nil { - invalidJSON(w, s.Logger) - return - } - - if err := ValidDashboardCellRequest(&cell); err != nil { - invalidData(w, err, s.Logger) - return - } - - ids := uuid.V4{} - cid, err := ids.Generate() - if err != nil { - msg := fmt.Sprintf("Error creating cell ID of dashboard %d: %v", id, err) - Error(w, http.StatusInternalServerError, msg, s.Logger) - return - } - cell.ID = cid - - dash.Cells = append(dash.Cells, cell) - if err := s.DashboardsStore.Update(ctx, dash); err != nil { - msg := fmt.Sprintf("Error adding cell %s to dashboard %d: %v", cid, id, err) - Error(w, http.StatusInternalServerError, msg, s.Logger) - return - } - - boards := newDashboardResponse(dash) - for _, cell := range boards.Cells { - if cell.ID == cid { - encodeJSON(w, http.StatusOK, cell, s.Logger) - return - } - } -} - -// DashboardCellID adds a cell to an existing dashboard -func (s *Service) DashboardCellID(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, err := paramID("id", r) - if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) - return - } - - dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) - if err != nil { - notFound(w, id, s.Logger) - return - } - - boards := newDashboardResponse(dash) - cid := httprouter.GetParamFromContext(ctx, "cid") - for _, cell := range boards.Cells { - if cell.ID == cid { - encodeJSON(w, http.StatusOK, cell, s.Logger) - return - } - } - notFound(w, id, s.Logger) -} - -// RemoveDashboardCell adds a cell to an existing dashboard -func (s *Service) RemoveDashboardCell(w http.ResponseWriter, r *http.Request) { - id, err := paramID("id", r) - if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) - return - } - - ctx := r.Context() - dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) - if err != nil { - notFound(w, id, s.Logger) - return - } - - cid := httprouter.GetParamFromContext(ctx, "cid") - cellid := -1 - for i, cell := range dash.Cells { - if cell.ID == cid { - cellid = i - break - } - } - if cellid == -1 { - notFound(w, id, s.Logger) - return - } - - dash.Cells = append(dash.Cells[:cellid], dash.Cells[cellid+1:]...) - if err := s.DashboardsStore.Update(ctx, dash); err != nil { - msg := fmt.Sprintf("Error removing cell %s from dashboard %d: %v", cid, id, err) - Error(w, http.StatusInternalServerError, msg, s.Logger) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// ReplaceDashboardCell adds a cell to an existing dashboard -func (s *Service) ReplaceDashboardCell(w http.ResponseWriter, r *http.Request) { - id, err := paramID("id", r) - if err != nil { - Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) - return - } - - ctx := r.Context() - dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) - if err != nil { - notFound(w, id, s.Logger) - return - } - - cid := httprouter.GetParamFromContext(ctx, "cid") - cellid := -1 - for i, cell := range dash.Cells { - if cell.ID == cid { - cellid = i - break - } - } - if cellid == -1 { - notFound(w, id, s.Logger) - return - } - - var cell chronograf.DashboardCell - if err := json.NewDecoder(r.Body).Decode(&cell); err != nil { - invalidJSON(w, s.Logger) - return - } - - if err := ValidDashboardCellRequest(&cell); err != nil { - invalidData(w, err, s.Logger) - return - } - cell.ID = cid - - dash.Cells[cellid] = cell - if err := s.DashboardsStore.Update(ctx, dash); err != nil { - msg := fmt.Sprintf("Error updating cell %s in dashboard %d: %v", cid, id, err) - Error(w, http.StatusInternalServerError, msg, s.Logger) - return - } - - boards := newDashboardResponse(dash) - for _, cell := range boards.Cells { - if cell.ID == cid { - encodeJSON(w, http.StatusOK, cell, s.Logger) - return - } - } -} diff --git a/server/dashboards_test.go b/server/dashboards_test.go index 2c7c1c490b..73cd59c32f 100644 --- a/server/dashboards_test.go +++ b/server/dashboards_test.go @@ -233,6 +233,7 @@ func Test_newDashboardResponse(t *testing.T) { }, }, want: &dashboardResponse{ + Templates: []templateResponse{}, Cells: []dashboardCellResponse{ dashboardCellResponse{ Links: dashboardCellLinks{ @@ -289,8 +290,9 @@ func Test_newDashboardResponse(t *testing.T) { }, }, Links: dashboardLinks{ - Self: "/chronograf/v1/dashboards/0", - Cells: "/chronograf/v1/dashboards/0/cells", + Self: "/chronograf/v1/dashboards/0", + Cells: "/chronograf/v1/dashboards/0/cells", + Templates: "/chronograf/v1/dashboards/0/templates", }, }, }, diff --git a/server/mux.go b/server/mux.go index 9e05756c11..0024cf221d 100644 --- a/server/mux.go +++ b/server/mux.go @@ -155,6 +155,13 @@ func NewMux(opts MuxOpts, service Service) http.Handler { router.GET("/chronograf/v1/dashboards/:id/cells/:cid", service.DashboardCellID) router.DELETE("/chronograf/v1/dashboards/:id/cells/:cid", service.RemoveDashboardCell) router.PUT("/chronograf/v1/dashboards/:id/cells/:cid", service.ReplaceDashboardCell) + // Dashboard Templates + router.GET("/chronograf/v1/dashboards/:id/templates", service.Templates) + router.POST("/chronograf/v1/dashboards/:id/templates", service.NewTemplate) + + router.GET("/chronograf/v1/dashboards/:id/templates/:tid", service.TemplateID) + router.DELETE("/chronograf/v1/dashboards/:id/templates/:tid", service.RemoveTemplate) + router.PUT("/chronograf/v1/dashboards/:id/templates/:tid", service.ReplaceTemplate) // Databases router.GET("/chronograf/v1/sources/:id/dbs", service.GetDatabases) diff --git a/server/queries.go b/server/queries.go index c98e500b7d..a11fbd5944 100644 --- a/server/queries.go +++ b/server/queries.go @@ -8,12 +8,14 @@ import ( "golang.org/x/net/context" "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/influx" "github.com/influxdata/chronograf/influx/queries" ) type QueryRequest struct { - ID string `json:"id"` - Query string `json:"query"` + ID string `json:"id"` + Query string `json:"query"` + TemplateVars []chronograf.TemplateVar `json:"tempVars,omitempty"` } type QueriesRequest struct { @@ -21,10 +23,12 @@ type QueriesRequest struct { } type QueryResponse struct { - ID string `json:"id"` - Query string `json:"query"` - QueryConfig chronograf.QueryConfig `json:"queryConfig"` - QueryAST *queries.SelectStatement `json:"queryAST,omitempty"` + ID string `json:"id"` + Query string `json:"query"` + QueryConfig chronograf.QueryConfig `json:"queryConfig"` + QueryAST *queries.SelectStatement `json:"queryAST,omitempty"` + QueryTemplated *string `json:"queryTemplated,omitempty"` + TemplateVars []chronograf.TemplateVar `json:"tempVars,omitempty"` } type QueriesResponse struct { @@ -62,17 +66,28 @@ func (s *Service) Queries(w http.ResponseWriter, r *http.Request) { Query: q.Query, } - qc := ToQueryConfig(q.Query) + query := q.Query + if len(q.TemplateVars) > 0 { + query = influx.TemplateReplace(query, q.TemplateVars) + qr.QueryTemplated = &query + } + + qc := ToQueryConfig(query) if err := s.DefaultRP(ctx, &qc, &src); err != nil { Error(w, http.StatusBadRequest, err.Error(), s.Logger) return } qr.QueryConfig = qc - if stmt, err := queries.ParseSelect(q.Query); err == nil { + if stmt, err := queries.ParseSelect(query); err == nil { qr.QueryAST = stmt } + if len(q.TemplateVars) > 0 { + qr.TemplateVars = q.TemplateVars + qr.QueryConfig.RawText = &qr.Query + } + qr.QueryConfig.ID = q.ID res.Queries[i] = qr } diff --git a/server/swagger.json b/server/swagger.json index f66ebb6594..3a358efdf8 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -3055,10 +3055,20 @@ "Proxy": { "type": "object", "example": { - "query": "select * from cpu where time > now() - 10m", + "query": "select $myfield from cpu where time > now() - 10m", "db": "telegraf", "rp": "autogen", - "format": "raw" + "tempVars": [ + { + "tempVar": "$myfield", + "values": [ + { + "type": "fieldKey", + "value": "usage_user" + } + ] + } + ] }, "required": [ "query" @@ -3073,12 +3083,49 @@ "rp": { "type": "string" }, - "format": { + "tempVars": { + "type": "array", + "description": "Template variables to replace within an InfluxQL query", + "items": { + "$ref": "#/definitions/TemplateVariable" + } + } + } + }, + "TemplateVariable": { + "type": "object", + "description": "Named variable within an InfluxQL query to be replaced with values", + "properties": { + "tempVar": { + "type": "string", + "description": "String to replace within an InfluxQL statement" + }, + "values": { + "type": "array", + "description": "Values used to replace tempVar.", + "items": { + "$ref": "#/definitions/TemplateValue" + } + } + } + }, + "TemplateValue": { + "type": "object", + "description": "Value use to replace a template in an InfluxQL query. The type governs the output format", + "properties": { + "value": { + "type": "string", + "description": "Specific value that will be encoded based on type" + }, + "type": { "type": "string", "enum": [ - "raw" + "csv", + "tagKey", + "tagValue", + "fieldKey" ], - "default": "raw" + "description": "The type will change the format of the output value. tagKey/fieldKey are double quoted; tagValue are single quoted; csv are not quoted." } } }, @@ -3986,4 +4033,4 @@ } } } -} +} \ No newline at end of file diff --git a/server/templates.go b/server/templates.go new file mode 100644 index 0000000000..16500e97a0 --- /dev/null +++ b/server/templates.go @@ -0,0 +1,248 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/bouk/httprouter" + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/uuid" +) + +// ValidTemplateRequest checks if the request sent to the server is the correct format. +func ValidTemplateRequest(template *chronograf.Template) error { + switch template.Type { + default: + return fmt.Errorf("Unknown template type %s", template.Type) + case "query", "constant", "csv", "fieldKeys", "tagKeys", "tagValues", "measurements", "databases": + } + + for _, v := range template.Values { + switch v.Type { + default: + return fmt.Errorf("Unknown template variable type %s", v.Type) + case "csv", "fieldKey", "tagKey", "tagValue", "measurement", "database", "constant": + } + } + + if template.Type == "query" && template.Query == nil { + return fmt.Errorf("No query set for template of type 'query'") + } + + return nil +} + +type templateLinks struct { + Self string `json:"self"` // Self link mapping to this resource +} + +type templateResponse struct { + chronograf.Template + Links templateLinks `json:"links"` +} + +func newTemplateResponses(dID chronograf.DashboardID, tmps []chronograf.Template) []templateResponse { + res := make([]templateResponse, len(tmps)) + for i, t := range tmps { + res[i] = newTemplateResponse(dID, t) + } + return res +} + +type templatesResponses struct { + Templates []templateResponse `json:"templates"` +} + +func newTemplateResponse(dID chronograf.DashboardID, tmp chronograf.Template) templateResponse { + base := "/chronograf/v1/dashboards" + return templateResponse{ + Template: tmp, + Links: templateLinks{ + Self: fmt.Sprintf("%s/%d/templates/%s", base, dID, tmp.ID), + }, + } +} + +// Templates returns all templates from a dashboard within the store +func (s *Service) Templates(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + ctx := r.Context() + d, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + res := templatesResponses{ + Templates: newTemplateResponses(chronograf.DashboardID(id), d.Templates), + } + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// NewTemplate adds a template to an existing dashboard +func (s *Service) NewTemplate(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + ctx := r.Context() + dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + var template chronograf.Template + if err := json.NewDecoder(r.Body).Decode(&template); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := ValidTemplateRequest(&template); err != nil { + invalidData(w, err, s.Logger) + return + } + + ids := uuid.V4{} + tid, err := ids.Generate() + if err != nil { + msg := fmt.Sprintf("Error creating template ID for dashboard %d: %v", id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + template.ID = chronograf.TemplateID(tid) + + dash.Templates = append(dash.Templates, template) + if err := s.DashboardsStore.Update(ctx, dash); err != nil { + msg := fmt.Sprintf("Error adding template %s to dashboard %d: %v", tid, id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + + res := newTemplateResponse(dash.ID, template) + encodeJSON(w, http.StatusOK, res, s.Logger) +} + +// TemplateID retrieves a specific template from a dashboard +func (s *Service) TemplateID(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + tid := httprouter.GetParamFromContext(ctx, "tid") + for _, t := range dash.Templates { + if t.ID == chronograf.TemplateID(tid) { + res := newTemplateResponse(chronograf.DashboardID(id), t) + encodeJSON(w, http.StatusOK, res, s.Logger) + return + } + } + + notFound(w, id, s.Logger) +} + +// RemoveTemplate removes a specific template from an existing dashboard +func (s *Service) RemoveTemplate(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + ctx := r.Context() + dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + tid := httprouter.GetParamFromContext(ctx, "tid") + pos := -1 + for i, t := range dash.Templates { + if t.ID == chronograf.TemplateID(tid) { + pos = i + break + } + } + if pos == -1 { + notFound(w, id, s.Logger) + return + } + + dash.Templates = append(dash.Templates[:pos], dash.Templates[pos+1:]...) + if err := s.DashboardsStore.Update(ctx, dash); err != nil { + msg := fmt.Sprintf("Error removing template %s from dashboard %d: %v", tid, id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// ReplaceTemplate replaces a template entirely within an existing dashboard +func (s *Service) ReplaceTemplate(w http.ResponseWriter, r *http.Request) { + id, err := paramID("id", r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger) + return + } + + ctx := r.Context() + dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id)) + if err != nil { + notFound(w, id, s.Logger) + return + } + + tid := httprouter.GetParamFromContext(ctx, "tid") + pos := -1 + for i, t := range dash.Templates { + if t.ID == chronograf.TemplateID(tid) { + pos = i + break + } + } + if pos == -1 { + notFound(w, id, s.Logger) + return + } + + var template chronograf.Template + if err := json.NewDecoder(r.Body).Decode(&template); err != nil { + invalidJSON(w, s.Logger) + return + } + + if err := ValidTemplateRequest(&template); err != nil { + invalidData(w, err, s.Logger) + return + } + template.ID = chronograf.TemplateID(tid) + + dash.Templates[pos] = template + if err := s.DashboardsStore.Update(ctx, dash); err != nil { + msg := fmt.Sprintf("Error updating template %s in dashboard %d: %v", tid, id, err) + Error(w, http.StatusInternalServerError, msg, s.Logger) + return + } + + res := newTemplateResponse(chronograf.DashboardID(id), template) + encodeJSON(w, http.StatusOK, res, s.Logger) +} diff --git a/server/templates_test.go b/server/templates_test.go new file mode 100644 index 0000000000..8a9bec46f6 --- /dev/null +++ b/server/templates_test.go @@ -0,0 +1,71 @@ +package server + +import ( + "testing" + + "github.com/influxdata/chronograf" +) + +func TestValidTemplateRequest(t *testing.T) { + tests := []struct { + name string + template *chronograf.Template + wantErr bool + }{ + { + name: "Valid Template", + template: &chronograf.Template{ + Type: "fieldKeys", + TemplateVar: chronograf.TemplateVar{ + Values: []chronograf.TemplateValue{ + { + Type: "fieldKey", + }, + }, + }, + }, + }, + { + name: "Invalid Template Type", + wantErr: true, + template: &chronograf.Template{ + Type: "Unknown Type", + TemplateVar: chronograf.TemplateVar{ + Values: []chronograf.TemplateValue{ + { + Type: "fieldKey", + }, + }, + }, + }, + }, + { + name: "Invalid Template Variable Type", + wantErr: true, + template: &chronograf.Template{ + Type: "csv", + TemplateVar: chronograf.TemplateVar{ + Values: []chronograf.TemplateValue{ + { + Type: "unknown value", + }, + }, + }, + }, + }, + { + name: "No query set", + wantErr: true, + template: &chronograf.Template{ + Type: "query", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := ValidTemplateRequest(tt.template); (err != nil) != tt.wantErr { + t.Errorf("ValidTemplateRequest() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/ui/.eslintrc b/ui/.eslintrc index 517f82ebf5..a74b895c30 100644 --- a/ui/.eslintrc +++ b/ui/.eslintrc @@ -149,7 +149,7 @@ 'eol-last': 0, // TODO: revisit 'id-length': 0, 'id-match': 0, - 'indent': [2, 2, {SwitchCase: 1}], + 'indent': [0, 2, {SwitchCase: 1}], 'key-spacing': [2, {beforeColon: false, afterColon: true}], 'linebreak-style': [2, 'unix'], 'lines-around-comment': 0, @@ -234,6 +234,6 @@ 'react/require-extension': 0, 'react/self-closing-comp': 0, // TODO: we can re-enable this if some brave soul wants to update the code (mostly spans acting as icons) 'react/sort-comp': 0, // TODO: 2 - 'react/jsx-wrap-multilines': 'error', + 'react/jsx-wrap-multilines': ['error', {'declaration': false, 'assignment': false}], }, } diff --git a/ui/package.json b/ui/package.json index c8771b5c78..b1108ffde1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -70,7 +70,7 @@ "mocha": "^2.4.5", "mocha-loader": "^0.7.1", "mustache": "^2.2.1", - "node-sass": "^3.5.3", + "node-sass": "^4.5.2", "postcss-browser-reporter": "^0.4.0", "postcss-calc": "^5.2.0", "postcss-loader": "^0.8.0", diff --git a/ui/spec/dashboards/reducers/uiSpec.js b/ui/spec/dashboards/reducers/uiSpec.js index 2a10a3bfc3..9fc364ff00 100644 --- a/ui/spec/dashboards/reducers/uiSpec.js +++ b/ui/spec/dashboards/reducers/uiSpec.js @@ -10,11 +10,48 @@ import { editDashboardCell, renameDashboardCell, syncDashboardCell, + templateVariableSelected, } from 'src/dashboards/actions' let state -const d1 = {id: 1, cells: [], name: "d1"} -const d2 = {id: 2, cells: [], name: "d2"} +const templates = [ + { + id: '1', + type: 'query', + label: 'test query', + tempVar: '$REGION', + query: { + db: 'db1', + rp: 'rp1', + measurement: 'm1', + influxql: 'SHOW TAGS WHERE CHRONOGIRAFFE = "friend"', + }, + values: [ + {value: 'us-west', type: 'tagKey', selected: false}, + {value: 'us-east', type: 'tagKey', selected: true}, + {value: 'us-mount', type: 'tagKey', selected: false}, + ], + }, + { + id: '2', + type: 'csv', + label: 'test csv', + tempVar: '$TEMPERATURE', + values: [ + {value: '98.7', type: 'measurement', selected: false}, + {value: '99.1', type: 'measurement', selected: false}, + {value: '101.3', type: 'measurement', selected: true}, + ], + }, +] + +const d1 = { + id: 1, + cells: [], + name: 'd1', + templates, +} +const d2 = {id: 2, cells: [], name: 'd2', templates: []} const dashboards = [d1, d2] const c1 = { x: 0, @@ -23,9 +60,21 @@ const c1 = { h: 4, id: 1, isEditing: false, - name: "Gigawatts", + name: 'Gigawatts', } const cells = [c1] +const tempVar = { + ...d1.templates[0], + id: '1', + type: 'measurement', + label: 'test query', + tempVar: '$HOSTS', + query: { + db: 'db1', + text: 'SHOW TAGS WHERE HUNTER = "coo"', + }, + values: ['h1', 'h2', 'h3'], +} describe('DataExplorer.Reducers.UI', () => { it('can load the dashboards', () => { @@ -66,6 +115,7 @@ describe('DataExplorer.Reducers.UI', () => { id: 1, cells: updatedCells, name: 'd1', + templates, } const actual = reducer(state, updateDashboardCells(d1, updatedCells)) @@ -106,7 +156,28 @@ describe('DataExplorer.Reducers.UI', () => { dashboards: [dash], } - const actual = reducer(state, renameDashboardCell(dash, 0, 0, "Plutonium Consumption Rate (ug/sec)")) - expect(actual.dashboards[0].cells[0].name).to.equal("Plutonium Consumption Rate (ug/sec)") + const actual = reducer( + state, + renameDashboardCell(dash, 0, 0, 'Plutonium Consumption Rate (ug/sec)') + ) + expect(actual.dashboards[0].cells[0].name).to.equal( + 'Plutonium Consumption Rate (ug/sec)' + ) + }) + + it('can select a different template variable', () => { + const dash = _.cloneDeep(d1) + state = { + dashboards: [dash], + } + const value = dash.templates[0].values[2].value + const actual = reducer( + {dashboards}, + templateVariableSelected(dash.id, dash.templates[0].id, [{value}]) + ) + + expect(actual.dashboards[0].templates[0].values[0].selected).to.equal(false) + expect(actual.dashboards[0].templates[0].values[1].selected).to.equal(false) + expect(actual.dashboards[0].templates[0].values[2].selected).to.equal(true) }) }) diff --git a/ui/spec/dashboards/templatingSpec.js b/ui/spec/dashboards/templatingSpec.js new file mode 100644 index 0000000000..0e349f5e38 --- /dev/null +++ b/ui/spec/dashboards/templatingSpec.js @@ -0,0 +1,86 @@ +import {TEMPLATE_MATCHER} from 'src/dashboards/constants' + +describe('templating', () => { + describe('matching', () => { + it('can match the expected strings', () => { + const matchingStrings = [ + 'SELECT : FROM "db1"."rp1"."m1" WHERE time > now() - 15m', + 'SELECT :t, "f1" FROM "db1"."rp1"."m1" WHERE time > now() - 15m', + 'SELECT :tv1, "f1" FROM "db1"."rp1"."m1" WHERE time > now() - 15m', + 'SELECT "f1" FROM "db1"."rp1"."m1" WHERE time > now() - :tv', + ] + + matchingStrings.forEach(s => { + const result = s.match(TEMPLATE_MATCHER) + expect(result.length).to.be.above(0) + }) + }) + + it('does not match unexpected strings', () => { + const nonMatchingStrings = [ + 'SELECT "foo", "f1" FROM "db1"."rp1"."m1" WHERE time > now() - 15m', + 'SELECT :tv1:, :tv2: FROM "db1"."rp1"."m1" WHERE time > now() - 15m', + ] + + nonMatchingStrings.forEach(s => { + const result = s.match(TEMPLATE_MATCHER) + expect(result).to.equal(null) + }) + }) + + it('only matches when starts with : but does not end in :', () => { + const matchingStrings = [ + 'SELECT :tv1, :tv2: FROM "db1"."rp1"."m1" WHERE time > now() - 15m', + 'SELECT :tv1:, :tv2 FROM "db1"."rp1"."m1" WHERE time > now() - 15m', + ] + + matchingStrings.forEach(s => { + const result = s.match(TEMPLATE_MATCHER) + expect(result.length).to.equal(1) + }) + }) + }) + + describe('replacing', () => { + const tempVar = ':tv1:' + it('can replace the expected strings', () => { + const s = 'SELECT :fasdf FROM "db1"."rp1"."m1"' + const actual = s.replace(TEMPLATE_MATCHER, tempVar) + const expected = `SELECT ${tempVar} FROM "db1"."rp1"."m1"` + + expect(actual).to.equal(expected) + }) + + it('can replace a string with a numeric character', () => { + const s = 'SELECT :fas0df FROM "db1"."rp1"."m1"' + const actual = s.replace(TEMPLATE_MATCHER, tempVar) + const expected = `SELECT ${tempVar} FROM "db1"."rp1"."m1"` + + expect(actual).to.equal(expected) + }) + + it('can replace the expected strings that are next to ,', () => { + const s = 'SELECT :fasdf, "f1" FROM "db1"."rp1"."m1"' + const actual = s.replace(TEMPLATE_MATCHER, tempVar) + const expected = `SELECT ${tempVar}, "f1" FROM "db1"."rp1"."m1"` + + expect(actual).to.equal(expected) + }) + + it('can replace the expected strings that are next to .', () => { + const s = 'SELECT "f1" FROM "db1".:asdf."m1"' + const actual = s.replace(TEMPLATE_MATCHER, tempVar) + const expected = `SELECT "f1" FROM "db1".${tempVar}."m1"` + + expect(actual).to.equal(expected) + }) + + it('can does not replace other tempVars', () => { + const s = 'SELECT :foo: FROM "db1".:asdfasd."m1"' + const actual = s.replace(TEMPLATE_MATCHER, tempVar) + const expected = `SELECT :foo: FROM "db1".${tempVar}."m1"` + + expect(actual).to.equal(expected) + }) + }) +}) diff --git a/ui/src/CheckSources.js b/ui/src/CheckSources.js index f008fd088b..d671c1a2a4 100644 --- a/ui/src/CheckSources.js +++ b/ui/src/CheckSources.js @@ -12,21 +12,53 @@ import {errorThrown as errorThrownAction} from 'shared/actions/errors' // Acts as a 'router middleware'. The main `App` component is responsible for // getting the list of data nodes, but not every page requires them to function. // Routes that do require data nodes can be nested under this component. +const {arrayOf, func, node, shape, string} = PropTypes const CheckSources = React.createClass({ propTypes: { - children: PropTypes.node, - params: PropTypes.shape({ - sourceID: PropTypes.string, + sources: arrayOf( + shape({ + links: shape({ + proxy: string.isRequired, + self: string.isRequired, + kapacitors: string.isRequired, + queries: string.isRequired, + permissions: string.isRequired, + users: string.isRequired, + databases: string.isRequired, + }).isRequired, + }) + ), + children: node, + params: shape({ + sourceID: string, }).isRequired, - router: PropTypes.shape({ - push: PropTypes.func.isRequired, + router: shape({ + push: func.isRequired, }).isRequired, - location: PropTypes.shape({ - pathname: PropTypes.string.isRequired, + location: shape({ + pathname: string.isRequired, }).isRequired, - sources: PropTypes.array.isRequired, - errorThrown: PropTypes.func.isRequired, - loadSources: PropTypes.func.isRequired, + loadSources: func.isRequired, + errorThrown: func.isRequired, + }, + + childContextTypes: { + source: shape({ + links: shape({ + proxy: string.isRequired, + self: string.isRequired, + kapacitors: string.isRequired, + queries: string.isRequired, + permissions: string.isRequired, + users: string.isRequired, + databases: string.isRequired, + }).isRequired, + }), + }, + + getChildContext() { + const {sources, params: {sourceID}} = this.props + return {source: sources.find(s => s.id === sourceID)} }, getInitialState() { @@ -51,8 +83,8 @@ const CheckSources = React.createClass({ async componentWillUpdate(nextProps, nextState) { const {router, location, params, errorThrown, sources} = nextProps const {isFetching} = nextState - const source = sources.find((s) => s.id === params.sourceID) - const defaultSource = sources.find((s) => s.default === true) + const source = sources.find(s => s.id === params.sourceID) + const defaultSource = sources.find(s => s.default === true) if (!isFetching && !source) { const rest = location.pathname.match(/\/sources\/\d+?\/(.+)/) @@ -80,15 +112,21 @@ const CheckSources = React.createClass({ render() { const {params, sources} = this.props const {isFetching} = this.state - const source = sources.find((s) => s.id === params.sourceID) + const source = sources.find(s => s.id === params.sourceID) if (isFetching || !source) { return
} - return this.props.children && React.cloneElement(this.props.children, Object.assign({}, this.props, { - source, - })) + return ( + this.props.children && + React.cloneElement( + this.props.children, + Object.assign({}, this.props, { + source, + }) + ) + ) }, }) @@ -96,9 +134,11 @@ const mapStateToProps = ({sources}) => ({ sources, }) -const mapDispatchToProps = (dispatch) => ({ +const mapDispatchToProps = dispatch => ({ loadSources: bindActionCreators(loadSourcesAction, dispatch), errorThrown: bindActionCreators(errorThrownAction, dispatch), }) -export default connect(mapStateToProps, mapDispatchToProps)(withRouter(CheckSources)) +export default connect(mapStateToProps, mapDispatchToProps)( + withRouter(CheckSources) +) diff --git a/ui/src/admin/containers/AdminPage.js b/ui/src/admin/containers/AdminPage.js index b262253e87..3862705ccf 100644 --- a/ui/src/admin/containers/AdminPage.js +++ b/ui/src/admin/containers/AdminPage.js @@ -157,7 +157,7 @@ class AdminPage extends Component {
-

+

Admin

diff --git a/ui/src/alerts/containers/AlertsApp.js b/ui/src/alerts/containers/AlertsApp.js index 5bd902921e..e3f791541d 100644 --- a/ui/src/alerts/containers/AlertsApp.js +++ b/ui/src/alerts/containers/AlertsApp.js @@ -125,7 +125,7 @@ class AlertsApp extends Component {
-

+

Alert History

diff --git a/ui/src/dashboards/actions/index.js b/ui/src/dashboards/actions/index.js index 355c398d32..d8ee0ed510 100644 --- a/ui/src/dashboards/actions/index.js +++ b/ui/src/dashboards/actions/index.js @@ -12,6 +12,8 @@ import {errorThrown} from 'shared/actions/errors' import {NEW_DEFAULT_DASHBOARD_CELL} from 'src/dashboards/constants' +import {TEMPLATE_VARIABLE_SELECTED} from 'shared/constants/actionTypes' + export const loadDashboards = (dashboards, dashboardID) => ({ type: 'LOAD_DASHBOARDS', payload: { @@ -20,28 +22,28 @@ export const loadDashboards = (dashboards, dashboardID) => ({ }, }) -export const setTimeRange = (timeRange) => ({ +export const setTimeRange = timeRange => ({ type: 'SET_DASHBOARD_TIME_RANGE', payload: { timeRange, }, }) -export const updateDashboard = (dashboard) => ({ +export const updateDashboard = dashboard => ({ type: 'UPDATE_DASHBOARD', payload: { dashboard, }, }) -export const deleteDashboard = (dashboard) => ({ +export const deleteDashboard = dashboard => ({ type: 'DELETE_DASHBOARD', payload: { dashboard, }, }) -export const deleteDashboardFailed = (dashboard) => ({ +export const deleteDashboardFailed = dashboard => ({ type: 'DELETE_DASHBOARD_FAILED', payload: { dashboard, @@ -80,8 +82,8 @@ export const editDashboardCell = (dashboard, x, y, isEditing) => ({ // as a suitable id payload: { dashboard, - x, // x-coord of the cell to be edited - y, // y-coord of the cell to be edited + x, // x-coord of the cell to be edited + y, // y-coord of the cell to be edited isEditing, }, }) @@ -90,15 +92,16 @@ export const renameDashboardCell = (dashboard, x, y, name) => ({ type: 'RENAME_DASHBOARD_CELL', payload: { dashboard, - x, // x-coord of the cell to be renamed - y, // y-coord of the cell to be renamed + x, // x-coord of the cell to be renamed + y, // y-coord of the cell to be renamed name, }, }) -export const deleteDashboardCell = (cell) => ({ +export const deleteDashboardCell = (dashboard, cell) => ({ type: 'DELETE_DASHBOARD_CELL', payload: { + dashboard, cell, }, }) @@ -111,65 +114,84 @@ export const editCellQueryStatus = (queryID, status) => ({ }, }) +export const templateVariableSelected = (dashboardID, templateID, values) => ({ + type: TEMPLATE_VARIABLE_SELECTED, + payload: { + dashboardID, + templateID, + values, + }, +}) + // Async Action Creators -export const getDashboardsAsync = (dashboardID) => async (dispatch) => { +export const getDashboardsAsync = () => async dispatch => { try { const {data: {dashboards}} = await getDashboardsAJAX() - dispatch(loadDashboards(dashboards, dashboardID)) + dispatch(loadDashboards(dashboards)) } catch (error) { - dispatch(errorThrown(error)) console.error(error) - throw error + dispatch(errorThrown(error)) } } -export const putDashboard = (dashboard) => async (dispatch) => { +export const putDashboard = dashboard => async dispatch => { try { const {data} = await updateDashboardAJAX(dashboard) dispatch(updateDashboard(data)) } catch (error) { + console.error(error) dispatch(errorThrown(error)) } } -export const updateDashboardCell = (dashboard, cell) => async (dispatch) => { +export const updateDashboardCell = (dashboard, cell) => async dispatch => { try { const {data} = await updateDashboardCellAJAX(cell) dispatch(syncDashboardCell(dashboard, data)) } catch (error) { + console.error(error) dispatch(errorThrown(error)) } } -export const deleteDashboardAsync = (dashboard) => async (dispatch) => { +export const deleteDashboardAsync = dashboard => async dispatch => { dispatch(deleteDashboard(dashboard)) try { await deleteDashboardAJAX(dashboard) - dispatch(publishAutoDismissingNotification('success', 'Dashboard deleted successfully.')) + dispatch( + publishAutoDismissingNotification( + 'success', + 'Dashboard deleted successfully.' + ) + ) } catch (error) { - dispatch(errorThrown(error, `Failed to delete dashboard: ${error.data.message}.`)) + dispatch( + errorThrown(error, `Failed to delete dashboard: ${error.data.message}.`) + ) dispatch(deleteDashboardFailed(dashboard)) } } -export const addDashboardCellAsync = (dashboard) => async (dispatch) => { +export const addDashboardCellAsync = dashboard => async dispatch => { try { - const {data} = await addDashboardCellAJAX(dashboard, NEW_DEFAULT_DASHBOARD_CELL) + const {data} = await addDashboardCellAJAX( + dashboard, + NEW_DEFAULT_DASHBOARD_CELL + ) dispatch(addDashboardCell(dashboard, data)) } catch (error) { - dispatch(errorThrown(error)) console.error(error) - throw error + dispatch(errorThrown(error)) } } -export const deleteDashboardCellAsync = (cell) => async (dispatch) => { +export const deleteDashboardCellAsync = (dashboard, cell) => async dispatch => { try { await deleteDashboardCellAJAX(cell) - dispatch(deleteDashboardCell(cell)) + dispatch(deleteDashboardCell(dashboard, cell)) } catch (error) { + console.error(error) dispatch(errorThrown(error)) - throw error } } diff --git a/ui/src/dashboards/apis/index.js b/ui/src/dashboards/apis/index.js index baded96042..90db538188 100644 --- a/ui/src/dashboards/apis/index.js +++ b/ui/src/dashboards/apis/index.js @@ -1,4 +1,5 @@ import AJAX from 'utils/ajax' +import {proxy} from 'utils/queryUrlGenerator' export function getDashboards() { return AJAX({ @@ -23,7 +24,7 @@ export function updateDashboardCell(cell) { }) } -export const createDashboard = async (dashboard) => { +export const createDashboard = async dashboard => { try { return await AJAX({ method: 'POST', @@ -36,7 +37,7 @@ export const createDashboard = async (dashboard) => { } } -export const deleteDashboard = async (dashboard) => { +export const deleteDashboard = async dashboard => { try { return await AJAX({ method: 'DELETE', @@ -61,7 +62,7 @@ export const addDashboardCell = async (dashboard, cell) => { } } -export const deleteDashboardCell = async (cell) => { +export const deleteDashboardCell = async cell => { try { return await AJAX({ method: 'DELETE', @@ -72,3 +73,34 @@ export const deleteDashboardCell = async (cell) => { throw error } } + +export const editTemplateVariables = async templateVariable => { + try { + return await AJAX({ + method: 'PUT', + url: templateVariable.links.self, + data: templateVariable, + }) + } catch (error) { + console.error(error) + throw error + } +} + +export const runTemplateVariableQuery = async ( + source, + { + query, + db, + // rp, TODO + tempVars, + } +) => { + try { + // TODO: add rp as argument to proxy + return await proxy({source: source.links.proxy, query, db, tempVars}) + } catch (error) { + console.error(error) + throw error + } +} diff --git a/ui/src/dashboards/components/CellEditorOverlay.js b/ui/src/dashboards/components/CellEditorOverlay.js index 68728b27a9..e21b76ba00 100644 --- a/ui/src/dashboards/components/CellEditorOverlay.js +++ b/ui/src/dashboards/components/CellEditorOverlay.js @@ -131,6 +131,7 @@ class CellEditorOverlay extends Component { const { source, onCancel, + templates, timeRange, autoRefresh, editQueryStatus, @@ -174,6 +175,7 @@ class CellEditorOverlay extends Component { /> { if (dashboard.id === 0) { return null } - const cells = dashboard.cells.map((cell) => { + const {templates} = dashboard + + const cells = dashboard.cells.map(cell => { const dashboardCell = {...cell} - dashboardCell.queries = dashboardCell.queries.map(({label, query, queryConfig, db}) => - ({ + dashboardCell.queries = dashboardCell.queries.map( + ({label, query, queryConfig, db}) => ({ label, query, queryConfig, @@ -38,11 +44,47 @@ const Dashboard = ({ }) return ( -
- {cells.length ? -
- +
+
+ Template Variables +
+
+ {templates.map(({id, values}) => { + const items = values.map(value => ({...value, text: value.value})) + const selectedItem = items.find(item => item.selected) || items[0] + const selectedText = selectedItem && selectedItem.text + + // TODO: change Dropdown to a MultiSelectDropdown, `selected` to + // the full array, and [item] to all `selected` values when we update + // this component to support multiple values + return ( + + onSelectTemplate(id, [item].map(x => omit(x, 'text')))} + /> + ) + })} + +
+
+ {cells.length + ? -
: -
-

This Dashboard has no Graphs

- -
- } + :
+

This Dashboard has no Graphs

+ +
}
) } -const { - bool, - func, - shape, - string, - number, -} = PropTypes +const {arrayOf, bool, func, shape, string, number} = PropTypes Dashboard.propTypes = { dashboard: shape({}).isRequired, - isEditMode: bool, inPresentationMode: bool, onAddCell: func, onPositionChange: func, @@ -94,6 +124,26 @@ Dashboard.propTypes = { }).isRequired, autoRefresh: number.isRequired, timeRange: shape({}).isRequired, + onOpenTemplateManager: func.isRequired, + onSelectTemplate: func.isRequired, + templates: arrayOf( + shape({ + type: string.isRequired, + tempVar: string.isRequired, + query: shape({ + db: string, + rp: string, + influxql: string, + }), + values: arrayOf( + shape({ + type: string.isRequired, + value: string.isRequired, + selected: bool, + }) + ).isRequired, + }) + ), } export default Dashboard diff --git a/ui/src/dashboards/components/DatabaseDropdown.js b/ui/src/dashboards/components/DatabaseDropdown.js new file mode 100644 index 0000000000..4b79bc4b73 --- /dev/null +++ b/ui/src/dashboards/components/DatabaseDropdown.js @@ -0,0 +1,77 @@ +import React, {PropTypes, Component} from 'react' +import Dropdown from 'shared/components/Dropdown' + +import {showDatabases} from 'shared/apis/metaQuery' +import parsers from 'shared/parsing' +const {databases: showDatabasesParser} = parsers + +class DatabaseDropdown extends Component { + constructor(props) { + super(props) + this.state = { + databases: [], + } + + this._getDatabases = ::this._getDatabases + } + + componentDidMount() { + this._getDatabases() + } + + render() { + const {databases} = this.state + const {database, onSelectDatabase, onStartEdit} = this.props + + if (!database) { + this.componentDidMount() + } + + return ( + ({text}))} + selected={database || 'Loading...'} + onChoose={onSelectDatabase} + onClick={() => onStartEdit(null)} + /> + ) + } + + async _getDatabases() { + const {source} = this.context + const {database, onSelectDatabase, onErrorThrown} = this.props + const proxy = source.links.proxy + try { + const {data} = await showDatabases(proxy) + const {databases} = showDatabasesParser(data) + + this.setState({databases}) + const selectedDatabaseText = databases.includes(database) + ? database + : databases[0] || 'No databases' + onSelectDatabase({text: selectedDatabaseText}) + } catch (error) { + console.error(error) + onErrorThrown(error) + } + } +} + +const {func, shape, string} = PropTypes + +DatabaseDropdown.contextTypes = { + source: shape({ + links: shape({ + proxy: string.isRequired, + }).isRequired, + }).isRequired, +} + +DatabaseDropdown.propTypes = { + database: string, + onSelectDatabase: func.isRequired, + onStartEdit: func.isRequired, + onErrorThrown: func.isRequired, +} + +export default DatabaseDropdown diff --git a/ui/src/dashboards/components/MeasurementDropdown.js b/ui/src/dashboards/components/MeasurementDropdown.js new file mode 100644 index 0000000000..a53e8f026c --- /dev/null +++ b/ui/src/dashboards/components/MeasurementDropdown.js @@ -0,0 +1,86 @@ +import React, {PropTypes, Component} from 'react' + +import Dropdown from 'shared/components/Dropdown' +import {showMeasurements} from 'shared/apis/metaQuery' +import parsers from 'shared/parsing' +const {measurements: showMeasurementsParser} = parsers + +class MeasurementDropdown extends Component { + constructor(props) { + super(props) + this.state = { + measurements: [], + } + + this._getMeasurements = ::this._getMeasurements + } + + componentDidMount() { + this._getMeasurements() + } + + componentDidUpdate(nextProps) { + if (nextProps.database === this.props.database) { + return + } + + this._getMeasurements() + } + + render() { + const {measurements} = this.state + const {measurement, onSelectMeasurement, onStartEdit} = this.props + return ( + ({text}))} + selected={measurement || 'Select Measurement'} + onChoose={onSelectMeasurement} + onClick={() => onStartEdit(null)} + /> + ) + } + + async _getMeasurements() { + const {source: {links: {proxy}}} = this.context + const { + measurement, + database, + onSelectMeasurement, + onErrorThrown, + } = this.props + + try { + const {data} = await showMeasurements(proxy, database) + const {measurements} = showMeasurementsParser(data) + + this.setState({measurements}) + const selectedMeasurementText = measurements.includes(measurement) + ? measurement + : measurements[0] || 'No measurements' + onSelectMeasurement({text: selectedMeasurementText}) + } catch (error) { + console.error(error) + onErrorThrown(error) + } + } +} + +const {func, shape, string} = PropTypes + +MeasurementDropdown.contextTypes = { + source: shape({ + links: shape({ + proxy: string.isRequired, + }).isRequired, + }).isRequired, +} + +MeasurementDropdown.propTypes = { + database: string.isRequired, + measurement: string, + onSelectMeasurement: func.isRequired, + onStartEdit: func.isRequired, + onErrorThrown: func.isRequired, +} + +export default MeasurementDropdown diff --git a/ui/src/dashboards/components/TagKeyDropdown.js b/ui/src/dashboards/components/TagKeyDropdown.js new file mode 100644 index 0000000000..e2e42f8549 --- /dev/null +++ b/ui/src/dashboards/components/TagKeyDropdown.js @@ -0,0 +1,91 @@ +import React, {PropTypes, Component} from 'react' + +import Dropdown from 'shared/components/Dropdown' +import {showTagKeys} from 'shared/apis/metaQuery' +import parsers from 'shared/parsing' +const {tagKeys: showTagKeysParser} = parsers + +class TagKeyDropdown extends Component { + constructor(props) { + super(props) + this.state = { + tagKeys: [], + } + + this._getTags = ::this._getTags + } + + componentDidMount() { + this._getTags() + } + + componentDidUpdate(nextProps) { + if ( + nextProps.database === this.props.database && + nextProps.measurement === this.props.measurement + ) { + return + } + + this._getTags() + } + + render() { + const {tagKeys} = this.state + const {tagKey, onSelectTagKey, onStartEdit} = this.props + return ( + ({text}))} + selected={tagKey || 'Select Tag Key'} + onChoose={onSelectTagKey} + onClick={() => onStartEdit(null)} + /> + ) + } + + async _getTags() { + const { + database, + measurement, + tagKey, + onSelectTagKey, + onErrorThrown, + } = this.props + const {source: {links: {proxy}}} = this.context + + try { + const {data} = await showTagKeys({source: proxy, database, measurement}) + const {tagKeys} = showTagKeysParser(data) + + this.setState({tagKeys}) + const selectedTagKeyText = tagKeys.includes(tagKey) + ? tagKey + : tagKeys[0] || 'No tags' + onSelectTagKey({text: selectedTagKeyText}) + } catch (error) { + console.error(error) + onErrorThrown(error) + } + } +} + +const {func, shape, string} = PropTypes + +TagKeyDropdown.contextTypes = { + source: shape({ + links: shape({ + proxy: string.isRequired, + }).isRequired, + }).isRequired, +} + +TagKeyDropdown.propTypes = { + database: string.isRequired, + measurement: string.isRequired, + tagKey: string, + onSelectTagKey: func.isRequired, + onStartEdit: func.isRequired, + onErrorThrown: func.isRequired, +} + +export default TagKeyDropdown diff --git a/ui/src/dashboards/components/TemplateQueryBuilder.js b/ui/src/dashboards/components/TemplateQueryBuilder.js new file mode 100644 index 0000000000..1ad5b86541 --- /dev/null +++ b/ui/src/dashboards/components/TemplateQueryBuilder.js @@ -0,0 +1,111 @@ +import React, {PropTypes} from 'react' +import DatabaseDropdown from 'src/dashboards/components/DatabaseDropdown' +import MeasurementDropdown from 'src/dashboards/components/MeasurementDropdown' +import TagKeyDropdown from 'src/dashboards/components/TagKeyDropdown' + +const TemplateQueryBuilder = ({ + selectedType, + selectedDatabase, + selectedMeasurement, + selectedTagKey, + onSelectDatabase, + onSelectMeasurement, + onSelectTagKey, + onStartEdit, + onErrorThrown, +}) => { + switch (selectedType) { + case 'csv': + return
Enter values below
+ case 'databases': + return
SHOW DATABASES
+ case 'measurements': + return ( +
+ SHOW MEASUREMENTS ON + +
+ ) + case 'fieldKeys': + case 'tagKeys': + return ( +
+ + SHOW {selectedType === 'fieldKeys' ? 'FIELD' : 'TAG'} KEYS ON + + + FROM + {selectedDatabase + ? + :
No database selected
} +
+ ) + case 'tagValues': + return ( +
+ SHOW TAG VALUES ON + + FROM + {selectedDatabase + ? + : 'Pick a DB'} + WITH KEY = + {selectedMeasurement + ? + : 'Pick a Tag Key'} +
+ ) + default: + return
n/a
+ } +} + +const {func, string} = PropTypes + +TemplateQueryBuilder.propTypes = { + selectedType: string.isRequired, + onSelectDatabase: func.isRequired, + onSelectMeasurement: func.isRequired, + onSelectTagKey: func.isRequired, + onStartEdit: func.isRequired, + selectedMeasurement: string, + selectedDatabase: string, + selectedTagKey: string, + onErrorThrown: func.isRequired, +} + +export default TemplateQueryBuilder diff --git a/ui/src/dashboards/components/TemplateVariableManager.js b/ui/src/dashboards/components/TemplateVariableManager.js new file mode 100644 index 0000000000..79dd0c32d9 --- /dev/null +++ b/ui/src/dashboards/components/TemplateVariableManager.js @@ -0,0 +1,243 @@ +import React, {Component, PropTypes} from 'react' +import classNames from 'classnames' +import uuid from 'node-uuid' + +import TemplateVariableTable + from 'src/dashboards/components/TemplateVariableTable' + +import {TEMPLATE_VARIABLE_TYPES} from 'src/dashboards/constants' + +const TemplateVariableManager = ({ + onClose, + onEditTemplateVariables, + source, + templates, + onRunQuerySuccess, + onRunQueryFailure, + onSaveTemplatesSuccess, + onAddVariable, + onDelete, + tempVarAlreadyExists, + isEdited, +}) => ( +
+
+
+

Template Variables

+
+
+ + + onClose(isEdited)} + /> +
+
+
+ +
+
+) + +class TemplateVariableManagerWrapper extends Component { + constructor(props) { + super(props) + + this.state = { + rows: this.props.templates, + isEdited: false, + } + + this.onRunQuerySuccess = ::this.onRunQuerySuccess + this.onSaveTemplatesSuccess = ::this.onSaveTemplatesSuccess + this.onAddVariable = ::this.onAddVariable + this.onDeleteTemplateVariable = ::this.onDeleteTemplateVariable + this.tempVarAlreadyExists = ::this.tempVarAlreadyExists + } + + onAddVariable() { + const {rows} = this.state + + const newRow = { + tempVar: '', + values: [], + id: uuid.v4(), + type: 'csv', + query: { + influxql: '', + db: '', + // rp, TODO + measurement: '', + tagKey: '', + }, + isNew: true, + } + + const newRows = [newRow, ...rows] + + this.setState({rows: newRows}) + } + + onRunQuerySuccess(template, queryConfig, parsedData, tempVar) { + const {rows} = this.state + const {id, links} = template + const { + type, + query: influxql, + database: db, + measurement, + tagKey, + } = queryConfig + + // Determine which is the selectedValue, if any + const currentRow = rows.find(row => row.id === id) + + let selectedValue + if (currentRow && currentRow.values && currentRow.values.length) { + const matchedValue = currentRow.values.find(val => val.selected) + if (matchedValue) { + selectedValue = matchedValue.value + } + } + + if ( + !selectedValue && + currentRow && + currentRow.values && + currentRow.values.length + ) { + selectedValue = currentRow.values[0].value + } + + if (!selectedValue) { + selectedValue = parsedData[0] + } + + const values = parsedData.map(value => ({ + value, + type: TEMPLATE_VARIABLE_TYPES[type], + selected: selectedValue === value, + })) + + const templateVariable = { + tempVar, + values, + id, + type, + query: { + influxql, + db, + // rp, TODO + measurement, + tagKey, + }, + links, + } + + const newRows = rows.map(r => (r.id === template.id ? templateVariable : r)) + + this.setState({rows: newRows, isEdited: true}) + } + + onSaveTemplatesSuccess() { + const {rows} = this.state + + const newRows = rows.map(row => ({...row, isNew: false})) + + this.setState({rows: newRows, isEdited: false}) + } + + onDeleteTemplateVariable(templateID) { + const {rows} = this.state + + const newRows = rows.filter(({id}) => id !== templateID) + + this.setState({rows: newRows, isEdited: true}) + } + + tempVarAlreadyExists(testTempVar, testID) { + const {rows: tempVars} = this.state + return tempVars.some( + ({tempVar, id}) => tempVar === testTempVar && id !== testID + ) + } + + render() { + const {rows, isEdited} = this.state + return ( + + ) + } +} + +const {arrayOf, bool, func, shape, string} = PropTypes + +TemplateVariableManager.propTypes = { + ...TemplateVariableManagerWrapper.propTypes, + onRunQuerySuccess: func.isRequired, + onSaveTemplatesSuccess: func.isRequired, + onAddVariable: func.isRequired, + isEdited: bool.isRequired, + onDelete: func.isRequired, +} + +TemplateVariableManagerWrapper.propTypes = { + onClose: func.isRequired, + onEditTemplateVariables: func.isRequired, + source: shape({ + links: shape({ + proxy: string, + }), + }).isRequired, + templates: arrayOf( + shape({ + type: string.isRequired, + tempVar: string.isRequired, + query: shape({ + db: string, + influxql: string, + }), + values: arrayOf( + shape({ + value: string.isRequired, + type: string.isRequired, + selected: bool.isRequired, + }) + ).isRequired, + }) + ), + onRunQueryFailure: func.isRequired, +} + +export default TemplateVariableManagerWrapper diff --git a/ui/src/dashboards/components/TemplateVariableRow.js b/ui/src/dashboards/components/TemplateVariableRow.js new file mode 100644 index 0000000000..edb4f65831 --- /dev/null +++ b/ui/src/dashboards/components/TemplateVariableRow.js @@ -0,0 +1,484 @@ +import React, {PropTypes, Component} from 'react' +import {connect} from 'react-redux' +import {bindActionCreators} from 'redux' + +import OnClickOutside from 'react-onclickoutside' +import classNames from 'classnames' + +import Dropdown from 'shared/components/Dropdown' +import DeleteConfirmButtons from 'shared/components/DeleteConfirmButtons' +import TemplateQueryBuilder + from 'src/dashboards/components/TemplateQueryBuilder' + +import { + runTemplateVariableQuery as runTemplateVariableQueryAJAX, +} from 'src/dashboards/apis' + +import parsers from 'shared/parsing' + +import {TEMPLATE_TYPES} from 'src/dashboards/constants' +import generateTemplateVariableQuery + from 'src/dashboards/utils/templateVariableQueryGenerator' + +import {errorThrown as errorThrownAction} from 'shared/actions/errors' +import {publishAutoDismissingNotification} from 'shared/dispatchers' + +const RowValues = ({ + selectedType, + values = [], + isEditing, + onStartEdit, + autoFocusTarget, +}) => { + const _values = values.map(({value}) => value).join(', ') + + if (selectedType === 'csv') { + return ( + + ) + } + return ( +
+ {values.length ? _values : 'No values to display'} +
+ ) +} + +const RowButtons = ({ + onStartEdit, + isEditing, + onCancelEdit, + onDelete, + id, + selectedType, +}) => { + if (isEditing) { + return ( +
+ + +
+ ) + } + return ( +
+ onDelete(id)} /> + +
+ ) +} + +const TemplateVariableRow = ({ + template: {id, tempVar, values}, + isEditing, + selectedType, + selectedDatabase, + selectedMeasurement, + onSelectType, + onSelectDatabase, + onSelectMeasurement, + selectedTagKey, + onSelectTagKey, + onStartEdit, + onCancelEdit, + autoFocusTarget, + onSubmit, + onDelete, + onErrorThrown, +}) => ( +
+
+ +
+
+ onStartEdit(null)} + selected={TEMPLATE_TYPES.find(t => t.type === selectedType).text} + className="dropdown-140" + /> +
+
+ + +
+
+ +
+
+) + +const TableInput = ({ + name, + defaultValue, + isEditing, + onStartEdit, + autoFocusTarget, +}) => { + return isEditing + ?
+ +
+ :
onStartEdit(name)}> +
{defaultValue}
+
+} + +class RowWrapper extends Component { + constructor(props) { + super(props) + const {template: {type, query, isNew}} = this.props + + this.state = { + isEditing: !!isNew, + isNew: !!isNew, + hasBeenSavedToComponentStateOnce: !isNew, + selectedType: type, + selectedDatabase: query && query.db, + selectedMeasurement: query && query.measurement, + selectedTagKey: query && query.tagKey, + autoFocusTarget: 'tempVar', + } + + this.handleSubmit = ::this.handleSubmit + this.handleSelectType = ::this.handleSelectType + this.handleSelectDatabase = ::this.handleSelectDatabase + this.handleSelectMeasurement = ::this.handleSelectMeasurement + this.handleSelectTagKey = ::this.handleSelectTagKey + this.handleStartEdit = ::this.handleStartEdit + this.handleCancelEdit = ::this.handleCancelEdit + this.runTemplateVariableQuery = ::this.runTemplateVariableQuery + } + + handleSubmit({ + selectedDatabase: database, + selectedMeasurement: measurement, + selectedTagKey: tagKey, + selectedType: type, + }) { + return async e => { + e.preventDefault() + + const { + source, + template, + template: {id}, + onRunQuerySuccess, + onRunQueryFailure, + tempVarAlreadyExists, + notify, + } = this.props + + const _tempVar = e.target.tempVar.value.replace(/\u003a/g, '') + const tempVar = `\u003a${_tempVar}\u003a` // add ':'s + + if (tempVarAlreadyExists(tempVar, id)) { + return notify( + 'error', + `Variable '${_tempVar}' already exists. Please enter a new value.` + ) + } + + this.setState({ + isEditing: false, + hasBeenSavedToComponentStateOnce: true, + }) + + const {query, tempVars} = generateTemplateVariableQuery({ + type, + tempVar, + query: { + database, + // rp, TODO + measurement, + tagKey, + }, + }) + + const queryConfig = { + type, + tempVars, + query, + database, + // rp: TODO + measurement, + tagKey, + } + + try { + let parsedData + if (type === 'csv') { + parsedData = e.target.values.value + .split(',') + .map(value => value.trim()) + } else { + parsedData = await this.runTemplateVariableQuery(source, queryConfig) + } + onRunQuerySuccess(template, queryConfig, parsedData, tempVar) + } catch (error) { + onRunQueryFailure(error) + } + } + } + + handleClickOutside() { + this.setState({isEditing: false}) + } + + handleStartEdit(name) { + this.setState({isEditing: true, autoFocusTarget: name}) + } + + handleCancelEdit() { + const { + template: {type, query: {db, measurement, tagKey}, id}, + onDelete, + } = this.props + const {hasBeenSavedToComponentStateOnce} = this.state + + if (!hasBeenSavedToComponentStateOnce) { + return onDelete(id) + } + this.setState({ + selectedType: type, + selectedDatabase: db, + selectedMeasurement: measurement, + selectedTagKey: tagKey, + isEditing: false, + }) + } + + handleSelectType(item) { + this.setState({ + selectedType: item.type, + selectedDatabase: null, + selectedMeasurement: null, + selectedTagKey: null, + }) + } + + handleSelectDatabase(item) { + this.setState({selectedDatabase: item.text}) + } + + handleSelectMeasurement(item) { + this.setState({selectedMeasurement: item.text}) + } + + handleSelectTagKey(item) { + this.setState({selectedTagKey: item.text}) + } + + async runTemplateVariableQuery( + source, + {query, database, rp, tempVars, type, measurement, tagKey} + ) { + try { + const {data} = await runTemplateVariableQueryAJAX(source, { + query, + db: database, + rp, + tempVars, + }) + const parsedData = parsers[type](data, tagKey || measurement) // tagKey covers tagKey and fieldKey + if (parsedData.errors.length) { + throw parsedData.errors + } + + return parsedData[type] + } catch (error) { + console.error(error) + throw error + } + } + + render() { + const { + isEditing, + selectedType, + selectedDatabase, + selectedMeasurement, + selectedTagKey, + autoFocusTarget, + } = this.state + + return ( + + ) + } +} + +const {arrayOf, bool, func, shape, string} = PropTypes + +RowWrapper.propTypes = { + source: shape({ + links: shape({ + proxy: string, + }), + }).isRequired, + template: shape({ + type: string.isRequired, + tempVar: string.isRequired, + query: shape({ + db: string, + influxql: string, + measurement: string, + tagKey: string, + }), + values: arrayOf( + shape({ + value: string.isRequired, + type: string.isRequired, + selected: bool.isRequired, + }) + ).isRequired, + links: shape({ + self: string.isRequired, + }), + }), + onRunQuerySuccess: func.isRequired, + onRunQueryFailure: func.isRequired, + onDelete: func.isRequired, + tempVarAlreadyExists: func.isRequired, + notify: func.isRequired, +} + +TemplateVariableRow.propTypes = { + ...RowWrapper.propTypes, + selectedType: string.isRequired, + selectedDatabase: string, + selectedTagKey: string, + onSelectType: func.isRequired, + onSelectDatabase: func.isRequired, + onSelectTagKey: func.isRequired, + onStartEdit: func.isRequired, + onCancelEdit: func.isRequired, + onSubmit: func.isRequired, + onErrorThrown: func.isRequired, +} + +TableInput.propTypes = { + defaultValue: string, + isEditing: bool.isRequired, + onStartEdit: func.isRequired, + name: string.isRequired, + autoFocusTarget: string, +} + +RowValues.propTypes = { + selectedType: string.isRequired, + values: arrayOf(shape()), + isEditing: bool.isRequired, + onStartEdit: func.isRequired, + autoFocusTarget: string, +} + +RowButtons.propTypes = { + onStartEdit: func.isRequired, + isEditing: bool.isRequired, + onCancelEdit: func.isRequired, + onDelete: func.isRequired, + id: string.isRequired, + selectedType: string.isRequired, +} + +const mapDispatchToProps = dispatch => ({ + onErrorThrown: bindActionCreators(errorThrownAction, dispatch), + notify: bindActionCreators(publishAutoDismissingNotification, dispatch), +}) + +export default connect(null, mapDispatchToProps)(OnClickOutside(RowWrapper)) diff --git a/ui/src/dashboards/components/TemplateVariableTable.js b/ui/src/dashboards/components/TemplateVariableTable.js new file mode 100644 index 0000000000..c6f89b602d --- /dev/null +++ b/ui/src/dashboards/components/TemplateVariableTable.js @@ -0,0 +1,76 @@ +import React, {PropTypes} from 'react' + +import TemplateVariableRow from 'src/dashboards/components/TemplateVariableRow' + +const TemplateVariableTable = ({ + source, + templates, + onRunQuerySuccess, + onRunQueryFailure, + onDelete, + tempVarAlreadyExists, +}) => ( +
+ {templates.length + ?
+
+
Variable
+
Type
+
Definition / Values
+
+
+
+ {templates.map(t => ( + + ))} +
+
+ :
+

You have no Template Variables, why not create one?

+
+ } +
+) + +const {arrayOf, bool, func, shape, string} = PropTypes + +TemplateVariableTable.propTypes = { + source: shape({ + links: shape({ + proxy: string, + }), + }).isRequired, + templates: arrayOf( + shape({ + type: string.isRequired, + tempVar: string.isRequired, + query: shape({ + db: string, + influxql: string, + measurement: string, + tagKey: string, + }), + values: arrayOf( + shape({ + value: string.isRequired, + type: string.isRequired, + selected: bool.isRequired, + }) + ).isRequired, + }) + ), + onRunQuerySuccess: func.isRequired, + onRunQueryFailure: func.isRequired, + onDelete: func.isRequired, + tempVarAlreadyExists: func.isRequired, +} + +export default TemplateVariableTable diff --git a/ui/src/dashboards/constants/index.js b/ui/src/dashboards/constants/index.js index 8ede7d3c04..5cd24df3e8 100644 --- a/ui/src/dashboards/constants/index.js +++ b/ui/src/dashboards/constants/index.js @@ -26,3 +26,49 @@ export const NEW_DASHBOARD = { name: 'Name This Dashboard', cells: [NEW_DEFAULT_DASHBOARD_CELL], } + +export const TEMPLATE_TYPES = [ + { + text: 'CSV', + type: 'csv', + }, + { + text: 'Databases', + type: 'databases', + }, + { + text: 'Measurements', + type: 'measurements', + }, + { + text: 'Field Keys', + type: 'fieldKeys', + }, + { + text: 'Tag Keys', + type: 'tagKeys', + }, + { + text: 'Tag Values', + type: 'tagValues', + }, +] + +export const TEMPLATE_VARIABLE_TYPES = { + csv: 'csv', + databases: 'database', + measurements: 'measurement', + fieldKeys: 'fieldKey', + tagKeys: 'tagKey', + tagValues: 'tagValue', +} + +export const TEMPLATE_VARIABLE_QUERIES = { + databases: 'SHOW DATABASES', + measurements: 'SHOW MEASUREMENTS ON :database:', + fieldKeys: 'SHOW FIELD KEYS ON :database: FROM :measurement:', + tagKeys: 'SHOW TAG KEYS ON :database: FROM :measurement:', + tagValues: 'SHOW TAG VALUES ON :database: FROM :measurement: WITH KEY=:tagKey:', +} + +export const TEMPLATE_MATCHER = /\B:\B|:\w+\b(?!:)/g diff --git a/ui/src/dashboards/containers/DashboardPage.js b/ui/src/dashboards/containers/DashboardPage.js index 46d7e987b0..d0fd7c7810 100644 --- a/ui/src/dashboards/containers/DashboardPage.js +++ b/ui/src/dashboards/containers/DashboardPage.js @@ -1,85 +1,54 @@ -import React, {PropTypes} from 'react' +import React, {PropTypes, Component} from 'react' import {Link} from 'react-router' import {connect} from 'react-redux' import {bindActionCreators} from 'redux' +import OverlayTechnologies from 'src/shared/components/OverlayTechnologies' import CellEditorOverlay from 'src/dashboards/components/CellEditorOverlay' import DashboardHeader from 'src/dashboards/components/DashboardHeader' import DashboardHeaderEdit from 'src/dashboards/components/DashboardHeaderEdit' import Dashboard from 'src/dashboards/components/Dashboard' +import TemplateVariableManager + from 'src/dashboards/components/TemplateVariableManager' + +import {errorThrown as errorThrownAction} from 'shared/actions/errors' import * as dashboardActionCreators from 'src/dashboards/actions' import {setAutoRefresh} from 'shared/actions/app' import {presentationButtonDispatcher} from 'shared/dispatchers' -const { - arrayOf, - bool, - func, - number, - shape, - string, -} = PropTypes +class DashboardPage extends Component { + constructor(props) { + super(props) -const DashboardPage = React.createClass({ - propTypes: { - source: shape({ - links: shape({ - proxy: string, - self: string, - }), - }), - params: shape({ - sourceID: string.isRequired, - dashboardID: string.isRequired, - }).isRequired, - location: shape({ - pathname: string.isRequired, - }).isRequired, - dashboardActions: shape({ - putDashboard: func.isRequired, - getDashboardsAsync: func.isRequired, - setTimeRange: func.isRequired, - addDashboardCellAsync: func.isRequired, - editDashboardCell: func.isRequired, - renameDashboardCell: func.isRequired, - editQueryStatus: func, - }).isRequired, - dashboards: arrayOf(shape({ - id: number.isRequired, - cells: arrayOf(shape({})).isRequired, - })), - handleChooseAutoRefresh: func.isRequired, - autoRefresh: number.isRequired, - timeRange: shape({}).isRequired, - inPresentationMode: bool.isRequired, - handleClickPresentationButton: func, - cellQueryStatus: shape({ - queryID: string, - status: shape(), - }).isRequired, - }, - - childContextTypes: { - source: shape({ - links: shape({ - proxy: string.isRequired, - self: string.isRequired, - }).isRequired, - }).isRequired, - }, - - getChildContext() { - return {source: this.props.source} - }, - - getInitialState() { - return { + this.state = { selectedCell: null, isEditMode: false, + isTemplating: false, } - }, + + this.handleAddCell = ::this.handleAddCell + this.handleEditDashboard = ::this.handleEditDashboard + this.handleSaveEditedCell = ::this.handleSaveEditedCell + this.handleDismissOverlay = ::this.handleDismissOverlay + this.handleUpdatePosition = ::this.handleUpdatePosition + this.handleChooseTimeRange = ::this.handleChooseTimeRange + this.handleRenameDashboard = ::this.handleRenameDashboard + this.handleEditDashboardCell = ::this.handleEditDashboardCell + this.handleCancelEditDashboard = ::this.handleCancelEditDashboard + this.handleDeleteDashboardCell = ::this.handleDeleteDashboardCell + this.handleOpenTemplateManager = ::this.handleOpenTemplateManager + this.handleRenameDashboardCell = ::this.handleRenameDashboardCell + this.handleUpdateDashboardCell = ::this.handleUpdateDashboardCell + this.handleCloseTemplateManager = ::this.handleCloseTemplateManager + this.handleSummonOverlayTechnologies = ::this + .handleSummonOverlayTechnologies + this.handleRunTemplateVariableQuery = ::this.handleRunTemplateVariableQuery + this.handleSelectTemplate = ::this.handleSelectTemplate + this.handleEditTemplateVariables = ::this.handleEditTemplateVariables + this.handleRunQueryFailure = ::this.handleRunQueryFailure + } componentDidMount() { const { @@ -88,78 +57,159 @@ const DashboardPage = React.createClass({ } = this.props getDashboardsAsync(dashboardID) - }, + } + + handleOpenTemplateManager() { + this.setState({isTemplating: true}) + } + + handleCloseTemplateManager(isEdited) { + if ( + !isEdited || + (isEdited && confirm('Do you want to close without saving?')) // eslint-disable-line no-alert + ) { + this.setState({isTemplating: false}) + } + } handleDismissOverlay() { this.setState({selectedCell: null}) - }, + } handleSaveEditedCell(newCell) { - this.props.dashboardActions.updateDashboardCell(this.getActiveDashboard(), newCell) - .then(this.handleDismissOverlay) - }, + this.props.dashboardActions + .updateDashboardCell(this.getActiveDashboard(), newCell) + .then(this.handleDismissOverlay) + } handleSummonOverlayTechnologies(cell) { this.setState({selectedCell: cell}) - }, + } handleChooseTimeRange({lower}) { this.props.dashboardActions.setTimeRange({lower, upper: null}) - }, + } handleUpdatePosition(cells) { const newDashboard = {...this.getActiveDashboard(), cells} this.props.dashboardActions.updateDashboard(newDashboard) this.props.dashboardActions.putDashboard(newDashboard) - }, + } handleAddCell() { this.props.dashboardActions.addDashboardCellAsync(this.getActiveDashboard()) - }, + } handleEditDashboard() { this.setState({isEditMode: true}) - }, + } handleCancelEditDashboard() { this.setState({isEditMode: false}) - }, + } handleRenameDashboard(name) { this.setState({isEditMode: false}) const newDashboard = {...this.getActiveDashboard(), name} this.props.dashboardActions.updateDashboard(newDashboard) this.props.dashboardActions.putDashboard(newDashboard) - }, + } // Places cell into editing mode. handleEditDashboardCell(x, y, isEditing) { return () => { - this.props.dashboardActions.editDashboardCell(this.getActiveDashboard(), x, y, !isEditing) /* eslint-disable no-negated-condition */ + this.props.dashboardActions.editDashboardCell( + this.getActiveDashboard(), + x, + y, + !isEditing + ) /* eslint-disable no-negated-condition */ } - }, + } handleRenameDashboardCell(x, y) { - return (evt) => { - this.props.dashboardActions.renameDashboardCell(this.getActiveDashboard(), x, y, evt.target.value) + return evt => { + this.props.dashboardActions.renameDashboardCell( + this.getActiveDashboard(), + x, + y, + evt.target.value + ) } - }, + } handleUpdateDashboardCell(newCell) { return () => { - this.props.dashboardActions.editDashboardCell(this.getActiveDashboard(), newCell.x, newCell.y, false) + this.props.dashboardActions.editDashboardCell( + this.getActiveDashboard(), + newCell.x, + newCell.y, + false + ) this.props.dashboardActions.putDashboard(this.getActiveDashboard()) } - }, + } handleDeleteDashboardCell(cell) { - this.props.dashboardActions.deleteDashboardCellAsync(cell) - }, + const dashboard = this.getActiveDashboard() + this.props.dashboardActions.deleteDashboardCellAsync(dashboard, cell) + } + + handleSelectTemplate(templateID, values) { + const {params: {dashboardID}} = this.props + this.props.dashboardActions.templateVariableSelected( + +dashboardID, + templateID, + values + ) + } + + handleRunTemplateVariableQuery( + templateVariable, + {query, db, tempVars, type, tagKey, measurement} + ) { + const {source} = this.props + this.props.dashboardActions.runTemplateVariableQueryAsync( + templateVariable, + { + source, + query, + db, + // rp, TODO + tempVars, + type, + tagKey, + measurement, + } + ) + } + + handleEditTemplateVariables(templates, onSaveTemplatesSuccess) { + return async () => { + const {params: {dashboardID}, dashboards} = this.props + const currentDashboard = dashboards.find(({id}) => id === +dashboardID) + + try { + await this.props.dashboardActions.putDashboard({ + ...currentDashboard, + templates, + }) + onSaveTemplatesSuccess() + } catch (error) { + console.error(error) + } + } + } + + handleRunQueryFailure(error) { + console.error(error) + this.props.errorThrown(error) + } getActiveDashboard() { const {params: {dashboardID}, dashboards} = this.props return dashboards.find(d => d.id === +dashboardID) - }, + } render() { const { @@ -177,35 +227,41 @@ const DashboardPage = React.createClass({ const dashboard = dashboards.find(d => d.id === +dashboardID) - const { - selectedCell, - isEditMode, - } = this.state + const {selectedCell, isEditMode, isTemplating} = this.state return (
- { - selectedCell ? - + + + : null} + {selectedCell + ? : - null - } - { - isEditMode ? - + : null} + {isEditMode + ? : - + : - { - dashboards ? - dashboards.map((d, i) => { - return ( + {dashboards + ? dashboards.map((d, i) => (
  • - + {d.name}
  • - ) - }) : - null - } -
    - } - { - dashboard ? - : - null - } + )) + : null} +
    } + {dashboard + ? + : null}
    ) - }, -}) + } +} -const mapStateToProps = (state) => { +const {arrayOf, bool, func, number, shape, string} = PropTypes + +DashboardPage.propTypes = { + source: shape({ + links: shape({ + proxy: string, + self: string, + }), + }).isRequired, + params: shape({ + sourceID: string.isRequired, + dashboardID: string.isRequired, + }).isRequired, + location: shape({ + pathname: string.isRequired, + }).isRequired, + dashboardActions: shape({ + putDashboard: func.isRequired, + getDashboardsAsync: func.isRequired, + setTimeRange: func.isRequired, + addDashboardCellAsync: func.isRequired, + editDashboardCell: func.isRequired, + renameDashboardCell: func.isRequired, + }).isRequired, + dashboards: arrayOf( + shape({ + id: number.isRequired, + cells: arrayOf(shape({})).isRequired, + templates: arrayOf( + shape({ + type: string.isRequired, + tempVar: string.isRequired, + query: shape({ + db: string, + rp: string, + influxql: string, + }), + values: arrayOf( + shape({ + value: string.isRequired, + selected: bool.isRequired, + type: string.isRequired, + }) + ), + }) + ), + }) + ), + handleChooseAutoRefresh: func.isRequired, + autoRefresh: number.isRequired, + timeRange: shape({}).isRequired, + inPresentationMode: bool.isRequired, + handleClickPresentationButton: func, + cellQueryStatus: shape({ + queryID: string, + status: shape(), + }).isRequired, + errorThrown: func, +} + +const mapStateToProps = state => { const { - app: { - ephemeral: {inPresentationMode}, - persisted: {autoRefresh}, - }, - dashboardUI: { - dashboards, - timeRange, - cellQueryStatus, - }, + app: {ephemeral: {inPresentationMode}, persisted: {autoRefresh}}, + dashboardUI: {dashboards, timeRange, cellQueryStatus}, } = state return { @@ -279,10 +386,11 @@ const mapStateToProps = (state) => { } } -const mapDispatchToProps = (dispatch) => ({ +const mapDispatchToProps = dispatch => ({ handleChooseAutoRefresh: bindActionCreators(setAutoRefresh, dispatch), handleClickPresentationButton: presentationButtonDispatcher(dispatch), dashboardActions: bindActionCreators(dashboardActionCreators, dispatch), + errorThrown: bindActionCreators(errorThrownAction, dispatch), }) export default connect(mapStateToProps, mapDispatchToProps)(DashboardPage) diff --git a/ui/src/dashboards/reducers/ui.js b/ui/src/dashboards/reducers/ui.js index 48a96dba80..4ba21390cf 100644 --- a/ui/src/dashboards/reducers/ui.js +++ b/ui/src/dashboards/reducers/ui.js @@ -10,6 +10,8 @@ const initialState = { cellQueryStatus: {queryID: null, status: null}, } +import {TEMPLATE_VARIABLE_SELECTED} from 'shared/constants/actionTypes' + export default function ui(state = initialState, action) { switch (action.type) { case 'LOAD_DASHBOARDS': { @@ -30,8 +32,9 @@ export default function ui(state = initialState, action) { case 'UPDATE_DASHBOARD': { const {dashboard} = action.payload const newState = { - dashboard, - dashboards: state.dashboards.map((d) => d.id === dashboard.id ? dashboard : d), + dashboards: state.dashboards.map( + d => (d.id === dashboard.id ? dashboard : d) + ), } return {...state, ...newState} @@ -40,7 +43,7 @@ export default function ui(state = initialState, action) { case 'DELETE_DASHBOARD': { const {dashboard} = action.payload const newState = { - dashboards: state.dashboards.filter((d) => d.id !== dashboard.id), + dashboards: state.dashboards.filter(d => d.id !== dashboard.id), } return {...state, ...newState} @@ -49,10 +52,7 @@ export default function ui(state = initialState, action) { case 'DELETE_DASHBOARD_FAILED': { const {dashboard} = action.payload const newState = { - dashboards: [ - _.cloneDeep(dashboard), - ...state.dashboards, - ], + dashboards: [_.cloneDeep(dashboard), ...state.dashboards], } return {...state, ...newState} } @@ -66,7 +66,9 @@ export default function ui(state = initialState, action) { } const newState = { - dashboards: state.dashboards.map((d) => d.id === dashboard.id ? newDashboard : d), + dashboards: state.dashboards.map( + d => (d.id === dashboard.id ? newDashboard : d) + ), } return {...state, ...newState} @@ -78,7 +80,9 @@ export default function ui(state = initialState, action) { const newCells = [cell, ...dashboard.cells] const newDashboard = {...dashboard, cells: newCells} - const newDashboards = dashboards.map((d) => d.id === dashboard.id ? newDashboard : d) + const newDashboards = dashboards.map( + d => (d.id === dashboard.id ? newDashboard : d) + ) const newState = {dashboards: newDashboards} return {...state, ...newState} @@ -87,7 +91,7 @@ export default function ui(state = initialState, action) { case 'EDIT_DASHBOARD_CELL': { const {x, y, isEditing, dashboard} = action.payload - const cell = dashboard.cells.find((c) => c.x === x && c.y === y) + const cell = dashboard.cells.find(c => c.x === x && c.y === y) const newCell = { ...cell, @@ -96,27 +100,32 @@ export default function ui(state = initialState, action) { const newDashboard = { ...dashboard, - cells: dashboard.cells.map((c) => c.x === x && c.y === y ? newCell : c), + cells: dashboard.cells.map(c => (c.x === x && c.y === y ? newCell : c)), } const newState = { - dashboards: state.dashboards.map((d) => d.id === dashboard.id ? newDashboard : d), + dashboards: state.dashboards.map( + d => (d.id === dashboard.id ? newDashboard : d) + ), } return {...state, ...newState} } case 'DELETE_DASHBOARD_CELL': { - const {cell} = action.payload - const {dashboard} = state + const {dashboard, cell} = action.payload - const newCells = dashboard.cells.filter((c) => !(c.x === cell.x && c.y === cell.y)) + const newCells = dashboard.cells.filter( + c => !(c.x === cell.x && c.y === cell.y) + ) const newDashboard = { ...dashboard, cells: newCells, } const newState = { - dashboards: state.dashboards.map((d) => d.id === dashboard.id ? newDashboard : d), + dashboards: state.dashboards.map( + d => (d.id === dashboard.id ? newDashboard : d) + ), } return {...state, ...newState} @@ -127,11 +136,15 @@ export default function ui(state = initialState, action) { const newDashboard = { ...dashboard, - cells: dashboard.cells.map((c) => c.x === cell.x && c.y === cell.y ? cell : c), + cells: dashboard.cells.map( + c => (c.x === cell.x && c.y === cell.y ? cell : c) + ), } const newState = { - dashboards: state.dashboards.map((d) => d.id === dashboard.id ? newDashboard : d), + dashboards: state.dashboards.map( + d => (d.id === dashboard.id ? newDashboard : d) + ), } return {...state, ...newState} @@ -140,7 +153,7 @@ export default function ui(state = initialState, action) { case 'RENAME_DASHBOARD_CELL': { const {x, y, name, dashboard} = action.payload - const cell = dashboard.cells.find((c) => c.x === x && c.y === y) + const cell = dashboard.cells.find(c => c.x === x && c.y === y) const newCell = { ...cell, @@ -149,11 +162,13 @@ export default function ui(state = initialState, action) { const newDashboard = { ...dashboard, - cells: dashboard.cells.map((c) => c.x === x && c.y === y ? newCell : c), + cells: dashboard.cells.map(c => (c.x === x && c.y === y ? newCell : c)), } const newState = { - dashboards: state.dashboards.map((d) => d.id === dashboard.id ? newDashboard : d), + dashboards: state.dashboards.map( + d => (d.id === dashboard.id ? newDashboard : d) + ), } return {...state, ...newState} @@ -164,6 +179,37 @@ export default function ui(state = initialState, action) { return {...state, cellQueryStatus: {queryID, status}} } + + case TEMPLATE_VARIABLE_SELECTED: { + const { + dashboardID, + templateID, + values: updatedSelectedValues, + } = action.payload + const newDashboards = state.dashboards.map(dashboard => { + if (dashboard.id === dashboardID) { + const newTemplates = dashboard.templates.map(staleTemplate => { + if (staleTemplate.id === templateID) { + const newValues = staleTemplate.values.map(staleValue => { + let selected = false + for (let i = 0; i < updatedSelectedValues.length; i++) { + if (updatedSelectedValues[i].value === staleValue.value) { + selected = true + break + } + } + return {...staleValue, selected} + }) + return {...staleTemplate, values: newValues} + } + return staleTemplate + }) + return {...dashboard, templates: newTemplates} + } + return dashboard + }) + return {...state, dashboards: newDashboards} + } } return state diff --git a/ui/src/dashboards/utils/templateVariableQueryGenerator.js b/ui/src/dashboards/utils/templateVariableQueryGenerator.js new file mode 100644 index 0000000000..1d03e62d2e --- /dev/null +++ b/ui/src/dashboards/utils/templateVariableQueryGenerator.js @@ -0,0 +1,56 @@ +import {TEMPLATE_VARIABLE_QUERIES} from 'src/dashboards/constants' + +const generateTemplateVariableQuery = ({ + type, + query: { + database, + // rp, TODO + measurement, + tagKey, + }, +}) => { + const tempVars = [] + + if (database) { + tempVars.push({ + tempVar: ':database:', + values: [ + { + type: 'database', + value: database, + }, + ], + }) + } + if (measurement) { + tempVars.push({ + tempVar: ':measurement:', + values: [ + { + type: 'measurement', + value: measurement, + }, + ], + }) + } + if (tagKey) { + tempVars.push({ + tempVar: ':tagKey:', + values: [ + { + type: 'tagKey', + value: tagKey, + }, + ], + }) + } + + const query = TEMPLATE_VARIABLE_QUERIES[type] + + return { + query, + tempVars, + } +} + +export default generateTemplateVariableQuery diff --git a/ui/src/data_explorer/components/QueryBuilder.js b/ui/src/data_explorer/components/QueryBuilder.js index d16b1c96eb..d642726af2 100644 --- a/ui/src/data_explorer/components/QueryBuilder.js +++ b/ui/src/data_explorer/components/QueryBuilder.js @@ -7,11 +7,7 @@ import TagList from './TagList' import QueryEditor from './QueryEditor' import buildInfluxQLQuery from 'utils/influxql' -const { - string, - shape, - func, -} = PropTypes +const {arrayOf, func, shape, string} = PropTypes const QueryBuilder = React.createClass({ propTypes: { @@ -27,6 +23,11 @@ const QueryBuilder = React.createClass({ upper: string, lower: string, }).isRequired, + templates: arrayOf( + shape({ + tempVar: string.isRequired, + }) + ), actions: shape({ chooseNamespace: func.isRequired, chooseMeasurement: func.isRequired, @@ -78,12 +79,12 @@ const QueryBuilder = React.createClass({ }, render() { - const {query, timeRange} = this.props + const {query, timeRange, templates} = this.props const q = query.rawText || buildInfluxQLQuery(timeRange, query) || '' return (
    - + {this.renderLists()}
    ) diff --git a/ui/src/data_explorer/components/QueryEditor.js b/ui/src/data_explorer/components/QueryEditor.js index 19d57a0142..18fb38aa6b 100644 --- a/ui/src/data_explorer/components/QueryEditor.js +++ b/ui/src/data_explorer/components/QueryEditor.js @@ -1,66 +1,195 @@ -import React, {PropTypes} from 'react' +import React, {PropTypes, Component} from 'react' +import _ from 'lodash' import classNames from 'classnames' + import Dropdown from 'src/shared/components/Dropdown' import LoadingDots from 'src/shared/components/LoadingDots' +import TemplateDrawer from 'src/shared/components/TemplateDrawer' import {QUERY_TEMPLATES} from 'src/data_explorer/constants' +import {TEMPLATE_MATCHER} from 'src/dashboards/constants' -const ENTER = 13 -const ESCAPE = 27 -const {bool, func, shape, string} = PropTypes -const QueryEditor = React.createClass({ - propTypes: { - query: string.isRequired, - onUpdate: func.isRequired, - config: shape({ - status: shape({ - error: string, - loading: bool, - success: string, - warn: string, - }), - }).isRequired, - }, - - getInitialState() { - return { +class QueryEditor extends Component { + constructor(props) { + super(props) + this.state = { value: this.props.query, + isTemplating: false, + selectedTemplate: { + tempVar: _.get(this.props.templates, ['0', 'tempVar'], ''), + }, + filteredTemplates: this.props.templates, } - }, + + this.handleKeyDown = ::this.handleKeyDown + this.handleChange = ::this.handleChange + this.handleUpdate = ::this.handleUpdate + this.handleChooseTemplate = ::this.handleChooseTemplate + this.handleCloseDrawer = ::this.handleCloseDrawer + this.findTempVar = ::this.findTempVar + this.handleTemplateReplace = ::this.handleTemplateReplace + this.handleMouseOverTempVar = ::this.handleMouseOverTempVar + this.handleClickTempVar = ::this.handleClickTempVar + this.closeDrawer = ::this.closeDrawer + } componentWillReceiveProps(nextProps) { if (this.props.query !== nextProps.query) { this.setState({value: nextProps.query}) } - }, + } + + handleCloseDrawer() { + this.setState({isTemplating: false}) + } + + handleMouseOverTempVar(template) { + this.handleTemplateReplace(template) + } + + handleClickTempVar(template) { + // Clicking a tempVar does the same thing as hitting 'Enter' + this.handleTemplateReplace(template, 'Enter') + this.closeDrawer() + } + + closeDrawer() { + this.setState({ + isTemplating: false, + selectedTemplate: { + tempVar: _.get(this.props.templates, ['0', 'tempVar'], ''), + }, + }) + } handleKeyDown(e) { - if (e.keyCode === ENTER) { + const {isTemplating, value} = this.state + + if (isTemplating) { + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': + e.preventDefault() + return this.handleTemplateReplace(this.findTempVar('next')) + case 'ArrowLeft': + case 'ArrowUp': + e.preventDefault() + return this.handleTemplateReplace(this.findTempVar('previous')) + case 'Enter': + e.preventDefault() + this.handleTemplateReplace(this.state.selectedTemplate, e.key) + return this.closeDrawer() + case 'Escape': + e.preventDefault() + return this.closeDrawer() + } + } else if (e.key === 'Escape') { + e.preventDefault() + this.setState({value, isTemplating: false}) + } else if (e.key === 'Enter') { e.preventDefault() this.handleUpdate() - } else if (e.keyCode === ESCAPE) { - this.setState({value: this.state.value}, () => { - this.editor.blur() - }) } - }, + } + + handleTemplateReplace(selectedTemplate, key) { + const {selectionStart, value} = this.editor + const isEnter = key === 'Enter' + const {tempVar} = selectedTemplate + + let templatedValue + const matched = value.match(TEMPLATE_MATCHER) + if (matched) { + const newTempVar = isEnter + ? tempVar + : tempVar.substring(0, tempVar.length - 1) + templatedValue = value.replace(TEMPLATE_MATCHER, newTempVar) + } + + const enterModifier = isEnter ? 0 : -1 + const diffInLength = tempVar.length - matched[0].length + enterModifier + + this.setState({value: templatedValue, selectedTemplate}, () => + this.editor.setSelectionRange( + selectionStart + diffInLength, + selectionStart + diffInLength + ) + ) + } + + findTempVar(direction) { + const {filteredTemplates: templates} = this.state + const {selectedTemplate} = this.state + + const i = _.findIndex(templates, selectedTemplate) + const lastIndex = templates.length - 1 + + if (i >= 0) { + if (direction === 'next') { + return templates[(i + 1) % templates.length] + } + + if (direction === 'previous') { + if (i === 0) { + return templates[lastIndex] + } + + return templates[i - 1] + } + } + + return templates[0] + } handleChange() { - this.setState({ - value: this.editor.value, - }) - }, + const {templates} = this.props + const {selectedTemplate} = this.state + const value = this.editor.value + const matches = value.match(TEMPLATE_MATCHER) + if (matches) { + // maintain cursor poition + const start = this.editor.selectionStart + const end = this.editor.selectionEnd + const filteredTemplates = templates.filter(t => + t.tempVar.includes(matches[0].substring(1)) + ) + + const found = filteredTemplates.find( + t => t.tempVar === selectedTemplate && selectedTemplate.tempVar + ) + const newTemplate = found ? found : filteredTemplates[0] + + this.setState({ + isTemplating: true, + selectedTemplate: newTemplate, + filteredTemplates, + value, + }) + this.editor.setSelectionRange(start, end) + } else { + this.setState({isTemplating: false, value}) + } + } handleUpdate() { this.props.onUpdate(this.state.value) - }, + } handleChooseTemplate(template) { this.setState({value: template.query}) - }, + } + + handleSelectTempVar(tempVar) { + this.setState({selectedTemplate: tempVar}) + } render() { const {config: {status}} = this.props - const {value} = this.state + const { + value, + isTemplating, + selectedTemplate, + filteredTemplates, + } = this.state return (
    @@ -69,13 +198,80 @@ const QueryEditor = React.createClass({ onChange={this.handleChange} onKeyDown={this.handleKeyDown} onBlur={this.handleUpdate} - ref={editor => (this.editor = editor)} + ref={editor => this.editor = editor} value={value} placeholder="Enter a query or select database, measurement, and field below and have us build one for you..." autoComplete="off" spellCheck="false" /> - {this.renderStatus(status)} +
    +
    +
    {this.renderStatus(status)}
    +
    + {isTemplating + ? + : null} +
    +
    +
    +
    + ) + } + + renderStatus(status) { + if (!status) { + return ( +
    + +
    + ) + } + + if (status.loading) { + return ( +
    + + +
    + ) + } + + return ( +
    + + + {status.error || status.warn || status.success} +
    ) - }, + } +} - renderStatus(status) { - if (!status) { - return
    - } +const {arrayOf, func, shape, string} = PropTypes - if (status.loading) { - return ( -
    - -
    - ) - } - - return ( -
    - - {status.error || status.warn || status.success} -
    - ) - }, -}) +QueryEditor.propTypes = { + query: string.isRequired, + onUpdate: func.isRequired, + config: shape().isRequired, + templates: arrayOf( + shape({ + tempVar: string.isRequired, + }) + ), +} export default QueryEditor diff --git a/ui/src/data_explorer/components/QueryMaker.js b/ui/src/data_explorer/components/QueryMaker.js index 8dbba312ae..f471a2c7be 100644 --- a/ui/src/data_explorer/components/QueryMaker.js +++ b/ui/src/data_explorer/components/QueryMaker.js @@ -4,14 +4,7 @@ import QueryBuilder from './QueryBuilder' import QueryMakerTab from './QueryMakerTab' import buildInfluxQLQuery from 'utils/influxql' -const { - arrayOf, - func, - node, - number, - shape, - string, -} = PropTypes +const {arrayOf, func, node, number, shape, string} = PropTypes const QueryMaker = React.createClass({ propTypes: { @@ -25,6 +18,11 @@ const QueryMaker = React.createClass({ upper: string, lower: string, }).isRequired, + templates: arrayOf( + shape({ + tempVar: string.isRequired, + }) + ), actions: shape({ chooseNamespace: func.isRequired, chooseMeasurement: func.isRequired, @@ -76,7 +74,7 @@ const QueryMaker = React.createClass({ }, renderQueryBuilder() { - const {timeRange, actions, source} = this.props + const {timeRange, actions, source, templates} = this.props const query = this.getActiveQuery() if (!query) { @@ -93,6 +91,7 @@ const QueryMaker = React.createClass({ { @@ -94,7 +93,7 @@ const Visualization = React.createClass({ return {text, id: query.id} }) const queries = statements.filter(s => s.text !== null).map(s => { - return {host: [proxyLink], text: s.text, id: s.id} + return {host: [proxy], text: s.text, id: s.id} }) return ( diff --git a/ui/src/data_explorer/containers/Header.js b/ui/src/data_explorer/containers/Header.js index 238deb4c9c..0f59f8ea48 100644 --- a/ui/src/data_explorer/containers/Header.js +++ b/ui/src/data_explorer/containers/Header.js @@ -43,7 +43,9 @@ const Header = React.createClass({
    -

    Data Explorer

    +

    + Data Explorer +

    diff --git a/ui/src/hosts/containers/HostsPage.js b/ui/src/hosts/containers/HostsPage.js index 4f732f724c..66b8c4214b 100644 --- a/ui/src/hosts/containers/HostsPage.js +++ b/ui/src/hosts/containers/HostsPage.js @@ -78,7 +78,7 @@ export const HostsPage = React.createClass({
    -

    +

    Host List

    diff --git a/ui/src/kapacitor/components/KapacitorForm.js b/ui/src/kapacitor/components/KapacitorForm.js index 5db261ad19..cfdb29399a 100644 --- a/ui/src/kapacitor/components/KapacitorForm.js +++ b/ui/src/kapacitor/components/KapacitorForm.js @@ -11,7 +11,7 @@ class KapacitorForm extends Component {
    -

    +

    Configure Kapacitor

    diff --git a/ui/src/kapacitor/components/KapacitorRules.js b/ui/src/kapacitor/components/KapacitorRules.js index 77102e078a..f9af86bcbd 100644 --- a/ui/src/kapacitor/components/KapacitorRules.js +++ b/ui/src/kapacitor/components/KapacitorRules.js @@ -50,7 +50,7 @@ const PageContents = ({children, source}) => (
    -

    Kapacitor Rules

    +

    Kapacitor Rules

    diff --git a/ui/src/shared/actions/timeSeries.js b/ui/src/shared/actions/timeSeries.js index 79248e09dc..d6d78d24ab 100644 --- a/ui/src/shared/actions/timeSeries.js +++ b/ui/src/shared/actions/timeSeries.js @@ -14,7 +14,9 @@ export const handleSuccess = (data, query, editQueryStatus) => { const series = _.get(results, ['0', 'series'], false) // 200 from server and no results = warn if (!series && !error) { - editQueryStatus(query.id, {warn: 'Your query is syntactically correct but returned no results'}) + editQueryStatus(query.id, { + warn: 'Your query is syntactically correct but returned no results', + }) return data } @@ -37,10 +39,19 @@ export const handleError = (error, query, editQueryStatus) => { console.error(error) } -export const fetchTimeSeriesAsync = async ({source, db, rp, query}, editQueryStatus = noop) => { +export const fetchTimeSeriesAsync = async ( + {source, db, rp, query, tempVars}, + editQueryStatus = noop +) => { handleLoading(query, editQueryStatus) try { - const {data} = await proxy({source, db, rp, query: query.text}) + const {data} = await proxy({ + source, + db, + rp, + query: query.text, + tempVars, + }) return handleSuccess(data, query, editQueryStatus) } catch (error) { errorThrown(error) diff --git a/ui/src/shared/apis/metaQuery.js b/ui/src/shared/apis/metaQuery.js index d6236ecbbd..5e6733ff9a 100644 --- a/ui/src/shared/apis/metaQuery.js +++ b/ui/src/shared/apis/metaQuery.js @@ -2,7 +2,7 @@ import AJAX from 'utils/ajax' import _ from 'lodash' import {buildInfluxUrl, proxy} from 'utils/queryUrlGenerator' -export const showDatabases = async (source) => { +export const showDatabases = async source => { const query = 'SHOW DATABASES' return await proxy({source, query}) } @@ -10,7 +10,7 @@ export const showDatabases = async (source) => { export const showRetentionPolicies = async (source, databases) => { let query if (Array.isArray(databases)) { - query = databases.map((db) => `SHOW RETENTION POLICIES ON "${db}"`).join(';') + query = databases.map(db => `SHOW RETENTION POLICIES ON "${db}"`).join(';') } else { query = `SHOW RETENTION POLICIES ON "${databases}"` } @@ -30,24 +30,35 @@ export function killQuery(source, queryId) { return proxy({source, query}) } -export function showMeasurements(source, db) { +export const showMeasurements = async (source, db) => { const query = 'SHOW MEASUREMENTS' - return proxy({source, db, query}) + return await proxy({source, db, query}) } -export function showTagKeys({source, database, retentionPolicy, measurement}) { +export const showTagKeys = async ({ + source, + database, + retentionPolicy, + measurement, +}) => { const rp = _.toString(retentionPolicy) const query = `SHOW TAG KEYS FROM "${rp}"."${measurement}"` - return proxy({source, db: database, rp: retentionPolicy, query}) + return await proxy({source, db: database, rp: retentionPolicy, query}) } -export function showTagValues({source, database, retentionPolicy, measurement, tagKeys}) { - const keys = tagKeys.sort().map((k) => `"${k}"`).join(', ') +export const showTagValues = async ({ + source, + database, + retentionPolicy, + measurement, + tagKeys, +}) => { + const keys = tagKeys.sort().map(k => `"${k}"`).join(', ') const rp = _.toString(retentionPolicy) const query = `SHOW TAG VALUES FROM "${rp}"."${measurement}" WITH KEY IN (${keys})` - return proxy({source, db: database, rp: retentionPolicy, query}) + return await proxy({source, db: database, rp: retentionPolicy, query}) } export function showShards() { @@ -56,7 +67,14 @@ export function showShards() { }) } -export function createRetentionPolicy({host, database, rpName, duration, replicationFactor, clusterID}) { +export function createRetentionPolicy({ + host, + database, + rpName, + duration, + replicationFactor, + clusterID, +}) { const statement = `CREATE RETENTION POLICY "${rpName}" ON "${database}" DURATION ${duration} REPLICATION ${replicationFactor}` const url = buildInfluxUrl({host, statement}) @@ -70,8 +88,8 @@ export function dropShard(host, shard, clusterID) { return proxy(url, clusterID) } -export function showFieldKeys(source, db, measurement, rp) { +export const showFieldKeys = async (source, db, measurement, rp) => { const query = `SHOW FIELD KEYS FROM "${rp}"."${measurement}"` - return proxy({source, query, db}) + return await proxy({source, query, db}) } diff --git a/ui/src/shared/apis/timeSeries.js b/ui/src/shared/apis/timeSeries.js deleted file mode 100644 index 1b81607a51..0000000000 --- a/ui/src/shared/apis/timeSeries.js +++ /dev/null @@ -1,12 +0,0 @@ -import {proxy} from 'utils/queryUrlGenerator' - -const fetchTimeSeries = async (source, database, query) => { - try { - return await proxy({source, query, database}) - } catch (error) { - console.error('error from proxy: ', error) - throw error - } -} - -export default fetchTimeSeries diff --git a/ui/src/shared/components/AutoRefresh.js b/ui/src/shared/components/AutoRefresh.js index d91722c519..9664233b70 100644 --- a/ui/src/shared/components/AutoRefresh.js +++ b/ui/src/shared/components/AutoRefresh.js @@ -4,6 +4,7 @@ import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries' const { arrayOf, + bool, element, func, number, @@ -12,82 +13,141 @@ const { string, } = PropTypes -const AutoRefresh = (ComposedComponent) => { +const AutoRefresh = ComposedComponent => { const wrapper = React.createClass({ propTypes: { children: element, autoRefresh: number.isRequired, - queries: arrayOf(shape({ - host: oneOfType([string, arrayOf(string)]), - text: string, - }).isRequired).isRequired, + templates: arrayOf( + shape({ + type: string.isRequired, + label: string.isRequired, + tempVar: string.isRequired, + query: shape({ + db: string, + rp: string, + influxql: string, + }), + values: arrayOf( + shape({ + type: string.isRequired, + value: string.isRequired, + selected: bool, + }) + ).isRequired, + }) + ), + queries: arrayOf( + shape({ + host: oneOfType([string, arrayOf(string)]), + text: string, + }).isRequired + ).isRequired, editQueryStatus: func, }, + getInitialState() { return { lastQuerySuccessful: false, timeSeries: [], } }, + componentDidMount() { const {queries, autoRefresh} = this.props this.executeQueries(queries) if (autoRefresh) { - this.intervalID = setInterval(() => this.executeQueries(queries), autoRefresh) + this.intervalID = setInterval( + () => this.executeQueries(queries), + autoRefresh + ) } }, + componentWillReceiveProps(nextProps) { - const shouldRefetch = this.queryDifference(this.props.queries, nextProps.queries).length + const queriesDidUpdate = this.queryDifference( + this.props.queries, + nextProps.queries + ).length + + const tempVarsDidUpdate = !_.isEqual( + this.props.templates, + nextProps.templates + ) + + const shouldRefetch = queriesDidUpdate || tempVarsDidUpdate if (shouldRefetch) { this.executeQueries(nextProps.queries) } - if ((this.props.autoRefresh !== nextProps.autoRefresh) || shouldRefetch) { + if (this.props.autoRefresh !== nextProps.autoRefresh || shouldRefetch) { clearInterval(this.intervalID) if (nextProps.autoRefresh) { - this.intervalID = setInterval(() => this.executeQueries(nextProps.queries), nextProps.autoRefresh) + this.intervalID = setInterval( + () => this.executeQueries(nextProps.queries), + nextProps.autoRefresh + ) } } }, + queryDifference(left, right) { - const leftStrs = left.map((q) => `${q.host}${q.text}`) - const rightStrs = right.map((q) => `${q.host}${q.text}`) - return _.difference(_.union(leftStrs, rightStrs), _.intersection(leftStrs, rightStrs)) + const leftStrs = left.map(q => `${q.host}${q.text}`) + const rightStrs = right.map(q => `${q.host}${q.text}`) + return _.difference( + _.union(leftStrs, rightStrs), + _.intersection(leftStrs, rightStrs) + ) }, - async executeQueries(queries) { + + executeQueries(queries) { + const {templates = [], editQueryStatus} = this.props + if (!queries.length) { - this.setState({ - timeSeries: [], - }) + this.setState({timeSeries: []}) return } this.setState({isFetching: true}) - let count = 0 - const newSeries = [] - for (const query of queries) { + + const selectedTempVarTemplates = templates.map(template => { + const selectedValues = template.values.filter(value => value.selected) + return {...template, values: selectedValues} + }) + + const timeSeriesPromises = queries.map(query => { const {host, database, rp} = query - // TODO: enact this via an action creator so redux will know about it; currently errors are used as responses here - // TODO: may need to make this a try/catch - const response = await fetchTimeSeriesAsync({source: host, db: database, rp, query}, this.props.editQueryStatus) - newSeries.push({response}) - count += 1 - if (count === queries.length) { - const querySuccessful = !this._noResultsForQuery(newSeries) - this.setState({ - lastQuerySuccessful: querySuccessful, - isFetching: false, - timeSeries: newSeries, - }) - } - } + return fetchTimeSeriesAsync( + { + source: host, + db: database, + rp, + query, + tempVars: selectedTempVarTemplates, + }, + editQueryStatus + ) + }) + + Promise.all(timeSeriesPromises).then(timeSeries => { + const newSeries = timeSeries.map(response => ({response})) + const lastQuerySuccessful = !this._noResultsForQuery(newSeries) + + this.setState({ + timeSeries: newSeries, + lastQuerySuccessful, + isFetching: false, + }) + }) }, + componentWillUnmount() { clearInterval(this.intervalID) this.intervalID = false }, + render() { const {timeSeries} = this.state @@ -95,16 +155,14 @@ const AutoRefresh = (ComposedComponent) => { return this.renderFetching(timeSeries) } - if (this._noResultsForQuery(timeSeries) || !this.state.lastQuerySuccessful) { + if ( + this._noResultsForQuery(timeSeries) || + !this.state.lastQuerySuccessful + ) { return this.renderNoResults() } - return ( - - ) + return }, /** @@ -140,8 +198,8 @@ const AutoRefresh = (ComposedComponent) => { return true } - return data.every((datum) => { - return datum.response.results.every((result) => { + return data.every(datum => { + return datum.response.results.every(result => { return Object.keys(result).length === 0 }) }) diff --git a/ui/src/shared/components/DeleteConfirmButtons.js b/ui/src/shared/components/DeleteConfirmButtons.js index 1ecb3d880a..94782d5313 100644 --- a/ui/src/shared/components/DeleteConfirmButtons.js +++ b/ui/src/shared/components/DeleteConfirmButtons.js @@ -4,7 +4,10 @@ import OnClickOutside from 'shared/components/OnClickOutside' import ConfirmButtons from 'shared/components/ConfirmButtons' const DeleteButton = ({onClickDelete}) => ( - ) @@ -35,23 +38,24 @@ class DeleteConfirmButtons extends Component { const {onDelete, item} = this.props const {isConfirming} = this.state - return isConfirming ? - : - + return isConfirming + ? + : } } -const { - func, - shape, -} = PropTypes +const {func, oneOfType, shape, string} = PropTypes DeleteButton.propTypes = { onClickDelete: func.isRequired, } DeleteConfirmButtons.propTypes = { - item: shape({}), + item: oneOfType([(string, shape())]), onDelete: func.isRequired, } diff --git a/ui/src/shared/components/Dropdown.js b/ui/src/shared/components/Dropdown.js index 6f3441bbff..7eb0a32520 100644 --- a/ui/src/shared/components/Dropdown.js +++ b/ui/src/shared/components/Dropdown.js @@ -1,7 +1,6 @@ import React, {Component, PropTypes} from 'react' import {Link} from 'react-router' import classnames from 'classnames' - import OnClickOutside from 'shared/components/OnClickOutside' class Dropdown extends Component { @@ -134,6 +133,7 @@ Dropdown.propTypes = { }) ).isRequired, onChoose: func.isRequired, + onClick: func, addNew: shape({ url: string.isRequired, text: string.isRequired, diff --git a/ui/src/shared/components/LayoutRenderer.js b/ui/src/shared/components/LayoutRenderer.js index 1721c3a18b..e6e3c4b728 100644 --- a/ui/src/shared/components/LayoutRenderer.js +++ b/ui/src/shared/components/LayoutRenderer.js @@ -46,6 +46,7 @@ export const LayoutRenderer = React.createClass({ type: string.isRequired, }).isRequired ), + templates: arrayOf(shape()).isRequired, host: string, source: string, onPositionChange: func, @@ -89,12 +90,41 @@ export const LayoutRenderer = React.createClass({ return text }, + renderRefreshingGraph(type, queries) { + const {autoRefresh, templates} = this.props + + if (type === 'single-stat') { + return ( + + ) + } + + const displayOptions = { + stepPlot: type === 'line-stepplot', + stackedGraph: type === 'line-stacked', + } + + return ( + + ) + }, + generateVisualizations() { - const {autoRefresh, timeRange, source, cells, onEditCell, onRenameCell, onUpdateCell, onDeleteCell, onSummonOverlayTechnologies, shouldNotBeEditable} = this.props + const {timeRange, source, cells, onEditCell, onRenameCell, onUpdateCell, onDeleteCell, onSummonOverlayTechnologies, shouldNotBeEditable} = this.props return cells.map((cell) => { - const qs = cell.queries.map((query) => { - // TODO: Canned dashboards use an old query schema, + const queries = cell.queries.map((query) => { + // TODO: Canned dashboards (and possibly Kubernetes dashboard) use an old query schema, // which does not have enough information for the new `buildInfluxQLQuery` function // to operate on. We will use `buildQueryForOldQuerySchema` until we conform // on a stable query representation. @@ -112,29 +142,6 @@ export const LayoutRenderer = React.createClass({ }) }) - if (cell.type === 'single-stat') { - return ( -
    - - - -
    - ) - } - - const displayOptions = { - stepPlot: cell.type === 'line-stepplot', - stackedGraph: cell.type === 'line-stacked', - } - return (
    - + {this.renderRefreshingGraph(cell.type, queries)}
    ) diff --git a/ui/src/shared/components/OverlayTechnologies.js b/ui/src/shared/components/OverlayTechnologies.js new file mode 100644 index 0000000000..cd91c7bd63 --- /dev/null +++ b/ui/src/shared/components/OverlayTechnologies.js @@ -0,0 +1,13 @@ +import React, {PropTypes} from 'react' + +const OverlayTechnologies = ({children}) =>
    {children}
    + +const { + node, +} = PropTypes + +OverlayTechnologies.propTypes = { + children: node.isRequired, +} + +export default OverlayTechnologies diff --git a/ui/src/shared/components/SimpleDropdown.js b/ui/src/shared/components/SimpleDropdown.js deleted file mode 100644 index 33e75e4ac5..0000000000 --- a/ui/src/shared/components/SimpleDropdown.js +++ /dev/null @@ -1,59 +0,0 @@ -import React, {PropTypes} from 'react' -import classNames from 'classnames' -import OnClickOutside from 'shared/components/OnClickOutside' - -const Dropdown = React.createClass({ - propTypes: { - children: PropTypes.node.isRequired, - items: PropTypes.arrayOf(PropTypes.shape({ - text: PropTypes.string.isRequired, - })).isRequired, - onChoose: PropTypes.func.isRequired, - className: PropTypes.string, - }, - getInitialState() { - return { - isOpen: false, - } - }, - handleClickOutside() { - this.setState({isOpen: false}) - }, - handleSelection(item) { - this.toggleMenu() - this.props.onChoose(item) - }, - toggleMenu(e) { - if (e) { - e.stopPropagation() - } - this.setState({isOpen: !this.state.isOpen}) - }, - render() { - const self = this - const {items, className} = self.props - - return ( -
    -
    - {this.props.children} -
    - {self.state.isOpen ? -
      - {items.map((item, i) => { - return ( -
    • self.handleSelection(item)}> - - {item.text} - -
    • - ) - })} -
    - : null} -
    - ) - }, -}) - -export default OnClickOutside(Dropdown) diff --git a/ui/src/shared/components/TemplateDrawer.js b/ui/src/shared/components/TemplateDrawer.js new file mode 100644 index 0000000000..877d3fa673 --- /dev/null +++ b/ui/src/shared/components/TemplateDrawer.js @@ -0,0 +1,41 @@ +import React, {PropTypes} from 'react' +import OnClickOutside from 'react-onclickoutside' +import classNames from 'classnames' + +const TemplateDrawer = ({ + templates, + selected, + onMouseOverTempVar, + onClickTempVar, +}) => ( +
    + {templates.map(t => ( +
    { + onMouseOverTempVar(t) + }} + onClick={() => onClickTempVar(t)} + key={t.tempVar} + > + {' '}{t.tempVar}{' '} +
    + ))} +
    +) + +const {arrayOf, func, shape, string} = PropTypes + +TemplateDrawer.propTypes = { + templates: arrayOf( + shape({ + tempVar: string.isRequired, + }) + ), + selected: shape({ + tempVar: string, + }), + onMouseOverTempVar: func.isRequired, + onClickTempVar: func.isRequired, +} + +export default OnClickOutside(TemplateDrawer) diff --git a/ui/src/shared/constants/actionTypes.js b/ui/src/shared/constants/actionTypes.js new file mode 100644 index 0000000000..8c8f62840e --- /dev/null +++ b/ui/src/shared/constants/actionTypes.js @@ -0,0 +1 @@ +export const TEMPLATE_VARIABLE_SELECTED = 'TEMPLATE_VARIABLE_SELECTED' diff --git a/ui/src/shared/constants/index.js b/ui/src/shared/constants/index.js index 6df02a514b..a10ed24931 100644 --- a/ui/src/shared/constants/index.js +++ b/ui/src/shared/constants/index.js @@ -384,7 +384,7 @@ export const HEARTBEAT_INTERVAL = 10000 // ms export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds. export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds. -export const SHORT_NOTIFICATION_DISMISS_DELAY = 1500 // in milliseconds +export const SHORT_NOTIFICATION_DISMISS_DELAY = 2000 // in milliseconds export const REVERT_STATE_DELAY = 1500 // ms diff --git a/ui/src/shared/parsing/index.js b/ui/src/shared/parsing/index.js new file mode 100644 index 0000000000..7ac11b03bf --- /dev/null +++ b/ui/src/shared/parsing/index.js @@ -0,0 +1,24 @@ +import databases from 'shared/parsing/showDatabases' +import measurements from 'shared/parsing/showMeasurements' +import fieldKeys from 'shared/parsing/showFieldKeys' +import tagKeys from 'shared/parsing/showTagKeys' +import tagValues from 'shared/parsing/showTagValues' + +const parsers = { + databases, + measurements: data => { + const {errors, measurementSets} = measurements(data) + return {errors, measurements: measurementSets[0].measurements} + }, + fieldKeys: (data, key) => { + const {errors, fieldSets} = fieldKeys(data) + return {errors, fieldKeys: fieldSets[key]} + }, + tagKeys, + tagValues: (data, key) => { + const {errors, tags} = tagValues(data) + return {errors, tagValues: tags[key]} + }, +} + +export default parsers diff --git a/ui/src/shared/parsing/showFieldKeys.js b/ui/src/shared/parsing/showFieldKeys.js index 25f5c6abbb..2c6915365f 100644 --- a/ui/src/shared/parsing/showFieldKeys.js +++ b/ui/src/shared/parsing/showFieldKeys.js @@ -2,7 +2,7 @@ export default function parseShowFieldKeys(response) { const errors = [] const fieldSets = {} - response.results.forEach((result) => { + response.results.forEach(result => { if (result.error) { errors.push(result.error) return @@ -14,7 +14,7 @@ export default function parseShowFieldKeys(response) { const series = result.series[0] const fieldKeyIndex = series.columns.indexOf('fieldKey') - const fields = series.values.map((value) => { + const fields = series.values.map(value => { return value[fieldKeyIndex] }) const measurement = series.name diff --git a/ui/src/shared/parsing/showMeasurements.js b/ui/src/shared/parsing/showMeasurements.js index c3141ac312..ab291da5b8 100644 --- a/ui/src/shared/parsing/showMeasurements.js +++ b/ui/src/shared/parsing/showMeasurements.js @@ -21,7 +21,7 @@ export default function parseShowMeasurements(response) { const series = result.series[0] const measurementNameIndex = series.columns.indexOf('name') - const measurements = series.values.map((value) => value[measurementNameIndex]) + const measurements = series.values.map(value => value[measurementNameIndex]) measurementSets.push({ index, @@ -34,4 +34,3 @@ export default function parseShowMeasurements(response) { measurementSets, } } - diff --git a/ui/src/sources/containers/ManageSources.js b/ui/src/sources/containers/ManageSources.js index 7136b0dfdd..94e08a626b 100644 --- a/ui/src/sources/containers/ManageSources.js +++ b/ui/src/sources/containers/ManageSources.js @@ -49,7 +49,7 @@ class ManageSources extends Component {
    -

    Configuration

    +

    Configuration

    diff --git a/ui/src/sources/containers/SourcePage.js b/ui/src/sources/containers/SourcePage.js index ac7be0ac73..9b46f69334 100644 --- a/ui/src/sources/containers/SourcePage.js +++ b/ui/src/sources/containers/SourcePage.js @@ -115,7 +115,7 @@ export const SourcePage = React.createClass({
    -

    +

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

    diff --git a/ui/src/style/components/query-editor.scss b/ui/src/style/components/query-editor.scss index bb95febc18..ae59cbe338 100644 --- a/ui/src/style/components/query-editor.scss +++ b/ui/src/style/components/query-editor.scss @@ -11,19 +11,10 @@ border-radius: 0 $radius 0 0; background-color: $query-editor--bg; position: relative; -} -.query-editor--field, -.query-editor--status { - font-family: $code-font; - transition: - color 0.25s ease, - background-color 0.25s ease, - border-color 0.25s ease; - border: 2px solid $query-editor--bg; - background-color: $query-editor--field-bg; - + z-index: 2; /* Minimum amount to obcure the toggle flip within Query Builder. Will fix later */ } .query-editor--field { + font-family: $code-font; font-size: 12px; line-height: 14px; font-weight: 600; @@ -34,7 +25,13 @@ resize: none; width: 100%; height: $query-editor--field-height; + transition: + color 0.25s ease, + background-color 0.25s ease, + border-color 0.25s ease; + border: 2px solid $query-editor--bg; border-bottom: 0; + background-color: $query-editor--field-bg; color: $query-editor--field-text; padding: 12px 10px 0 10px; border-radius: $radius $radius 0 0; @@ -49,33 +46,16 @@ color: $query-editor--field-text !important; border-color: $c-pool; } - &:focus + .query-editor--status { + &:focus + .varmoji { border-color: $c-pool; } } .query-editor--status { + display: flex; + align-items: center; + justify-content: flex-end; height: $query-editor--status-height; - line-height: $query-editor--status-height; - font-size: 12px; - padding: 0 10px; - padding-right: ($query-editor--templates-width + ($query-editor--templates-offset * 2)) !important; - border-radius: 0 0 $radius $radius; - border-top: 0; - color: $query-editor--status-default; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - span.icon { - margin-right: 5px; - } - - /* Error State */ - &.query-editor--error { color: $query-editor--status-error; } - /* Warning State */ - &.query-editor--warning { color: $query-editor--status-warning; } - /* Success State */ - &.query-editor--success { color: $query-editor--status-success; } /* Loading State */ .loading-dots { bottom: $query-editor--templates-offset; @@ -83,10 +63,30 @@ transform: translateY(50%); } } +.query-status-output { + flex: 1 0 0; + display: inline-block; + color: $query-editor--status-default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 10px; + line-height: $query-editor--status-height; + font-size: 12px; + font-family: $code-font; + + span.icon { + margin-right: 5px; + } + /* Error State */ + &.query-status-output--error { color: $query-editor--status-error; } + /* Warning State */ + &.query-status-output--warning { color: $query-editor--status-warning; } + /* Success State */ + &.query-status-output--success { color: $query-editor--status-success; } +} .dropdown.query-editor--templates { - position: absolute; - bottom: ($query-editor--templates-offset - 8px); - right: $query-editor--templates-offset; + margin: 0 4px 0 0 ; div.dropdown-toggle.btn.btn-sm { width: $query-editor--templates-width; @@ -103,6 +103,88 @@ min-width: $query-editor--templates-menu-width; max-width: $query-editor--templates-menu-width; } +} + + +/* + Varmoji Flipper + ------------------------------------- + Handles the 3D flip transition between two states (isTemplating) + Contents could in theory be anything +*/ +.varmoji { + transition: border-color 0.25s ease; + border: 2px solid $query-editor--bg; + border-top: 0; + background-color: $query-editor--field-bg; + border-radius: 0 0 $radius $radius; + height: $query-editor--status-height; + width: 100%; + perspective: 1000px; +} +.varmoji-container { + transition: transform 0.6s ease; + transform-style: preserve-3d; + position: relative; + transform-origin: 100% #{$query-editor--status-height / 2}; +} +.varmoji-front, +.varmoji-back { + backface-visibility: hidden; + width: 100%; + height: $query-editor--status-height; + position: absolute; + top: 0; + left: 0; +} +.varmoji-front { + z-index: 3; + transform: rotateX(0deg); +} +.varmoji-back { + z-index: 2; + transform: rotateX(180deg); +} +.varmoji.varmoji-rotated .varmoji-container { + transform: rotateX(-180deg); +} + +/* + Template Drawer + ------------------------------------- + Not sure if this needs its own stylesheet +*/ + +.template-drawer { + height: $query-editor--status-height; + width: 100%; + display: flex; + align-items: center; + padding: 0 4px; +} +.template-drawer--item { + margin-right: 2px; + display: inline-block; + font-family: $code-font; + font-weight: 700; + font-size: 12px; + height: ($query-editor--status-height - 14px); + line-height: ($query-editor--status-height - 14px); + padding: 0 6px; + background-color: $query-editor--field-bg; + color: $c-comet; + border-radius: $radius-small; + cursor: pointer; + transition: + color 0.25s ease, + background-color 0.25s ease; + + /* Selected State */ + &.template-drawer--selected { + color: $g20-white; + background-color: $c-star; + } + .divider { background: linear-gradient(to right, #00C9FF 0%, #22ADF6 100%); } diff --git a/ui/src/style/components/tables.scss b/ui/src/style/components/tables.scss index a17acdd30c..5a046bff97 100644 --- a/ui/src/style/components/tables.scss +++ b/ui/src/style/components/tables.scss @@ -3,6 +3,45 @@ ---------------------------------------------- */ +// table-custom class allows us to make a table from divs so we can use +// forms inside a table +.table-custom { + display: table !important; + border-collapse: separate; + border-spacing: 2px; + width: 100%; + padding: 10px; + .thead { + display: table-header-group; + color: white; + color: $g17-whisper !important; + border-width: 1px; + } + .th { + display: table-cell; + font-weight: 700; + color: $g14-chromium !important; + border: 0 !important; + padding: 6px 8px !important; + } + .tbody { + display: table-row-group; + } + .tr { + display: table-row; + } + .td { + display: table-cell; + font-weight: 500; + color: $g14-chromium !important; + border: 0 !important; + padding: 6px 8px !important; + } + .tr .td .input { + background-color: $g5-pepper; + color: $g19-ghost !important; + } +} table { thead th { diff --git a/ui/src/style/components/template-variables-manager.scss b/ui/src/style/components/template-variables-manager.scss new file mode 100644 index 0000000000..a9a7bc8630 --- /dev/null +++ b/ui/src/style/components/template-variables-manager.scss @@ -0,0 +1,222 @@ +/* + Styles for the Template Variables Manager Panel + ------------------------------------------------------ + Accessed via Dashboards +*/ + +$tvmp-panel-max-width: 1300px; +$tvmp-gutter: 30px; +$tvmp-min-height: 150px; +$tvmp-max-height: calc(100% - 90px); +$tvmp-table-gutter: 8px; + +.template-variable-manager { + max-width: $tvmp-panel-max-width; + margin: 0 auto; +} +.template-variable-manager--header { + height: $chronograf-page-header-height; + background: $g0-obsidian; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 $tvmp-gutter; + + .page-header__dismiss { + margin-left: 10px; + margin-right: -20px; + } +} +.template-variable-manager--body { + padding: 18px ($tvmp-gutter - $tvmp-table-gutter) $tvmp-gutter ($tvmp-gutter - $tvmp-table-gutter); + border-radius: 0 0 $radius $radius; + min-height: $tvmp-min-height; + max-height: $tvmp-max-height; + @include gradient-v($g2-kevlar,$g0-obsidian); + @include custom-scrollbar-round($g0-obsidian,$g3-castle); +} +.template-variable-manager--table, +.template-variable-manager--table-container { + width: 100%; +} +/* Column Widths */ +.tvm--col-1 {flex: 0 0 140px;} +.tvm--col-2 {flex: 0 0 140px;} +.tvm--col-3 {flex: 1 0 500px;} +.tvm--col-4 {flex: 0 0 160px;} + +/* Table Column Labels */ +.template-variable-manager--table-heading { + padding: 0 $tvmp-table-gutter; + height: 18px; + display: flex; + align-items: center; + flex-wrap: nowrap; + font-weight: 600; + font-size: 12px; + color: $g11-sidewalk; + + > * { + @include no-user-select(); + padding-left: 6px; + margin-right: $tvmp-table-gutter; + &:last-child {margin-right: 0;} + } +} + +/* Table Body */ +.template-variable-manager--table-rows { + display: flex; + flex-direction: column; + align-items: stretch; +} +.template-variable-manager--table-row { + border-radius: 4px; + display: flex; + align-items: flex-start; + padding: $tvmp-table-gutter; + transition: background-color 0.25s ease; + + &.editing { + background-color: $g3-castle; + } + + > * { + margin-right: $tvmp-table-gutter; + &:last-child {margin-right: 0;} + } +} + +.tvm-input, +.form-control.tvm-input-edit { + font-weight: 600 !important; + width: 100% !important; + padding: 0 6px !important; + font-family: $code-font; + color: $c-comet !important; + height: 30px !important; + + &:focus, + &:focus:hover { + color: $c-comet !important; + } +} +.tvm-input { + font-size: 12px; + line-height: 26px; + padding: 0 8px; + border-radius: $radius; + border: 2px solid $g5-pepper; + background-color: $g2-kevlar; + transition: + border-color 0.25s ease; + + &:hover { + cursor: text; + border-color: $g6-smoke; + } +} +.tvm-values, +.tvm-values-empty { + width: 100%; + white-space: pre-wrap; + overflow: auto; + font-size: 12px; + font-weight: 600; + font-family: $code-font; + color: $c-pool; + padding: 0 $tvmp-table-gutter; + min-height: 30px; + max-height: 90px; + line-height: 30px; + border-radius: $radius; + background-color: $g5-pepper; + margin-top: 2px; + @include custom-scrollbar-round($g5-pepper, $g7-graphite); +} +.tvm-values-empty { + color: $g9-mountain; + font-style: italic; +} +.tvm-csv-instructions { + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + font-weight: 600; + font-family: $code-font; + padding: 0 $tvmp-table-gutter; + height: 30px; + line-height: 30px; + border-radius: $radius; + background-color: $g5-pepper; + margin-bottom: 2px; + color: $g9-mountain; + font-style: italic; +} + +.tvm-query-builder { + display: flex; + align-items: center; + height: 30px; + + > * {margin-right: 2px;} + > *:last-child {margin-right: 0;} + + .dropdown { + flex: 1 0 0; + + & > .dropdown-toggle {width: 100%;} + } +} +.tvm-query-builder--text { + @include no-user-select(); + background-color: $g5-pepper; + border-radius: $radius-small; + padding: 0 $tvmp-table-gutter; + white-space: nowrap; + height: 30px; + line-height: 30px; + color: $c-pool; + font-size: 12px; + font-weight: 600; + font-family: $code-font; +} +.tvm-actions { + display: flex; + align-items: center; + justify-content: flex-end; + + .btn-edit { + order: 1; + width: 30px; + text-align: center; + padding-left: 0 !important; + padding-right: 0 !important; + > span.icon {margin: 0 !important;} + } + + > .btn {margin-left: $tvmp-table-gutter;} + + /* Override confirm buttons styles */ + /* Janky, but doing this quick & dirty for now */ + .btn-danger { + order: 2; + height: 30px !important; + line-height: 30px !important; + padding: 0 9px !important; + font-size: 13px; + } + .confirm-buttons > .btn { + height: 30px !important; + width: 30px !important; + margin-left: $tvmp-table-gutter !important; + font-size: 13px; + padding: 0 !important; + } + /* Hide the edit button when confirming a delete */ + .confirm-buttons + .btn-edit { + display: none; + } +} diff --git a/ui/src/style/layout/page-header.scss b/ui/src/style/layout/page-header.scss index 88a3e5e535..5da4446964 100644 --- a/ui/src/style/layout/page-header.scss +++ b/ui/src/style/layout/page-header.scss @@ -19,56 +19,88 @@ $page-header-weight: 400 !important; background-color: $g0-obsidian; border: none; margin: 0; +} +.page-header__container { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + width: 100%; + max-width: ($page-wrapper-max-width - $page-wrapper-padding - $page-wrapper-padding); +} +.page-header__left, +.page-header__right { + flex: 1 0 0; + display: flex; + align-items: center; - &__container { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: nowrap; - width: 100%; - max-width: ($page-wrapper-max-width - $page-wrapper-padding - $page-wrapper-padding); - } - h1 { - text-transform: none; - font-size: $page-header-size; - font-weight: $page-header-weight; + > *:only-child { margin: 0; - display: inline-block; - vertical-align: middle; - @include no-user-select(); - cursor: default; } - &__left, - &__right { - flex: 1 0 0; - display: flex; - align-items: center; +} +.page-header__left { + justify-content: flex-start; + > * { + margin: 0 4px 0 0; + } +} +.page-header__right { + justify-content: flex-end; + > * { + margin: 0 0 0 4px; + } +} +.page-header.full-width .page-header__container { + max-width: 100%; +} +.page-header.full-width-no-scrollbar { + padding-right: $page-wrapper-padding; - > *:only-child { - margin: 0; - } - } - &__left { - justify-content: flex-start; - > * { - margin: 0 4px 0 0; - } - } - &__right { - justify-content: flex-end; - > * { - margin: 0 0 0 4px; - } - } - &.full-width .page-header__container { + .page-header__container { max-width: 100%; } - &.full-width-no-scrollbar { - padding-right: $page-wrapper-padding; +} +.page-header__title { + text-transform: none; + font-size: $page-header-size; + font-weight: $page-header-weight; + margin: 0; + display: inline-block; + vertical-align: middle; + @include no-user-select(); + cursor: default; +} +.page-header__dismiss { + width: ($chronograf-page-header-height - 20px); + height: ($chronograf-page-header-height - 20px); + position: relative; - .page-header__container { - max-width: 100%; - } + /* Use psuedo elements to render the X */ + &:before, + &:after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 22px; + height: 2px; + border-radius: 1px; + background-color: $g11-sidewalk; + transition: background-color 0.25s ease; + } + &:before { + transform: translate(-50%,-50%) rotate(45deg); + } + &:after { + transform: translate(-50%,-50%) rotate(-45deg); + } + /* Hover State */ + &:hover { + cursor: pointer; + } + &:hover:before, + &:hover:after { + background-color: $g18-cloud; } } \ No newline at end of file diff --git a/ui/src/style/pages/dashboards.scss b/ui/src/style/pages/dashboards.scss index fd13534d8e..964a03fde6 100644 --- a/ui/src/style/pages/dashboards.scss +++ b/ui/src/style/pages/dashboards.scss @@ -33,12 +33,35 @@ $dash-graph-options-arrow: 8px; Default Dashboard Mode ------------------------------------------------------ */ +.cell-shell { + background-color: $g3-castle; + border-radius: $radius; + border: 2px solid $g3-castle; + transition-property: left, top, border-color, background-color; +} + .dashboard { + .template-control-bar { + height: 50px; + font-size: 18px; + font-weight: 400; + color: $g14-chromium; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + padding: 10px 15px; + @extend .cell-shell; + .dropdown { + flex: 0 1 auto; + min-width: 100px; + } + .dropdown-toggle { + width: 100%; + } + } .react-grid-item { - background-color: $g3-castle; - border-radius: $radius; - border: 2px solid $g3-castle; - transition-property: left, top, border-color, background-color; + @extend .cell-shell; } .graph-empty { background-color: transparent; @@ -82,6 +105,7 @@ $dash-graph-options-arrow: 8px; top: $dash-graph-heading; left: 0; padding: 0; + z-index: 0; & > div:not(.graph-empty) { position: absolute; @@ -353,116 +377,13 @@ $dash-graph-options-arrow: 8px; } /* - Cell Edit Mode + Overylay Technology (Cell Edit Mode) ------------------------------------------------------ */ -$overlay-controls-height: 50px; -$overlay-controls-bg: $g2-kevlar; -$overlay-bg: rgba($c-pool, 0.7); +@import 'overlay-technology'; -.overlay-technology { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - z-index: 100; - background: $overlay-bg; - - .overlay-controls { - padding: 0 16px; - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - flex: 0 0 $overlay-controls-height; - width: calc(100% - #{($explorer-page-padding * 2)}); - left: $explorer-page-padding; - margin-top: 16px; - border: 0; - background-color: $g2-kevlar; - border-radius: $radius $radius 0 0; - } - .overlay-controls--right { - display: flex; - align-items: center; - flex-wrap: nowrap; - - .toggle { - margin: 0 0 0 5px; - } - p { - font-weight: 600; - color: $g13-mist; - margin: 0; - @include no-user-select; - } - } - .overlay--graph-name { - margin: 0; - font-size: 17px; - font-weight: 400; - text-transform: uppercase; - @include no-user-select; - } - .confirm-buttons { - margin-left: 32px; - } - .confirm-buttons .btn { - height: 30px; - line-height: 30px; - padding: 0 9px; - - & > span.icon { - font-size: 16px; - } - } - .overlay-controls .toggle { - - .toggle-btn { - background-color: $overlay-controls-bg; - - &:hover { - background-color: $g4-onyx; - } - &.active { - background-color: $g5-pepper; - } - } - } -} - -/* Graph editing in Dashboards is a little smaller so the dash can be seen in the background */ -.overlay-technology .resize-container.page-contents { - background-image: none !important; - overflow: visible; -} -.overlay-technology .graph { - width: 70%; - left: 15%; -} -.overlay-technology .graph-heading, -.overlay-technology .graph-container, -.overlay-technology .table-container { - top: -24px; -} -.overlay-technology .graph-heading .graph-actions { - order: 2; -} -.overlay-technology .graph-container, -.overlay-technology .table-container { - height: calc(100% - 38px); -} -.overlay-technology .query-maker { - flex: 1 0 0; - padding: 0 8px; - margin-bottom: 8px; - border-radius: 0 0 $radius $radius; - background-color: $g2-kevlar; -} -.overlay-technology .query-maker--tabs { - margin-top: 0; -} -.overlay-technology .query-maker--tab-contents { - margin-bottom: 8px; -} +/* + Template Variables Manager + ------------------------------------------------------ +*/ +@import '../components/template-variables-manager'; diff --git a/ui/src/style/pages/overlay-technology.scss b/ui/src/style/pages/overlay-technology.scss new file mode 100644 index 0000000000..912b236f37 --- /dev/null +++ b/ui/src/style/pages/overlay-technology.scss @@ -0,0 +1,128 @@ +/* + Styles for Overlay Technology (aka Cell Edit Mode) + ------------------------------------------------------ +*/ + +$overlay-controls-height: 50px; +$overlay-controls-bg: $g2-kevlar; +$overlay-z: 100; + +.overlay-technology { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: $overlay-z; + padding: 0 30px; + + &:before { + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + @include gradient-diag-down($c-pool,$c-comet); + opacity: 0.7; + z-index: -1; + } + + .overlay-controls { + padding: 0 16px; + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + flex: 0 0 $overlay-controls-height; + width: calc(100% - #{($explorer-page-padding * 2)}); + left: $explorer-page-padding; + margin-top: 16px; + border: 0; + background-color: $g2-kevlar; + border-radius: $radius $radius 0 0; + } + .overlay-controls--right { + display: flex; + align-items: center; + flex-wrap: nowrap; + + .toggle { + margin: 0 0 0 5px; + } + p { + font-weight: 600; + color: $g13-mist; + margin: 0; + @include no-user-select; + } + } + .overlay--graph-name { + margin: 0; + font-size: 17px; + font-weight: 400; + text-transform: uppercase; + @include no-user-select; + } +} +.overlay-controls .confirm-buttons { + margin-left: 32px; +} +.overlay-controls .confirm-buttons .btn { + height: 30px; + line-height: 30px; + padding: 0 9px; + + & > span.icon { + font-size: 16px; + } +} +.overlay-controls .toggle { + + .toggle-btn { + background-color: $overlay-controls-bg; + + &:hover { + background-color: $g4-onyx; + } + &.active { + background-color: $g5-pepper; + } + } +} + +/* Graph editing in Dashboards is a little smaller so the dash can be seen in the background */ +.overlay-technology .resize-container.page-contents { + background-image: none !important; + overflow: visible; +} +.overlay-technology .graph { + width: 70%; + left: 15%; +} +.overlay-technology .graph-heading, +.overlay-technology .graph-container, +.overlay-technology .table-container { + top: -24px; +} +.overlay-technology .graph-heading .graph-actions { + order: 2; +} +.overlay-technology .graph-container, +.overlay-technology .table-container { + height: calc(100% - 38px); +} +.overlay-technology .query-maker { + flex: 1 0 0; + padding: 0 8px; + margin-bottom: 8px; + border-radius: 0 0 $radius $radius; + background-color: $g2-kevlar; +} +.overlay-technology .query-maker--tabs { + margin-top: 0; +} +.overlay-technology .query-maker--tab-contents { + margin-bottom: 8px; +} diff --git a/ui/src/utils/queryUrlGenerator.js b/ui/src/utils/queryUrlGenerator.js index fba968da74..28c4b93eab 100644 --- a/ui/src/utils/queryUrlGenerator.js +++ b/ui/src/utils/queryUrlGenerator.js @@ -1,11 +1,12 @@ import AJAX from 'utils/ajax' -export const proxy = async ({source, query, db, rp}) => { +export const proxy = async ({source, query, db, rp, tempVars}) => { try { return await AJAX({ method: 'POST', url: source, data: { + tempVars, query, db, rp, diff --git a/ui/yarn.lock b/ui/yarn.lock index e038b442a4..83f59bc6c3 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -4357,6 +4357,10 @@ lodash.merge@^4.4.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.0.tgz#69884ba144ac33fe699737a6086deffadd0f89c5" +lodash.mergewith@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.0.tgz#150cf0a16791f5903b8891eab154609274bdea55" + lodash.pick@^4.2.0, lodash.pick@^4.2.1, lodash.pick@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" @@ -4784,9 +4788,9 @@ node-pre-gyp@^0.6.29: tar "~2.2.1" tar-pack "~3.3.0" -node-sass@^3.5.3: - version "3.13.1" - resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-3.13.1.tgz#7240fbbff2396304b4223527ed3020589c004fc2" +node-sass@^4.5.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.5.2.tgz#4012fa2bd129b1d6365117e88d9da0500d99da64" dependencies: async-foreach "^0.1.3" chalk "^1.1.1" @@ -4797,13 +4801,15 @@ node-sass@^3.5.3: in-publish "^2.0.0" lodash.assign "^4.2.0" lodash.clonedeep "^4.3.2" + lodash.mergewith "^4.6.0" meow "^3.7.0" mkdirp "^0.5.1" nan "^2.3.2" node-gyp "^3.3.1" npmlog "^4.0.0" - request "^2.61.0" + request "^2.79.0" sass-graph "^2.1.1" + stdout-stream "^1.4.0" node-uuid@^1.4.7: version "1.4.7" @@ -6157,7 +6163,7 @@ request-progress@~2.0.1: dependencies: throttleit "^1.0.0" -request@2, request@^2.55.0, request@^2.61.0, request@^2.74.0, request@^2.79.0, request@~2.79.0: +request@2, request@^2.55.0, request@^2.74.0, request@^2.79.0, request@~2.79.0: version "2.79.0" resolved "https://registry.yarnpkg.com/request/-/request-2.79.0.tgz#4dfe5bf6be8b8cdc37fcf93e04b65577722710de" dependencies: @@ -6676,6 +6682,12 @@ sshpk@^1.7.0: version "1.3.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" +stdout-stream@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/stdout-stream/-/stdout-stream-1.4.0.tgz#a2c7c8587e54d9427ea9edb3ac3f2cd522df378b" + dependencies: + readable-stream "^2.0.1" + stream-browserify@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db"