diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 8d2f9e8fe5..b697f14b42 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.3.9.0 +current_version = 1.3.10.0 files = README.md server/swagger.json parse = (?P\d+)\.(?P\d+)\.(?P\d+)\.(?P\d+) serialize = {major}.{minor}.{patch}.{release} diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a908e263..2615c899d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +## v1.3.11.0 [unreleased] +### Bug Fixes +### Features +### UI Improvements + +## v1.3.10.0 [2017-10-24] +### Bug Fixes +1. [#2095](https://github.com/influxdata/chronograf/pull/2095): Improve the copy in the retention policy edit page +1. [#2122](https://github.com/influxdata/chronograf/pull/2122): Fix 'Could not connect to source' bug on source creation with unsafe-ssl +1. [#2093](https://github.com/influxdata/chronograf/pull/2093): Fix when exporting `SHOW DATABASES` CSV has bad data +1. [#2098](https://github.com/influxdata/chronograf/pull/2098): Fix not-equal-to highlighting in Kapacitor Rule Builder +1. [#2130](https://github.com/influxdata/chronograf/pull/2130): Fix undescriptive error messages for database and retention policy creation +1. [#2135](https://github.com/influxdata/chronograf/pull/2135): Fix drag and drop cancel button when writing data in the data explorer +1. [#2128](https://github.com/influxdata/chronograf/pull/2128): Fix persistence of "SELECT AS" statements in queries + +### Features +1. [#2083](https://github.com/influxdata/chronograf/pull/2083): Every dashboard can now have its own time range +1. [#2045](https://github.com/influxdata/chronograf/pull/2045): Add CSV download option in dashboard cells +1. [#2133](https://github.com/influxdata/chronograf/pull/2133): Implicitly prepend source urls with http:// +1. [#2127](https://github.com/influxdata/chronograf/pull/2127): Add support for graph zooming and point display on the millisecond-level +1. [#2103](https://github.com/influxdata/chronograf/pull/2103): Add manual refresh button for Dashboard, Data Explorer, and Host Pages + +### UI Improvements +1. [#2111](https://github.com/influxdata/chronograf/pull/2111): Increase size of Cell Editor query tabs to reveal more of their query strings +1. [#2120](https://github.com/influxdata/chronograf/pull/2120): Improve appearance of Admin Page tabs on smaller screens +1. [#2119](https://github.com/influxdata/chronograf/pull/2119): Add cancel button to Tickscript editor +1. [#2104](https://github.com/influxdata/chronograf/pull/2104): Redesign dashboard naming & renaming interaction +1. [#2104](https://github.com/influxdata/chronograf/pull/2104): Redesign dashboard switching dropdown + ## v1.3.9.0 [2017-10-06] ### Bug Fixes 1. [#2004](https://github.com/influxdata/chronograf/pull/2004): Fix Data Explorer disappearing query templates in dropdown @@ -5,6 +34,12 @@ 1. [#2015](https://github.com/influxdata/chronograf/pull/2015): Chronograf shows real status for windows hosts when metrics are saved in non-default db - thank you, @ar7z1! 1. [#2019](https://github.com/influxdata/chronograf/pull/2006): Fix false error warning for duplicate kapacitor name 1. [#2018](https://github.com/influxdata/chronograf/pull/2018): Fix unresponsive display options and query builder in dashboards +1. [#2004](https://github.com/influxdata/chronograf/pull/2004): Fix DE query templates dropdown disappearance +1. [#2006](https://github.com/influxdata/chronograf/pull/2006): Fix no alert for duplicate db name +1. [#2015](https://github.com/influxdata/chronograf/pull/2015): Chronograf shows real status for windows hosts when metrics are saved in non-default db - thank you, @ar7z1! +1. [#2019](https://github.com/influxdata/chronograf/pull/2006): Fix false error warning for duplicate kapacitor name +1. [#2018](https://github.com/influxdata/chronograf/pull/2018): Fix unresponsive display options and query builder in dashboards +1. [#1996](https://github.com/influxdata/chronograf/pull/1996): Able to switch InfluxDB sources on a per graph basis ### Features 1. [#1885](https://github.com/influxdata/chronograf/pull/1885): Add `fill` options to data explorer and dashboard queries @@ -13,6 +48,7 @@ 1. [#1992](https://github.com/influxdata/chronograf/pull/1992): Add .csv download button to data explorer 1. [#2082](https://github.com/influxdata/chronograf/pull/2082): Add Data Explorer InfluxQL query and location query synchronization, so queries can be shared via a a URL 1. [#1996](https://github.com/influxdata/chronograf/pull/1996): Able to switch InfluxDB sources on a per graph basis +1. [#2041](https://github.com/influxdata/chronograf/pull/2041): Add now() as an option in the Dashboard date picker ### UI Improvements 1. [#2002](https://github.com/influxdata/chronograf/pull/2002): Require a second click when deleting a dashboard cell @@ -27,6 +63,8 @@ 1. [#2057](https://github.com/influxdata/chronograf/pull/2057): Improve appearance of placeholder text in inputs 1. [#2057](https://github.com/influxdata/chronograf/pull/2057): Add ability to use "Default" values in Source Connection form 1. [#2069](https://github.com/influxdata/chronograf/pull/2069): Display name & port in SourceIndicator tooltip +1. [#2078](https://github.com/influxdata/chronograf/pull/2078): Improve UX/UI of Kapacitor Rule Builder to be more intuitive +1. [#2078](https://github.com/influxdata/chronograf/pull/2078): Rename "Measurements" to "Measurements & Tags" in Query Builder ## v1.3.8.1 [unreleased] ### Bug Fixes diff --git a/README.md b/README.md index 3e66f2fce9..8ae69adb63 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Change the default root path of the Chronograf server with the `--basepath` opti ## Versions -The most recent version of Chronograf is [v1.3.9.0](https://www.influxdata.com/downloads/). +The most recent version of Chronograf is [v1.3.10.0](https://www.influxdata.com/downloads/). Spotted a bug or have a feature request? Please open [an issue](https://github.com/influxdata/chronograf/issues/new)! @@ -138,7 +138,7 @@ By default, chronograf runs on port `8888`. To get started right away with Docker, you can pull down our latest release: ```sh -docker pull chronograf:1.3.9.0 +docker pull chronograf:1.3.10.0 ``` ### From Source diff --git a/chronograf.go b/chronograf.go index 16187e1591..ae2f6a10e5 100644 --- a/chronograf.go +++ b/chronograf.go @@ -205,14 +205,23 @@ func (g *GroupByVar) Exec(query string) { durStr := query[start+len(whereClause):] // attempt to parse out a relative time range - dur, err := g.parseRelative(durStr) + // locate duration literal start + prefix := "time > now() - " + lowerDuration, err := g.parseRelative(durStr, prefix) if err == nil { - // we parsed relative duration successfully - g.Duration = dur - return + prefix := "time < now() - " + upperDuration, err := g.parseRelative(durStr, prefix) + if err != nil { + g.Duration = lowerDuration + return + } + g.Duration = lowerDuration - upperDuration + if g.Duration < 0 { + g.Duration = -g.Duration + } } - dur, err = g.parseAbsolute(durStr) + dur, err := g.parseAbsolute(durStr) if err == nil { // we found an absolute time range g.Duration = dur @@ -223,9 +232,7 @@ func (g *GroupByVar) Exec(query string) { // InfluxQL query following the "where" keyword. For example, in the fragment // "time > now() - 180d GROUP BY :interval:", parseRelative would return a // duration equal to 180d -func (g *GroupByVar) parseRelative(fragment string) (time.Duration, error) { - // locate duration literal start - prefix := "time > now() - " +func (g *GroupByVar) parseRelative(fragment string, prefix string) (time.Duration, error) { start := strings.Index(fragment, prefix) if start == -1 { return time.Duration(0), errors.New("not a relative duration") @@ -298,11 +305,19 @@ func (g *GroupByVar) parseAbsolute(fragment string) (time.Duration, error) { } func (g *GroupByVar) String() string { - duration := int64(g.Duration/time.Second) / int64(g.Resolution) * 3 - if duration == 0 { - duration = 1 + // The function is: ((total_seconds * millisecond_converstion) / group_by) = pixels / 3 + // Number of points given the pixels + pixels := float64(g.Resolution) / 3.0 + msPerPixel := float64(g.Duration/time.Millisecond) / pixels + secPerPixel := float64(g.Duration/time.Second) / pixels + if secPerPixel < 1.0 { + if msPerPixel < 1.0 { + msPerPixel = 1.0 + } + return "time(" + strconv.FormatInt(int64(msPerPixel), 10) + "ms)" } - return "time(" + strconv.Itoa(int(duration)) + "s)" + // If groupby is more than 1 second round to the second + return "time(" + strconv.FormatInt(int64(secPerPixel), 10) + "s)" } func (g *GroupByVar) Name() string { @@ -495,8 +510,10 @@ type TriggerValues struct { // Field represent influxql fields and functions from the UI type Field struct { - Field string `json:"field"` - Funcs []string `json:"funcs"` + Value interface{} `json:"value"` + Type string `json:"type"` + Alias string `json:"alias"` + Args []Field `json:"args,omitempty"` } // GroupBy represents influxql group by tags from the UI diff --git a/chronograf_test.go b/chronograf_test.go index f08e14c6d9..850674a4d4 100644 --- a/chronograf_test.go +++ b/chronograf_test.go @@ -2,54 +2,61 @@ package chronograf_test import ( "testing" - "time" "github.com/influxdata/chronograf" ) func Test_GroupByVar(t *testing.T) { gbvTests := []struct { - name string - query string - expected time.Duration - resolution uint // the screen resolution to render queries into - reportingInterval time.Duration + name string + query string + want string + resolution uint // the screen resolution to render queries into }{ { - "relative time", - "SELECT mean(usage_idle) FROM cpu WHERE time > now() - 180d GROUP BY :interval:", - 4320 * time.Hour, - 1000, - 10 * time.Second, + name: "relative time only lower bound with one day of duration", + query: "SELECT mean(usage_idle) FROM cpu WHERE time > now() - 1d GROUP BY :interval:", + resolution: 1000, + want: "time(259s)", }, { - "absolute time", - "SELECT mean(usage_idle) FROM cpu WHERE time > '1985-10-25T00:01:00Z' and time < '1985-10-25T00:02:00Z' GROUP BY :interval:", - 1 * time.Minute, - 1000, - 10 * time.Second, + name: "relative time with relative upper bound with one minute of duration", + query: "SELECT mean(usage_idle) FROM cpu WHERE time > now() - 3m AND time < now() - 2m GROUP BY :interval:", + resolution: 1000, + want: "time(180ms)", }, { - "absolute time with nano", - "SELECT mean(usage_idle) FROM cpu WHERE time > '2017-07-24T15:33:42.994Z' and time < '2017-08-24T15:33:42.994Z' GROUP BY :interval:", - 744 * time.Hour, - 1000, - 10 * time.Second, + name: "relative time with relative lower bound and now upper with one day of duration", + query: "SELECT mean(usage_idle) FROM cpu WHERE time > now() - 1d AND time < now() GROUP BY :interval:", + resolution: 1000, + want: "time(259s)", + }, + { + name: "absolute time with one minute of duration", + query: "SELECT mean(usage_idle) FROM cpu WHERE time > '1985-10-25T00:01:00Z' and time < '1985-10-25T00:02:00Z' GROUP BY :interval:", + resolution: 1000, + want: "time(180ms)", + }, + { + name: "absolute time with nano seconds and zero duraiton", + query: "SELECT mean(usage_idle) FROM cpu WHERE time > '2017-07-24T15:33:42.994Z' and time < '2017-07-24T15:33:42.994Z' GROUP BY :interval:", + resolution: 1000, + want: "time(1ms)", }, } for _, test := range gbvTests { t.Run(test.name, func(t *testing.T) { gbv := chronograf.GroupByVar{ - Var: ":interval:", - Resolution: test.resolution, - ReportingInterval: test.reportingInterval, + Var: ":interval:", + Resolution: test.resolution, } gbv.Exec(test.query) + got := gbv.String() - if gbv.Duration != test.expected { - t.Fatalf("%q - durations not equal! Want: %s, Got: %s", test.name, test.expected, gbv.Duration) + if got != test.want { + t.Fatalf("%q - durations not equal! Want: %s, Got: %s", test.name, test.want, got) } }) } diff --git a/influx/query.go b/influx/query.go index 2c6a9636b1..2346fde7c4 100644 --- a/influx/query.go +++ b/influx/query.go @@ -2,6 +2,7 @@ package influx import ( "fmt" + "strconv" "strings" "time" @@ -117,31 +118,25 @@ func Convert(influxQL string) (chronograf.QueryConfig, error) { } // Add fill to queryConfig only if there's a `GROUP BY time` switch stmt.Fill { - default: - return raw, nil case influxql.NullFill: qc.Fill = "null" - case influxql.NoFill: qc.Fill = "none" - case influxql.NumberFill: qc.Fill = fmt.Sprint(stmt.FillValue) - case influxql.PreviousFill: qc.Fill = "previous" - case influxql.LinearFill: qc.Fill = "linear" - + default: + return raw, nil } case *influxql.VarRef: qc.GroupBy.Tags = append(qc.GroupBy.Tags, v.Val) } } - fields := make(map[string][]string) - order := make(map[string]int) + qc.Fields = []chronograf.Field{} for _, fld := range stmt.Fields { switch f := fld.Expr.(type) { default: @@ -151,42 +146,55 @@ func Convert(influxQL string) (chronograf.QueryConfig, error) { if _, ok = supportedFuncs[f.Name]; !ok { return raw, nil } - // Query configs only support single argument functions - if len(f.Args) != 1 { - return raw, nil - } - ref, ok := f.Args[0].(*influxql.VarRef) - // query config only support fields in the function - if !ok { - return raw, nil - } - // We only support field strings - if ref.Type != influxql.Unknown { - return raw, nil - } - if call, ok := fields[ref.Val]; !ok { - order[ref.Val] = len(fields) - fields[ref.Val] = []string{f.Name} - } else { - fields[ref.Val] = append(call, f.Name) + + fldArgs := []chronograf.Field{} + for _, arg := range f.Args { + switch ref := arg.(type) { + case *influxql.VarRef: + fldArgs = append(fldArgs, chronograf.Field{ + Value: ref.Val, + Type: "field", + }) + case *influxql.IntegerLiteral: + fldArgs = append(fldArgs, chronograf.Field{ + Value: strconv.FormatInt(ref.Val, 10), + Type: "integer", + }) + case *influxql.NumberLiteral: + fldArgs = append(fldArgs, chronograf.Field{ + Value: strconv.FormatFloat(ref.Val, 'f', -1, 64), + Type: "number", + }) + case *influxql.RegexLiteral: + fldArgs = append(fldArgs, chronograf.Field{ + Value: ref.Val.String(), + Type: "regex", + }) + case *influxql.Wildcard: + fldArgs = append(fldArgs, chronograf.Field{ + Value: "*", + Type: "wildcard", + }) + default: + return raw, nil + } } + + qc.Fields = append(qc.Fields, chronograf.Field{ + Value: f.Name, + Type: "func", + Alias: fld.Alias, + Args: fldArgs, + }) case *influxql.VarRef: if f.Type != influxql.Unknown { return raw, nil } - if _, ok := fields[f.Val]; !ok { - order[f.Val] = len(fields) - fields[f.Val] = []string{} - } - } - } - - qc.Fields = make([]chronograf.Field, len(fields)) - for fld, funcs := range fields { - i := order[fld] - qc.Fields[i] = chronograf.Field{ - Field: fld, - Funcs: funcs, + qc.Fields = append(qc.Fields, chronograf.Field{ + Value: f.Val, + Type: "field", + Alias: fld.Alias, + }) } } @@ -451,16 +459,19 @@ func isTagFilter(exp influxql.Expr) (tagFilter, bool) { } var supportedFuncs = map[string]bool{ - "mean": true, - "median": true, - "count": true, - "min": true, - "max": true, - "sum": true, - "first": true, - "last": true, - "spread": true, - "stddev": true, + "mean": true, + "median": true, + "count": true, + "min": true, + "max": true, + "sum": true, + "first": true, + "last": true, + "spread": true, + "stddev": true, + "percentile": true, + "top": true, + "bottom": true, } // shortDur converts duration into the queryConfig duration format diff --git a/influx/query_test.go b/influx/query_test.go index 3314c2e796..d01a46fb53 100644 --- a/influx/query_test.go +++ b/influx/query_test.go @@ -1,9 +1,9 @@ package influx import ( - "reflect" "testing" + "github.com/google/go-cmp/cmp" "github.com/influxdata/chronograf" ) @@ -24,20 +24,20 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_idle", - Funcs: []string{}, + Value: "usage_idle", + Type: "field", }, chronograf.Field{ - Field: "usage_guest_nice", - Funcs: []string{}, + Value: "usage_guest_nice", + Type: "field", }, chronograf.Field{ - Field: "usage_system", - Funcs: []string{}, + Value: "usage_system", + Type: "field", }, chronograf.Field{ - Field: "usage_guest", - Funcs: []string{}, + Value: "usage_guest", + Type: "field", }, }, Tags: map[string][]string{}, @@ -55,17 +55,43 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_idle", - Funcs: []string{ - "mean", - "median", + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_idle", + Type: "field", + }, }, }, chronograf.Field{ - Field: "usage_guest_nice", - Funcs: []string{ - "count", - "mean", + Value: "median", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_idle", + Type: "field", + }, + }, + }, + chronograf.Field{ + Value: "count", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_guest_nice", + Type: "field", + }, + }, + }, + chronograf.Field{ + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_guest_nice", + Type: "field", + }, }, }, }, @@ -108,8 +134,8 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_user", - Funcs: []string{}, + Value: "usage_user", + Type: "field", }, }, Tags: map[string][]string{"host": []string{"myhost"}}, @@ -144,8 +170,8 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_user", - Funcs: []string{}, + Value: "usage_user", + Type: "field", }, }, Tags: map[string][]string{"host": []string{"myhost"}}, @@ -169,8 +195,8 @@ func TestConvert(t *testing.T) { Tags: map[string][]string{}, Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_user", - Funcs: []string{}, + Value: "usage_user", + Type: "field", }, }, GroupBy: chronograf.GroupBy{ @@ -216,8 +242,8 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_user", - Funcs: []string{}, + Value: "usage_user", + Type: "field", }, }, Tags: map[string][]string{}, @@ -236,8 +262,8 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_user", - Funcs: []string{}, + Value: "usage_user", + Type: "field", }, }, Tags: map[string][]string{"host": []string{"myhost"}}, @@ -261,8 +287,8 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_user", - Funcs: []string{}, + Value: "usage_user", + Type: "field", }, }, Tags: map[string][]string{ @@ -305,8 +331,8 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_user", - Funcs: []string{}, + Value: "usage_user", + Type: "field", }, }, Tags: map[string][]string{ @@ -332,20 +358,20 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_idle", - Funcs: []string{}, + Value: "usage_idle", + Type: "field", }, chronograf.Field{ - Field: "usage_guest_nice", - Funcs: []string{}, + Value: "usage_guest_nice", + Type: "field", }, chronograf.Field{ - Field: "usage_system", - Funcs: []string{}, + Value: "usage_system", + Type: "field", }, chronograf.Field{ - Field: "usage_guest", - Funcs: []string{}, + Value: "usage_guest", + Type: "field", }, }, Tags: map[string][]string{ @@ -379,20 +405,20 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_idle", - Funcs: []string{}, + Value: "usage_idle", + Type: "field", }, chronograf.Field{ - Field: "usage_guest_nice", - Funcs: []string{}, + Value: "usage_guest_nice", + Type: "field", }, chronograf.Field{ - Field: "usage_system", - Funcs: []string{}, + Value: "usage_system", + Type: "field", }, chronograf.Field{ - Field: "usage_guest", - Funcs: []string{}, + Value: "usage_guest", + Type: "field", }, }, Tags: map[string][]string{ @@ -426,9 +452,13 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_idle", - Funcs: []string{ - "mean", + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_idle", + Type: "field", + }, }, }, }, @@ -453,9 +483,13 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_idle", - Funcs: []string{ - "mean", + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_idle", + Type: "field", + }, }, }, }, @@ -473,16 +507,21 @@ func TestConvert(t *testing.T) { }, { name: "Test implicit null fill accepted and made explicit", - influxQL: `SELECT mean("usage_idle") FROM "telegraf"."autogen"."cpu" WHERE time > now() - 15m GROUP BY time(1m)`, + influxQL: `SELECT mean("usage_idle") as "mean_usage_idle" FROM "telegraf"."autogen"."cpu" WHERE time > now() - 15m GROUP BY time(1m)`, want: chronograf.QueryConfig{ Database: "telegraf", Measurement: "cpu", RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_idle", - Funcs: []string{ - "mean", + Value: "mean", + Type: "func", + Alias: "mean_usage_idle", + Args: []chronograf.Field{ + { + Value: "usage_idle", + Type: "field", + }, }, }, }, @@ -498,6 +537,147 @@ func TestConvert(t *testing.T) { }, }, }, + { + name: "Test percentile with a number parameter", + influxQL: `SELECT percentile("usage_idle", 3.14) as "mean_usage_idle" FROM "telegraf"."autogen"."cpu" WHERE time > now() - 15m GROUP BY time(1m)`, + want: chronograf.QueryConfig{ + Database: "telegraf", + Measurement: "cpu", + RetentionPolicy: "autogen", + Fields: []chronograf.Field{ + chronograf.Field{ + Value: "percentile", + Type: "func", + Alias: "mean_usage_idle", + Args: []chronograf.Field{ + { + Value: "usage_idle", + Type: "field", + }, + chronograf.Field{ + Value: "3.14", + Type: "number", + }, + }, + }, + }, + GroupBy: chronograf.GroupBy{ + Time: "1m", + Tags: []string{}, + }, + Tags: map[string][]string{}, + AreTagsAccepted: false, + Fill: "null", + Range: &chronograf.DurationRange{ + Lower: "now() - 15m", + }, + }, + }, + { + name: "Test top with 2 arguments", + influxQL: `SELECT TOP("water_level","location",2) FROM "h2o_feet"`, + want: chronograf.QueryConfig{ + Measurement: "h2o_feet", + Fields: []chronograf.Field{ + chronograf.Field{ + Value: "top", + Type: "func", + Args: []chronograf.Field{ + { + Value: "water_level", + Type: "field", + }, + chronograf.Field{ + Value: "location", + Type: "field", + }, + chronograf.Field{ + Value: "2", + Type: "integer", + }, + }, + }, + }, + GroupBy: chronograf.GroupBy{ + Tags: []string{}, + }, + Tags: map[string][]string{}, + AreTagsAccepted: false, + }, + }, + { + name: "count of a regex", + influxQL: ` SELECT COUNT(/water/) FROM "h2o_feet"`, + want: chronograf.QueryConfig{ + Measurement: "h2o_feet", + Fields: []chronograf.Field{ + chronograf.Field{ + Value: "count", + Type: "func", + Args: []chronograf.Field{ + { + Value: "water", + Type: "regex", + }, + }, + }, + }, + GroupBy: chronograf.GroupBy{ + Tags: []string{}, + }, + Tags: map[string][]string{}, + AreTagsAccepted: false, + }, + }, + { + name: "count with aggregate", + influxQL: `SELECT COUNT(water) as "count_water" FROM "h2o_feet"`, + want: chronograf.QueryConfig{ + Measurement: "h2o_feet", + Fields: []chronograf.Field{ + chronograf.Field{ + Value: "count", + Type: "func", + Alias: "count_water", + Args: []chronograf.Field{ + { + Value: "water", + Type: "field", + }, + }, + }, + }, + GroupBy: chronograf.GroupBy{ + Tags: []string{}, + }, + Tags: map[string][]string{}, + AreTagsAccepted: false, + }, + }, + { + name: "count of a wildcard", + influxQL: ` SELECT COUNT(*) FROM "h2o_feet"`, + want: chronograf.QueryConfig{ + Measurement: "h2o_feet", + Fields: []chronograf.Field{ + chronograf.Field{ + Value: "count", + Type: "func", + Args: []chronograf.Field{ + { + Value: "*", + Type: "wildcard", + }, + }, + }, + }, + GroupBy: chronograf.GroupBy{ + Tags: []string{}, + }, + Tags: map[string][]string{}, + AreTagsAccepted: false, + }, + }, { name: "Test fill number (int) accepted", influxQL: `SELECT mean("usage_idle") FROM "telegraf"."autogen"."cpu" WHERE time > now() - 15m GROUP BY time(1m) FILL(1337)`, @@ -507,9 +687,13 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_idle", - Funcs: []string{ - "mean", + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_idle", + Type: "field", + }, }, }, }, @@ -534,9 +718,13 @@ func TestConvert(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ chronograf.Field{ - Field: "usage_idle", - Funcs: []string{ - "mean", + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_idle", + Type: "field", + }, }, }, }, @@ -573,8 +761,8 @@ func TestConvert(t *testing.T) { t.Errorf("Convert() = %s, want %s", *got.RawText, tt.RawText) } } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Convert() = \n%#v\n want \n%#v\n", got, tt.want) + if !cmp.Equal(got, tt.want) { + t.Errorf("Convert() = %s", cmp.Diff(got, tt.want)) } }) } diff --git a/influx/templates_test.go b/influx/templates_test.go index d1d7670b65..bb6ffc4bb9 100644 --- a/influx/templates_test.go +++ b/influx/templates_test.go @@ -274,16 +274,25 @@ func TestGroupByVarString(t *testing.T) { ReportingInterval: 10 * time.Second, Duration: 24 * time.Hour, }, - want: "time(369s)", + want: "time(370s)", }, { - name: "String() outputs a minimum of 1s intervals", + name: "String() milliseconds if less than one second intervals", tvar: &chronograf.GroupByVar{ Resolution: 100000, ReportingInterval: 10 * time.Second, Duration: time.Hour, }, - want: "time(1s)", + want: "time(107ms)", + }, + { + name: "String() milliseconds if less than one millisecond", + tvar: &chronograf.GroupByVar{ + Resolution: 100000, + ReportingInterval: 10 * time.Second, + Duration: time.Second, + }, + want: "time(1ms)", }, } for _, tt := range tests { diff --git a/kapacitor/ast.go b/kapacitor/ast.go index d7578b09a3..2623339fc1 100644 --- a/kapacitor/ast.go +++ b/kapacitor/ast.go @@ -422,15 +422,26 @@ func Reverse(script chronograf.TICKScript) (chronograf.AlertRule, error) { } else { rule.Query.GroupBy.Time = commonVars.Period rule.Every = commonVars.Every - funcs := []string{} if fieldFunc.Func != "" { - funcs = append(funcs, fieldFunc.Func) - } - rule.Query.Fields = []chronograf.Field{ - { - Field: fieldFunc.Field, - Funcs: funcs, - }, + rule.Query.Fields = []chronograf.Field{ + { + Type: "func", + Value: fieldFunc.Func, + Args: []chronograf.Field{ + { + Value: fieldFunc.Field, + Type: "field", + }, + }, + }, + } + } else { + rule.Query.Fields = []chronograf.Field{ + { + Type: "field", + Value: fieldFunc.Field, + }, + } } } diff --git a/kapacitor/ast_test.go b/kapacitor/ast_test.go index 07f98e5c9f..666d83c217 100644 --- a/kapacitor/ast_test.go +++ b/kapacitor/ast_test.go @@ -4,6 +4,7 @@ import ( "reflect" "testing" + "github.com/google/go-cmp/cmp" "github.com/influxdata/chronograf" ) @@ -112,10 +113,14 @@ func TestReverse(t *testing.T) { Measurement: "cpu", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{ - "mean", + Value: "mean", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, }, + Type: "func", }, }, GroupBy: chronograf.GroupBy{ @@ -220,9 +225,15 @@ func TestReverse(t *testing.T) { Measurement: "cpu", RetentionPolicy: "autogen", Fields: []chronograf.Field{ - chronograf.Field{ - Field: "usage_user", - Funcs: []string{"mean"}, + { + Value: "mean", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, + Type: "func", }, }, Tags: map[string][]string{ @@ -360,8 +371,14 @@ func TestReverse(t *testing.T) { Measurement: "haproxy", Fields: []chronograf.Field{ { - Field: "status", - Funcs: []string{"last"}, + Value: "last", + Args: []chronograf.Field{ + { + Value: "status", + Type: "field", + }, + }, + Type: "func", }, }, GroupBy: chronograf.GroupBy{ @@ -475,8 +492,14 @@ func TestReverse(t *testing.T) { Measurement: "haproxy", Fields: []chronograf.Field{ { - Field: "status", - Funcs: []string{"last"}, + Value: "last", + Args: []chronograf.Field{ + { + Value: "status", + Type: "field", + }, + }, + Type: "func", }, }, GroupBy: chronograf.GroupBy{ @@ -592,8 +615,14 @@ func TestReverse(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, + Type: "func", }, }, Tags: map[string][]string{ @@ -717,8 +746,14 @@ func TestReverse(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, + Type: "func", }, }, Tags: map[string][]string{ @@ -842,8 +877,14 @@ func TestReverse(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, + Type: "func", }, }, Tags: map[string][]string{ @@ -955,8 +996,8 @@ func TestReverse(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{}, + Value: "usage_user", + Type: "field", }, }, Tags: map[string][]string{ @@ -1090,8 +1131,14 @@ trigger RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, + Type: "func", }, }, Tags: map[string][]string{ @@ -1226,8 +1273,14 @@ trigger RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, + Type: "func", }, }, Tags: map[string][]string{ @@ -1441,8 +1494,8 @@ trigger Measurement: "cq", Fields: []chronograf.Field{ { - Field: "queryOk", - Funcs: []string{}, + Value: "queryOk", + Type: "field", }, }, GroupBy: chronograf.GroupBy{ @@ -1465,8 +1518,8 @@ trigger if tt.want.Query != nil { if got.Query == nil { t.Errorf("Reverse() = got nil QueryConfig") - } else if !reflect.DeepEqual(*got.Query, *tt.want.Query) { - t.Errorf("Reverse() = QueryConfig not equal\n%#v\n, want \n%#v\n", *got.Query, *tt.want.Query) + } else if !cmp.Equal(*got.Query, *tt.want.Query) { + t.Errorf("Reverse() = QueryConfig not equal %s", cmp.Diff(*got.Query, *tt.want.Query)) } } } diff --git a/kapacitor/client_test.go b/kapacitor/client_test.go index 1932b1a6c9..61ff9c93fd 100644 --- a/kapacitor/client_test.go +++ b/kapacitor/client_test.go @@ -322,8 +322,8 @@ trigger Measurement: "cq", Fields: []chronograf.Field{ { - Field: "queryOk", - Funcs: []string{}, + Value: "queryOk", + Type: "field", }, }, GroupBy: chronograf.GroupBy{ @@ -645,8 +645,8 @@ trigger Measurement: "cq", Fields: []chronograf.Field{ { - Field: "queryOk", - Funcs: []string{}, + Value: "queryOk", + Type: "field", }, }, GroupBy: chronograf.GroupBy{ diff --git a/kapacitor/data.go b/kapacitor/data.go index 625e68a3b0..444ea954c8 100644 --- a/kapacitor/data.go +++ b/kapacitor/data.go @@ -41,15 +41,18 @@ func Data(rule chronograf.AlertRule) (string, error) { } value := "" for _, field := range rule.Query.Fields { - for _, fnc := range field.Funcs { + if field.Type == "func" && len(field.Args) > 0 && field.Args[0].Type == "field" { // Only need a window if we have an aggregate function value = value + "|window().period(period).every(every).align()\n" - value = value + fmt.Sprintf(`|%s('%s').as('value')`, fnc, fld) + value = value + fmt.Sprintf(`|%s('%s').as('value')`, field.Value, field.Args[0].Value) break // only support a single field } if value != "" { break // only support a single field } + if field.Type == "field" { + value = fmt.Sprintf(`|eval(lambda: "%s").as('value')`, field.Value) + } } if value == "" { value = fmt.Sprintf(`|eval(lambda: "%s").as('value')`, fld) diff --git a/kapacitor/influxout_test.go b/kapacitor/influxout_test.go index fccb485302..87b6ee7c36 100644 --- a/kapacitor/influxout_test.go +++ b/kapacitor/influxout_test.go @@ -31,8 +31,14 @@ func TestInfluxOut(t *testing.T) { Query: &chronograf.QueryConfig{ Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, }, }, }, diff --git a/kapacitor/tickscripts_test.go b/kapacitor/tickscripts_test.go index dc70d93fe1..4af7356168 100644 --- a/kapacitor/tickscripts_test.go +++ b/kapacitor/tickscripts_test.go @@ -4,6 +4,7 @@ import ( "fmt" "testing" + "github.com/google/go-cmp/cmp" "github.com/influxdata/chronograf" "github.com/sergi/go-diff/diffmatchpatch" ) @@ -26,8 +27,14 @@ func TestGenerate(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, }, }, Tags: map[string][]string{ @@ -71,8 +78,14 @@ func TestThreshold(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, }, }, Tags: map[string][]string{ @@ -215,8 +228,14 @@ func TestThresholdStringCrit(t *testing.T) { Measurement: "haproxy", Fields: []chronograf.Field{ { - Field: "status", - Funcs: []string{"last"}, + Value: "last", + Type: "func", + Args: []chronograf.Field{ + { + Value: "status", + Type: "field", + }, + }, }, }, GroupBy: chronograf.GroupBy{ @@ -353,8 +372,14 @@ func TestThresholdStringCritGreater(t *testing.T) { Measurement: "haproxy", Fields: []chronograf.Field{ { - Field: "status", - Funcs: []string{"last"}, + Value: "last", + Type: "func", + Args: []chronograf.Field{ + { + Value: "status", + Type: "field", + }, + }, }, }, GroupBy: chronograf.GroupBy{ @@ -489,8 +514,14 @@ func TestThresholdDetail(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, }, }, Tags: map[string][]string{ @@ -636,8 +667,14 @@ func TestThresholdInsideRange(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, }, }, Tags: map[string][]string{ @@ -782,8 +819,14 @@ func TestThresholdOutsideRange(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, }, }, Tags: map[string][]string{ @@ -927,8 +970,8 @@ func TestThresholdNoAggregate(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{}, + Value: "usage_user", + Type: "field", }, }, Tags: map[string][]string{ @@ -1064,8 +1107,14 @@ func TestRelative(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, }, }, Tags: map[string][]string{ @@ -1221,8 +1270,14 @@ func TestRelativeChange(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, }, }, Tags: map[string][]string{ @@ -1375,8 +1430,14 @@ func TestDeadman(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "usage_user", - Funcs: []string{"mean"}, + Value: "mean", + Type: "func", + Args: []chronograf.Field{ + { + Value: "usage_user", + Type: "field", + }, + }, }, }, Tags: map[string][]string{ @@ -1488,9 +1549,7 @@ trigger continue } if got != tt.want { - diff := diffmatchpatch.New() - delta := diff.DiffMain(string(tt.want), string(got), true) - t.Errorf("%q\n%s", tt.name, diff.DiffPrettyText(delta)) + t.Errorf("%q\n%s", tt.name, cmp.Diff(string(tt.want), string(got))) } } } diff --git a/kapacitor/vars.go b/kapacitor/vars.go index 7031a9c844..99c4d5a6a0 100644 --- a/kapacitor/vars.go +++ b/kapacitor/vars.go @@ -133,7 +133,7 @@ func window(rule chronograf.AlertRule) string { } // Period only makes sense if the field has a been grouped via a time duration. for _, field := range rule.Query.Fields { - if len(field.Funcs) > 0 { + if field.Type == "func" { return fmt.Sprintf("var period = %s\nvar every = %s", rule.Query.GroupBy.Time, rule.Every) } } @@ -151,12 +151,30 @@ func groupBy(q *chronograf.QueryConfig) string { } func field(q *chronograf.QueryConfig) (string, error) { - if q != nil { - for _, field := range q.Fields { - return field.Field, nil - } + if q == nil { + return "", fmt.Errorf("No fields set in query") } - return "", fmt.Errorf("No fields set in query") + if len(q.Fields) != 1 { + return "", fmt.Errorf("expect only one field but found %d", len(q.Fields)) + } + field := q.Fields[0] + if field.Type == "func" { + for _, arg := range field.Args { + if arg.Type == "field" { + f, ok := arg.Value.(string) + if !ok { + return "", fmt.Errorf("field value %v is should be string but is %T", arg.Value, arg.Value) + } + return f, nil + } + } + return "", fmt.Errorf("No fields set in query") + } + f, ok := field.Value.(string) + if !ok { + return "", fmt.Errorf("field value %v is should be string but is %T", field.Value, field.Value) + } + return f, nil } func whereFilter(q *chronograf.QueryConfig) string { diff --git a/kapacitor/vars_test.go b/kapacitor/vars_test.go index f0ac6df364..90d79390a8 100644 --- a/kapacitor/vars_test.go +++ b/kapacitor/vars_test.go @@ -22,7 +22,8 @@ func TestVarsCritStringEqual(t *testing.T) { RetentionPolicy: "autogen", Fields: []chronograf.Field{ { - Field: "status", + Value: "status", + Type: "field", }, }, GroupBy: chronograf.GroupBy{ diff --git a/server/dashboards_test.go b/server/dashboards_test.go index 30ee0d0629..622393757e 100644 --- a/server/dashboards_test.go +++ b/server/dashboards_test.go @@ -311,8 +311,8 @@ func Test_newDashboardResponse(t *testing.T) { Measurement: "grays_sports_alamanc", Fields: []chronograf.Field{ { - Field: "winning_horses", - Funcs: []string{}, + Type: "field", + Value: "winning_horses", }, }, GroupBy: chronograf.GroupBy{ diff --git a/server/kapacitors.go b/server/kapacitors.go index 438d3067e0..25ba53707c 100644 --- a/server/kapacitors.go +++ b/server/kapacitors.go @@ -381,12 +381,6 @@ func newAlertResponse(task *kapa.Task, srcID, kapaID int) *alertResponse { res.Query.Fields = make([]chronograf.Field, 0) } - for _, f := range res.Query.Fields { - if f.Funcs == nil { - f.Funcs = make([]string, 0) - } - } - if res.Query.GroupBy.Tags == nil { res.Query.GroupBy.Tags = make([]string, 0) } @@ -405,9 +399,8 @@ func ValidRuleRequest(rule chronograf.AlertRule) error { } var hasFuncs bool for _, f := range rule.Query.Fields { - if len(f.Funcs) > 0 { + if f.Type == "func" && len(f.Args) > 0 { hasFuncs = true - break } } // All kapacitor rules with functions must have a window that is applied diff --git a/server/kapacitors_test.go b/server/kapacitors_test.go index 2a55ea637c..6e66d14c0a 100644 --- a/server/kapacitors_test.go +++ b/server/kapacitors_test.go @@ -37,8 +37,14 @@ func TestValidRuleRequest(t *testing.T) { Query: &chronograf.QueryConfig{ Fields: []chronograf.Field{ { - Field: "oldmanpeabody", - Funcs: []string{"max"}, + Value: "max", + Type: "func", + Args: []chronograf.Field{ + { + Value: "oldmanpeabody", + Type: "field", + }, + }, }, }, }, @@ -52,8 +58,14 @@ func TestValidRuleRequest(t *testing.T) { Query: &chronograf.QueryConfig{ Fields: []chronograf.Field{ { - Field: "oldmanpeabody", - Funcs: []string{"max"}, + Value: "max", + Type: "func", + Args: []chronograf.Field{ + { + Value: "oldmanpeabody", + Type: "field", + }, + }, }, }, }, diff --git a/server/swagger.json b/server/swagger.json index 11c40d9060..fc478f1cd9 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -3,7 +3,7 @@ "info": { "title": "Chronograf", "description": "API endpoints for Chronograf", - "version": "1.3.9.0" + "version": "1.3.10.0" }, "schemes": ["http"], "basePath": "/chronograf/v1", @@ -2497,8 +2497,14 @@ "retentionPolicy": "autogen", "fields": [ { - "field": "usage_system", - "funcs": ["max"] + "value": "max", + "type": "func", + "args": [ + { + "value": "usage_system", + "type": "field" + } + ] } ], "tags": {}, @@ -2552,19 +2558,7 @@ "fields": { "type": "array", "items": { - "type": "object", - "properties": { - "field": { - "type": "string" - }, - "funcs": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["field", "funcs"] + "$ref": "#/definitions/Field" } }, "range": { @@ -2615,6 +2609,36 @@ } } }, + "Field": { + "type": "object", + "required": ["type", "value"], + "description": "Represents a field to be returned from an InfluxQL query", + "properties": { + "value": { + "description": + "value is the value of the field. Meaning of the value is implied by the `type` key", + "type": "string" + }, + "type": { + "description": + "type describes the field type. func is a function; field is a field reference", + "type": "string", + "enum": ["func", "field", "integer", "number", "regex", "wildcard"] + }, + "alias": { + "description": + "Alias overrides the field name in the returned response. Applies only if type is `func`", + "type": "string" + }, + "args": { + "description": "Args are the arguments to the function", + "type": "array", + "items": { + "$ref": "#/definitions/Field" + } + } + } + }, "KapacitorProperty": { "type": "object", "description": @@ -2706,8 +2730,14 @@ "retentionPolicy": "autogen", "fields": [ { - "field": "usage_system", - "funcs": ["max"] + "value": "max", + "type": "func", + "args": [ + { + "value": "usage_system", + "type": "field" + } + ] } ], "tags": {}, @@ -2790,7 +2820,6 @@ "opsgenie", "pagerduty", "victorops", - "smtp", "email", "exec", "log", @@ -2961,6 +2990,12 @@ }, "required": ["db", "rp"] }, + "Sources": { + "type": "array", + "items": { + "$ref": "#/definitions/Source" + } + }, "Source": { "type": "object", "example": { @@ -3787,7 +3822,7 @@ "queries": [ { "query": - "SELECT mean(\"usage_user\") AS \"usage_user\" FROM \"cpu\"", + "SELECT mean(\"usage_user\") AS \"mean_usage_user\" FROM \"cpu\"", "label": "%", "queryConfig": { "database": "", @@ -3795,8 +3830,15 @@ "retentionPolicy": "", "fields": [ { - "field": "usage_user", - "funcs": ["mean"] + "value": "mean", + "type": "func", + "alias": "mean_usage_user", + "args": [ + { + "value": "usage_user", + "type": "field" + } + ] } ], "tags": {}, diff --git a/ui/.gitignore b/ui/.gitignore index af00fbc26d..663f2cac8f 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -5,3 +5,4 @@ dev/ dist/ bower_components/ log/ +.tern-project diff --git a/ui/package.json b/ui/package.json index 18699542e8..6bdd366142 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "chronograf-ui", - "version": "1.3.9-0", + "version": "1.3.10-0", "private": false, "license": "AGPL-3.0", "description": "", @@ -101,7 +101,7 @@ "bootstrap": "^3.3.7", "calculate-size": "^1.1.1", "classnames": "^2.2.3", - "dygraphs": "^2.0.0", + "dygraphs": "influxdata/dygraphs", "eslint-plugin-babel": "^4.1.2", "fast.js": "^0.1.1", "fixed-data-table": "^0.6.1", @@ -109,6 +109,7 @@ "jquery": "^3.1.0", "lodash": "^4.3.0", "moment": "^2.13.0", + "nano-date": "^2.0.1", "node-uuid": "^1.4.7", "query-string": "^5.0.0", "react": "^15.0.2", diff --git a/ui/spec/dashboards/reducers/dashTimeV1Spec.js b/ui/spec/dashboards/reducers/dashTimeV1Spec.js new file mode 100644 index 0000000000..7c7ee3925e --- /dev/null +++ b/ui/spec/dashboards/reducers/dashTimeV1Spec.js @@ -0,0 +1,71 @@ +import reducer from 'src/dashboards/reducers/dashTimeV1' +import { + addDashTimeV1, + setDashTimeV1, + deleteDashboard, +} from 'src/dashboards/actions/index' + +const initialState = { + ranges: [], +} + +const emptyState = undefined +const dashboardID = 1 +const timeRange = {upper: null, lower: 'now() - 15m'} + +describe('Dashboards.Reducers.DashTimeV1', () => { + it('can load initial state', () => { + const noopAction = () => ({type: 'NOOP'}) + const actual = reducer(emptyState, noopAction) + const expected = {ranges: []} + + expect(actual).to.deep.equal(expected) + }) + + it('can add a dashboard time', () => { + const actual = reducer(emptyState, addDashTimeV1(dashboardID, timeRange)) + const expected = [{dashboardID, timeRange}] + + expect(actual.ranges).to.deep.equal(expected) + }) + + it('can delete a dashboard time range', () => { + const state = { + ranges: [{dashboardID, timeRange}], + } + const dashboard = {id: dashboardID} + + const actual = reducer(state, deleteDashboard(dashboard)) + const expected = [] + + expect(actual.ranges).to.deep.equal(expected) + }) + + describe('setting a dashboard time range', () => { + it('can update an existing dashboard', () => { + const state = { + ranges: [{dashboardID, upper: timeRange.upper, lower: timeRange.lower}], + } + + const {upper, lower} = { + upper: '2017-10-07 12:05', + lower: '2017-10-05 12:04', + } + + const actual = reducer(state, setDashTimeV1(dashboardID, {upper, lower})) + const expected = [{dashboardID, upper, lower}] + + expect(actual.ranges).to.deep.equal(expected) + }) + + it('can set a new time range if none exists', () => { + const actual = reducer(emptyState, setDashTimeV1(dashboardID, timeRange)) + + const expected = [ + {dashboardID, upper: timeRange.upper, lower: timeRange.lower}, + ] + + expect(actual.ranges).to.deep.equal(expected) + }) + }) +}) diff --git a/ui/spec/data_explorer/reducers/queryConfigSpec.js b/ui/spec/data_explorer/reducers/queryConfigSpec.js index a2a9d7e1c3..7dfa1399f7 100644 --- a/ui/spec/data_explorer/reducers/queryConfigSpec.js +++ b/ui/spec/data_explorer/reducers/queryConfigSpec.js @@ -1,18 +1,20 @@ import reducer from 'src/data_explorer/reducers/queryConfigs' import defaultQueryConfig from 'src/utils/defaultQueryConfig' import { - chooseNamespace, - chooseMeasurement, - toggleField, - applyFuncsToField, + fill, chooseTag, groupByTag, groupByTime, - toggleTagAcceptance, - fill, - updateQueryConfig, + toggleField, + removeFuncs, updateRawQuery, editQueryStatus, + chooseNamespace, + chooseMeasurement, + applyFuncsToField, + addInitialField, + updateQueryConfig, + toggleTagAcceptance, } from 'src/data_explorer/actions/view' import {LINEAR, NULL_STRING} from 'shared/constants/queryFillOptions' @@ -77,16 +79,20 @@ describe('Chronograf.Reducers.DataExplorer.queryConfigs', () => { }) ) const three = reducer(two, chooseMeasurement(queryId, 'disk')) + state = reducer( three, - toggleField(queryId, {field: 'a great field', funcs: []}) + addInitialField(queryId, { + value: 'a great field', + type: 'field', + }) ) }) describe('choosing a new namespace', () => { it('clears out the old measurement and fields', () => { // what about tags? - expect(state[queryId].measurement).to.exist + expect(state[queryId].measurement).to.equal('disk') expect(state[queryId].fields.length).to.equal(1) const newState = reducer( @@ -97,7 +103,7 @@ describe('Chronograf.Reducers.DataExplorer.queryConfigs', () => { }) ) - expect(newState[queryId].measurement).not.to.exist + expect(newState[queryId].measurement).to.be.null expect(newState[queryId].fields.length).to.equal(0) }) }) @@ -126,100 +132,120 @@ describe('Chronograf.Reducers.DataExplorer.queryConfigs', () => { const newState = reducer( state, - toggleField(queryId, {field: 'a different field', funcs: []}) + toggleField(queryId, { + value: 'f2', + type: 'field', + }) ) expect(newState[queryId].fields.length).to.equal(2) - expect(newState[queryId].fields[1].field).to.equal('a different field') + expect(newState[queryId].fields[1].alias).to.deep.equal('mean_f2') + expect(newState[queryId].fields[1].args).to.deep.equal([ + {value: 'f2', type: 'field'}, + ]) + expect(newState[queryId].fields[1].value).to.deep.equal('mean') }) - it('applies a funcs to newly selected fields', () => { + it('applies a func to newly selected fields', () => { expect(state[queryId].fields.length).to.equal(1) - - const oneFieldOneFunc = reducer( - state, - applyFuncsToField(queryId, {field: 'a great field', funcs: ['func1']}) - ) + expect(state[queryId].fields[0].type).to.equal('func') + expect(state[queryId].fields[0].value).to.equal('mean') const newState = reducer( - oneFieldOneFunc, - toggleField(queryId, {field: 'a different field', funcs: []}) + state, + toggleField(queryId, { + value: 'f2', + type: 'field', + }) ) - expect(newState[queryId].fields[1].funcs.length).to.equal(1) - expect(newState[queryId].fields[1].funcs[0]).to.equal('func1') + expect(newState[queryId].fields[1].value).to.equal('mean') + expect(newState[queryId].fields[1].alias).to.equal('mean_f2') + expect(newState[queryId].fields[1].args).to.deep.equal([ + {value: 'f2', type: 'field'}, + ]) + expect(newState[queryId].fields[1].type).to.equal('func') }) it('adds the field property to query config if not found', () => { delete state[queryId].fields expect(state[queryId].fields).to.equal(undefined) - const field = 'fk1' const newState = reducer( state, - toggleField(queryId, {field: 'fk1', funcs: []}) + toggleField(queryId, {value: 'fk1', type: 'field'}) ) expect(newState[queryId].fields.length).to.equal(1) - expect(newState[queryId].fields[0].field).to.equal(field) }) }) }) describe('DE_APPLY_FUNCS_TO_FIELD', () => { - it('applies functions to a field without any existing functions', () => { + it('applies new functions to a field', () => { + const f1 = {value: 'f1', type: 'field'} + const f2 = {value: 'f2', type: 'field'} + const f3 = {value: 'f3', type: 'field'} + const f4 = {value: 'f4', type: 'field'} + const initialState = { [queryId]: { id: 123, database: 'db1', measurement: 'm1', fields: [ - {field: 'f1', funcs: ['fn1', 'fn2']}, - {field: 'f2', funcs: ['fn1']}, + {value: 'fn1', type: 'func', args: [f1], alias: `fn1_${f1.value}`}, + {value: 'fn1', type: 'func', args: [f2], alias: `fn1_${f2.value}`}, + {value: 'fn2', type: 'func', args: [f1], alias: `fn2_${f1.value}`}, ], }, } + const action = applyFuncsToField(queryId, { - field: 'f1', - funcs: ['fn3', 'fn4'], + field: {value: 'f1', type: 'field'}, + funcs: [ + {value: 'fn3', type: 'func', args: []}, + {value: 'fn4', type: 'func', args: []}, + ], }) const nextState = reducer(initialState, action) - expect(nextState[queryId].fields).to.eql([ - {field: 'f1', funcs: ['fn3', 'fn4']}, - {field: 'f2', funcs: ['fn1']}, + expect(nextState[queryId].fields).to.deep.equal([ + {value: 'fn3', type: 'func', args: [f1], alias: `fn3_${f1.value}`}, + {value: 'fn4', type: 'func', args: [f1], alias: `fn4_${f1.value}`}, + {value: 'fn1', type: 'func', args: [f2], alias: `fn1_${f2.value}`}, ]) }) + }) + describe('DE_REMOVE_FUNCS', () => { it('removes all functions and group by time when one field has no funcs applied', () => { + const f1 = {value: 'f1', type: 'field'} + const f2 = {value: 'f2', type: 'field'} + const fields = [ + {value: 'fn1', type: 'func', args: [f1], alias: `fn1_${f1.value}`}, + {value: 'fn1', type: 'func', args: [f2], alias: `fn1_${f2.value}`}, + ] + const groupBy = {time: '1m', tags: []} + const initialState = { [queryId]: { id: 123, database: 'db1', measurement: 'm1', - fields: [ - {field: 'f1', funcs: ['fn1', 'fn2']}, - {field: 'f2', funcs: ['fn3', 'fn4']}, - ], - groupBy: { - time: '1m', - tags: [], - }, + fields, + groupBy, }, } - const action = applyFuncsToField(queryId, { - field: 'f1', - funcs: [], - }) + const action = removeFuncs(queryId, fields, groupBy) const nextState = reducer(initialState, action) + const actual = nextState[queryId].fields + const expected = [f1, f2] - expect(nextState[queryId].fields).to.eql([ - {field: 'f1', funcs: []}, - {field: 'f2', funcs: []}, - ]) + expect(actual).to.eql(expected) expect(nextState[queryId].groupBy.time).to.equal(null) }) }) diff --git a/ui/spec/kapacitor/reducers/queryConfigSpec.js b/ui/spec/kapacitor/reducers/queryConfigSpec.js index 3d638622c7..f21b046491 100644 --- a/ui/spec/kapacitor/reducers/queryConfigSpec.js +++ b/ui/spec/kapacitor/reducers/queryConfigSpec.js @@ -77,7 +77,7 @@ describe('Chronograf.Reducers.Kapacitor.queryConfigs', () => { const three = reducer(two, chooseMeasurement(queryId, 'disk')) state = reducer( three, - toggleField(queryId, {field: 'a great field', funcs: []}) + toggleField(queryId, {value: 'a great field', funcs: []}) ) }) @@ -124,11 +124,11 @@ describe('Chronograf.Reducers.Kapacitor.queryConfigs', () => { const newState = reducer( state, - toggleField(queryId, {field: 'a different field', funcs: []}) + toggleField(queryId, {value: 'a different field', type: 'field'}) ) expect(newState[queryId].fields.length).to.equal(1) - expect(newState[queryId].fields[0].field).to.equal('a different field') + expect(newState[queryId].fields[0].value).to.equal('a different field') }) }) @@ -138,11 +138,11 @@ describe('Chronograf.Reducers.Kapacitor.queryConfigs', () => { const newState = reducer( state, - toggleField(queryId, {field: 'a different field', funcs: []}) + toggleField(queryId, {value: 'a different field', type: 'field'}) ) expect(newState[queryId].fields.length).to.equal(1) - expect(newState[queryId].fields[0].field).to.equal('a different field') + expect(newState[queryId].fields[0].value).to.equal('a different field') }) it('applies no funcs to newly selected fields', () => { @@ -150,69 +150,43 @@ describe('Chronograf.Reducers.Kapacitor.queryConfigs', () => { const newState = reducer( state, - toggleField(queryId, {field: 'a different field'}) + toggleField(queryId, {value: 'a different field', type: 'field'}) ) - expect(newState[queryId].fields[0].funcs).to.equal(undefined) + expect(newState[queryId].fields[0].type).to.equal('field') }) }) }) describe('KAPA_APPLY_FUNCS_TO_FIELD', () => { it('applies functions to a field without any existing functions', () => { + const f1 = {value: 'f1', type: 'field'} const initialState = { [queryId]: { id: 123, database: 'db1', measurement: 'm1', - fields: [ - {field: 'f1', funcs: ['fn1', 'fn2']}, - {field: 'f2', funcs: ['fn1']}, - ], - }, - } - const action = applyFuncsToField(queryId, { - field: 'f1', - funcs: ['fn3', 'fn4'], - }) - - const nextState = reducer(initialState, action) - - expect(nextState[queryId].fields).to.eql([ - {field: 'f1', funcs: ['fn3', 'fn4']}, - {field: 'f2', funcs: ['fn1']}, - ]) - }) - - it('removes all functions and group by time when one field has no funcs applied', () => { - const initialState = { - [queryId]: { - id: 123, - database: 'db1', - measurement: 'm1', - fields: [ - {field: 'f1', funcs: ['fn1', 'fn2']}, - {field: 'f2', funcs: ['fn3', 'fn4']}, - ], + fields: [f1], groupBy: { - time: '1m', tags: [], + time: null, }, }, } const action = applyFuncsToField(queryId, { - field: 'f1', - funcs: [], + field: {value: 'f1', type: 'field'}, + funcs: [{value: 'fn3', type: 'func'}, {value: 'fn4', type: 'func'}], }) const nextState = reducer(initialState, action) + const actual = nextState[queryId].fields + const expected = [ + {value: 'fn3', type: 'func', args: [f1], alias: `fn3_${f1.value}`}, + {value: 'fn4', type: 'func', args: [f1], alias: `fn4_${f1.value}`}, + ] - expect(nextState[queryId].fields).to.eql([ - {field: 'f1', funcs: []}, - {field: 'f2', funcs: []}, - ]) - expect(nextState[queryId].groupBy.time).to.equal(null) + expect(actual).to.eql(expected) }) }) diff --git a/ui/spec/normalizers/dashboardTimeSpec.js b/ui/spec/normalizers/dashboardTimeSpec.js new file mode 100644 index 0000000000..e6c38dfc26 --- /dev/null +++ b/ui/spec/normalizers/dashboardTimeSpec.js @@ -0,0 +1,58 @@ +import normalizer from 'src/normalizers/dashboardTime' + +const dashboardID = 1 +const upper = null +const lower = 'now() - 15m' +const timeRange = {dashboardID, upper, lower} + +describe('Normalizers.DashboardTime', () => { + it('can filter out non-objects', () => { + const ranges = [1, null, undefined, 'string', timeRange] + + const actual = normalizer(ranges) + const expected = [timeRange] + + expect(actual).to.deep.equal(expected) + }) + + it('can remove objects with missing keys', () => { + const ranges = [ + {}, + {dashboardID, upper}, + {dashboardID, lower}, + {upper, lower}, + timeRange, + ] + + const actual = normalizer(ranges) + const expected = [timeRange] + expect(actual).to.deep.equal(expected) + }) + + it('can remove timeRanges with incorrect dashboardID', () => { + const ranges = [{dashboardID: '1', upper, lower}, timeRange] + + const actual = normalizer(ranges) + const expected = [timeRange] + expect(actual).to.deep.equal(expected) + }) + + it('can remove timeRange when is neither an upper or lower bound', () => { + const noBounds = {dashboardID, upper: null, lower: null} + const ranges = [timeRange, noBounds] + + const actual = normalizer(ranges) + const expected = [timeRange] + expect(actual).to.deep.equal(expected) + }) + + it('can remove a timeRange when upper and lower bounds are of the wrong type', () => { + const badTime = {dashboardID, upper: [], lower} + const reallyBadTime = {dashboardID, upper, lower: {bad: 'time'}} + const ranges = [timeRange, badTime, reallyBadTime] + + const actual = normalizer(ranges) + const expected = [timeRange] + expect(actual).to.deep.equal(expected) + }) +}) diff --git a/ui/spec/shared/parsing/resultsToCSVSpec.js b/ui/spec/shared/parsing/resultsToCSVSpec.js index e2e2bae2b1..6945e713db 100644 --- a/ui/spec/shared/parsing/resultsToCSVSpec.js +++ b/ui/spec/shared/parsing/resultsToCSVSpec.js @@ -1,4 +1,8 @@ -import resultsToCSV, {formatDate} from 'shared/parsing/resultsToCSV' +import { + resultsToCSV, + formatDate, + dashboardtoCSV, +} from 'shared/parsing/resultsToCSV' describe('formatDate', () => { it('converts timestamp to an excel compatible date string', () => { @@ -29,6 +33,7 @@ describe('resultsToCSV', () => { ] const response = resultsToCSV(results) const expected = { + flag: 'ok', name: 'procstat', CSVString: `date,mean_cpu_usage\n${formatDate( 1505262600000 @@ -36,10 +41,65 @@ describe('resultsToCSV', () => { 1505264400000 )},2.616484718180463\n${formatDate(1505266200000)},1.6174323943535571`, } - expect(response).to.have.all.keys('name', 'CSVString') + expect(response).to.have.all.keys('flag', 'name', 'CSVString') + expect(response.flag).to.be.a('string') expect(response.name).to.be.a('string') expect(response.CSVString).to.be.a('string') + expect(response.flag).to.equal(expected.flag) expect(response.name).to.equal(expected.name) expect(response.CSVString).to.equal(expected.CSVString) }) }) + +describe('dashboardtoCSV', () => { + it('parses the array of timeseries data displayed by the dashboard cell to a CSVstring for download', () => { + const data = [ + { + results: [ + { + statement_id: 0, + series: [ + { + name: 'procstat', + columns: ['time', 'mean_cpu_usage'], + values: [ + [1505262600000, 0.06163066773148772], + [1505264400000, 2.616484718180463], + [1505266200000, 1.6174323943535571], + ], + }, + ], + }, + ], + }, + { + results: [ + { + statement_id: 0, + series: [ + { + name: 'procstat', + columns: ['not-time', 'mean_cpu_usage'], + values: [ + [1505262600000, 0.06163066773148772], + [1505264400000, 2.616484718180463], + [1505266200000, 1.6174323943535571], + ], + }, + ], + }, + ], + }, + ] + const result = dashboardtoCSV(data) + const expected = `time,mean_cpu_usage,not-time,mean_cpu_usage\n${formatDate( + 1505262600000 + )},0.06163066773148772,1505262600000,0.06163066773148772\n${formatDate( + 1505264400000 + )},2.616484718180463,1505264400000,2.616484718180463\n${formatDate( + 1505266200000 + )},1.6174323943535571,1505266200000,1.6174323943535571` + expect(result).to.be.a('string') + expect(result).to.equal(expected) + }) +}) diff --git a/ui/spec/shared/reducers/helpers/fieldSpec.js b/ui/spec/shared/reducers/helpers/fieldSpec.js new file mode 100644 index 0000000000..22b23886d1 --- /dev/null +++ b/ui/spec/shared/reducers/helpers/fieldSpec.js @@ -0,0 +1,86 @@ +import _ from 'lodash' +import { + fieldWalk, + removeField, + getFieldsDeep, + fieldNamesDeep, +} from 'shared/reducers/helpers/fields' + +describe('Reducers.Helpers.Fields', () => { + it('can walk all fields and get all values', () => { + const fields = [ + { + value: 'fn1', + type: 'func', + args: [ + {value: 'f1', type: 'func', args: [{value: 'f2', type: 'field'}]}, + ], + }, + {value: 'fn1', type: 'func', args: [{value: 'f2', type: 'field'}]}, + {value: 'fn2', type: 'func', args: [{value: 'f2', type: 'field'}]}, + ] + const actual = fieldWalk(fields, f => _.get(f, 'value')) + expect(actual).to.deep.equal(['fn1', 'f1', 'f2', 'fn1', 'f2', 'fn2', 'f2']) + }) + + it('can return all unique fields for type field', () => { + const fields = [ + { + value: 'fn1', + type: 'func', + args: [ + {value: 'f1', type: 'func', args: [{value: 'f2', type: 'field'}]}, + ], + }, + {value: 'fn1', type: 'func', args: [{value: 'f2', type: 'field'}]}, + {value: 'fn2', type: 'func', args: [{value: 'f2', type: 'field'}]}, + ] + const actual = getFieldsDeep(fields) + expect(actual).to.deep.equal([{value: 'f2', type: 'field'}]) + }) + + it('can return all unique field value for type field', () => { + const fields = [ + { + value: 'fn1', + type: 'func', + args: [ + {value: 'f1', type: 'func', args: [{value: 'f2', type: 'field'}]}, + ], + }, + {value: 'fn1', type: 'func', args: [{value: 'f2', type: 'field'}]}, + {value: 'fn2', type: 'func', args: [{value: 'f2', type: 'field'}]}, + ] + const actual = fieldNamesDeep(fields) + expect(actual).to.deep.equal(['f2']) + }) + + describe('removeField', () => { + it('can remove fields at any level of the tree', () => { + const fields = [ + { + value: 'fn1', + type: 'func', + args: [ + {value: 'f1', type: 'func', args: [{value: 'f2', type: 'field'}]}, + ], + }, + {value: 'fn2', type: 'func', args: [{value: 'f2', type: 'field'}]}, + {value: 'fn3', type: 'func', args: [{value: 'f3', type: 'field'}]}, + ] + const actual = removeField('f2', fields) + expect(actual).to.deep.equal([ + {value: 'fn3', type: 'func', args: [{value: 'f3', type: 'field'}]}, + ]) + }) + + it('can remove fields from a flat field list', () => { + const fields = [ + {value: 'f1', type: 'field'}, + {value: 'f2', type: 'field'}, + ] + const actual = removeField('f2', fields) + expect(actual).to.deep.equal([{value: 'f1', type: 'field'}]) + }) + }) +}) diff --git a/ui/spec/data_explorer/utils/influxql/selectSpec.js b/ui/spec/utils/influxqlSpec.js similarity index 76% rename from ui/spec/data_explorer/utils/influxql/selectSpec.js rename to ui/spec/utils/influxqlSpec.js index d9ca398127..667c5c5696 100644 --- a/ui/spec/data_explorer/utils/influxql/selectSpec.js +++ b/ui/spec/utils/influxqlSpec.js @@ -1,7 +1,8 @@ -import buildInfluxQLQuery from 'utils/influxql' +import buildInfluxQLQuery, {buildQuery} from 'utils/influxql' import defaultQueryConfig from 'src/utils/defaultQueryConfig' import {NONE, NULL_STRING} from 'shared/constants/queryFillOptions' +import {TYPE_QUERY_CONFIG} from 'src/dashboards/constants' function mergeConfig(options) { return Object.assign({}, defaultQueryConfig(123), options) @@ -29,7 +30,7 @@ describe('buildInfluxQLQuery', () => { config = mergeConfig({ database: 'db1', measurement: 'm1', - fields: [{field: 'f1', func: null}], + fields: [{value: 'f1', type: 'field'}], }) }) @@ -46,7 +47,7 @@ describe('buildInfluxQLQuery', () => { database: 'db1', measurement: 'm1', retentionPolicy: 'rp1', - fields: [{field: 'f1', func: null}], + fields: [{value: 'f1', type: 'field'}], }) timeBounds = {lower: 'now() - 1hr'} }) @@ -70,7 +71,7 @@ describe('buildInfluxQLQuery', () => { database: 'db1', measurement: 'm1', retentionPolicy: 'rp1', - fields: [{field: '*', func: null}], + fields: [{value: '*', type: 'field'}], }) }) @@ -87,7 +88,14 @@ describe('buildInfluxQLQuery', () => { database: 'db1', measurement: 'm0', retentionPolicy: 'rp1', - fields: [{field: 'value', funcs: ['min']}], + fields: [ + { + value: 'min', + type: 'func', + alias: 'min_value', + args: [{value: 'value', type: 'field'}], + }, + ], groupBy: {time: '10m', tags: []}, fill: NULL_STRING, }) @@ -107,7 +115,14 @@ describe('buildInfluxQLQuery', () => { database: 'db1', measurement: 'm0', retentionPolicy: 'rp1', - fields: [{field: 'value', funcs: ['min']}], + fields: [ + { + value: 'min', + type: 'func', + alias: 'min_value', + args: [{value: 'value', type: 'field'}], + }, + ], groupBy: {time: null, tags: ['t1', 't2']}, }) timeBounds = {lower: 'now() - 12h'} @@ -125,7 +140,7 @@ describe('buildInfluxQLQuery', () => { database: 'db1', retentionPolicy: 'rp1', measurement: 'm0', - fields: [{field: 'value', funcs: []}], + fields: [{value: 'value', type: 'field'}], }) timeBounds = { lower: "'2015-07-23T15:52:24.447Z'", @@ -146,7 +161,14 @@ describe('buildInfluxQLQuery', () => { database: 'db1', retentionPolicy: 'rp1', measurement: 'm0', - fields: [{field: 'value', funcs: ['min']}], + fields: [ + { + value: 'min', + type: 'func', + alias: 'min_value', + args: [{value: 'value', type: 'field'}], + }, + ], groupBy: {time: '10m', tags: ['t1', 't2']}, fill: NULL_STRING, }) @@ -166,7 +188,7 @@ describe('buildInfluxQLQuery', () => { database: 'db1', retentionPolicy: 'rp1', measurement: 'm0', - fields: [{field: 'f0', funcs: []}, {field: 'f1', funcs: []}], + fields: [{value: 'f0', type: 'field'}, {value: 'f1', type: 'field'}], }) timeBounds = {upper: "'2015-02-24T00:00:00Z'"} }) @@ -188,7 +210,7 @@ describe('buildInfluxQLQuery', () => { database: 'db1', measurement: 'm0', retentionPolicy: 'rp1', - fields: [{field: 'f0', funcs: []}], + fields: [{value: 'f0', type: 'field'}], tags: { k1: ['v1', 'v3', 'v4'], k2: ['v2'], @@ -211,7 +233,14 @@ describe('buildInfluxQLQuery', () => { database: 'db1', retentionPolicy: 'rp1', measurement: 'm0', - fields: [{field: 'value', funcs: ['min']}], + fields: [ + { + value: 'min', + type: 'func', + alias: 'min_value', + args: [{value: 'value', type: 'field'}], + }, + ], groupBy: {time: '10m', tags: []}, }) timeBounds = {lower: 'now() - 12h'} @@ -229,7 +258,14 @@ describe('buildInfluxQLQuery', () => { database: 'db1', retentionPolicy: 'rp1', measurement: 'm0', - fields: [{field: 'value', funcs: ['min']}], + fields: [ + { + value: 'min', + type: 'func', + alias: 'min_value', + args: [{value: 'value', type: 'field'}], + }, + ], groupBy: {time: '10m', tags: []}, fill: NULL_STRING, }) @@ -244,7 +280,14 @@ describe('buildInfluxQLQuery', () => { database: 'db1', retentionPolicy: 'rp1', measurement: 'm0', - fields: [{field: 'value', funcs: ['min']}], + fields: [ + { + value: 'min', + type: 'func', + alias: 'min_value', + args: [{value: 'value', type: 'field'}], + }, + ], groupBy: {time: '10m', tags: []}, fill: NONE, }) @@ -259,7 +302,14 @@ describe('buildInfluxQLQuery', () => { database: 'db1', retentionPolicy: 'rp1', measurement: 'm0', - fields: [{field: 'value', funcs: ['min']}], + fields: [ + { + value: 'min', + type: 'func', + alias: 'min_value', + args: [{value: 'value', type: 'field'}], + }, + ], groupBy: {time: '10m', tags: ['t1', 't2']}, fill: '1337', }) @@ -271,4 +321,25 @@ describe('buildInfluxQLQuery', () => { }) }) }) + + describe('build query', () => { + beforeEach(() => { + config = mergeConfig({ + database: 'db1', + measurement: 'm1', + retentionPolicy: 'rp1', + fields: [{value: 'f1', type: 'field'}], + groupBy: {time: '10m', tags: []}, + }) + }) + + it('builds an influxql relative time bound query', () => { + const timeRange = {upper: null, lower: 'now() - 15m'} + const expected = + 'SELECT "f1" FROM "db1"."rp1"."m1" WHERE time > now() - 15m GROUP BY time(10m) FILL(null)' + const actual = buildQuery(TYPE_QUERY_CONFIG, timeRange, config) + + expect(actual).to.equal(expected) + }) + }) }) diff --git a/ui/src/admin/actions/index.js b/ui/src/admin/actions/index.js index 63737c91d0..c4f905b235 100644 --- a/ui/src/admin/actions/index.js +++ b/ui/src/admin/actions/index.js @@ -293,8 +293,10 @@ export const createDatabaseAsync = (url, database) => async dispatch => { ) ) } catch (error) { - dispatch(errorThrown(error)) - // undo optimistic upda, `Failed to create database: ${error.data.message}`te + dispatch( + errorThrown(error, `Failed to create database: ${error.data.message}`) + ) + // undo optimistic update setTimeout(() => dispatch(removeDatabase(database)), REVERT_STATE_DELAY) } } @@ -316,8 +318,13 @@ export const createRetentionPolicyAsync = ( ) dispatch(syncRetentionPolicy(database, retentionPolicy, data)) } catch (error) { - dispatch(errorThrown(error)) - // undo optimistic upda, `Failed to create retention policy: ${error.data.message}`te + dispatch( + errorThrown( + error, + `Failed to create retention policy: ${error.data.message}` + ) + ) + // undo optimistic update setTimeout( () => dispatch(removeRetentionPolicy(database, retentionPolicy)), REVERT_STATE_DELAY diff --git a/ui/src/admin/components/DatabaseRow.js b/ui/src/admin/components/DatabaseRow.js index bff528f3a1..60a216138c 100644 --- a/ui/src/admin/components/DatabaseRow.js +++ b/ui/src/admin/components/DatabaseRow.js @@ -161,7 +161,7 @@ class DatabaseRow extends Component { name="name" type="text" defaultValue={formattedDuration} - placeholder="INF, 5m, 1d etc" + placeholder="INF, 1h30m, 1d, etc" onKeyDown={this.handleKeyDown} ref={r => (this.duration = r)} autoFocus={!isNew} diff --git a/ui/src/admin/containers/AdminPage.js b/ui/src/admin/containers/AdminPage.js index 65e448d469..29d6d4c95b 100644 --- a/ui/src/admin/containers/AdminPage.js +++ b/ui/src/admin/containers/AdminPage.js @@ -2,24 +2,24 @@ import React, {Component, PropTypes} from 'react' import {connect} from 'react-redux' import {bindActionCreators} from 'redux' import { - loadUsersAsync, - loadRolesAsync, - loadPermissionsAsync, addUser, addRole, - deleteUser, // TODO rename to removeUser throughout + tests - deleteRole, // TODO rename to removeUser throughout + tests editUser, editRole, + deleteUser, + deleteRole, + loadUsersAsync, + loadRolesAsync, createUserAsync, createRoleAsync, deleteUserAsync, deleteRoleAsync, + loadPermissionsAsync, updateRoleUsersAsync, - updateRolePermissionsAsync, - updateUserPermissionsAsync, updateUserRolesAsync, updateUserPasswordAsync, + updateRolePermissionsAsync, + updateUserPermissionsAsync, filterUsers as filterUsersAction, filterRoles as filterRolesAction, } from 'src/admin/actions' @@ -166,26 +166,26 @@ class AdminPage extends Component { users={users} roles={roles} source={source} - permissions={allowed} hasRoles={hasRoles} - isEditingUsers={users.some(u => u.isEditing)} - isEditingRoles={roles.some(r => r.isEditing)} - onClickCreate={this.handleClickCreate} + permissions={allowed} + onFilterUsers={filterUsers} + onFilterRoles={filterRoles} onEditUser={this.handleEditUser} onEditRole={this.handleEditRole} onSaveUser={this.handleSaveUser} onSaveRole={this.handleSaveRole} - onCancelEditUser={this.handleCancelEditUser} - onCancelEditRole={this.handleCancelEditRole} onDeleteUser={this.handleDeleteUser} onDeleteRole={this.handleDeleteRole} - onFilterUsers={filterUsers} - onFilterRoles={filterRoles} + onClickCreate={this.handleClickCreate} + onCancelEditUser={this.handleCancelEditUser} + onCancelEditRole={this.handleCancelEditRole} + isEditingUsers={users.some(u => u.isEditing)} + isEditingRoles={roles.some(r => r.isEditing)} onUpdateRoleUsers={this.handleUpdateRoleUsers} - onUpdateRolePermissions={this.handleUpdateRolePermissions} - onUpdateUserPermissions={this.handleUpdateUserPermissions} onUpdateUserRoles={this.handleUpdateUserRoles} onUpdateUserPassword={this.handleUpdateUserPassword} + onUpdateRolePermissions={this.handleUpdateRolePermissions} + onUpdateUserPermissions={this.handleUpdateUserPermissions} /> diff --git a/ui/src/admin/containers/DatabaseManagerPage.js b/ui/src/admin/containers/DatabaseManagerPage.js index 8a7247df1f..71d7f36584 100644 --- a/ui/src/admin/containers/DatabaseManagerPage.js +++ b/ui/src/admin/containers/DatabaseManagerPage.js @@ -19,32 +19,6 @@ class DatabaseManagerPage extends Component { actions.loadDBsAndRPsAsync(databases) } - render() { - const {source, databases, actions, notify} = this.props - return ( - db.isEditing)} - onKeyDownDatabase={this.handleKeyDownDatabase} - onDatabaseDeleteConfirm={this.handleDatabaseDeleteConfirm} - addDatabase={actions.addDatabase} - onEditDatabase={this.handleEditDatabase} - onCancelDatabase={actions.removeDatabase} - onConfirmDatabase={this.handleCreateDatabase} - onDeleteDatabase={actions.deleteDatabaseAsync} - onStartDeleteDatabase={this.handleStartDeleteDatabase} - onRemoveDeleteCode={actions.removeDatabaseDeleteCode} - onAddRetentionPolicy={this.handleAddRetentionPolicy} - onCreateRetentionPolicy={actions.createRetentionPolicyAsync} - onUpdateRetentionPolicy={actions.updateRetentionPolicyAsync} - onRemoveRetentionPolicy={actions.removeRetentionPolicy} - onDeleteRetentionPolicy={this.handleDeleteRetentionPolicy} - /> - ) - } - handleDeleteRetentionPolicy = (db, rp) => () => { this.props.actions.deleteRetentionPolicyAsync(db, rp) } @@ -114,6 +88,32 @@ class DatabaseManagerPage extends Component { actions.editDatabase(database, {deleteCode: value}) } + + render() { + const {source, databases, actions, notify} = this.props + return ( + db.isEditing)} + onRemoveRetentionPolicy={actions.removeRetentionPolicy} + onDeleteRetentionPolicy={this.handleDeleteRetentionPolicy} + onDatabaseDeleteConfirm={this.handleDatabaseDeleteConfirm} + onCreateRetentionPolicy={actions.createRetentionPolicyAsync} + onUpdateRetentionPolicy={actions.updateRetentionPolicyAsync} + /> + ) + } } const {arrayOf, bool, func, number, shape, string} = PropTypes diff --git a/ui/src/dashboards/actions/index.js b/ui/src/dashboards/actions/index.js index 24eca86edd..b151b5e19e 100644 --- a/ui/src/dashboards/actions/index.js +++ b/ui/src/dashboards/actions/index.js @@ -28,6 +28,29 @@ export const loadDashboards = (dashboards, dashboardID) => ({ }, }) +export const loadDeafaultDashTimeV1 = dashboardID => ({ + type: 'ADD_DASHBOARD_TIME_V1', + payload: { + dashboardID, + }, +}) + +export const addDashTimeV1 = (dashboardID, timeRange) => ({ + type: 'ADD_DASHBOARD_TIME_V1', + payload: { + dashboardID, + timeRange, + }, +}) + +export const setDashTimeV1 = (dashboardID, timeRange) => ({ + type: 'SET_DASHBOARD_TIME_V1', + payload: { + dashboardID, + timeRange, + }, +}) + export const setTimeRange = timeRange => ({ type: 'SET_DASHBOARD_TIME_RANGE', payload: { @@ -46,6 +69,7 @@ export const deleteDashboard = dashboard => ({ type: 'DELETE_DASHBOARD', payload: { dashboard, + dashboardID: dashboard.id, }, }) diff --git a/ui/src/dashboards/components/CellEditorOverlay.js b/ui/src/dashboards/components/CellEditorOverlay.js index 4d443c78fe..59e8d193d4 100644 --- a/ui/src/dashboards/components/CellEditorOverlay.js +++ b/ui/src/dashboards/components/CellEditorOverlay.js @@ -12,12 +12,16 @@ import DisplayOptions from 'src/dashboards/components/DisplayOptions' import * as queryModifiers from 'src/utils/queryTransitions' import defaultQueryConfig from 'src/utils/defaultQueryConfig' -import buildInfluxQLQuery from 'utils/influxql' +import {buildQuery} from 'utils/influxql' import {getQueryConfig} from 'shared/apis' -import {removeUnselectedTemplateValues} from 'src/dashboards/constants' +import { + removeUnselectedTemplateValues, + TYPE_QUERY_CONFIG, +} from 'src/dashboards/constants' import {OVERLAY_TECHNOLOGY} from 'shared/constants/classNames' import {MINIMUM_HEIGHTS, INITIAL_HEIGHTS} from 'src/data_explorer/constants' +import {AUTO_GROUP_BY} from 'shared/constants' class CellEditorOverlay extends Component { constructor(props) { @@ -59,11 +63,11 @@ class CellEditorOverlay extends Component { } } - queryStateReducer = queryModifier => (queryID, payload) => { + queryStateReducer = queryModifier => (queryID, ...payload) => { const {queriesWorkingDraft} = this.state const query = queriesWorkingDraft.find(q => q.id === queryID) - const nextQuery = queryModifier(query, payload) + const nextQuery = queryModifier(query, ...payload) const nextQueries = queriesWorkingDraft.map( q => @@ -147,7 +151,7 @@ class CellEditorOverlay extends Component { const queries = queriesWorkingDraft.map(q => { const timeRange = q.range || {upper: null, lower: ':dashboardTime:'} - const query = q.rawText || buildInfluxQLQuery(timeRange, q) + const query = q.rawText || buildQuery(TYPE_QUERY_CONFIG, timeRange, q) return { queryConfig: q, @@ -365,6 +369,7 @@ class CellEditorOverlay extends Component { activeQueryIndex={activeQueryIndex} activeQuery={this.getActiveQuery()} setActiveQueryIndex={this.handleSetActiveQueryIndex} + initialGroupByTime={AUTO_GROUP_BY} />} diff --git a/ui/src/dashboards/components/Dashboard.js b/ui/src/dashboards/components/Dashboard.js index 1cf622cc7c..41b31710d2 100644 --- a/ui/src/dashboards/components/Dashboard.js +++ b/ui/src/dashboards/components/Dashboard.js @@ -13,6 +13,7 @@ const Dashboard = ({ onAddCell, timeRange, autoRefresh, + manualRefresh, onDeleteCell, synchronizer, onPositionChange, @@ -57,6 +58,7 @@ const Dashboard = ({ isEditable={true} timeRange={timeRange} autoRefresh={autoRefresh} + manualRefresh={manualRefresh} synchronizer={synchronizer} onDeleteCell={onDeleteCell} onPositionChange={onPositionChange} @@ -111,6 +113,7 @@ Dashboard.propTypes = { }).isRequired, sources: arrayOf(shape({})).isRequired, autoRefresh: number.isRequired, + manualRefresh: number, timeRange: shape({}).isRequired, onOpenTemplateManager: func.isRequired, onSelectTemplate: func.isRequired, diff --git a/ui/src/dashboards/components/DashboardHeader.js b/ui/src/dashboards/components/DashboardHeader.js index e4ac27e707..c66d8d2838 100644 --- a/ui/src/dashboards/components/DashboardHeader.js +++ b/ui/src/dashboards/components/DashboardHeader.js @@ -7,46 +7,57 @@ import AutoRefreshDropdown from 'shared/components/AutoRefreshDropdown' import TimeRangeDropdown from 'shared/components/TimeRangeDropdown' import SourceIndicator from 'shared/components/SourceIndicator' import GraphTips from 'shared/components/GraphTips' +import DashboardHeaderEdit from 'src/dashboards/components/DashboardHeaderEdit' +import DashboardSwitcher from 'src/dashboards/components/DashboardSwitcher' const DashboardHeader = ({ - children, - buttonText, - dashboard, - headerText, - timeRange: {upper, lower}, - zoomedTimeRange: {zoomedLower, zoomedUpper}, - autoRefresh, + names, + onSave, + onCancel, + isEditMode, isHidden, + dashboard, + onAddCell, + autoRefresh, + activeDashboard, + onEditDashboard, + onManualRefresh, handleChooseTimeRange, handleChooseAutoRefresh, - handleClickPresentationButton, - onAddCell, - onEditDashboard, onToggleTempVarControls, showTemplateControlBar, + timeRange: {upper, lower}, + handleClickPresentationButton, + zoomedTimeRange: {zoomedLower, zoomedUpper}, }) => isHidden ? null :
-
- {buttonText && -
- -
    - {children} -
-
} - {headerText} +
+ {names && names.length > 1 + ? + : null} + {dashboard + ? + :

+ {activeDashboard} +

}
@@ -62,15 +73,6 @@ const DashboardHeader = ({ : null} - {dashboard - ? - : null} {dashboard ?
@@ -97,13 +100,13 @@ const DashboardHeader = ({ className="btn btn-default btn-sm btn-square" onClick={handleClickPresentationButton} > - +
-const {array, bool, func, number, shape, string} = PropTypes +const {arrayOf, bool, func, number, shape, string} = PropTypes DashboardHeader.defaultProps = { zoomedTimeRange: { @@ -113,24 +116,27 @@ DashboardHeader.defaultProps = { } DashboardHeader.propTypes = { - children: array, - buttonText: string, + activeDashboard: string.isRequired, + onEditDashboard: func, dashboard: shape({}), - headerText: string, timeRange: shape({ lower: string, upper: string, }).isRequired, autoRefresh: number.isRequired, isHidden: bool.isRequired, + isEditMode: bool, handleChooseTimeRange: func.isRequired, handleChooseAutoRefresh: func.isRequired, + onManualRefresh: func.isRequired, handleClickPresentationButton: func.isRequired, onAddCell: func, - onEditDashboard: func, onToggleTempVarControls: func, showTemplateControlBar: bool, zoomedTimeRange: shape({}), + onCancel: func, + onSave: func, + names: arrayOf(shape({})).isRequired, } export default DashboardHeader diff --git a/ui/src/dashboards/components/DashboardHeaderEdit.js b/ui/src/dashboards/components/DashboardHeaderEdit.js index efe3d86a4b..c08d9dcbf9 100644 --- a/ui/src/dashboards/components/DashboardHeaderEdit.js +++ b/ui/src/dashboards/components/DashboardHeaderEdit.js @@ -1,70 +1,81 @@ import React, {PropTypes, Component} from 'react' -import ConfirmButtons from 'shared/components/ConfirmButtons' +import { + DASHBOARD_NAME_MAX_LENGTH, + NEW_DASHBOARD, +} from 'src/dashboards/constants/index' class DashboardEditHeader extends Component { constructor(props) { super(props) - const {dashboard: {name}} = props this.state = { - name, + reset: false, } } - handleChange = e => { - this.setState({name: e.target.value}) - } + handleInputBlur = e => { + const {onSave, onCancel} = this.props + const {reset} = this.state - handleFormSubmit = e => { - e.preventDefault() - const name = e.target.name.value - this.props.onSave(name) - } - - handleKeyUp = e => { - const {onCancel} = this.props - if (e.key === 'Escape') { + if (reset) { onCancel() + } else { + const newName = e.target.value || NEW_DASHBOARD.name + onSave(newName) } + this.setState({reset: false}) + } + + handleKeyDown = e => { + if (e.key === 'Enter') { + this.inputRef.blur() + } + if (e.key === 'Escape') { + this.inputRef.value = this.props.activeDashboard + this.setState({reset: true}, () => this.inputRef.blur()) + } + } + + handleFocus = e => { + e.target.select() } render() { - const {onSave, onCancel} = this.props - const {name} = this.state + const {onEditDashboard, isEditMode, activeDashboard} = this.props return ( -
-
-
- + {isEditMode + ? (this.inputRef = r)} /> -
- -
+ :

+ {activeDashboard} +

}
) } } -const {shape, func} = PropTypes +const {bool, func, string} = PropTypes DashboardEditHeader.propTypes = { - dashboard: shape({}), - onCancel: func.isRequired, + activeDashboard: string.isRequired, onSave: func.isRequired, + onCancel: func.isRequired, + isEditMode: bool, + onEditDashboard: func.isRequired, } export default DashboardEditHeader diff --git a/ui/src/dashboards/components/DashboardSwitcher.js b/ui/src/dashboards/components/DashboardSwitcher.js new file mode 100644 index 0000000000..73688239cf --- /dev/null +++ b/ui/src/dashboards/components/DashboardSwitcher.js @@ -0,0 +1,89 @@ +import React, {Component, PropTypes} from 'react' +import {Link} from 'react-router' +import _ from 'lodash' +import classnames from 'classnames' +import OnClickOutside from 'shared/components/OnClickOutside' + +class DashboardSwitcher extends Component { + constructor(props) { + super(props) + + this.state = { + isOpen: false, + } + } + + handleToggleMenu = () => { + this.setState({isOpen: !this.state.isOpen}) + } + + handleCloseMenu = () => { + this.setState({isOpen: false}) + } + + handleClickOutside = () => { + this.setState({isOpen: false}) + } + + render() { + const {activeDashboard, names} = this.props + const {isOpen} = this.state + const sorted = _.sortBy(names, ({name}) => name.toLowerCase()) + + return ( +
+ +
    + {sorted.map(({name, link}) => + + )} +
+
+ ) + } +} + +const NameLink = ({name, link, activeName, onClose}) => +
  • + + {name} + +
  • + +const {arrayOf, func, shape, string} = PropTypes + +DashboardSwitcher.propTypes = { + activeDashboard: string.isRequired, + names: arrayOf( + shape({ + link: string.isRequired, + name: string.isRequired, + }) + ).isRequired, +} + +NameLink.propTypes = { + name: string.isRequired, + link: string.isRequired, + activeName: string.isRequired, + onClose: func.isRequired, +} + +export default OnClickOutside(DashboardSwitcher) diff --git a/ui/src/dashboards/components/QueryMaker.js b/ui/src/dashboards/components/QueryMaker.js index 3a8c1e56c8..5b8e695c73 100644 --- a/ui/src/dashboards/components/QueryMaker.js +++ b/ui/src/dashboards/components/QueryMaker.js @@ -4,13 +4,14 @@ import EmptyQuery from 'src/shared/components/EmptyQuery' import QueryTabList from 'src/shared/components/QueryTabList' import QueryTextArea from 'src/dashboards/components/QueryTextArea' import SchemaExplorer from 'src/shared/components/SchemaExplorer' -import buildInfluxQLQuery from 'utils/influxql' +import {buildQuery} from 'utils/influxql' +import {TYPE_QUERY_CONFIG} from 'src/dashboards/constants' const TEMPLATE_RANGE = {upper: null, lower: ':dashboardTime:'} const rawTextBinder = (links, id, action) => text => action(links.queries, id, text) const buildText = q => - q.rawText || buildInfluxQLQuery(q.range || TEMPLATE_RANGE, q) || '' + q.rawText || buildQuery(TYPE_QUERY_CONFIG, q.range || TEMPLATE_RANGE, q) || '' const QueryMaker = ({ source, @@ -22,6 +23,7 @@ const QueryMaker = ({ activeQuery, onDeleteQuery, activeQueryIndex, + initialGroupByTime, setActiveQueryIndex, }) =>
    @@ -46,16 +48,17 @@ const QueryMaker = ({ templates={templates} />
    : }
    -const {arrayOf, bool, func, number, shape, string} = PropTypes +const {arrayOf, func, number, shape, string} = PropTypes QueryMaker.propTypes = { source: shape({ @@ -68,7 +71,6 @@ QueryMaker.propTypes = { upper: string, lower: string, }).isRequired, - isInDataExplorer: bool, actions: shape({ chooseNamespace: func.isRequired, chooseMeasurement: func.isRequired, @@ -80,6 +82,7 @@ QueryMaker.propTypes = { fill: func, applyFuncsToField: func.isRequired, editRawTextAsync: func.isRequired, + addInitialField: func.isRequired, }).isRequired, setActiveQueryIndex: func.isRequired, onDeleteQuery: func.isRequired, @@ -91,6 +94,7 @@ QueryMaker.propTypes = { tempVar: string.isRequired, }) ).isRequired, + initialGroupByTime: string.isRequired, } export default QueryMaker diff --git a/ui/src/dashboards/components/VisualizationName.js b/ui/src/dashboards/components/VisualizationName.js index 05e9cc6718..937194cf75 100644 --- a/ui/src/dashboards/components/VisualizationName.js +++ b/ui/src/dashboards/components/VisualizationName.js @@ -1,17 +1,20 @@ import React, {Component, PropTypes} from 'react' +import {NEW_DEFAULT_DASHBOARD_CELL} from 'src/dashboards/constants/index' + class VisualizationName extends Component { constructor(props) { super(props) this.state = { reset: false, + isEditing: false, } } handleInputBlur = reset => e => { this.props.onCellRename(reset ? this.props.defaultName : e.target.value) - this.setState({reset: false}) + this.setState({reset: false, isEditing: false}) } handleKeyDown = e => { @@ -24,21 +27,39 @@ class VisualizationName extends Component { } } + handleEditMode = () => { + this.setState({isEditing: true}) + } + + handleFocus = e => { + e.target.select() + } + render() { const {defaultName} = this.props - const {reset} = this.state + const {reset, isEditing} = this.state + const graphNameClass = + defaultName === NEW_DEFAULT_DASHBOARD_CELL.name + ? 'graph-name graph-name__untitled' + : 'graph-name' return (
    - (this.inputRef = r)} - /> + {isEditing + ? (this.inputRef = r)} + /> + :
    + {defaultName} +
    }
    ) } diff --git a/ui/src/dashboards/constants/index.js b/ui/src/dashboards/constants/index.js index 5e0d0d9fea..8bc3dc51c4 100644 --- a/ui/src/dashboards/constants/index.js +++ b/ui/src/dashboards/constants/index.js @@ -106,3 +106,8 @@ export const TOOLTIP_CONTENT = { FORMAT: '

    K/M/B = Thousand / Million / Billion
    K/M/G = Kilo / Mega / Giga

    ', } + +export const TYPE_QUERY_CONFIG = 'queryConfig' +export const TYPE_IFQL = 'ifql' + +export const DASHBOARD_NAME_MAX_LENGTH = 50 diff --git a/ui/src/dashboards/containers/DashboardPage.js b/ui/src/dashboards/containers/DashboardPage.js index 60f6e8168c..6094b63ca1 100644 --- a/ui/src/dashboards/containers/DashboardPage.js +++ b/ui/src/dashboards/containers/DashboardPage.js @@ -1,5 +1,4 @@ import React, {PropTypes, Component} from 'react' -import {Link} from 'react-router' import {connect} from 'react-redux' import {bindActionCreators} from 'redux' @@ -8,11 +7,12 @@ import Dygraph from 'src/external/dygraph' import OverlayTechnologies from '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/template_variables/Manager' +import ManualRefresh from 'src/shared/components/ManualRefresh' import {errorThrown as errorThrownAction} from 'shared/actions/errors' +import idNormalizer, {TYPE_ID} from 'src/normalizers/id' import * as dashboardActionCreators from 'src/dashboards/actions' @@ -22,6 +22,13 @@ import { } from 'shared/actions/app' import {presentationButtonDispatcher} from 'shared/dispatchers' +const FORMAT_INFLUXQL = 'influxql' +const defaultTimeRange = { + upper: null, + lower: 'now() - 15m', + format: FORMAT_INFLUXQL, +} + class DashboardPage extends Component { constructor(props) { super(props) @@ -32,12 +39,13 @@ class DashboardPage extends Component { selectedCell: null, isTemplating: false, zoomedTimeRange: {zoomedLower: null, zoomedUpper: null}, + names: [], } } async componentDidMount() { const { - params: {dashboardID}, + params: {dashboardID, sourceID}, dashboardActions: { getDashboardsAsync, updateTempVarValues, @@ -47,11 +55,20 @@ class DashboardPage extends Component { } = this.props const dashboards = await getDashboardsAsync() - const dashboard = dashboards.find(d => d.id === +dashboardID) + const dashboard = dashboards.find( + d => d.id === idNormalizer(TYPE_ID, dashboardID) + ) // Refresh and persists influxql generated template variable values await updateTempVarValues(source, dashboard) await putDashboardByID(dashboardID) + + const names = dashboards.map(d => ({ + name: d.name, + link: `/sources/${sourceID}/dashboards/${d.id}`, + })) + + this.setState({names}) } handleOpenTemplateManager = () => { @@ -72,8 +89,9 @@ class DashboardPage extends Component { } handleSaveEditedCell = newCell => { - this.props.dashboardActions - .updateDashboardCell(this.getActiveDashboard(), newCell) + const {dashboardActions, dashboard} = this.props + dashboardActions + .updateDashboardCell(dashboard, newCell) .then(this.handleDismissOverlay) } @@ -81,18 +99,26 @@ class DashboardPage extends Component { this.setState({selectedCell: cell}) } - handleChooseTimeRange = timeRange => { - this.props.dashboardActions.setTimeRange(timeRange) + handleChooseTimeRange = ({upper, lower}) => { + const {dashboard, dashboardActions} = this.props + dashboardActions.setDashTimeV1(dashboard.id, { + upper, + lower, + format: FORMAT_INFLUXQL, + }) } handleUpdatePosition = cells => { - const newDashboard = {...this.getActiveDashboard(), cells} - this.props.dashboardActions.updateDashboard(newDashboard) - this.props.dashboardActions.putDashboard(newDashboard) + const {dashboardActions, dashboard} = this.props + const newDashboard = {...dashboard, cells} + + dashboardActions.updateDashboard(newDashboard) + dashboardActions.putDashboard(newDashboard) } handleAddCell = () => { - this.props.dashboardActions.addDashboardCellAsync(this.getActiveDashboard()) + const {dashboardActions, dashboard} = this.props + dashboardActions.addDashboardCellAsync(dashboard) } handleEditDashboard = () => { @@ -104,42 +130,40 @@ class DashboardPage extends Component { } handleRenameDashboard = name => { + const {dashboardActions, dashboard} = this.props this.setState({isEditMode: false}) - const newDashboard = {...this.getActiveDashboard(), name} - this.props.dashboardActions.updateDashboard(newDashboard) - this.props.dashboardActions.putDashboard(newDashboard) + const newDashboard = {...dashboard, name} + + dashboardActions.updateDashboard(newDashboard) + dashboardActions.putDashboard(newDashboard) } - handleUpdateDashboardCell = newCell => { - return () => { - this.props.dashboardActions.updateDashboardCell( - this.getActiveDashboard(), - newCell - ) - } + handleUpdateDashboardCell = newCell => () => { + const {dashboardActions, dashboard} = this.props + dashboardActions.updateDashboardCell(dashboard, newCell) } handleDeleteDashboardCell = cell => { - const dashboard = this.getActiveDashboard() - this.props.dashboardActions.deleteDashboardCellAsync(dashboard, cell) + const {dashboardActions, dashboard} = this.props + dashboardActions.deleteDashboardCellAsync(dashboard, cell) } handleSelectTemplate = templateID => values => { - const {params: {dashboardID}} = this.props - this.props.dashboardActions.templateVariableSelected( - +dashboardID, - templateID, - [values] - ) + const {dashboardActions, dashboard} = this.props + dashboardActions.templateVariableSelected(dashboard.id, templateID, [ + values, + ]) } handleEditTemplateVariables = ( templates, onSaveTemplatesSuccess ) => async () => { + const {dashboardActions, dashboard} = this.props + try { - await this.props.dashboardActions.putDashboard({ - ...this.getActiveDashboard(), + await dashboardActions.putDashboard({ + ...dashboard, templates, }) onSaveTemplatesSuccess() @@ -154,9 +178,13 @@ class DashboardPage extends Component { } synchronizer = dygraph => { - const dygraphs = [...this.state.dygraphs, dygraph] - const {dashboards, params} = this.props - const dashboard = dashboards.find(d => d.id === +params.dashboardID) + const dygraphs = [...this.state.dygraphs, dygraph].filter(d => d.graphDiv) + const {dashboards, params: {dashboardID}} = this.props + + const dashboard = dashboards.find( + d => d.id === idNormalizer(TYPE_ID, dashboardID) + ) + if ( dashboard && dygraphs.length === dashboard.cells.length && @@ -168,6 +196,7 @@ class DashboardPage extends Component { range: false, }) } + this.setState({dygraphs}) } @@ -179,11 +208,6 @@ class DashboardPage extends Component { this.setState({zoomedTimeRange: {zoomedLower, zoomedUpper}}) } - getActiveDashboard() { - const {params: {dashboardID}, dashboards} = this.props - return dashboards.find(d => d.id === +dashboardID) - } - render() { const {zoomedTimeRange} = this.state const {zoomedLower, zoomedUpper} = zoomedTimeRange @@ -194,8 +218,11 @@ class DashboardPage extends Component { timeRange, timeRange: {lower, upper}, showTemplateControlBar, + dashboard, dashboards, autoRefresh, + manualRefresh, + onManualRefresh, cellQueryStatus, dashboardActions, inPresentationMode, @@ -246,8 +273,6 @@ class DashboardPage extends Component { values: [], } - const dashboard = this.getActiveDashboard() - let templatesIncludingDashTime if (dashboard) { templatesIncludingDashTime = [ @@ -260,7 +285,7 @@ class DashboardPage extends Component { templatesIncludingDashTime = [] } - const {selectedCell, isEditMode, isTemplating} = this.state + const {selectedCell, isEditMode, isTemplating, names} = this.state return (
    @@ -290,39 +315,28 @@ class DashboardPage extends Component { editQueryStatus={dashboardActions.editCellQueryStatus} /> : null} - {isEditMode - ? - : - {dashboards - ? dashboards.map((d, i) => -
  • - - {d.name} - -
  • - ) - : null} -
    } + {dashboard ? { +const mapStateToProps = (state, {params: {dashboardID}}) => { const { app: { ephemeral: {inPresentationMode}, persisted: {autoRefresh, showTemplateControlBar}, }, - dashboardUI: {dashboards, timeRange, cellQueryStatus}, + dashboardUI: {dashboards, cellQueryStatus}, sources, + dashTimeV1, } = state + const timeRange = + dashTimeV1.ranges.find( + r => r.dashboardID === idNormalizer(TYPE_ID, dashboardID) + ) || defaultTimeRange + + const dashboard = dashboards.find( + d => d.id === idNormalizer(TYPE_ID, dashboardID) + ) + return { dashboards, autoRefresh, + dashboard, timeRange, showTemplateControlBar, inPresentationMode, @@ -444,4 +476,6 @@ const mapDispatchToProps = dispatch => ({ errorThrown: bindActionCreators(errorThrownAction, dispatch), }) -export default connect(mapStateToProps, mapDispatchToProps)(DashboardPage) +export default connect(mapStateToProps, mapDispatchToProps)( + ManualRefresh(DashboardPage) +) diff --git a/ui/src/dashboards/reducers/dashTimeV1.js b/ui/src/dashboards/reducers/dashTimeV1.js new file mode 100644 index 0000000000..4d8c81d278 --- /dev/null +++ b/ui/src/dashboards/reducers/dashTimeV1.js @@ -0,0 +1,34 @@ +import _ from 'lodash' +const initialState = { + ranges: [], +} + +const dashTimeV1 = (state = initialState, action) => { + switch (action.type) { + case 'ADD_DASHBOARD_TIME_V1': { + const {dashboardID, timeRange} = action.payload + const ranges = [...state.ranges, {dashboardID, timeRange}] + + return {...state, ranges} + } + + case 'DELETE_DASHBOARD': { + const {dashboardID} = action.payload + const ranges = state.ranges.filter(r => r.dashboardID !== dashboardID) + + return {...state, ranges} + } + + case 'SET_DASHBOARD_TIME_V1': { + const {dashboardID, timeRange} = action.payload + const newTimeRange = [{dashboardID, ...timeRange}] + const ranges = _.unionBy(newTimeRange, state.ranges, 'dashboardID') + + return {...state, ranges} + } + } + + return state +} + +export default dashTimeV1 diff --git a/ui/src/data_explorer/actions/view/index.js b/ui/src/data_explorer/actions/view/index.js index 1561195ae8..0c12cb2072 100644 --- a/ui/src/data_explorer/actions/view/index.js +++ b/ui/src/data_explorer/actions/view/index.js @@ -4,8 +4,6 @@ import {getQueryConfig} from 'shared/apis' import {errorThrown} from 'shared/actions/errors' -import {DEFAULT_DATA_EXPLORER_GROUP_BY_INTERVAL} from 'src/data_explorer/constants' - export const addQuery = () => ({ type: 'DE_ADD_QUERY', payload: { @@ -44,30 +42,21 @@ export const fill = (queryId, value) => ({ }, }) -// all fields implicitly have a function applied to them by default, unless -// it was explicitly removed previously, so set the auto group by time except -// under that removal condition -export const toggleFieldWithGroupByInterval = (queryID, fieldFunc) => ( - dispatch, - getState -) => { - dispatch(toggleField(queryID, fieldFunc)) - // toggleField determines whether to add a func, so now check state for funcs - // presence, and if present then apply default group by time - const updatedFieldFunc = getState().dataExplorerQueryConfigs[ - queryID - ].fields.find(({field}) => field === fieldFunc.field) - // updatedFieldFunc could be undefined if it was toggled for removal - if (updatedFieldFunc && updatedFieldFunc.funcs.length) { - dispatch(groupByTime(queryID, DEFAULT_DATA_EXPLORER_GROUP_BY_INTERVAL)) - } -} +export const removeFuncs = (queryID, fields, groupBy) => ({ + type: 'DE_REMOVE_FUNCS', + payload: { + queryID, + fields, + groupBy, + }, +}) -export const applyFuncsToField = (queryId, fieldFunc) => ({ +export const applyFuncsToField = (queryId, fieldFunc, groupBy) => ({ type: 'DE_APPLY_FUNCS_TO_FIELD', payload: { queryId, fieldFunc, + groupBy, }, }) @@ -141,6 +130,15 @@ export const updateQueryConfig = config => ({ }, }) +export const addInitialField = (queryID, field, groupBy) => ({ + type: 'DE_ADD_INITIAL_FIELD', + payload: { + queryID, + field, + groupBy, + }, +}) + export const editQueryStatus = (queryID, status) => ({ type: 'DE_EDIT_QUERY_STATUS', payload: { @@ -154,7 +152,6 @@ export const editRawTextAsync = (url, id, text) => async dispatch => { try { const {data} = await getQueryConfig(url, [{query: text, id}]) const config = data.queries.find(q => q.id === id) - config.queryConfig.rawText = text dispatch(updateQueryConfig(config.queryConfig)) } catch (error) { dispatch(errorThrown(error)) diff --git a/ui/src/data_explorer/components/FieldListItem.js b/ui/src/data_explorer/components/FieldListItem.js index 9c730a47c9..19d60c2f0b 100644 --- a/ui/src/data_explorer/components/FieldListItem.js +++ b/ui/src/data_explorer/components/FieldListItem.js @@ -1,84 +1,103 @@ -import React, {PropTypes} from 'react' +import React, {PropTypes, Component} from 'react' import classnames from 'classnames' import _ from 'lodash' import FunctionSelector from 'shared/components/FunctionSelector' +import {firstFieldName} from 'shared/reducers/helpers/fields' -const {string, shape, func, arrayOf, bool} = PropTypes -const FieldListItem = React.createClass({ - propTypes: { - fieldFunc: shape({ - field: string.isRequired, - funcs: arrayOf(string).isRequired, - }).isRequired, - isSelected: bool.isRequired, - onToggleField: func.isRequired, - onApplyFuncsToField: func.isRequired, - isKapacitorRule: bool.isRequired, - }, - - getInitialState() { - return { +class FieldListItem extends Component { + constructor(props) { + super(props) + this.state = { isOpen: false, } - }, + } - toggleFunctionsMenu(e) { + toggleFunctionsMenu = e => { if (e) { e.stopPropagation() } this.setState({isOpen: !this.state.isOpen}) - }, + } - handleToggleField() { - this.props.onToggleField(this.props.fieldFunc) + close = () => { this.setState({isOpen: false}) - }, + } - handleApplyFunctions(selectedFuncs) { - this.props.onApplyFuncsToField({ - field: this.props.fieldFunc.field, - funcs: selectedFuncs, + handleToggleField = () => { + const {onToggleField} = this.props + const value = this._getFieldName() + + onToggleField({value, type: 'field'}) + this.close() + } + + handleApplyFunctions = selectedFuncs => { + const {onApplyFuncsToField} = this.props + const fieldName = this._getFieldName() + const field = {value: fieldName, type: 'field'} + + onApplyFuncsToField({ + field, + funcs: selectedFuncs.map(this._makeFunc), }) - this.setState({isOpen: false}) - }, + this.close() + } + + _makeFunc = value => ({ + value, + type: 'func', + }) + + _getFieldName = () => { + const {fieldFuncs} = this.props + const fieldFunc = _.head(fieldFuncs) + + return _.get(fieldFunc, 'type') === 'field' + ? _.get(fieldFunc, 'value') + : firstFieldName(_.get(fieldFunc, 'args')) + } render() { - const {isKapacitorRule, fieldFunc, isSelected} = this.props + const {isKapacitorRule, isSelected, funcs} = this.props const {isOpen} = this.state - const {field: fieldText} = fieldFunc + const fieldName = this._getFieldName() let fieldFuncsLabel - if (!fieldFunc.funcs.length) { - fieldFuncsLabel = '0 Functions' - } else if (fieldFunc.funcs.length === 1) { - fieldFuncsLabel = `${fieldFunc.funcs.length} Function` - } else if (fieldFunc.funcs.length > 1) { - fieldFuncsLabel = `${fieldFunc.funcs.length} Functions` + const num = funcs.length + switch (num) { + case 0: + fieldFuncsLabel = '0 Functions' + break + case 1: + fieldFuncsLabel = `${num} Function` + break + default: + fieldFuncsLabel = `${num} Functions` + break } - return ( -
    +
    - {fieldText} + {fieldName} {isSelected ?
    {fieldFuncsLabel}
    @@ -87,13 +106,35 @@ const FieldListItem = React.createClass({ {isSelected && isOpen ? : null}
    ) - }, -}) + } +} +const {string, shape, func, arrayOf, bool} = PropTypes + +FieldListItem.propTypes = { + fieldFuncs: arrayOf( + shape({ + type: string.isRequired, + value: string.isRequired, + alias: string, + args: arrayOf( + shape({ + type: string.isRequired, + value: string.isRequired, + }) + ), + }) + ).isRequired, + isSelected: bool.isRequired, + onToggleField: func.isRequired, + onApplyFuncsToField: func.isRequired, + isKapacitorRule: bool.isRequired, + funcs: arrayOf(string.isRequired).isRequired, +} export default FieldListItem diff --git a/ui/src/data_explorer/components/GroupByTimeDropdown.js b/ui/src/data_explorer/components/GroupByTimeDropdown.js index 95b604e45f..ebf1d8e5be 100644 --- a/ui/src/data_explorer/components/GroupByTimeDropdown.js +++ b/ui/src/data_explorer/components/GroupByTimeDropdown.js @@ -1,53 +1,48 @@ import React, {PropTypes} from 'react' +import {withRouter} from 'react-router' import groupByTimeOptions from 'hson!src/data_explorer/data/groupByTimes.hson' import Dropdown from 'shared/components/Dropdown' -import {DEFAULT_DASHBOARD_GROUP_BY_INTERVAL} from 'shared/constants' +import {AUTO_GROUP_BY} from 'shared/constants' -const {bool, func, string} = PropTypes +const {func, string, shape} = PropTypes -const GroupByTimeDropdown = React.createClass({ - propTypes: { - selected: string, - onChooseGroupByTime: func.isRequired, - isInRuleBuilder: bool, - isInDataExplorer: bool, - }, +const isInRuleBuilder = pathname => pathname.includes('alert-rules') +const isInDataExplorer = pathname => pathname.includes('data-explorer') - render() { - const { - selected, - onChooseGroupByTime, - isInRuleBuilder, - isInDataExplorer, - } = this.props +const getOptions = pathname => + isInDataExplorer(pathname) || isInRuleBuilder(pathname) + ? groupByTimeOptions.filter(({menuOption}) => menuOption !== AUTO_GROUP_BY) + : groupByTimeOptions - let validOptions = groupByTimeOptions - if (isInDataExplorer || isInRuleBuilder) { - validOptions = validOptions.filter( - ({menuOption}) => menuOption !== DEFAULT_DASHBOARD_GROUP_BY_INTERVAL - ) - } +const GroupByTimeDropdown = ({ + selected, + onChooseGroupByTime, + location: {pathname}, +}) => +
    + + ({ + ...groupBy, + text: groupBy.menuOption, + }))} + onChoose={onChooseGroupByTime} + selected={selected || 'Time'} + /> +
    - return ( -
    - - ({ - ...groupBy, - text: groupBy.menuOption, - }))} - onChoose={onChooseGroupByTime} - selected={selected || 'Time'} - /> -
    - ) - }, -}) +GroupByTimeDropdown.propTypes = { + location: shape({ + pathname: string.isRequired, + }).isRequired, + selected: string, + onChooseGroupByTime: func.isRequired, +} -export default GroupByTimeDropdown +export default withRouter(GroupByTimeDropdown) diff --git a/ui/src/data_explorer/components/QueryMaker.js b/ui/src/data_explorer/components/QueryMaker.js index 5b7fd4d465..3057b56828 100644 --- a/ui/src/data_explorer/components/QueryMaker.js +++ b/ui/src/data_explorer/components/QueryMaker.js @@ -7,7 +7,13 @@ import {buildRawText} from 'utils/influxql' const rawTextBinder = (links, id, action) => text => action(links.queries, id, text) -const QueryMaker = ({source, actions, timeRange, activeQuery}) => +const QueryMaker = ({ + source, + actions, + timeRange, + activeQuery, + initialGroupByTime, +}) =>
    actions.editRawTextAsync )} /> - +
    @@ -46,8 +56,10 @@ QueryMaker.propTypes = { toggleTagAcceptance: func.isRequired, applyFuncsToField: func.isRequired, editRawTextAsync: func.isRequired, + addInitialField: func.isRequired, }).isRequired, activeQuery: shape({}), + initialGroupByTime: string.isRequired, } export default QueryMaker diff --git a/ui/src/data_explorer/components/Table.js b/ui/src/data_explorer/components/Table.js index 88c1ca5555..eefe3bec6b 100644 --- a/ui/src/data_explorer/components/Table.js +++ b/ui/src/data_explorer/components/Table.js @@ -88,6 +88,8 @@ class ChronoTable extends Component { ) } + makeTabName = ({name, tags}) => (tags ? `${name}.${tags[name]}` : name) + render() { const {containerWidth, height, query} = this.props const {series, columnWidths, isLoading, activeSeriesIndex} = this.state @@ -121,11 +123,11 @@ class ChronoTable extends Component {
    {series.length < maximumTabsCount ?
    - {series.map(({name}, i) => + {series.map((s, i) => diff --git a/ui/src/data_explorer/components/TagListItem.js b/ui/src/data_explorer/components/TagListItem.js index 73d2ee6642..21242e91bc 100644 --- a/ui/src/data_explorer/components/TagListItem.js +++ b/ui/src/data_explorer/components/TagListItem.js @@ -99,7 +99,7 @@ const TagListItem = React.createClass({ }, render() { - const {tagKey, tagValues} = this.props + const {tagKey, tagValues, isUsingGroupBy} = this.props const {isOpen} = this.state const tagItemLabel = `${tagKey} — ${tagValues.length}` @@ -115,8 +115,9 @@ const TagListItem = React.createClass({ {tagItemLabel}
    diff --git a/ui/src/data_explorer/components/VisHeader.js b/ui/src/data_explorer/components/VisHeader.js index 0457460fe7..2d3325456e 100644 --- a/ui/src/data_explorer/components/VisHeader.js +++ b/ui/src/data_explorer/components/VisHeader.js @@ -3,13 +3,17 @@ import classnames from 'classnames' import _ from 'lodash' import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries' -import resultsToCSV from 'src/shared/parsing/resultsToCSV.js' +import {resultsToCSV} from 'src/shared/parsing/resultsToCSV.js' import download from 'src/external/download.js' const getCSV = (query, errorThrown) => async () => { try { const {results} = await fetchTimeSeriesAsync({source: query.host, query}) - const {name, CSVString} = resultsToCSV(results) + const {flag, name, CSVString} = resultsToCSV(results) + if (flag === 'no_data') { + errorThrown('no data', 'There are no data to download.') + return + } download(CSVString, `${name}.csv`, 'text/plain') } catch (error) { errorThrown(error, 'Unable to download .csv file') diff --git a/ui/src/data_explorer/components/VisView.js b/ui/src/data_explorer/components/VisView.js index 59865c0211..0790774a26 100644 --- a/ui/src/data_explorer/components/VisView.js +++ b/ui/src/data_explorer/components/VisView.js @@ -12,6 +12,7 @@ const VisView = ({ templates, autoRefresh, heightPixels, + manualRefresh, editQueryStatus, resizerBottomHeight, }) => { @@ -41,6 +42,7 @@ const VisView = ({ templates={templates} cellHeight={heightPixels} autoRefresh={autoRefresh} + manualRefresh={manualRefresh} editQueryStatus={editQueryStatus} /> ) @@ -58,6 +60,7 @@ VisView.propTypes = { autoRefresh: number.isRequired, heightPixels: number, editQueryStatus: func.isRequired, + manualRefresh: number, activeQueryIndex: number, resizerBottomHeight: number, } diff --git a/ui/src/data_explorer/components/Visualization.js b/ui/src/data_explorer/components/Visualization.js index 5d106e301e..308084e84b 100644 --- a/ui/src/data_explorer/components/Visualization.js +++ b/ui/src/data_explorer/components/Visualization.js @@ -55,9 +55,9 @@ class Visualization extends Component { autoRefresh, heightPixels, queryConfigs, + manualRefresh, editQueryStatus, activeQueryIndex, - isInDataExplorer, resizerBottomHeight, errorThrown, } = this.props @@ -99,12 +99,12 @@ class Visualization extends Component { axes={axes} query={query} queries={queries} - templates={templates} cellType={cellType} + templates={templates} autoRefresh={autoRefresh} heightPixels={heightPixels} + manualRefresh={manualRefresh} editQueryStatus={editQueryStatus} - isInDataExplorer={isInDataExplorer} resizerBottomHeight={resizerBottomHeight} />
    @@ -123,7 +123,7 @@ Visualization.defaultProps = { cellType: '', } -const {arrayOf, bool, func, number, shape, string} = PropTypes +const {arrayOf, func, number, shape, string} = PropTypes Visualization.contextTypes = { source: shape({ @@ -138,7 +138,6 @@ Visualization.propTypes = { cellType: string, autoRefresh: number.isRequired, templates: arrayOf(shape()), - isInDataExplorer: bool, timeRange: shape({ upper: string, lower: string, @@ -156,6 +155,7 @@ Visualization.propTypes = { }), resizerBottomHeight: number, errorThrown: func.isRequired, + manualRefresh: number, } export default Visualization diff --git a/ui/src/data_explorer/components/WriteDataForm.js b/ui/src/data_explorer/components/WriteDataForm.js index 2ae70db830..1221c6f05a 100644 --- a/ui/src/data_explorer/components/WriteDataForm.js +++ b/ui/src/data_explorer/components/WriteDataForm.js @@ -79,6 +79,10 @@ class WriteDataForm extends Component { file = e.target.files[0] } + if (!file) { + return + } + e.preventDefault() e.stopPropagation() @@ -94,6 +98,7 @@ class WriteDataForm extends Component { handleCancelFile = () => { this.setState({uploadContent: ''}) + this.fileInput.value = '' } handleDragOver = e => { diff --git a/ui/src/data_explorer/constants/index.js b/ui/src/data_explorer/constants/index.js index b407ac6944..c6e9bf0767 100644 --- a/ui/src/data_explorer/constants/index.js +++ b/ui/src/data_explorer/constants/index.js @@ -81,5 +81,3 @@ export const QUERY_TEMPLATES = [ {text: 'Show Stats', query: 'SHOW STATS'}, {text: 'Show Diagnostics', query: 'SHOW DIAGNOSTICS'}, ] - -export const DEFAULT_DATA_EXPLORER_GROUP_BY_INTERVAL = '10s' diff --git a/ui/src/data_explorer/containers/DataExplorer.js b/ui/src/data_explorer/containers/DataExplorer.js index 5e579ef6db..5b7da7339b 100644 --- a/ui/src/data_explorer/containers/DataExplorer.js +++ b/ui/src/data_explorer/containers/DataExplorer.js @@ -12,8 +12,9 @@ import WriteDataForm from 'src/data_explorer/components/WriteDataForm' import Header from '../containers/Header' import ResizeContainer from 'shared/components/ResizeContainer' import OverlayTechnologies from 'shared/components/OverlayTechnologies' +import ManualRefresh from 'src/shared/components/ManualRefresh' -import {VIS_VIEWS} from 'shared/constants' +import {VIS_VIEWS, INITIAL_GROUP_BY_TIME} from 'shared/constants' import {MINIMUM_HEIGHTS, INITIAL_HEIGHTS} from '../constants' import {errorThrown} from 'shared/actions/errors' import {setAutoRefresh} from 'shared/actions/app' @@ -35,6 +36,7 @@ class DataExplorer extends Component { if (queryConfigs.length === 0) { this.props.queryConfigActions.addQuery() } + return queryConfigs[0] } @@ -66,17 +68,22 @@ class DataExplorer extends Component { this.setState({showWriteForm: true}) } + handleChooseTimeRange = bounds => { + this.props.setTimeRange(bounds) + } + render() { const { - autoRefresh, - errorThrownAction, - handleChooseAutoRefresh, - timeRange, - setTimeRange, - queryConfigs, - queryConfigActions, source, + timeRange, + autoRefresh, + queryConfigs, + manualRefresh, + onManualRefresh, + errorThrownAction, writeLineProtocol, + queryConfigActions, + handleChooseAutoRefresh, } = this.props const {showWriteForm} = this.state @@ -98,8 +105,10 @@ class DataExplorer extends Component {
    @@ -162,6 +172,8 @@ DataExplorer.propTypes = { }).isRequired, writeLineProtocol: func.isRequired, errorThrownAction: func.isRequired, + onManualRefresh: func.isRequired, + manualRefresh: number.isRequired, } DataExplorer.childContextTypes = { @@ -207,5 +219,5 @@ const mapDispatchToProps = dispatch => { } export default connect(mapStateToProps, mapDispatchToProps)( - withRouter(DataExplorer) + withRouter(ManualRefresh(DataExplorer)) ) diff --git a/ui/src/data_explorer/containers/Header.js b/ui/src/data_explorer/containers/Header.js index cbf79fc4f1..df57ffb8fa 100644 --- a/ui/src/data_explorer/containers/Header.js +++ b/ui/src/data_explorer/containers/Header.js @@ -8,63 +8,55 @@ import GraphTips from 'shared/components/GraphTips' const {func, number, shape, string} = PropTypes -const Header = React.createClass({ - propTypes: { - actions: shape({ - handleChooseAutoRefresh: func.isRequired, - setTimeRange: func.isRequired, - }), - autoRefresh: number.isRequired, - showWriteForm: func.isRequired, - timeRange: shape({ - lower: string, - upper: string, - }).isRequired, - }, - - handleChooseTimeRange(bounds) { - this.props.actions.setTimeRange(bounds) - }, - - render() { - const { - autoRefresh, - actions: {handleChooseAutoRefresh}, - showWriteForm, - timeRange, - } = this.props - - return ( -
    -
    -
    -

    Data Explorer

    -
    -
    - - -
    - - Write Data -
    - - -
    -
    +const Header = ({ + timeRange, + autoRefresh, + showWriteForm, + onManualRefresh, + onChooseTimeRange, + onChooseAutoRefresh, +}) => +
    +
    +
    +

    Data Explorer

    - ) - }, -}) +
    + + +
    + + Write Data +
    + + +
    +
    +
    + +Header.propTypes = { + onChooseAutoRefresh: func.isRequired, + onChooseTimeRange: func.isRequired, + onManualRefresh: func.isRequired, + autoRefresh: number.isRequired, + showWriteForm: func.isRequired, + timeRange: shape({ + lower: string, + upper: string, + }).isRequired, +} export default withRouter(Header) diff --git a/ui/src/data_explorer/reducers/queryConfigs.js b/ui/src/data_explorer/reducers/queryConfigs.js index 9aa0aaec59..3837ca36ab 100644 --- a/ui/src/data_explorer/reducers/queryConfigs.js +++ b/ui/src/data_explorer/reducers/queryConfigs.js @@ -2,17 +2,19 @@ import _ from 'lodash' import defaultQueryConfig from 'src/utils/defaultQueryConfig' import { - editRawText, - applyFuncsToField, - chooseMeasurement, - chooseNamespace, + fill, chooseTag, groupByTag, + removeFuncs, groupByTime, toggleField, - toggleTagAcceptance, - fill, + editRawText, updateRawQuery, + chooseNamespace, + chooseMeasurement, + addInitialField, + applyFuncsToField, + toggleTagAcceptance, } from 'src/utils/queryTransitions' const queryConfigs = (state = {}, action) => { @@ -98,10 +100,12 @@ const queryConfigs = (state = {}, action) => { } case 'DE_APPLY_FUNCS_TO_FIELD': { - const {queryId, fieldFunc} = action.payload - const nextQueryConfig = applyFuncsToField(state[queryId], fieldFunc, { - preventAutoGroupBy: true, - }) + const {queryId, fieldFunc, groupBy} = action.payload + const nextQueryConfig = applyFuncsToField( + state[queryId], + fieldFunc, + groupBy + ) return Object.assign({}, state, { [queryId]: nextQueryConfig, @@ -151,6 +155,22 @@ const queryConfigs = (state = {}, action) => { return {...state, ...nextState} } + + case 'DE_REMOVE_FUNCS': { + const {queryID, fields} = action.payload + const nextQuery = removeFuncs(state[queryID], fields) + + // fields with no functions cannot have a group by time + return {...state, [queryID]: nextQuery} + } + + // Adding the first feild applies a groupBy time + case 'DE_ADD_INITIAL_FIELD': { + const {queryID, field, groupBy} = action.payload + const nextQuery = addInitialField(state[queryID], field, groupBy) + + return {...state, [queryID]: nextQuery} + } } return state } diff --git a/ui/src/hosts/containers/HostPage.js b/ui/src/hosts/containers/HostPage.js index 74dd2014b2..5e09fb1cb5 100644 --- a/ui/src/hosts/containers/HostPage.js +++ b/ui/src/hosts/containers/HostPage.js @@ -1,5 +1,4 @@ -import React, {PropTypes} from 'react' -import {Link} from 'react-router' +import React, {PropTypes, Component} from 'react' import {connect} from 'react-redux' import {bindActionCreators} from 'redux' import _ from 'lodash' @@ -10,6 +9,7 @@ import Dygraph from 'src/external/dygraph' import LayoutRenderer from 'shared/components/LayoutRenderer' import DashboardHeader from 'src/dashboards/components/DashboardHeader' import FancyScrollbar from 'shared/components/FancyScrollbar' +import ManualRefresh from 'src/shared/components/ManualRefresh' import timeRanges from 'hson!shared/data/timeRanges.hson' import { @@ -23,39 +23,16 @@ import {fetchLayouts} from 'shared/apis' import {setAutoRefresh} from 'shared/actions/app' import {presentationButtonDispatcher} from 'shared/dispatchers' -const {shape, string, bool, func, number} = PropTypes - -export const HostPage = React.createClass({ - propTypes: { - source: shape({ - links: shape({ - proxy: string.isRequired, - }).isRequired, - telegraf: string.isRequired, - id: string.isRequired, - }), - params: shape({ - hostID: string.isRequired, - }).isRequired, - location: shape({ - query: shape({ - app: string, - }), - }), - autoRefresh: number.isRequired, - handleChooseAutoRefresh: func.isRequired, - inPresentationMode: bool, - handleClickPresentationButton: func, - }, - - getInitialState() { - return { +class HostPage extends Component { + constructor(props) { + super(props) + this.state = { layouts: [], - hosts: [], + hosts: {}, timeRange: timeRanges.find(tr => tr.lower === 'now() - 1h'), dygraphs: [], } - }, + } async componentDidMount() { const {source, params, location} = this.props @@ -70,6 +47,7 @@ export const HostPage = React.createClass({ mappings, source.telegraf ) + const measurements = await getMeasurementsForHost(source, params.hostID) const host = newHosts[this.props.params.hostID] @@ -96,19 +74,19 @@ export const HostPage = React.createClass({ } this.setState({layouts: filteredLayouts, hosts: filteredHosts}) // eslint-disable-line react/no-did-mount-set-state - }, + } - handleChooseTimeRange({lower, upper}) { + handleChooseTimeRange = ({lower, upper}) => { if (upper) { this.setState({timeRange: {lower, upper}}) } else { const timeRange = timeRanges.find(range => range.lower === lower) this.setState({timeRange}) } - }, + } - synchronizer(dygraph) { - const dygraphs = [...this.state.dygraphs, dygraph] + synchronizer = dygraph => { + const dygraphs = [...this.state.dygraphs, dygraph].filter(d => d.graphDiv) const numGraphs = this.state.layouts.reduce((acc, {cells}) => { return acc + cells.length }, 0) @@ -121,11 +99,11 @@ export const HostPage = React.createClass({ }) } this.setState({dygraphs}) - }, + } - renderLayouts(layouts) { + renderLayouts = layouts => { const {timeRange} = this.state - const {source, autoRefresh} = this.props + const {source, autoRefresh, manualRefresh} = this.props const autoflowLayouts = layouts.filter(layout => !!layout.autoflow) @@ -173,53 +151,46 @@ export const HostPage = React.createClass({ return ( ) - }, + } render() { const { - params: {hostID}, - location: {query: {app}}, - source: {id}, autoRefresh, - handleChooseAutoRefresh, + onManualRefresh, + params: {hostID, sourceID}, inPresentationMode, + handleChooseAutoRefresh, handleClickPresentationButton, - source, } = this.props const {layouts, timeRange, hosts} = this.state - const appParam = app ? `?app=${app}` : '' + const names = _.map(hosts, ({name}) => ({ + name, + link: `/sources/${sourceID}/hosts/${name}`, + })) return (
    - {Object.keys(hosts).map((host, i) => { - return ( -
  • - - {host} - -
  • - ) - })} -
    + />
    ) - }, -}) + } +} + +const {shape, string, bool, func, number} = PropTypes + +HostPage.propTypes = { + source: shape({ + links: shape({ + proxy: string.isRequired, + }).isRequired, + telegraf: string.isRequired, + id: string.isRequired, + }), + params: shape({ + hostID: string.isRequired, + }).isRequired, + location: shape({ + query: shape({ + app: string, + }), + }), + inPresentationMode: bool, + autoRefresh: number.isRequired, + manualRefresh: number.isRequired, + onManualRefresh: func.isRequired, + handleChooseAutoRefresh: func.isRequired, + handleClickPresentationButton: func, +} const mapStateToProps = ({ app: {ephemeral: {inPresentationMode}, persisted: {autoRefresh}}, @@ -247,4 +244,6 @@ const mapDispatchToProps = dispatch => ({ handleClickPresentationButton: presentationButtonDispatcher(dispatch), }) -export default connect(mapStateToProps, mapDispatchToProps)(HostPage) +export default connect(mapStateToProps, mapDispatchToProps)( + ManualRefresh(HostPage) +) diff --git a/ui/src/index.js b/ui/src/index.js index 8a8838dfc5..8783417b8a 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -26,7 +26,7 @@ import { TickscriptPage, } from 'src/kapacitor' import {AdminPage} from 'src/admin' -import {CreateSource, SourcePage, ManageSources} from 'src/sources' +import {SourcePage, ManageSources} from 'src/sources' import NotFound from 'shared/components/NotFound' import {getMe} from 'shared/apis' @@ -127,7 +127,7 @@ const Root = React.createClass({ diff --git a/ui/src/kapacitor/actions/queryConfigs.js b/ui/src/kapacitor/actions/queryConfigs.js index 16120c2f8d..f255642342 100644 --- a/ui/src/kapacitor/actions/queryConfigs.js +++ b/ui/src/kapacitor/actions/queryConfigs.js @@ -61,3 +61,11 @@ export const groupByTime = (queryId, time) => ({ time, }, }) + +export const removeFuncs = (queryID, fields) => ({ + type: 'KAPA_REMOVE_FUNCS', + payload: { + queryID, + fields, + }, +}) diff --git a/ui/src/kapacitor/components/DataSection.js b/ui/src/kapacitor/components/DataSection.js index 025d4d2b9c..7231f994f6 100644 --- a/ui/src/kapacitor/components/DataSection.js +++ b/ui/src/kapacitor/components/DataSection.js @@ -15,13 +15,8 @@ const makeQueryHandlers = (actions, query) => ({ actions.chooseMeasurement(query.id, measurement) }, - handleToggleField: onRemoveEvery => field => { + handleToggleField: field => { actions.toggleField(query.id, field) - // Every is only added when a function has been added to a field. - // Here, the field is selected without a function. - onRemoveEvery() - // Because there are no functions there is no group by time. - actions.groupByTime(query.id, null) }, handleGroupByTime: time => { @@ -44,6 +39,10 @@ const makeQueryHandlers = (actions, query) => ({ handleGroupByTag: tagKey => { actions.groupByTag(query.id, tagKey) }, + + handleRemoveFuncs: fields => { + actions.removeFuncs(query.id, fields) + }, }) const DataSection = ({ @@ -51,23 +50,23 @@ const DataSection = ({ query, isDeadman, isKapacitorRule, - onRemoveEvery, onAddEvery, }) => { const { - handleChooseNamespace, - handleChooseMeasurement, + handleChooseTag, + handleGroupByTag, handleToggleField, handleGroupByTime, + handleRemoveFuncs, + handleChooseNamespace, handleApplyFuncsToField, - handleChooseTag, + handleChooseMeasurement, handleToggleTagAcceptance, - handleGroupByTag, } = makeQueryHandlers(actions, query) return (
    -
    +
    }
    @@ -108,7 +108,6 @@ DataSection.propTypes = { toggleTagAcceptance: func.isRequired, }).isRequired, onAddEvery: func.isRequired, - onRemoveEvery: func.isRequired, timeRange: shape({}).isRequired, isKapacitorRule: bool, isDeadman: bool, diff --git a/ui/src/kapacitor/components/Deadman.js b/ui/src/kapacitor/components/Deadman.js index 029cc2c135..dea1dd15e1 100644 --- a/ui/src/kapacitor/components/Deadman.js +++ b/ui/src/kapacitor/components/Deadman.js @@ -7,7 +7,7 @@ const periods = PERIODS.map(text => { }) const Deadman = ({rule, onChange}) => -
    +

    Send Alert if Data is missing for

    { - const {addFlashMessage, queryConfigs, rule} = this.props + const {addFlashMessage, queryConfigs, rule, router, source} = this.props const updatedRule = Object.assign({}, rule, { query: queryConfigs[rule.queryID], }) editRule(updatedRule) .then(() => { - addFlashMessage({type: 'success', text: 'Rule successfully updated!'}) + router.push(`/sources/${source.id}/alert-rules`) + addFlashMessage({ + type: 'success', + text: `${rule.name} successfully saved!`, + }) }) .catch(() => { addFlashMessage({ type: 'error', - text: 'There was a problem updating the rule', + text: `There was a problem saving ${rule.name}`, }) }) } @@ -85,11 +90,11 @@ class KapacitorRule extends Component { } if (!buildInfluxQLQuery({}, query)) { - return 'Please select a database, measurement, and field' + return 'Please select a Database, Measurement, and Field' } if (!rule.values.value) { - return 'Please enter a value in the Rule Conditions section' + return 'Please enter a value in the Conditions section' } return '' @@ -98,7 +103,7 @@ class KapacitorRule extends Component { deadmanValidation = () => { const {query} = this.props if (query && (!query.database || !query.measurement)) { - return 'Deadman requires a database and measurement' + return 'Deadman rules require a Database and Measurement' } return '' @@ -144,19 +149,21 @@ class KapacitorRule extends Component { return (
    + e => { + const {defaultName, onRuleRename, ruleID} = this.props + + onRuleRename(ruleID, reset ? defaultName : e.target.value) + this.setState({reset: false}) + } + + handleKeyDown = e => { + if (e.key === 'Enter') { + this.inputRef.blur() + } + if (e.key === 'Escape') { + this.inputRef.value = this.props.defaultName + this.setState({reset: true}, () => this.inputRef.blur()) + } + } + + render() { + const {isEditing, defaultName} = this.props + const {reset} = this.state + + return ( +
    +

    + {isEditing ? 'Name' : 'Name this Alert Rule'} +

    +
    +
    + (this.inputRef = r)} + /> +
    +
    +
    + ) + } +} + +const {bool, func, string} = PropTypes + +NameSection.propTypes = { + isEditing: bool, + defaultName: string.isRequired, + onRuleRename: func.isRequired, + ruleID: string.isRequired, +} + +export default NameSection diff --git a/ui/src/kapacitor/components/Relative.js b/ui/src/kapacitor/components/Relative.js index ff2713971a..04ca3886d3 100644 --- a/ui/src/kapacitor/components/Relative.js +++ b/ui/src/kapacitor/components/Relative.js @@ -12,7 +12,7 @@ const Relative = ({ onDropdownChange, rule: {values: {change, shift, operator, value}}, }) => -
    +

    Send Alert when

    { + const autoRefreshMs = 30000 + const queryText = buildInfluxQLQuery({lower}, query) + const queries = [{host: source.links.proxy, text: queryText}] + const kapacitorLineColors = ['#4ED8A0'] - render() { + if (!queryText) { return ( -
    - {this.renderGraph()} +
    +

    + Select a Time-Series to preview on a graph +

    ) - }, + } - renderGraph() { - const {query, source, timeRange: {lower}, rule} = this.props - const autoRefreshMs = 30000 - const queryText = buildInfluxQLQuery({lower}, query) - const queries = [{host: source.links.proxy, text: queryText}] - const kapacitorLineColors = ['#4ED8A0'] - - if (!queryText) { - return ( -
    -

    - Select a Time-Series to preview on a graph -

    -
    - ) - } - - return ( + return ( +
    +
    +

    Preview Data from

    + +
    - ) - }, +
    + ) +} - createUnderlayCallback() { - const {rule} = this.props - return (canvas, area, dygraph) => { - if (rule.trigger !== 'threshold' || rule.values.value === '') { - return - } - - const theOnePercent = 0.01 - let highlightStart = 0 - let highlightEnd = 0 - - switch (rule.values.operator) { - case 'equal to or greater': - case 'greater than': { - highlightStart = rule.values.value - highlightEnd = dygraph.yAxisRange()[1] - break - } - - case 'equal to or less than': - case 'less than': { - highlightStart = dygraph.yAxisRange()[0] - highlightEnd = rule.values.value - break - } - - case 'not equal to': - case 'equal to': { - const width = - theOnePercent * (dygraph.yAxisRange()[1] - dygraph.yAxisRange()[0]) - highlightStart = +rule.values.value - width - highlightEnd = +rule.values.value + width - break - } - - case 'outside range': { - const {rangeValue, value} = rule.values - highlightStart = Math.min(+value, +rangeValue) - highlightEnd = Math.max(+value, +rangeValue) - - canvas.fillStyle = 'rgba(78, 216, 160, 0.3)' - canvas.fillRect(area.x, area.y, area.w, area.h) - break - } - case 'inside range': { - const {rangeValue, value} = rule.values - highlightStart = Math.min(+value, +rangeValue) - highlightEnd = Math.max(+value, +rangeValue) - break - } - } - - const bottom = dygraph.toDomYCoord(highlightStart) - const top = dygraph.toDomYCoord(highlightEnd) - - canvas.fillStyle = - rule.values.operator === 'outside range' - ? 'rgba(41, 41, 51, 1)' - : 'rgba(78, 216, 160, 0.3)' - canvas.fillRect(area.x, top, area.w, bottom - top) - } - }, -}) +RuleGraph.propTypes = { + source: shape({ + links: shape({ + proxy: string.isRequired, + }).isRequired, + }).isRequired, + query: shape({}).isRequired, + rule: shape({}).isRequired, + timeRange: shape({}).isRequired, + onChooseTimeRange: func.isRequired, +} export default RuleGraph diff --git a/ui/src/kapacitor/components/RuleHeader.js b/ui/src/kapacitor/components/RuleHeader.js index 9710069356..bb8246214a 100644 --- a/ui/src/kapacitor/components/RuleHeader.js +++ b/ui/src/kapacitor/components/RuleHeader.js @@ -1,66 +1,24 @@ import React, {PropTypes, Component} from 'react' -import RuleHeaderEdit from 'src/kapacitor/components/RuleHeaderEdit' import RuleHeaderSave from 'src/kapacitor/components/RuleHeaderSave' class RuleHeader extends Component { constructor(props) { super(props) - - this.state = { - isEditingName: false, - } - } - - toggleEditName = () => { - this.setState({isEditingName: !this.state.isEditingName}) - } - - handleEditName = rule => e => { - if (e.key === 'Enter') { - const {updateRuleName} = this.props.actions - updateRuleName(rule.id, e.target.value) - this.toggleEditName() - } - - if (e.key === 'Escape') { - this.toggleEditName() - } - } - - handleEditNameBlur = rule => e => { - const {updateRuleName} = this.props.actions - updateRuleName(rule.id, e.target.value) - this.toggleEditName() } render() { - const { - rule, - source, - onSave, - timeRange, - validationError, - onChooseTimeRange, - } = this.props - - const {isEditingName} = this.state + const {source, onSave, validationError} = this.props return (
    - +
    +

    Alert Rule Builder

    +
    @@ -73,13 +31,7 @@ const {func, shape, string} = PropTypes RuleHeader.propTypes = { source: shape({}).isRequired, onSave: func.isRequired, - rule: shape({}).isRequired, - actions: shape({ - updateRuleName: func.isRequired, - }).isRequired, validationError: string.isRequired, - onChooseTimeRange: func.isRequired, - timeRange: shape({}).isRequired, } export default RuleHeader diff --git a/ui/src/kapacitor/components/RuleHeaderEdit.js b/ui/src/kapacitor/components/RuleHeaderEdit.js deleted file mode 100644 index dc76fba5fc..0000000000 --- a/ui/src/kapacitor/components/RuleHeaderEdit.js +++ /dev/null @@ -1,51 +0,0 @@ -import React, {PropTypes} from 'react' -import ReactTooltip from 'react-tooltip' - -const RuleHeaderEdit = ({ - rule, - isEditing, - onToggleEdit, - onEditName, - onEditNameBlur, -}) => - isEditing - ? - :
    -

    - {rule.name} - - -

    -
    - -const {bool, func, shape} = PropTypes - -RuleHeaderEdit.propTypes = { - rule: shape(), - isEditing: bool.isRequired, - onToggleEdit: func.isRequired, - onEditName: func.isRequired, - onEditNameBlur: func.isRequired, -} - -export default RuleHeaderEdit diff --git a/ui/src/kapacitor/components/RuleHeaderSave.js b/ui/src/kapacitor/components/RuleHeaderSave.js index 8d71493aa4..8a6143d7f3 100644 --- a/ui/src/kapacitor/components/RuleHeaderSave.js +++ b/ui/src/kapacitor/components/RuleHeaderSave.js @@ -1,21 +1,10 @@ import React, {PropTypes} from 'react' import ReactTooltip from 'react-tooltip' -import TimeRangeDropdown from 'shared/components/TimeRangeDropdown' import SourceIndicator from 'shared/components/SourceIndicator' -const RuleHeaderSave = ({ - onSave, - timeRange, - validationError, - onChooseTimeRange, -}) => +const RuleHeaderSave = ({onSave, validationError}) =>
    - {validationError ?
    -const {func, shape, string} = PropTypes +const {func, string} = PropTypes RuleHeaderSave.propTypes = { onSave: func.isRequired, validationError: string.isRequired, - onChooseTimeRange: func.isRequired, - timeRange: shape({}).isRequired, } export default RuleHeaderSave diff --git a/ui/src/kapacitor/components/RuleMessageText.js b/ui/src/kapacitor/components/RuleMessageText.js index ceb1d366f5..953fae5902 100644 --- a/ui/src/kapacitor/components/RuleMessageText.js +++ b/ui/src/kapacitor/components/RuleMessageText.js @@ -1,13 +1,15 @@ import React, {PropTypes} from 'react' const RuleMessageText = ({rule, updateMessage}) => -