Merge branch 'master' into russorat-patch-1

pull/2865/head
Deniz Kusefoglu 2018-02-27 17:05:24 -08:00
commit 6c9cc38988
82 changed files with 4977 additions and 1580 deletions

View File

@ -1,6 +1,8 @@
## v1.4.2.0 [unreleased]
### Features
1. [#2837](https://github.com/influxdata/chronograf/pull/2837): Prevent execution of queries in cells that are not in view on the dashboard page
1. [#2837] (https://github.com/influxdata/chronograf/pull/2837): Prevent execution of queries in cells that are not in view on the dashboard page
1. [#2829] (https://github.com/influxdata/chronograf/pull/2829): Add an optional persistent legend which can toggle series visibility to dashboard cells
### UI Improvements
1. [#2848](https://github.com/influxdata/chronograf/pull/2848): Add ability to set a prefix and suffix on Single Stat and Gauge cell types
1. [#2831](https://github.com/influxdata/chronograf/pull/2831): Rename 'Create Alerts' page to 'Manage Tasks'; Redesign page to improve clarity of purpose

View File

@ -34,6 +34,7 @@ const (
ErrOrganizationAlreadyExists = Error("organization already exists")
ErrCannotDeleteDefaultOrganization = Error("cannot delete default organization")
ErrConfigNotFound = Error("cannot find configuration")
ErrAnnotationNotFound = Error("annotation not found")
)
// Error is a domain error encountered while processing chronograf requests
@ -98,12 +99,24 @@ type TSDBStatus interface {
Type(context.Context) (string, error)
}
// Point is a field set in a series
type Point struct {
Database string
RetentionPolicy string
Measurement string
Time int64
Tags map[string]string
Fields map[string]interface{}
}
// TimeSeries represents a queryable time series database.
type TimeSeries interface {
// Query retrieves time series data from the database.
Query(context.Context, Query) (Response, error)
// Connect will connect to the time series using the information in `Source`.
Connect(context.Context, *Source) error
// Query retrieves time series data from the database.
Query(context.Context, Query) (Response, error)
// Write records points into a series
Write(context.Context, []Point) error
// UsersStore represents the user accounts within the TimeSeries database
Users(context.Context) UsersStore
// Permissions returns all valid names permissions in this database
@ -170,6 +183,7 @@ type Query struct {
Command string `json:"query"` // Command is the query itself
DB string `json:"db,omitempty"` // DB is optional and if empty will not be used.
RP string `json:"rp,omitempty"` // RP is a retention policy and optional; if empty will not be used.
Epoch string `json:"epoch,omitempty"` // Epoch is the time format for the return results
TemplateVars []TemplateVar `json:"tempVars,omitempty"` // TemplateVars are template variables to replace within an InfluxQL query
Wheres []string `json:"wheres,omitempty"` // Wheres restricts the query to certain attributes
GroupBys []string `json:"groupbys,omitempty"` // GroupBys collate the query by these tags
@ -235,7 +249,7 @@ type SourcesStore interface {
Update(context.Context, Source) error
}
// DBRP is a database and retention policy for a kapacitor task
// DBRP represents a database and retention policy for a time series source
type DBRP struct {
DB string `json:"db"`
RP string `json:"rp"`
@ -471,6 +485,24 @@ type Databases interface {
DropRP(context.Context, string, string) error
}
// Annotation represents a time-based metadata associated with a source
type Annotation struct {
ID string // ID is the unique annotation identifier
StartTime time.Time // StartTime starts the annotation
EndTime time.Time // EndTime ends the annotation
Text string // Text is the associated user-facing text describing the annotation
Type string // Type describes the kind of annotation
}
// AnnotationStore represents storage and retrieval of annotations
type AnnotationStore interface {
All(ctx context.Context, start, stop time.Time) ([]Annotation, error) // All lists all Annotations between start and stop
Add(context.Context, *Annotation) (*Annotation, error) // Add creates a new annotation in the store
Delete(ctx context.Context, id string) error // Delete removes the annotation from the store
Get(ctx context.Context, id string) (*Annotation, error) // Get retrieves an annotation
Update(context.Context, *Annotation) error // Update replaces annotation
}
// DashboardID is the dashboard ID
type DashboardID int

View File

@ -144,6 +144,14 @@ func (c *Client) Query(ctx context.Context, q chronograf.Query) (chronograf.Resp
return c.nextDataNode().Query(ctx, q)
}
// Write records points into a time series
func (c *Client) Write(ctx context.Context, points []chronograf.Point) error {
if !c.opened {
return chronograf.ErrUninitialized
}
return c.nextDataNode().Write(ctx, points)
}
// Users is the interface to the users within Influx Enterprise
func (c *Client) Users(context.Context) chronograf.UsersStore {
return c.UsersStore

View File

@ -118,6 +118,10 @@ func (ts *TimeSeries) Connect(ctx context.Context, src *chronograf.Source) error
return nil
}
func (ts *TimeSeries) Write(ctx context.Context, points []chronograf.Point) error {
return nil
}
func (ts *TimeSeries) Users(ctx context.Context) chronograf.UsersStore {
return nil
}

271
influx/annotations.go Normal file
View File

@ -0,0 +1,271 @@
package influx
import (
"bytes"
"context"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/influxdata/chronograf/id"
"github.com/influxdata/chronograf"
)
const (
// AllAnnotations returns all annotations from the chronograf database
AllAnnotations = `SELECT "start_time", "modified_time_ns", "text", "type", "id" FROM "annotations" WHERE "deleted"=false AND time >= %dns and "start_time" <= %d ORDER BY time DESC`
// GetAnnotationID returns all annotations from the chronograf database where id is %s
GetAnnotationID = `SELECT "start_time", "modified_time_ns", "text", "type", "id" FROM "annotations" WHERE "id"='%s' AND "deleted"=false ORDER BY time DESC`
// AnnotationsDB is chronograf. Perhaps later we allow this to be changed
AnnotationsDB = "chronograf"
// DefaultRP is autogen. Perhaps later we allow this to be changed
DefaultRP = "autogen"
// DefaultMeasurement is annotations.
DefaultMeasurement = "annotations"
)
var _ chronograf.AnnotationStore = &AnnotationStore{}
// AnnotationStore stores annotations within InfluxDB
type AnnotationStore struct {
client chronograf.TimeSeries
id chronograf.ID
now Now
}
// NewAnnotationStore constructs an annoation store with a client
func NewAnnotationStore(client chronograf.TimeSeries) *AnnotationStore {
return &AnnotationStore{
client: client,
id: &id.UUID{},
now: time.Now,
}
}
// All lists all Annotations
func (a *AnnotationStore) All(ctx context.Context, start, stop time.Time) ([]chronograf.Annotation, error) {
return a.queryAnnotations(ctx, fmt.Sprintf(AllAnnotations, start.UnixNano(), stop.UnixNano()))
}
// Get retrieves an annotation
func (a *AnnotationStore) Get(ctx context.Context, id string) (*chronograf.Annotation, error) {
annos, err := a.queryAnnotations(ctx, fmt.Sprintf(GetAnnotationID, id))
if err != nil {
return nil, err
}
if len(annos) == 0 {
return nil, chronograf.ErrAnnotationNotFound
}
return &annos[0], nil
}
// Add creates a new annotation in the store
func (a *AnnotationStore) Add(ctx context.Context, anno *chronograf.Annotation) (*chronograf.Annotation, error) {
var err error
anno.ID, err = a.id.Generate()
if err != nil {
return nil, err
}
return anno, a.client.Write(ctx, []chronograf.Point{
toPoint(anno, a.now()),
})
}
// Delete removes the annotation from the store
func (a *AnnotationStore) Delete(ctx context.Context, id string) error {
cur, err := a.Get(ctx, id)
if err != nil {
return err
}
return a.client.Write(ctx, []chronograf.Point{
toDeletedPoint(cur, a.now()),
})
}
// Update replaces annotation; if the annotation's time is different, it
// also removes the previous annotation
func (a *AnnotationStore) Update(ctx context.Context, anno *chronograf.Annotation) error {
cur, err := a.Get(ctx, anno.ID)
if err != nil {
return err
}
if err := a.client.Write(ctx, []chronograf.Point{toPoint(anno, a.now())}); err != nil {
return err
}
// If the updated annotation has a different time, then, we must
// delete the previous annotation
if !cur.EndTime.Equal(anno.EndTime) {
return a.client.Write(ctx, []chronograf.Point{
toDeletedPoint(cur, a.now()),
})
}
return nil
}
// queryAnnotations queries the chronograf db and produces all annotations
func (a *AnnotationStore) queryAnnotations(ctx context.Context, query string) ([]chronograf.Annotation, error) {
res, err := a.client.Query(ctx, chronograf.Query{
Command: query,
DB: AnnotationsDB,
Epoch: "ns",
})
if err != nil {
return nil, err
}
octets, err := res.MarshalJSON()
if err != nil {
return nil, err
}
results := influxResults{}
d := json.NewDecoder(bytes.NewReader(octets))
d.UseNumber()
if err := d.Decode(&results); err != nil {
return nil, err
}
return results.Annotations()
}
func toPoint(anno *chronograf.Annotation, now time.Time) chronograf.Point {
return chronograf.Point{
Database: AnnotationsDB,
RetentionPolicy: DefaultRP,
Measurement: DefaultMeasurement,
Time: anno.EndTime.UnixNano(),
Tags: map[string]string{
"id": anno.ID,
},
Fields: map[string]interface{}{
"deleted": false,
"start_time": anno.StartTime.UnixNano(),
"modified_time_ns": int64(now.UnixNano()),
"text": anno.Text,
"type": anno.Type,
},
}
}
func toDeletedPoint(anno *chronograf.Annotation, now time.Time) chronograf.Point {
return chronograf.Point{
Database: AnnotationsDB,
RetentionPolicy: DefaultRP,
Measurement: DefaultMeasurement,
Time: anno.EndTime.UnixNano(),
Tags: map[string]string{
"id": anno.ID,
},
Fields: map[string]interface{}{
"deleted": true,
"start_time": int64(0),
"modified_time_ns": int64(now.UnixNano()),
"text": "",
"type": "",
},
}
}
type value []interface{}
func (v value) Int64(idx int) (int64, error) {
if idx >= len(v) {
return 0, fmt.Errorf("index %d does not exist in values", idx)
}
n, ok := v[idx].(json.Number)
if !ok {
return 0, fmt.Errorf("value at index %d is not int64, but, %T", idx, v[idx])
}
return n.Int64()
}
func (v value) Time(idx int) (time.Time, error) {
tm, err := v.Int64(idx)
if err != nil {
return time.Time{}, err
}
return time.Unix(0, tm), nil
}
func (v value) String(idx int) (string, error) {
if idx >= len(v) {
return "", fmt.Errorf("index %d does not exist in values", idx)
}
str, ok := v[idx].(string)
if !ok {
return "", fmt.Errorf("value at index %d is not string, but, %T", idx, v[idx])
}
return str, nil
}
type influxResults []struct {
Series []struct {
Values []value `json:"values"`
} `json:"series"`
}
// annotationResult is an intermediate struct to track the latest modified
// time of an annotation
type annotationResult struct {
chronograf.Annotation
// modTime is bookkeeping to handle the case when an update fails; the latest
// modTime will be the record returned
modTime int64
}
// Annotations converts AllAnnotations query to annotations
func (r *influxResults) Annotations() (res []chronograf.Annotation, err error) {
annos := map[string]annotationResult{}
for _, u := range *r {
for _, s := range u.Series {
for _, v := range s.Values {
anno := annotationResult{}
if anno.EndTime, err = v.Time(0); err != nil {
return
}
if anno.StartTime, err = v.Time(1); err != nil {
return
}
if anno.modTime, err = v.Int64(2); err != nil {
return
}
if anno.Text, err = v.String(3); err != nil {
return
}
if anno.Type, err = v.String(4); err != nil {
return
}
if anno.ID, err = v.String(5); err != nil {
return
}
// If there are two annotations with the same id, take
// the annotation with the latest modification time
// This is to prevent issues when an update or delete fails.
// Updates and deletes are multiple step queries.
prev, ok := annos[anno.ID]
if !ok || anno.modTime > prev.modTime {
annos[anno.ID] = anno
}
}
}
}
res = []chronograf.Annotation{}
for _, a := range annos {
res = append(res, a.Annotation)
}
sort.Slice(res, func(i int, j int) bool {
return res[i].StartTime.Before(res[j].StartTime) || res[i].ID < res[j].ID
})
return res, err
}

665
influx/annotations_test.go Normal file
View File

@ -0,0 +1,665 @@
package influx
import (
"context"
"encoding/json"
"fmt"
"reflect"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/mocks"
)
func Test_toPoint(t *testing.T) {
tests := []struct {
name string
anno *chronograf.Annotation
now time.Time
want chronograf.Point
}{
0: {
name: "convert annotation to point w/o start and end times",
anno: &chronograf.Annotation{
ID: "1",
Text: "mytext",
Type: "mytype",
},
now: time.Unix(0, 0),
want: chronograf.Point{
Database: AnnotationsDB,
RetentionPolicy: DefaultRP,
Measurement: DefaultMeasurement,
Time: time.Time{}.UnixNano(),
Tags: map[string]string{
"id": "1",
},
Fields: map[string]interface{}{
"deleted": false,
"start_time": time.Time{}.UnixNano(),
"modified_time_ns": int64(time.Unix(0, 0).UnixNano()),
"text": "mytext",
"type": "mytype",
},
},
},
1: {
name: "convert annotation to point with start/end time",
anno: &chronograf.Annotation{
ID: "1",
Text: "mytext",
Type: "mytype",
StartTime: time.Unix(100, 0),
EndTime: time.Unix(200, 0),
},
now: time.Unix(0, 0),
want: chronograf.Point{
Database: AnnotationsDB,
RetentionPolicy: DefaultRP,
Measurement: DefaultMeasurement,
Time: time.Unix(200, 0).UnixNano(),
Tags: map[string]string{
"id": "1",
},
Fields: map[string]interface{}{
"deleted": false,
"start_time": time.Unix(100, 0).UnixNano(),
"modified_time_ns": int64(time.Unix(0, 0).UnixNano()),
"text": "mytext",
"type": "mytype",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := toPoint(tt.anno, tt.now); !reflect.DeepEqual(got, tt.want) {
t.Errorf("toPoint() = %v, want %v", got, tt.want)
}
})
}
}
func Test_toDeletedPoint(t *testing.T) {
tests := []struct {
name string
anno *chronograf.Annotation
now time.Time
want chronograf.Point
}{
0: {
name: "convert annotation to point w/o start and end times",
anno: &chronograf.Annotation{
ID: "1",
EndTime: time.Unix(0, 0),
},
now: time.Unix(0, 0),
want: chronograf.Point{
Database: AnnotationsDB,
RetentionPolicy: DefaultRP,
Measurement: DefaultMeasurement,
Time: 0,
Tags: map[string]string{
"id": "1",
},
Fields: map[string]interface{}{
"deleted": true,
"start_time": int64(0),
"modified_time_ns": int64(0),
"text": "",
"type": "",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := toDeletedPoint(tt.anno, tt.now); !cmp.Equal(got, tt.want) {
t.Errorf("toDeletedPoint() = %s", cmp.Diff(got, tt.want))
}
})
}
}
func Test_value_Int64(t *testing.T) {
tests := []struct {
name string
v value
idx int
want int64
wantErr bool
}{
{
name: "index out of range returns error",
idx: 1,
wantErr: true,
},
{
name: "converts a string to int64",
v: value{
json.Number("1"),
},
idx: 0,
want: int64(1),
},
{
name: "when not a json.Number, return error",
v: value{
"howdy",
},
idx: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.v.Int64(tt.idx)
if (err != nil) != tt.wantErr {
t.Errorf("value.Int64() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("value.Int64() = %v, want %v", got, tt.want)
}
})
}
}
func Test_value_Time(t *testing.T) {
tests := []struct {
name string
v value
idx int
want time.Time
wantErr bool
}{
{
name: "index out of range returns error",
idx: 1,
wantErr: true,
},
{
name: "converts a string to int64",
v: value{
json.Number("1"),
},
idx: 0,
want: time.Unix(0, 1),
},
{
name: "when not a json.Number, return error",
v: value{
"howdy",
},
idx: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.v.Time(tt.idx)
if (err != nil) != tt.wantErr {
t.Errorf("value.Time() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("value.Time() = %v, want %v", got, tt.want)
}
})
}
}
func Test_value_String(t *testing.T) {
tests := []struct {
name string
v value
idx int
want string
wantErr bool
}{
{
name: "index out of range returns error",
idx: 1,
wantErr: true,
},
{
name: "converts a string",
v: value{
"howdy",
},
idx: 0,
want: "howdy",
},
{
name: "when not a string, return error",
v: value{
0,
},
idx: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.v.String(tt.idx)
if (err != nil) != tt.wantErr {
t.Errorf("value.String() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("value.String() = %v, want %v", got, tt.want)
}
})
}
}
func TestAnnotationStore_queryAnnotations(t *testing.T) {
type args struct {
ctx context.Context
query string
}
tests := []struct {
name string
client chronograf.TimeSeries
args args
want []chronograf.Annotation
wantErr bool
}{
{
name: "query error returns an error",
client: &mocks.TimeSeries{
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return nil, fmt.Errorf("error")
},
},
wantErr: true,
},
{
name: "response marshal error returns an error",
client: &mocks.TimeSeries{
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return mocks.NewResponse("", fmt.Errorf("")), nil
},
},
wantErr: true,
},
{
name: "Bad JSON returns an error",
client: &mocks.TimeSeries{
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return mocks.NewResponse(`{}`, nil), nil
},
},
wantErr: true,
},
{
name: "Incorrect fields returns error",
client: &mocks.TimeSeries{
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return mocks.NewResponse(`[{
"series": [
{
"name": "annotations",
"columns": [
"time",
"deleted",
"id",
"modified_time_ns",
"start_time",
"text",
"type"
],
"values": [
[
1516920117000000000,
true,
"4ba9f836-20e8-4b8e-af51-e1363edd7b6d",
1517425994487495051,
0,
"",
""
]
]
}
]
}
]}]`, nil), nil
},
},
wantErr: true,
},
{
name: "two annotation response",
client: &mocks.TimeSeries{
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return mocks.NewResponse(`[
{
"series": [
{
"name": "annotations",
"columns": [
"time",
"start_time",
"modified_time_ns",
"text",
"type",
"id"
],
"values": [
[
1516920177345000000,
0,
1516989242129417403,
"mytext",
"mytype",
"ecf3a75d-f1c0-40e8-9790-902701467e92"
],
[
1516920177345000000,
0,
1517425914433539296,
"mytext2",
"mytype2",
"ea0aa94b-969a-4cd5-912a-5db61d502268"
]
]
}
]
}
]`, nil), nil
},
},
want: []chronograf.Annotation{
{
EndTime: time.Unix(0, 1516920177345000000),
StartTime: time.Unix(0, 0),
Text: "mytext2",
Type: "mytype2",
ID: "ea0aa94b-969a-4cd5-912a-5db61d502268",
},
{
EndTime: time.Unix(0, 1516920177345000000),
StartTime: time.Unix(0, 0),
Text: "mytext",
Type: "mytype",
ID: "ecf3a75d-f1c0-40e8-9790-902701467e92",
},
},
},
{
name: "same id returns one",
client: &mocks.TimeSeries{
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return mocks.NewResponse(`[
{
"series": [
{
"name": "annotations",
"columns": [
"time",
"start_time",
"modified_time_ns",
"text",
"type",
"id"
],
"values": [
[
1516920177345000000,
0,
1516989242129417403,
"mytext",
"mytype",
"ea0aa94b-969a-4cd5-912a-5db61d502268"
],
[
1516920177345000000,
0,
1517425914433539296,
"mytext2",
"mytype2",
"ea0aa94b-969a-4cd5-912a-5db61d502268"
]
]
}
]
}
]`, nil), nil
},
},
want: []chronograf.Annotation{
{
EndTime: time.Unix(0, 1516920177345000000),
StartTime: time.Unix(0, 0),
Text: "mytext2",
Type: "mytype2",
ID: "ea0aa94b-969a-4cd5-912a-5db61d502268",
},
},
},
{
name: "no responses returns empty array",
client: &mocks.TimeSeries{
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return mocks.NewResponse(`[ { } ]`, nil), nil
},
},
want: []chronograf.Annotation{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := &AnnotationStore{
client: tt.client,
}
got, err := a.queryAnnotations(tt.args.ctx, tt.args.query)
if (err != nil) != tt.wantErr {
t.Errorf("AnnotationStore.queryAnnotations() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("AnnotationStore.queryAnnotations() = %v, want %v", got, tt.want)
}
})
}
}
func TestAnnotationStore_Update(t *testing.T) {
type fields struct {
client chronograf.TimeSeries
now Now
}
type args struct {
ctx context.Context
anno *chronograf.Annotation
}
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
{
name: "no responses returns error",
fields: fields{
client: &mocks.TimeSeries{
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return mocks.NewResponse(`[ { } ]`, nil), nil
},
WriteF: func(context.Context, []chronograf.Point) error {
return nil
},
},
},
args: args{
ctx: context.Background(),
anno: &chronograf.Annotation{
ID: "1",
},
},
wantErr: true,
},
{
name: "error writing returns error",
fields: fields{
now: func() time.Time { return time.Time{} },
client: &mocks.TimeSeries{
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return mocks.NewResponse(`[
{
"series": [
{
"name": "annotations",
"columns": [
"time",
"start_time",
"modified_time_ns",
"text",
"type",
"id"
],
"values": [
[
1516920177345000000,
0,
1516989242129417403,
"mytext",
"mytype",
"ecf3a75d-f1c0-40e8-9790-902701467e92"
],
[
1516920177345000000,
0,
1517425914433539296,
"mytext2",
"mytype2",
"ea0aa94b-969a-4cd5-912a-5db61d502268"
]
]
}
]
}
]`, nil), nil
},
WriteF: func(context.Context, []chronograf.Point) error {
return fmt.Errorf("error")
},
},
},
args: args{
ctx: context.Background(),
anno: &chronograf.Annotation{
ID: "1",
},
},
wantErr: true,
},
{
name: "Update with delete",
fields: fields{
now: func() time.Time { return time.Time{} },
client: &mocks.TimeSeries{
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return mocks.NewResponse(`[
{
"series": [
{
"name": "annotations",
"columns": [
"time",
"start_time",
"modified_time_ns",
"text",
"type",
"id"
],
"values": [
[
1516920177345000000,
0,
1516989242129417403,
"mytext",
"mytype",
"ecf3a75d-f1c0-40e8-9790-902701467e92"
]
]
}
]
}
]`, nil), nil
},
WriteF: func(context.Context, []chronograf.Point) error {
return nil
},
},
},
args: args{
ctx: context.Background(),
anno: &chronograf.Annotation{
ID: "1",
},
},
},
{
name: "Update with delete no delete",
fields: fields{
now: func() time.Time { return time.Time{} },
client: &mocks.TimeSeries{
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return mocks.NewResponse(`[
{
"series": [
{
"name": "annotations",
"columns": [
"time",
"start_time",
"modified_time_ns",
"text",
"type",
"id"
],
"values": [
[
1516920177345000000,
0,
1516989242129417403,
"mytext",
"mytype",
"ecf3a75d-f1c0-40e8-9790-902701467e92"
]
]
}
]
}
]`, nil), nil
},
WriteF: func(context.Context, []chronograf.Point) error {
return nil
},
},
},
args: args{
ctx: context.Background(),
anno: &chronograf.Annotation{
ID: "ecf3a75d-f1c0-40e8-9790-902701467e92",
EndTime: time.Unix(0, 1516920177345000000),
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := &AnnotationStore{
client: tt.fields.client,
now: tt.fields.now,
}
if err := a.Update(tt.args.ctx, tt.args.anno); (err != nil) != tt.wantErr {
t.Errorf("AnnotationStore.Update() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@ -77,9 +77,6 @@ func (b *BearerJWT) Token(username string) (string, error) {
return JWT(username, b.SharedSecret, b.Now)
}
// Now returns the current time
type Now func() time.Time
// JWT returns a token string accepted by InfluxDB using the sharedSecret as an Authorization: Bearer header
func JWT(username, sharedSecret string, now Now) (string, error) {
token := &jwt.Token{

View File

@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
@ -73,7 +74,10 @@ func (c *Client) query(u *url.URL, q chronograf.Query) (chronograf.Response, err
params.Set("q", command)
params.Set("db", q.DB)
params.Set("rp", q.RP)
params.Set("epoch", "ms") // TODO(timraymond): set this based on analysis
params.Set("epoch", "ms")
if q.Epoch != "" {
params.Set("epoch", q.Epoch)
}
req.URL.RawQuery = params.Encode()
if c.Authorizer != nil {
@ -261,3 +265,105 @@ func (c *Client) ping(u *url.URL) (string, string, error) {
return version, chronograf.InfluxDB, nil
}
// Write POSTs line protocol to a database and retention policy
func (c *Client) Write(ctx context.Context, points []chronograf.Point) error {
for _, point := range points {
if err := c.writePoint(ctx, &point); err != nil {
return err
}
}
return nil
}
func (c *Client) writePoint(ctx context.Context, point *chronograf.Point) error {
lp, err := toLineProtocol(point)
if err != nil {
return err
}
err = c.write(ctx, c.URL, point.Database, point.RetentionPolicy, lp)
if err == nil {
return nil
}
// Some influxdb errors should not be treated as errors
if strings.Contains(err.Error(), "hinted handoff queue not empty") {
// This is an informational message
return nil
}
// If the database was not found, try to recreate it:
if strings.Contains(err.Error(), "database not found") {
_, err = c.CreateDB(ctx, &chronograf.Database{
Name: point.Database,
})
if err != nil {
return err
}
// retry the write
return c.write(ctx, c.URL, point.Database, point.RetentionPolicy, lp)
}
return err
}
func (c *Client) write(ctx context.Context, u *url.URL, db, rp, lp string) error {
u.Path = "write"
req, err := http.NewRequest("POST", u.String(), strings.NewReader(lp))
if err != nil {
return err
}
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
if c.Authorizer != nil {
if err := c.Authorizer.Set(req); err != nil {
return err
}
}
params := req.URL.Query()
params.Set("db", db)
params.Set("rp", rp)
req.URL.RawQuery = params.Encode()
hc := &http.Client{}
if c.InsecureSkipVerify {
hc.Transport = skipVerifyTransport
} else {
hc.Transport = defaultTransport
}
errChan := make(chan (error))
go func() {
resp, err := hc.Do(req)
if err != nil {
errChan <- err
return
}
if resp.StatusCode == http.StatusNoContent {
errChan <- nil
return
}
defer resp.Body.Close()
var response Response
dec := json.NewDecoder(resp.Body)
err = dec.Decode(&response)
if err != nil && err.Error() != "EOF" {
errChan <- err
return
}
errChan <- errors.New(response.Err)
return
}()
select {
case err := <-errChan:
return err
case <-ctx.Done():
return chronograf.ErrUpstreamTimeout
}
}

View File

@ -14,6 +14,7 @@ import (
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/influx"
"github.com/influxdata/chronograf/log"
"github.com/influxdata/chronograf/mocks"
)
// NewClient initializes an HTTP Client for InfluxDB.
@ -395,3 +396,153 @@ func TestClient_Roles(t *testing.T) {
t.Errorf("Client.Roles() want error")
}
}
func TestClient_write(t *testing.T) {
type fields struct {
Authorizer influx.Authorizer
InsecureSkipVerify bool
Logger chronograf.Logger
}
type args struct {
ctx context.Context
point chronograf.Point
}
tests := []struct {
name string
fields fields
args args
body string
wantErr bool
}{
{
name: "write point to influxdb",
fields: fields{
Logger: mocks.NewLogger(),
},
args: args{
ctx: context.Background(),
point: chronograf.Point{
Database: "mydb",
RetentionPolicy: "myrp",
Measurement: "mymeas",
Time: 10,
Tags: map[string]string{
"tag1": "value1",
"tag2": "value2",
},
Fields: map[string]interface{}{
"field1": "value1",
},
},
},
},
{
name: "point without fields",
args: args{
ctx: context.Background(),
point: chronograf.Point{},
},
wantErr: true,
},
{
name: "hinted handoff errors are not errors really.",
fields: fields{
Logger: mocks.NewLogger(),
},
args: args{
ctx: context.Background(),
point: chronograf.Point{
Database: "mydb",
RetentionPolicy: "myrp",
Measurement: "mymeas",
Time: 10,
Tags: map[string]string{
"tag1": "value1",
"tag2": "value2",
},
Fields: map[string]interface{}{
"field1": "value1",
},
},
},
body: `{"error":"hinted handoff queue not empty"}`,
},
{
name: "database not found creates a new db",
fields: fields{
Logger: mocks.NewLogger(),
},
args: args{
ctx: context.Background(),
point: chronograf.Point{
Database: "mydb",
RetentionPolicy: "myrp",
Measurement: "mymeas",
Time: 10,
Tags: map[string]string{
"tag1": "value1",
"tag2": "value2",
},
Fields: map[string]interface{}{
"field1": "value1",
},
},
},
body: `{"error":"database not found"}`,
},
{
name: "error from database reported",
fields: fields{
Logger: mocks.NewLogger(),
},
args: args{
ctx: context.Background(),
point: chronograf.Point{
Database: "mydb",
RetentionPolicy: "myrp",
Measurement: "mymeas",
Time: 10,
Tags: map[string]string{
"tag1": "value1",
"tag2": "value2",
},
Fields: map[string]interface{}{
"field1": "value1",
},
},
},
body: `{"error":"oh no!"}`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
retry := 0 // if the retry is > 0 then we don't error
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.RequestURI, "/write") {
if tt.body == "" || retry > 0 {
rw.WriteHeader(http.StatusNoContent)
return
}
retry++
rw.WriteHeader(http.StatusBadRequest)
rw.Write([]byte(tt.body))
return
}
rw.WriteHeader(http.StatusOK)
rw.Write([]byte(`{"results":[{}]}`))
}))
defer ts.Close()
u, _ := url.Parse(ts.URL)
c := &influx.Client{
URL: u,
Authorizer: tt.fields.Authorizer,
InsecureSkipVerify: tt.fields.InsecureSkipVerify,
Logger: tt.fields.Logger,
}
if err := c.Write(tt.args.ctx, []chronograf.Point{tt.args.point}); (err != nil) != tt.wantErr {
t.Errorf("Client.write() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

84
influx/lineprotocol.go Normal file
View File

@ -0,0 +1,84 @@
package influx
import (
"fmt"
"sort"
"strings"
"github.com/influxdata/chronograf"
)
var (
escapeMeasurement = strings.NewReplacer(
`,` /* to */, `\,`,
` ` /* to */, `\ `,
)
escapeKeys = strings.NewReplacer(
`,` /* to */, `\,`,
`"` /* to */, `\"`,
` ` /* to */, `\ `,
`=` /* to */, `\=`,
)
escapeTagValues = strings.NewReplacer(
`,` /* to */, `\,`,
`"` /* to */, `\"`,
` ` /* to */, `\ `,
`=` /* to */, `\=`,
)
escapeFieldStrings = strings.NewReplacer(
`"` /* to */, `\"`,
`\` /* to */, `\\`,
)
)
func toLineProtocol(point *chronograf.Point) (string, error) {
measurement := escapeMeasurement.Replace(point.Measurement)
if len(measurement) == 0 {
return "", fmt.Errorf("measurement required to write point")
}
if len(point.Fields) == 0 {
return "", fmt.Errorf("at least one field required to write point")
}
tags := []string{}
for tag, value := range point.Tags {
if value != "" {
t := fmt.Sprintf("%s=%s", escapeKeys.Replace(tag), escapeTagValues.Replace(value))
tags = append(tags, t)
}
}
// it is faster to insert data into influx db if the tags are sorted
sort.Strings(tags)
fields := []string{}
for field, value := range point.Fields {
var format string
switch v := value.(type) {
case int64, int32, int16, int8, int:
format = fmt.Sprintf("%s=%di", escapeKeys.Replace(field), v)
case uint64, uint32, uint16, uint8, uint:
format = fmt.Sprintf("%s=%du", escapeKeys.Replace(field), v)
case float64, float32:
format = fmt.Sprintf("%s=%f", escapeKeys.Replace(field), v)
case string:
format = fmt.Sprintf(`%s="%s"`, escapeKeys.Replace(field), escapeFieldStrings.Replace(v))
case bool:
format = fmt.Sprintf("%s=%t", escapeKeys.Replace(field), v)
}
if format != "" {
fields = append(fields, format)
}
}
sort.Strings(fields)
lp := measurement
if len(tags) > 0 {
lp += fmt.Sprintf(",%s", strings.Join(tags, ","))
}
lp += fmt.Sprintf(" %s", strings.Join(fields, ","))
if point.Time != 0 {
lp += fmt.Sprintf(" %d", point.Time)
}
return lp, nil
}

129
influx/lineprotocol_test.go Normal file
View File

@ -0,0 +1,129 @@
package influx
import (
"testing"
"time"
"github.com/influxdata/chronograf"
)
func Test_toLineProtocol(t *testing.T) {
tests := []struct {
name string
point *chronograf.Point
want string
wantErr bool
}{
0: {
name: "requires a measurement",
point: &chronograf.Point{},
wantErr: true,
},
1: {
name: "requires at least one field",
point: &chronograf.Point{
Measurement: "telegraf",
},
wantErr: true,
},
2: {
name: "no tags produces line protocol",
point: &chronograf.Point{
Measurement: "telegraf",
Fields: map[string]interface{}{
"myfield": 1,
},
},
want: "telegraf myfield=1i",
},
3: {
name: "test all influx data types",
point: &chronograf.Point{
Measurement: "telegraf",
Fields: map[string]interface{}{
"int": 19,
"uint": uint(85),
"float": 88.0,
"string": "mph",
"time_machine": true,
"invalidField": time.Time{},
},
},
want: `telegraf float=88.000000,int=19i,string="mph",time_machine=true,uint=85u`,
},
4: {
name: "test all influx data types",
point: &chronograf.Point{
Measurement: "telegraf",
Tags: map[string]string{
"marty": "mcfly",
"doc": "brown",
},
Fields: map[string]interface{}{
"int": 19,
"uint": uint(85),
"float": 88.0,
"string": "mph",
"time_machine": true,
"invalidField": time.Time{},
},
Time: 497115501000000000,
},
want: `telegraf,doc=brown,marty=mcfly float=88.000000,int=19i,string="mph",time_machine=true,uint=85u 497115501000000000`,
},
5: {
name: "measurements with comma or spaces are escaped",
point: &chronograf.Point{
Measurement: "O Romeo, Romeo, wherefore art thou Romeo",
Tags: map[string]string{
"part": "JULIET",
},
Fields: map[string]interface{}{
"act": 2,
"scene": 2,
"page": 2,
"line": 33,
},
},
want: `O\ Romeo\,\ Romeo\,\ wherefore\ art\ thou\ Romeo,part=JULIET act=2i,line=33i,page=2i,scene=2i`,
},
6: {
name: "tags with comma, quota, space, equal are escaped",
point: &chronograf.Point{
Measurement: "quotes",
Tags: map[string]string{
"comma,": "comma,",
`quote"`: `quote"`,
"space ": `space "`,
"equal=": "equal=",
},
Fields: map[string]interface{}{
"myfield": 1,
},
},
want: `quotes,comma\,=comma\,,equal\==equal\=,quote\"=quote\",space\ =space\ \" myfield=1i`,
},
7: {
name: "fields with quotes or backslashes are escaped",
point: &chronograf.Point{
Measurement: "quotes",
Fields: map[string]interface{}{
`quote"\`: `quote"\`,
},
},
want: `quotes quote\"\="quote\"\\"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := toLineProtocol(tt.point)
if (err != nil) != tt.wantErr {
t.Errorf("toLineProtocol() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("toLineProtocol() = %v, want %v", got, tt.want)
}
})
}
}

6
influx/now.go Normal file
View File

@ -0,0 +1,6 @@
package influx
import "time"
// Now returns the current time
type Now func() time.Time

View File

@ -10,6 +10,7 @@ import (
"github.com/influxdata/influxdb/influxql"
)
// TimeRangeAsEpochNano extracs the min and max epoch times from the expression
func TimeRangeAsEpochNano(expr influxql.Expr, now time.Time) (min, max int64, err error) {
tmin, tmax, err := influxql.TimeRange(expr)
if err != nil {
@ -28,8 +29,10 @@ func TimeRangeAsEpochNano(expr influxql.Expr, now time.Time) (min, max int64, er
return
}
// WhereToken is used to parse the time expression from an influxql query
const WhereToken = "WHERE"
// ParseTime extracts the duration of the time range of the query
func ParseTime(influxQL string, now time.Time) (time.Duration, error) {
start := strings.Index(strings.ToUpper(influxQL), WhereToken)
if start == -1 {

View File

@ -9,6 +9,7 @@ import (
"github.com/influxdata/chronograf"
)
// SortTemplates the templates by size, then type, then value.
func SortTemplates(ts []chronograf.TemplateVar) []chronograf.TemplateVar {
sort.Slice(ts, func(i, j int) bool {
if len(ts[i].Values) != len(ts[j].Values) {
@ -82,6 +83,8 @@ func RenderTemplate(query string, t chronograf.TemplateVar, now time.Time) (stri
return query, nil
}
// AutoGroupBy generates the time to group by in order to decimate the number of
// points returned in a query
func AutoGroupBy(resolution, pixelsPerPoint int64, duration time.Duration) string {
// The function is: ((total_seconds * millisecond_converstion) / group_by) = pixels / 3
// Number of points given the pixels

View File

@ -126,7 +126,7 @@ func (c *Client) All(ctx context.Context) ([]chronograf.User, error) {
return users, nil
}
// Number of users in Influx
// Num is the number of users in DB
func (c *Client) Num(ctx context.Context) (int, error) {
all, err := c.All(ctx)
if err != nil {

View File

@ -113,7 +113,8 @@ func TestServer(t *testing.T) {
"permissions": "/chronograf/v1/sources/5000/permissions",
"users": "/chronograf/v1/sources/5000/users",
"roles": "/chronograf/v1/sources/5000/roles",
"databases": "/chronograf/v1/sources/5000/dbs"
"databases": "/chronograf/v1/sources/5000/dbs",
"annotations": "/chronograf/v1/sources/5000/annotations"
}
}
`,
@ -296,7 +297,8 @@ func TestServer(t *testing.T) {
"permissions": "/chronograf/v1/sources/5000/permissions",
"users": "/chronograf/v1/sources/5000/users",
"roles": "/chronograf/v1/sources/5000/roles",
"databases": "/chronograf/v1/sources/5000/dbs"
"databases": "/chronograf/v1/sources/5000/dbs",
"annotations": "/chronograf/v1/sources/5000/annotations"
}
}
]

20
mocks/response.go Normal file
View File

@ -0,0 +1,20 @@
package mocks
// NewResponse returns a mocked chronograf.Response
func NewResponse(res string, err error) *Response {
return &Response{
res: res,
err: err,
}
}
// Response is a mocked chronograf.Response
type Response struct {
res string
err error
}
// MarshalJSON returns the res and err as the fake response.
func (r *Response) MarshalJSON() ([]byte, error) {
return []byte(r.res), r.err
}

View File

@ -10,10 +10,12 @@ var _ chronograf.TimeSeries = &TimeSeries{}
// TimeSeries is a mockable chronograf time series by overriding the functions.
type TimeSeries struct {
// Query retrieves time series data from the database.
QueryF func(context.Context, chronograf.Query) (chronograf.Response, error)
// Connect will connect to the time series using the information in `Source`.
ConnectF func(context.Context, *chronograf.Source) error
// Query retrieves time series data from the database.
QueryF func(context.Context, chronograf.Query) (chronograf.Response, error)
// Write records points into the TimeSeries
WriteF func(context.Context, []chronograf.Point) error
// UsersStore represents the user accounts within the TimeSeries database
UsersF func(context.Context) chronograf.UsersStore
// Permissions returns all valid names permissions in this database
@ -27,14 +29,19 @@ func (t *TimeSeries) New(chronograf.Source, chronograf.Logger) (chronograf.TimeS
return t, nil
}
// Connect will connect to the time series using the information in `Source`.
func (t *TimeSeries) Connect(ctx context.Context, src *chronograf.Source) error {
return t.ConnectF(ctx, src)
}
// Query retrieves time series data from the database.
func (t *TimeSeries) Query(ctx context.Context, query chronograf.Query) (chronograf.Response, error) {
return t.QueryF(ctx, query)
}
// Connect will connect to the time series using the information in `Source`.
func (t *TimeSeries) Connect(ctx context.Context, src *chronograf.Source) error {
return t.ConnectF(ctx, src)
// Write records a point into the time series
func (t *TimeSeries) Write(ctx context.Context, points []chronograf.Point) error {
return t.WriteF(ctx, points)
}
// Users represents the user accounts within the TimeSeries database

452
server/annotations.go Normal file
View File

@ -0,0 +1,452 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/influx"
)
const (
since = "since"
until = "until"
timeMilliFormat = "2006-01-02T15:04:05.999Z07:00"
)
type annotationLinks struct {
Self string `json:"self"` // Self link mapping to this resource
}
type annotationResponse struct {
ID string `json:"id"` // ID is the unique annotation identifier
StartTime string `json:"startTime"` // StartTime in RFC3339 of the start of the annotation
EndTime string `json:"endTime"` // EndTime in RFC3339 of the end of the annotation
Text string `json:"text"` // Text is the associated user-facing text describing the annotation
Type string `json:"type"` // Type describes the kind of annotation
Links annotationLinks `json:"links"`
}
func newAnnotationResponse(src chronograf.Source, a *chronograf.Annotation) annotationResponse {
base := "/chronograf/v1/sources"
res := annotationResponse{
ID: a.ID,
StartTime: a.StartTime.UTC().Format(timeMilliFormat),
EndTime: a.EndTime.UTC().Format(timeMilliFormat),
Text: a.Text,
Type: a.Type,
Links: annotationLinks{
Self: fmt.Sprintf("%s/%d/annotations/%s", base, src.ID, a.ID),
},
}
if a.EndTime.IsZero() {
res.EndTime = ""
}
return res
}
type annotationsResponse struct {
Annotations []annotationResponse `json:"annotations"`
}
func newAnnotationsResponse(src chronograf.Source, as []chronograf.Annotation) annotationsResponse {
annotations := make([]annotationResponse, len(as))
for i, a := range as {
annotations[i] = newAnnotationResponse(src, &a)
}
return annotationsResponse{
Annotations: annotations,
}
}
func validAnnotationQuery(query url.Values) (startTime, stopTime time.Time, err error) {
start := query.Get(since)
if start == "" {
return time.Time{}, time.Time{}, fmt.Errorf("since parameter is required")
}
startTime, err = time.Parse(timeMilliFormat, start)
if err != nil {
return
}
// if until isn't stated, the default time is now
stopTime = time.Now()
stop := query.Get(until)
if stop != "" {
stopTime, err = time.Parse(timeMilliFormat, stop)
if err != nil {
return time.Time{}, time.Time{}, err
}
}
if startTime.After(stopTime) {
startTime, stopTime = stopTime, startTime
}
return startTime, stopTime, nil
}
// Annotations returns all annotations within the annotations store
func (s *Service) Annotations(w http.ResponseWriter, r *http.Request) {
id, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger)
return
}
start, stop, err := validAnnotationQuery(r.URL.Query())
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger)
return
}
ctx := r.Context()
src, err := s.Store.Sources(ctx).Get(ctx, id)
if err != nil {
notFound(w, id, s.Logger)
return
}
ts, err := s.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err)
Error(w, http.StatusBadRequest, msg, s.Logger)
return
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err)
Error(w, http.StatusBadRequest, msg, s.Logger)
return
}
store := influx.NewAnnotationStore(ts)
annotations, err := store.All(ctx, start, stop)
if err != nil {
msg := fmt.Errorf("Error loading annotations: %v", err)
unknownErrorWithMessage(w, msg, s.Logger)
return
}
res := newAnnotationsResponse(src, annotations)
encodeJSON(w, http.StatusOK, res, s.Logger)
}
// Annotation returns a specified annotation id within the annotations store
func (s *Service) Annotation(w http.ResponseWriter, r *http.Request) {
id, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger)
return
}
annoID, err := paramStr("aid", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger)
return
}
ctx := r.Context()
src, err := s.Store.Sources(ctx).Get(ctx, id)
if err != nil {
notFound(w, id, s.Logger)
return
}
ts, err := s.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err)
Error(w, http.StatusBadRequest, msg, s.Logger)
return
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err)
Error(w, http.StatusBadRequest, msg, s.Logger)
return
}
store := influx.NewAnnotationStore(ts)
anno, err := store.Get(ctx, annoID)
if err != nil {
if err != chronograf.ErrAnnotationNotFound {
msg := fmt.Errorf("Error loading annotation: %v", err)
unknownErrorWithMessage(w, msg, s.Logger)
return
}
Error(w, http.StatusBadRequest, err.Error(), s.Logger)
return
}
res := newAnnotationResponse(src, anno)
encodeJSON(w, http.StatusOK, res, s.Logger)
}
type newAnnotationRequest struct {
StartTime time.Time
EndTime time.Time
Text string `json:"text,omitempty"` // Text is the associated user-facing text describing the annotation
Type string `json:"type,omitempty"` // Type describes the kind of annotation
}
func (ar *newAnnotationRequest) UnmarshalJSON(data []byte) error {
type Alias newAnnotationRequest
aux := &struct {
StartTime string `json:"startTime"` // StartTime is the time in rfc3339 milliseconds
EndTime string `json:"endTime"` // EndTime is the time in rfc3339 milliseconds
*Alias
}{
Alias: (*Alias)(ar),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
var err error
ar.StartTime, err = time.Parse(timeMilliFormat, aux.StartTime)
if err != nil {
return err
}
ar.EndTime, err = time.Parse(timeMilliFormat, aux.EndTime)
if err != nil {
return err
}
if ar.StartTime.After(ar.EndTime) {
ar.StartTime, ar.EndTime = ar.EndTime, ar.StartTime
}
return nil
}
func (ar *newAnnotationRequest) Annotation() *chronograf.Annotation {
return &chronograf.Annotation{
StartTime: ar.StartTime,
EndTime: ar.EndTime,
Text: ar.Text,
Type: ar.Type,
}
}
// NewAnnotation adds the annotation from a POST body to the annotations store
func (s *Service) NewAnnotation(w http.ResponseWriter, r *http.Request) {
id, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger)
return
}
ctx := r.Context()
src, err := s.Store.Sources(ctx).Get(ctx, id)
if err != nil {
notFound(w, id, s.Logger)
return
}
ts, err := s.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err)
Error(w, http.StatusBadRequest, msg, s.Logger)
return
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err)
Error(w, http.StatusBadRequest, msg, s.Logger)
return
}
var req newAnnotationRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, s.Logger)
return
}
store := influx.NewAnnotationStore(ts)
anno, err := store.Add(ctx, req.Annotation())
if err != nil {
if err == chronograf.ErrUpstreamTimeout {
msg := "Timeout waiting for response"
Error(w, http.StatusRequestTimeout, msg, s.Logger)
return
}
Error(w, http.StatusBadRequest, err.Error(), s.Logger)
return
}
res := newAnnotationResponse(src, anno)
location(w, res.Links.Self)
encodeJSON(w, http.StatusCreated, res, s.Logger)
}
// RemoveAnnotation removes the annotation from the time series source
func (s *Service) RemoveAnnotation(w http.ResponseWriter, r *http.Request) {
id, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger)
return
}
annoID, err := paramStr("aid", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger)
return
}
ctx := r.Context()
src, err := s.Store.Sources(ctx).Get(ctx, id)
if err != nil {
notFound(w, id, s.Logger)
return
}
ts, err := s.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err)
Error(w, http.StatusBadRequest, msg, s.Logger)
return
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err)
Error(w, http.StatusBadRequest, msg, s.Logger)
return
}
store := influx.NewAnnotationStore(ts)
if err = store.Delete(ctx, annoID); err != nil {
if err == chronograf.ErrUpstreamTimeout {
msg := "Timeout waiting for response"
Error(w, http.StatusRequestTimeout, msg, s.Logger)
return
}
Error(w, http.StatusBadRequest, err.Error(), s.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}
type updateAnnotationRequest struct {
StartTime *time.Time `json:"startTime,omitempty"` // StartTime is the time in rfc3339 milliseconds
EndTime *time.Time `json:"endTime,omitempty"` // EndTime is the time in rfc3339 milliseconds
Text *string `json:"text,omitempty"` // Text is the associated user-facing text describing the annotation
Type *string `json:"type,omitempty"` // Type describes the kind of annotation
}
// TODO: make sure that endtime is after starttime
func (u *updateAnnotationRequest) UnmarshalJSON(data []byte) error {
type Alias updateAnnotationRequest
aux := &struct {
StartTime *string `json:"startTime,omitempty"`
EndTime *string `json:"endTime,omitempty"`
*Alias
}{
Alias: (*Alias)(u),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
if aux.StartTime != nil {
tm, err := time.Parse(timeMilliFormat, *aux.StartTime)
if err != nil {
return err
}
u.StartTime = &tm
}
if aux.EndTime != nil {
tm, err := time.Parse(timeMilliFormat, *aux.EndTime)
if err != nil {
return err
}
u.EndTime = &tm
}
// Update must have at least one field set
if u.StartTime == nil && u.EndTime == nil && u.Text == nil && u.Type == nil {
return fmt.Errorf("update request must have at least one field")
}
return nil
}
// UpdateAnnotation overwrite an existing annotation
func (s *Service) UpdateAnnotation(w http.ResponseWriter, r *http.Request) {
id, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger)
return
}
annoID, err := paramStr("aid", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger)
return
}
ctx := r.Context()
src, err := s.Store.Sources(ctx).Get(ctx, id)
if err != nil {
notFound(w, id, s.Logger)
return
}
ts, err := s.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err)
Error(w, http.StatusBadRequest, msg, s.Logger)
return
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", id, err)
Error(w, http.StatusBadRequest, msg, s.Logger)
return
}
store := influx.NewAnnotationStore(ts)
cur, err := store.Get(ctx, annoID)
if err != nil {
notFound(w, annoID, s.Logger)
return
}
var req updateAnnotationRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, s.Logger)
return
}
if req.StartTime != nil {
cur.StartTime = *req.StartTime
}
if req.EndTime != nil {
cur.EndTime = *req.EndTime
}
if req.Text != nil {
cur.Text = *req.Text
}
if req.Type != nil {
cur.Type = *req.Type
}
if err = store.Update(ctx, cur); err != nil {
if err == chronograf.ErrUpstreamTimeout {
msg := "Timeout waiting for response"
Error(w, http.StatusRequestTimeout, msg, s.Logger)
return
}
Error(w, http.StatusBadRequest, err.Error(), s.Logger)
return
}
res := newAnnotationResponse(src, cur)
location(w, res.Links.Self)
encodeJSON(w, http.StatusOK, res, s.Logger)
}

191
server/annotations_test.go Normal file
View File

@ -0,0 +1,191 @@
package server
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/mocks"
)
func TestService_Annotations(t *testing.T) {
type fields struct {
Store DataStore
TimeSeriesClient TimeSeriesClient
}
tests := []struct {
name string
fields fields
w *httptest.ResponseRecorder
r *http.Request
ID string
want string
}{
{
name: "error no id",
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "/chronograf/v1/sources/1/annotations", bytes.NewReader([]byte(`howdy`))),
want: `{"code":422,"message":"Error converting ID "}`,
},
{
name: "no since parameter",
ID: "1",
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "/chronograf/v1/sources/1/annotations", bytes.NewReader([]byte(`howdy`))),
want: `{"code":422,"message":"since parameter is required"}`,
},
{
name: "invalid since parameter",
ID: "1",
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "/chronograf/v1/sources/1/annotations?since=howdy", bytes.NewReader([]byte(`howdy`))),
want: `{"code":422,"message":"parsing time \"howdy\" as \"2006-01-02T15:04:05.999Z07:00\": cannot parse \"howdy\" as \"2006\""}`,
},
{
name: "error is returned when get is an error",
fields: fields{
Store: &mocks.Store{
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{}, fmt.Errorf("error")
},
},
},
},
ID: "1",
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "/chronograf/v1/sources/1/annotations?since=1985-04-12T23:20:50.52Z", bytes.NewReader([]byte(`howdy`))),
want: `{"code":404,"message":"ID 1 not found"}`,
},
{
name: "error is returned connect is an error",
fields: fields{
Store: &mocks.Store{
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: ID,
}, nil
},
},
},
TimeSeriesClient: &mocks.TimeSeries{
ConnectF: func(context.Context, *chronograf.Source) error {
return fmt.Errorf("error)")
},
},
},
ID: "1",
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "/chronograf/v1/sources/1/annotations?since=1985-04-12T23:20:50.52Z", bytes.NewReader([]byte(`howdy`))),
want: `{"code":400,"message":"Unable to connect to source 1: error)"}`,
},
{
name: "error returned when annotations are invalid",
fields: fields{
Store: &mocks.Store{
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: ID,
}, nil
},
},
},
TimeSeriesClient: &mocks.TimeSeries{
ConnectF: func(context.Context, *chronograf.Source) error {
return nil
},
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return mocks.NewResponse(`{[]}`, nil), nil
},
},
},
ID: "1",
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "/chronograf/v1/sources/1/annotations?since=1985-04-12T23:20:50.52Z", bytes.NewReader([]byte(`howdy`))),
want: `{"code":500,"message":"Unknown error: Error loading annotations: invalid character '[' looking for beginning of object key string"}`,
},
{
name: "error is returned connect is an error",
fields: fields{
Store: &mocks.Store{
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: ID,
}, nil
},
},
},
TimeSeriesClient: &mocks.TimeSeries{
ConnectF: func(context.Context, *chronograf.Source) error {
return nil
},
QueryF: func(context.Context, chronograf.Query) (chronograf.Response, error) {
return mocks.NewResponse(`[
{
"series": [
{
"name": "annotations",
"columns": [
"time",
"start_time",
"modified_time_ns",
"text",
"type",
"id"
],
"values": [
[
1516920177345000000,
0,
1516989242129417403,
"mytext",
"mytype",
"ea0aa94b-969a-4cd5-912a-5db61d502268"
]
]
}
]
}
]`, nil), nil
},
},
},
ID: "1",
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "/chronograf/v1/sources/1/annotations?since=1985-04-12T23:20:50.52Z", bytes.NewReader([]byte(`howdy`))),
want: `{"annotations":[{"id":"ea0aa94b-969a-4cd5-912a-5db61d502268","startTime":"1970-01-01T00:00:00Z","endTime":"2018-01-25T22:42:57.345Z","text":"mytext","type":"mytype","links":{"self":"/chronograf/v1/sources/1/annotations/ea0aa94b-969a-4cd5-912a-5db61d502268"}}]}
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.r = tt.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
}))
s := &Service{
Store: tt.fields.Store,
TimeSeriesClient: tt.fields.TimeSeriesClient,
Logger: mocks.NewLogger(),
}
s.Annotations(tt.w, tt.r)
got := tt.w.Body.String()
if got != tt.want {
t.Errorf("Annotations() got != want:\n%s\n%s", got, tt.want)
}
})
}
}

View File

@ -171,6 +171,13 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
// intended for Chronograf Users with the Viewer Role type.
router.POST("/chronograf/v1/sources/:id/queries", EnsureViewer(service.Queries))
// Annotations are user-defined events associated with this source
router.GET("/chronograf/v1/sources/:id/annotations", EnsureViewer(service.Annotations))
router.POST("/chronograf/v1/sources/:id/annotations", EnsureEditor(service.NewAnnotation))
router.GET("/chronograf/v1/sources/:id/annotations/:aid", EnsureViewer(service.Annotation))
router.DELETE("/chronograf/v1/sources/:id/annotations/:aid", EnsureEditor(service.RemoveAnnotation))
router.PATCH("/chronograf/v1/sources/:id/annotations/:aid", EnsureEditor(service.UpdateAnnotation))
// All possible permissions for users in this source
router.GET("/chronograf/v1/sources/:id/permissions", EnsureViewer(service.Permissions))
@ -420,3 +427,19 @@ func paramID(key string, r *http.Request) (int, error) {
}
return id, nil
}
func paramInt64(key string, r *http.Request) (int64, error) {
ctx := r.Context()
param := httprouter.GetParamFromContext(ctx, key)
v, err := strconv.ParseInt(param, 10, 64)
if err != nil {
return -1, fmt.Errorf("Error converting parameter %s", param)
}
return v, nil
}
func paramStr(key string, r *http.Request) (string, error) {
ctx := r.Context()
param := httprouter.GetParamFromContext(ctx, key)
return param, nil
}

View File

@ -23,7 +23,8 @@ type sourceLinks struct {
Permissions string `json:"permissions"` // URL for all allowed permissions for this source
Users string `json:"users"` // URL for all users associated with this source
Roles string `json:"roles,omitempty"` // URL for all users associated with this source
Databases string `json:"databases"` // URL for the databases contained within this soure
Databases string `json:"databases"` // URL for the databases contained within this source
Annotations string `json:"annotations"` // URL for the annotations of this source
}
type sourceResponse struct {
@ -53,6 +54,7 @@ func newSourceResponse(src chronograf.Source) sourceResponse {
Permissions: fmt.Sprintf("%s/%d/permissions", httpAPISrcs, src.ID),
Users: fmt.Sprintf("%s/%d/users", httpAPISrcs, src.ID),
Databases: fmt.Sprintf("%s/%d/dbs", httpAPISrcs, src.ID),
Annotations: fmt.Sprintf("%s/%d/annotations", httpAPISrcs, src.ID),
},
}

View File

@ -182,6 +182,7 @@ func Test_newSourceResponse(t *testing.T) {
Users: "/chronograf/v1/sources/1/users",
Permissions: "/chronograf/v1/sources/1/permissions",
Databases: "/chronograf/v1/sources/1/dbs",
Annotations: "/chronograf/v1/sources/1/annotations",
},
},
},
@ -205,6 +206,7 @@ func Test_newSourceResponse(t *testing.T) {
Users: "/chronograf/v1/sources/1/users",
Permissions: "/chronograf/v1/sources/1/permissions",
Databases: "/chronograf/v1/sources/1/dbs",
Annotations: "/chronograf/v1/sources/1/annotations",
},
},
},

View File

@ -3140,7 +3140,7 @@
"rp": "autogen",
"tempVars": [
{
"tempVar": "$myfield",
"tempVar": ":myfield:",
"values": [
{
"type": "fieldKey",
@ -3161,6 +3161,11 @@
"rp": {
"type": "string"
},
"epoch": {
"description": "timestamp return format",
"type": "string",
"enum": ["h", "m", "s", "ms", "u", "ns"]
},
"tempVars": {
"type": "array",
"description":

View File

@ -19,7 +19,6 @@
"test:lint": "yarn run lint; yarn run test",
"test:dev": "concurrently \"yarn run lint --watch\" \"yarn run test --no-single-run --reporters=verbose\"",
"clean": "rm -rf build/*",
"storybook": "node ./storybook.js",
"prettier": "prettier --single-quote --trailing-comma es5 --bracket-spacing false --semi false --write \"{src,spec}/**/*.js\"; eslint src --fix"
},
"author": "",
@ -29,7 +28,6 @@
}
},
"devDependencies": {
"@kadira/storybook": "^2.21.0",
"autoprefixer": "^6.3.1",
"babel-core": "^6.5.1",
"babel-eslint": "6.1.2",

View File

@ -0,0 +1,103 @@
import {visibleAnnotations} from 'shared/annotations/helpers'
import Dygraph from 'src/external/dygraph'
const start = 1515628800000
const end = 1516060800000
const timeSeries = [
[start, 25],
[1515715200000, 13],
[1515801600000, 10],
[1515888000000, 5],
[1515974400000, null],
[end, 14],
]
const labels = ['time', 'test.label']
const div = document.createElement('div')
const graph = new Dygraph(div, timeSeries, {labels})
const oneHourMs = '3600000'
const a1 = {
group: '',
name: 'a1',
time: '1515716160000',
duration: '',
text: 'you have no swoggels',
}
const a2 = {
group: '',
name: 'a2',
time: '1515716169000',
duration: '3600000', // 1 hour
text: 'you have no swoggels',
}
const annotations = [a1]
describe('Shared.Annotations.Helpers', () => {
describe('visibleAnnotations', () => {
it('returns an empty array with no graph or annotations are provided', () => {
const actual = visibleAnnotations(undefined, annotations)
const expected = []
expect(actual).to.deep.equal(expected)
})
it('returns an annotation if it is in the time range', () => {
const actual = visibleAnnotations(graph, annotations)
const expected = annotations
expect(actual).to.deep.equal(expected)
})
it('removes an annotation if it is out of the time range', () => {
const outOfBounds = {
group: '',
name: 'not in time range',
time: '2515716169000',
duration: '',
}
const newAnnos = [...annotations, outOfBounds]
const actual = visibleAnnotations(graph, newAnnos)
const expected = annotations
expect(actual).to.deep.equal(expected)
})
describe('with a duration', () => {
it('it adds an annotation', () => {
const withDurations = [...annotations, a2]
const actual = visibleAnnotations(graph, withDurations)
const expectedAnnotation = {
...a2,
time: `${Number(a2.time) + Number(a2.duration)}`,
duration: '',
}
const expected = [...withDurations, expectedAnnotation]
expect(actual).to.deep.equal(expected)
})
it('does not add a duration annotation if it is out of bounds', () => {
const annotationWithOutOfBoundsDuration = {
...a2,
duration: a2.time,
}
const withDurations = [
...annotations,
annotationWithOutOfBoundsDuration,
]
const actual = visibleAnnotations(graph, withDurations)
const expected = withDurations
expect(actual).to.deep.equal(expected)
})
})
})
})

View File

@ -0,0 +1,67 @@
import reducer from 'shared/reducers/annotations'
import {
addAnnotation,
deleteAnnotation,
loadAnnotations,
updateAnnotation,
} from 'shared/actions/annotations'
const a1 = {
id: '1',
group: '',
name: 'anno1',
time: '1515716169000',
duration: '',
text: 'you have no swoggels',
}
const a2 = {
id: '2',
group: '',
name: 'anno1',
time: '1515716169000',
duration: '',
text: 'you have no swoggels',
}
const state = {
mode: null,
annotations: [],
}
describe.only('Shared.Reducers.annotations', () => {
it('can load the annotations', () => {
const expected = [{time: '0', duration: ''}]
const actual = reducer(state, loadAnnotations(expected))
expect(actual.annotations).to.deep.equal(expected)
})
it('can update an annotation', () => {
const expected = [{...a1, time: ''}]
const actual = reducer(
{...state, annotations: [a1]},
updateAnnotation(expected[0])
)
expect(actual.annotations).to.deep.equal(expected)
})
it('can delete an annotation', () => {
const expected = [a2]
const actual = reducer(
{...state, annotations: [a1, a2]},
deleteAnnotation(a1)
)
expect(actual.annotations).to.deep.equal(expected)
})
it('can add an annotation', () => {
const expected = [a1]
const actual = reducer(state, addAnnotation(a1))
expect(actual.annotations).to.deep.equal(expected)
})
})

View File

@ -10,11 +10,11 @@ import FancyScrollbar from 'shared/components/FancyScrollbar'
import {DISPLAY_OPTIONS, TOOLTIP_CONTENT} from 'src/dashboards/constants'
import {GRAPH_TYPES} from 'src/dashboards/graphics/graph'
import {updateAxes} from 'src/dashboards/actions/cellEditorOverlay'
const {LINEAR, LOG, BASE_2, BASE_10} = DISPLAY_OPTIONS
const getInputMin = scale => (scale === LOG ? '0' : null)
import {updateAxes} from 'src/dashboards/actions/cellEditorOverlay'
class AxesOptions extends Component {
handleSetPrefixSuffix = e => {
const {handleUpdateAxes, axes} = this.props
@ -73,6 +73,8 @@ class AxesOptions extends Component {
const {
axes: {y: {bounds, label, prefix, suffix, base, scale, defaultYLabel}},
type,
staticLegend,
onToggleStaticLegend,
} = this.props
const [min, max] = bounds
@ -162,6 +164,18 @@ class AxesOptions extends Component {
onClickTab={this.handleSetScale(LOG)}
/>
</Tabber>
<Tabber labelText="Static Legend">
<Tab
text="Show"
isActive={staticLegend}
onClickTab={onToggleStaticLegend(true)}
/>
<Tab
text="Hide"
isActive={!staticLegend}
onClickTab={onToggleStaticLegend(false)}
/>
</Tabber>
</form>
</div>
</FancyScrollbar>
@ -169,7 +183,7 @@ class AxesOptions extends Component {
}
}
const {arrayOf, func, shape, string} = PropTypes
const {arrayOf, bool, func, shape, string} = PropTypes
AxesOptions.defaultProps = {
axes: {
@ -193,6 +207,8 @@ AxesOptions.propTypes = {
defaultYLabel: string,
}),
}).isRequired,
onToggleStaticLegend: func.isRequired,
staticLegend: bool,
handleUpdateAxes: func.isRequired,
}

View File

@ -14,6 +14,7 @@ import * as queryModifiers from 'src/utils/queryTransitions'
import defaultQueryConfig from 'src/utils/defaultQueryConfig'
import {buildQuery} from 'utils/influxql'
import {getQueryConfig} from 'shared/apis'
import {IS_STATIC_LEGEND} from 'src/shared/constants'
import {
removeUnselectedTemplateValues,
@ -28,7 +29,7 @@ class CellEditorOverlay extends Component {
constructor(props) {
super(props)
const {cell: {queries}, sources} = props
const {cell: {queries, legend}, sources} = props
let source = _.get(queries, ['0', 'source'], null)
source = sources.find(s => s.links.self === source) || props.source
@ -45,6 +46,7 @@ class CellEditorOverlay extends Component {
queriesWorkingDraft,
activeQueryIndex: 0,
isDisplayOptionsTabActive: false,
staticLegend: IS_STATIC_LEGEND(legend),
}
}
@ -102,8 +104,7 @@ class CellEditorOverlay extends Component {
}
handleSaveCell = () => {
const {queriesWorkingDraft} = this.state
const {queriesWorkingDraft, staticLegend} = this.state
const {cell, singleStatColors, gaugeColors} = this.props
const queries = queriesWorkingDraft.map(q => {
@ -131,6 +132,12 @@ class CellEditorOverlay extends Component {
...cell,
queries,
colors,
legend: staticLegend
? {
type: 'static',
orientation: 'bottom',
}
: {},
})
}
@ -142,6 +149,10 @@ class CellEditorOverlay extends Component {
this.setState({activeQueryIndex})
}
handleToggleStaticLegend = staticLegend => () => {
this.setState({staticLegend})
}
handleSetQuerySource = source => {
const queriesWorkingDraft = this.state.queriesWorkingDraft.map(q => ({
..._.cloneDeep(q),
@ -238,6 +249,10 @@ class CellEditorOverlay extends Component {
}
}
handleResetFocus = () => {
this.overlayRef.focus()
}
render() {
const {
onCancel,
@ -251,6 +266,7 @@ class CellEditorOverlay extends Component {
activeQueryIndex,
isDisplayOptionsTabActive,
queriesWorkingDraft,
staticLegend,
} = this.state
const queryActions = {
@ -282,6 +298,7 @@ class CellEditorOverlay extends Component {
autoRefresh={autoRefresh}
queryConfigs={queriesWorkingDraft}
editQueryStatus={editQueryStatus}
staticLegend={staticLegend}
/>
<CEOBottom>
<OverlayControls
@ -296,7 +313,12 @@ class CellEditorOverlay extends Component {
onClickDisplayOptions={this.handleClickDisplayOptionsTab}
/>
{isDisplayOptionsTabActive
? <DisplayOptions queryConfigs={queriesWorkingDraft} />
? <DisplayOptions
queryConfigs={queriesWorkingDraft}
onToggleStaticLegend={this.handleToggleStaticLegend}
staticLegend={staticLegend}
onResetFocus={this.handleResetFocus}
/>
: <QueryMaker
source={this.getSource()}
templates={templates}

View File

@ -35,15 +35,24 @@ class DisplayOptions extends Component {
}
renderOptions = () => {
const {cell: {type}} = this.props
const {
cell: {type},
staticLegend,
onToggleStaticLegend,
onResetFocus,
} = this.props
switch (type) {
case 'gauge':
return <GaugeOptions />
return <GaugeOptions onResetFocus={onResetFocus} />
case 'single-stat':
return <SingleStatOptions />
return <SingleStatOptions onResetFocus={onResetFocus} />
default:
return <AxesOptions />
return (
<AxesOptions
onToggleStaticLegend={onToggleStaticLegend}
staticLegend={staticLegend}
/>
)
}
}
@ -56,7 +65,8 @@ class DisplayOptions extends Component {
)
}
}
const {arrayOf, shape, string} = PropTypes
const {arrayOf, bool, func, shape, string} = PropTypes
DisplayOptions.propTypes = {
cell: shape({
@ -70,6 +80,9 @@ DisplayOptions.propTypes = {
}),
}).isRequired,
queryConfigs: arrayOf(shape()).isRequired,
onToggleStaticLegend: func.isRequired,
staticLegend: bool,
onResetFocus: func.isRequired,
}
const mapStateToProps = ({cellEditorOverlay: {cell, cell: {axes}}}) => ({

View File

@ -22,7 +22,7 @@ import {
class GaugeOptions extends Component {
handleAddThreshold = () => {
const {gaugeColors, handleUpdateGaugeColors} = this.props
const {gaugeColors, handleUpdateGaugeColors, onResetFocus} = this.props
const sortedColors = _.sortBy(gaugeColors, color => color.value)
if (sortedColors.length <= MAX_THRESHOLDS) {
@ -47,16 +47,19 @@ class GaugeOptions extends Component {
}
handleUpdateGaugeColors([...gaugeColors, newThreshold])
} else {
onResetFocus()
}
}
handleDeleteThreshold = threshold => () => {
const {handleUpdateGaugeColors} = this.props
const {handleUpdateGaugeColors, onResetFocus} = this.props
const gaugeColors = this.props.gaugeColors.filter(
color => color.id !== threshold.id
)
handleUpdateGaugeColors(gaugeColors)
onResetFocus()
}
handleChooseColor = threshold => chosenColor => {
@ -217,6 +220,7 @@ GaugeOptions.propTypes = {
handleUpdateGaugeColors: func.isRequired,
handleUpdateAxes: func.isRequired,
axes: shape({}).isRequired,
onResetFocus: func.isRequired,
}
const mapStateToProps = ({cellEditorOverlay: {gaugeColors, cell: {axes}}}) => ({

View File

@ -42,6 +42,7 @@ class SingleStatOptions extends Component {
singleStatColors,
singleStatType,
handleUpdateSingleStatColors,
onResetFocus,
} = this.props
const randomColor = _.random(0, GAUGE_COLORS.length - 1)
@ -67,16 +68,18 @@ class SingleStatOptions extends Component {
}
handleUpdateSingleStatColors([...singleStatColors, newThreshold])
onResetFocus()
}
handleDeleteThreshold = threshold => () => {
const {handleUpdateSingleStatColors} = this.props
const {handleUpdateSingleStatColors, onResetFocus} = this.props
const singleStatColors = this.props.singleStatColors.filter(
color => color.id !== threshold.id
)
handleUpdateSingleStatColors(singleStatColors)
onResetFocus()
}
handleChooseColor = threshold => chosenColor => {
@ -242,6 +245,7 @@ SingleStatOptions.propTypes = {
handleUpdateSingleStatColors: func.isRequired,
handleUpdateAxes: func.isRequired,
axes: shape({}).isRequired,
onResetFocus: func.isRequired,
}
const mapStateToProps = ({

View File

@ -2,7 +2,7 @@ import React, {PropTypes} from 'react'
import QuestionMarkTooltip from 'src/shared/components/QuestionMarkTooltip'
export const Tabber = ({labelText, children, tipID, tipContent}) =>
<div className="form-group col-sm-6">
<div className="form-group col-md-6">
<label>
{labelText}
{tipID

View File

@ -18,6 +18,7 @@ const DashVisualization = (
queryConfigs,
editQueryStatus,
resizerTopHeight,
staticLegend,
singleStatColors,
},
{source: {links: {proxy}}}
@ -37,13 +38,14 @@ const DashVisualization = (
autoRefresh={autoRefresh}
editQueryStatus={editQueryStatus}
resizerTopHeight={resizerTopHeight}
staticLegend={staticLegend}
/>
</div>
</div>
)
}
const {arrayOf, func, number, shape, string} = PropTypes
const {arrayOf, bool, func, number, shape, string} = PropTypes
DashVisualization.propTypes = {
type: string.isRequired,
@ -79,6 +81,7 @@ DashVisualization.propTypes = {
value: number.isRequired,
}).isRequired
),
staticLegend: bool,
}
DashVisualization.contextTypes = {

View File

@ -20,6 +20,8 @@ import {publishNotification} from 'shared/actions/notifications'
import idNormalizer, {TYPE_ID} from 'src/normalizers/id'
import * as dashboardActionCreators from 'src/dashboards/actions'
import * as annotationActions from 'shared/actions/annotations'
import {
showCellEditorOverlay,
hideCellEditorOverlay,
@ -36,6 +38,7 @@ const FORMAT_INFLUXQL = 'influxql'
const defaultTimeRange = {
upper: null,
lower: 'now() - 15m',
seconds: 900,
format: FORMAT_INFLUXQL,
}
@ -68,8 +71,14 @@ class DashboardPage extends Component {
isUsingAuth,
router,
notify,
getAnnotationsAsync,
timeRange,
} = this.props
getAnnotationsAsync(
source.links.annotations,
Date.now() - timeRange.seconds * 1000
)
window.addEventListener('resize', this.handleWindowResize, true)
const dashboards = await getDashboardsAsync()
@ -136,13 +145,21 @@ class DashboardPage extends Component {
.then(handleHideCellEditorOverlay)
}
handleChooseTimeRange = ({upper, lower}) => {
const {dashboard, dashboardActions} = this.props
handleChooseTimeRange = timeRange => {
const {
dashboard,
dashboardActions,
getAnnotationsAsync,
source,
} = this.props
dashboardActions.setDashTimeV1(dashboard.id, {
upper,
lower,
...timeRange,
format: FORMAT_INFLUXQL,
})
getAnnotationsAsync(
source.links.annotations,
Date.now() - timeRange.seconds * 1000
)
}
handleUpdatePosition = cells => {
@ -510,6 +527,7 @@ DashboardPage.propTypes = {
isUsingAuth: bool.isRequired,
router: shape().isRequired,
notify: func.isRequired,
getAnnotationsAsync: func.isRequired,
handleShowCellEditorOverlay: func.isRequired,
handleHideCellEditorOverlay: func.isRequired,
selectedCell: shape({}),
@ -530,6 +548,7 @@ const mapStateToProps = (state, {params: {dashboardID}}) => {
auth: {me, isUsingAuth},
cellEditorOverlay: {cell, singleStatType, singleStatColors, gaugeColors},
} = state
const meRole = _.get(me, 'role', null)
const timeRange =
@ -543,16 +562,16 @@ const mapStateToProps = (state, {params: {dashboardID}}) => {
const selectedCell = cell
return {
dashboards,
autoRefresh,
dashboard,
timeRange,
showTemplateControlBar,
inPresentationMode,
cellQueryStatus,
sources,
meRole,
dashboard,
timeRange,
dashboards,
autoRefresh,
isUsingAuth,
cellQueryStatus,
inPresentationMode,
showTemplateControlBar,
selectedCell,
singleStatType,
singleStatColors,
@ -570,6 +589,10 @@ const mapDispatchToProps = dispatch => ({
dashboardActions: bindActionCreators(dashboardActionCreators, dispatch),
errorThrown: bindActionCreators(errorThrownAction, dispatch),
notify: bindActionCreators(publishNotification, dispatch),
getAnnotationsAsync: bindActionCreators(
annotationActions.getAnnotationsAsync,
dispatch
),
handleShowCellEditorOverlay: bindActionCreators(
showCellEditorOverlay,
dispatch

View File

@ -0,0 +1,79 @@
import * as api from 'shared/apis/annotation'
export const editingAnnotation = () => ({
type: 'EDITING_ANNOTATION',
})
export const dismissEditingAnnotation = () => ({
type: 'DISMISS_EDITING_ANNOTATION',
})
export const addingAnnotation = () => ({
type: 'ADDING_ANNOTATION',
})
export const addingAnnotationSuccess = () => ({
type: 'ADDING_ANNOTATION_SUCCESS',
})
export const dismissAddingAnnotation = () => ({
type: 'DISMISS_ADDING_ANNOTATION',
})
export const mouseEnterTempAnnotation = () => ({
type: 'MOUSEENTER_TEMP_ANNOTATION',
})
export const mouseLeaveTempAnnotation = () => ({
type: 'MOUSELEAVE_TEMP_ANNOTATION',
})
export const loadAnnotations = annotations => ({
type: 'LOAD_ANNOTATIONS',
payload: {
annotations,
},
})
export const updateAnnotation = annotation => ({
type: 'UPDATE_ANNOTATION',
payload: {
annotation,
},
})
export const deleteAnnotation = annotation => ({
type: 'DELETE_ANNOTATION',
payload: {
annotation,
},
})
export const addAnnotation = annotation => ({
type: 'ADD_ANNOTATION',
payload: {
annotation,
},
})
export const addAnnotationAsync = (createUrl, annotation) => async dispatch => {
dispatch(addAnnotation(annotation))
const savedAnnotation = await api.createAnnotation(createUrl, annotation)
dispatch(addAnnotation(savedAnnotation))
dispatch(deleteAnnotation(annotation))
}
export const getAnnotationsAsync = (indexUrl, since) => async dispatch => {
const annotations = await api.getAnnotations(indexUrl, since)
dispatch(loadAnnotations(annotations))
}
export const deleteAnnotationAsync = annotation => async dispatch => {
await api.deleteAnnotation(annotation)
dispatch(deleteAnnotation(annotation))
}
export const updateAnnotationAsync = annotation => async dispatch => {
await api.updateAnnotation(annotation)
dispatch(updateAnnotation(annotation))
}

View File

@ -0,0 +1,28 @@
export const ANNOTATION_MIN_DELTA = 0.5
export const ADDING = 'adding'
export const EDITING = 'editing'
export const TEMP_ANNOTATION = {
id: 'tempAnnotation',
text: 'Name Me',
type: '',
startTime: '',
endTime: '',
}
export const visibleAnnotations = (graph, annotations = []) => {
const [xStart, xEnd] = graph.xAxisRange()
if (xStart === 0 && xEnd === 0) {
return []
}
return annotations.filter(a => {
if (a.endTime === a.startTime) {
return xStart <= +a.startTime && +a.startTime <= xEnd
}
return !(+a.endTime < xStart || xEnd < +a.startTime)
})
}

View File

@ -0,0 +1,40 @@
import AJAX from 'src/utils/ajax'
const msToRFC = ms => ms && new Date(parseInt(ms, 10)).toISOString()
const rfcToMS = rfc3339 => rfc3339 && JSON.stringify(Date.parse(rfc3339))
const annoToMillisecond = anno => ({
...anno,
startTime: rfcToMS(anno.startTime),
endTime: rfcToMS(anno.endTime),
})
const annoToRFC = anno => ({
...anno,
startTime: msToRFC(anno.startTime),
endTime: msToRFC(anno.endTime),
})
export const createAnnotation = async (url, annotation) => {
const data = annoToRFC(annotation)
const response = await AJAX({method: 'POST', url, data})
return annoToMillisecond(response.data)
}
export const getAnnotations = async (url, since) => {
const {data} = await AJAX({
method: 'GET',
url,
params: {since: msToRFC(since)},
})
return data.annotations.map(annoToMillisecond)
}
export const deleteAnnotation = async annotation => {
const url = annotation.links.self
await AJAX({method: 'DELETE', url})
}
export const updateAnnotation = async annotation => {
const url = annotation.links.self
const data = annoToRFC(annotation)
await AJAX({method: 'PATCH', url, data})
}

View File

@ -0,0 +1,43 @@
import React, {PropTypes} from 'react'
import AnnotationPoint from 'shared/components/AnnotationPoint'
import AnnotationSpan from 'shared/components/AnnotationSpan'
import * as schema from 'shared/schemas'
const Annotation = ({
dygraph,
annotation,
mode,
lastUpdated,
staticLegendHeight,
}) =>
<div>
{annotation.startTime === annotation.endTime
? <AnnotationPoint
lastUpdated={lastUpdated}
annotation={annotation}
mode={mode}
dygraph={dygraph}
staticLegendHeight={staticLegendHeight}
/>
: <AnnotationSpan
lastUpdated={lastUpdated}
annotation={annotation}
mode={mode}
dygraph={dygraph}
staticLegendHeight={staticLegendHeight}
/>}
</div>
const {number, shape, string} = PropTypes
Annotation.propTypes = {
mode: string,
lastUpdated: number,
annotation: schema.annotation.isRequired,
dygraph: shape({}).isRequired,
staticLegendHeight: number,
}
export default Annotation

View File

@ -0,0 +1,74 @@
import React, {Component, PropTypes} from 'react'
import onClickOutside from 'react-onclickoutside'
class AnnotationInput extends Component {
state = {
isEditing: false,
}
handleInputClick = () => {
this.setState({isEditing: true})
}
handleKeyDown = e => {
const {onConfirmUpdate, onRejectUpdate} = this.props
if (e.key === 'Enter') {
onConfirmUpdate()
this.setState({isEditing: false})
}
if (e.key === 'Escape') {
onRejectUpdate()
this.setState({isEditing: false})
}
}
handleFocus = e => {
e.target.select()
}
handleChange = e => {
this.props.onChangeInput(e.target.value)
}
handleClickOutside = () => {
this.props.onConfirmUpdate()
this.setState({isEditing: false})
}
render() {
const {isEditing} = this.state
const {value} = this.props
return (
<div className="annotation-tooltip--input-container">
{isEditing
? <input
type="text"
className="annotation-tooltip--input form-control input-xs"
value={value}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
autoFocus={true}
onFocus={this.handleFocus}
placeholder="Annotation text"
/>
: <div className="input-cte" onClick={this.handleInputClick}>
{value}
<span className="icon pencil" />
</div>}
</div>
)
}
}
const {func, string} = PropTypes
AnnotationInput.propTypes = {
value: string,
onChangeInput: func.isRequired,
onConfirmUpdate: func.isRequired,
onRejectUpdate: func.isRequired,
}
export default onClickOutside(AnnotationInput)

View File

@ -0,0 +1,159 @@
import React, {PropTypes} from 'react'
import {connect} from 'react-redux'
import {
DYGRAPH_CONTAINER_H_MARGIN,
DYGRAPH_CONTAINER_V_MARGIN,
DYGRAPH_CONTAINER_XLABEL_MARGIN,
} from 'shared/constants'
import {ANNOTATION_MIN_DELTA, EDITING} from 'shared/annotations/helpers'
import * as schema from 'shared/schemas'
import * as actions from 'shared/actions/annotations'
import AnnotationTooltip from 'shared/components/AnnotationTooltip'
class AnnotationPoint extends React.Component {
state = {
isMouseOver: false,
isDragging: false,
}
handleMouseEnter = () => {
this.setState({isMouseOver: true})
}
handleMouseLeave = e => {
const {annotation} = this.props
if (e.relatedTarget.id === `tooltip-${annotation.id}`) {
return this.setState({isDragging: false})
}
this.setState({isMouseOver: false})
}
handleDragStart = () => {
this.setState({isDragging: true})
}
handleDragEnd = () => {
const {annotation, updateAnnotationAsync} = this.props
updateAnnotationAsync(annotation)
this.setState({isDragging: false})
}
handleDrag = e => {
if (this.props.mode !== EDITING) {
return
}
const {pageX} = e
const {annotation, dygraph, updateAnnotation} = this.props
if (pageX === 0) {
return
}
const {startTime} = annotation
const {left} = dygraph.graphDiv.getBoundingClientRect()
const [startX, endX] = dygraph.xAxisRange()
const graphX = pageX - left
let newTime = dygraph.toDataXCoord(graphX)
const oldTime = +startTime
if (
Math.abs(
dygraph.toPercentXCoord(newTime) - dygraph.toPercentXCoord(oldTime)
) *
100 <
ANNOTATION_MIN_DELTA
) {
return
}
if (newTime >= endX) {
newTime = endX
}
if (newTime <= startX) {
newTime = startX
}
updateAnnotation({
...annotation,
startTime: `${newTime}`,
endTime: `${newTime}`,
})
e.preventDefault()
e.stopPropagation()
}
render() {
const {annotation, mode, dygraph, staticLegendHeight} = this.props
const {isDragging} = this.state
const isEditing = mode === EDITING
const flagClass = isDragging
? 'annotation-point--flag__dragging'
: 'annotation-point--flag'
const markerClass = isDragging ? 'annotation dragging' : 'annotation'
const clickClass = isEditing
? 'annotation--click-area editing'
: 'annotation--click-area'
const markerStyles = {
left: `${dygraph.toDomXCoord(annotation.startTime) +
DYGRAPH_CONTAINER_H_MARGIN}px`,
height: `calc(100% - ${staticLegendHeight +
DYGRAPH_CONTAINER_XLABEL_MARGIN +
DYGRAPH_CONTAINER_V_MARGIN * 2}px)`,
}
return (
<div className={markerClass} style={markerStyles}>
<div
className={clickClass}
draggable={true}
onDrag={this.handleDrag}
onDragStart={this.handleDragStart}
onDragEnd={this.handleDragEnd}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
/>
<div className={flagClass} />
<AnnotationTooltip
isEditing={isEditing}
timestamp={annotation.startTime}
annotation={annotation}
onMouseLeave={this.handleMouseLeave}
annotationState={this.state}
/>
</div>
)
}
}
const {func, number, shape, string} = PropTypes
AnnotationPoint.defaultProps = {
staticLegendHeight: 0,
}
AnnotationPoint.propTypes = {
annotation: schema.annotation.isRequired,
mode: string.isRequired,
dygraph: shape({}).isRequired,
updateAnnotation: func.isRequired,
updateAnnotationAsync: func.isRequired,
staticLegendHeight: number.isRequired,
}
const mdtp = {
updateAnnotationAsync: actions.updateAnnotationAsync,
updateAnnotation: actions.updateAnnotation,
}
export default connect(null, mdtp)(AnnotationPoint)

View File

@ -0,0 +1,235 @@
import React, {PropTypes} from 'react'
import {connect} from 'react-redux'
import {
DYGRAPH_CONTAINER_H_MARGIN,
DYGRAPH_CONTAINER_V_MARGIN,
DYGRAPH_CONTAINER_XLABEL_MARGIN,
} from 'shared/constants'
import {ANNOTATION_MIN_DELTA, EDITING} from 'shared/annotations/helpers'
import * as schema from 'shared/schemas'
import * as actions from 'shared/actions/annotations'
import AnnotationTooltip from 'shared/components/AnnotationTooltip'
import AnnotationWindow from 'shared/components/AnnotationWindow'
class AnnotationSpan extends React.Component {
state = {
isDragging: null,
isMouseOver: null,
}
handleMouseEnter = direction => () => {
this.setState({isMouseOver: direction})
}
handleMouseLeave = e => {
const {annotation} = this.props
if (e.relatedTarget.id === `tooltip-${annotation.id}`) {
return this.setState({isDragging: null})
}
this.setState({isMouseOver: null})
}
handleDragStart = direction => () => {
this.setState({isDragging: direction})
}
handleDragEnd = () => {
const {annotation, updateAnnotationAsync} = this.props
const [startTime, endTime] = [
annotation.startTime,
annotation.endTime,
].sort()
const newAnnotation = {
...annotation,
startTime,
endTime,
}
updateAnnotationAsync(newAnnotation)
this.setState({isDragging: null})
}
handleDrag = timeProp => e => {
if (this.props.mode !== EDITING) {
return
}
const {pageX} = e
const {annotation, dygraph, updateAnnotation} = this.props
if (pageX === 0) {
return
}
const oldTime = +annotation[timeProp]
const {left} = dygraph.graphDiv.getBoundingClientRect()
const [startX, endX] = dygraph.xAxisRange()
const graphX = pageX - left
let newTime = dygraph.toDataXCoord(graphX)
if (
Math.abs(
dygraph.toPercentXCoord(newTime) - dygraph.toPercentXCoord(oldTime)
) *
100 <
ANNOTATION_MIN_DELTA
) {
return
}
if (newTime >= endX) {
newTime = endX
}
if (newTime <= startX) {
newTime = startX
}
updateAnnotation({...annotation, [timeProp]: `${newTime}`})
e.preventDefault()
e.stopPropagation()
}
renderLeftMarker(startTime, dygraph) {
const isEditing = this.props.mode === EDITING
const {isDragging, isMouseOver} = this.state
const {annotation, staticLegendHeight} = this.props
const flagClass = isDragging
? 'annotation-span--left-flag dragging'
: 'annotation-span--left-flag'
const markerClass = isDragging ? 'annotation dragging' : 'annotation'
const clickClass = isEditing
? 'annotation--click-area editing'
: 'annotation--click-area'
const leftBound = dygraph.xAxisRange()[0]
if (startTime < leftBound) {
return null
}
const showTooltip = isDragging === 'left' || isMouseOver === 'left'
const markerStyles = {
left: `${dygraph.toDomXCoord(startTime) + DYGRAPH_CONTAINER_H_MARGIN}px`,
height: `calc(100% - ${staticLegendHeight +
DYGRAPH_CONTAINER_XLABEL_MARGIN +
DYGRAPH_CONTAINER_V_MARGIN * 2}px)`,
}
return (
<div className={markerClass} style={markerStyles}>
{showTooltip &&
<AnnotationTooltip
isEditing={isEditing}
timestamp={annotation.startTime}
annotation={annotation}
onMouseLeave={this.handleMouseLeave}
annotationState={this.state}
/>}
<div
className={clickClass}
draggable={true}
onDrag={this.handleDrag('startTime')}
onDragStart={this.handleDragStart('left')}
onDragEnd={this.handleDragEnd}
onMouseEnter={this.handleMouseEnter('left')}
onMouseLeave={this.handleMouseLeave}
/>
<div className={flagClass} />
</div>
)
}
renderRightMarker(endTime, dygraph) {
const isEditing = this.props.mode === EDITING
const {isDragging, isMouseOver} = this.state
const {annotation, staticLegendHeight} = this.props
const flagClass = isDragging
? 'annotation-span--right-flag dragging'
: 'annotation-span--right-flag'
const markerClass = isDragging ? 'annotation dragging' : 'annotation'
const clickClass = isEditing
? 'annotation--click-area editing'
: 'annotation--click-area'
const rightBound = dygraph.xAxisRange()[1]
if (rightBound < endTime) {
return null
}
const showTooltip = isDragging === 'right' || isMouseOver === 'right'
const markerStyles = {
left: `${dygraph.toDomXCoord(endTime) + DYGRAPH_CONTAINER_H_MARGIN}px`,
height: `calc(100% - ${staticLegendHeight +
DYGRAPH_CONTAINER_XLABEL_MARGIN +
DYGRAPH_CONTAINER_V_MARGIN * 2}px)`,
}
return (
<div className={markerClass} style={markerStyles}>
{showTooltip &&
<AnnotationTooltip
isEditing={isEditing}
timestamp={annotation.endTime}
annotation={annotation}
onMouseLeave={this.handleMouseLeave}
annotationState={this.state}
/>}
<div
className={clickClass}
draggable={true}
onDrag={this.handleDrag('endTime')}
onDragStart={this.handleDragStart('right')}
onDragEnd={this.handleDragEnd}
onMouseEnter={this.handleMouseEnter('right')}
onMouseLeave={this.handleMouseLeave}
/>
<div className={flagClass} />
</div>
)
}
render() {
const {annotation, dygraph, staticLegendHeight} = this.props
const {isDragging} = this.state
return (
<div>
<AnnotationWindow
annotation={annotation}
dygraph={dygraph}
active={!!isDragging}
staticLegendHeight={staticLegendHeight}
/>
{this.renderLeftMarker(annotation.startTime, dygraph)}
{this.renderRightMarker(annotation.endTime, dygraph)}
</div>
)
}
}
const {func, number, shape, string} = PropTypes
AnnotationSpan.defaultProps = {
staticLegendHeight: 0,
}
AnnotationSpan.propTypes = {
annotation: schema.annotation.isRequired,
mode: string.isRequired,
dygraph: shape({}).isRequired,
staticLegendHeight: number.isRequired,
updateAnnotationAsync: func.isRequired,
updateAnnotation: func.isRequired,
}
const mdtp = {
updateAnnotationAsync: actions.updateAnnotationAsync,
updateAnnotation: actions.updateAnnotation,
}
export default connect(null, mdtp)(AnnotationSpan)

View File

@ -0,0 +1,120 @@
import React, {Component, PropTypes} from 'react'
import {connect} from 'react-redux'
import moment from 'moment'
import classnames from 'classnames'
import AnnotationInput from 'src/shared/components/AnnotationInput'
import * as schema from 'shared/schemas'
import * as actions from 'shared/actions/annotations'
const TimeStamp = ({time}) =>
<div className="annotation-tooltip--timestamp">
{`${moment(+time).format('YYYY/DD/MM HH:mm:ss.SS')}`}
</div>
class AnnotationTooltip extends Component {
state = {
annotation: this.props.annotation,
}
componentWillReceiveProps = ({annotation}) => {
this.setState({annotation})
}
handleChangeInput = key => value => {
const {annotation} = this.state
const newAnnotation = {...annotation, [key]: value}
this.setState({annotation: newAnnotation})
}
handleConfirmUpdate = () => {
this.props.updateAnnotationAsync(this.state.annotation)
}
handleRejectUpdate = () => {
this.setState({annotation: this.props.annotation})
}
handleDelete = () => {
this.props.deleteAnnotationAsync(this.props.annotation)
}
render() {
const {annotation} = this.state
const {
onMouseLeave,
timestamp,
annotationState: {isDragging, isMouseOver},
isEditing,
span,
} = this.props
const tooltipClass = classnames('annotation-tooltip', {
hidden: !(isDragging || isMouseOver),
'annotation-span-tooltip': !!span,
})
return (
<div
id={`tooltip-${annotation.id}`}
onMouseLeave={onMouseLeave}
className={tooltipClass}
style={
span
? {left: `${span.tooltipLeft}px`, minWidth: `${span.spanWidth}px`}
: {}
}
>
{isDragging
? <TimeStamp time={timestamp} />
: <div className="annotation-tooltip--items">
{isEditing
? <div>
<AnnotationInput
value={annotation.text}
onChangeInput={this.handleChangeInput('text')}
onConfirmUpdate={this.handleConfirmUpdate}
onRejectUpdate={this.handleRejectUpdate}
/>
<button
className="annotation-tooltip--delete"
onClick={this.handleDelete}
title="Delete this Annotation"
>
<span className="icon trash" />
</button>
</div>
: <div>
{annotation.text}
</div>}
<TimeStamp time={timestamp} />
</div>}
</div>
)
}
}
const {bool, func, number, shape, string} = PropTypes
TimeStamp.propTypes = {
time: string.isRequired,
}
AnnotationTooltip.propTypes = {
isEditing: bool,
annotation: schema.annotation.isRequired,
timestamp: string,
onMouseLeave: func.isRequired,
annotationState: shape({}),
deleteAnnotationAsync: func.isRequired,
updateAnnotationAsync: func.isRequired,
span: shape({
spanCenter: number.isRequired,
spanWidth: number.isRequired,
}),
}
export default connect(null, {
deleteAnnotationAsync: actions.deleteAnnotationAsync,
updateAnnotationAsync: actions.updateAnnotationAsync,
})(AnnotationTooltip)

View File

@ -0,0 +1,51 @@
import React, {PropTypes} from 'react'
import {
DYGRAPH_CONTAINER_H_MARGIN,
DYGRAPH_CONTAINER_V_MARGIN,
DYGRAPH_CONTAINER_XLABEL_MARGIN,
} from 'shared/constants'
import * as schema from 'shared/schemas'
const windowDimensions = (anno, dygraph, staticLegendHeight) => {
// TODO: export and test this function
const [startX, endX] = dygraph.xAxisRange()
const startTime = Math.max(+anno.startTime, startX)
const endTime = Math.min(+anno.endTime, endX)
const windowStartXCoord = dygraph.toDomXCoord(startTime)
const windowEndXCoord = dygraph.toDomXCoord(endTime)
const windowWidth = Math.abs(windowEndXCoord - windowStartXCoord)
const windowLeftXCoord =
Math.min(windowStartXCoord, windowEndXCoord) + DYGRAPH_CONTAINER_H_MARGIN
const height = staticLegendHeight
? `calc(100% - ${staticLegendHeight +
DYGRAPH_CONTAINER_XLABEL_MARGIN +
DYGRAPH_CONTAINER_V_MARGIN * 2}px)`
: 'calc(100% - 36px)'
return {
left: `${windowLeftXCoord}px`,
width: `${windowWidth}px`,
height,
}
}
const AnnotationWindow = ({annotation, dygraph, active, staticLegendHeight}) =>
<div
className={`annotation-window${active ? ' active' : ''}`}
style={windowDimensions(annotation, dygraph, staticLegendHeight)}
/>
const {bool, number, shape} = PropTypes
AnnotationWindow.propTypes = {
annotation: schema.annotation.isRequired,
dygraph: shape({}).isRequired,
staticLegendHeight: number,
active: bool,
}
export default AnnotationWindow

View File

@ -0,0 +1,129 @@
import React, {PropTypes, Component} from 'react'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import Annotation from 'src/shared/components/Annotation'
import NewAnnotation from 'src/shared/components/NewAnnotation'
import * as schema from 'src/shared/schemas'
import {ADDING, TEMP_ANNOTATION} from 'src/shared/annotations/helpers'
import {
updateAnnotation,
addingAnnotationSuccess,
dismissAddingAnnotation,
mouseEnterTempAnnotation,
mouseLeaveTempAnnotation,
} from 'src/shared/actions/annotations'
import {visibleAnnotations} from 'src/shared/annotations/helpers'
class Annotations extends Component {
state = {
lastUpdated: null,
}
componentDidMount() {
this.props.annotationsRef(this)
}
heartbeat = () => {
this.setState({lastUpdated: Date.now()})
}
render() {
const {lastUpdated} = this.state
const {
mode,
dygraph,
isTempHovering,
handleUpdateAnnotation,
handleDismissAddingAnnotation,
handleAddingAnnotationSuccess,
handleMouseEnterTempAnnotation,
handleMouseLeaveTempAnnotation,
staticLegendHeight,
} = this.props
const annotations = visibleAnnotations(
dygraph,
this.props.annotations
).filter(a => a.id !== TEMP_ANNOTATION.id)
const tempAnnotation = this.props.annotations.find(
a => a.id === TEMP_ANNOTATION.id
)
return (
<div className="annotations-container">
{mode === ADDING &&
tempAnnotation &&
<NewAnnotation
dygraph={dygraph}
tempAnnotation={tempAnnotation}
onDismissAddingAnnotation={handleDismissAddingAnnotation}
onAddingAnnotationSuccess={handleAddingAnnotationSuccess}
onUpdateAnnotation={handleUpdateAnnotation}
isTempHovering={isTempHovering}
onMouseEnterTempAnnotation={handleMouseEnterTempAnnotation}
onMouseLeaveTempAnnotation={handleMouseLeaveTempAnnotation}
staticLegendHeight={staticLegendHeight}
/>}
{annotations.map(a =>
<Annotation
key={a.id}
mode={mode}
annotation={a}
dygraph={dygraph}
staticLegendHeight={staticLegendHeight}
lastUpdated={lastUpdated}
/>
)}
</div>
)
}
}
const {arrayOf, bool, func, number, shape, string} = PropTypes
Annotations.propTypes = {
annotations: arrayOf(schema.annotation),
dygraph: shape({}),
mode: string,
isTempHovering: bool,
annotationsRef: func,
handleUpdateAnnotation: func.isRequired,
handleDismissAddingAnnotation: func.isRequired,
handleAddingAnnotationSuccess: func.isRequired,
handleMouseEnterTempAnnotation: func.isRequired,
handleMouseLeaveTempAnnotation: func.isRequired,
staticLegendHeight: number,
}
const mapStateToProps = ({
annotations: {annotations, mode, isTempHovering},
}) => ({
annotations,
mode: mode || 'NORMAL',
isTempHovering,
})
const mapDispatchToProps = dispatch => ({
handleAddingAnnotationSuccess: bindActionCreators(
addingAnnotationSuccess,
dispatch
),
handleDismissAddingAnnotation: bindActionCreators(
dismissAddingAnnotation,
dispatch
),
handleMouseEnterTempAnnotation: bindActionCreators(
mouseEnterTempAnnotation,
dispatch
),
handleMouseLeaveTempAnnotation: bindActionCreators(
mouseLeaveTempAnnotation,
dispatch
),
handleUpdateAnnotation: bindActionCreators(updateAnnotation, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(Annotations)

View File

@ -1,15 +1,20 @@
/* eslint-disable no-magic-numbers */
import React, {Component, PropTypes} from 'react'
import {connect} from 'react-redux'
import shallowCompare from 'react-addons-shallow-compare'
import _ from 'lodash'
import NanoDate from 'nano-date'
import Dygraphs from 'src/external/dygraph'
import getRange, {getStackedRange} from 'shared/parsing/getRangeForDygraph'
import DygraphLegend from 'src/shared/components/DygraphLegend'
import StaticLegend from 'src/shared/components/StaticLegend'
import Annotations from 'src/shared/components/Annotations'
import getRange, {getStackedRange} from 'shared/parsing/getRangeForDygraph'
import {DISPLAY_OPTIONS} from 'src/dashboards/constants'
import {buildDefaultYLabel} from 'shared/presenters'
import {numberValueFormatter} from 'src/utils/formatting'
import {
OPTIONS,
LINE_COLORS,
@ -21,22 +26,13 @@ import {
} from 'src/shared/graphs/helpers'
const {LINEAR, LOG, BASE_10, BASE_2} = DISPLAY_OPTIONS
export default class Dygraph extends Component {
class Dygraph extends Component {
constructor(props) {
super(props)
this.state = {
legend: {
x: null,
series: [],
},
pageX: null,
sortType: '',
filterText: '',
isSynced: false,
isHidden: true,
isAscending: true,
isSnipped: false,
isFilterVisible: false,
staticLegendHeight: null,
}
}
@ -56,8 +52,6 @@ export default class Dygraph extends Component {
logscale: y.scale === LOG,
colors: this.getLineColors(),
series: this.hashColorDygraphSeries(),
legendFormatter: this.legendFormatter,
highlightCallback: this.highlightCallback,
unhighlightCallback: this.unhighlightCallback,
plugins: [new Dygraphs.Plugins.Crosshair({direction: 'vertical'})],
axes: {
@ -130,6 +124,7 @@ export default class Dygraph extends Component {
const {labels, axes: {y, y2}, options, isBarGraph} = this.props
const dygraph = this.dygraph
if (!dygraph) {
throw new Error(
'Dygraph not configured in time; this should not be possible!'
@ -160,7 +155,7 @@ export default class Dygraph extends Component {
colors: this.getLineColors(),
series: this.hashColorDygraphSeries(),
plotter: isBarGraph ? barPlotter : null,
visibility: this.visibility(),
drawCallback: this.annotationsRef.heartbeat,
}
dygraph.updateOptions(updateOptions)
@ -220,25 +215,6 @@ export default class Dygraph extends Component {
}
}
handleSortLegend = sortType => () => {
this.setState({sortType, isAscending: !this.state.isAscending})
}
handleLegendInputChange = e => {
this.setState({filterText: e.target.value})
}
handleSnipLabel = () => {
this.setState({isSnipped: !this.state.isSnipped})
}
handleToggleFilter = () => {
this.setState({
isFilterVisible: !this.state.isFilterVisible,
filterText: '',
})
}
handleHideLegend = e => {
const {top, bottom, left, right} = this.graphRef.getBoundingClientRect()
@ -251,9 +227,6 @@ export default class Dygraph extends Component {
if (!isMouseHoveringGraph) {
this.setState({isHidden: true})
if (!this.visibility().find(bool => bool === true)) {
this.setState({filterText: ''})
}
}
}
@ -270,22 +243,6 @@ export default class Dygraph extends Component {
)
}
visibility = () => {
const timeSeries = this.getTimeSeries()
const {filterText, legend} = this.state
const series = _.get(timeSeries, '0', [])
const numSeries = series.length
return Array(numSeries ? numSeries - 1 : numSeries)
.fill(true)
.map((s, i) => {
if (!legend.series[i]) {
return true
}
return !!legend.series[i].label.match(filterText)
})
}
getTimeSeries = () => {
const {timeSeries} = this.props
// Avoid 'Can't plot empty data set' errors by falling back to a
@ -305,8 +262,6 @@ export default class Dygraph extends Component {
return buildDefaultYLabel(queryConfig)
}
handleLegendRef = el => (this.legendRef = el)
resize = () => {
this.dygraph.resizeElements_()
this.dygraph.predraw_()
@ -334,90 +289,61 @@ export default class Dygraph extends Component {
crosshair.plugin.deselect()
}
unhighlightCallback = e => {
const {top, bottom, left, right} = this.legendRef.getBoundingClientRect()
const mouseY = e.clientY
const mouseX = e.clientX
const mouseBuffer = 5
const mouseInLegendY = mouseY <= bottom && mouseY >= top - mouseBuffer
const mouseInLegendX = mouseX <= right && mouseX >= left
const isMouseHoveringLegend = mouseInLegendY && mouseInLegendX
if (!isMouseHoveringLegend) {
this.setState({isHidden: true})
if (!this.visibility().find(bool => bool === true)) {
this.setState({filterText: ''})
}
}
}
highlightCallback = ({pageX}) => {
this.pageX = pageX
handleShowLegend = () => {
this.setState({isHidden: false})
}
legendFormatter = legend => {
if (!legend.x) {
return ''
}
handleAnnotationsRef = ref => (this.annotationsRef = ref)
const {state: {legend: prevLegend}} = this
const highlighted = legend.series.find(s => s.isHighlighted)
const prevHighlighted = prevLegend.series.find(s => s.isHighlighted)
const yVal = highlighted && highlighted.y
const prevY = prevHighlighted && prevHighlighted.y
if (legend.x === prevLegend.x && yVal === prevY) {
return ''
}
this.setState({legend})
return ''
handleReceiveStaticLegendHeight = staticLegendHeight => {
this.setState({staticLegendHeight})
}
render() {
const {
legend,
sortType,
isHidden,
isSnipped,
filterText,
isAscending,
isFilterVisible,
} = this.state
const {isHidden, staticLegendHeight} = this.state
const {staticLegend} = this.props
let dygraphStyle = {...this.props.containerStyle, zIndex: '2'}
if (staticLegend) {
const cellVerticalPadding = 16
dygraphStyle = {
...this.props.containerStyle,
zIndex: '2',
height: `calc(100% - ${staticLegendHeight + cellVerticalPadding}px)`,
}
}
return (
<div className="dygraph-child" onMouseLeave={this.deselectCrosshair}>
<DygraphLegend
{...legend}
graph={this.graphRef}
legend={this.legendRef}
pageX={this.pageX}
sortType={sortType}
onHide={this.handleHideLegend}
isHidden={isHidden}
isFilterVisible={isFilterVisible}
isSnipped={isSnipped}
filterText={filterText}
isAscending={isAscending}
onSnip={this.handleSnipLabel}
onSort={this.handleSortLegend}
legendRef={this.handleLegendRef}
onToggleFilter={this.handleToggleFilter}
onInputChange={this.handleLegendInputChange}
/>
{this.dygraph &&
<Annotations
dygraph={this.dygraph}
annotationsRef={this.handleAnnotationsRef}
staticLegendHeight={staticLegendHeight}
/>}
{this.dygraph &&
<DygraphLegend
isHidden={isHidden}
dygraph={this.dygraph}
onHide={this.handleHideLegend}
onShow={this.handleShowLegend}
/>}
<div
ref={r => {
this.graphRef = r
this.props.dygraphRef(r)
}}
className="dygraph-child-container"
style={this.props.containerStyle}
style={dygraphStyle}
/>
{staticLegend &&
<StaticLegend
dygraph={this.dygraph}
handleReceiveStaticLegendHeight={
this.handleReceiveStaticLegendHeight
}
/>}
</div>
)
}
@ -445,6 +371,9 @@ Dygraph.defaultProps = {
overrideLineColors: null,
dygraphRef: () => {},
onZoom: () => {},
staticLegend: {
type: null,
},
}
Dygraph.propTypes = {
@ -463,6 +392,7 @@ Dygraph.propTypes = {
containerStyle: shape({}),
isGraphFilled: bool,
isBarGraph: bool,
staticLegend: bool,
overrideLineColors: array,
dygraphSeries: shape({}).isRequired,
ruleValues: shape({
@ -477,4 +407,11 @@ Dygraph.propTypes = {
setResolution: func,
dygraphRef: func,
onZoom: func,
mode: string,
}
const mapStateToProps = ({annotations: {mode}}) => ({
mode,
})
export default connect(mapStateToProps, null)(Dygraph)

View File

@ -1,169 +1,246 @@
import React, {PropTypes} from 'react'
import React, {PropTypes, Component} from 'react'
import _ from 'lodash'
import classnames from 'classnames'
import uuid from 'node-uuid'
import {makeLegendStyles} from 'shared/graphs/helpers'
import {makeLegendStyles, removeMeasurement} from 'shared/graphs/helpers'
const removeMeasurement = (label = '') => {
const [measurement] = label.match(/^(.*)[.]/g) || ['']
return label.replace(measurement, '')
}
class DygraphLegend extends Component {
state = {
legend: {
x: null,
series: [],
},
sortType: '',
isAscending: true,
filterText: '',
isSnipped: false,
isFilterVisible: false,
legendStyles: {},
pageX: null,
}
const DygraphLegend = ({
xHTML,
pageX,
graph,
legend,
series,
onSort,
onSnip,
onHide,
isHidden,
isSnipped,
sortType,
legendRef,
filterText,
isAscending,
onInputChange,
isFilterVisible,
onToggleFilter,
}) => {
const withValues = series.filter(s => !_.isNil(s.y))
const sorted = _.sortBy(
withValues,
({y, label}) => (sortType === 'numeric' ? y : label)
)
componentDidMount() {
this.props.dygraph.updateOptions({
legendFormatter: this.legendFormatter,
highlightCallback: this.highlightCallback,
unhighlightCallback: this.unhighlightCallback,
})
}
const ordered = isAscending ? sorted : sorted.reverse()
const filtered = ordered.filter(s => s.label.match(filterText))
const hidden = isHidden ? 'hidden' : ''
const style = makeLegendStyles(graph, legend, pageX)
componentWillUnmount() {
if (
!this.props.dygraph.graphDiv ||
!this.props.dygraph.visibility().find(bool => bool === true)
) {
this.setState({filterText: ''})
}
}
const renderSortAlpha = (
<div
className={classnames('sort-btn btn btn-sm btn-square', {
'btn-primary': sortType !== 'numeric',
'btn-default': sortType === 'numeric',
'sort-btn--asc': isAscending && sortType !== 'numeric',
'sort-btn--desc': !isAscending && sortType !== 'numeric',
})}
onClick={onSort('alphabetic')}
>
<div className="sort-btn--arrow" />
<div className="sort-btn--top">A</div>
<div className="sort-btn--bottom">Z</div>
</div>
)
const renderSortNum = (
<button
className={classnames('sort-btn btn btn-sm btn-square', {
'btn-primary': sortType === 'numeric',
'btn-default': sortType !== 'numeric',
'sort-btn--asc': isAscending && sortType === 'numeric',
'sort-btn--desc': !isAscending && sortType === 'numeric',
})}
onClick={onSort('numeric')}
>
<div className="sort-btn--arrow" />
<div className="sort-btn--top">0</div>
<div className="sort-btn--bottom">9</div>
</button>
)
return (
<div
className={`dygraph-legend ${hidden}`}
ref={legendRef}
onMouseLeave={onHide}
style={style}
>
<div className="dygraph-legend--header">
<div className="dygraph-legend--timestamp">
{xHTML}
</div>
{renderSortAlpha}
{renderSortNum}
<button
className={classnames('btn btn-square btn-sm', {
'btn-default': !isFilterVisible,
'btn-primary': isFilterVisible,
})}
onClick={onToggleFilter}
>
<span className="icon search" />
</button>
<button
className={classnames('btn btn-sm', {
'btn-default': !isSnipped,
'btn-primary': isSnipped,
})}
onClick={onSnip}
>
Snip
</button>
</div>
{isFilterVisible
? <input
className="dygraph-legend--filter form-control input-sm"
type="text"
value={filterText}
onChange={onInputChange}
placeholder="Filter items..."
autoFocus={true}
/>
: null}
<div className="dygraph-legend--divider" />
<div className="dygraph-legend--contents">
{filtered.map(({label, color, yHTML, isHighlighted}) => {
const seriesClass = isHighlighted
? 'dygraph-legend--row highlight'
: 'dygraph-legend--row'
return (
<div key={uuid.v4()} className={seriesClass}>
<span style={{color}}>
{isSnipped ? removeMeasurement(label) : label}
</span>
<figure>
{yHTML || 'no value'}
</figure>
</div>
)
handleToggleFilter = () => {
this.setState({
isFilterVisible: !this.state.isFilterVisible,
filterText: '',
})
}
handleSnipLabel = () => {
this.setState({isSnipped: !this.state.isSnipped})
}
handleLegendInputChange = e => {
const {dygraph} = this.props
const {legend} = this.state
const filterText = e.target.value
legend.series.map((s, i) => {
if (!legend.series[i]) {
return dygraph.setVisibility(i, true)
}
dygraph.setVisibility(i, !!legend.series[i].label.match(filterText))
})
this.setState({filterText})
}
handleSortLegend = sortType => () => {
this.setState({sortType, isAscending: !this.state.isAscending})
}
unhighlightCallback = e => {
const {
top,
bottom,
left,
right,
} = this.legendNodeRef.getBoundingClientRect()
const mouseY = e.clientY
const mouseX = e.clientX
const mouseBuffer = 5
const mouseInLegendY = mouseY <= bottom && mouseY >= top - mouseBuffer
const mouseInLegendX = mouseX <= right && mouseX >= left
const isMouseHoveringLegend = mouseInLegendY && mouseInLegendX
if (!isMouseHoveringLegend) {
this.props.onHide(e)
}
}
highlightCallback = ({pageX}) => {
this.setState({pageX})
this.props.onShow()
}
legendFormatter = legend => {
if (!legend.x) {
return ''
}
const {legend: prevLegend} = this.state
const highlighted = legend.series.find(s => s.isHighlighted)
const prevHighlighted = prevLegend.series.find(s => s.isHighlighted)
const yVal = highlighted && highlighted.y
const prevY = prevHighlighted && prevHighlighted.y
if (legend.x === prevLegend.x && yVal === prevY) {
return ''
}
this.legend = this.setState({legend})
return ''
}
render() {
const {dygraph, onHide, isHidden} = this.props
const {
pageX,
legend,
filterText,
isSnipped,
sortType,
isAscending,
isFilterVisible,
} = this.state
const withValues = legend.series.filter(s => !_.isNil(s.y))
const sorted = _.sortBy(
withValues,
({y, label}) => (sortType === 'numeric' ? y : label)
)
const ordered = isAscending ? sorted : sorted.reverse()
const filtered = ordered.filter(s => s.label.match(filterText))
const hidden = isHidden ? 'hidden' : ''
const style = makeLegendStyles(dygraph.graphDiv, this.legendNodeRef, pageX)
const renderSortAlpha = (
<div
className={classnames('sort-btn btn btn-sm btn-square', {
'btn-primary': sortType !== 'numeric',
'btn-default': sortType === 'numeric',
'sort-btn--asc': isAscending && sortType !== 'numeric',
'sort-btn--desc': !isAscending && sortType !== 'numeric',
})}
onClick={this.handleSortLegend('alphabetic')}
>
<div className="sort-btn--arrow" />
<div className="sort-btn--top">A</div>
<div className="sort-btn--bottom">Z</div>
</div>
</div>
)
)
const renderSortNum = (
<button
className={classnames('sort-btn btn btn-sm btn-square', {
'btn-primary': sortType === 'numeric',
'btn-default': sortType !== 'numeric',
'sort-btn--asc': isAscending && sortType === 'numeric',
'sort-btn--desc': !isAscending && sortType === 'numeric',
})}
onClick={this.handleSortLegend('numeric')}
>
<div className="sort-btn--arrow" />
<div className="sort-btn--top">0</div>
<div className="sort-btn--bottom">9</div>
</button>
)
return (
<div
className={`dygraph-legend ${hidden}`}
ref={el => (this.legendNodeRef = el)}
onMouseLeave={onHide}
style={style}
>
<div className="dygraph-legend--header">
<div className="dygraph-legend--timestamp">
{legend.xHTML}
</div>
{renderSortAlpha}
{renderSortNum}
<button
className={classnames('btn btn-square btn-sm', {
'btn-default': !isFilterVisible,
'btn-primary': isFilterVisible,
})}
onClick={this.handleToggleFilter}
>
<span className="icon search" />
</button>
<button
className={classnames('btn btn-sm', {
'btn-default': !isSnipped,
'btn-primary': isSnipped,
})}
onClick={this.handleSnipLabel}
>
Snip
</button>
</div>
{isFilterVisible
? <input
className="dygraph-legend--filter form-control input-sm"
type="text"
value={filterText}
onChange={this.handleLegendInputChange}
placeholder="Filter items..."
autoFocus={true}
/>
: null}
<div className="dygraph-legend--divider" />
<div className="dygraph-legend--contents">
{filtered.map(({label, color, yHTML, isHighlighted}) => {
const seriesClass = isHighlighted
? 'dygraph-legend--row highlight'
: 'dygraph-legend--row'
return (
<div key={uuid.v4()} className={seriesClass}>
<span style={{color}}>
{isSnipped ? removeMeasurement(label) : label}
</span>
<figure>
{yHTML || 'no value'}
</figure>
</div>
)
})}
</div>
</div>
)
}
}
const {arrayOf, bool, func, number, shape, string} = PropTypes
const {bool, func, shape} = PropTypes
DygraphLegend.propTypes = {
x: number,
xHTML: string,
series: arrayOf(
shape({
color: string,
dashHTML: string,
isVisible: bool,
label: string,
y: number,
yHTML: string,
})
),
pageX: number,
legend: shape({}),
graph: shape({}),
onSnip: func.isRequired,
dygraph: shape({}),
onHide: func.isRequired,
onSort: func.isRequired,
onInputChange: func.isRequired,
onToggleFilter: func.isRequired,
filterText: string.isRequired,
isAscending: bool.isRequired,
sortType: string.isRequired,
onShow: func.isRequired,
isHidden: bool.isRequired,
legendRef: func.isRequired,
isSnipped: bool.isRequired,
isFilterVisible: bool.isRequired,
}
export default DygraphLegend

View File

@ -4,7 +4,7 @@ import ReactTooltip from 'react-tooltip'
const GraphTips = React.createClass({
render() {
const graphTipsText =
'<h1>Graph Tips:</h1><p><code>Click + Drag</code> Zoom in (X or Y)<br/><code>Shift + Click</code> Pan Graph Window<br/><code>Double Click</code> Reset Graph Window</p>'
'<h1>Graph Tips:</h1><p><code>Click + Drag</code> Zoom in (X or Y)<br/><code>Shift + Click</code> Pan Graph Window<br/><code>Double Click</code> Reset Graph Window</p><h1>Static Legend Tips:</h1><p><code>Click</code>Focus on single Series<br/><code>Shift + Click</code> Show/Hide single Series</p>'
return (
<div
className="graph-tips"

View File

@ -3,6 +3,7 @@ import WidgetCell from 'shared/components/WidgetCell'
import LayoutCell from 'shared/components/LayoutCell'
import RefreshingGraph from 'shared/components/RefreshingGraph'
import {buildQueriesForLayouts} from 'utils/buildQueriesForLayouts'
import {IS_STATIC_LEGEND} from 'src/shared/constants'
import _ from 'lodash'
@ -16,11 +17,8 @@ const getSource = (cell, source, sources, defaultSource) => {
}
class LayoutState extends Component {
constructor(props) {
super(props)
this.state = {
celldata: [],
}
state = {
celldata: [],
}
grabDataForDownload = celldata => {
@ -28,11 +26,10 @@ class LayoutState extends Component {
}
render() {
const {celldata} = this.state
return (
<Layout
{...this.props}
celldata={celldata}
{...this.state}
grabDataForDownload={this.grabDataForDownload}
/>
)
@ -43,10 +40,11 @@ const Layout = (
{
host,
cell,
cell: {h, axes, type, colors},
cell: {h, axes, type, colors, legend},
source,
sources,
onZoom,
celldata,
templates,
timeRange,
isEditable,
@ -57,17 +55,17 @@ const Layout = (
synchronizer,
resizeCoords,
onCancelEditCell,
onStopAddAnnotation,
onSummonOverlayTechnologies,
grabDataForDownload,
celldata,
},
{source: defaultSource}
) =>
<LayoutCell
cell={cell}
celldata={celldata}
isEditable={isEditable}
onEditCell={onEditCell}
celldata={celldata}
onDeleteCell={onDeleteCell}
onCancelEditCell={onCancelEditCell}
onSummonOverlayTechnologies={onSummonOverlayTechnologies}
@ -79,14 +77,16 @@ const Layout = (
inView={cell.inView}
axes={axes}
type={type}
staticLegend={IS_STATIC_LEGEND(legend)}
cellHeight={h}
onZoom={onZoom}
sources={sources}
timeRange={timeRange}
templates={templates}
autoRefresh={autoRefresh}
manualRefresh={manualRefresh}
synchronizer={synchronizer}
manualRefresh={manualRefresh}
onStopAddAnnotation={onStopAddAnnotation}
grabDataForDownload={grabDataForDownload}
resizeCoords={resizeCoords}
queries={buildQueriesForLayouts(

View File

@ -10,23 +10,6 @@ import {dashboardtoCSV} from 'shared/parsing/resultsToCSV'
import download from 'src/external/download.js'
class LayoutCell extends Component {
constructor(props) {
super(props)
this.state = {
isDeleting: false,
}
}
closeMenu = () => {
this.setState({
isDeleting: false,
})
}
handleDeleteClick = () => {
this.setState({isDeleting: true})
}
handleDeleteCell = cell => () => {
this.props.onDeleteCell(cell)
}
@ -49,7 +32,6 @@ class LayoutCell extends Component {
render() {
const {cell, children, isEditable, celldata} = this.props
const {isDeleting} = this.state
const queries = _.get(cell, ['queries'], [])
// Passing the cell ID into the child graph so that further along
@ -64,12 +46,9 @@ class LayoutCell extends Component {
cell={cell}
queries={queries}
dataExists={!!celldata.length}
isDeleting={isDeleting}
isEditable={isEditable}
onDelete={this.handleDeleteCell}
onEdit={this.handleSummonOverlay}
handleClickOutside={this.closeMenu}
onDeleteClick={this.handleDeleteClick}
onCSVDownload={this.handleCSVDownload}
/>
</Authorized>

View File

@ -1,70 +1,137 @@
import React, {PropTypes} from 'react'
import OnClickOutside from 'react-onclickoutside'
import CustomTimeIndicator from 'shared/components/CustomTimeIndicator'
import React, {Component, PropTypes} from 'react'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
const LayoutCellMenu = OnClickOutside(
({
cell,
onEdit,
queries,
onDelete,
isEditable,
dataExists,
isDeleting,
onDeleteClick,
onCSVDownload,
}) =>
<div className="dash-graph-context">
import classnames from 'classnames'
import MenuTooltipButton from 'src/shared/components/MenuTooltipButton'
import CustomTimeIndicator from 'src/shared/components/CustomTimeIndicator'
import {EDITING} from 'src/shared/annotations/helpers'
import {cellSupportsAnnotations} from 'src/shared/constants/index'
import {
addingAnnotation,
editingAnnotation,
dismissEditingAnnotation,
} from 'src/shared/actions/annotations'
class LayoutCellMenu extends Component {
state = {
subMenuIsOpen: false,
}
handleToggleSubMenu = () => {
this.setState({subMenuIsOpen: !this.state.subMenuIsOpen})
}
render() {
const {subMenuIsOpen} = this.state
const {
mode,
cell,
onEdit,
queries,
onDelete,
isEditable,
dataExists,
onCSVDownload,
onStartAddingAnnotation,
onStartEditingAnnotation,
onDismissEditingAnnotation,
} = this.props
return (
<div
className={`${isEditable
? 'dash-graph--custom-indicators dash-graph--draggable'
: 'dash-graph--custom-indicators'}`}
className={classnames('dash-graph-context', {
'dash-graph-context__open': subMenuIsOpen,
})}
>
{queries && <CustomTimeIndicator queries={queries} />}
</div>
{isEditable &&
<div className="dash-graph-context--buttons">
<div className="dash-graph-context--button" onClick={onEdit(cell)}>
<span className="icon pencil" />
</div>
{dataExists &&
<div
className={`${isEditable
? 'dash-graph--custom-indicators dash-graph--draggable'
: 'dash-graph--custom-indicators'}`}
>
{queries && <CustomTimeIndicator queries={queries} />}
</div>
{isEditable &&
mode !== EDITING &&
<div className="dash-graph-context--buttons">
{queries.length
? <MenuTooltipButton
icon="pencil"
menuOptions={[
{text: 'Configure', action: onEdit(cell)},
{
text: 'Add Annotation',
action: onStartAddingAnnotation,
disabled: !cellSupportsAnnotations(cell.type),
},
{
text: 'Edit Annotations',
action: onStartEditingAnnotation,
disabled: !cellSupportsAnnotations(cell.type),
},
]}
informParent={this.handleToggleSubMenu}
/>
: null}
{dataExists &&
<MenuTooltipButton
icon="download"
menuOptions={[
{text: 'Download CSV', action: onCSVDownload(cell)},
]}
informParent={this.handleToggleSubMenu}
/>}
<MenuTooltipButton
icon="trash"
theme="danger"
menuOptions={[{text: 'Confirm', action: onDelete(cell)}]}
informParent={this.handleToggleSubMenu}
/>
</div>}
{mode === 'editing' &&
<div className="dash-graph-context--buttons">
<div
className="dash-graph-context--button"
onClick={onCSVDownload(cell)}
className="btn btn-xs btn-success"
onClick={onDismissEditingAnnotation}
>
<span className="icon download" />
</div>}
{isDeleting
? <div className="dash-graph-context--button active">
<span className="icon trash" />
<div
className="dash-graph-context--confirm"
onClick={onDelete(cell)}
>
Confirm
</div>
</div>
: <div
className="dash-graph-context--button"
onClick={onDeleteClick}
>
<span className="icon trash" />
</div>}
</div>}
</div>
)
Done Editing
</div>
</div>}
</div>
)
}
}
const {arrayOf, bool, func, shape} = PropTypes
const {arrayOf, bool, func, shape, string} = PropTypes
LayoutCellMenu.propTypes = {
isDeleting: bool,
mode: string,
onEdit: func,
onDelete: func,
onDeleteClick: func,
cell: shape(),
isEditable: bool,
dataExists: bool,
onCSVDownload: func,
queries: arrayOf(shape()),
onStartAddingAnnotation: func.isRequired,
onStartEditingAnnotation: func.isRequired,
onDismissEditingAnnotation: func.isRequired,
}
export default LayoutCellMenu
const mapStateToProps = ({annotations: {mode}}) => ({
mode,
})
const mapDispatchToProps = dispatch => ({
onStartAddingAnnotation: bindActionCreators(addingAnnotation, dispatch),
onStartEditingAnnotation: bindActionCreators(editingAnnotation, dispatch),
onDismissEditingAnnotation: bindActionCreators(
dismissEditingAnnotation,
dispatch
),
})
export default connect(mapStateToProps, mapDispatchToProps)(LayoutCellMenu)

View File

@ -127,12 +127,13 @@ class LayoutRenderer extends Component {
timeRange={timeRange}
isEditable={isEditable}
onEditCell={onEditCell}
resizeCoords={resizeCoords}
autoRefresh={autoRefresh}
manualRefresh={manualRefresh}
resizeCoords={resizeCoords}
onDeleteCell={onDeleteCell}
synchronizer={synchronizer}
manualRefresh={manualRefresh}
onCancelEditCell={onCancelEditCell}
onStopAddAnnotation={this.handleStopAddAnnotation}
onSummonOverlayTechnologies={onSummonOverlayTechnologies}
/>
</Authorized>

View File

@ -50,9 +50,11 @@ class LineGraph extends Component {
resizeCoords,
synchronizer,
isRefreshing,
setResolution,
isGraphFilled,
showSingleStat,
displayOptions,
staticLegend,
underlayCallback,
overrideLineColors,
isFetchingInitially,
@ -84,6 +86,13 @@ class LineGraph extends Component {
? SINGLE_STAT_LINE_COLORS
: overrideLineColors
const containerStyle = {
width: 'calc(100% - 32px)',
height: 'calc(100% - 16px)',
position: 'absolute',
top: '8px',
}
let prefix
let suffix
@ -101,18 +110,19 @@ class LineGraph extends Component {
onZoom={onZoom}
labels={labels}
queries={queries}
options={options}
timeRange={timeRange}
isBarGraph={isBarGraph}
timeSeries={timeSeries}
ruleValues={ruleValues}
synchronizer={synchronizer}
resizeCoords={resizeCoords}
overrideLineColors={lineColors}
dygraphSeries={dygraphSeries}
setResolution={this.props.setResolution}
containerStyle={{width: '100%', height: '100%'}}
setResolution={setResolution}
overrideLineColors={lineColors}
containerStyle={containerStyle}
staticLegend={staticLegend}
isGraphFilled={showSingleStat ? false : isGraphFilled}
options={options}
/>
{showSingleStat
? <SingleStat
@ -147,6 +157,7 @@ LineGraph.defaultProps = {
underlayCallback: () => {},
isGraphFilled: true,
overrideLineColors: null,
staticLegend: false,
}
LineGraph.propTypes = {
@ -166,6 +177,7 @@ LineGraph.propTypes = {
underlayCallback: func,
isGraphFilled: bool,
isBarGraph: bool,
staticLegend: bool,
overrideLineColors: array,
showSingleStat: bool,
displayOptions: shape({

View File

@ -0,0 +1,99 @@
import React, {Component, PropTypes} from 'react'
import OnClickOutside from 'react-onclickoutside'
import classnames from 'classnames'
class MenuTooltipButton extends Component {
state = {
expanded: false,
}
handleButtonClick = () => {
const {informParent} = this.props
this.setState({expanded: !this.state.expanded})
informParent()
}
handleMenuItemClick = menuItemAction => () => {
const {informParent} = this.props
this.setState({expanded: false})
menuItemAction()
informParent()
}
handleClickOutside = () => {
const {informParent} = this.props
const {expanded} = this.state
if (expanded === false) {
return
}
this.setState({expanded: false})
informParent()
}
renderMenuOptions = () => {
const {menuOptions} = this.props
const {expanded} = this.state
if (expanded === false) {
return null
}
return menuOptions.map((option, i) =>
<div
key={i}
className={`dash-graph-context--menu-item${option.disabled
? ' disabled'
: ''}`}
onClick={
option.disabled ? null : this.handleMenuItemClick(option.action)
}
>
{option.text}
</div>
)
}
render() {
const {icon, theme} = this.props
const {expanded} = this.state
return (
<div
className={classnames('dash-graph-context--button', {active: expanded})}
onClick={this.handleButtonClick}
>
<span className={`icon ${icon}`} />
{expanded
? <div className={`dash-graph-context--menu ${theme}`}>
{this.renderMenuOptions()}
</div>
: null}
</div>
)
}
}
const {arrayOf, bool, func, shape, string} = PropTypes
MenuTooltipButton.defaultProps = {
theme: 'default',
}
MenuTooltipButton.propTypes = {
theme: string, // accepted values: default, primary, warning, success, danger
icon: string.isRequired,
menuOptions: arrayOf(
shape({
text: string.isRequired,
action: func.isRequired,
disabled: bool,
})
).isRequired,
informParent: func,
}
export default OnClickOutside(MenuTooltipButton)

View File

@ -0,0 +1,214 @@
import React, {Component, PropTypes} from 'react'
import classnames from 'classnames'
import {connect} from 'react-redux'
import uuid from 'node-uuid'
import OnClickOutside from 'shared/components/OnClickOutside'
import AnnotationWindow from 'shared/components/AnnotationWindow'
import * as schema from 'shared/schemas'
import * as actions from 'shared/actions/annotations'
import {DYGRAPH_CONTAINER_XLABEL_MARGIN} from 'shared/constants'
class NewAnnotation extends Component {
state = {
isMouseOver: false,
gatherMode: 'startTime',
}
clampWithinGraphTimerange = timestamp => {
const [xRangeStart] = this.props.dygraph.xAxisRange()
return Math.max(xRangeStart, timestamp)
}
eventToTimestamp = ({pageX: pxBetweenMouseAndPage}) => {
const {left: pxBetweenGraphAndPage} = this.wrapper.getBoundingClientRect()
const graphXCoordinate = pxBetweenMouseAndPage - pxBetweenGraphAndPage
const timestamp = this.props.dygraph.toDataXCoord(graphXCoordinate)
const clamped = this.clampWithinGraphTimerange(timestamp)
return `${clamped}`
}
handleMouseDown = e => {
const startTime = this.eventToTimestamp(e)
this.props.onUpdateAnnotation({...this.props.tempAnnotation, startTime})
this.setState({gatherMode: 'endTime'})
}
handleMouseMove = e => {
if (this.props.isTempHovering === false) {
return
}
const {tempAnnotation, onUpdateAnnotation} = this.props
const newTime = this.eventToTimestamp(e)
if (this.state.gatherMode === 'startTime') {
onUpdateAnnotation({
...tempAnnotation,
startTime: newTime,
endTime: newTime,
})
} else {
onUpdateAnnotation({...tempAnnotation, endTime: newTime})
}
}
handleMouseUp = e => {
const {
tempAnnotation,
onUpdateAnnotation,
addAnnotationAsync,
onAddingAnnotationSuccess,
onMouseLeaveTempAnnotation,
} = this.props
const createUrl = this.context.source.links.annotations
const upTime = this.eventToTimestamp(e)
const downTime = tempAnnotation.startTime
const [startTime, endTime] = [downTime, upTime].sort()
const newAnnotation = {...tempAnnotation, startTime, endTime}
onUpdateAnnotation(newAnnotation)
addAnnotationAsync(createUrl, {...newAnnotation, id: uuid.v4()})
onAddingAnnotationSuccess()
onMouseLeaveTempAnnotation()
this.setState({
isMouseOver: false,
gatherMode: 'startTime',
})
}
handleMouseOver = e => {
this.setState({isMouseOver: true})
this.handleMouseMove(e)
this.props.onMouseEnterTempAnnotation()
}
handleMouseLeave = () => {
this.setState({isMouseOver: false})
this.props.onMouseLeaveTempAnnotation()
}
handleClickOutside = () => {
const {onDismissAddingAnnotation, isTempHovering} = this.props
if (!isTempHovering) {
onDismissAddingAnnotation()
}
}
renderTimestamp(time) {
const timestamp = `${new Date(+time)}`
return (
<div className="new-annotation-tooltip">
<span className="new-annotation-helper">Click or Drag to Annotate</span>
<span className="new-annotation-timestamp">
{timestamp}
</span>
</div>
)
}
render() {
const {
dygraph,
isTempHovering,
tempAnnotation,
tempAnnotation: {startTime, endTime},
staticLegendHeight,
} = this.props
const {isMouseOver} = this.state
const crosshairOne = Math.max(-1000, dygraph.toDomXCoord(startTime))
const crosshairTwo = dygraph.toDomXCoord(endTime)
const crosshairHeight = `calc(100% - ${staticLegendHeight +
DYGRAPH_CONTAINER_XLABEL_MARGIN}px)`
const isDragging = startTime !== endTime
const flagOneClass =
crosshairOne < crosshairTwo
? 'annotation-span--left-flag dragging'
: 'annotation-span--right-flag dragging'
const flagTwoClass =
crosshairOne < crosshairTwo
? 'annotation-span--right-flag dragging'
: 'annotation-span--left-flag dragging'
const pointFlagClass = 'annotation-point--flag__dragging'
return (
<div>
{isDragging &&
<AnnotationWindow
annotation={tempAnnotation}
dygraph={dygraph}
active={true}
staticLegendHeight={staticLegendHeight}
/>}
<div
className={classnames('new-annotation', {
hover: isTempHovering,
})}
ref={el => (this.wrapper = el)}
onMouseMove={this.handleMouseMove}
onMouseOver={this.handleMouseOver}
onMouseLeave={this.handleMouseLeave}
onMouseUp={this.handleMouseUp}
onMouseDown={this.handleMouseDown}
>
{isDragging &&
<div
className="new-annotation--crosshair"
style={{left: crosshairTwo, height: crosshairHeight}}
>
{isMouseOver &&
isDragging &&
this.renderTimestamp(tempAnnotation.endTime)}
<div className={flagTwoClass} />
</div>}
<div
className="new-annotation--crosshair"
style={{left: crosshairOne, height: crosshairHeight}}
>
{isMouseOver &&
!isDragging &&
this.renderTimestamp(tempAnnotation.startTime)}
<div className={isDragging ? flagOneClass : pointFlagClass} />
</div>
</div>
</div>
)
}
}
const {bool, func, number, shape, string} = PropTypes
NewAnnotation.contextTypes = {
source: shape({
links: shape({
annotations: string,
}),
}),
}
NewAnnotation.propTypes = {
dygraph: shape({}).isRequired,
isTempHovering: bool,
tempAnnotation: schema.annotation.isRequired,
addAnnotationAsync: func.isRequired,
onDismissAddingAnnotation: func.isRequired,
onAddingAnnotationSuccess: func.isRequired,
onUpdateAnnotation: func.isRequired,
onMouseEnterTempAnnotation: func.isRequired,
onMouseLeaveTempAnnotation: func.isRequired,
staticLegendHeight: number,
}
const mdtp = {
addAnnotationAsync: actions.addAnnotationAsync,
}
export default connect(null, mdtp)(OnClickOutside(NewAnnotation))

View File

@ -24,6 +24,7 @@ const RefreshingGraph = ({
cellHeight,
autoRefresh,
resizerTopHeight,
staticLegend,
manualRefresh, // when changed, re-mounts the component
synchronizer,
resizeCoords,
@ -54,6 +55,7 @@ const RefreshingGraph = ({
cellHeight={cellHeight}
prefix={prefix}
suffix={suffix}
inView={inView}
/>
)
}
@ -72,6 +74,7 @@ const RefreshingGraph = ({
cellID={cellID}
prefix={prefix}
suffix={suffix}
inView={inView}
/>
)
}
@ -95,6 +98,7 @@ const RefreshingGraph = ({
isBarGraph={type === 'bar'}
synchronizer={synchronizer}
resizeCoords={resizeCoords}
staticLegend={staticLegend}
displayOptions={displayOptions}
editQueryStatus={editQueryStatus}
grabDataForDownload={grabDataForDownload}
@ -119,6 +123,7 @@ RefreshingGraph.propTypes = {
axes: shape(),
queries: arrayOf(shape()).isRequired,
editQueryStatus: func,
staticLegend: bool,
onZoom: func,
resizeCoords: shape(),
grabDataForDownload: func,
@ -137,6 +142,7 @@ RefreshingGraph.propTypes = {
RefreshingGraph.defaultProps = {
manualRefresh: 0,
staticLegend: false,
inView: true,
}

View File

@ -0,0 +1,113 @@
import React, {PropTypes, Component} from 'react'
import _ from 'lodash'
import uuid from 'node-uuid'
import {removeMeasurement} from 'shared/graphs/helpers'
const staticLegendItemClassname = (visibilities, i, hoverEnabled) => {
if (visibilities.length) {
return `${hoverEnabled
? 'static-legend--item'
: 'static-legend--single'}${visibilities[i] ? '' : ' disabled'}`
}
// all series are visible to match expected initial state
return 'static-legend--item'
}
class StaticLegend extends Component {
constructor(props) {
super(props)
}
state = {
visibilities: [],
clickStatus: false,
}
componentDidMount = () => {
const {height} = this.staticLegendRef.getBoundingClientRect()
this.props.handleReceiveStaticLegendHeight(height)
}
componentDidUpdate = () => {
const {height} = this.staticLegendRef.getBoundingClientRect()
this.props.handleReceiveStaticLegendHeight(height)
}
componentWillUnmount = () => {
this.props.handleReceiveStaticLegendHeight(null)
}
handleClick = i => e => {
const visibilities = this.props.dygraph.visibility()
const clickStatus = this.state.clickStatus
if (e.shiftKey || e.metaKey) {
visibilities[i] = !visibilities[i]
this.props.dygraph.setVisibility(visibilities)
this.setState({visibilities})
return
}
const prevClickStatus = clickStatus && visibilities[i]
const newVisibilities = prevClickStatus
? _.map(visibilities, () => true)
: _.map(visibilities, () => false)
newVisibilities[i] = true
this.props.dygraph.setVisibility(newVisibilities)
this.setState({
visibilities: newVisibilities,
clickStatus: !prevClickStatus,
})
}
render() {
const {dygraph} = this.props
const {visibilities} = this.state
const labels = dygraph ? _.drop(dygraph.getLabels()) : []
const colors = dygraph
? _.map(labels, l => dygraph.attributes_.series_[l].options.color)
: []
const hoverEnabled = labels.length > 1
return (
<div
className="static-legend"
ref={s => {
this.staticLegendRef = s
}}
>
{_.map(labels, (v, i) =>
<div
className={staticLegendItemClassname(visibilities, i, hoverEnabled)}
key={uuid.v4()}
onMouseDown={this.handleClick(i)}
>
<div
className="static-legend--dot"
style={{backgroundColor: colors[i]}}
/>
<span style={{color: colors[i]}}>
{removeMeasurement(v)}
</span>
</div>
)}
</div>
)
}
}
const {shape, func} = PropTypes
StaticLegend.propTypes = {
sharedLegend: shape({}),
dygraph: shape({}),
handleReceiveStaticLegendHeight: func.isRequired,
}
export default StaticLegend

View File

@ -1,3 +1,5 @@
import _ from 'lodash'
export const PERMISSIONS = {
ViewAdmin: {
description: 'Can view or edit admin screens',
@ -413,6 +415,10 @@ export const PAGE_CONTAINER_MARGIN = 30 // TODO: get this dynamically to ensure
export const LAYOUT_MARGIN = 4
export const DASHBOARD_LAYOUT_ROW_HEIGHT = 83.5
export const DYGRAPH_CONTAINER_H_MARGIN = 16
export const DYGRAPH_CONTAINER_V_MARGIN = 8
export const DYGRAPH_CONTAINER_XLABEL_MARGIN = 20
export const DEFAULT_SOURCE = {
url: 'http://localhost:8086',
name: 'Influx 1',
@ -424,4 +430,18 @@ export const DEFAULT_SOURCE = {
metaUrl: '',
}
export const IS_STATIC_LEGEND = legend =>
_.get(legend, 'type', false) === 'static'
export const linksLink = '/chronograf/v1'
export const cellSupportsAnnotations = cellType => {
const supportedTypes = [
'line',
'bar',
'line-plus-single-stat',
'line-stacked',
'line-stepplot',
]
return !!supportedTypes.find(type => type === cellType)
}

View File

@ -173,6 +173,12 @@ export const makeLegendStyles = (graph, legend, pageX) => {
}
}
// globally matches anything that ends in a '.'
export const removeMeasurement = (label = '') => {
const [measurement] = label.match(/^(.*)[.]/g) || ['']
return label.replace(measurement, '')
}
export const OPTIONS = {
rightGap: 0,
axisLineWidth: 2,

View File

@ -0,0 +1,122 @@
import {ADDING, EDITING, TEMP_ANNOTATION} from 'src/shared/annotations/helpers'
const initialState = {
mode: null,
isTempHovering: false,
annotations: [],
}
const annotationsReducer = (state = initialState, action) => {
switch (action.type) {
case 'EDITING_ANNOTATION': {
return {
...state,
mode: EDITING,
}
}
case 'DISMISS_EDITING_ANNOTATION': {
return {
...state,
mode: null,
}
}
case 'ADDING_ANNOTATION': {
const annotations = state.annotations.filter(
a => a.id !== TEMP_ANNOTATION.id
)
return {
...state,
mode: ADDING,
isTempHovering: true,
annotations: [...annotations, TEMP_ANNOTATION],
}
}
case 'ADDING_ANNOTATION_SUCCESS': {
return {
...state,
isTempHovering: false,
mode: null,
}
}
case 'DISMISS_ADDING_ANNOTATION': {
const annotations = state.annotations.filter(
a => a.id !== TEMP_ANNOTATION.id
)
return {
...state,
isTempHovering: false,
mode: null,
annotations,
}
}
case 'MOUSEENTER_TEMP_ANNOTATION': {
const newState = {
...state,
isTempHovering: true,
}
return newState
}
case 'MOUSELEAVE_TEMP_ANNOTATION': {
const newState = {
...state,
isTempHovering: false,
}
return newState
}
case 'LOAD_ANNOTATIONS': {
const {annotations} = action.payload
return {
...state,
annotations,
}
}
case 'UPDATE_ANNOTATION': {
const {annotation} = action.payload
const annotations = state.annotations.map(
a => (a.id === annotation.id ? annotation : a)
)
return {
...state,
annotations,
}
}
case 'DELETE_ANNOTATION': {
const {annotation} = action.payload
const annotations = state.annotations.filter(a => a.id !== annotation.id)
return {
...state,
annotations,
}
}
case 'ADD_ANNOTATION': {
const {annotation} = action.payload
const annotations = [...state.annotations, annotation]
return {
...state,
annotations,
}
}
}
return state
}
export default annotationsReducer

View File

@ -5,14 +5,16 @@ import errors from './errors'
import links from './links'
import {notifications, dismissedNotifications} from './notifications'
import sources from './sources'
import annotations from './annotations'
export default {
app,
auth,
links,
config,
errors,
links,
sources,
annotations,
notifications,
dismissedNotifications,
sources,
}

11
ui/src/shared/schemas.js Normal file
View File

@ -0,0 +1,11 @@
import {PropTypes} from 'react'
const {shape, string} = PropTypes
export const annotation = shape({
id: string.isRequired,
startTime: string.isRequired,
endTime: string.isRequired,
text: string.isRequired,
type: string.isRequired,
})

View File

@ -6,6 +6,7 @@ export const fixtureStatusPageCells = [
y: 0,
w: 12,
h: 4,
legend: {},
name: 'Alert Events per Day Last 30 Days',
queries: [
{
@ -54,6 +55,7 @@ export const fixtureStatusPageCells = [
y: 5,
w: 6.5,
h: 6,
legend: {},
queries: [
{
query: '',
@ -80,6 +82,7 @@ export const fixtureStatusPageCells = [
y: 5,
w: 3,
h: 6,
legend: {},
queries: [
{
query: '',
@ -106,6 +109,7 @@ export const fixtureStatusPageCells = [
y: 5,
w: 2.5,
h: 6,
legend: {},
queries: [
{
query: '',

View File

@ -27,6 +27,7 @@
@import 'layout/flash-messages';
// Components
@import 'components/annotations';
@import 'components/ceo-display-options';
@import 'components/confirm-button';
@import 'components/confirm-buttons';
@ -51,6 +52,7 @@
@import 'components/page-header-dropdown';
@import 'components/page-header-editable';
@import 'components/page-spinner';
@import 'components/static-legend';
@import 'components/query-maker';
@import 'components/react-tooltips';
@import 'components/redacted-input';

View File

@ -0,0 +1,280 @@
$annotation-color: $g20-white;
$annotation-color__drag: $c-hydrogen;
$window0: rgba($annotation-color,0);
$window15: rgba($annotation-color,0.15);
$window35: rgba($annotation-color,0.35);
$active-window0: rgba($annotation-color__drag,0);
$active-window15: rgba($annotation-color__drag,0.15);
$active-window35: rgba($annotation-color__drag,0.35);
$timestamp-font-size: 14px;
$timestamp-font-weight: 600;
.annotation {
position: absolute;
top: 8px;
z-index: 3;
background-color: $annotation-color;
height: calc(100% - 36px);
width: 2px;
transform: translateX(-1px); // translate should always be half with width to horizontally center the annotation pos
transition: background-color 0.25s ease;
visibility: visible;
&.dragging {
background-color: $annotation-color__drag;
z-index: 4;
}
}
.annotation-point--flag {
position: absolute;
z-index: 2;
top: -3px;
left: -2px;
width: 6px;
height: 6px;
background-color: $annotation-color;
border-radius: 50%;
transition:
transform 0.25s ease,
background-color 0.25s ease;
}
.annotation-point--flag__dragging {
@extend .annotation-point--flag;
transform: scale(1.5,1.5);
background-color: $annotation-color__drag;
}
.annotation-span--flag {
position: absolute;
z-index: 2;
top: -6px;
width: 0;
height: 0;
border: 6px solid transparent;
border-radius: 0;
background: none;
transition: transform 0.25s ease;
&.mouseover {
transform: scale(1.5,1.5);
}
}
.annotation-span--left-flag {
@extend .annotation-span--flag;
transform-origin: 0% 50%;
left: 0;
border-left-color: $annotation-color;
&.dragging {
border-left-color: $annotation-color__drag;
}
}
.annotation-span--right-flag {
@extend .annotation-span--flag;
transform-origin: 100% 50%;
right: 0;
border-right-color: $annotation-color;
&.dragging {
border-right-color: $annotation-color__drag;
}
}
.annotation-tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background-color: $g0-obsidian;
z-index: 3;
padding: 6px 12px;
border-radius: 4px;
white-space: nowrap;
user-select: none;
display: flex;
box-shadow: 0 0 10px 2px $g2-kevlar;
&:before {
content: '';
display: block;
position: absolute;
width: calc(100% + 16px);
height: calc(100% + 28px);
top: -14px;
left: -8px;
z-index: -1;
}
&.hidden {
display: none;
}
}
.annotation-tooltip--delete {
position: relative;
width: 22px;
height: 22px;
margin-left: 4px;
padding: 0;
border-radius: 3px;
background-color: $g2-kevlar;
color: $c-curacao;
transition: color 0.25s ease;
outline: none;
border: none;
font-size: 13px;
&:hover {
color: $c-tungsten;
cursor: pointer;
}
}
.annotation-tooltip--input-container {
flex: 1 0 0;
margin-bottom: 4px;
}
.annotation-tooltip--input,
.annotation-tooltip--input-container .input-cte {
width: 100%;
}
.annotation-tooltip--input-container .input-cte {
height: 22px;
font-size: 12px;
line-height: 18px;
padding: 0 7px;
> span.icon {
right: 9px;
}
}
.annotation-tooltip--timestamp {
color: $g20-white;
display: block;
font-size: $timestamp-font-size;
font-weight: $timestamp-font-weight;
}
.annotation-tooltip--items {
display: flex;
flex-direction: column;
align-items: stretch;
> div {
display: flex;
flex-wrap: nowrap;
justify-content: center;
}
}
.annotation-tooltip--form {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
width: 100%;
}
.annotation-tooltip--input-button {
margin-left: 2px;
}
.annotation--click-area {
position: absolute;
top: -8px;
left: -7px;
width: 16px;
height: 16px;
z-index: 4;
cursor: default;
&.editing {
left: -5px;
width: 12px;
height: calc(100% + 8px);
cursor: col-resize;
}
}
.annotation-window {
position: absolute;
top: 8px;
background: linear-gradient(to bottom, $window15 0%, $window0 100%);
border-top: 2px dotted $window35;
z-index: 1;
&.active {
background: linear-gradient(to bottom, $active-window15 0%, $active-window0 100%);
border-top: 2px dotted $active-window35;
}
}
/*
New Annotations
------------------------------------------------------------------------------
*/
.new-annotation {
position: absolute;
z-index: 9999;
top: 8px;
width: calc(100% - 32px);
height: calc(100% - 16px);
cursor: pointer;
}
.new-annotation.hover .new-annotation--crosshair {
opacity: 1;
}
.new-annotation--crosshair {
opacity: 0;
position: absolute;
top: 0;
height: calc(100% - 20px);
width: 2px;
transform: translateX(-1px);
background: linear-gradient(to bottom, $c-hydrogen 0%, $c-pool 100%);
transition: opacity 0.4s ease;
z-index: 5;
cursor: pointer;
}
.new-annotation-tooltip {
display: flex;
flex-direction: column;
align-items: center;
background: linear-gradient(to bottom, $c-pool 0%,$c-ocean 100%);
border-radius: 4px;
padding: 6px 12px;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.new-annotation-helper {
white-space: nowrap;
font-size: 13px;
line-height: 13px;
font-weight: 600;
color: $c-neutrino;
margin-bottom: 4px;
}
.new-annotation-timestamp {
white-space: nowrap;
font-size: $timestamp-font-size;
line-height: $timestamp-font-size;
font-weight: $timestamp-font-weight;
color: $g20-white;
}

View File

@ -120,9 +120,6 @@ $graph-gutter: 16px;
height: 100%;
padding: 8px 16px;
}
& > .dygraph > .dygraph-child > .dygraph-child-container {
height: 100% !important;
}
}
.graph-empty {

View File

@ -45,6 +45,7 @@
> span.icon {opacity: 1;}
}
}
.input-cte__disabled {
border-color: $g4-onyx;
background-color: $g4-onyx;
@ -60,4 +61,4 @@
&:hover {
color: $g9-mountain;
}
}
}

View File

@ -43,13 +43,13 @@ $tooltip-code-color: $c-potassium;
h1 {
font-size: 14px;
font-weight: 600;
margin: 0 0 8px 0;
margin: 8px 0;
line-height: 1.125em;
letter-spacing: 0;
font-family: $default-font;
&:only-child {
margin: 0;
&:first-of-type {
margin-top: 0;
}
}

View File

@ -0,0 +1,79 @@
/*
static Legend
------------------------------------------------------------------------------
Seen in a dashboard cell, below the graph
NOTE: Styles for the parent are stored in Javascript, in staticLegend.js
*/
.static-legend {
position: absolute;
width: calc(100% - 32px);
bottom: 8px;
left: 16px;
display: flex;
padding-top: 8px;
align-items: flex-end;
flex-wrap: wrap;
max-height: 50%;
overflow: auto;
@include custom-scrollbar($g3-castle,$g6-smoke);
}
.static-legend--dot {
display: inline-block;
vertical-align: middle;
margin-right: 4px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: $g20-white;
}
.static-legend--item,
.static-legend--single {
height: 22px;
line-height: 22px;
white-space: nowrap;
background-color: $g4-onyx;
border-radius: 3px;
color: $g20-white;
font-size: 12px;
font-weight: 600;
padding: 0 7px;
margin: 1px;
}
.static-legend--item {
transition: background-color 0.25s ease, color 0.25s ease;
span,
.static-legend--dot {
opacity: 0.8;
transition: opacity 0.25s ease;
}
&:hover {
cursor: pointer;
background-color: $g6-smoke;
span,
.static-legend--dot {
opacity: 1;
}
}
&.disabled {
background-color: $g1-raven;
font-style: italic;
span,
.static-legend--dot {
opacity: 0.35;
}
&:hover {
background-color: $g2-kevlar;
span,
.static-legend--dot {
opacity: 0.65;
}
}
}
}

View File

@ -192,7 +192,6 @@ $dash-graph-options-arrow: 8px;
.dash-graph--custom-indicators {
height: 24px;
border-radius: 3px;
top: 3px;
display: flex;
cursor: default;
@ -227,6 +226,9 @@ $dash-graph-options-arrow: 8px;
align-items: center;
flex-wrap: nowrap;
}
.dash-graph-context.dash-graph-context__open {
z-index: 20;
}
.dash-graph-context--buttons {
display: flex;
}
@ -237,6 +239,7 @@ $dash-graph-options-arrow: 8px;
font-size: 12px;
position: relative;
color: $g11-sidewalk;
margin-right: 2px;
transition: color 0.25s ease, background-color 0.25s ease;
&:hover,
@ -245,8 +248,8 @@ $dash-graph-options-arrow: 8px;
color: $g20-white;
background-color: $g5-pepper;
}
&:first-child {
margin-right: 2px;
&:last-child {
margin-right: 0px;
}
> .icon {
@ -255,41 +258,104 @@ $dash-graph-options-arrow: 8px;
left: 50%;
transform: translate(-50%, -50%);
}
&.active {
position: relative;
z-index: 20;
}
}
.dash-graph-context--confirm {
.dash-graph-context--menu,
.dash-graph-context--menu.default {
z-index: 3;
position: absolute;
top: calc(100% + 8px);
left: 50%;
background-color: $g6-smoke;
transform: translateX(-50%);
width: 58px;
height: 24px;
border-radius: 3px;
text-align: center;
font-size: 12px;
font-weight: 700;
line-height: 24px;
background-color: $c-curacao;
color: $g20-white;
transition: background-color 0.25s ease;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
&:before {
position: absolute;
content: '';
border: 6px solid transparent;
border-bottom-color: $c-curacao;
border-bottom-color: $g6-smoke;
left: 50%;
top: 0;
transform: translate(-50%, -100%);
transition: border-color 0.25s ease;
}
&:hover {
background-color: $c-dreamsicle;
cursor: pointer;
.dash-graph-context--menu-item {
@include no-user-select();
white-space: nowrap;
font-size: 12px;
font-weight: 700;
line-height: 26px;
height: 26px;
padding: 0 10px;
color: $g20-white;
transition: background-color 0.25s ease;
&:first-child {
border-top-left-radius: 3px;
border-top-right-radius: 3px;
}
&:last-child {
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
}
&:hover {
background-color: $g8-storm;
cursor: pointer;
}
&.disabled,
&.disabled:hover {
cursor: default;
background-color: transparent;
font-style: italic;
color: $g11-sidewalk;
}
}
&:hover:before {
border-bottom-color: $c-dreamsicle;
}
.dash-graph-context--menu.primary {
background-color: $c-ocean;
&:before {
border-bottom-color: $c-ocean;
}
.dash-graph-context--menu-item:hover {
background-color: $c-pool;
}
}
.dash-graph-context--menu.warning {
background-color: $c-star;
&:before {
border-bottom-color: $c-star;
}
.dash-graph-context--menu-item:hover {
background-color: $c-comet;
}
}
.dash-graph-context--menu.success {
background-color: $c-rainforest;
&:before {
border-bottom-color: $c-rainforest;
}
.dash-graph-context--menu-item:hover {
background-color: $c-honeydew;
}
}
.dash-graph-context--menu.danger {
background-color: $c-curacao;
&:before {
border-bottom-color: $c-curacao;
}
.dash-graph-context--menu-item:hover {
background-color: $c-dreamsicle;
}
}

View File

@ -1,71 +0,0 @@
import React from 'react'
import {storiesOf, action, linkTo} from '@kadira/storybook'
import Center from './components/Center'
import MultiSelectDropdown from 'shared/components/MultiSelectDropdown'
import Tooltip from 'shared/components/Tooltip'
storiesOf('MultiSelectDropdown', module)
.add('Select Roles w/label', () => (
<Center>
<MultiSelectDropdown
items={[
'Admin',
'User',
'Chrono Girafferoo',
'Prophet',
'Susford',
]}
selectedItems={[
'User',
'Chrono Girafferoo',
]}
label={'Select Roles'}
onApply={action('onApply')}
/>
</Center>
))
.add('Selected Item list', () => (
<Center>
<MultiSelectDropdown
items={[
'Admin',
'User',
'Chrono Giraffe',
'Prophet',
'Susford',
]}
selectedItems={[
'User',
'Chrono Giraffe',
]}
onApply={action('onApply')}
/>
</Center>
))
.add('0 selected items', () => (
<Center>
<MultiSelectDropdown
items={[
'Admin',
'User',
'Chrono Giraffe',
'Prophet',
'Susford',
]}
selectedItems={[]}
onApply={action('onApply')}
/>
</Center>
))
storiesOf('Tooltip', module)
.add('Delete', () => (
<Center>
<Tooltip tip={`Are you sure? TrashIcon`}>
<div className="btn btn-info btn-sm">
Delete
</div>
</Tooltip>
</Center>
))

View File

@ -1,14 +0,0 @@
import React from 'react'
const Center = ({children}) => (
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%)',
}}>
{children}
</div>
)
export default Center

View File

@ -1,6 +0,0 @@
// CSS
import 'src/style/chronograf.scss';
// Kapacitor Stories
import './kapacitor'
import './admin'

View File

@ -1,93 +0,0 @@
import React from 'react'
import {storiesOf, action, linkTo} from '@kadira/storybook'
import {spyActions} from './shared'
// Stubs
import kapacitor from './stubs/kapacitor'
import source from './stubs/source'
import rule from './stubs/rule'
import query from './stubs/query'
import queryConfigs from './stubs/queryConfigs'
// Actions for Spies
import * as kapacitorActions from 'src/kapacitor/actions/view'
import * as queryActions from 'src/data_explorer/actions/view'
// Components
import KapacitorRule from 'src/kapacitor/components/KapacitorRule'
import ValuesSection from 'src/kapacitor/components/ValuesSection'
const valuesSection = (trigger, values) =>
<div className="rule-builder">
<ValuesSection
rule={rule({
trigger,
values,
})}
query={query()}
onChooseTrigger={action('chooseTrigger')}
onUpdateValues={action('updateRuleValues')}
/>
</div>
storiesOf('ValuesSection', module)
.add('Threshold', () =>
valuesSection('threshold', {
operator: 'less than',
rangeOperator: 'greater than',
value: '10',
rangeValue: '20',
})
)
.add('Threshold inside Range', () =>
valuesSection('threshold', {
operator: 'inside range',
rangeOperator: 'greater than',
value: '10',
rangeValue: '20',
})
)
// .add('Threshold outside of Range', () => (
// valuesSection('threshold', {
// "operator": "otuside of range",
// "rangeOperator": "less than",
// "value": "10",
// "rangeValue": "20",
// })
// ))
.add('Relative', () =>
valuesSection('relative', {
change: 'change',
operator: 'greater than',
shift: '1m',
value: '10',
})
)
.add('Deadman', () =>
valuesSection('deadman', {
period: '10m',
})
)
storiesOf('KapacitorRule', module).add('Threshold', () =>
<div className="chronograf-root">
<KapacitorRule
source={source()}
rule={rule({
trigger: 'threshold',
})}
query={query()}
queryConfigs={queryConfigs()}
kapacitor={kapacitor()}
queryActions={spyActions(queryActions)}
kapacitorActions={spyActions(kapacitorActions)}
addFlashMessage={action('addFlashMessage')}
enabledAlerts={['slack']}
isEditing={true}
router={{
push: action('route'),
}}
/>
</div>
)

View File

@ -1,8 +0,0 @@
export const spyActions = (actions) => Object.keys(actions)
.reduce((acc, a) => {
acc[a] = (...evt) => {
action(a)(...evt);
return actions[a](...evt);
};
return acc;
}, {});

View File

@ -1,16 +0,0 @@
const kapacitor = () => {
return ({
"id": "1",
"name": "kapa",
"url": "http://chronograf.influxcloud.net:9092",
"username": "testuser",
"password": "hunter2",
"links": {
"proxy": "http://localhost:3888/chronograf/v1/sources/2/kapacitors/1/proxy",
"self": "http://localhost:3888/chronograf/v1/sources/2/kapacitors/1",
"rules": "http://localhost:3888/chronograf/v1/sources/2/kapacitors/1/rules"
}
});
}
export default kapacitor;

View File

@ -1,24 +0,0 @@
const query = () => {
return ({
"id": "ad64c9e3-11d9-4e1a-bb6f-e80e09aec1cf",
"database": "telegraf",
"measurement": "cpu",
"retentionPolicy": "autogen",
"fields": [
{
"field": "usage_idle",
"funcs": [
"mean"
]
}
],
"tags": {},
"groupBy": {
"time": "10s",
"tags": []
},
"areTagsAccepted": true
});
}
export default query;

View File

@ -1,26 +0,0 @@
const queryConfigs = () => {
return ({
"ad64c9e3-11d9-4e1a-bb6f-e80e09aec1cf": {
"id": "ad64c9e3-11d9-4e1a-bb6f-e80e09aec1cf",
"database": "telegraf",
"measurement": "cpu",
"retentionPolicy": "autogen",
"fields": [
{
"field": "usage_idle",
"funcs": [
"mean"
]
}
],
"tags": {},
"groupBy": {
"time": "10s",
"tags": []
},
"areTagsAccepted": true
},
});
}
export default queryConfigs;

View File

@ -1,54 +0,0 @@
const rule = ({
trigger,
values,
}) => {
values = {
"rangeOperator": "greater than",
"change": "change",
"operator": "greater than",
"shift": "1m",
"value": "10",
"rangeValue": "20",
"period": "10m",
...values,
};
return ({
"id": "chronograf-v1-08cdb16b-7874-4c8f-858d-1c07043cb2f5",
"query": {
"id": "ad64c9e3-11d9-4e1a-bb6f-e80e09aec1cf",
"database": "telegraf",
"measurement": "cpu",
"retentionPolicy": "autogen",
"fields": [
{
"field": "usage_idle",
"funcs": [
"mean"
]
}
],
"tags": {},
"groupBy": {
"time": "10s",
"tags": []
},
"areTagsAccepted": true
},
"every": "30s",
"alerts": [],
"message": "",
trigger,
values,
"name": "Untitled Rule",
"tickscript": "var db = 'telegraf'\n\nvar rp = 'autogen'\n\nvar measurement = 'cpu'\n\nvar groupBy = []\n\nvar whereFilter = lambda: TRUE\n\nvar period = 10s\n\nvar every = 30s\n\nvar name = 'Untitled Rule'\n\nvar idVar = name + ':{{.Group}}'\n\nvar message = ''\n\nvar idTag = 'alertID'\n\nvar levelTag = 'level'\n\nvar messageField = 'message'\n\nvar durationField = 'duration'\n\nvar outputDB = 'chronograf'\n\nvar outputRP = 'autogen'\n\nvar outputMeasurement = 'alerts'\n\nvar triggerType = 'threshold'\n\nvar lower = 10\n\nvar upper = 20\n\nvar data = stream\n |from()\n .database(db)\n .retentionPolicy(rp)\n .measurement(measurement)\n .groupBy(groupBy)\n .where(whereFilter)\n |window()\n .period(period)\n .every(every)\n .align()\n |mean('usage_idle')\n .as('value')\n\nvar trigger = data\n |alert()\n .crit(lambda: \"value\" < lower AND \"value\" > upper)\n .stateChangesOnly()\n .message(message)\n .id(idVar)\n .idTag(idTag)\n .levelTag(levelTag)\n .messageField(messageField)\n .durationField(durationField)\n\ntrigger\n |influxDBOut()\n .create()\n .database(outputDB)\n .retentionPolicy(outputRP)\n .measurement(outputMeasurement)\n .tag('alertName', name)\n .tag('triggerType', triggerType)\n\ntrigger\n |httpOut('output')\n",
"links": {
"self": "/chronograf/v1/sources/2/kapacitors/1/rules/chronograf-v1-08cdb16b-7874-4c8f-858d-1c07043cb2f5",
"kapacitor": "/chronograf/v1/sources/2/kapacitors/1/proxy?path=%2Fkapacitor%2Fv1%2Ftasks%2Fchronograf-v1-08cdb16b-7874-4c8f-858d-1c07043cb2f5",
"output": "/chronograf/v1/sources/2/kapacitors/1/proxy?path=%2Fkapacitor%2Fv1%2Ftasks%2Fchronograf-v1-08cdb16b-7874-4c8f-858d-1c07043cb2f5%2Foutput"
},
"queryID": "ad64c9e3-11d9-4e1a-bb6f-e80e09aec1cf"
});
}
export default rule;

View File

@ -1,18 +0,0 @@
const source = () => {
return ({
"id": "2",
"name": "test-user",
"username": "test-user",
"password": "hunter2",
"url": "http://chronograf.influxcloud.net:8086",
"default": true,
"telegraf": "telegraf",
"links": {
"self": "http://localhost:3888/chronograf/v1/sources/2",
"kapacitors": "http://localhost:3888/chronograf/v1/sources/2/kapacitors",
"proxy": "http://localhost:3888/chronograf/v1/sources/2/proxy"
}
})
}
export default source;

View File

@ -1,112 +0,0 @@
const src = process.argv.find(s => s.includes('--src=')).replace('--src=', '')
const dataExplorerUrl = `http://localhost:8888/sources/${src}/chronograf/data-explorer`
const dataTest = s => `[data-test="${s}"]`
module.exports = {
'Data Explorer (functional) - SHOW DATABASES'(browser) {
browser
// Navigate to the Data Explorer
.url(dataExplorerUrl)
// Open a new query tab
.waitForElementVisible(dataTest('add-query-button'), 1000)
.click(dataTest('add-query-button'))
.waitForElementVisible(dataTest('query-editor-field'), 1000)
// Drop any existing testing database
.setValue(dataTest('query-editor-field'), 'DROP DATABASE "testing"\n')
.click(dataTest('query-editor-field'))
.pause(500)
// Create a new testing database
.clearValue(dataTest('query-editor-field'))
.setValue(dataTest('query-editor-field'), 'CREATE DATABASE "testing"\n')
.click(dataTest('query-editor-field'))
.pause(2000)
.refresh()
.waitForElementVisible(dataTest('query-editor-field'), 1000)
.clearValue(dataTest('query-editor-field'))
.setValue(dataTest('query-editor-field'), 'SHOW DATABASES\n')
.click(dataTest('query-editor-field'))
.pause(1000)
.waitForElementVisible(
dataTest('query-builder-list-item-database-testing'),
5000
)
.assert.containsText(
dataTest('query-builder-list-item-database-testing'),
'testing'
)
.end()
},
'Query Builder'(browser) {
browser
// Navigate to the Data Explorer
.url(dataExplorerUrl)
// Check to see that there are no results displayed
.waitForElementVisible(dataTest('data-explorer-no-results'), 5000)
.assert.containsText(dataTest('data-explorer-no-results'), 'No Results')
// Open a new query tab
.waitForElementVisible(dataTest('new-query-button'), 1000)
.click(dataTest('new-query-button'))
// Select the testing database
.waitForElementVisible(
dataTest('query-builder-list-item-database-testing'),
1000
)
.click(dataTest('query-builder-list-item-database-testing'))
// Open up the Write Data dialog
.click(dataTest('write-data-button'))
// Set the dialog to manual entry mode
.waitForElementVisible(dataTest('manual-entry-button'), 1000)
.click(dataTest('manual-entry-button'))
// Enter some time-series data
.setValue(
dataTest('manual-entry-field'),
'testing,test_measurement=1,test_measurement2=2 value=3,value2=4'
)
// Pause, then click the submit button
.pause(500)
.click(dataTest('write-data-submit-button'))
.pause(2000)
// Start building a query
// Select the testing measurement
.waitForElementVisible(
dataTest('query-builder-list-item-measurement-testing'),
2000
)
.click(dataTest('query-builder-list-item-measurement-testing'))
// Select both test measurements
.waitForElementVisible(
dataTest('query-builder-list-item-tag-test_measurement'),
1000
)
.click(dataTest('query-builder-list-item-tag-test_measurement'))
.click(dataTest('query-builder-list-item-tag-test_measurement2'))
.pause(500)
// Select both tag values
.waitForElementVisible(
dataTest('query-builder-list-item-tag-value-1'),
1000
)
.click(dataTest('query-builder-list-item-tag-value-1'))
.click(dataTest('query-builder-list-item-tag-value-2'))
.pause(500)
// Select both field values
.waitForElementVisible(
dataTest('query-builder-list-item-field-value'),
1000
)
.click(dataTest('query-builder-list-item-field-value'))
.click(dataTest('query-builder-list-item-field-value2'))
.pause(500)
// Assert the built query string
.assert.containsText(
dataTest('query-editor-field'),
'SELECT mean("value") AS "mean_value", mean("value2") AS "mean_value2" FROM "testing"."autogen"."testing" WHERE time > now() - 1h AND "test_measurement"=\'1\' AND "test_measurement2"=\'2\' GROUP BY time(10s)'
)
.click(dataTest('data-table'))
.click(dataTest('query-builder-list-item-function-value'))
.waitForElementVisible(dataTest('function-selector-item-mean'), 1000)
.click(dataTest('function-selector-item-mean'))
.click(dataTest('function-selector-apply'))
.end()
},
}

File diff suppressed because it is too large Load Diff