Merge pull request #841 from influxdata/de-struction

Simplify DataExplorer
pull/835/merge
Andrew Watkins 2017-02-08 08:38:56 -08:00 committed by GitHub
commit b84179f913
42 changed files with 248 additions and 1623 deletions

View File

@ -59,7 +59,6 @@ Chronograf's graphing tool that allows you to dig in and create personalized vis
* Generate [InfluxQL](https://docs.influxdata.com/influxdb/latest/query_language/) statements with the query builder
* Generate and edit [InfluxQL](https://docs.influxdata.com/influxdb/latest/query_language/) statements with the raw query editor
* Create visualizations and view query results in tabular format
* Manage visualizations with exploration sessions
### Dashboards

View File

@ -15,18 +15,16 @@ type Client struct {
Now func() time.Time
LayoutIDs chronograf.ID
ExplorationStore *ExplorationStore
SourcesStore *SourcesStore
ServersStore *ServersStore
LayoutStore *LayoutStore
UsersStore *UsersStore
AlertsStore *AlertsStore
DashboardsStore *DashboardsStore
SourcesStore *SourcesStore
ServersStore *ServersStore
LayoutStore *LayoutStore
UsersStore *UsersStore
AlertsStore *AlertsStore
DashboardsStore *DashboardsStore
}
func NewClient() *Client {
c := &Client{Now: time.Now}
c.ExplorationStore = &ExplorationStore{client: c}
c.SourcesStore = &SourcesStore{client: c}
c.ServersStore = &ServersStore{client: c}
c.AlertsStore = &AlertsStore{client: c}
@ -49,10 +47,6 @@ func (c *Client) Open() error {
c.db = db
if err := c.db.Update(func(tx *bolt.Tx) error {
// Always create explorations bucket.
if _, err := tx.CreateBucketIfNotExists(ExplorationBucket); err != nil {
return err
}
// Always create Sources bucket.
if _, err := tx.CreateBucketIfNotExists(SourcesBucket); err != nil {
return err

View File

@ -1,128 +0,0 @@
package bolt
import (
"context"
"github.com/boltdb/bolt"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/bolt/internal"
)
// Ensure ExplorationStore implements chronograf.ExplorationStore.
var _ chronograf.ExplorationStore = &ExplorationStore{}
var ExplorationBucket = []byte("Explorations")
type ExplorationStore struct {
client *Client
}
// Search the ExplorationStore for all explorations owned by userID.
func (s *ExplorationStore) Query(ctx context.Context, uid chronograf.UserID) ([]*chronograf.Exploration, error) {
var explorations []*chronograf.Exploration
if err := s.client.db.View(func(tx *bolt.Tx) error {
if err := tx.Bucket(ExplorationBucket).ForEach(func(k, v []byte) error {
var e chronograf.Exploration
if err := internal.UnmarshalExploration(v, &e); err != nil {
return err
} else if e.UserID != uid {
return nil
}
explorations = append(explorations, &e)
return nil
}); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return explorations, nil
}
// Create a new Exploration in the ExplorationStore.
func (s *ExplorationStore) Add(ctx context.Context, e *chronograf.Exploration) (*chronograf.Exploration, error) {
if err := s.client.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(ExplorationBucket)
seq, err := b.NextSequence()
if err != nil {
return err
}
e.ID = chronograf.ExplorationID(seq)
e.CreatedAt = s.client.Now().UTC()
e.UpdatedAt = e.CreatedAt
if v, err := internal.MarshalExploration(e); err != nil {
return err
} else if err := b.Put(itob(int(e.ID)), v); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return e, nil
}
// Delete the exploration from the ExplorationStore
func (s *ExplorationStore) Delete(ctx context.Context, e *chronograf.Exploration) error {
if err := s.client.db.Update(func(tx *bolt.Tx) error {
if err := tx.Bucket(ExplorationBucket).Delete(itob(int(e.ID))); err != nil {
return err
}
return nil
}); err != nil {
return err
}
return nil
}
// Retrieve an exploration for an id exists.
func (s *ExplorationStore) Get(ctx context.Context, id chronograf.ExplorationID) (*chronograf.Exploration, error) {
var e chronograf.Exploration
if err := s.client.db.View(func(tx *bolt.Tx) error {
if v := tx.Bucket(ExplorationBucket).Get(itob(int(id))); v == nil {
return chronograf.ErrExplorationNotFound
} else if err := internal.UnmarshalExploration(v, &e); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return &e, nil
}
// Update an exploration; will also update the `UpdatedAt` time.
func (s *ExplorationStore) Update(ctx context.Context, e *chronograf.Exploration) error {
if err := s.client.db.Update(func(tx *bolt.Tx) error {
// Retreive an existing exploration with the same exploration ID.
var ee chronograf.Exploration
b := tx.Bucket(ExplorationBucket)
if v := b.Get(itob(int(e.ID))); v == nil {
return chronograf.ErrExplorationNotFound
} else if err := internal.UnmarshalExploration(v, &ee); err != nil {
return err
}
ee.Name = e.Name
ee.UserID = e.UserID
ee.Data = e.Data
ee.UpdatedAt = s.client.Now().UTC()
if v, err := internal.MarshalExploration(&ee); err != nil {
return err
} else if err := b.Put(itob(int(ee.ID)), v); err != nil {
return err
}
return nil
}); err != nil {
return err
}
return nil
}

View File

@ -1,142 +0,0 @@
package bolt_test
import (
"context"
"testing"
"github.com/influxdata/chronograf"
)
// Ensure an ExplorationStore can store, retrieve, update, and delete explorations.
func TestExplorationStore_CRUD(t *testing.T) {
c, err := NewTestClient()
if err != nil {
t.Fatal(err)
}
if err := c.Open(); err != nil {
t.Fatal(err)
}
defer c.Close()
s := c.ExplorationStore
explorations := []*chronograf.Exploration{
&chronograf.Exploration{
Name: "Ferdinand Magellan",
UserID: 2,
Data: "{\"panels\":{\"123\":{\"id\":\"123\",\"queryIds\":[\"456\"]}},\"queryConfigs\":{\"456\":{\"id\":\"456\",\"database\":null,\"measurement\":null,\"retentionPolicy\":null,\"fields\":[],\"tags\":{},\"groupBy\":{\"time\":null,\"tags\":[]},\"areTagsAccepted\":true,\"rawText\":null}}}",
},
&chronograf.Exploration{
Name: "Marco Polo",
UserID: 3,
Data: "{\"panels\":{\"123\":{\"id\":\"123\",\"queryIds\":[\"456\"]}},\"queryConfigs\":{\"456\":{\"id\":\"456\",\"database\":null,\"measurement\":null,\"retentionPolicy\":null,\"fields\":[],\"tags\":{},\"groupBy\":{\"time\":null,\"tags\":[]},\"areTagsAccepted\":true,\"rawText\":null}}}",
},
&chronograf.Exploration{
Name: "Leif Ericson",
UserID: 3,
Data: "{\"panels\":{\"123\":{\"id\":\"123\",\"queryIds\":[\"456\"]}},\"queryConfigs\":{\"456\":{\"id\":\"456\",\"database\":null,\"measurement\":null,\"retentionPolicy\":null,\"fields\":[],\"tags\":{},\"groupBy\":{\"time\":null,\"tags\":[]},\"areTagsAccepted\":true,\"rawText\":null}}}",
},
}
ctx := context.Background()
// Add new explorations.
for i := range explorations {
if _, err := s.Add(ctx, explorations[i]); err != nil {
t.Fatal(err)
}
}
// Confirm first exploration in the store is the same as the original.
if e, err := s.Get(ctx, explorations[0].ID); err != nil {
t.Fatal(err)
} else if e.ID != explorations[0].ID {
t.Fatalf("exploration ID error: got %v, expected %v", e.ID, explorations[1].ID)
} else if e.Name != explorations[0].Name {
t.Fatalf("exploration Name error: got %v, expected %v", e.Name, explorations[1].Name)
} else if e.UserID != explorations[0].UserID {
t.Fatalf("exploration UserID error: got %v, expected %v", e.UserID, explorations[1].UserID)
} else if e.Data != explorations[0].Data {
t.Fatalf("exploration Data error: got %v, expected %v", e.Data, explorations[1].Data)
}
// Update explorations.
explorations[1].Name = "Francis Drake"
explorations[2].UserID = 4
if err := s.Update(ctx, explorations[1]); err != nil {
t.Fatal(err)
} else if err := s.Update(ctx, explorations[2]); err != nil {
t.Fatal(err)
}
// Confirm explorations are updated.
if e, err := s.Get(ctx, explorations[1].ID); err != nil {
t.Fatal(err)
} else if e.Name != "Francis Drake" {
t.Fatalf("exploration 1 update error: got %v, expected %v", e.Name, "Francis Drake")
}
if e, err := s.Get(ctx, explorations[2].ID); err != nil {
t.Fatal(err)
} else if e.UserID != 4 {
t.Fatalf("exploration 2 update error: got %v, expected %v", e.UserID, 4)
}
// Delete an exploration.
if err := s.Delete(ctx, explorations[2]); err != nil {
t.Fatal(err)
}
// Confirm exploration has been deleted.
if e, err := s.Get(ctx, explorations[2].ID); err != chronograf.ErrExplorationNotFound {
t.Fatalf("exploration delete error: got %v, expected %v", e, chronograf.ErrExplorationNotFound)
}
}
// Ensure Explorations can be queried by UserID.
func TestExplorationStore_Query(t *testing.T) {
c, err := NewTestClient()
if err != nil {
t.Fatal(err)
}
if err := c.Open(); err != nil {
t.Fatal(err)
}
defer c.Close()
s := c.ExplorationStore
explorations := []*chronograf.Exploration{
&chronograf.Exploration{
Name: "Ferdinand Magellan",
UserID: 2,
Data: "{\"panels\":{\"123\":{\"id\":\"123\",\"queryIds\":[\"456\"]}},\"queryConfigs\":{\"456\":{\"id\":\"456\",\"database\":null,\"measurement\":null,\"retentionPolicy\":null,\"fields\":[],\"tags\":{},\"groupBy\":{\"time\":null,\"tags\":[]},\"areTagsAccepted\":true,\"rawText\":null}}}",
},
&chronograf.Exploration{
Name: "Marco Polo",
UserID: 3,
Data: "{\"panels\":{\"123\":{\"id\":\"123\",\"queryIds\":[\"456\"]}},\"queryConfigs\":{\"456\":{\"id\":\"456\",\"database\":null,\"measurement\":null,\"retentionPolicy\":null,\"fields\":[],\"tags\":{},\"groupBy\":{\"time\":null,\"tags\":[]},\"areTagsAccepted\":true,\"rawText\":null}}}",
},
&chronograf.Exploration{
Name: "Leif Ericson",
UserID: 3,
Data: "{\"panels\":{\"123\":{\"id\":\"123\",\"queryIds\":[\"456\"]}},\"queryConfigs\":{\"456\":{\"id\":\"456\",\"database\":null,\"measurement\":null,\"retentionPolicy\":null,\"fields\":[],\"tags\":{},\"groupBy\":{\"time\":null,\"tags\":[]},\"areTagsAccepted\":true,\"rawText\":null}}}",
},
}
ctx := context.Background()
// Add new explorations.
for i := range explorations {
if _, err := s.Add(ctx, explorations[i]); err != nil {
t.Fatal(err)
}
}
// Query for explorations.
if e, err := s.Query(ctx, 3); err != nil {
t.Fatal(err)
} else if len(e) != 2 {
t.Fatalf("exploration query length error: got %v, expected %v", len(explorations), len(e))
} else if e[0].Name != explorations[1].Name {
t.Fatalf("exploration query error: got %v, expected %v", explorations[0].Name, "Marco Polo")
} else if e[1].Name != explorations[2].Name {
t.Fatalf("exploration query error: got %v, expected %v", explorations[1].Name, "Leif Ericson")
}
}

View File

@ -2,7 +2,6 @@ package internal
import (
"encoding/json"
"time"
"github.com/gogo/protobuf/proto"
"github.com/influxdata/chronograf"
@ -10,37 +9,6 @@ import (
//go:generate protoc --gogo_out=. internal.proto
// MarshalExploration encodes an exploration to binary protobuf format.
func MarshalExploration(e *chronograf.Exploration) ([]byte, error) {
return proto.Marshal(&Exploration{
ID: int64(e.ID),
Name: e.Name,
UserID: int64(e.UserID),
Data: e.Data,
CreatedAt: e.CreatedAt.UnixNano(),
UpdatedAt: e.UpdatedAt.UnixNano(),
Default: e.Default,
})
}
// UnmarshalExploration decodes an exploration from binary protobuf data.
func UnmarshalExploration(data []byte, e *chronograf.Exploration) error {
var pb Exploration
if err := proto.Unmarshal(data, &pb); err != nil {
return err
}
e.ID = chronograf.ExplorationID(pb.ID)
e.Name = pb.Name
e.UserID = chronograf.UserID(pb.UserID)
e.Data = pb.Data
e.CreatedAt = time.Unix(0, pb.CreatedAt).UTC()
e.UpdatedAt = time.Unix(0, pb.UpdatedAt).UTC()
e.Default = pb.Default
return nil
}
// MarshalSource encodes a source to binary protobuf format.
func MarshalSource(s chronograf.Source) ([]byte, error) {
return proto.Marshal(&Source{

View File

@ -9,7 +9,6 @@ It is generated from these files:
internal.proto
It has these top-level messages:
Exploration
Source
Dashboard
DashboardCell
@ -38,21 +37,6 @@ var _ = math.Inf
// proto package needs to be updated.
const _ = proto.GoGoProtoPackageIsVersion2 // please upgrade the proto package
type Exploration struct {
ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"`
Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"`
UserID int64 `protobuf:"varint,3,opt,name=UserID,proto3" json:"UserID,omitempty"`
Data string `protobuf:"bytes,4,opt,name=Data,proto3" json:"Data,omitempty"`
CreatedAt int64 `protobuf:"varint,5,opt,name=CreatedAt,proto3" json:"CreatedAt,omitempty"`
UpdatedAt int64 `protobuf:"varint,6,opt,name=UpdatedAt,proto3" json:"UpdatedAt,omitempty"`
Default bool `protobuf:"varint,7,opt,name=Default,proto3" json:"Default,omitempty"`
}
func (m *Exploration) Reset() { *m = Exploration{} }
func (m *Exploration) String() string { return proto.CompactTextString(m) }
func (*Exploration) ProtoMessage() {}
func (*Exploration) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{0} }
type Source struct {
ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"`
Name string `protobuf:"bytes,2,opt,name=Name,proto3" json:"Name,omitempty"`
@ -68,7 +52,7 @@ type Source struct {
func (m *Source) Reset() { *m = Source{} }
func (m *Source) String() string { return proto.CompactTextString(m) }
func (*Source) ProtoMessage() {}
func (*Source) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{1} }
func (*Source) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{0} }
type Dashboard struct {
ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"`
@ -79,7 +63,7 @@ type Dashboard struct {
func (m *Dashboard) Reset() { *m = Dashboard{} }
func (m *Dashboard) String() string { return proto.CompactTextString(m) }
func (*Dashboard) ProtoMessage() {}
func (*Dashboard) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{2} }
func (*Dashboard) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{1} }
func (m *Dashboard) GetCells() []*DashboardCell {
if m != nil {
@ -101,7 +85,7 @@ type DashboardCell struct {
func (m *DashboardCell) Reset() { *m = DashboardCell{} }
func (m *DashboardCell) String() string { return proto.CompactTextString(m) }
func (*DashboardCell) ProtoMessage() {}
func (*DashboardCell) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{3} }
func (*DashboardCell) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{2} }
func (m *DashboardCell) GetQueries() []*Query {
if m != nil {
@ -122,7 +106,7 @@ type Server struct {
func (m *Server) Reset() { *m = Server{} }
func (m *Server) String() string { return proto.CompactTextString(m) }
func (*Server) ProtoMessage() {}
func (*Server) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{4} }
func (*Server) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{3} }
type Layout struct {
ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"`
@ -135,7 +119,7 @@ type Layout struct {
func (m *Layout) Reset() { *m = Layout{} }
func (m *Layout) String() string { return proto.CompactTextString(m) }
func (*Layout) ProtoMessage() {}
func (*Layout) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{5} }
func (*Layout) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{4} }
func (m *Layout) GetCells() []*Cell {
if m != nil {
@ -160,7 +144,7 @@ type Cell struct {
func (m *Cell) Reset() { *m = Cell{} }
func (m *Cell) String() string { return proto.CompactTextString(m) }
func (*Cell) ProtoMessage() {}
func (*Cell) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{6} }
func (*Cell) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{5} }
func (m *Cell) GetQueries() []*Query {
if m != nil {
@ -182,7 +166,7 @@ type Query struct {
func (m *Query) Reset() { *m = Query{} }
func (m *Query) String() string { return proto.CompactTextString(m) }
func (*Query) ProtoMessage() {}
func (*Query) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{7} }
func (*Query) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{6} }
func (m *Query) GetRange() *Range {
if m != nil {
@ -199,7 +183,7 @@ type Range struct {
func (m *Range) Reset() { *m = Range{} }
func (m *Range) String() string { return proto.CompactTextString(m) }
func (*Range) ProtoMessage() {}
func (*Range) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{8} }
func (*Range) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{7} }
type AlertRule struct {
ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"`
@ -211,7 +195,7 @@ type AlertRule struct {
func (m *AlertRule) Reset() { *m = AlertRule{} }
func (m *AlertRule) String() string { return proto.CompactTextString(m) }
func (*AlertRule) ProtoMessage() {}
func (*AlertRule) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{9} }
func (*AlertRule) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{8} }
type User struct {
ID uint64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"`
@ -221,10 +205,9 @@ type User struct {
func (m *User) Reset() { *m = User{} }
func (m *User) String() string { return proto.CompactTextString(m) }
func (*User) ProtoMessage() {}
func (*User) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{10} }
func (*User) Descriptor() ([]byte, []int) { return fileDescriptorInternal, []int{9} }
func init() {
proto.RegisterType((*Exploration)(nil), "internal.Exploration")
proto.RegisterType((*Source)(nil), "internal.Source")
proto.RegisterType((*Dashboard)(nil), "internal.Dashboard")
proto.RegisterType((*DashboardCell)(nil), "internal.DashboardCell")
@ -240,50 +223,46 @@ func init() {
func init() { proto.RegisterFile("internal.proto", fileDescriptorInternal) }
var fileDescriptorInternal = []byte{
// 712 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0xbc, 0x55, 0xd1, 0x6e, 0xd3, 0x4a,
0x10, 0xd5, 0xc6, 0x76, 0x12, 0x4f, 0x7b, 0x7b, 0xaf, 0x56, 0xd5, 0xc5, 0x42, 0x3c, 0x44, 0x16,
0x48, 0x41, 0x82, 0x3e, 0xd0, 0x2f, 0x48, 0xe3, 0x0a, 0x05, 0x4a, 0x29, 0x9b, 0x06, 0x9e, 0x40,
0xda, 0x26, 0x9b, 0xc6, 0xc2, 0xb1, 0xcd, 0xda, 0x26, 0xf5, 0x3f, 0xf0, 0x05, 0x3c, 0xf0, 0x11,
0xf0, 0x29, 0xfc, 0x08, 0x9f, 0x80, 0x66, 0xbc, 0x76, 0x5c, 0x51, 0x50, 0x9f, 0x78, 0x9b, 0x33,
0x33, 0x9d, 0x3d, 0x73, 0xce, 0xb8, 0x81, 0xbd, 0x30, 0xce, 0x95, 0x8e, 0x65, 0x74, 0x90, 0xea,
0x24, 0x4f, 0x78, 0xbf, 0xc6, 0xfe, 0x37, 0x06, 0x3b, 0xc7, 0x57, 0x69, 0x94, 0x68, 0x99, 0x87,
0x49, 0xcc, 0xf7, 0xa0, 0x33, 0x09, 0x3c, 0x36, 0x60, 0x43, 0x4b, 0x74, 0x26, 0x01, 0xe7, 0x60,
0x9f, 0xca, 0xb5, 0xf2, 0x3a, 0x03, 0x36, 0x74, 0x05, 0xc5, 0xfc, 0x7f, 0xe8, 0xce, 0x32, 0xa5,
0x27, 0x81, 0x67, 0x51, 0x9f, 0x41, 0xd8, 0x1b, 0xc8, 0x5c, 0x7a, 0x76, 0xd5, 0x8b, 0x31, 0xbf,
0x07, 0xee, 0x58, 0x2b, 0x99, 0xab, 0xc5, 0x28, 0xf7, 0x1c, 0x6a, 0xdf, 0x26, 0xb0, 0x3a, 0x4b,
0x17, 0xa6, 0xda, 0xad, 0xaa, 0x4d, 0x82, 0x7b, 0xd0, 0x0b, 0xd4, 0x52, 0x16, 0x51, 0xee, 0xf5,
0x06, 0x6c, 0xd8, 0x17, 0x35, 0xf4, 0x7f, 0x30, 0xe8, 0x4e, 0x93, 0x42, 0xcf, 0xd5, 0xad, 0x08,
0x73, 0xb0, 0xcf, 0xcb, 0x54, 0x11, 0x5d, 0x57, 0x50, 0xcc, 0xef, 0x42, 0x1f, 0x69, 0xc7, 0xd8,
0x5b, 0x11, 0x6e, 0x30, 0xd6, 0xce, 0x64, 0x96, 0x6d, 0x12, 0xbd, 0x20, 0xce, 0xae, 0x68, 0x30,
0xff, 0x0f, 0xac, 0x99, 0x38, 0x21, 0xb2, 0xae, 0xc0, 0xf0, 0xf7, 0x34, 0x71, 0xce, 0xb9, 0x8a,
0xd4, 0xa5, 0x96, 0x4b, 0xaf, 0x5f, 0xcd, 0xa9, 0x31, 0x3f, 0x00, 0x3e, 0x89, 0x33, 0x35, 0x2f,
0xb4, 0x9a, 0xbe, 0x0f, 0xd3, 0xd7, 0x4a, 0x87, 0xcb, 0xd2, 0x73, 0x69, 0xc0, 0x0d, 0x15, 0xff,
0x1d, 0xb8, 0x81, 0xcc, 0x56, 0x17, 0x89, 0xd4, 0x8b, 0x5b, 0x2d, 0xfd, 0x18, 0x9c, 0xb9, 0x8a,
0xa2, 0xcc, 0xb3, 0x06, 0xd6, 0x70, 0xe7, 0xc9, 0x9d, 0x83, 0xe6, 0x06, 0x9a, 0x39, 0x63, 0x15,
0x45, 0xa2, 0xea, 0xf2, 0x3f, 0x33, 0xf8, 0xe7, 0x5a, 0x81, 0xef, 0x02, 0xbb, 0xa2, 0x37, 0x1c,
0xc1, 0xae, 0x10, 0x95, 0x34, 0xdf, 0x11, 0xac, 0x44, 0xb4, 0x21, 0x39, 0x1d, 0xc1, 0x36, 0x88,
0x56, 0x24, 0xa2, 0x23, 0xd8, 0x8a, 0x3f, 0x84, 0xde, 0x87, 0x42, 0xe9, 0x50, 0x65, 0x9e, 0x43,
0x4f, 0xff, 0xbb, 0x7d, 0xfa, 0x55, 0xa1, 0x74, 0x29, 0xea, 0x3a, 0xf2, 0x26, 0x03, 0x2a, 0x35,
0x29, 0xc6, 0x5c, 0x8e, 0x66, 0xf5, 0xaa, 0x1c, 0xc6, 0xfe, 0x27, 0xf4, 0x5b, 0xe9, 0x8f, 0x4a,
0xdf, 0x6a, 0xf5, 0xb6, 0xb7, 0xd6, 0x1f, 0xbc, 0xb5, 0x6f, 0xf6, 0xd6, 0xd9, 0x7a, 0xbb, 0x0f,
0xce, 0x54, 0xcf, 0x27, 0x81, 0x39, 0xce, 0x0a, 0xf8, 0x5f, 0x18, 0x74, 0x4f, 0x64, 0x99, 0x14,
0x79, 0x8b, 0x8e, 0x4b, 0x74, 0x06, 0xb0, 0x33, 0x4a, 0xd3, 0x28, 0x9c, 0xd3, 0xe7, 0x64, 0x58,
0xb5, 0x53, 0xd8, 0xf1, 0x42, 0xc9, 0xac, 0xd0, 0x6a, 0xad, 0xe2, 0xdc, 0xf0, 0x6b, 0xa7, 0xf8,
0x7d, 0x70, 0xc6, 0xe4, 0x9c, 0x4d, 0xf2, 0xed, 0x6d, 0xe5, 0xab, 0x0c, 0xa3, 0x22, 0x2e, 0x32,
0x2a, 0xf2, 0x64, 0x19, 0x25, 0x1b, 0x62, 0xdc, 0x17, 0x0d, 0xf6, 0xbf, 0x33, 0xb0, 0xff, 0x96,
0x87, 0xbb, 0xc0, 0x42, 0x63, 0x20, 0x0b, 0x1b, 0x47, 0x7b, 0x2d, 0x47, 0x3d, 0xe8, 0x95, 0x5a,
0xc6, 0x97, 0x2a, 0xf3, 0xfa, 0x03, 0x6b, 0x68, 0x89, 0x1a, 0x52, 0x25, 0x92, 0x17, 0x2a, 0xca,
0x3c, 0x77, 0x60, 0x0d, 0x5d, 0x51, 0xc3, 0xe6, 0x0a, 0xa0, 0x75, 0x05, 0x5f, 0x19, 0x38, 0xf4,
0x38, 0xfe, 0xdd, 0x38, 0x59, 0xaf, 0x65, 0xbc, 0x30, 0xd2, 0xd7, 0x10, 0xfd, 0x08, 0x8e, 0x8c,
0xec, 0x9d, 0xe0, 0x08, 0xb1, 0x38, 0x33, 0x22, 0x77, 0xc4, 0x19, 0xaa, 0xf6, 0x54, 0x27, 0x45,
0x7a, 0x54, 0x56, 0xf2, 0xba, 0xa2, 0xc1, 0xf8, 0x7f, 0xed, 0xcd, 0x4a, 0x69, 0xb3, 0xb3, 0x2b,
0x0c, 0xc2, 0x23, 0x38, 0x41, 0x56, 0x66, 0xcb, 0x0a, 0xf0, 0x07, 0xe0, 0x08, 0xdc, 0x82, 0x56,
0xbd, 0x26, 0x10, 0xa5, 0x45, 0x55, 0xf5, 0x0f, 0x4d, 0x1b, 0x4e, 0x99, 0xa5, 0xa9, 0xd2, 0xe6,
0x76, 0x2b, 0x40, 0xb3, 0x93, 0x8d, 0xd2, 0x44, 0xd9, 0x12, 0x15, 0xf0, 0xdf, 0x82, 0x3b, 0x8a,
0x94, 0xce, 0x45, 0x11, 0xa9, 0x5f, 0x4e, 0x8c, 0x83, 0xfd, 0x6c, 0xfa, 0xf2, 0xb4, 0xbe, 0x78,
0x8c, 0xb7, 0x77, 0x6a, 0xb5, 0xee, 0x14, 0x17, 0x7a, 0x2e, 0x53, 0x39, 0x09, 0xc8, 0x58, 0x4b,
0x18, 0xe4, 0x3f, 0x02, 0x1b, 0xbf, 0x87, 0xd6, 0x64, 0x9b, 0x26, 0xef, 0x83, 0x73, 0xbc, 0x96,
0x61, 0x64, 0x46, 0x57, 0xe0, 0xa2, 0x4b, 0xbf, 0x19, 0x87, 0x3f, 0x03, 0x00, 0x00, 0xff, 0xff,
0x6d, 0xf2, 0xe7, 0x54, 0x45, 0x06, 0x00, 0x00,
// 652 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0xbc, 0x94, 0x51, 0x6e, 0xd3, 0x4c,
0x10, 0xc7, 0xb5, 0xb1, 0x9d, 0xc4, 0xd3, 0x7e, 0xfd, 0x3e, 0xad, 0xaa, 0x8f, 0x15, 0x4f, 0x91,
0x05, 0x52, 0x90, 0xa0, 0x0f, 0xf4, 0x04, 0x6d, 0x8d, 0x50, 0xa0, 0x94, 0xb2, 0x69, 0xe1, 0x09,
0xa4, 0x6d, 0x3a, 0x69, 0x2c, 0x36, 0xb1, 0x59, 0xdb, 0xa4, 0xbe, 0x03, 0x27, 0xe0, 0x81, 0x43,
0x70, 0x15, 0x2e, 0xc2, 0x11, 0xd0, 0xec, 0xda, 0x8e, 0x2b, 0x2a, 0xd4, 0x27, 0xde, 0xe6, 0x37,
0xe3, 0xce, 0xce, 0xfc, 0xff, 0xd3, 0xc0, 0x4e, 0xb2, 0x2a, 0xd0, 0xac, 0x94, 0xde, 0xcb, 0x4c,
0x5a, 0xa4, 0x7c, 0xd8, 0x70, 0xf4, 0x93, 0x41, 0x7f, 0x9a, 0x96, 0x66, 0x86, 0x7c, 0x07, 0x7a,
0x93, 0x58, 0xb0, 0x11, 0x1b, 0x7b, 0xb2, 0x37, 0x89, 0x39, 0x07, 0xff, 0x44, 0x2d, 0x51, 0xf4,
0x46, 0x6c, 0x1c, 0x4a, 0x1b, 0x53, 0xee, 0xac, 0xca, 0x50, 0x78, 0x2e, 0x47, 0x31, 0xbf, 0x0f,
0xc3, 0xf3, 0x9c, 0xba, 0x2d, 0x51, 0xf8, 0x36, 0xdf, 0x32, 0xd5, 0x4e, 0x55, 0x9e, 0xaf, 0x53,
0x73, 0x29, 0x02, 0x57, 0x6b, 0x98, 0xff, 0x07, 0xde, 0xb9, 0x3c, 0x16, 0x7d, 0x9b, 0xa6, 0x90,
0x0b, 0x18, 0xc4, 0x38, 0x57, 0xa5, 0x2e, 0xc4, 0x60, 0xc4, 0xc6, 0x43, 0xd9, 0x20, 0xf5, 0x39,
0x43, 0x8d, 0x57, 0x46, 0xcd, 0xc5, 0xd0, 0xf5, 0x69, 0x98, 0xef, 0x01, 0x9f, 0xac, 0x72, 0x9c,
0x95, 0x06, 0xa7, 0x1f, 0x93, 0xec, 0x2d, 0x9a, 0x64, 0x5e, 0x89, 0xd0, 0x36, 0xb8, 0xa5, 0x12,
0x7d, 0x80, 0x30, 0x56, 0xf9, 0xe2, 0x22, 0x55, 0xe6, 0xf2, 0x4e, 0x4b, 0x3f, 0x81, 0x60, 0x86,
0x5a, 0xe7, 0xc2, 0x1b, 0x79, 0xe3, 0xad, 0xa7, 0xf7, 0xf6, 0x5a, 0x35, 0xdb, 0x3e, 0x47, 0xa8,
0xb5, 0x74, 0x5f, 0x45, 0x5f, 0x19, 0xfc, 0x73, 0xa3, 0xc0, 0xb7, 0x81, 0x5d, 0xdb, 0x37, 0x02,
0xc9, 0xae, 0x89, 0x2a, 0xdb, 0x3f, 0x90, 0xac, 0x22, 0x5a, 0x5b, 0x39, 0x03, 0xc9, 0xd6, 0x44,
0x0b, 0x2b, 0x62, 0x20, 0xd9, 0x82, 0x3f, 0x82, 0xc1, 0xa7, 0x12, 0x4d, 0x82, 0xb9, 0x08, 0xec,
0xd3, 0xff, 0x6e, 0x9e, 0x7e, 0x53, 0xa2, 0xa9, 0x64, 0x53, 0xa7, 0xb9, 0xad, 0x01, 0x4e, 0x4d,
0x1b, 0x53, 0xae, 0x20, 0xb3, 0x06, 0x2e, 0x47, 0x71, 0xf4, 0x85, 0xfc, 0x46, 0xf3, 0x19, 0xcd,
0x9d, 0x56, 0xef, 0x7a, 0xeb, 0xfd, 0xc1, 0x5b, 0xff, 0x76, 0x6f, 0x83, 0x8d, 0xb7, 0xbb, 0x10,
0x4c, 0xcd, 0x6c, 0x12, 0xdb, 0x09, 0x3d, 0xe9, 0x20, 0xfa, 0xc6, 0xa0, 0x7f, 0xac, 0xaa, 0xb4,
0x2c, 0x3a, 0xe3, 0x84, 0x76, 0x9c, 0x11, 0x6c, 0x1d, 0x64, 0x99, 0x4e, 0x66, 0xaa, 0x48, 0xd2,
0x55, 0x3d, 0x55, 0x37, 0x45, 0x5f, 0xbc, 0x42, 0x95, 0x97, 0x06, 0x97, 0xb8, 0x2a, 0xea, 0xf9,
0xba, 0x29, 0xfe, 0x00, 0x82, 0x23, 0xeb, 0x9c, 0x6f, 0xe5, 0xdb, 0xd9, 0xc8, 0xe7, 0x0c, 0xb3,
0x45, 0x5a, 0xe4, 0xa0, 0x2c, 0xd2, 0xb9, 0x4e, 0xd7, 0x76, 0xe2, 0xa1, 0x6c, 0x39, 0xfa, 0xc1,
0xc0, 0xff, 0x5b, 0x1e, 0x6e, 0x03, 0x4b, 0x6a, 0x03, 0x59, 0xd2, 0x3a, 0x3a, 0xe8, 0x38, 0x2a,
0x60, 0x50, 0x19, 0xb5, 0xba, 0xc2, 0x5c, 0x0c, 0x47, 0xde, 0xd8, 0x93, 0x0d, 0xda, 0x8a, 0x56,
0x17, 0xa8, 0x73, 0x11, 0x8e, 0xbc, 0x71, 0x28, 0x1b, 0x6c, 0xaf, 0x00, 0x3a, 0x57, 0xf0, 0x9d,
0x41, 0x60, 0x1f, 0xa7, 0xbf, 0x3b, 0x4a, 0x97, 0x4b, 0xb5, 0xba, 0xac, 0xa5, 0x6f, 0x90, 0xfc,
0x88, 0x0f, 0x6b, 0xd9, 0x7b, 0xf1, 0x21, 0xb1, 0x3c, 0xad, 0x45, 0xee, 0xc9, 0x53, 0x52, 0xed,
0xb9, 0x49, 0xcb, 0xec, 0xb0, 0x72, 0xf2, 0x86, 0xb2, 0x65, 0xfe, 0x3f, 0xf4, 0xdf, 0x2d, 0xd0,
0xd4, 0x3b, 0x87, 0xb2, 0x26, 0x3a, 0x82, 0x63, 0x9a, 0xaa, 0xde, 0xd2, 0x01, 0x7f, 0x08, 0x81,
0xa4, 0x2d, 0xec, 0xaa, 0x37, 0x04, 0xb2, 0x69, 0xe9, 0xaa, 0xd1, 0x7e, 0xfd, 0x19, 0x75, 0x39,
0xcf, 0x32, 0x34, 0xf5, 0xed, 0x3a, 0xb0, 0xbd, 0xd3, 0x35, 0x1a, 0x3b, 0xb2, 0x27, 0x1d, 0x44,
0xef, 0x21, 0x3c, 0xd0, 0x68, 0x0a, 0x59, 0x6a, 0xfc, 0xed, 0xc4, 0x38, 0xf8, 0x2f, 0xa6, 0xaf,
0x4f, 0x9a, 0x8b, 0xa7, 0x78, 0x73, 0xa7, 0x5e, 0xe7, 0x4e, 0x69, 0xa1, 0x97, 0x2a, 0x53, 0x93,
0xd8, 0x1a, 0xeb, 0xc9, 0x9a, 0xa2, 0xc7, 0xe0, 0xd3, 0xff, 0x43, 0xa7, 0xb3, 0x6f, 0x3b, 0xef,
0x42, 0xf0, 0x6c, 0xa9, 0x12, 0x5d, 0xb7, 0x76, 0x70, 0xd1, 0xb7, 0xbf, 0xbe, 0xfb, 0xbf, 0x02,
0x00, 0x00, 0xff, 0xff, 0xbd, 0xa5, 0x15, 0x7a, 0x8f, 0x05, 0x00, 0x00,
}

View File

@ -1,16 +1,6 @@
syntax = "proto3";
package internal;
message Exploration {
int64 ID = 1; // ExplorationID is a unique ID for an Exploration.
string Name = 2; // User provided name of the Exploration.
int64 UserID = 3; // UserID is the owner of this Exploration.
string Data = 4; // Opaque blob of JSON data.
int64 CreatedAt = 5; // Time the exploration was first created.
int64 UpdatedAt = 6; // Latest time the exploration was updated.
bool Default = 7; // Flags an exploration as the default.
}
message Source {
int64 ID = 1; // ID is the unique ID of the source
string Name = 2; // Name is the user-defined name for the source
@ -18,7 +8,7 @@ message Source {
string Username = 4; // Username is the username to connect to the source
string Password = 5;
string URL = 6; // URL are the connections to the source
bool Default = 7; // Flags an exploration as the default.
bool Default = 7; // Flags an source as the default.
string Telegraf = 8; // Telegraf is the db telegraf is written to. By default it is "telegraf"
bool InsecureSkipVerify = 9; // InsecureSkipVerify accepts any certificate from the influx server
}
@ -34,18 +24,18 @@ message DashboardCell {
int32 y = 2; // Y-coordinate of Cell in the Dashboard
int32 w = 3; // Width of Cell in the Dashboard
int32 h = 4; // Height of Cell in the Dashboard
repeated Query queries = 5; // Time-series data queries for Dashboard
repeated Query queries = 5; // Time-series data queries for Dashboard
string name = 6; // User-facing name for this Dashboard
string type = 7; // Dashboard visualization type
}
message Server {
int64 ID = 1; // ID is the unique ID of the server
string Name = 2; // Name is the user-defined name for the server
string Username = 3; // Username is the username to connect to the server
string Password = 4;
string URL = 5; // URL is the path to the server
int64 SrcID = 6; // SrcID is the ID of the data source
int64 ID = 1; // ID is the unique ID of the server
string Name = 2; // Name is the user-defined name for the server
string Username = 3; // Username is the username to connect to the server
string Password = 4;
string URL = 5; // URL is the path to the server
int64 SrcID = 6; // SrcID is the ID of the data source
}
message Layout {

View File

@ -3,33 +3,11 @@ package internal_test
import (
"reflect"
"testing"
"time"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/bolt/internal"
)
// Ensure an exploration can be marshaled and unmarshaled.
func TestMarshalExploration(t *testing.T) {
v := chronograf.Exploration{
ID: 12,
Name: "Some Exploration",
UserID: 34,
Data: "{\"data\":\"something\"}",
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
var vv chronograf.Exploration
if buf, err := internal.MarshalExploration(&v); err != nil {
t.Fatal(err)
} else if err := internal.UnmarshalExploration(buf, &vv); err != nil {
t.Fatal(err)
} else if !reflect.DeepEqual(v, vv) {
t.Fatalf("exploration protobuf copy error: got %#v, expected %#v", vv, v)
}
}
func TestMarshalSource(t *testing.T) {
v := chronograf.Source{
ID: 12,

View File

@ -8,16 +8,15 @@ import (
// General errors.
const (
ErrUpstreamTimeout = Error("request to backend timed out")
ErrExplorationNotFound = Error("exploration not found")
ErrSourceNotFound = Error("source not found")
ErrServerNotFound = Error("server not found")
ErrLayoutNotFound = Error("layout not found")
ErrDashboardNotFound = Error("dashboard not found")
ErrUserNotFound = Error("user not found")
ErrLayoutInvalid = Error("layout is invalid")
ErrAlertNotFound = Error("alert not found")
ErrAuthentication = Error("user not authenticated")
ErrUpstreamTimeout = Error("request to backend timed out")
ErrSourceNotFound = Error("source not found")
ErrServerNotFound = Error("server not found")
ErrLayoutNotFound = Error("layout not found")
ErrDashboardNotFound = Error("dashboard not found")
ErrUserNotFound = Error("user not found")
ErrLayoutInvalid = Error("layout is invalid")
ErrAlertNotFound = Error("alert not found")
ErrAuthentication = Error("user not authenticated")
)
// Error is a domain error encountered while processing chronograf requests
@ -238,13 +237,13 @@ type Dashboard struct {
// DashboardCell holds visual and query information for a cell
type DashboardCell struct {
X int32 `json:"x"`
Y int32 `json:"y"`
W int32 `json:"w"`
H int32 `json:"h"`
Name string `json:"name"`
X int32 `json:"x"`
Y int32 `json:"y"`
W int32 `json:"w"`
H int32 `json:"h"`
Name string `json:"name"`
Queries []Query `json:"queries"`
Type string `json:"type"`
Type string `json:"type"`
}
// DashboardsStore is the storage and retrieval of dashboards
@ -261,34 +260,6 @@ type DashboardsStore interface {
Update(context.Context, Dashboard) error
}
// ExplorationID is a unique ID for an Exploration.
type ExplorationID int
// Exploration is a serialization of front-end Data Explorer.
type Exploration struct {
ID ExplorationID
Name string // User provided name of the Exploration.
UserID UserID // UserID is the owner of this Exploration.
Data string // Opaque blob of JSON data.
CreatedAt time.Time // Time the exploration was first created.
UpdatedAt time.Time // Latest time the exploration was updated.
Default bool // Flags an exploration as the default.
}
// ExplorationStore stores front-end serializations of data explorer sessions.
type ExplorationStore interface {
// Search the ExplorationStore for each Exploration owned by `UserID`.
Query(ctx context.Context, userID UserID) ([]*Exploration, error)
// Create a new Exploration in the ExplorationStore.
Add(context.Context, *Exploration) (*Exploration, error)
// Delete the Exploration from the ExplorationStore.
Delete(context.Context, *Exploration) error
// Retrieve an Exploration if `ID` exists.
Get(ctx context.Context, ID ExplorationID) (*Exploration, error)
// Update the Exploration; will also update the `UpdatedAt` time.
Update(context.Context, *Exploration) error
}
// Cell is a rectangle and multiple time series queries to visualize.
type Cell struct {
X int32 `json:"x"`

View File

@ -44,7 +44,7 @@ Paste an existing [InfluxQL](https://docs.influxdata.com/influxdb/latest/query_l
![Raw Editor](https://github.com/influxdata/chronograf/blob/master/docs/images/raw-editor-gs.gif)
### Other Features
View query results in tabular format (1), easily alter the query's time range with the time range selector (2), and save your graphs in individual exploration sessions (3):
View query results in tabular format (1) and easily alter the query's time range with the time range selector (2):
![Data Exploration Extras](https://github.com/influxdata/chronograf/blob/master/docs/images/data-exploration-extras-gs.png)

View File

@ -3,7 +3,7 @@ The dashboard API will support collections of resizable InfluxQL visualizations.
### TL; DR
Here are the objects we are thinking about; dashboards contain layouts which
contain explorations.
contain queries.
#### Dashboard

View File

@ -1,220 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/influxdata/chronograf"
)
type link struct {
Href string `json:"href"`
Rel string `json:"rel"`
}
type exploration struct {
Name string `json:"name"` // Exploration name given by user.
Data interface{} `json:"data"` // Serialization of the exploration config.
CreatedAt time.Time `json:"created_at"` // Time exploration was created
UpdatedAt time.Time `json:"updated_at"` // Latest time the exploration was updated.
Link link `json:"link"` // Self link
}
func newExploration(e *chronograf.Exploration) exploration {
rel := "self"
href := fmt.Sprintf("%s/%d/explorations/%d", "/chronograf/v1/users", e.UserID, e.ID)
return exploration{
Name: e.Name,
Data: e.Data,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
Link: link{
Rel: rel,
Href: href,
},
}
}
type explorations struct {
Explorations []exploration `json:"explorations"`
}
// Explorations returns all explorations scoped by user id.
func (h *Service) Explorations(w http.ResponseWriter, r *http.Request) {
id, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return
}
ctx := r.Context()
mrExs, err := h.ExplorationStore.Query(ctx, chronograf.UserID(id))
if err != nil {
unknownErrorWithMessage(w, err, h.Logger)
return
}
exs := make([]exploration, len(mrExs))
for i, e := range mrExs {
exs[i] = newExploration(e)
}
res := explorations{
Explorations: exs,
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// ExplorationsID retrieves exploration ID scoped under user.
func (h *Service) ExplorationsID(w http.ResponseWriter, r *http.Request) {
eID, err := paramID("eid", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return
}
uID, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return
}
ctx := r.Context()
e, err := h.ExplorationStore.Get(ctx, chronograf.ExplorationID(eID))
if err != nil || e.UserID != chronograf.UserID(uID) {
notFound(w, eID, h.Logger)
return
}
res := newExploration(e)
encodeJSON(w, http.StatusOK, res, h.Logger)
}
type patchExplorationRequest struct {
Data interface{} `json:"data,omitempty"` // Serialized configuration
Name *string `json:"name,omitempty"` // Exploration name given by user.
}
// UpdateExploration incrementally updates exploration
func (h *Service) UpdateExploration(w http.ResponseWriter, r *http.Request) {
id, err := paramID("eid", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return
}
uID, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return
}
ctx := r.Context()
e, err := h.ExplorationStore.Get(ctx, chronograf.ExplorationID(id))
if err != nil || e.UserID != chronograf.UserID(uID) {
notFound(w, id, h.Logger)
return
}
var req patchExplorationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if req.Data != nil {
var ok bool
if e.Data, ok = req.Data.(string); !ok {
err := fmt.Errorf("Error: Exploration data is not a string")
invalidData(w, err, h.Logger)
return
}
}
if req.Name != nil {
e.Name = *req.Name
}
if err := h.ExplorationStore.Update(ctx, e); err != nil {
msg := "Error: Failed to update Exploration"
Error(w, http.StatusInternalServerError, msg, h.Logger)
return
}
res := newExploration(e)
encodeJSON(w, http.StatusOK, res, h.Logger)
}
type postExplorationRequest struct {
Data interface{} `json:"data"` // Serialization of config.
Name string `json:"name,omitempty"` // Exploration name given by user.
}
// NewExploration adds valid exploration scoped by user id.
func (h *Service) NewExploration(w http.ResponseWriter, r *http.Request) {
uID, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return
}
// TODO: Check user if user exists.
var req postExplorationRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
data := ""
if req.Data != nil {
data, _ = req.Data.(string)
}
e := &chronograf.Exploration{
Name: req.Name,
UserID: chronograf.UserID(uID),
Data: data,
}
ctx := r.Context()
e, err = h.ExplorationStore.Add(ctx, e)
if err != nil {
msg := fmt.Errorf("Error: Failed to save Exploration")
unknownErrorWithMessage(w, msg, h.Logger)
return
}
res := newExploration(e)
w.Header().Add("Location", res.Link.Href)
encodeJSON(w, http.StatusCreated, res, h.Logger)
}
// RemoveExploration deletes exploration from store.
func (h *Service) RemoveExploration(w http.ResponseWriter, r *http.Request) {
eID, err := paramID("eid", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return
}
uID, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return
}
ctx := r.Context()
e, err := h.ExplorationStore.Get(ctx, chronograf.ExplorationID(eID))
if err != nil || e.UserID != chronograf.UserID(uID) {
notFound(w, eID, h.Logger)
return
}
if err := h.ExplorationStore.Delete(ctx, &chronograf.Exploration{ID: chronograf.ExplorationID(eID)}); err != nil {
unknownErrorWithMessage(w, err, h.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -9,6 +9,11 @@ import (
"github.com/influxdata/chronograf"
)
type link struct {
Href string `json:"href"`
Rel string `json:"rel"`
}
type layoutResponse struct {
chronograf.Layout
Link link `json:"link"`

View File

@ -106,14 +106,6 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
router.PATCH("/chronograf/v1/users/:id", service.UpdateUser)
router.DELETE("/chronograf/v1/users/:id", service.RemoveUser)
// Explorations
router.GET("/chronograf/v1/users/:id/explorations", service.Explorations)
router.POST("/chronograf/v1/users/:id/explorations", service.NewExploration)
router.GET("/chronograf/v1/users/:id/explorations/:eid", service.ExplorationsID)
router.PATCH("/chronograf/v1/users/:id/explorations/:eid", service.UpdateExploration)
router.DELETE("/chronograf/v1/users/:id/explorations/:eid", service.RemoveExploration)
// Dashboards
router.GET("/chronograf/v1/dashboards", service.Dashboards)
router.POST("/chronograf/v1/dashboards", service.NewDashboard)

View File

@ -147,10 +147,9 @@ func openService(boltPath, cannedPath string, logger chronograf.Logger, useAuth
}
return Service{
ExplorationStore: db.ExplorationStore,
SourcesStore: db.SourcesStore,
ServersStore: db.ServersStore,
UsersStore: db.UsersStore,
SourcesStore: db.SourcesStore,
ServersStore: db.ServersStore,
UsersStore: db.UsersStore,
TimeSeries: &influx.Client{
Logger: logger,
},

View File

@ -4,16 +4,15 @@ import "github.com/influxdata/chronograf"
// Service handles REST calls to the persistence
type Service struct {
ExplorationStore chronograf.ExplorationStore
SourcesStore chronograf.SourcesStore
ServersStore chronograf.ServersStore
LayoutStore chronograf.LayoutStore
AlertRulesStore chronograf.AlertRulesStore
UsersStore chronograf.UsersStore
DashboardsStore chronograf.DashboardsStore
TimeSeries chronograf.TimeSeries
Logger chronograf.Logger
UseAuth bool
SourcesStore chronograf.SourcesStore
ServersStore chronograf.ServersStore
LayoutStore chronograf.LayoutStore
AlertRulesStore chronograf.AlertRulesStore
UsersStore chronograf.UsersStore
DashboardsStore chronograf.DashboardsStore
TimeSeries chronograf.TimeSeries
Logger chronograf.Logger
UseAuth bool
}
// ErrorMessage is the error response format for all service errors

View File

@ -425,233 +425,6 @@
}
}
},
"/users/{user_id}/explorations": {
"get": {
"tags": [
"users",
"explorations"
],
"summary": "Returns all explorations for specified user",
"parameters": [
{
"name": "user_id",
"in": "path",
"type": "string",
"description": "All Data Explorations returned only for this user.",
"required": true
}
],
"responses": {
"200": {
"description": "Data Explorations saved sessions for user are returned.",
"schema": {
"$ref": "#/definitions/Explorations"
}
},
"404": {
"description": "User does not exist.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "Unexpected internal service error",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"post": {
"tags": [
"users",
"explorations"
],
"summary": "Create new named exploration for this user",
"parameters": [
{
"name": "user_id",
"in": "path",
"type": "string",
"description": "ID of user to associate this exploration with.",
"required": true
},
{
"name": "exploration",
"in": "body",
"description": "Exploration session to save",
"schema": {
"$ref": "#/definitions/Exploration"
}
}
],
"responses": {
"201": {
"description": "Successfully created new Exploration session",
"headers": {
"Location": {
"type": "string",
"format": "url",
"description": "Location of the newly created exploration resource."
}
},
"schema": {
"$ref": "#/definitions/Exploration"
}
},
"404": {
"description": "User does not exist.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "A processing or an unexpected error.",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/users/{user_id}/explorations/{exploration_id}": {
"get": {
"tags": [
"users",
"explorations"
],
"parameters": [
{
"name": "user_id",
"in": "path",
"type": "string",
"description": "ID of user to associate this exploration with.",
"required": true
},
{
"name": "exploration_id",
"in": "path",
"type": "string",
"description": "ID of the specific exploration.",
"required": true
}
],
"summary": "Returns the specified data exploration session",
"description": "A data exploration session specifies query information.\n",
"responses": {
"200": {
"description": "Information relating to the exploration",
"schema": {
"$ref": "#/definitions/Exploration"
}
},
"404": {
"description": "User or exploration does not exist.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "Unexpected internal service error",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"patch": {
"tags": [
"users",
"explorations"
],
"summary": "Update exploration configuration",
"parameters": [
{
"name": "user_id",
"in": "path",
"type": "string",
"description": "ID of user",
"required": true
},
{
"name": "exploration_id",
"in": "path",
"type": "string",
"description": "ID of the specific exploration.",
"required": true
},
{
"name": "exploration",
"in": "body",
"description": "Update the exploration information to this.",
"required": true,
"schema": {
"$ref": "#/definitions/Exploration"
}
}
],
"responses": {
"200": {
"description": "Exploration's configuration was changed",
"schema": {
"$ref": "#/definitions/Exploration"
}
},
"404": {
"description": "Data source id, user, or exploration does not exist.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "A processing or an unexpected error.",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"delete": {
"tags": [
"users",
"explorations"
],
"parameters": [
{
"name": "user_id",
"in": "path",
"type": "string",
"description": "ID of user to associate this exploration with.",
"required": true
},
{
"name": "exploration_id",
"in": "path",
"type": "string",
"description": "ID of the specific exploration.",
"required": true
}
],
"summary": "This specific exporer session will be removed.",
"responses": {
"204": {
"description": "Exploration session has been removed"
},
"404": {
"description": "Data source id, user, or exploration does not exist.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "Unexpected internal service error",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/sources/{id}/kapacitors": {
"get": {
"tags": [
@ -2160,45 +1933,6 @@
}
}
},
"Explorations": {
"type": "object",
"required": [
"explorations"
],
"properties": {
"explorations": {
"type": "array",
"items": {
"$ref": "#/definitions/Exploration"
}
}
}
},
"Exploration": {
"type": "object",
"properties": {
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "Latest time the exploration was updated."
},
"name": {
"type": "string",
"description": "Exploration name given by user."
},
"data": {
"type": "object",
"description": "Serialization of the exploration query configuration."
},
"link": {
"$ref": "#/definitions/Link"
}
}
},
"Users": {
"type": "object",
"properties": {
@ -2563,4 +2297,4 @@
}
}
}
}
}

View File

@ -11,8 +11,7 @@ import (
)
type userLinks struct {
Self string `json:"self"` // Self link mapping to this resource
Explorations string `json:"explorations"` // URL for explorations endpoint
Self string `json:"self"` // Self link mapping to this resource
}
type userResponse struct {
@ -25,8 +24,7 @@ func newUserResponse(usr *chronograf.User) userResponse {
return userResponse{
User: usr,
Links: userLinks{
Self: fmt.Sprintf("%s/%d", base, usr.ID),
Explorations: fmt.Sprintf("%s/%d/explorations", base, usr.ID),
Self: fmt.Sprintf("%s/%d", base, usr.ID),
},
}
}

View File

@ -0,0 +1,11 @@
import reducer from 'src/data_explorer/reducers/dataExplorerUI';
import {activatePanel} from 'src/data_explorer/actions/view';
describe('DataExplorer.Reducers.UI', () => {
it('can set the active panel', () => {
const activePanel = 123;
const actual = reducer({}, activatePanel(activePanel));
expect(actual).to.deep.equal({activePanel});
});
});

View File

@ -1,34 +1,34 @@
import reducer from 'src/data_explorer/reducers/panels';
import {deletePanel} from 'src/data_explorer/actions/view';
const fakeAddPanelAction = (panelId, queryId) => {
const fakeAddPanelAction = (panelID, queryID) => {
return {
type: 'CREATE_PANEL',
payload: {panelId, queryId},
payload: {panelID, queryID},
};
};
describe('Chronograf.Reducers.Panel', () => {
let state;
const panelId = 123;
const queryId = 456;
const panelID = 123;
const queryID = 456;
beforeEach(() => {
state = reducer({}, fakeAddPanelAction(panelId, queryId));
state = reducer({}, fakeAddPanelAction(panelID, queryID));
});
it('can add a panel', () => {
const actual = state[panelId];
const actual = state[panelID];
expect(actual).to.deep.equal({
id: panelId,
queryIds: [queryId],
id: panelID,
queryIds: [queryID],
});
});
it('can delete a panel', () => {
const nextState = reducer(state, deletePanel(panelId));
const nextState = reducer(state, deletePanel(panelID));
const actual = nextState[panelId];
const actual = nextState[panelID];
expect(actual).to.equal(undefined);
});
});

View File

@ -12,10 +12,10 @@ import {
updateRawQuery,
} from 'src/data_explorer/actions/view';
const fakeAddQueryAction = (panelId, queryId) => {
const fakeAddQueryAction = (panelID, queryID) => {
return {
type: 'ADD_QUERY',
payload: {panelId, queryId},
payload: {panelID, queryID},
};
};

View File

@ -16,7 +16,6 @@ const App = React.createClass({
}),
params: PropTypes.shape({
sourceID: PropTypes.string.isRequired,
base64ExplorerID: PropTypes.string,
}).isRequired,
publishNotification: PropTypes.func.isRequired,
dismissNotification: PropTypes.func.isRequired,
@ -47,11 +46,11 @@ const App = React.createClass({
},
render() {
const {sourceID, base64ExplorerID} = this.props.params;
const {sourceID} = this.props.params;
return (
<div className="chronograf-root">
<SideNavContainer sourceID={sourceID} explorationID={base64ExplorerID} addFlashMessage={this.handleNotification} currentLocation={this.props.location.pathname} />
<SideNavContainer sourceID={sourceID} addFlashMessage={this.handleNotification} currentLocation={this.props.location.pathname} />
{this.renderNotifications()}
{this.props.children && React.cloneElement(this.props.children, {
addFlashMessage: this.handleNotification,

View File

@ -1,16 +1,11 @@
import uuid from 'node-uuid';
import AJAX from 'utils/ajax';
import getInitialState from 'src/store/getInitialState';
import {publishNotification} from 'src/shared/actions/notifications';
import _ from 'lodash';
import * as api from '../../api/';
export function createPanel() {
return {
type: 'CREATE_PANEL',
payload: {
panelId: uuid.v4(), // for the default Panel
queryId: uuid.v4(), // for the default Query
panelID: uuid.v4(), // for the default Panel
queryID: uuid.v4(), // for the default Query
},
};
}
@ -130,8 +125,6 @@ export function editRawText(queryId, rawText) {
}
export function setTimeRange(range) {
window.localStorage.setItem('timeRange', JSON.stringify(range));
return {
type: 'SET_TIME_RANGE',
payload: range,
@ -157,186 +150,6 @@ export function toggleTagAcceptance(queryId) {
};
}
export function createExploration(source, push) {
return (dispatch) => {
const initialState = getInitialState();
AJAX({
url: `/chronograf/v1/users/1/explorations`, // TODO: change this to use actual user link once users are introduced
method: 'POST',
data: JSON.stringify({
data: JSON.stringify(initialState),
}),
headers: {
'Content-Type': 'application/json',
},
}).then((resp) => {
const explorer = parseRawExplorer(resp.data);
dispatch(loadExploration(explorer));
push(`/sources/${source.id}/chronograf/data-explorer/${btoa(explorer.link.href)}`); // Base64 encode explorer URI
});
};
}
export function deleteExplorer(source, explorerURI, push) {
return (dispatch, getState) => {
AJAX({
url: explorerURI,
method: 'DELETE',
}).then(() => {
const state = getState();
// If the currently active explorer is being deleted, load another session;
if (state.activeExplorer.id === explorerURI) {
const explorerURIs = Object.keys(state.explorers);
const explorer = state.explorers[explorerURIs[0]];
// If there's only one exploration left, it means we're deleting the last
// exploration and should create a new one. If not, navigate to the first
// exploration in state.
if (explorerURIs.length === 1) {
dispatch(createExploration(source, push));
} else {
dispatch(loadExploration(explorer));
push(`/sources/${source.id}/chronograf/data-explorer/${btoa(explorer.id)}`);
}
}
dispatch({
type: 'DELETE_EXPLORER',
payload: {id: explorerURI},
});
dispatch(publishNotification('success', 'The exploration was successfully deleted'));
}).catch(() => {
dispatch(publishNotification('error', 'The exploration could not be deleted'));
});
};
}
export function editExplorer(explorerURI, params) {
return (dispatch) => {
AJAX({
url: explorerURI,
method: 'PATCH',
data: JSON.stringify(params),
headers: {
'Content-Type': 'application/json',
},
}).then((resp) => {
dispatch({
type: 'EDIT_EXPLORER',
payload: {
explorer: resp.data,
},
});
});
};
}
function loadExplorers(explorers) {
return {
type: 'LOAD_EXPLORERS',
payload: {explorers},
};
}
function loadExploration(explorer) {
return {
type: 'LOAD_EXPLORER',
payload: {explorer},
};
}
export function fetchExplorers({source, userID, explorerURI, push}) {
return (dispatch) => {
dispatch({type: 'FETCH_EXPLORERS'});
AJAX({
url: `/chronograf/v1/users/${userID}/explorations`,
}).then(({data: {explorations}}) => {
const explorers = explorations.map(parseRawExplorer);
dispatch(loadExplorers(explorers));
// Create a new explorer session for a user if they don't have any
// saved (e.g. when they visit for the first time).
if (!explorers.length) {
dispatch(createExploration(source, push));
return;
}
// If no explorerURI is provided, it means the user wasn't attempting to visit
// a specific explorer (i.e. `/data_explorer/:id`). In this case, pick the
// most recently updated explorer and navigate to it.
if (!explorerURI) {
const explorer = _.maxBy(explorers, (ex) => ex.updated_at);
dispatch(loadExploration(explorer));
push(`/sources/${source.id}/chronograf/data-explorer/${btoa(explorer.link.href)}`);
return;
}
// We have an explorerURI, meaning a specific explorer was requested.
const explorer = explorers.find((ex) => ex.id === explorerURI);
// Attempting to request a non-existent explorer
if (!explorer) {
return;
}
dispatch(loadExploration(explorer));
});
};
}
/**
* Informs reducers when to clear out state in expectation of
* a new data explorer being loaded, or showing a spinner, etc.
*/
function fetchExplorer() {
return {
type: 'FETCH_EXPLORER',
};
}
function saveExplorer(error) {
return {
type: 'SAVE_EXPLORER',
payload: error ? new Error(error) : null,
error: true,
};
}
export function chooseExploration(explorerURI, source, push) {
return (dispatch, getState) => {
// Save the previous session explicitly in case an auto-save was unable to complete.
const {panels, queryConfigs, activeExplorer} = getState();
api.saveExplorer({
explorerID: activeExplorer.id,
name: activeExplorer.name,
panels,
queryConfigs,
}).then(() => {
dispatch(saveExplorer());
}).catch(({response}) => {
const err = JSON.parse(response).error;
dispatch(saveExplorer(err));
console.error('Unable to save data explorer session: ', JSON.parse(response).error); // eslint-disable-line no-console
});
dispatch(fetchExplorer());
AJAX({
url: explorerURI,
}).then((resp) => {
const explorer = parseRawExplorer(resp.data);
dispatch(loadExploration(explorer));
push(`/sources/${source.id}/chronograf/data-explorer/${btoa(explorerURI)}`);
});
};
}
function parseRawExplorer(raw) {
return Object.assign({}, raw, {
data: JSON.parse(raw.data),
});
}
export function updateRawQuery(queryID, text) {
return {
type: 'UPDATE_RAW_QUERY',
@ -346,3 +159,12 @@ export function updateRawQuery(queryID, text) {
},
};
}
export function activatePanel(panelID) {
return {
type: 'ACTIVATE_PANEL',
payload: {
panelID,
},
};
}

View File

@ -1,15 +0,0 @@
import AJAX from 'utils/ajax';
export function saveExplorer({name, panels, queryConfigs, explorerID}) {
return AJAX({
url: explorerID,
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
data: JSON.stringify({
data: JSON.stringify({panels, queryConfigs}),
name,
}),
});
}

View File

@ -9,6 +9,7 @@ const PanelBuilder = React.createClass({
propTypes: {
width: string,
actions: PropTypes.shape({
activatePanel: func.isRequired,
createPanel: func.isRequired,
deleteQuery: func.isRequired,
addQuery: func.isRequired,
@ -23,25 +24,23 @@ const PanelBuilder = React.createClass({
toggleTagAcceptance: func.isRequired,
deletePanel: func.isRequired,
}).isRequired,
setActivePanel: func.isRequired,
setActiveQuery: func.isRequired,
activePanelID: string,
activeQueryID: string,
},
handleCreateExploer() {
handleCreateExplorer() {
this.props.actions.createPanel();
},
render() {
const {width, actions, setActivePanel, setActiveQuery, activePanelID, activeQueryID} = this.props;
const {width, actions, setActiveQuery, activePanelID, activeQueryID} = this.props;
return (
<div className="panel-builder" style={{width}}>
<div className="btn btn-block btn-primary" onClick={this.handleCreateExploer}><span className="icon graphline"></span>&nbsp;&nbsp;Create Graph</div>
<div className="btn btn-block btn-primary" onClick={this.handleCreateExplorer}><span className="icon graphline"></span>&nbsp;&nbsp;Create Graph</div>
<PanelList
actions={actions}
setActivePanel={setActivePanel}
setActiveQuery={setActiveQuery}
activePanelID={activePanelID}
activeQueryID={activeQueryID}

View File

@ -13,20 +13,20 @@ const PanelList = React.createClass({
}).isRequired,
panels: shape({}).isRequired,
queryConfigs: PropTypes.shape({}),
actions: shape({}).isRequired,
setActivePanel: func.isRequired,
actions: shape({
activatePanel: func.isRequired,
deleteQuery: func.isRequired,
addQuery: func.isRequired,
}).isRequired,
setActiveQuery: func.isRequired,
activePanelID: string,
activeQueryID: string,
},
handleTogglePanel(panel) {
// If the panel being toggled is currently active, it means we should
// close everything by setting `activePanelID` to null.
const activePanelID = panel.id === this.props.activePanelID ?
null : panel.id;
const panelID = panel.id === this.props.activePanelID ? null : panel.id;
this.props.actions.activatePanel(panelID);
this.props.setActivePanel(activePanelID);
// Reset the activeQueryID when toggling Exporations
this.props.setActiveQuery(null);
},

View File

@ -1,7 +1,5 @@
import React, {PropTypes} from 'react';
import {connect} from 'react-redux';
import {withRouter} from 'react-router';
import {fetchExplorers} from '../actions/view';
import DataExplorer from './DataExplorer';
const App = React.createClass({
@ -12,52 +10,15 @@ const App = React.createClass({
self: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
fetchExplorers: PropTypes.func.isRequired,
router: PropTypes.shape({
push: PropTypes.func.isRequired,
}).isRequired,
params: PropTypes.shape({
base64ExplorerID: PropTypes.string,
}).isRequired,
},
componentDidMount() {
const {base64ExplorerID} = this.props.params;
this.props.fetchExplorers({
source: this.props.source,
userID: 1, // TODO: get the userID
explorerID: base64ExplorerID ? this.decodeID(base64ExplorerID) : null,
push: this.props.router.push,
});
},
render() {
const {base64ExplorerID} = this.props.params;
return (
<div className="page">
<DataExplorer source={this.props.source} explorerID={this.decodeID(base64ExplorerID)} />
<DataExplorer source={this.props.source} />
</div>
);
},
decodeID(base64Id) {
try {
return atob(base64Id);
} catch (e) {
if (!(e instanceof DOMException && e.name === "InvalidCharacterError")) {
throw e;
}
return null;
}
},
});
function mapStateToProps() {
return {};
}
export default connect(mapStateToProps, {
fetchExplorers,
})(withRouter(App));
export default withRouter(App);

View File

@ -4,41 +4,38 @@ import PanelBuilder from '../components/PanelBuilder';
import Visualizations from '../components/Visualizations';
import Header from '../containers/Header';
import ResizeContainer from 'shared/components/ResizeContainer';
import {FETCHING} from '../reducers/explorers';
import {
setTimeRange as setTimeRangeAction,
createExploration as createExplorationAction,
chooseExploration as chooseExplorationAction,
deleteExplorer as deleteExplorerAction,
editExplorer as editExplorerAction,
} from '../actions/view';
const {
func,
shape,
string,
} = PropTypes;
const DataExplorer = React.createClass({
propTypes: {
source: PropTypes.shape({
links: PropTypes.shape({
proxy: PropTypes.string.isRequired,
self: PropTypes.string.isRequired,
source: shape({
links: shape({
proxy: string.isRequired,
self: string.isRequired,
}).isRequired,
}).isRequired,
explorers: PropTypes.shape({}).isRequired,
explorerID: PropTypes.string,
timeRange: PropTypes.shape({
upper: PropTypes.string,
lower: PropTypes.string,
timeRange: shape({
upper: string,
lower: string,
}).isRequired,
setTimeRange: PropTypes.func.isRequired,
createExploration: PropTypes.func.isRequired,
chooseExploration: PropTypes.func.isRequired,
deleteExplorer: PropTypes.func.isRequired,
editExplorer: PropTypes.func.isRequired,
activePanel: string,
setTimeRange: func.isRequired,
},
childContextTypes: {
source: PropTypes.shape({
links: PropTypes.shape({
proxy: PropTypes.string.isRequired,
self: PropTypes.string.isRequired,
source: shape({
links: shape({
proxy: string.isRequired,
self: string.isRequired,
}).isRequired,
}).isRequired,
},
@ -49,46 +46,33 @@ const DataExplorer = React.createClass({
getInitialState() {
return {
activePanelID: null,
activeQueryID: null,
};
},
handleSetActivePanel(id) {
this.setState({activePanelID: id});
},
handleSetActiveQuery(id) {
this.setState({activeQueryID: id});
},
render() {
const {timeRange, explorers, explorerID, setTimeRange, createExploration, chooseExploration, deleteExplorer, editExplorer} = this.props;
if (explorers === FETCHING || !explorerID) {
// TODO: page-wide spinner
return null;
}
const {timeRange, setTimeRange, activePanel} = this.props;
return (
<div className="data-explorer">
<Header
actions={{setTimeRange, createExploration, chooseExploration, deleteExplorer, editExplorer}}
explorers={explorers}
actions={{setTimeRange}}
timeRange={timeRange}
explorerID={explorerID}
/>
<ResizeContainer>
<PanelBuilder
timeRange={timeRange}
activePanelID={this.state.activePanelID}
activePanelID={activePanel}
activeQueryID={this.state.activeQueryID}
setActiveQuery={this.handleSetActiveQuery}
setActivePanel={this.handleSetActivePanel}
/>
<Visualizations
timeRange={timeRange}
activePanelID={this.state.activePanelID}
activePanelID={activePanel}
activeQueryID={this.state.activeQueryID}
/>
</ResizeContainer>
@ -98,16 +82,14 @@ const DataExplorer = React.createClass({
});
function mapStateToProps(state) {
const {timeRange, dataExplorerUI} = state;
return {
timeRange: state.timeRange,
explorers: state.explorers,
timeRange,
activePanel: dataExplorerUI.activePanel,
};
}
export default connect(mapStateToProps, {
setTimeRange: setTimeRangeAction,
createExploration: createExplorationAction,
chooseExploration: chooseExplorationAction,
deleteExplorer: deleteExplorerAction,
editExplorer: editExplorerAction,
})(DataExplorer);

View File

@ -3,7 +3,6 @@ import moment from 'moment';
import {withRouter} from 'react-router';
import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown';
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
import Dropdown from 'shared/components/Dropdown';
const Header = React.createClass({
propTypes: {
@ -11,25 +10,9 @@ const Header = React.createClass({
upper: PropTypes.string,
lower: PropTypes.string,
}).isRequired,
explorers: PropTypes.shape({}).isRequired,
explorerID: PropTypes.string.isRequired,
actions: PropTypes.shape({
setTimeRange: PropTypes.func.isRequired,
createExploration: PropTypes.func.isRequired,
chooseExploration: PropTypes.func.isRequired,
deleteExplorer: PropTypes.func.isRequired,
editExplorer: PropTypes.func.isRequired,
}),
router: React.PropTypes.shape({
push: React.PropTypes.func.isRequired,
}).isRequired,
},
getInitialState() {
return {
explorerIDToDelete: null,
explorerIDToEdit: null,
};
},
contextTypes: {
@ -52,65 +35,9 @@ const Header = React.createClass({
return selected ? selected.inputValue : 'Custom';
},
handleCreateExploration() {
// TODO: passing in this.props.router.push is a big smell, getting something like
// react-router-redux might be better here
this.props.actions.createExploration(this.context.source, this.props.router.push);
},
handleChooseExplorer({id}) {
if (id === this.props.explorerID) {
return;
}
this.props.actions.chooseExploration(id, this.context.source, this.props.router.push);
},
/**
* As far as modals, bootstrap handles opening and closing, and we just need to
* store an id for whichever explorer was chosen.
*/
openDeleteExplorerModal({id}) {
this.setState({explorerIDToDelete: id});
},
confirmDeleteExplorer() {
this.props.actions.deleteExplorer(
this.context.source,
this.state.explorerIDToDelete,
this.props.router.push
);
},
openEditExplorerModal({id}) {
this.setState({explorerIDToEdit: id});
},
confirmEditExplorer({name}) {
this.props.actions.editExplorer(this.state.explorerIDToEdit, {name});
},
getName({name, createdAt}) {
return name || `${moment(createdAt).format('MMM-DD-YY — hh:mm:ss')}`;
},
render() {
const {timeRange, explorers, explorerID} = this.props;
const {timeRange} = this.props;
const selectedExplorer = explorers[explorerID];
if (!selectedExplorer) {
return null;
}
const dropdownItems = Object.keys(explorers).map((id) => {
const ex = explorers[id];
return {text: this.getName(ex), id: ex.id};
});
const dropdownActions = [
{text: 'Rename', icon: 'pencil', target: '#editExplorerModal', handler: this.openEditExplorerModal},
{text: 'Delete', icon: 'trash', target: '#deleteExplorerModal', handler: this.openDeleteExplorerModal},
];
return (
<div className="page-header">
<div className="page-header__container">
@ -118,15 +45,6 @@ const Header = React.createClass({
<h1>Explorer</h1>
</div>
<div className="page-header__right">
<h1>Session:</h1>
<Dropdown
className="sessions-dropdown"
items={dropdownItems}
actions={dropdownActions}
onChoose={this.handleChooseExplorer}
selected={this.getName(selectedExplorer)}
/>
<div className="btn btn-sm btn-primary sessions-dropdown__btn" onClick={this.handleCreateExploration}>New</div>
<h1>Source:</h1>
<div className="source-indicator">
<span className="icon cpu"></span>
@ -135,106 +53,9 @@ const Header = React.createClass({
<TimeRangeDropdown onChooseTimeRange={this.handleChooseTimeRange} selected={this.findSelected(timeRange)} />
</div>
</div>
<DeleteExplorerModal onConfirm={this.confirmDeleteExplorer} />
<EditExplorerModal onConfirm={this.confirmEditExplorer} />
</div>
);
},
});
const DeleteExplorerModal = React.createClass({
propTypes: {
onConfirm: PropTypes.func.isRequired,
},
render() {
return (
<div className="modal fade" id="deleteExplorerModal" tabIndex="-1" role="dialog">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
<h4 className="modal-title">Are you sure?</h4>
</div>
<div className="modal-footer">
<button className="btn btn-info" type="button" data-dismiss="modal">Cancel</button>
<button onClick={this.handleConfirm} className="btn btn-danger" type="button" data-dismiss="modal">Confirm</button>
</div>
</div>
</div>
</div>
);
},
handleConfirm(e) {
e.preventDefault();
this.props.onConfirm();
},
});
const EditExplorerModal = React.createClass({
propTypes: {
onConfirm: PropTypes.func.isRequired,
},
getInitialState() {
return {error: null};
},
render() {
return (
<div className="modal fade" id="editExplorerModal" tabIndex="-1" role="dialog">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<button type="button" className="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
<h4 className="modal-title">Rename Exploration</h4>
</div>
<form onSubmit={this.handleConfirm}>
<div className="modal-body">
{this.state.error ? <div className="alert alert-danger" role="alert">{this.state.error}</div> : null}
<div className="form-grid padding-top">
<div className="form-group col-md-8 col-md-offset-2">
<input ref="name" name="renameExplorer" type="text" className="form-control input-lg" id="renameExplorer" required={true} />
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn btn-info" onClick={this.handleCancel}>Cancel</button>
<input type="submit" value="Rename" className="btn btn-success" />
</div>
</form>
</div>
</div>
</div>
);
},
// We can't use `data-dismiss="modal"` because pressing the enter key will
// close the modal instead of submitting the form.
handleCancel() {
$('#editExplorerModal').modal('hide'); // eslint-disable-line no-undef
},
handleConfirm(e) {
e.preventDefault();
const name = this.refs.name.value;
if (name === '') {
this.setState({error: "Name can't be blank"});
return;
}
$('#editExplorerModal').modal('hide'); // eslint-disable-line no-undef
this.refs.name.value = '';
this.setState({error: null});
this.props.onConfirm({name});
},
});
export default withRouter(Header);

View File

@ -1,10 +0,0 @@
export default function activeExplorer(state = {}, action) {
switch (action.type) {
case 'LOAD_EXPLORER': {
const {link, name} = action.payload.explorer;
return {id: link.href, name};
}
}
return state;
}

View File

@ -0,0 +1,11 @@
export default function dataExplorerUI(state = {}, action) {
switch (action.type) {
case 'ACTIVATE_PANEL':
case 'CREATE_PANEL': {
const {panelID} = action.payload;
return {...state, activePanel: panelID};
}
}
return state;
}

View File

@ -1,57 +0,0 @@
import u from 'updeep';
export const FETCHING = {};
export default function explorers(state = {}, action) {
switch (action.type) {
case 'FETCH_EXPLORERS': {
return FETCHING;
}
case 'LOAD_EXPLORERS': {
return action.payload.explorers.reduce((nextState, explorer) => {
nextState[explorer.link.href] = normalizeExplorer(explorer);
return nextState;
}, {});
}
case 'LOAD_EXPLORER': {
const {explorer} = action.payload;
const update = {
[explorer.link.href]: normalizeExplorer(explorer),
};
return u(update, state);
}
case 'DELETE_EXPLORER': {
const {id} = action.payload;
return u(u.omit(id), state);
}
case 'EDIT_EXPLORER': {
const {explorer} = action.payload;
const update = {
[explorer.link.href]: normalizeExplorer(explorer),
};
return u(update, state);
}
}
return state;
}
function normalizeExplorer(explorer) {
const {link, name, data, user_id, created_at, updated_at} = explorer;
return Object.assign({}, explorer, {
id: link.href,
name,
data,
userID: user_id,
createdAt: created_at,
updatedAt: updated_at,
});
}

View File

@ -1,13 +1,11 @@
import queryConfigs from './queryConfigs';
import panels from './panels';
import timeRange from './timeRange';
import explorers from './explorers';
import activeExplorer from './activeExplorer';
import dataExplorerUI from './dataExplorerUI';
export {
queryConfigs,
panels,
timeRange,
explorers,
activeExplorer,
dataExplorerUI,
};

View File

@ -2,20 +2,12 @@ import update from 'react-addons-update';
export default function panels(state = {}, action) {
switch (action.type) {
case 'LOAD_EXPLORER': {
return action.payload.explorer.data.panels;
}
case 'CREATE_PANEL': {
const {panelId, queryId} = action.payload;
const panel = {
id: panelId,
queryIds: [queryId],
const {panelID, queryID} = action.payload;
return {
...state,
[panelID]: {id: panelID, queryIds: [queryID]},
};
return update(state, {
[panelId]: {$set: panel},
});
}
case 'RENAME_PANEL': {
@ -54,5 +46,6 @@ export default function panels(state = {}, action) {
});
}
}
return state;
}

View File

@ -49,9 +49,9 @@ export default function queryConfigs(state = {}, action) {
case 'CREATE_PANEL':
case 'ADD_KAPACITOR_QUERY':
case 'ADD_QUERY': {
const {queryId, options} = action.payload;
const {queryID, options} = action.payload;
const nextState = Object.assign({}, state, {
[queryId]: Object.assign({}, defaultQueryConfig(queryId), options),
[queryID]: Object.assign({}, defaultQueryConfig(queryID), options),
});
return nextState;

View File

@ -18,15 +18,11 @@ import NotFound from 'src/shared/components/NotFound';
import configureStore from 'src/store/configureStore';
import {getMe, getSources} from 'shared/apis';
import {receiveMe} from 'shared/actions/me';
import {loadLocalStorage} from './localStorage';
import 'src/style/chronograf.scss';
const defaultTimeRange = {upper: null, lower: 'now() - 15m'};
const lsTimeRange = window.localStorage.getItem('timeRange');
const parsedTimeRange = JSON.parse(lsTimeRange) || {};
const timeRange = Object.assign(defaultTimeRange, parsedTimeRange);
const store = configureStore({timeRange});
const store = configureStore(loadLocalStorage());
const rootNode = document.getElementById('react-root');
let browserHistory;
@ -112,7 +108,6 @@ const Root = React.createClass({
<Route path="manage-sources/new" component={SourcePage} />
<Route path="manage-sources/:id/edit" component={SourcePage} />
<Route path="chronograf/data-explorer" component={DataExplorer} />
<Route path="chronograf/data-explorer/:base64ExplorerID" component={DataExplorer} />
<Route path="hosts" component={HostsPage} />
<Route path="hosts/:hostID" component={HostPage} />
<Route path="kubernetes" component={KubernetesPage} />

33
ui/src/localStorage.js Normal file
View File

@ -0,0 +1,33 @@
import _ from 'lodash';
export const loadLocalStorage = () => {
try {
const serializedState = localStorage.getItem('state');
const defaultTimeRange = {upper: null, lower: 'now() - 15m'};
if (serializedState === null) {
return {timeRange: defaultTimeRange};
}
const parsedState = JSON.parse(serializedState) || {};
const timeRange = _.isEmpty(parsedState.timeRange) ? defaultTimeRange : parsedState.timeRange;
return {...parsedState, timeRange};
} catch (err) {
console.error(`Loading persisted state failed: ${err}`); // eslint-disable-line no-console
return {};
}
};
export const saveToLocalStorage = ({panels, queryConfigs, timeRange, dataExplorerUI}) => {
try {
window.localStorage.setItem('state', JSON.stringify({
panels,
queryConfigs,
timeRange,
dataExplorerUI,
}));
} catch (err) {
console.error('Unable to save data explorer: ', JSON.parse(err)); // eslint-disable-line no-console
}
};

View File

@ -6,17 +6,15 @@ const SideNav = React.createClass({
propTypes: {
location: string.isRequired,
sourceID: string.isRequired,
explorationID: string,
me: shape({
email: string,
}),
},
render() {
const {me, location, sourceID, explorationID} = this.props;
const {me, location, sourceID} = this.props;
const sourcePrefix = `/sources/${sourceID}`;
const explorationSuffix = explorationID ? `/${explorationID}` : '';
const dataExplorerLink = `${sourcePrefix}/chronograf/data-explorer${explorationSuffix}`;
const dataExplorerLink = `${sourcePrefix}/chronograf/data-explorer`;
const loggedIn = !!(me && me.email);

View File

@ -8,20 +8,18 @@ const SideNavApp = React.createClass({
currentLocation: string.isRequired,
addFlashMessage: func.isRequired,
sourceID: string.isRequired,
explorationID: string,
me: shape({
email: string,
}),
},
render() {
const {me, currentLocation, sourceID, explorationID} = this.props;
const {me, currentLocation, sourceID} = this.props;
return (
<SideNav
sourceID={sourceID}
location={currentLocation}
explorationID={explorationID}
me={me}
/>
);

View File

@ -2,14 +2,14 @@ import {createStore, applyMiddleware, compose} from 'redux';
import {combineReducers} from 'redux';
import thunkMiddleware from 'redux-thunk';
import makeQueryExecuter from 'src/shared/middleware/queryExecuter';
import * as chronografReducers from 'src/data_explorer/reducers';
import * as dataExplorerReducers from 'src/data_explorer/reducers';
import * as sharedReducers from 'src/shared/reducers';
import rulesReducer from 'src/kapacitor/reducers/rules';
import persistStateEnhancer from './persistStateEnhancer';
const rootReducer = combineReducers({
...sharedReducers,
...chronografReducers,
...dataExplorerReducers,
rules: rulesReducer,
});

View File

@ -1,4 +1,5 @@
import {saveExplorer} from 'src/data_explorer/api';
import _ from 'lodash';
import {saveToLocalStorage} from '../localStorage';
/**
* Redux store enhancer (https://github.com/reactjs/redux/blob/master/docs/Glossary.md)
@ -6,48 +7,17 @@ import {saveExplorer} from 'src/data_explorer/api';
* It subscribes a listener function to the store -- meaning every time the store emits an update
* (after some state has changed), we'll have a chance to react.
*
* After the store emits an update, we'll queue a function to request a save in x number of
* seconds. The previous timer is cleared out as well, meaning we won't end up firing a ton of
* saves in a short period of time. Only after the store has been idle for x seconds will the save occur.
* Debouncing the saveToLocalStorage to ensure we are stringify and setItem at most once per second.
*/
const autoSaveTimer = (() => {
let timer;
return {
set(cb) {
const timeUntilSave = 3000;
timer = setTimeout(cb, timeUntilSave);
},
clear() {
clearInterval(timer);
},
};
})();
export default function persistState() {
return (next) => (reducer, initialState, enhancer) => {
const store = next(reducer, initialState, enhancer);
const throttleMs = 1000;
store.subscribe(() => {
const state = Object.assign({}, store.getState());
const explorerID = state.activeExplorer.id;
const name = state.activeExplorer.name;
if (!explorerID) {
return;
}
const {panels, queryConfigs} = state;
autoSaveTimer.clear();
autoSaveTimer.set(() => {
saveExplorer({panels, queryConfigs, explorerID, name}).then((_) => {
// TODO: This is a no-op currently because we don't have any feedback in the UI around saving, but maybe we do something in the future?
// If we ever show feedback in the UI, we could potentially indicate to remove it here.
}).catch(({response}) => {
console.error('Unable to save data explorer session: ', JSON.parse(response).error); // eslint-disable-line no-console
});
});
});
store.subscribe(_.throttle(() => {
saveToLocalStorage(store.getState());
}, throttleMs));
return store;
};