Merge branch 'master' into multitenancy
commit
aa8742662f
|
@ -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<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<release>\d+)
|
||||
serialize = {major}.{minor}.{patch}.{release}
|
||||
|
|
38
CHANGELOG.md
38
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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
111
influx/query.go
111
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
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -22,7 +22,8 @@ func TestVarsCritStringEqual(t *testing.T) {
|
|||
RetentionPolicy: "autogen",
|
||||
Fields: []chronograf.Field{
|
||||
{
|
||||
Field: "status",
|
||||
Value: "status",
|
||||
Type: "field",
|
||||
},
|
||||
},
|
||||
GroupBy: chronograf.GroupBy{
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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": {},
|
||||
|
|
|
@ -5,3 +5,4 @@ dev/
|
|||
dist/
|
||||
bower_components/
|
||||
log/
|
||||
.tern-project
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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'}])
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -19,32 +19,6 @@ class DatabaseManagerPage extends Component {
|
|||
actions.loadDBsAndRPsAsync(databases)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {source, databases, actions, notify} = this.props
|
||||
return (
|
||||
<DatabaseManager
|
||||
databases={databases}
|
||||
notify={notify}
|
||||
isRFDisplayed={!!source.metaUrl}
|
||||
isAddDBDisabled={!!databases.some(db => 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 (
|
||||
<DatabaseManager
|
||||
notify={notify}
|
||||
databases={databases}
|
||||
isRFDisplayed={!!source.metaUrl}
|
||||
addDatabase={actions.addDatabase}
|
||||
onEditDatabase={this.handleEditDatabase}
|
||||
onCancelDatabase={actions.removeDatabase}
|
||||
onConfirmDatabase={this.handleCreateDatabase}
|
||||
onDeleteDatabase={actions.deleteDatabaseAsync}
|
||||
onKeyDownDatabase={this.handleKeyDownDatabase}
|
||||
onAddRetentionPolicy={this.handleAddRetentionPolicy}
|
||||
onRemoveDeleteCode={actions.removeDatabaseDeleteCode}
|
||||
onStartDeleteDatabase={this.handleStartDeleteDatabase}
|
||||
isAddDBDisabled={!!databases.some(db => 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
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>}
|
||||
</CEOBottom>
|
||||
</ResizeContainer>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -5,46 +5,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
|
||||
: <div className="page-header full-width">
|
||||
<div className="page-header__container">
|
||||
<div className="page-header__left">
|
||||
{buttonText &&
|
||||
<div className="dropdown page-header-dropdown">
|
||||
<button
|
||||
className="dropdown-toggle"
|
||||
type="button"
|
||||
data-toggle="dropdown"
|
||||
>
|
||||
<span>
|
||||
{buttonText}
|
||||
</span>
|
||||
<span className="caret" />
|
||||
</button>
|
||||
<ul className="dropdown-menu">
|
||||
{children}
|
||||
</ul>
|
||||
</div>}
|
||||
{headerText}
|
||||
<div
|
||||
className={
|
||||
dashboard
|
||||
? 'page-header__left page-header__dash-editable'
|
||||
: 'page-header__left'
|
||||
}
|
||||
>
|
||||
{names && names.length > 1
|
||||
? <DashboardSwitcher
|
||||
names={names}
|
||||
activeDashboard={activeDashboard}
|
||||
/>
|
||||
: null}
|
||||
{dashboard
|
||||
? <DashboardHeaderEdit
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
activeDashboard={activeDashboard}
|
||||
onEditDashboard={onEditDashboard}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
: <h1 className="page-header__title">
|
||||
{activeDashboard}
|
||||
</h1>}
|
||||
</div>
|
||||
<div className="page-header__right">
|
||||
<GraphTips />
|
||||
|
@ -55,15 +66,6 @@ const DashboardHeader = ({
|
|||
Add Cell
|
||||
</button>
|
||||
: null}
|
||||
{dashboard
|
||||
? <button
|
||||
className="btn btn-default btn-sm"
|
||||
onClick={onEditDashboard}
|
||||
>
|
||||
<span className="icon pencil" />
|
||||
Rename
|
||||
</button>
|
||||
: null}
|
||||
{dashboard
|
||||
? <div
|
||||
className={classnames('btn btn-default btn-sm', {
|
||||
|
@ -76,6 +78,7 @@ const DashboardHeader = ({
|
|||
: null}
|
||||
<AutoRefreshDropdown
|
||||
onChoose={handleChooseAutoRefresh}
|
||||
onManualRefresh={onManualRefresh}
|
||||
selected={autoRefresh}
|
||||
iconName="refresh"
|
||||
/>
|
||||
|
@ -90,13 +93,13 @@ const DashboardHeader = ({
|
|||
className="btn btn-default btn-sm btn-square"
|
||||
onClick={handleClickPresentationButton}
|
||||
>
|
||||
<span className="icon expand-a" style={{margin: 0}} />
|
||||
<span className="icon expand-a" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
const {array, bool, func, number, shape, string} = PropTypes
|
||||
const {arrayOf, bool, func, number, shape, string} = PropTypes
|
||||
|
||||
DashboardHeader.defaultProps = {
|
||||
zoomedTimeRange: {
|
||||
|
@ -106,24 +109,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
|
||||
|
|
|
@ -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 (
|
||||
<div className="page-header full-width">
|
||||
<div className="page-header__container">
|
||||
<form
|
||||
className="page-header__left"
|
||||
style={{flex: '1 0 0%'}}
|
||||
onSubmit={this.handleFormSubmit}
|
||||
>
|
||||
<input
|
||||
className="page-header--editing"
|
||||
name="name"
|
||||
value={name}
|
||||
placeholder="Name this Dashboard"
|
||||
onKeyUp={this.handleKeyUp}
|
||||
<div className="dashboard-title">
|
||||
{isEditMode
|
||||
? <input
|
||||
maxLength={DASHBOARD_NAME_MAX_LENGTH}
|
||||
type="text"
|
||||
className="dashboard-title--input form-control input-sm"
|
||||
defaultValue={activeDashboard}
|
||||
autoComplete="off"
|
||||
autoFocus={true}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
onChange={this.handleChange}
|
||||
onBlur={this.handleInputBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onFocus={this.handleFocus}
|
||||
placeholder="Name this Dashboard"
|
||||
ref={r => (this.inputRef = r)}
|
||||
/>
|
||||
</form>
|
||||
<ConfirmButtons item={name} onConfirm={onSave} onCancel={onCancel} />
|
||||
</div>
|
||||
: <h1 onClick={onEditDashboard}>
|
||||
{activeDashboard}
|
||||
</h1>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
@ -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 (
|
||||
<div
|
||||
className={classnames('dropdown dashboard-switcher', {open: isOpen})}
|
||||
>
|
||||
<button
|
||||
className="btn btn-square btn-default btn-sm dropdown-toggle"
|
||||
onClick={this.handleToggleMenu}
|
||||
>
|
||||
<span className="icon dash-f" />
|
||||
</button>
|
||||
<ul className="dropdown-menu">
|
||||
{sorted.map(({name, link}) =>
|
||||
<NameLink
|
||||
key={link}
|
||||
name={name}
|
||||
link={link}
|
||||
activeName={activeDashboard}
|
||||
onClose={this.handleCloseMenu}
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const NameLink = ({name, link, activeName, onClose}) =>
|
||||
<li
|
||||
className={classnames('dropdown-item', {
|
||||
active: name === activeName,
|
||||
})}
|
||||
>
|
||||
<Link to={link} onClick={onClose}>
|
||||
{name}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
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)
|
|
@ -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,
|
||||
}) =>
|
||||
<div className="query-maker query-maker--panel">
|
||||
|
@ -46,16 +48,17 @@ const QueryMaker = ({
|
|||
templates={templates}
|
||||
/>
|
||||
<SchemaExplorer
|
||||
query={activeQuery}
|
||||
actions={actions}
|
||||
onAddQuery={onAddQuery}
|
||||
source={source}
|
||||
actions={actions}
|
||||
query={activeQuery}
|
||||
onAddQuery={onAddQuery}
|
||||
initialGroupByTime={initialGroupByTime}
|
||||
/>
|
||||
</div>
|
||||
: <EmptyQuery onAddQuery={onAddQuery} />}
|
||||
</div>
|
||||
|
||||
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
|
||||
|
|
|
@ -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 (
|
||||
<div className="graph-heading">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control input-md"
|
||||
defaultValue={defaultName}
|
||||
onBlur={this.handleInputBlur(reset)}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
placeholder="Name this Cell..."
|
||||
ref={r => (this.inputRef = r)}
|
||||
/>
|
||||
{isEditing
|
||||
? <input
|
||||
type="text"
|
||||
className="form-control input-sm"
|
||||
defaultValue={defaultName}
|
||||
onBlur={this.handleInputBlur(reset)}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
placeholder="Name this Cell..."
|
||||
autoFocus={true}
|
||||
onFocus={this.handleFocus}
|
||||
ref={r => (this.inputRef = r)}
|
||||
/>
|
||||
: <div className={graphNameClass} onClick={this.handleEditMode}>
|
||||
{defaultName}
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -106,3 +106,8 @@ export const TOOLTIP_CONTENT = {
|
|||
FORMAT:
|
||||
'<p><strong>K/M/B</strong> = Thousand / Million / Billion<br/><strong>K/M/G</strong> = Kilo / Mega / Giga </p>',
|
||||
}
|
||||
|
||||
export const TYPE_QUERY_CONFIG = 'queryConfig'
|
||||
export const TYPE_IFQL = 'ifql'
|
||||
|
||||
export const DASHBOARD_NAME_MAX_LENGTH = 50
|
||||
|
|
|
@ -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 (
|
||||
<div className="page">
|
||||
|
@ -290,39 +315,28 @@ class DashboardPage extends Component {
|
|||
editQueryStatus={dashboardActions.editCellQueryStatus}
|
||||
/>
|
||||
: null}
|
||||
{isEditMode
|
||||
? <DashboardHeaderEdit
|
||||
dashboard={dashboard}
|
||||
onSave={this.handleRenameDashboard}
|
||||
onCancel={this.handleCancelEditDashboard}
|
||||
/>
|
||||
: <DashboardHeader
|
||||
source={source}
|
||||
sourceID={sourceID}
|
||||
dashboard={dashboard}
|
||||
timeRange={timeRange}
|
||||
zoomedTimeRange={zoomedTimeRange}
|
||||
autoRefresh={autoRefresh}
|
||||
isHidden={inPresentationMode}
|
||||
onAddCell={this.handleAddCell}
|
||||
onEditDashboard={this.handleEditDashboard}
|
||||
buttonText={dashboard ? dashboard.name : ''}
|
||||
showTemplateControlBar={showTemplateControlBar}
|
||||
handleChooseAutoRefresh={handleChooseAutoRefresh}
|
||||
handleChooseTimeRange={this.handleChooseTimeRange}
|
||||
onToggleTempVarControls={this.handleToggleTempVarControls}
|
||||
handleClickPresentationButton={handleClickPresentationButton}
|
||||
>
|
||||
{dashboards
|
||||
? dashboards.map((d, i) =>
|
||||
<li className="dropdown-item" key={i}>
|
||||
<Link to={`/sources/${sourceID}/dashboards/${d.id}`}>
|
||||
{d.name}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
: null}
|
||||
</DashboardHeader>}
|
||||
<DashboardHeader
|
||||
names={names}
|
||||
sourceID={sourceID}
|
||||
dashboard={dashboard}
|
||||
dashboards={dashboards}
|
||||
timeRange={timeRange}
|
||||
isEditMode={isEditMode}
|
||||
autoRefresh={autoRefresh}
|
||||
isHidden={inPresentationMode}
|
||||
onAddCell={this.handleAddCell}
|
||||
onManualRefresh={onManualRefresh}
|
||||
zoomedTimeRange={zoomedTimeRange}
|
||||
onSave={this.handleRenameDashboard}
|
||||
onCancel={this.handleCancelEditDashboard}
|
||||
onEditDashboard={this.handleEditDashboard}
|
||||
activeDashboard={dashboard ? dashboard.name : ''}
|
||||
showTemplateControlBar={showTemplateControlBar}
|
||||
handleChooseAutoRefresh={handleChooseAutoRefresh}
|
||||
handleChooseTimeRange={this.handleChooseTimeRange}
|
||||
onToggleTempVarControls={this.handleToggleTempVarControls}
|
||||
handleClickPresentationButton={handleClickPresentationButton}
|
||||
/>
|
||||
{dashboard
|
||||
? <Dashboard
|
||||
source={source}
|
||||
|
@ -330,6 +344,7 @@ class DashboardPage extends Component {
|
|||
dashboard={dashboard}
|
||||
timeRange={timeRange}
|
||||
autoRefresh={autoRefresh}
|
||||
manualRefresh={manualRefresh}
|
||||
onZoom={this.handleZoomedTimeRange}
|
||||
onAddCell={this.handleAddCell}
|
||||
synchronizer={this.synchronizer}
|
||||
|
@ -366,6 +381,7 @@ DashboardPage.propTypes = {
|
|||
pathname: string.isRequired,
|
||||
query: shape({}),
|
||||
}).isRequired,
|
||||
dashboard: shape({}),
|
||||
dashboardActions: shape({
|
||||
putDashboard: func.isRequired,
|
||||
getDashboardsAsync: func.isRequired,
|
||||
|
@ -401,7 +417,10 @@ DashboardPage.propTypes = {
|
|||
handleChooseAutoRefresh: func.isRequired,
|
||||
autoRefresh: number.isRequired,
|
||||
templateControlBarVisibilityToggled: func.isRequired,
|
||||
timeRange: shape({}).isRequired,
|
||||
timeRange: shape({
|
||||
upper: string,
|
||||
lower: string,
|
||||
}),
|
||||
showTemplateControlBar: bool.isRequired,
|
||||
inPresentationMode: bool.isRequired,
|
||||
handleClickPresentationButton: func,
|
||||
|
@ -410,21 +429,34 @@ DashboardPage.propTypes = {
|
|||
status: shape(),
|
||||
}).isRequired,
|
||||
errorThrown: func,
|
||||
manualRefresh: number.isRequired,
|
||||
onManualRefresh: func.isRequired,
|
||||
}
|
||||
|
||||
const mapStateToProps = state => {
|
||||
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)
|
||||
)
|
||||
|
|
|
@ -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
|
|
@ -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))
|
||||
|
|
|
@ -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 (
|
||||
<div key={fieldFunc}>
|
||||
<div>
|
||||
<div
|
||||
className={classnames('query-builder--list-item', {
|
||||
active: isSelected,
|
||||
})}
|
||||
onClick={_.wrap(fieldFunc, this.handleToggleField)}
|
||||
data-test={`query-builder-list-item-field-${fieldText}`}
|
||||
onClick={this.handleToggleField}
|
||||
data-test={`query-builder-list-item-field-${fieldName}`}
|
||||
>
|
||||
<span>
|
||||
<div className="query-builder--checkbox" />
|
||||
{fieldText}
|
||||
{fieldName}
|
||||
</span>
|
||||
{isSelected
|
||||
? <div
|
||||
className={classnames('btn btn-xs', {
|
||||
active: isOpen,
|
||||
'btn-default': !fieldFunc.funcs.length,
|
||||
'btn-primary': fieldFunc.funcs.length,
|
||||
'btn-default': !num,
|
||||
'btn-primary': num,
|
||||
})}
|
||||
onClick={this.toggleFunctionsMenu}
|
||||
data-test={`query-builder-list-item-function-${fieldText}`}
|
||||
data-test={`query-builder-list-item-function-${fieldName}`}
|
||||
>
|
||||
{fieldFuncsLabel}
|
||||
</div>
|
||||
|
@ -87,13 +106,35 @@ const FieldListItem = React.createClass({
|
|||
{isSelected && isOpen
|
||||
? <FunctionSelector
|
||||
onApply={this.handleApplyFunctions}
|
||||
selectedItems={fieldFunc.funcs || []}
|
||||
selectedItems={funcs}
|
||||
singleSelect={isKapacitorRule}
|
||||
/>
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
@ -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},
|
||||
}) =>
|
||||
<div className="group-by-time">
|
||||
<label className="group-by-time--label">Group by:</label>
|
||||
<Dropdown
|
||||
className="group-by-time--dropdown"
|
||||
menuClass={isInRuleBuilder(pathname) ? 'dropdown-malachite' : null}
|
||||
buttonColor={isInRuleBuilder(pathname) ? 'btn-default' : 'btn-info'}
|
||||
items={getOptions(pathname).map(groupBy => ({
|
||||
...groupBy,
|
||||
text: groupBy.menuOption,
|
||||
}))}
|
||||
onChoose={onChooseGroupByTime}
|
||||
selected={selected || 'Time'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div className="group-by-time">
|
||||
<label className="group-by-time--label">Group by:</label>
|
||||
<Dropdown
|
||||
className="group-by-time--dropdown"
|
||||
menuClass={isInRuleBuilder ? 'dropdown-malachite' : null}
|
||||
buttonColor={isInRuleBuilder ? 'btn-default' : 'btn-info'}
|
||||
items={validOptions.map(groupBy => ({
|
||||
...groupBy,
|
||||
text: groupBy.menuOption,
|
||||
}))}
|
||||
onChoose={onChooseGroupByTime}
|
||||
selected={selected || 'Time'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
GroupByTimeDropdown.propTypes = {
|
||||
location: shape({
|
||||
pathname: string.isRequired,
|
||||
}).isRequired,
|
||||
selected: string,
|
||||
onChooseGroupByTime: func.isRequired,
|
||||
}
|
||||
|
||||
export default GroupByTimeDropdown
|
||||
export default withRouter(GroupByTimeDropdown)
|
||||
|
|
|
@ -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,
|
||||
}) =>
|
||||
<div className="query-maker query-maker--panel">
|
||||
<div className="query-maker--tab-contents">
|
||||
<QueryEditor
|
||||
|
@ -19,7 +25,11 @@ const QueryMaker = ({source, actions, timeRange, activeQuery}) =>
|
|||
actions.editRawTextAsync
|
||||
)}
|
||||
/>
|
||||
<SchemaExplorer query={activeQuery} actions={actions} />
|
||||
<SchemaExplorer
|
||||
initialGroupByTime={initialGroupByTime}
|
||||
query={activeQuery}
|
||||
actions={actions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -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
|
||||
|
|
|
@ -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 {
|
|||
<div style={{width: '100%', height: '100%', position: 'relative'}}>
|
||||
{series.length < maximumTabsCount
|
||||
? <div className="table--tabs">
|
||||
{series.map(({name}, i) =>
|
||||
{series.map((s, i) =>
|
||||
<TabItem
|
||||
isActive={i === activeSeriesIndex}
|
||||
key={i}
|
||||
name={name}
|
||||
name={this.makeTabName(s)}
|
||||
index={i}
|
||||
onClickTab={this.handleClickTab}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
</span>
|
||||
<div
|
||||
className={classnames('btn btn-default btn-xs group-by-tag', {
|
||||
active: this.props.isUsingGroupBy,
|
||||
className={classnames('btn btn-xs group-by-tag', {
|
||||
'btn-primary': isUsingGroupBy,
|
||||
'btn-default': !isUsingGroupBy,
|
||||
})}
|
||||
onClick={this.handleGroupBy}
|
||||
>
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
@ -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
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 {
|
|||
<Header
|
||||
timeRange={timeRange}
|
||||
autoRefresh={autoRefresh}
|
||||
actions={{handleChooseAutoRefresh, setTimeRange}}
|
||||
showWriteForm={this.handleOpenWriteData}
|
||||
onChooseTimeRange={this.handleChooseTimeRange}
|
||||
onChooseAutoRefresh={handleChooseAutoRefresh}
|
||||
onManualRefresh={onManualRefresh}
|
||||
/>
|
||||
<ResizeContainer
|
||||
containerClass="page-contents"
|
||||
|
@ -113,16 +122,17 @@ class DataExplorer extends Component {
|
|||
actions={queryConfigActions}
|
||||
timeRange={timeRange}
|
||||
activeQuery={this.getActiveQuery()}
|
||||
initialGroupByTime={INITIAL_GROUP_BY_TIME}
|
||||
/>
|
||||
<Visualization
|
||||
isInDataExplorer={true}
|
||||
autoRefresh={autoRefresh}
|
||||
timeRange={timeRange}
|
||||
queryConfigs={queryConfigs}
|
||||
errorThrown={errorThrownAction}
|
||||
activeQueryIndex={0}
|
||||
editQueryStatus={queryConfigActions.editQueryStatus}
|
||||
views={VIS_VIEWS}
|
||||
activeQueryIndex={0}
|
||||
timeRange={timeRange}
|
||||
autoRefresh={autoRefresh}
|
||||
queryConfigs={queryConfigs}
|
||||
manualRefresh={manualRefresh}
|
||||
errorThrown={errorThrownAction}
|
||||
editQueryStatus={queryConfigActions.editQueryStatus}
|
||||
/>
|
||||
</ResizeContainer>
|
||||
</div>
|
||||
|
@ -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))
|
||||
)
|
||||
|
|
|
@ -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 (
|
||||
<div className="page-header full-width">
|
||||
<div className="page-header__container">
|
||||
<div className="page-header__left">
|
||||
<h1 className="page-header__title">Data Explorer</h1>
|
||||
</div>
|
||||
<div className="page-header__right">
|
||||
<GraphTips />
|
||||
<SourceIndicator />
|
||||
<div
|
||||
className="btn btn-sm btn-default"
|
||||
onClick={showWriteForm}
|
||||
data-test="write-data-button"
|
||||
>
|
||||
<span className="icon pencil" />
|
||||
Write Data
|
||||
</div>
|
||||
<AutoRefreshDropdown
|
||||
onChoose={handleChooseAutoRefresh}
|
||||
selected={autoRefresh}
|
||||
iconName="refresh"
|
||||
/>
|
||||
<TimeRangeDropdown
|
||||
onChooseTimeRange={this.handleChooseTimeRange}
|
||||
selected={timeRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
const Header = ({
|
||||
timeRange,
|
||||
autoRefresh,
|
||||
showWriteForm,
|
||||
onManualRefresh,
|
||||
onChooseTimeRange,
|
||||
onChooseAutoRefresh,
|
||||
}) =>
|
||||
<div className="page-header full-width">
|
||||
<div className="page-header__container">
|
||||
<div className="page-header__left">
|
||||
<h1 className="page-header__title">Data Explorer</h1>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
<div className="page-header__right">
|
||||
<GraphTips />
|
||||
<SourceIndicator />
|
||||
<div
|
||||
className="btn btn-sm btn-default"
|
||||
onClick={showWriteForm}
|
||||
data-test="write-data-button"
|
||||
>
|
||||
<span className="icon pencil" />
|
||||
Write Data
|
||||
</div>
|
||||
<AutoRefreshDropdown
|
||||
iconName="refresh"
|
||||
selected={autoRefresh}
|
||||
onChoose={onChooseAutoRefresh}
|
||||
onManualRefresh={onManualRefresh}
|
||||
/>
|
||||
<TimeRangeDropdown
|
||||
selected={timeRange}
|
||||
page="DataExplorer"
|
||||
onChooseTimeRange={onChooseTimeRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<LayoutRenderer
|
||||
timeRange={timeRange}
|
||||
cells={layoutCells}
|
||||
autoRefresh={autoRefresh}
|
||||
source={source}
|
||||
host={this.props.params.hostID}
|
||||
isEditable={false}
|
||||
cells={layoutCells}
|
||||
timeRange={timeRange}
|
||||
autoRefresh={autoRefresh}
|
||||
manualRefresh={manualRefresh}
|
||||
host={this.props.params.hostID}
|
||||
synchronizer={this.synchronizer}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="page">
|
||||
<DashboardHeader
|
||||
buttonText={hostID}
|
||||
autoRefresh={autoRefresh}
|
||||
names={names}
|
||||
timeRange={timeRange}
|
||||
activeDashboard={hostID}
|
||||
autoRefresh={autoRefresh}
|
||||
isHidden={inPresentationMode}
|
||||
handleChooseTimeRange={this.handleChooseTimeRange}
|
||||
onManualRefresh={onManualRefresh}
|
||||
handleChooseAutoRefresh={handleChooseAutoRefresh}
|
||||
handleChooseTimeRange={this.handleChooseTimeRange}
|
||||
handleClickPresentationButton={handleClickPresentationButton}
|
||||
source={source}
|
||||
>
|
||||
{Object.keys(hosts).map((host, i) => {
|
||||
return (
|
||||
<li className="dropdown-item" key={i}>
|
||||
<Link to={`/sources/${id}/hosts/${host + appParam}`}>
|
||||
{host}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</DashboardHeader>
|
||||
/>
|
||||
<FancyScrollbar
|
||||
className={classnames({
|
||||
'page-contents': true,
|
||||
|
@ -232,8 +203,34 @@ export const HostPage = React.createClass({
|
|||
</FancyScrollbar>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
|
|
|
@ -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({
|
|||
<Route path="/login" component={UserIsNotAuthenticated(Login)} />
|
||||
<Route
|
||||
path="/sources/new"
|
||||
component={UserIsAuthenticated(CreateSource)}
|
||||
component={UserIsAuthenticated(SourcePage)}
|
||||
/>
|
||||
<Route path="/sources/:sourceID" component={UserIsAuthenticated(App)}>
|
||||
<Route component={CheckSources}>
|
||||
|
|
|
@ -61,3 +61,11 @@ export const groupByTime = (queryId, time) => ({
|
|||
time,
|
||||
},
|
||||
})
|
||||
|
||||
export const removeFuncs = (queryID, fields) => ({
|
||||
type: 'KAPA_REMOVE_FUNCS',
|
||||
payload: {
|
||||
queryID,
|
||||
fields,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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 (
|
||||
<div className="rule-section">
|
||||
<div className="query-builder rule-section--border-bottom">
|
||||
<div className="query-builder">
|
||||
<DatabaseList query={query} onChooseNamespace={handleChooseNamespace} />
|
||||
<MeasurementList
|
||||
query={query}
|
||||
|
@ -80,10 +79,11 @@ const DataSection = ({
|
|||
? null
|
||||
: <FieldList
|
||||
query={query}
|
||||
onToggleField={handleToggleField(onRemoveEvery)}
|
||||
onGroupByTime={handleGroupByTime}
|
||||
applyFuncsToField={handleApplyFuncsToField(onAddEvery)}
|
||||
onToggleField={handleToggleField}
|
||||
isKapacitorRule={isKapacitorRule}
|
||||
onGroupByTime={handleGroupByTime}
|
||||
removeFuncs={handleRemoveFuncs}
|
||||
applyFuncsToField={handleApplyFuncsToField(onAddEvery)}
|
||||
/>}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -108,7 +108,6 @@ DataSection.propTypes = {
|
|||
toggleTagAcceptance: func.isRequired,
|
||||
}).isRequired,
|
||||
onAddEvery: func.isRequired,
|
||||
onRemoveEvery: func.isRequired,
|
||||
timeRange: shape({}).isRequired,
|
||||
isKapacitorRule: bool,
|
||||
isDeadman: bool,
|
||||
|
|
|
@ -7,7 +7,7 @@ const periods = PERIODS.map(text => {
|
|||
})
|
||||
|
||||
const Deadman = ({rule, onChange}) =>
|
||||
<div className="rule-section--row">
|
||||
<div className="rule-section--row rule-section--row-first rule-section--row-last">
|
||||
<p>Send Alert if Data is missing for</p>
|
||||
<Dropdown
|
||||
className="dropdown-80"
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React, {PropTypes, Component} from 'react'
|
||||
|
||||
import NameSection from 'src/kapacitor/components/NameSection'
|
||||
import ValuesSection from 'src/kapacitor/components/ValuesSection'
|
||||
import RuleHeader from 'src/kapacitor/components/RuleHeader'
|
||||
import RuleMessage from 'src/kapacitor/components/RuleMessage'
|
||||
|
@ -51,19 +52,23 @@ class KapacitorRule extends Component {
|
|||
}
|
||||
|
||||
handleEdit = () => {
|
||||
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 (
|
||||
<div className="page">
|
||||
<RuleHeader
|
||||
rule={rule}
|
||||
actions={ruleActions}
|
||||
onSave={isEditing ? this.handleEdit : this.handleCreate}
|
||||
onChooseTimeRange={this.handleChooseTimeRange}
|
||||
validationError={this.validationError()}
|
||||
timeRange={timeRange}
|
||||
source={source}
|
||||
onSave={isEditing ? this.handleEdit : this.handleCreate}
|
||||
validationError={this.validationError()}
|
||||
/>
|
||||
<FancyScrollbar className="page-contents fancy-scroll--kapacitor">
|
||||
<div className="container-fluid">
|
||||
<div className="row">
|
||||
<div className="col-xs-12">
|
||||
<div className="rule-builder">
|
||||
<NameSection
|
||||
isEditing={isEditing}
|
||||
defaultName={rule.name}
|
||||
onRuleRename={ruleActions.updateRuleName}
|
||||
ruleID={rule.id}
|
||||
/>
|
||||
<ValuesSection
|
||||
rule={rule}
|
||||
source={source}
|
||||
|
@ -170,6 +177,7 @@ class KapacitorRule extends Component {
|
|||
onDeadmanChange={this.handleDeadmanChange}
|
||||
onRuleTypeInputChange={this.handleRuleTypeInputChange}
|
||||
onRuleTypeDropdownChange={this.handleRuleTypeDropdownChange}
|
||||
onChooseTimeRange={this.handleChooseTimeRange}
|
||||
/>
|
||||
<RuleMessage
|
||||
rule={rule}
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import React, {Component, PropTypes} from 'react'
|
||||
|
||||
class NameSection extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.state = {
|
||||
reset: false,
|
||||
}
|
||||
}
|
||||
|
||||
handleInputBlur = reset => 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 (
|
||||
<div className="rule-section">
|
||||
<h3 className="rule-section--heading">
|
||||
{isEditing ? 'Name' : 'Name this Alert Rule'}
|
||||
</h3>
|
||||
<div className="rule-section--body">
|
||||
<div className="rule-section--row rule-section--row-first rule-section--row-last">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control input-md form-malachite"
|
||||
defaultValue={defaultName}
|
||||
onBlur={this.handleInputBlur(reset)}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
placeholder="ex: Ruley McRuleface"
|
||||
ref={r => (this.inputRef = r)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const {bool, func, string} = PropTypes
|
||||
|
||||
NameSection.propTypes = {
|
||||
isEditing: bool,
|
||||
defaultName: string.isRequired,
|
||||
onRuleRename: func.isRequired,
|
||||
ruleID: string.isRequired,
|
||||
}
|
||||
|
||||
export default NameSection
|
|
@ -12,7 +12,7 @@ const Relative = ({
|
|||
onDropdownChange,
|
||||
rule: {values: {change, shift, operator, value}},
|
||||
}) =>
|
||||
<div className="rule-section--row rule-section--border-bottom">
|
||||
<div className="rule-section--row rule-section--row-first rule-section--border-bottom">
|
||||
<p>Send Alert when</p>
|
||||
<Dropdown
|
||||
className="dropdown-110"
|
||||
|
|
|
@ -2,119 +2,67 @@ import React, {PropTypes} from 'react'
|
|||
import buildInfluxQLQuery from 'utils/influxql'
|
||||
import AutoRefresh from 'shared/components/AutoRefresh'
|
||||
import LineGraph from 'shared/components/LineGraph'
|
||||
import TimeRangeDropdown from 'shared/components/TimeRangeDropdown'
|
||||
import underlayCallback from 'src/kapacitor/helpers/ruleGraphUnderlay'
|
||||
|
||||
const RefreshingLineGraph = AutoRefresh(LineGraph)
|
||||
|
||||
export const RuleGraph = React.createClass({
|
||||
propTypes: {
|
||||
source: PropTypes.shape({
|
||||
links: PropTypes.shape({
|
||||
proxy: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
query: PropTypes.shape({}).isRequired,
|
||||
rule: PropTypes.shape({}).isRequired,
|
||||
timeRange: PropTypes.shape({}).isRequired,
|
||||
},
|
||||
const {shape, string, func} = PropTypes
|
||||
const RuleGraph = ({
|
||||
query,
|
||||
source,
|
||||
timeRange: {lower},
|
||||
timeRange,
|
||||
rule,
|
||||
onChooseTimeRange,
|
||||
}) => {
|
||||
const autoRefreshMs = 30000
|
||||
const queryText = buildInfluxQLQuery({lower}, query)
|
||||
const queries = [{host: source.links.proxy, text: queryText}]
|
||||
const kapacitorLineColors = ['#4ED8A0']
|
||||
|
||||
render() {
|
||||
if (!queryText) {
|
||||
return (
|
||||
<div className="rule-builder--graph">
|
||||
{this.renderGraph()}
|
||||
<div className="rule-builder--graph-empty">
|
||||
<p>
|
||||
Select a <strong>Time-Series</strong> to preview on a graph
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rule-builder--graph-empty">
|
||||
<p>
|
||||
Select a <strong>Time-Series</strong> to preview on a graph
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
return (
|
||||
<div className="rule-builder--graph">
|
||||
<div className="rule-builder--graph-options">
|
||||
<p>Preview Data from</p>
|
||||
<TimeRangeDropdown
|
||||
onChooseTimeRange={onChooseTimeRange}
|
||||
selected={timeRange}
|
||||
preventCustomTimeRange={true}
|
||||
/>
|
||||
</div>
|
||||
<RefreshingLineGraph
|
||||
queries={queries}
|
||||
autoRefresh={autoRefreshMs}
|
||||
underlayCallback={this.createUnderlayCallback()}
|
||||
isGraphFilled={false}
|
||||
overrideLineColors={kapacitorLineColors}
|
||||
ruleValues={rule.values}
|
||||
autoRefresh={autoRefreshMs}
|
||||
overrideLineColors={kapacitorLineColors}
|
||||
underlayCallback={underlayCallback(rule)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
@ -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 (
|
||||
<div className="page-header">
|
||||
<div className="page-header__container">
|
||||
<RuleHeaderEdit
|
||||
rule={rule}
|
||||
isEditing={isEditingName}
|
||||
onToggleEdit={this.toggleEditName}
|
||||
onEditName={this.handleEditName}
|
||||
onEditNameBlur={this.handleEditNameBlur}
|
||||
/>
|
||||
<div className="page-header__left">
|
||||
<h1 className="page-header__title">Alert Rule Builder</h1>
|
||||
</div>
|
||||
<RuleHeaderSave
|
||||
source={source}
|
||||
onSave={onSave}
|
||||
timeRange={timeRange}
|
||||
validationError={validationError}
|
||||
onChooseTimeRange={onChooseTimeRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
import React, {PropTypes} from 'react'
|
||||
import ReactTooltip from 'react-tooltip'
|
||||
|
||||
const RuleHeaderEdit = ({
|
||||
rule,
|
||||
isEditing,
|
||||
onToggleEdit,
|
||||
onEditName,
|
||||
onEditNameBlur,
|
||||
}) =>
|
||||
isEditing
|
||||
? <input
|
||||
className="page-header--editing kapacitor-theme"
|
||||
autoFocus={true}
|
||||
defaultValue={rule.name}
|
||||
onKeyDown={onEditName(rule)}
|
||||
onBlur={onEditNameBlur(rule)}
|
||||
placeholder="Name your rule"
|
||||
spellCheck={false}
|
||||
autoComplete={false}
|
||||
/>
|
||||
: <div className="page-header__left">
|
||||
<h1
|
||||
className="page-header__title page-header--editable kapacitor-theme"
|
||||
onClick={onToggleEdit}
|
||||
data-for="rename-kapacitor-tooltip"
|
||||
data-tip="<p>Click to Rename</p>"
|
||||
>
|
||||
{rule.name}
|
||||
<span className="icon pencil" />
|
||||
<ReactTooltip
|
||||
id="rename-kapacitor-tooltip"
|
||||
effect="solid"
|
||||
html={true}
|
||||
place="bottom"
|
||||
class="influx-tooltip kapacitor-tooltip"
|
||||
/>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
const {bool, func, shape} = PropTypes
|
||||
|
||||
RuleHeaderEdit.propTypes = {
|
||||
rule: shape(),
|
||||
isEditing: bool.isRequired,
|
||||
onToggleEdit: func.isRequired,
|
||||
onEditName: func.isRequired,
|
||||
onEditNameBlur: func.isRequired,
|
||||
}
|
||||
|
||||
export default RuleHeaderEdit
|
|
@ -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}) =>
|
||||
<div className="page-header__right">
|
||||
<SourceIndicator />
|
||||
<TimeRangeDropdown
|
||||
onChooseTimeRange={onChooseTimeRange}
|
||||
selected={timeRange}
|
||||
preventCustomTimeRange={true}
|
||||
/>
|
||||
{validationError
|
||||
? <button
|
||||
className="btn btn-success btn-sm disabled"
|
||||
|
@ -36,13 +25,11 @@ const RuleHeaderSave = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
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
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
import React, {PropTypes} from 'react'
|
||||
|
||||
const RuleMessageText = ({rule, updateMessage}) =>
|
||||
<textarea
|
||||
className="form-control form-malachite monotype rule-builder--message"
|
||||
onChange={updateMessage}
|
||||
placeholder="Example: {{ .ID }} is {{ .Level }} value: {{ index .Fields "value" }}"
|
||||
value={rule.message}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="rule-builder--message">
|
||||
<textarea
|
||||
className="form-control form-malachite monotype"
|
||||
onChange={updateMessage}
|
||||
placeholder="Example: {{ .ID }} is {{ .Level }} value: {{ index .Fields "value" }}"
|
||||
value={rule.message}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
const {func, shape} = PropTypes
|
||||
|
||||
|
|
|
@ -1,9 +1,19 @@
|
|||
import React, {PropTypes} from 'react'
|
||||
import {OPERATORS} from 'src/kapacitor/constants'
|
||||
import Dropdown from 'shared/components/Dropdown'
|
||||
import _ from 'lodash'
|
||||
|
||||
const mapToItems = (arr, type) => arr.map(text => ({text, type}))
|
||||
const operators = mapToItems(OPERATORS, 'operator')
|
||||
const noopSubmit = e => e.preventDefault()
|
||||
const getField = ({fields}) => {
|
||||
const alias = _.get(fields, ['0', 'alias'], false)
|
||||
if (!alias) {
|
||||
return _.get(fields, ['0', 'value'], 'Select a Time-Series')
|
||||
}
|
||||
|
||||
return alias
|
||||
}
|
||||
|
||||
const Threshold = ({
|
||||
rule: {values: {operator, value, rangeValue}},
|
||||
|
@ -11,10 +21,10 @@ const Threshold = ({
|
|||
onDropdownChange,
|
||||
onRuleTypeInputChange,
|
||||
}) =>
|
||||
<div className="rule-section--row rule-section--border-bottom">
|
||||
<div className="rule-section--row rule-section--row-first rule-section--border-bottom">
|
||||
<p>Send Alert where</p>
|
||||
<span className="rule-builder--metric">
|
||||
{query.fields.length ? query.fields[0].field : 'Select a Time-Series'}
|
||||
{getField(query)}
|
||||
</span>
|
||||
<p>is</p>
|
||||
<Dropdown
|
||||
|
@ -24,7 +34,7 @@ const Threshold = ({
|
|||
selected={operator}
|
||||
onChoose={onDropdownChange}
|
||||
/>
|
||||
<form style={{display: 'flex'}}>
|
||||
<form style={{display: 'flex'}} onSubmit={noopSubmit}>
|
||||
<input
|
||||
className="form-control input-sm form-malachite monotype"
|
||||
style={{width: '160px', marginLeft: '6px'}}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import React, {PropTypes} from 'react'
|
||||
|
||||
import TickscriptHeader from 'src/kapacitor/components/TickscriptHeader'
|
||||
import TickscriptEditor from 'src/kapacitor/components/TickscriptEditor'
|
||||
|
||||
|
@ -48,7 +49,9 @@ const {arrayOf, bool, func, shape, string} = PropTypes
|
|||
|
||||
Tickscript.propTypes = {
|
||||
onSave: func.isRequired,
|
||||
source: shape(),
|
||||
source: shape({
|
||||
id: string,
|
||||
}),
|
||||
task: shape({
|
||||
id: string,
|
||||
script: string,
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import React, {PropTypes} from 'react'
|
||||
import {Link} from 'react-router'
|
||||
|
||||
import SourceIndicator from 'shared/components/SourceIndicator'
|
||||
import TickscriptType from 'src/kapacitor/components/TickscriptType'
|
||||
import MultiSelectDBDropdown from 'shared/components/MultiSelectDBDropdown'
|
||||
|
@ -11,6 +13,7 @@ const addName = list => list.map(l => ({...l, name: `${l.db}.${l.rp}`}))
|
|||
const TickscriptHeader = ({
|
||||
task: {id, type, dbrps},
|
||||
task,
|
||||
source,
|
||||
onSave,
|
||||
onChangeType,
|
||||
onChangeID,
|
||||
|
@ -31,6 +34,12 @@ const TickscriptHeader = ({
|
|||
selectedItems={addName(dbrps)}
|
||||
onApply={onSelectDbrps}
|
||||
/>
|
||||
<Link
|
||||
className="btn btn-sm btn-default"
|
||||
to={`/sources/${source.id}/alert-rules`}
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
title={id ? '' : 'ID your TICKscript to save'}
|
||||
|
@ -47,6 +56,9 @@ const {arrayOf, bool, func, shape, string} = PropTypes
|
|||
|
||||
TickscriptHeader.propTypes = {
|
||||
onSave: func,
|
||||
source: shape({
|
||||
id: string,
|
||||
}),
|
||||
onSelectDbrps: func.isRequired,
|
||||
task: shape({
|
||||
dbrps: arrayOf(
|
||||
|
|
|
@ -28,11 +28,12 @@ const ValuesSection = ({
|
|||
timeRange,
|
||||
onAddEvery,
|
||||
onRemoveEvery,
|
||||
onChooseTrigger,
|
||||
onDeadmanChange,
|
||||
onChooseTimeRange,
|
||||
queryConfigActions,
|
||||
onRuleTypeInputChange,
|
||||
onRuleTypeDropdownChange,
|
||||
onChooseTrigger,
|
||||
}) =>
|
||||
<div className="rule-section">
|
||||
<h3 className="rule-section--heading">Alert Type</h3>
|
||||
|
@ -49,7 +50,7 @@ const ValuesSection = ({
|
|||
)}
|
||||
</TabList>
|
||||
<div>
|
||||
<h3 className="rule-builder--sub-header">Time Series</h3>
|
||||
<h3 className="rule-section--sub-heading">Time Series</h3>
|
||||
<DataSection
|
||||
query={query}
|
||||
timeRange={timeRange}
|
||||
|
@ -60,7 +61,7 @@ const ValuesSection = ({
|
|||
isDeadman={isDeadman(rule)}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="rule-builder--sub-header">Rule Conditions</h3>
|
||||
<h3 className="rule-section--sub-heading">Conditions</h3>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Threshold
|
||||
|
@ -88,6 +89,7 @@ const ValuesSection = ({
|
|||
query={query}
|
||||
source={source}
|
||||
timeRange={timeRange}
|
||||
onChooseTimeRange={onChooseTimeRange}
|
||||
/>}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
@ -110,6 +112,7 @@ ValuesSection.propTypes = {
|
|||
timeRange: shape({}).isRequired,
|
||||
queryConfigActions: shape({}).isRequired,
|
||||
source: shape({}).isRequired,
|
||||
onChooseTimeRange: func.isRequired,
|
||||
}
|
||||
|
||||
export default ValuesSection
|
||||
|
|
|
@ -21,16 +21,27 @@ export const defaultRuleConfigs = {
|
|||
|
||||
export const defaultEveryFrequency = '30s'
|
||||
|
||||
// constants taken from https://github.com/influxdata/chronograf/blob/870dbc72d1a8b784eaacad5eeea79fc54968b656/kapacitor/operators.go#L13
|
||||
export const EQUAL_TO = 'equal to'
|
||||
export const LESS_THAN = 'less than'
|
||||
export const GREATER_THAN = 'greater than'
|
||||
export const NOT_EQUAL_TO = 'not equal to'
|
||||
export const INSIDE_RANGE = 'inside range'
|
||||
export const OUTSIDE_RANGE = 'outside range'
|
||||
export const EQUAL_TO_OR_GREATER_THAN = 'equal to or greater'
|
||||
export const EQUAL_TO_OR_LESS_THAN = 'equal to or less than'
|
||||
|
||||
export const OPERATORS = [
|
||||
'greater than',
|
||||
'equal to or greater',
|
||||
'equal to or less than',
|
||||
'less than',
|
||||
'equal to',
|
||||
'not equal to',
|
||||
'inside range',
|
||||
'outside range',
|
||||
GREATER_THAN,
|
||||
EQUAL_TO_OR_GREATER_THAN,
|
||||
EQUAL_TO_OR_LESS_THAN,
|
||||
LESS_THAN,
|
||||
EQUAL_TO,
|
||||
NOT_EQUAL_TO,
|
||||
INSIDE_RANGE,
|
||||
OUTSIDE_RANGE,
|
||||
]
|
||||
|
||||
// export const RELATIONS = ['once', 'more than ', 'less than'];
|
||||
export const PERIODS = ['1m', '5m', '10m', '30m', '1h', '2h', '24h']
|
||||
export const CHANGES = ['change', '% change']
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
import {
|
||||
EQUAL_TO,
|
||||
LESS_THAN,
|
||||
NOT_EQUAL_TO,
|
||||
GREATER_THAN,
|
||||
INSIDE_RANGE,
|
||||
OUTSIDE_RANGE,
|
||||
EQUAL_TO_OR_LESS_THAN,
|
||||
EQUAL_TO_OR_GREATER_THAN,
|
||||
} from 'src/kapacitor/constants'
|
||||
|
||||
const HIGHLIGHT = 'rgba(78, 216, 160, 0.3)'
|
||||
const BACKGROUND = 'rgba(41, 41, 51, 1)'
|
||||
|
||||
const getFillColor = operator => {
|
||||
const backgroundColor = BACKGROUND
|
||||
const highlightColor = HIGHLIGHT
|
||||
|
||||
if (operator === OUTSIDE_RANGE) {
|
||||
return backgroundColor
|
||||
}
|
||||
|
||||
if (operator === NOT_EQUAL_TO) {
|
||||
return backgroundColor
|
||||
}
|
||||
|
||||
return highlightColor
|
||||
}
|
||||
|
||||
const underlayCallback = rule => (canvas, area, dygraph) => {
|
||||
const {values} = rule
|
||||
const {operator, value} = values
|
||||
|
||||
if (rule.trigger !== 'threshold' || value === '' || !isFinite(value)) {
|
||||
return
|
||||
}
|
||||
|
||||
const theOnePercent = 0.01
|
||||
let highlightStart = 0
|
||||
let highlightEnd = 0
|
||||
|
||||
switch (operator) {
|
||||
case `${EQUAL_TO_OR_GREATER_THAN}`:
|
||||
case `${GREATER_THAN}`: {
|
||||
highlightStart = value
|
||||
highlightEnd = dygraph.yAxisRange()[1]
|
||||
break
|
||||
}
|
||||
|
||||
case `${EQUAL_TO_OR_LESS_THAN}`:
|
||||
case `${LESS_THAN}`: {
|
||||
highlightStart = dygraph.yAxisRange()[0]
|
||||
highlightEnd = value
|
||||
break
|
||||
}
|
||||
|
||||
case `${EQUAL_TO}`: {
|
||||
const width =
|
||||
theOnePercent * (dygraph.yAxisRange()[1] - dygraph.yAxisRange()[0])
|
||||
highlightStart = +value - width
|
||||
highlightEnd = +value + width
|
||||
break
|
||||
}
|
||||
|
||||
case `${NOT_EQUAL_TO}`: {
|
||||
const width =
|
||||
theOnePercent * (dygraph.yAxisRange()[1] - dygraph.yAxisRange()[0])
|
||||
highlightStart = +value - width
|
||||
highlightEnd = +value + width
|
||||
|
||||
canvas.fillStyle = HIGHLIGHT
|
||||
canvas.fillRect(area.x, area.y, area.w, area.h)
|
||||
break
|
||||
}
|
||||
|
||||
case `${OUTSIDE_RANGE}`: {
|
||||
highlightStart = Math.min(+value, +values.rangeValue)
|
||||
highlightEnd = Math.max(+value, +values.rangeValue)
|
||||
|
||||
canvas.fillStyle = HIGHLIGHT
|
||||
canvas.fillRect(area.x, area.y, area.w, area.h)
|
||||
break
|
||||
}
|
||||
|
||||
case `${INSIDE_RANGE}`: {
|
||||
highlightStart = Math.min(+value, +values.rangeValue)
|
||||
highlightEnd = Math.max(+value, +values.rangeValue)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const bottom = dygraph.toDomYCoord(highlightStart)
|
||||
const top = dygraph.toDomYCoord(highlightEnd)
|
||||
|
||||
const fillColor = getFillColor(operator)
|
||||
canvas.fillStyle = fillColor
|
||||
canvas.fillRect(area.x, top, area.w, bottom - top)
|
||||
}
|
||||
|
||||
export default underlayCallback
|
|
@ -6,7 +6,8 @@ import {
|
|||
chooseTag,
|
||||
groupByTag,
|
||||
groupByTime,
|
||||
toggleField,
|
||||
removeFuncs,
|
||||
toggleKapaField,
|
||||
toggleTagAcceptance,
|
||||
} from 'src/utils/queryTransitions'
|
||||
|
||||
|
@ -91,24 +92,20 @@ const queryConfigs = (state = {}, action) => {
|
|||
|
||||
case 'KAPA_TOGGLE_FIELD': {
|
||||
const {queryId, fieldFunc} = action.payload
|
||||
// 3rd arg is true to prevent func from automatically being added
|
||||
const nextQueryConfig = toggleField(state[queryId], fieldFunc, true)
|
||||
const nextQueryConfig = toggleKapaField(state[queryId], fieldFunc)
|
||||
|
||||
return Object.assign({}, state, {
|
||||
[queryId]: {...nextQueryConfig, rawText: null},
|
||||
})
|
||||
return {...state, [queryId]: {...nextQueryConfig, rawText: null}}
|
||||
}
|
||||
|
||||
case 'KAPA_APPLY_FUNCS_TO_FIELD': {
|
||||
const {queryId, fieldFunc} = action.payload
|
||||
const {groupBy} = state[queryId]
|
||||
const nextQueryConfig = applyFuncsToField(state[queryId], fieldFunc, {
|
||||
preventAutoGroupBy: true,
|
||||
isKapacitorRule: true,
|
||||
...groupBy,
|
||||
time: groupBy.time ? groupBy.time : '10s',
|
||||
})
|
||||
|
||||
return Object.assign({}, state, {
|
||||
[queryId]: nextQueryConfig,
|
||||
})
|
||||
return {...state, [queryId]: nextQueryConfig}
|
||||
}
|
||||
|
||||
case 'KAPA_GROUP_BY_TIME': {
|
||||
|
@ -119,6 +116,14 @@ const queryConfigs = (state = {}, action) => {
|
|||
[queryId]: nextQueryConfig,
|
||||
})
|
||||
}
|
||||
|
||||
case 'KAPA_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}
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import _ from 'lodash'
|
||||
import normalizer from 'src/normalizers/dashboardTime'
|
||||
|
||||
export const loadLocalStorage = errorsQueue => {
|
||||
try {
|
||||
const serializedState = localStorage.getItem('state')
|
||||
|
@ -12,8 +15,22 @@ export const loadLocalStorage = errorsQueue => {
|
|||
console.log(errorText) // eslint-disable-line no-console
|
||||
errorsQueue.push(errorText)
|
||||
|
||||
window.localStorage.removeItem('state')
|
||||
return {}
|
||||
if (!state.dashTimeV1) {
|
||||
window.localStorage.removeItem('state')
|
||||
return {}
|
||||
}
|
||||
|
||||
const ranges = normalizer(_.get(state, ['dashTimeV1', 'ranges'], []))
|
||||
const dashTimeV1 = {ranges}
|
||||
|
||||
window.localStorage.setItem(
|
||||
'state',
|
||||
JSON.stringify({
|
||||
dashTimeV1,
|
||||
})
|
||||
)
|
||||
|
||||
return {dashTimeV1}
|
||||
}
|
||||
|
||||
delete state.VERSION
|
||||
|
@ -34,9 +51,11 @@ export const saveToLocalStorage = ({
|
|||
dataExplorerQueryConfigs,
|
||||
timeRange,
|
||||
dataExplorer,
|
||||
dashTimeV1: {ranges},
|
||||
}) => {
|
||||
try {
|
||||
const appPersisted = Object.assign({}, {app: {persisted}})
|
||||
const dashTimeV1 = {ranges: normalizer(ranges)}
|
||||
|
||||
window.localStorage.setItem(
|
||||
'state',
|
||||
|
@ -46,6 +65,7 @@ export const saveToLocalStorage = ({
|
|||
timeRange,
|
||||
dataExplorer,
|
||||
VERSION, // eslint-disable-line no-undef
|
||||
dashTimeV1,
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import _ from 'lodash'
|
||||
|
||||
const dashtime = ranges => {
|
||||
if (!Array.isArray(ranges)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalized = ranges.filter(r => {
|
||||
if (!_.isObject(r)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// check for presence of keys
|
||||
if (
|
||||
!r.hasOwnProperty('dashboardID') ||
|
||||
!r.hasOwnProperty('lower') ||
|
||||
!r.hasOwnProperty('upper')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const {dashboardID, lower, upper} = r
|
||||
|
||||
if (!dashboardID || typeof dashboardID !== 'number') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!lower && !upper) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isCorrectType = bound =>
|
||||
_.isString(bound) || _.isNull(bound) || _.isInteger(bound)
|
||||
|
||||
if (!isCorrectType(lower) || !isCorrectType(upper)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export default dashtime
|
|
@ -0,0 +1,18 @@
|
|||
export const TYPE_ID = 'ID'
|
||||
export const TYPE_URI = 'ID'
|
||||
|
||||
const idNormalizer = (type, id) => {
|
||||
switch (type) {
|
||||
case 'ID': {
|
||||
return +id
|
||||
}
|
||||
|
||||
case 'URI': {
|
||||
// handle decode of URI here
|
||||
}
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
export default idNormalizer
|
|
@ -1,65 +1,19 @@
|
|||
import React, {PropTypes} from 'react'
|
||||
import React, {PropTypes, Component} from 'react'
|
||||
import _ from 'lodash'
|
||||
|
||||
import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries'
|
||||
import {removeUnselectedTemplateValues} from 'src/dashboards/constants'
|
||||
|
||||
const {
|
||||
array,
|
||||
arrayOf,
|
||||
bool,
|
||||
element,
|
||||
func,
|
||||
number,
|
||||
oneOfType,
|
||||
shape,
|
||||
string,
|
||||
} = PropTypes
|
||||
|
||||
const AutoRefresh = ComposedComponent => {
|
||||
const wrapper = React.createClass({
|
||||
propTypes: {
|
||||
children: element,
|
||||
autoRefresh: number.isRequired,
|
||||
templates: arrayOf(
|
||||
shape({
|
||||
type: string.isRequired,
|
||||
tempVar: string.isRequired,
|
||||
query: shape({
|
||||
db: string,
|
||||
rp: string,
|
||||
influxql: string,
|
||||
}),
|
||||
values: arrayOf(
|
||||
shape({
|
||||
type: string.isRequired,
|
||||
value: string.isRequired,
|
||||
selected: bool,
|
||||
})
|
||||
).isRequired,
|
||||
})
|
||||
),
|
||||
queries: arrayOf(
|
||||
shape({
|
||||
host: oneOfType([string, arrayOf(string)]),
|
||||
text: string,
|
||||
}).isRequired
|
||||
).isRequired,
|
||||
axes: shape({
|
||||
bounds: shape({
|
||||
y: array,
|
||||
y2: array,
|
||||
}),
|
||||
}),
|
||||
editQueryStatus: func,
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
return {
|
||||
class wrapper extends Component {
|
||||
constructor() {
|
||||
super()
|
||||
this.state = {
|
||||
lastQuerySuccessful: false,
|
||||
timeSeries: [],
|
||||
resolution: null,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {queries, templates, autoRefresh} = this.props
|
||||
|
@ -70,7 +24,7 @@ const AutoRefresh = ComposedComponent => {
|
|||
autoRefresh
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const queriesDidUpdate = this.queryDifference(
|
||||
|
@ -99,19 +53,19 @@ const AutoRefresh = ComposedComponent => {
|
|||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
queryDifference(left, right) {
|
||||
queryDifference = (left, right) => {
|
||||
const leftStrs = left.map(q => `${q.host}${q.text}`)
|
||||
const rightStrs = right.map(q => `${q.host}${q.text}`)
|
||||
return _.difference(
|
||||
_.union(leftStrs, rightStrs),
|
||||
_.intersection(leftStrs, rightStrs)
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
executeQueries(queries, templates = []) {
|
||||
const {editQueryStatus} = this.props
|
||||
executeQueries = async (queries, templates = []) => {
|
||||
const {editQueryStatus, grabDataForDownload} = this.props
|
||||
const {resolution} = this.state
|
||||
|
||||
if (!queries.length) {
|
||||
|
@ -147,26 +101,33 @@ const AutoRefresh = ComposedComponent => {
|
|||
)
|
||||
})
|
||||
|
||||
Promise.all(timeSeriesPromises).then(timeSeries => {
|
||||
try {
|
||||
const timeSeries = await Promise.all(timeSeriesPromises)
|
||||
const newSeries = timeSeries.map(response => ({response}))
|
||||
const lastQuerySuccessful = !this._noResultsForQuery(newSeries)
|
||||
const lastQuerySuccessful = this._resultsForQuery(newSeries)
|
||||
|
||||
this.setState({
|
||||
timeSeries: newSeries,
|
||||
lastQuerySuccessful,
|
||||
isFetching: false,
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
if (grabDataForDownload) {
|
||||
grabDataForDownload(timeSeries)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.intervalID)
|
||||
this.intervalID = false
|
||||
},
|
||||
}
|
||||
|
||||
setResolution(resolution) {
|
||||
setResolution = resolution => {
|
||||
this.setState({resolution})
|
||||
},
|
||||
}
|
||||
|
||||
render() {
|
||||
const {timeSeries} = this.state
|
||||
|
@ -176,7 +137,7 @@ const AutoRefresh = ComposedComponent => {
|
|||
}
|
||||
|
||||
if (
|
||||
this._noResultsForQuery(timeSeries) ||
|
||||
!this._resultsForQuery(timeSeries) ||
|
||||
!this.state.lastQuerySuccessful
|
||||
) {
|
||||
return this.renderNoResults()
|
||||
|
@ -189,13 +150,13 @@ const AutoRefresh = ComposedComponent => {
|
|||
setResolution={this.setResolution}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Graphs can potentially show mulitple kinds of spinners based on whether
|
||||
* a graph is being fetched for the first time, or is being refreshed.
|
||||
*/
|
||||
renderFetching(data) {
|
||||
renderFetching = data => {
|
||||
const isFirstFetch = !Object.keys(this.state.timeSeries).length
|
||||
return (
|
||||
<ComposedComponent
|
||||
|
@ -206,30 +167,76 @@ const AutoRefresh = ComposedComponent => {
|
|||
isRefreshing={!isFirstFetch}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
renderNoResults() {
|
||||
renderNoResults = () => {
|
||||
return (
|
||||
<div className="graph-empty">
|
||||
<p data-test="data-explorer-no-results">No Results</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
_noResultsForQuery(data) {
|
||||
if (!data.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
return data.every(({response}) => {
|
||||
return _.get(response, 'results', []).every(result => {
|
||||
return (
|
||||
Object.keys(result).filter(k => k !== 'statement_id').length === 0
|
||||
_resultsForQuery = data =>
|
||||
data.length
|
||||
? data.every(({response}) =>
|
||||
_.get(response, 'results', []).every(
|
||||
result =>
|
||||
Object.keys(result).filter(k => k !== 'statement_id').length !==
|
||||
0
|
||||
)
|
||||
)
|
||||
})
|
||||
: false
|
||||
}
|
||||
|
||||
const {
|
||||
array,
|
||||
arrayOf,
|
||||
bool,
|
||||
element,
|
||||
func,
|
||||
number,
|
||||
oneOfType,
|
||||
shape,
|
||||
string,
|
||||
} = PropTypes
|
||||
|
||||
wrapper.propTypes = {
|
||||
children: element,
|
||||
autoRefresh: number.isRequired,
|
||||
templates: arrayOf(
|
||||
shape({
|
||||
type: string.isRequired,
|
||||
tempVar: string.isRequired,
|
||||
query: shape({
|
||||
db: string,
|
||||
rp: string,
|
||||
influxql: string,
|
||||
}),
|
||||
values: arrayOf(
|
||||
shape({
|
||||
type: string.isRequired,
|
||||
value: string.isRequired,
|
||||
selected: bool,
|
||||
})
|
||||
).isRequired,
|
||||
})
|
||||
},
|
||||
})
|
||||
),
|
||||
queries: arrayOf(
|
||||
shape({
|
||||
host: oneOfType([string, arrayOf(string)]),
|
||||
text: string,
|
||||
}).isRequired
|
||||
).isRequired,
|
||||
axes: shape({
|
||||
bounds: shape({
|
||||
y: array,
|
||||
y2: array,
|
||||
}),
|
||||
}),
|
||||
editQueryStatus: func,
|
||||
grabDataForDownload: func,
|
||||
}
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
|
|
@ -28,37 +28,51 @@ class AutoRefreshDropdown extends Component {
|
|||
toggleMenu = () => this.setState({isOpen: !this.state.isOpen})
|
||||
|
||||
render() {
|
||||
const {selected} = this.props
|
||||
const {selected, onManualRefresh} = this.props
|
||||
const {isOpen} = this.state
|
||||
const {milliseconds, inputValue} = this.findAutoRefreshItem(selected)
|
||||
|
||||
return (
|
||||
<div className={classnames('dropdown dropdown-160', {open: isOpen})}>
|
||||
<div
|
||||
className="btn btn-sm btn-default dropdown-toggle"
|
||||
onClick={this.toggleMenu}
|
||||
>
|
||||
<span
|
||||
className={classnames(
|
||||
'icon',
|
||||
+milliseconds > 0 ? 'refresh' : 'pause'
|
||||
<div
|
||||
className={classnames('autorefresh-dropdown', {
|
||||
paused: +milliseconds === 0,
|
||||
})}
|
||||
>
|
||||
<div className={classnames('dropdown dropdown-160', {open: isOpen})}>
|
||||
<div
|
||||
className="btn btn-sm btn-default dropdown-toggle"
|
||||
onClick={this.toggleMenu}
|
||||
>
|
||||
<span
|
||||
className={classnames(
|
||||
'icon',
|
||||
+milliseconds > 0 ? 'refresh' : 'pause'
|
||||
)}
|
||||
/>
|
||||
<span className="dropdown-selected">
|
||||
{inputValue}
|
||||
</span>
|
||||
<span className="caret" />
|
||||
</div>
|
||||
<ul className="dropdown-menu">
|
||||
<li className="dropdown-header">AutoRefresh Interval</li>
|
||||
{autoRefreshItems.map(item =>
|
||||
<li className="dropdown-item" key={item.menuOption}>
|
||||
<a href="#" onClick={this.handleSelection(item.milliseconds)}>
|
||||
{item.menuOption}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
/>
|
||||
<span className="dropdown-selected">
|
||||
{inputValue}
|
||||
</span>
|
||||
<span className="caret" />
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="dropdown-menu">
|
||||
<li className="dropdown-header">AutoRefresh Interval</li>
|
||||
{autoRefreshItems.map(item =>
|
||||
<li className="dropdown-item" key={item.menuOption}>
|
||||
<a href="#" onClick={this.handleSelection(item.milliseconds)}>
|
||||
{item.menuOption}
|
||||
</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{+milliseconds === 0
|
||||
? <div
|
||||
className="btn btn-sm btn-default btn-square"
|
||||
onClick={onManualRefresh}
|
||||
>
|
||||
<span className="icon refresh" />
|
||||
</div>
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -69,6 +83,7 @@ const {number, func} = PropTypes
|
|||
AutoRefreshDropdown.propTypes = {
|
||||
selected: number.isRequired,
|
||||
onChoose: func.isRequired,
|
||||
onManualRefresh: func,
|
||||
}
|
||||
|
||||
export default OnClickOutside(AutoRefreshDropdown)
|
||||
|
|
|
@ -8,6 +8,9 @@ const dateFormat = 'YYYY-MM-DD HH:mm'
|
|||
class CustomTimeRange extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
isNow: this.props.timeRange.upper === 'now()',
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -68,6 +71,14 @@ class CustomTimeRange extends Component {
|
|||
this.upperCal.refresh()
|
||||
}
|
||||
|
||||
handleToggleNow = () => {
|
||||
this.setState({isNow: !this.state.isNow})
|
||||
}
|
||||
|
||||
handleNowOff = () => {
|
||||
this.setState({isNow: false})
|
||||
}
|
||||
|
||||
/*
|
||||
* Upper and lower time ranges are passed in with single quotes as part of
|
||||
* the string literal, i.e. "'2015-09-23T18:00:00.000Z'". Remove them
|
||||
|
@ -78,6 +89,10 @@ class CustomTimeRange extends Component {
|
|||
return ''
|
||||
}
|
||||
|
||||
if (timeRange === 'now()') {
|
||||
return moment(new Date()).format(dateFormat)
|
||||
}
|
||||
|
||||
// If the given time range is relative, create a fixed timestamp based on its value
|
||||
if (timeRange.match(/^now/)) {
|
||||
const [, duration, unitOfTime] = timeRange.match(/(\d+)(\w+)/)
|
||||
|
@ -89,10 +104,16 @@ class CustomTimeRange extends Component {
|
|||
|
||||
handleClick = () => {
|
||||
const {onApplyTimeRange, onClose} = this.props
|
||||
const {isNow} = this.state
|
||||
|
||||
const lower = this.lowerCal.getDate().toISOString()
|
||||
const upper = this.upperCal.getDate().toISOString()
|
||||
|
||||
onApplyTimeRange({lower, upper})
|
||||
if (isNow) {
|
||||
onApplyTimeRange({lower, upper: 'now()'})
|
||||
} else {
|
||||
onApplyTimeRange({lower, upper})
|
||||
}
|
||||
|
||||
if (onClose) {
|
||||
onClose()
|
||||
|
@ -142,6 +163,10 @@ class CustomTimeRange extends Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const {isNow} = this.state
|
||||
const {page} = this.props
|
||||
const isNowDisplayed = page !== 'DataExplorer'
|
||||
|
||||
return (
|
||||
<div className="custom-time--container">
|
||||
<div className="custom-time--shortcuts">
|
||||
|
@ -159,7 +184,7 @@ class CustomTimeRange extends Component {
|
|||
<div className="custom-time--wrap">
|
||||
<div className="custom-time--dates" onClick={this.handleRefreshCals}>
|
||||
<div
|
||||
className="lower-container"
|
||||
className="custom-time--lower-container"
|
||||
ref={r => (this.lowerContainer = r)}
|
||||
>
|
||||
<input
|
||||
|
@ -170,15 +195,33 @@ class CustomTimeRange extends Component {
|
|||
/>
|
||||
</div>
|
||||
<div
|
||||
className="upper-container"
|
||||
className="custom-time--upper-container"
|
||||
ref={r => (this.upperContainer = r)}
|
||||
disabled={isNow}
|
||||
>
|
||||
{isNowDisplayed
|
||||
? <div
|
||||
className={`btn btn-xs custom-time--now ${isNow
|
||||
? 'btn-primary'
|
||||
: 'btn-default'}`}
|
||||
onClick={this.handleToggleNow}
|
||||
>
|
||||
Now
|
||||
</div>
|
||||
: null}
|
||||
<input
|
||||
className="custom-time--upper form-control input-sm"
|
||||
ref={r => (this.upper = r)}
|
||||
placeholder="to"
|
||||
onKeyUp={this.handleRefreshCals}
|
||||
disabled={isNow}
|
||||
/>
|
||||
{isNow && page !== 'DataExplorer'
|
||||
? <div
|
||||
className="custom-time--mask"
|
||||
onClick={this.handleNowOff}
|
||||
/>
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
@ -202,6 +245,7 @@ CustomTimeRange.propTypes = {
|
|||
upper: string,
|
||||
}).isRequired,
|
||||
onClose: func,
|
||||
page: string,
|
||||
}
|
||||
|
||||
export default CustomTimeRange
|
||||
|
|
|
@ -13,7 +13,7 @@ class CustomTimeRangeOverlay extends Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const {onClose, timeRange, onApplyTimeRange} = this.props
|
||||
const {onClose, timeRange, onApplyTimeRange, page} = this.props
|
||||
|
||||
return (
|
||||
<div className="custom-time--overlay">
|
||||
|
@ -21,6 +21,7 @@ class CustomTimeRangeOverlay extends Component {
|
|||
onApplyTimeRange={onApplyTimeRange}
|
||||
timeRange={timeRange}
|
||||
onClose={onClose}
|
||||
page={page}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
@ -36,6 +37,7 @@ CustomTimeRangeOverlay.propTypes = {
|
|||
upper: string,
|
||||
}).isRequired,
|
||||
onClose: func,
|
||||
page: string,
|
||||
}
|
||||
|
||||
export default OnClickOutside(CustomTimeRangeOverlay)
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
/* eslint-disable no-magic-numbers */
|
||||
import React, {Component, PropTypes} from 'react'
|
||||
import shallowCompare from 'react-addons-shallow-compare'
|
||||
|
||||
import _ from 'lodash'
|
||||
import moment from 'moment'
|
||||
import NanoDate from 'nano-date'
|
||||
|
||||
import Dygraphs from 'src/external/dygraph'
|
||||
import getRange, {getStackedRange} from 'shared/parsing/getRangeForDygraph'
|
||||
|
@ -305,8 +304,8 @@ export default class Dygraph extends Component {
|
|||
if (!timeRange) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return moment(timeRange).utc().format()
|
||||
const date = new NanoDate(timeRange)
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
deselectCrosshair = () => {
|
||||
|
|
|
@ -8,6 +8,12 @@ import FancyScrollbar from 'shared/components/FancyScrollbar'
|
|||
|
||||
import {showFieldKeys} from 'shared/apis/metaQuery'
|
||||
import showFieldKeysParser from 'shared/parsing/showFieldKeys'
|
||||
import {
|
||||
functionNames,
|
||||
numFunctions,
|
||||
getFieldsWithName,
|
||||
getFuncsByFieldName,
|
||||
} from 'shared/reducers/helpers/fields'
|
||||
|
||||
class FieldList extends Component {
|
||||
constructor(props) {
|
||||
|
@ -58,6 +64,49 @@ class FieldList extends Component {
|
|||
this.props.onFill(fill)
|
||||
}
|
||||
|
||||
handleToggleField = field => {
|
||||
const {
|
||||
query,
|
||||
onToggleField,
|
||||
addInitialField,
|
||||
initialGroupByTime: time,
|
||||
isKapacitorRule,
|
||||
} = this.props
|
||||
const {fields, groupBy} = query
|
||||
const initialGroupBy = {...groupBy, time}
|
||||
|
||||
if (!_.size(fields)) {
|
||||
return isKapacitorRule
|
||||
? onToggleField(field)
|
||||
: addInitialField(field, initialGroupBy)
|
||||
}
|
||||
|
||||
onToggleField(field)
|
||||
}
|
||||
|
||||
handleApplyFuncs = fieldFunc => {
|
||||
const {
|
||||
query,
|
||||
removeFuncs,
|
||||
applyFuncsToField,
|
||||
initialGroupByTime: time,
|
||||
} = this.props
|
||||
const {groupBy, fields} = query
|
||||
const {funcs} = fieldFunc
|
||||
|
||||
// If one field has no funcs, all fields must have no funcs
|
||||
if (!_.size(funcs)) {
|
||||
return removeFuncs(fields)
|
||||
}
|
||||
|
||||
// If there is no groupBy time, set one
|
||||
if (!groupBy.time) {
|
||||
return applyFuncsToField(fieldFunc, {...groupBy, time})
|
||||
}
|
||||
|
||||
applyFuncsToField(fieldFunc, groupBy)
|
||||
}
|
||||
|
||||
_getFields = () => {
|
||||
const {database, measurement, retentionPolicy} = this.props.query
|
||||
const {source} = this.context
|
||||
|
@ -73,20 +122,20 @@ class FieldList extends Component {
|
|||
}
|
||||
|
||||
this.setState({
|
||||
fields: fieldSets[measurement].map(f => ({field: f, funcs: []})),
|
||||
fields: fieldSets[measurement].map(f => ({value: f, type: 'field'})),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
query: {fields = [], groupBy, fill},
|
||||
query: {database, measurement, fields = [], groupBy, fill},
|
||||
isKapacitorRule,
|
||||
isInDataExplorer,
|
||||
} = this.props
|
||||
|
||||
const hasAggregates = fields.some(f => f.funcs && f.funcs.length)
|
||||
const hasAggregates = numFunctions(fields) > 0
|
||||
const hasGroupByTime = groupBy.time
|
||||
const noDBorMeas = !database || !measurement
|
||||
|
||||
return (
|
||||
<div className="query-builder--column">
|
||||
|
@ -98,8 +147,6 @@ class FieldList extends Component {
|
|||
isOpen={!hasGroupByTime}
|
||||
selected={groupBy.time}
|
||||
onChooseGroupByTime={this.handleGroupByTime}
|
||||
isInRuleBuilder={isKapacitorRule}
|
||||
isInDataExplorer={isInDataExplorer}
|
||||
/>
|
||||
{isKapacitorRule
|
||||
? null
|
||||
|
@ -107,40 +154,39 @@ class FieldList extends Component {
|
|||
</div>
|
||||
: null}
|
||||
</div>
|
||||
{this.renderList()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{noDBorMeas
|
||||
? <div className="query-builder--list-empty">
|
||||
<span>
|
||||
No <strong>Measurement</strong> selected
|
||||
</span>
|
||||
</div>
|
||||
: <div className="query-builder--list">
|
||||
<FancyScrollbar>
|
||||
{this.state.fields.map((fieldFunc, i) => {
|
||||
const selectedFields = getFieldsWithName(
|
||||
fieldFunc.value,
|
||||
fields
|
||||
)
|
||||
|
||||
renderList() {
|
||||
const {database, measurement, fields = []} = this.props.query
|
||||
if (!database || !measurement) {
|
||||
return (
|
||||
<div className="query-builder--list-empty">
|
||||
<span>
|
||||
No <strong>Measurement</strong> selected
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const funcs = getFuncsByFieldName(fieldFunc.value, fields)
|
||||
const fieldFuncs = selectedFields.length
|
||||
? selectedFields
|
||||
: [fieldFunc]
|
||||
|
||||
return (
|
||||
<div className="query-builder--list">
|
||||
<FancyScrollbar>
|
||||
{this.state.fields.map(fieldFunc => {
|
||||
const selectedField = fields.find(f => f.field === fieldFunc.field)
|
||||
return (
|
||||
<FieldListItem
|
||||
key={fieldFunc.field}
|
||||
onToggleField={this.props.onToggleField}
|
||||
onApplyFuncsToField={this.props.applyFuncsToField}
|
||||
isSelected={!!selectedField}
|
||||
fieldFunc={selectedField || fieldFunc}
|
||||
isKapacitorRule={this.props.isKapacitorRule}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</FancyScrollbar>
|
||||
return (
|
||||
<FieldListItem
|
||||
key={i}
|
||||
onToggleField={this.handleToggleField}
|
||||
onApplyFuncsToField={this.handleApplyFuncs}
|
||||
isSelected={!!selectedFields.length}
|
||||
fieldFuncs={fieldFuncs}
|
||||
funcs={functionNames(funcs)}
|
||||
isKapacitorRule={isKapacitorRule}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</FancyScrollbar>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -150,6 +196,7 @@ const {bool, func, shape, string} = PropTypes
|
|||
|
||||
FieldList.defaultProps = {
|
||||
isKapacitorRule: false,
|
||||
initialGroupByTime: null,
|
||||
}
|
||||
|
||||
FieldList.contextTypes = {
|
||||
|
@ -171,12 +218,14 @@ FieldList.propTypes = {
|
|||
onFill: func,
|
||||
applyFuncsToField: func.isRequired,
|
||||
isKapacitorRule: bool,
|
||||
isInDataExplorer: bool,
|
||||
querySource: shape({
|
||||
links: shape({
|
||||
proxy: string.isRequired,
|
||||
}).isRequired,
|
||||
}),
|
||||
removeFuncs: func.isRequired,
|
||||
addInitialField: func,
|
||||
initialGroupByTime: string,
|
||||
}
|
||||
|
||||
export default FieldList
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, {PropTypes} from 'react'
|
||||
import React, {Component, PropTypes} from 'react'
|
||||
import WidgetCell from 'shared/components/WidgetCell'
|
||||
import LayoutCell from 'shared/components/LayoutCell'
|
||||
import RefreshingGraph from 'shared/components/RefreshingGraph'
|
||||
|
@ -15,6 +15,30 @@ const getSource = (cell, source, sources, defaultSource) => {
|
|||
return sources.find(src => src.links.self === s) || defaultSource
|
||||
}
|
||||
|
||||
class LayoutState extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
celldata: [],
|
||||
}
|
||||
}
|
||||
|
||||
grabDataForDownload = celldata => {
|
||||
this.setState({celldata})
|
||||
}
|
||||
|
||||
render() {
|
||||
const {celldata} = this.state
|
||||
return (
|
||||
<Layout
|
||||
{...this.props}
|
||||
celldata={celldata}
|
||||
grabDataForDownload={this.grabDataForDownload}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const Layout = (
|
||||
{
|
||||
host,
|
||||
|
@ -28,11 +52,14 @@ const Layout = (
|
|||
isEditable,
|
||||
onEditCell,
|
||||
autoRefresh,
|
||||
manualRefresh,
|
||||
onDeleteCell,
|
||||
synchronizer,
|
||||
resizeCoords,
|
||||
onCancelEditCell,
|
||||
onSummonOverlayTechnologies,
|
||||
grabDataForDownload,
|
||||
celldata,
|
||||
},
|
||||
{source: defaultSource}
|
||||
) =>
|
||||
|
@ -40,6 +67,7 @@ const Layout = (
|
|||
cell={cell}
|
||||
isEditable={isEditable}
|
||||
onEditCell={onEditCell}
|
||||
celldata={celldata}
|
||||
onDeleteCell={onDeleteCell}
|
||||
onCancelEditCell={onCancelEditCell}
|
||||
onSummonOverlayTechnologies={onSummonOverlayTechnologies}
|
||||
|
@ -55,7 +83,9 @@ const Layout = (
|
|||
timeRange={timeRange}
|
||||
templates={templates}
|
||||
autoRefresh={autoRefresh}
|
||||
manualRefresh={manualRefresh}
|
||||
synchronizer={synchronizer}
|
||||
grabDataForDownload={grabDataForDownload}
|
||||
resizeCoords={resizeCoords}
|
||||
queries={buildQueriesForLayouts(
|
||||
cell,
|
||||
|
@ -72,8 +102,9 @@ Layout.contextTypes = {
|
|||
source: shape(),
|
||||
}
|
||||
|
||||
Layout.propTypes = {
|
||||
const propTypes = {
|
||||
autoRefresh: number.isRequired,
|
||||
manualRefresh: number,
|
||||
timeRange: shape({
|
||||
lower: string.isRequired,
|
||||
}),
|
||||
|
@ -115,4 +146,11 @@ Layout.propTypes = {
|
|||
sources: arrayOf(shape()),
|
||||
}
|
||||
|
||||
export default Layout
|
||||
LayoutState.propTypes = {...propTypes}
|
||||
Layout.propTypes = {
|
||||
...propTypes,
|
||||
grabDataForDownload: func,
|
||||
celldata: arrayOf(shape()),
|
||||
}
|
||||
|
||||
export default LayoutState
|
||||
|
|
|
@ -3,6 +3,9 @@ import _ from 'lodash'
|
|||
|
||||
import LayoutCellMenu from 'shared/components/LayoutCellMenu'
|
||||
import LayoutCellHeader from 'shared/components/LayoutCellHeader'
|
||||
import {errorThrown} from 'shared/actions/errors'
|
||||
import {dashboardtoCSV} from 'shared/parsing/resultsToCSV'
|
||||
import download from 'src/external/download.js'
|
||||
|
||||
class LayoutCell extends Component {
|
||||
constructor(props) {
|
||||
|
@ -30,8 +33,19 @@ class LayoutCell extends Component {
|
|||
this.props.onSummonOverlayTechnologies(cell)
|
||||
}
|
||||
|
||||
handleCSVDownload = cell => () => {
|
||||
const joinedName = cell.name.split(' ').join('_')
|
||||
const {celldata} = this.props
|
||||
try {
|
||||
download(dashboardtoCSV(celldata), `${joinedName}.csv`, 'text/plain')
|
||||
} catch (error) {
|
||||
errorThrown(error, 'Unable to download .csv file')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {cell, children, isEditable} = this.props
|
||||
const {cell, children, isEditable, celldata} = this.props
|
||||
|
||||
const {isDeleting} = this.state
|
||||
const queries = _.get(cell, ['queries'], [])
|
||||
|
@ -40,12 +54,14 @@ class LayoutCell extends Component {
|
|||
<div className="dash-graph">
|
||||
<LayoutCellMenu
|
||||
cell={cell}
|
||||
dataExists={!!celldata.length}
|
||||
isDeleting={isDeleting}
|
||||
isEditable={isEditable}
|
||||
onDelete={this.handleDeleteCell}
|
||||
onEdit={this.handleSummonOverlay}
|
||||
handleClickOutside={this.closeMenu}
|
||||
onDeleteClick={this.handleDeleteClick}
|
||||
onCSVDownload={this.handleCSVDownload}
|
||||
/>
|
||||
<LayoutCellHeader
|
||||
queries={queries}
|
||||
|
@ -84,6 +100,7 @@ LayoutCell.propTypes = {
|
|||
onSummonOverlayTechnologies: func,
|
||||
isEditable: bool,
|
||||
onCancelEditCell: func,
|
||||
celldata: arrayOf(shape()),
|
||||
}
|
||||
|
||||
export default LayoutCell
|
||||
|
|
|
@ -2,7 +2,15 @@ import React, {PropTypes} from 'react'
|
|||
import OnClickOutside from 'react-onclickoutside'
|
||||
|
||||
const LayoutCellMenu = OnClickOutside(
|
||||
({isDeleting, onEdit, onDeleteClick, onDelete, cell}) =>
|
||||
({
|
||||
isDeleting,
|
||||
onEdit,
|
||||
onDeleteClick,
|
||||
onDelete,
|
||||
onCSVDownload,
|
||||
dataExists,
|
||||
cell,
|
||||
}) =>
|
||||
<div
|
||||
className={
|
||||
isDeleting
|
||||
|
@ -13,6 +21,14 @@ const LayoutCellMenu = OnClickOutside(
|
|||
<div className="dash-graph-context--button" onClick={onEdit(cell)}>
|
||||
<span className="icon pencil" />
|
||||
</div>
|
||||
{dataExists
|
||||
? <div
|
||||
className="dash-graph-context--button"
|
||||
onClick={onCSVDownload(cell)}
|
||||
>
|
||||
<span className="icon download" />
|
||||
</div>
|
||||
: null}
|
||||
{isDeleting
|
||||
? <div className="dash-graph-context--button active">
|
||||
<span className="icon trash" />
|
||||
|
@ -46,6 +62,7 @@ LayoutCellMenuContainer.propTypes = {
|
|||
onDeleteClick: func,
|
||||
cell: shape(),
|
||||
isEditable: bool,
|
||||
dataExists: bool,
|
||||
}
|
||||
|
||||
LayoutCellMenu.propTypes = LayoutCellMenuContainer.propTypes
|
||||
|
|
|
@ -75,6 +75,7 @@ class LayoutRenderer extends Component {
|
|||
isEditable,
|
||||
onEditCell,
|
||||
autoRefresh,
|
||||
manualRefresh,
|
||||
onDeleteCell,
|
||||
synchronizer,
|
||||
onCancelEditCell,
|
||||
|
@ -114,6 +115,7 @@ class LayoutRenderer extends Component {
|
|||
onEditCell={onEditCell}
|
||||
resizeCoords={resizeCoords}
|
||||
autoRefresh={autoRefresh}
|
||||
manualRefresh={manualRefresh}
|
||||
onDeleteCell={onDeleteCell}
|
||||
synchronizer={synchronizer}
|
||||
onCancelEditCell={onCancelEditCell}
|
||||
|
@ -131,6 +133,7 @@ const {arrayOf, bool, func, number, shape, string} = PropTypes
|
|||
|
||||
LayoutRenderer.propTypes = {
|
||||
autoRefresh: number.isRequired,
|
||||
manualRefresh: number,
|
||||
timeRange: shape({
|
||||
lower: string.isRequired,
|
||||
}),
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import React, {Component} from 'react'
|
||||
|
||||
const ManualRefresh = WrappedComponent =>
|
||||
class extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
manualRefresh: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
handleManualRefresh = () => {
|
||||
this.setState({
|
||||
manualRefresh: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<WrappedComponent
|
||||
{...this.props}
|
||||
manualRefresh={this.state.manualRefresh}
|
||||
onManualRefresh={this.handleManualRefresh}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ManualRefresh
|
|
@ -100,7 +100,7 @@ const MeasurementList = React.createClass({
|
|||
return (
|
||||
<div className="query-builder--column">
|
||||
<div className="query-builder--heading">
|
||||
<span>Measurements</span>
|
||||
<span>Measurements & Tags</span>
|
||||
{this.props.query.database
|
||||
? <div className="query-builder--filter">
|
||||
<input
|
||||
|
|
|
@ -18,9 +18,11 @@ const RefreshingGraph = ({
|
|||
timeRange,
|
||||
cellHeight,
|
||||
autoRefresh,
|
||||
manualRefresh, // when changed, re-mounts the component
|
||||
synchronizer,
|
||||
resizeCoords,
|
||||
editQueryStatus,
|
||||
grabDataForDownload,
|
||||
}) => {
|
||||
if (!queries.length) {
|
||||
return (
|
||||
|
@ -35,6 +37,7 @@ const RefreshingGraph = ({
|
|||
if (type === 'single-stat') {
|
||||
return (
|
||||
<RefreshingSingleStat
|
||||
key={manualRefresh}
|
||||
queries={[queries[0]]}
|
||||
templates={templates}
|
||||
autoRefresh={autoRefresh}
|
||||
|
@ -53,6 +56,7 @@ const RefreshingGraph = ({
|
|||
axes={axes}
|
||||
onZoom={onZoom}
|
||||
queries={queries}
|
||||
key={manualRefresh}
|
||||
templates={templates}
|
||||
timeRange={timeRange}
|
||||
autoRefresh={autoRefresh}
|
||||
|
@ -61,6 +65,7 @@ const RefreshingGraph = ({
|
|||
resizeCoords={resizeCoords}
|
||||
displayOptions={displayOptions}
|
||||
editQueryStatus={editQueryStatus}
|
||||
grabDataForDownload={grabDataForDownload}
|
||||
showSingleStat={type === 'line-plus-single-stat'}
|
||||
/>
|
||||
)
|
||||
|
@ -73,6 +78,7 @@ RefreshingGraph.propTypes = {
|
|||
lower: string.isRequired,
|
||||
}),
|
||||
autoRefresh: number.isRequired,
|
||||
manualRefresh: number,
|
||||
templates: arrayOf(shape()),
|
||||
synchronizer: func,
|
||||
type: string.isRequired,
|
||||
|
@ -82,6 +88,11 @@ RefreshingGraph.propTypes = {
|
|||
editQueryStatus: func,
|
||||
onZoom: func,
|
||||
resizeCoords: shape(),
|
||||
grabDataForDownload: func,
|
||||
}
|
||||
|
||||
RefreshingGraph.defaultProps = {
|
||||
manualRefresh: 0,
|
||||
}
|
||||
|
||||
export default RefreshingGraph
|
||||
|
|
|
@ -4,22 +4,25 @@ import DatabaseList from 'src/shared/components/DatabaseList'
|
|||
import MeasurementList from 'src/shared/components/MeasurementList'
|
||||
import FieldList from 'src/shared/components/FieldList'
|
||||
|
||||
const actionBinder = (id, action) => item => action(id, item)
|
||||
const actionBinder = (id, action) => (...args) => action(id, ...args)
|
||||
|
||||
const SchemaExplorer = ({
|
||||
query,
|
||||
query: {id},
|
||||
source,
|
||||
initialGroupByTime,
|
||||
actions: {
|
||||
fill,
|
||||
chooseTag,
|
||||
groupByTag,
|
||||
groupByTime,
|
||||
fill,
|
||||
toggleField,
|
||||
removeFuncs,
|
||||
addInitialField,
|
||||
chooseNamespace,
|
||||
chooseMeasurement,
|
||||
applyFuncsToField,
|
||||
toggleTagAcceptance,
|
||||
toggleFieldWithGroupByInterval,
|
||||
},
|
||||
}) =>
|
||||
<div className="query-builder">
|
||||
|
@ -41,10 +44,13 @@ const SchemaExplorer = ({
|
|||
source={source}
|
||||
query={query}
|
||||
querySource={source}
|
||||
onToggleField={actionBinder(id, toggleFieldWithGroupByInterval)}
|
||||
initialGroupByTime={initialGroupByTime}
|
||||
onToggleField={actionBinder(id, toggleField)}
|
||||
onFill={actionBinder(id, fill)}
|
||||
onGroupByTime={actionBinder(id, groupByTime)}
|
||||
applyFuncsToField={actionBinder(id, applyFuncsToField)}
|
||||
removeFuncs={actionBinder(id, removeFuncs)}
|
||||
addInitialField={actionBinder(id, addInitialField)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -65,8 +71,11 @@ SchemaExplorer.propTypes = {
|
|||
toggleTagAcceptance: func.isRequired,
|
||||
fill: func.isRequired,
|
||||
editRawTextAsync: func.isRequired,
|
||||
addInitialField: func.isRequired,
|
||||
removeFuncs: func.isRequired,
|
||||
}).isRequired,
|
||||
source: shape({}),
|
||||
initialGroupByTime: string.isRequired,
|
||||
}
|
||||
|
||||
export default SchemaExplorer
|
||||
|
|
|
@ -58,7 +58,7 @@ export const TabList = React.createClass({
|
|||
|
||||
if (this.props.isKapacitorTabs === 'true') {
|
||||
return (
|
||||
<div className="rule-section--row rule-section--row-first rule-section--border-bottom">
|
||||
<div className="rule-section--row rule-section--row-first rule-section--row-last">
|
||||
<p>Choose One:</p>
|
||||
<div className="nav nav-tablist nav-tablist-sm nav-tablist-malachite">
|
||||
{children}
|
||||
|
|
|
@ -9,7 +9,9 @@ import CustomTimeRangeOverlay from 'shared/components/CustomTimeRangeOverlay'
|
|||
import timeRanges from 'hson!shared/data/timeRanges.hson'
|
||||
import {DROPDOWN_MENU_MAX_HEIGHT} from 'shared/constants/index'
|
||||
|
||||
const dateFormat = 'YYYY-MM-DD HH:mm'
|
||||
const emptyTime = {lower: '', upper: ''}
|
||||
const format = t => moment(t.replace(/\'/g, '')).format(dateFormat)
|
||||
|
||||
class TimeRangeDropdown extends Component {
|
||||
constructor(props) {
|
||||
|
@ -29,8 +31,10 @@ class TimeRangeDropdown extends Component {
|
|||
|
||||
findTimeRangeInputValue = ({upper, lower}) => {
|
||||
if (upper && lower) {
|
||||
const format = t =>
|
||||
moment(t.replace(/\'/g, '')).format('YYYY-MM-DD HH:mm')
|
||||
if (upper === 'now()') {
|
||||
return `${format(lower)} - Now`
|
||||
}
|
||||
|
||||
return `${format(lower)} - ${format(upper)}`
|
||||
}
|
||||
|
||||
|
@ -44,7 +48,7 @@ class TimeRangeDropdown extends Component {
|
|||
|
||||
handleSelection = timeRange => () => {
|
||||
this.props.onChooseTimeRange(timeRange)
|
||||
this.setState({isOpen: false})
|
||||
this.setState({customTimeRange: emptyTime, isOpen: false})
|
||||
}
|
||||
|
||||
toggleMenu = () => {
|
||||
|
@ -69,16 +73,18 @@ class TimeRangeDropdown extends Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const {selected, preventCustomTimeRange} = this.props
|
||||
const {selected, preventCustomTimeRange, page} = this.props
|
||||
const {isOpen, customTimeRange, isCustomTimeRangeOpen} = this.state
|
||||
const isRelativeTimeRange = selected.upper === null
|
||||
const isNow = selected.upper === 'now()'
|
||||
|
||||
return (
|
||||
<div className="time-range-dropdown">
|
||||
<div
|
||||
className={classnames('dropdown', {
|
||||
'dropdown-160': isRelativeTimeRange,
|
||||
'dropdown-290': !isRelativeTimeRange,
|
||||
'dropdown-210': isNow,
|
||||
'dropdown-290': !isRelativeTimeRange && !isNow,
|
||||
open: isOpen,
|
||||
})}
|
||||
>
|
||||
|
@ -136,6 +142,7 @@ class TimeRangeDropdown extends Component {
|
|||
isVisible={isCustomTimeRangeOpen}
|
||||
onToggle={this.handleToggleCustomTimeRange}
|
||||
onClose={this.handleCloseCustomTimeRange}
|
||||
page={page}
|
||||
/>
|
||||
: null}
|
||||
</div>
|
||||
|
@ -145,6 +152,10 @@ class TimeRangeDropdown extends Component {
|
|||
|
||||
const {bool, func, shape, string} = PropTypes
|
||||
|
||||
TimeRangeDropdown.defaultProps = {
|
||||
page: 'default',
|
||||
}
|
||||
|
||||
TimeRangeDropdown.propTypes = {
|
||||
selected: shape({
|
||||
lower: string,
|
||||
|
@ -152,6 +163,7 @@ TimeRangeDropdown.propTypes = {
|
|||
}).isRequired,
|
||||
onChooseTimeRange: func.isRequired,
|
||||
preventCustomTimeRange: bool,
|
||||
page: string,
|
||||
}
|
||||
|
||||
export default OnClickOutside(TimeRangeDropdown)
|
||||
|
|
|
@ -402,7 +402,8 @@ export const VIS_VIEWS = [GRAPH, TABLE]
|
|||
|
||||
// InfluxQL Macros
|
||||
export const TEMP_VAR_INTERVAL = ':interval:'
|
||||
export const DEFAULT_DASHBOARD_GROUP_BY_INTERVAL = 'auto'
|
||||
export const INITIAL_GROUP_BY_TIME = '10s'
|
||||
export const AUTO_GROUP_BY = 'auto'
|
||||
|
||||
export const DEFAULT_HOME_PAGE = 'status'
|
||||
|
||||
|
@ -411,3 +412,14 @@ export const PAGE_HEADER_HEIGHT = 60 // TODO: get this dynamically to ensure lon
|
|||
export const PAGE_CONTAINER_MARGIN = 30 // TODO: get this dynamically to ensure longevity
|
||||
export const LAYOUT_MARGIN = 4
|
||||
export const DASHBOARD_LAYOUT_ROW_HEIGHT = 83.5
|
||||
|
||||
export const DEFAULT_SOURCE = {
|
||||
url: 'http://localhost:8086',
|
||||
name: 'Influx 1',
|
||||
username: '',
|
||||
password: '',
|
||||
default: true,
|
||||
telegraf: 'telegraf',
|
||||
insecureSkipVerify: false,
|
||||
metaUrl: '',
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ import BigNumber from 'bignumber.js'
|
|||
const ADD_FACTOR = 1.1
|
||||
const SUB_FACTOR = 0.9
|
||||
|
||||
const checkNumeric = num => (isFinite(num) ? num : null)
|
||||
|
||||
const considerEmpty = (userNumber, number) => {
|
||||
if (userNumber) {
|
||||
return +userNumber
|
||||
|
@ -17,13 +19,15 @@ const getRange = (
|
|||
ruleValues = {value: null, rangeValue: null, operator: ''}
|
||||
) => {
|
||||
const {value, rangeValue, operator} = ruleValues
|
||||
const [userMin, userMax] = userSelectedRange
|
||||
const [uMin, uMax] = userSelectedRange
|
||||
const userMin = checkNumeric(uMin)
|
||||
const userMax = checkNumeric(uMax)
|
||||
|
||||
const addPad = bigNum => bigNum.times(ADD_FACTOR).toNumber()
|
||||
const subPad = bigNum => bigNum.times(SUB_FACTOR).toNumber()
|
||||
|
||||
const pad = v => {
|
||||
if (v === null || v === '' || v === undefined) {
|
||||
if (v === null || v === '' || !isFinite(v)) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
@ -4,20 +4,50 @@ import moment from 'moment'
|
|||
export const formatDate = timestamp =>
|
||||
moment(timestamp).format('M/D/YYYY h:mm:ss A')
|
||||
|
||||
const resultsToCSV = results => {
|
||||
const {name, columns, values} = _.get(results, ['0', 'series', '0'], {})
|
||||
const [, ...cols] = columns
|
||||
export const resultsToCSV = results => {
|
||||
if (!_.get(results, ['0', 'series', '0'])) {
|
||||
return {flag: 'no_data', name: '', CSVString: ''}
|
||||
}
|
||||
|
||||
const CSVString = [['date', ...cols].join(',')]
|
||||
.concat(
|
||||
values.map(([timestamp, ...measurements]) =>
|
||||
// MS Excel format
|
||||
[formatDate(timestamp), ...measurements].join(',')
|
||||
const {name, columns, values} = _.get(results, ['0', 'series', '0'])
|
||||
|
||||
if (columns[0] === 'time') {
|
||||
const [, ...cols] = columns
|
||||
const CSVString = [['date', ...cols].join(',')]
|
||||
.concat(
|
||||
values.map(([timestamp, ...measurements]) =>
|
||||
// MS Excel format
|
||||
[formatDate(timestamp), ...measurements].join(',')
|
||||
)
|
||||
)
|
||||
)
|
||||
.join('\n')
|
||||
.join('\n')
|
||||
return {flag: 'ok', name, CSVString}
|
||||
}
|
||||
|
||||
return {name, CSVString}
|
||||
const CSVString = [columns.join(',')]
|
||||
.concat(values.map(row => row.join(',')))
|
||||
.join('\n')
|
||||
return {flag: 'ok', name, CSVString}
|
||||
}
|
||||
|
||||
export default resultsToCSV
|
||||
export const dashboardtoCSV = data => {
|
||||
const columnNames = _.flatten(
|
||||
data.map(r => _.get(r, 'results[0].series[0].columns', []))
|
||||
)
|
||||
const timeIndices = columnNames
|
||||
.map((e, i) => (e === 'time' ? i : -1))
|
||||
.filter(e => e >= 0)
|
||||
|
||||
let values = data.map(r => _.get(r, 'results[0].series[0].values', []))
|
||||
values = _.unzip(values).map(v => _.flatten(v))
|
||||
if (timeIndices) {
|
||||
values.map(v => {
|
||||
timeIndices.forEach(i => (v[i] = formatDate(v[i])))
|
||||
return v
|
||||
})
|
||||
}
|
||||
const CSVString = [columnNames.join(',')]
|
||||
.concat(values.map(v => v.join(',')))
|
||||
.join('\n')
|
||||
return CSVString
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue