Merge branch 'master' into russorat-patch-1
commit
6c9cc38988
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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{
|
||||
|
|
108
influx/influx.go
108
influx/influx.go
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package influx
|
||||
|
||||
import "time"
|
||||
|
||||
// Now returns the current time
|
||||
type Now func() time.Time
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}}}) => ({
|
||||
|
|
|
@ -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}}}) => ({
|
||||
|
|
|
@ -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 = ({
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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})
|
||||
}
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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)
|
|
@ -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))
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
|
@ -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: '',
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -120,9 +120,6 @@ $graph-gutter: 16px;
|
|||
height: 100%;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
& > .dygraph > .dygraph-child > .dygraph-child-container {
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.graph-empty {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
))
|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||
// CSS
|
||||
import 'src/style/chronograf.scss';
|
||||
|
||||
// Kapacitor Stories
|
||||
import './kapacitor'
|
||||
import './admin'
|
|
@ -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>
|
||||
)
|
|
@ -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;
|
||||
}, {});
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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()
|
||||
},
|
||||
}
|
762
ui/yarn.lock
762
ui/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue