Merge branch 'master' into multitenancy

pull/10616/head
Jared Scheib 2017-10-24 17:58:15 -07:00
commit aa8742662f
122 changed files with 3899 additions and 2012 deletions

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)
}
})
}

View File

@ -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

View File

@ -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))
}
})
}

View File

@ -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 {

View File

@ -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,
},
}
}
}

View File

@ -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))
}
}
}

View File

@ -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{

View File

@ -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)

View File

@ -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",
},
},
},
},
},

View File

@ -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)))
}
}
}

View File

@ -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 {

View File

@ -22,7 +22,8 @@ func TestVarsCritStringEqual(t *testing.T) {
RetentionPolicy: "autogen",
Fields: []chronograf.Field{
{
Field: "status",
Value: "status",
Type: "field",
},
},
GroupBy: chronograf.GroupBy{

View File

@ -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{

View File

@ -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

View File

@ -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",
},
},
},
},
},

View File

@ -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": {},

1
ui/.gitignore vendored
View File

@ -5,3 +5,4 @@ dev/
dist/
bower_components/
log/
.tern-project

View File

@ -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",

View File

@ -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)
})
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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'}])
})
})
})

View File

@ -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)
})
})
})

View File

@ -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

View File

@ -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}

View File

@ -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>

View File

@ -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

View File

@ -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,
},
})

View File

@ -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>

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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>
)
}

View File

@ -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

View File

@ -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)
)

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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}
/>

View File

@ -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}
>

View File

@ -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')

View 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,
}

View File

@ -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

View File

@ -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 => {

View File

@ -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'

View File

@ -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))
)

View File

@ -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)

View File

@ -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
}

View File

@ -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)
)

View File

@ -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}>

View File

@ -61,3 +61,11 @@ export const groupByTime = (queryId, time) => ({
time,
},
})
export const removeFuncs = (queryID, fields) => ({
type: 'KAPA_REMOVE_FUNCS',
payload: {
queryID,
fields,
},
})

View File

@ -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,

View File

@ -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"

View File

@ -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}

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 &quot;value&quot; }}"
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 &quot;value&quot; }}"
value={rule.message}
spellCheck={false}
/>
</div>
const {func, shape} = PropTypes

View File

@ -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'}}

View File

@ -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,

View File

@ -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(

View File

@ -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

View File

@ -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']

View File

@ -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

View File

@ -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
}

View File

@ -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) {

View File

@ -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

18
ui/src/normalizers/id.js Normal file
View File

@ -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

View File

@ -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
}

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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 = () => {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
}),

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}

View File

@ -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)

View File

@ -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: '',
}

View File

@ -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
}

View File

@ -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