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,
+}) => (
+
+)
+
+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}) => (
-