Introduce ability to edit a dashboard cell

* Correct documentation for dashboards

* Exclude .git and use 'make run-dev' in 'make continuous'

* Fix dashboard deletion bug where id serialization was wrong

* Commence creation of overlay technology, add autoRefresh props to DashboardPage

* Enhance overlay magnitude of overlay technology

* Add confirm buttons to overlay technology

* Refactor ResizeContainer to accommodate arbitrary containers

* Refactor ResizeContainer to require explicit ResizeTop and ResizeBottom for clarity

* Add markup and styles for OverlayControls

* CellEditorOverlay needs a larger minimum bottom height to accommodate more things

* Revert Visualization to not use ResizeTop or flex-box

* Remove TODO and move to issue

* Refactor CellEditorOverlay to allow selection of graph type

* Style Overlay controls, move confirm buttons to own stylesheet

* Fix toggle buttons in overlay so active is actually active

* Block user-select on a few UI items

* Update cell query shape to support Visualization and LayoutRenderer

* Code cleanup

* Repair fixture schema; update props for affected components

* Wired up selectedGraphType and activeQueryID in CellEditorOverlay

* Wire up chooseMeasurements in QueryBuilder

Pass queryActions into QueryBuilder so that DataExplorer can provide
actionCreators and CellEditorOverlay can provide functions that
modify its component state

* semicolon cleanup

* Bind all queryModifier actions to component state with a stateReducer

* Overlay Technologies™ can add and delete a query from a cell

* Semicolon cleanup

* Add conversion of InfluxQL to QueryConfig for dashboards

* Update go deps to add influxdb at af72d9b0e4

* Updated docs for dashboard query config

* Update CHANGELOG to mention InfluxQL to QueryConfig

* Make reducer’s name more specific for clarity

* Remove 'table' as graphType

* Make graph renaming prettier

* Remove duplicate DashboardQuery in swagger.json

* Fix swagger to include name and links for Cell

* Refactor CellEditorOverlay to enable graph type selection

* Add link.self to all Dashboard cells; add bolt migrations

* Make dash graph names only hover on contents

* Consolidate timeRange format patterns, clean up

* Add cell endpoints to dashboards

* Include Line + Stat in Visualization Type list

* Add cell link to dashboards

* Enable step plot and stacked graph in Visualization

* Overlay Technologies are summonable and dismissable

* OverlayTechnologies saves changes to a cell

* Convert NameableGraph to createClass for state

This was converted from a pure function to encapsulate the state of the
buttons. An attempt was made previously to store this state in Redux,
but it proved too convoluted with the current state of the reducers for
cells and dashboards. Another effort must take place to separate a cell
reducer to manage the state of an individual cell in Redux in order for
this state to be sanely kept in Redux as well.

For the time being, this state is being kept in the component for the
sake of expeditiousness, since this is needed for Dashboards to be
released. A refactor of this will occur later.

* Cells should contain a links key in server response

* Clean up console logs

* Use live data instead of a cellQuery fixture

* Update docs for dashboard creation

* DB and RP are already present in the Command field

* Fix LayoutRenderer’s understanding of query schema

* Return a new object, rather that mutate in place

* Visualization doesn’t use activeQueryID

* Selected is an object, not a string

* QueryBuilder refactored to use query index instead of query id

* CellEditorOverlay refactored to use query index instead of query id

* ConfirmButtons doesn’t need to act on an item

* Rename functions to follow convention

* Queries are no longer guaranteed to have ids

* Omit WHERE and GROUP BY clauses when saving query

* Select new query on add in OverlayTechnologies

* Add click outside to dash graph menu, style menu also

* Change context menu from ... to a caret

More consistent with the rest of the UI, better affordance

* Hide graph context menu in presentation mode

Don’t want people editing a dashboard from presentation mode

* Move graph refreshing spinner so it does not overlap with context menu

* Wire up Cell Menu to Overlay Technologies

* Correct empty dashboard type

* Refactor dashboard spec fixtures

* Test syncDashboardCell reducer

* Remove Delete button from graph dropdown menu (for now)

* Update changelog
pull/10616/head
Jared Scheib 2017-03-23 17:12:33 -07:00 committed by lukevmorris
parent 93a7b38043
commit b90ff76670
63 changed files with 1992 additions and 400 deletions

View File

@ -4,6 +4,8 @@
### Features
1. [#1020](https://github.com/influxdata/chronograf/pull/1020): Users can now edit cell names on dashboards
2. [#1035](https://github.com/influxdata/chronograf/pull/1035): Convert many InfluxQL statements to query builder
3. [#1015](https://github.com/influxdata/chronograf/pull/1015): Introduce ability to edit a dashboard cell
### UI Improvements

1
Godeps
View File

@ -7,6 +7,7 @@ github.com/elazarl/go-bindata-assetfs 9a6736ed45b44bf3835afeebb3034b57ed329f3e
github.com/gogo/protobuf 6abcf94fd4c97dcb423fdafd42fe9f96ca7e421b
github.com/google/go-github 1bc362c7737e51014af7299e016444b654095ad9
github.com/google/go-querystring 9235644dd9e52eeae6fa48efd539fdc351a0af53
github.com/influxdata/influxdb af72d9b0e4ebe95be30e89b160f43eabaf0529ed
github.com/influxdata/kapacitor 5408057e5a3493d3b5bd38d5d535ea45b587f8ff
github.com/influxdata/usage-client 6d3895376368aa52a3a81d2a16e90f0f52371967
github.com/jessevdk/go-flags 4cc2832a6e6d1d3b815e2b9d544b2a4dfb3ce8fa

View File

@ -8,6 +8,7 @@
* github.com/gogo/protobuf [BSD](https://github.com/gogo/protobuf/blob/master/LICENSE)
* github.com/google/go-github [BSD](https://github.com/google/go-github/blob/master/LICENSE)
* github.com/google/go-querystring [BSD](https://github.com/google/go-querystring/blob/master/LICENSE)
* github.com/influxdata/influxdb [MIT](https://github.com/influxdata/influxdb/blob/master/LICENSE)
* github.com/influxdata/kapacitor [MIT](https://github.com/influxdata/kapacitor/blob/master/LICENSE)
* github.com/influxdata/usage-client [MIT](https://github.com/influxdata/usage-client/blob/master/LICENSE.TXT)
* github.com/jessevdk/go-flags [BSD](https://github.com/jessevdk/go-flags/blob/master/LICENSE)

View File

@ -104,7 +104,7 @@ clean:
@rm -f .godep .jsdep .jssrc .dev-jssrc .bindata
continuous:
while true; do if fswatch -r --one-event .; then echo "#-> Starting build: `date`"; make dev; pkill chronograf; ./chronograf -d --log-level=debug & echo "#-> Build complete."; fi; sleep 0.5; done
while true; do if fswatch -e "\.git" -r --one-event .; then echo "#-> Starting build: `date`"; make dev; pkill -9 chronograf; make run-dev & echo "#-> Build complete."; fi; sleep 0.5; done
ctags:
ctags -R --languages="Go" --exclude=.git --exclude=ui .

View File

@ -75,9 +75,7 @@ curl -X POST -H "Content-Type: application/json" -d '{
"queries": [
{
"label": "%",
"query": "SELECT mean(\"usage_user\") AS \"usage_user\" FROM \"cpu\"",
"wheres": [],
"groupbys": []
"query": "SELECT mean(\"usage_user\") AS \"usage_user\" FROM \"telegraf\".\"autogen\".\"cpu\""
}
],
"type": "line"

View File

@ -11,7 +11,7 @@ import (
func setupTestClient() (*TestClient, error) {
if c, err := NewTestClient(); err != nil {
return nil, err
} else if err := c.Open(); err != nil {
} else if err := c.Open(context.TODO()); err != nil {
return nil, err
} else {
return c, nil

View File

@ -1,6 +1,7 @@
package bolt
import (
"context"
"time"
"github.com/boltdb/bolt"
@ -34,12 +35,15 @@ func NewClient() *Client {
client: c,
IDs: &uuid.V4{},
}
c.DashboardsStore = &DashboardsStore{client: c}
c.DashboardsStore = &DashboardsStore{
client: c,
IDs: &uuid.V4{},
}
return c
}
// Open and initialize boltDB. Initial buckets are created if they do not exist.
func (c *Client) Open() error {
func (c *Client) Open(ctx context.Context) error {
// Open database file.
db, err := bolt.Open(c.Path, 0600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
@ -77,7 +81,8 @@ func (c *Client) Open() error {
return err
}
return nil
// Runtime migrations
return c.DashboardsStore.Migrate(ctx)
}
// Close the connection to the bolt database

View File

@ -18,7 +18,43 @@ var DashboardBucket = []byte("Dashoard")
// DashboardsStore is the bolt implementation of storing dashboards
type DashboardsStore struct {
client *Client
IDs chronograf.DashboardID
IDs chronograf.ID
}
// AddIDs is a migration function that adds ID information to existing dashboards
func (d *DashboardsStore) AddIDs(ctx context.Context, boards []chronograf.Dashboard) error {
for _, board := range boards {
update := false
for i, cell := range board.Cells {
// If there are is no id set, we generate one and update the dashboard
if cell.ID == "" {
id, err := d.IDs.Generate()
if err != nil {
return err
}
cell.ID = id
board.Cells[i] = cell
update = true
}
}
if !update {
continue
}
if err := d.Update(ctx, board); err != nil {
return err
}
}
return nil
}
// Migrate updates the dashboards at runtime
func (d *DashboardsStore) Migrate(ctx context.Context) error {
// 1. Add UUIDs to cells without one
boards, err := d.All(ctx)
if err != nil {
return err
}
return d.AddIDs(ctx, boards)
}
// All returns all known dashboards
@ -51,6 +87,14 @@ func (d *DashboardsStore) Add(ctx context.Context, src chronograf.Dashboard) (ch
src.ID = chronograf.DashboardID(id)
strID := strconv.Itoa(int(id))
for i, cell := range src.Cells {
cid, err := d.IDs.Generate()
if err != nil {
return err
}
cell.ID = cid
src.Cells[i] = cell
}
if v, err := internal.MarshalDashboard(src); err != nil {
return err
} else if err := b.Put([]byte(strID), v); err != nil {
@ -85,7 +129,8 @@ func (d *DashboardsStore) Get(ctx context.Context, id chronograf.DashboardID) (c
// Delete the dashboard from DashboardsStore
func (d *DashboardsStore) Delete(ctx context.Context, dash chronograf.Dashboard) error {
if err := d.client.db.Update(func(tx *bolt.Tx) error {
if err := tx.Bucket(DashboardBucket).Delete(itob(int(dash.ID))); err != nil {
strID := strconv.Itoa(int(dash.ID))
if err := tx.Bucket(DashboardBucket).Delete([]byte(strID)); err != nil {
return err
}
return nil
@ -106,6 +151,17 @@ func (d *DashboardsStore) Update(ctx context.Context, dash chronograf.Dashboard)
return chronograf.ErrDashboardNotFound
}
for i, cell := range dash.Cells {
if cell.ID != "" {
continue
}
cid, err := d.IDs.Generate()
if err != nil {
return err
}
cell.ID = cid
dash.Cells[i] = cell
}
if v, err := internal.MarshalDashboard(dash); err != nil {
return err
} else if err := b.Put([]byte(strID), v); err != nil {

View File

@ -171,17 +171,14 @@ func MarshalDashboard(d chronograf.Dashboard) ([]byte, error) {
r.Upper, r.Lower = q.Range.Upper, q.Range.Lower
}
queries[j] = &Query{
Command: q.Command,
DB: q.DB,
RP: q.RP,
GroupBys: q.GroupBys,
Wheres: q.Wheres,
Label: q.Label,
Range: r,
Command: q.Command,
Label: q.Label,
Range: r,
}
}
cells[i] = &DashboardCell{
ID: c.ID,
X: c.X,
Y: c.Y,
W: c.W,
@ -208,15 +205,11 @@ func UnmarshalDashboard(data []byte, d *chronograf.Dashboard) error {
cells := make([]chronograf.DashboardCell, len(pb.Cells))
for i, c := range pb.Cells {
queries := make([]chronograf.Query, len(c.Queries))
queries := make([]chronograf.DashboardQuery, len(c.Queries))
for j, q := range c.Queries {
queries[j] = chronograf.Query{
Command: q.Command,
DB: q.DB,
RP: q.RP,
GroupBys: q.GroupBys,
Wheres: q.Wheres,
Label: q.Label,
queries[j] = chronograf.DashboardQuery{
Command: q.Command,
Label: q.Label,
}
if q.Range.Upper != q.Range.Lower {
queries[j].Range = &chronograf.Range{
@ -227,6 +220,7 @@ func UnmarshalDashboard(data []byte, d *chronograf.Dashboard) error {
}
cells[i] = chronograf.DashboardCell{
ID: c.ID,
X: c.X,
Y: c.Y,
W: c.W,

View File

@ -81,6 +81,7 @@ type DashboardCell struct {
Queries []*Query `protobuf:"bytes,5,rep,name=queries" json:"queries,omitempty"`
Name string `protobuf:"bytes,6,opt,name=name,proto3" json:"name,omitempty"`
Type string `protobuf:"bytes,7,opt,name=type,proto3" json:"type,omitempty"`
ID string `protobuf:"bytes,8,opt,name=ID,proto3" json:"ID,omitempty"`
}
func (m *DashboardCell) Reset() { *m = DashboardCell{} }
@ -224,46 +225,47 @@ func init() {
func init() { proto.RegisterFile("internal.proto", fileDescriptorInternal) }
var fileDescriptorInternal = []byte{
// 653 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0xbc, 0x54, 0xd1, 0x6e, 0xd3, 0x4a,
0x10, 0xd5, 0xc6, 0x76, 0x12, 0x4f, 0x7b, 0x7b, 0xaf, 0x56, 0x57, 0xb0, 0xe2, 0xc9, 0xb2, 0x40,
0x0a, 0x48, 0xf4, 0x81, 0x7e, 0x41, 0x5b, 0x4b, 0x28, 0xd0, 0x96, 0xb2, 0x69, 0xe1, 0x09, 0xa4,
0x6d, 0x3a, 0x69, 0x2c, 0x1c, 0xdb, 0xac, 0x6d, 0x52, 0xff, 0x02, 0xe2, 0x0b, 0x78, 0xe0, 0x23,
0xf8, 0x15, 0x7e, 0x08, 0xcd, 0x7a, 0xed, 0xb8, 0xa2, 0xa0, 0x3e, 0xf1, 0x36, 0x67, 0x66, 0x73,
0x66, 0xe6, 0x9c, 0x71, 0x60, 0x27, 0x4e, 0x4b, 0xd4, 0xa9, 0x4a, 0x76, 0x73, 0x9d, 0x95, 0x19,
0x1f, 0xb7, 0x38, 0xfc, 0x3c, 0x80, 0xe1, 0x2c, 0xab, 0xf4, 0x1c, 0xf9, 0x0e, 0x0c, 0xa6, 0x91,
0x60, 0x01, 0x9b, 0x38, 0x72, 0x30, 0x8d, 0x38, 0x07, 0xf7, 0x44, 0xad, 0x50, 0x0c, 0x02, 0x36,
0xf1, 0xa5, 0x89, 0x29, 0x77, 0x56, 0xe7, 0x28, 0x9c, 0x26, 0x47, 0x31, 0x7f, 0x00, 0xe3, 0xf3,
0x82, 0xd8, 0x56, 0x28, 0x5c, 0x93, 0xef, 0x30, 0xd5, 0x4e, 0x55, 0x51, 0xac, 0x33, 0x7d, 0x29,
0xbc, 0xa6, 0xd6, 0x62, 0xfe, 0x1f, 0x38, 0xe7, 0xf2, 0x48, 0x0c, 0x4d, 0x9a, 0x42, 0x2e, 0x60,
0x14, 0xe1, 0x42, 0x55, 0x49, 0x29, 0x46, 0x01, 0x9b, 0x8c, 0x65, 0x0b, 0x89, 0xe7, 0x0c, 0x13,
0xbc, 0xd2, 0x6a, 0x21, 0xc6, 0x0d, 0x4f, 0x8b, 0xf9, 0x2e, 0xf0, 0x69, 0x5a, 0xe0, 0xbc, 0xd2,
0x38, 0xfb, 0x10, 0xe7, 0x6f, 0x50, 0xc7, 0x8b, 0x5a, 0xf8, 0x86, 0xe0, 0x96, 0x0a, 0x75, 0x39,
0xc6, 0x52, 0x51, 0x6f, 0x30, 0x54, 0x2d, 0x0c, 0xdf, 0x83, 0x1f, 0xa9, 0x62, 0x79, 0x91, 0x29,
0x7d, 0x79, 0x27, 0x39, 0x9e, 0x82, 0x37, 0xc7, 0x24, 0x29, 0x84, 0x13, 0x38, 0x93, 0xad, 0x67,
0xf7, 0x77, 0x3b, 0x9d, 0x3b, 0x9e, 0x43, 0x4c, 0x12, 0xd9, 0xbc, 0x0a, 0xbf, 0x32, 0xf8, 0xe7,
0x46, 0x81, 0x6f, 0x03, 0xbb, 0x36, 0x3d, 0x3c, 0xc9, 0xae, 0x09, 0xd5, 0x86, 0xdf, 0x93, 0xac,
0x26, 0xb4, 0x36, 0x42, 0x7b, 0x92, 0xad, 0x09, 0x2d, 0x8d, 0xbc, 0x9e, 0x64, 0x4b, 0xfe, 0x18,
0x46, 0x1f, 0x2b, 0xd4, 0x31, 0x16, 0xc2, 0x33, 0xad, 0xff, 0xdd, 0xb4, 0x7e, 0x5d, 0xa1, 0xae,
0x65, 0x5b, 0xa7, 0xb9, 0x8d, 0x35, 0x8d, 0xce, 0x26, 0xa6, 0x5c, 0x49, 0x36, 0x8e, 0x9a, 0x1c,
0xc5, 0xe1, 0x17, 0x06, 0xc3, 0x19, 0xea, 0x4f, 0xa8, 0xef, 0xb4, 0x7a, 0xdf, 0x75, 0xe7, 0x0f,
0xae, 0xbb, 0xb7, 0xbb, 0xee, 0x6d, 0x5c, 0xff, 0x1f, 0xbc, 0x99, 0x9e, 0x4f, 0x23, 0x33, 0xa1,
0x23, 0x1b, 0x10, 0x7e, 0x63, 0x30, 0x3c, 0x52, 0x75, 0x56, 0x95, 0xbd, 0x71, 0x7c, 0x33, 0x4e,
0x00, 0x5b, 0xfb, 0x79, 0x9e, 0xc4, 0x73, 0x55, 0xc6, 0x59, 0x6a, 0xa7, 0xea, 0xa7, 0xe8, 0xc5,
0x31, 0xaa, 0xa2, 0xd2, 0xb8, 0xc2, 0xb4, 0xb4, 0xf3, 0xf5, 0x53, 0xfc, 0x21, 0x78, 0x87, 0xc6,
0x39, 0xd7, 0xc8, 0xb7, 0xb3, 0x91, 0xaf, 0x31, 0xcc, 0x14, 0x69, 0x91, 0xfd, 0xaa, 0xcc, 0x16,
0x49, 0xb6, 0x36, 0x13, 0x8f, 0x65, 0x87, 0xc3, 0x1f, 0x0c, 0xdc, 0xbf, 0xe5, 0xe1, 0x36, 0xb0,
0xd8, 0x1a, 0xc8, 0xe2, 0xce, 0xd1, 0x51, 0xcf, 0x51, 0x01, 0xa3, 0x5a, 0xab, 0xf4, 0x0a, 0x0b,
0x31, 0x0e, 0x9c, 0x89, 0x23, 0x5b, 0x68, 0x2a, 0x89, 0xba, 0xc0, 0xa4, 0x10, 0x7e, 0xe0, 0xd0,
0xb9, 0x5b, 0xd8, 0x5d, 0x01, 0xf4, 0xae, 0xe0, 0x3b, 0x03, 0xcf, 0x34, 0xa7, 0xdf, 0x1d, 0x66,
0xab, 0x95, 0x4a, 0x2f, 0xad, 0xf4, 0x2d, 0x24, 0x3f, 0xa2, 0x03, 0x2b, 0xfb, 0x20, 0x3a, 0x20,
0x2c, 0x4f, 0xad, 0xc8, 0x03, 0x79, 0x4a, 0xaa, 0x3d, 0xd7, 0x59, 0x95, 0x1f, 0xd4, 0x8d, 0xbc,
0xbe, 0xec, 0x30, 0xbf, 0x07, 0xc3, 0xb7, 0x4b, 0xd4, 0x76, 0x67, 0x5f, 0x5a, 0x44, 0x47, 0x70,
0x44, 0x53, 0xd9, 0x2d, 0x1b, 0xc0, 0x1f, 0x81, 0x27, 0x69, 0x0b, 0xb3, 0xea, 0x0d, 0x81, 0x4c,
0x5a, 0x36, 0xd5, 0x70, 0xcf, 0x3e, 0x23, 0x96, 0xf3, 0x3c, 0x47, 0x6d, 0x6f, 0xb7, 0x01, 0x86,
0x3b, 0x5b, 0xa3, 0x36, 0x23, 0x3b, 0xb2, 0x01, 0xe1, 0x3b, 0xf0, 0xf7, 0x13, 0xd4, 0xa5, 0xac,
0x12, 0xfc, 0xe5, 0xc4, 0x38, 0xb8, 0x2f, 0x66, 0xaf, 0x4e, 0xda, 0x8b, 0xa7, 0x78, 0x73, 0xa7,
0x4e, 0xef, 0x4e, 0x69, 0xa1, 0x97, 0x2a, 0x57, 0xd3, 0xc8, 0x18, 0xeb, 0x48, 0x8b, 0xc2, 0x27,
0xe0, 0xd2, 0xf7, 0xd0, 0x63, 0x76, 0x7f, 0xf7, 0x2d, 0x5d, 0x0c, 0xcd, 0xbf, 0xf2, 0xde, 0xcf,
0x00, 0x00, 0x00, 0xff, 0xff, 0xfa, 0x57, 0xfe, 0xff, 0xa7, 0x05, 0x00, 0x00,
// 660 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0xbc, 0x54, 0xdd, 0x6e, 0xd3, 0x4a,
0x10, 0xd6, 0xc6, 0x76, 0x7e, 0xa6, 0x3d, 0x3d, 0x47, 0xab, 0x23, 0x58, 0x71, 0x15, 0x59, 0x20,
0x05, 0x24, 0x7a, 0x41, 0x9f, 0xa0, 0xad, 0x25, 0x14, 0x68, 0x4b, 0xd9, 0xb4, 0x70, 0x05, 0xd2,
0x36, 0x9d, 0x34, 0x16, 0x8e, 0x6d, 0xd6, 0x36, 0xa9, 0x5f, 0x01, 0xf1, 0x0c, 0x3c, 0x00, 0x97,
0xbc, 0x0a, 0x2f, 0x84, 0x66, 0x77, 0xed, 0xb8, 0xa2, 0xa0, 0x5e, 0x71, 0x37, 0xdf, 0xcc, 0x66,
0x7e, 0xbe, 0xef, 0x73, 0x60, 0x27, 0x4e, 0x4b, 0xd4, 0xa9, 0x4a, 0x76, 0x73, 0x9d, 0x95, 0x19,
0x1f, 0x36, 0x38, 0xfc, 0xdc, 0x83, 0xfe, 0x2c, 0xab, 0xf4, 0x1c, 0xf9, 0x0e, 0xf4, 0xa6, 0x91,
0x60, 0x63, 0x36, 0xf1, 0x64, 0x6f, 0x1a, 0x71, 0x0e, 0xfe, 0x89, 0x5a, 0xa1, 0xe8, 0x8d, 0xd9,
0x64, 0x24, 0x4d, 0x4c, 0xb9, 0xb3, 0x3a, 0x47, 0xe1, 0xd9, 0x1c, 0xc5, 0xfc, 0x01, 0x0c, 0xcf,
0x0b, 0xea, 0xb6, 0x42, 0xe1, 0x9b, 0x7c, 0x8b, 0xa9, 0x76, 0xaa, 0x8a, 0x62, 0x9d, 0xe9, 0x4b,
0x11, 0xd8, 0x5a, 0x83, 0xf9, 0x7f, 0xe0, 0x9d, 0xcb, 0x23, 0xd1, 0x37, 0x69, 0x0a, 0xb9, 0x80,
0x41, 0x84, 0x0b, 0x55, 0x25, 0xa5, 0x18, 0x8c, 0xd9, 0x64, 0x28, 0x1b, 0x48, 0x7d, 0xce, 0x30,
0xc1, 0x2b, 0xad, 0x16, 0x62, 0x68, 0xfb, 0x34, 0x98, 0xef, 0x02, 0x9f, 0xa6, 0x05, 0xce, 0x2b,
0x8d, 0xb3, 0x0f, 0x71, 0xfe, 0x06, 0x75, 0xbc, 0xa8, 0xc5, 0xc8, 0x34, 0xb8, 0xa5, 0x42, 0x53,
0x8e, 0xb1, 0x54, 0x34, 0x1b, 0x4c, 0xab, 0x06, 0x86, 0xef, 0x61, 0x14, 0xa9, 0x62, 0x79, 0x91,
0x29, 0x7d, 0x79, 0x27, 0x3a, 0x9e, 0x42, 0x30, 0xc7, 0x24, 0x29, 0x84, 0x37, 0xf6, 0x26, 0x5b,
0xcf, 0xee, 0xef, 0xb6, 0x3c, 0xb7, 0x7d, 0x0e, 0x31, 0x49, 0xa4, 0x7d, 0x15, 0x7e, 0x63, 0xf0,
0xcf, 0x8d, 0x02, 0xdf, 0x06, 0x76, 0x6d, 0x66, 0x04, 0x92, 0x5d, 0x13, 0xaa, 0x4d, 0xff, 0x40,
0xb2, 0x9a, 0xd0, 0xda, 0x10, 0x1d, 0x48, 0xb6, 0x26, 0xb4, 0x34, 0xf4, 0x06, 0x92, 0x2d, 0xf9,
0x63, 0x18, 0x7c, 0xac, 0x50, 0xc7, 0x58, 0x88, 0xc0, 0x8c, 0xfe, 0x77, 0x33, 0xfa, 0x75, 0x85,
0xba, 0x96, 0x4d, 0x9d, 0xf6, 0x36, 0xd2, 0x58, 0x9e, 0x4d, 0x4c, 0xb9, 0x92, 0x64, 0x1c, 0xd8,
0x1c, 0xc5, 0xee, 0x5e, 0x4b, 0x6e, 0x6f, 0x1a, 0x85, 0x5f, 0x18, 0xf4, 0x67, 0xa8, 0x3f, 0xa1,
0xbe, 0x13, 0x15, 0x5d, 0x17, 0x78, 0x7f, 0x70, 0x81, 0x7f, 0xbb, 0x0b, 0x82, 0x8d, 0x0b, 0xfe,
0x87, 0x60, 0xa6, 0xe7, 0xd3, 0xc8, 0x6c, 0xec, 0x49, 0x0b, 0xc2, 0xaf, 0x0c, 0xfa, 0x47, 0xaa,
0xce, 0xaa, 0xb2, 0xb3, 0x8e, 0xd9, 0x94, 0x8f, 0x61, 0x6b, 0x3f, 0xcf, 0x93, 0x78, 0xae, 0xca,
0x38, 0x4b, 0xdd, 0x56, 0xdd, 0x14, 0xbd, 0x38, 0x46, 0x55, 0x54, 0x1a, 0x57, 0x98, 0x96, 0x6e,
0xbf, 0x6e, 0x8a, 0x3f, 0x84, 0xe0, 0xd0, 0x28, 0xe9, 0x1b, 0x3a, 0x77, 0x36, 0x74, 0x5a, 0x01,
0x4d, 0x91, 0x0e, 0xd9, 0xaf, 0xca, 0x6c, 0x91, 0x64, 0x6b, 0xb3, 0xf1, 0x50, 0xb6, 0x38, 0xfc,
0xc1, 0xc0, 0xff, 0x5b, 0x9a, 0x6e, 0x03, 0x8b, 0x9d, 0xa0, 0x2c, 0x6e, 0x15, 0x1e, 0x74, 0x14,
0x16, 0x30, 0xa8, 0xb5, 0x4a, 0xaf, 0xb0, 0x10, 0xc3, 0xb1, 0x37, 0xf1, 0x64, 0x03, 0x4d, 0x25,
0x51, 0x17, 0x98, 0x14, 0x62, 0x34, 0xf6, 0xc8, 0xfe, 0x0e, 0xb6, 0xae, 0x80, 0x8d, 0x2b, 0xc2,
0xef, 0x0c, 0x02, 0x33, 0x9c, 0x7e, 0x77, 0x98, 0xad, 0x56, 0x2a, 0xbd, 0x74, 0xd4, 0x37, 0x90,
0xf4, 0x88, 0x0e, 0x1c, 0xed, 0xbd, 0xe8, 0x80, 0xb0, 0x3c, 0x75, 0x24, 0xf7, 0xe4, 0x29, 0xb1,
0xf6, 0x5c, 0x67, 0x55, 0x7e, 0x50, 0x5b, 0x7a, 0x47, 0xb2, 0xc5, 0xfc, 0x1e, 0xf4, 0xdf, 0x2e,
0x51, 0xbb, 0x9b, 0x47, 0xd2, 0x21, 0x32, 0xc1, 0x11, 0x6d, 0xe5, 0xae, 0xb4, 0x80, 0x3f, 0x82,
0x40, 0xd2, 0x15, 0xe6, 0xd4, 0x1b, 0x04, 0x99, 0xb4, 0xb4, 0xd5, 0x70, 0xcf, 0x3d, 0xa3, 0x2e,
0xe7, 0x79, 0x8e, 0xda, 0x79, 0xd7, 0x02, 0xd3, 0x3b, 0x5b, 0xa3, 0x36, 0x2b, 0x7b, 0xd2, 0x82,
0xf0, 0x1d, 0x8c, 0xf6, 0x13, 0xd4, 0xa5, 0xac, 0x12, 0xfc, 0xc5, 0x62, 0x1c, 0xfc, 0x17, 0xb3,
0x57, 0x27, 0x8d, 0xe3, 0x29, 0xde, 0xf8, 0xd4, 0xeb, 0xf8, 0x94, 0x0e, 0x7a, 0xa9, 0x72, 0x35,
0x8d, 0x8c, 0xb0, 0x9e, 0x74, 0x28, 0x7c, 0x02, 0x3e, 0x7d, 0x0f, 0x9d, 0xce, 0xfe, 0xef, 0xbe,
0xa5, 0x8b, 0xbe, 0xf9, 0x97, 0xde, 0xfb, 0x19, 0x00, 0x00, 0xff, 0xff, 0x93, 0x68, 0x0f, 0xcf,
0xb7, 0x05, 0x00, 0x00,
}

View File

@ -28,6 +28,7 @@ message DashboardCell {
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
string ID = 8; // id is the unique id of the dashboard. MIGRATED FIELD added in 1.2.0-beta6
}
message Server {

View File

@ -14,7 +14,7 @@ func TestServerStore(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := c.Open(); err != nil {
if err := c.Open(context.TODO()); err != nil {
t.Fatal(err)
}
defer c.Close()

View File

@ -15,7 +15,7 @@ func TestSourceStore(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := c.Open(); err != nil {
if err := c.Open(context.TODO()); err != nil {
t.Fatal(err)
}
defer c.Close()

View File

@ -33,7 +33,7 @@ func TestUsersStore_Get(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := client.Open(); err != nil {
if err := client.Open(context.TODO()); err != nil {
t.Fatal(err)
}
defer client.Close()
@ -79,7 +79,7 @@ func TestUsersStore_Add(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := client.Open(); err != nil {
if err := client.Open(context.TODO()); err != nil {
t.Fatal(err)
}
defer client.Close()
@ -137,7 +137,7 @@ func TestUsersStore_Delete(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := client.Open(); err != nil {
if err := client.Open(context.TODO()); err != nil {
t.Fatal(err)
}
defer client.Close()
@ -189,7 +189,7 @@ func TestUsersStore_Update(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := client.Open(); err != nil {
if err := client.Open(context.TODO()); err != nil {
t.Fatal(err)
}
defer client.Close()
@ -234,7 +234,7 @@ func TestUsersStore_All(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := client.Open(); err != nil {
if err := client.Open(context.TODO()); err != nil {
t.Fatal(err)
}
defer client.Close()

View File

@ -121,6 +121,15 @@ type Query struct {
Range *Range `json:"range,omitempty"` // Range is the default Y-Axis range for the data
}
// DashboardQuery includes state for the query builder. This is a transition
// struct while we move to the full InfluxQL AST
type DashboardQuery struct {
Command string `json:"query"` // Command is the query itself
Label string `json:"label,omitempty"` // Label is the Y-Axis label for the data
Range *Range `json:"range,omitempty"` // Range is the default Y-Axis range for the data
QueryConfig QueryConfig `json:"queryConfig,omitempty"` // QueryConfig represents the query state that is understood by the data explorer
}
// Response is the result of a query against a TimeSeries
type Response interface {
MarshalJSON() ([]byte, error)
@ -328,13 +337,14 @@ 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"`
Queries []Query `json:"queries"`
Type string `json:"type"`
ID string `json:"-"`
X int32 `json:"x"`
Y int32 `json:"y"`
W int32 `json:"w"`
H int32 `json:"h"`
Name string `json:"name"`
Queries []DashboardQuery `json:"queries"`
Type string `json:"type"`
}
// DashboardsStore is the storage and retrieval of dashboards

View File

@ -1,6 +1,7 @@
package main
import (
"context"
"log"
"os"
@ -41,7 +42,8 @@ func main() {
os.Exit(0)
}
if err := srv.Serve(); err != nil {
ctx := context.Background()
if err := srv.Serve(ctx); err != nil {
log.Fatalln(err)
}
}

411
influx/query.go Normal file
View File

@ -0,0 +1,411 @@
package influx
import (
"strings"
"github.com/influxdata/chronograf"
"github.com/influxdata/influxdb/influxql"
)
// Convert changes an InfluxQL query to a QueryConfig
func Convert(influxQL string) (chronograf.QueryConfig, error) {
query, err := influxql.ParseQuery(influxQL)
if err != nil {
return chronograf.QueryConfig{}, err
}
raw := chronograf.QueryConfig{
RawText: query.String(),
Fields: []chronograf.Field{},
GroupBy: chronograf.GroupBy{
Tags: []string{},
},
Tags: make(map[string][]string, 0),
}
qc := chronograf.QueryConfig{
GroupBy: chronograf.GroupBy{
Tags: []string{},
},
Tags: make(map[string][]string, 0),
}
if len(query.Statements) != 1 {
return raw, nil
}
stmt, ok := query.Statements[0].(*influxql.SelectStatement)
if !ok {
return raw, nil
}
// Query config doesn't support limits
if stmt.Limit != 0 || stmt.Offset != 0 || stmt.SLimit != 0 || stmt.SOffset != 0 {
return raw, nil
}
// Query config doesn't support sorting
if len(stmt.SortFields) > 0 {
return raw, nil
}
// Query config doesn't support fill
if stmt.Fill != influxql.NullFill {
return raw, nil
}
// Query config doesn't allow SELECT INTO
if stmt.Target != nil {
return raw, nil
}
// Query config only allows selecting from one source at a time.
if len(stmt.Sources) != 1 {
return raw, nil
}
src := stmt.Sources[0]
measurement, ok := src.(*influxql.Measurement)
if !ok {
return raw, nil
}
if measurement.Regex != nil {
return raw, nil
}
qc.Database = measurement.Database
qc.RetentionPolicy = measurement.RetentionPolicy
qc.Measurement = measurement.Name
for _, dim := range stmt.Dimensions {
switch v := dim.Expr.(type) {
default:
return raw, nil
case *influxql.Call:
if v.Name != "time" {
return raw, nil
}
// Make sure there is exactly one argument.
if len(v.Args) != 1 {
return raw, nil
}
// Ensure the argument is a duration.
lit, ok := v.Args[0].(*influxql.DurationLiteral)
if !ok {
return raw, nil
}
qc.GroupBy.Time = lit.String()
case *influxql.VarRef:
qc.GroupBy.Tags = append(qc.GroupBy.Tags, v.Val)
}
}
fields := map[string][]string{}
for _, fld := range stmt.Fields {
switch f := fld.Expr.(type) {
case *influxql.Call:
// only support certain query config functions
if _, ok := supportedFuncs[f.Name]; !ok {
return raw, nil
}
// Query configs only support single argument functions
if len(f.Args) != 1 {
return raw, nil
}
ref, ok := f.Args[0].(*influxql.VarRef)
// query config only support fields in the function
if !ok {
return raw, nil
}
// We only support field strings
if ref.Type != influxql.Unknown {
return raw, nil
}
if call, ok := fields[ref.Val]; !ok {
fields[ref.Val] = []string{f.Name}
} else {
fields[ref.Val] = append(call, f.Name)
}
case *influxql.VarRef:
if f.Type != influxql.Unknown {
return raw, nil
}
if _, ok := fields[f.Val]; !ok {
fields[f.Val] = []string{}
}
}
}
for fld, funcs := range fields {
qc.Fields = append(qc.Fields, chronograf.Field{
Field: fld,
Funcs: funcs,
})
}
if stmt.Condition == nil {
return qc, nil
}
reduced := influxql.Reduce(stmt.Condition, nil)
logic, ok := isTagLogic(reduced)
if !ok {
return raw, nil
}
ops := map[string]bool{}
for _, l := range logic {
values, ok := qc.Tags[l.Tag]
if !ok {
values = []string{}
}
ops[l.Op] = true
values = append(values, l.Value)
qc.Tags[l.Tag] = values
}
if len(logic) > 0 {
if len(ops) != 1 {
return raw, nil
}
if _, ok := ops["=="]; ok {
qc.AreTagsAccepted = true
}
}
return qc, nil
}
// tagFilter represents a single tag that is filtered by some condition
type tagFilter struct {
Op string
Tag string
Value string
}
func isTime(exp influxql.Expr) bool {
if p, ok := exp.(*influxql.ParenExpr); ok {
return isTime(p.Expr)
} else if ref, ok := exp.(*influxql.VarRef); ok && strings.ToLower(ref.Val) == "time" {
return true
}
return false
}
func isNow(exp influxql.Expr) bool {
if p, ok := exp.(*influxql.ParenExpr); ok {
return isNow(p.Expr)
} else if call, ok := exp.(*influxql.Call); ok && strings.ToLower(call.Name) == "now" && len(call.Args) == 0 {
return true
}
return false
}
func isDuration(exp influxql.Expr) bool {
switch e := exp.(type) {
case *influxql.ParenExpr:
return isDuration(e.Expr)
case *influxql.DurationLiteral, *influxql.NumberLiteral, *influxql.IntegerLiteral, *influxql.TimeLiteral:
return true
}
return false
}
func isPreviousTime(exp influxql.Expr) bool {
if p, ok := exp.(*influxql.ParenExpr); ok {
return isPreviousTime(p.Expr)
} else if bin, ok := exp.(*influxql.BinaryExpr); ok {
now := isNow(bin.LHS) || isNow(bin.RHS) // either side can be now
op := bin.Op == influxql.SUB
dur := isDuration(bin.LHS) || isDuration(bin.RHS) // either side can be a isDuration
return now && op && dur
} else if isNow(exp) { // just comparing to now
return true
}
return false
}
func isTimeRange(exp influxql.Expr) bool {
if p, ok := exp.(*influxql.ParenExpr); ok {
return isTimeRange(p.Expr)
} else if bin, ok := exp.(*influxql.BinaryExpr); ok {
tm := isTime(bin.LHS) || isTime(bin.RHS) // Either side could be time
op := false
switch bin.Op {
case influxql.LT, influxql.LTE, influxql.GT, influxql.GTE:
op = true
}
prev := isPreviousTime(bin.LHS) || isPreviousTime(bin.RHS)
return tm && op && prev
}
return false
}
func hasTimeRange(exp influxql.Expr) bool {
if p, ok := exp.(*influxql.ParenExpr); ok {
return hasTimeRange(p.Expr)
} else if isTimeRange(exp) {
return true
} else if bin, ok := exp.(*influxql.BinaryExpr); ok {
return isTimeRange(bin.LHS) || isTimeRange(bin.RHS)
}
return false
}
func isTagLogic(exp influxql.Expr) ([]tagFilter, bool) {
if p, ok := exp.(*influxql.ParenExpr); ok {
return isTagLogic(p.Expr)
}
if isTimeRange(exp) {
return nil, true
} else if tf, ok := isTagFilter(exp); ok {
return []tagFilter{tf}, true
}
bin, ok := exp.(*influxql.BinaryExpr)
if !ok {
return nil, false
}
lhs, lhsOK := isTagFilter(bin.LHS)
rhs, rhsOK := isTagFilter(bin.RHS)
if lhsOK && rhsOK && lhs.Tag == rhs.Tag && lhs.Op == rhs.Op && bin.Op == influxql.OR {
return []tagFilter{lhs, rhs}, true
}
if bin.Op != influxql.AND {
return nil, false
}
tm := isTimeRange(bin.LHS) || isTimeRange(bin.RHS)
tf := lhsOK || rhsOK
if tm && tf {
if lhsOK {
return []tagFilter{lhs}, true
}
return []tagFilter{rhs}, true
}
tlLHS, lhsOK := isTagLogic(bin.LHS)
tlRHS, rhsOK := isTagLogic(bin.RHS)
if lhsOK && rhsOK {
ops := map[string]bool{} // there must only be one kind of ops
for _, tf := range tlLHS {
ops[tf.Op] = true
}
for _, tf := range tlRHS {
ops[tf.Op] = true
}
if len(ops) > 1 {
return nil, false
}
return append(tlLHS, tlRHS...), true
}
return nil, false
}
func hasTagFilter(exp influxql.Expr) bool {
if _, ok := isTagFilter(exp); ok {
return true
} else if p, ok := exp.(*influxql.ParenExpr); ok {
return hasTagFilter(p.Expr)
} else if bin, ok := exp.(*influxql.BinaryExpr); ok {
or := bin.Op == influxql.OR
and := bin.Op == influxql.AND
op := or || and
return op && (hasTagFilter(bin.LHS) || hasTagFilter(bin.RHS))
}
return false
}
func singleTagFilter(exp influxql.Expr) (tagFilter, bool) {
if p, ok := exp.(*influxql.ParenExpr); ok {
return singleTagFilter(p.Expr)
} else if tf, ok := isTagFilter(exp); ok {
return tf, true
} else if bin, ok := exp.(*influxql.BinaryExpr); ok && bin.Op == influxql.OR {
lhs, lhsOK := singleTagFilter(bin.LHS)
rhs, rhsOK := singleTagFilter(bin.RHS)
if lhsOK && rhsOK && lhs.Op == rhs.Op && lhs.Tag == rhs.Tag {
return lhs, true
}
}
return tagFilter{}, false
}
func isVarRef(exp influxql.Expr) bool {
if p, ok := exp.(*influxql.ParenExpr); ok {
return isVarRef(p.Expr)
} else if _, ok := exp.(*influxql.VarRef); ok {
return true
}
return false
}
func isString(exp influxql.Expr) bool {
if p, ok := exp.(*influxql.ParenExpr); ok {
return isString(p.Expr)
} else if _, ok := exp.(*influxql.StringLiteral); ok {
return true
}
return false
}
func isTagFilter(exp influxql.Expr) (tagFilter, bool) {
switch expr := exp.(type) {
default:
return tagFilter{}, false
case *influxql.ParenExpr:
return isTagFilter(expr.Expr)
case *influxql.BinaryExpr:
var Op string
if expr.Op == influxql.EQ {
Op = "=="
} else if expr.Op == influxql.NEQ {
Op = "!="
} else {
return tagFilter{}, false
}
hasValue := isString(expr.LHS) || isString(expr.RHS)
hasTag := isVarRef(expr.LHS) || isVarRef(expr.RHS)
if !(hasValue && hasTag) {
return tagFilter{}, false
}
value := ""
tag := ""
// Either tag op value or value op tag
if isVarRef(expr.LHS) {
t, _ := expr.LHS.(*influxql.VarRef)
tag = t.Val
v, _ := expr.RHS.(*influxql.StringLiteral)
value = v.Val
} else {
t, _ := expr.RHS.(*influxql.VarRef)
tag = t.Val
v, _ := expr.LHS.(*influxql.StringLiteral)
value = v.Val
}
return tagFilter{
Op: Op,
Tag: tag,
Value: value,
}, true
}
}
var supportedFuncs = map[string]bool{
"mean": true,
"median": true,
"count": true,
"min": true,
"max": true,
"sum": true,
"first": true,
"last": true,
"spread": true,
"stddev": true,
}

View File

@ -8,6 +8,8 @@ import (
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/influx"
"github.com/influxdata/chronograf/uuid"
)
const (
@ -18,25 +20,50 @@ const (
)
type dashboardLinks struct {
Self string `json:"self"` // Self link mapping to this resource
Cells string `json:"cells"` // Cells link to the cells endpoint
}
type dashboardCellLinks struct {
Self string `json:"self"` // Self link mapping to this resource
}
type dashboardCellResponse struct {
chronograf.DashboardCell
Links dashboardCellLinks `json:"links"`
}
type dashboardResponse struct {
chronograf.Dashboard
Links dashboardLinks `json:"links"`
ID chronograf.DashboardID `json:"id"`
Cells []dashboardCellResponse `json:"cells"`
Name string `json:"name"`
Links dashboardLinks `json:"links"`
}
type getDashboardsResponse struct {
Dashboards []dashboardResponse `json:"dashboards"`
Dashboards []*dashboardResponse `json:"dashboards"`
}
func newDashboardResponse(d chronograf.Dashboard) dashboardResponse {
func newDashboardResponse(d chronograf.Dashboard) *dashboardResponse {
base := "/chronograf/v1/dashboards"
DashboardDefaults(&d)
return dashboardResponse{
Dashboard: d,
AddQueryConfigs(&d)
cells := make([]dashboardCellResponse, len(d.Cells))
for i, cell := range d.Cells {
cells[i] = dashboardCellResponse{
DashboardCell: cell,
Links: dashboardCellLinks{
Self: fmt.Sprintf("%s/%d/cells/%s", base, d.ID, cell.ID),
},
}
}
return &dashboardResponse{
ID: d.ID,
Name: d.Name,
Cells: cells,
Links: dashboardLinks{
Self: fmt.Sprintf("%s/%d", base, d.ID),
Self: fmt.Sprintf("%s/%d", base, d.ID),
Cells: fmt.Sprintf("%s/%d/cells", base, d.ID),
},
}
}
@ -51,7 +78,7 @@ func (s *Service) Dashboards(w http.ResponseWriter, r *http.Request) {
}
res := getDashboardsResponse{
Dashboards: []dashboardResponse{},
Dashboards: []*dashboardResponse{},
}
for _, dashboard := range dashboards {
@ -228,6 +255,15 @@ func ValidDashboardRequest(d *chronograf.Dashboard) error {
return nil
}
// ValidDashboardCellRequest verifies that the dashboard cells have a query
func ValidDashboardCellRequest(c *chronograf.DashboardCell) error {
if len(c.Queries) == 0 {
return fmt.Errorf("query required")
}
CorrectWidthHeight(c)
return nil
}
// DashboardDefaults updates the dashboard with the default values
// if none are specified
func DashboardDefaults(d *chronograf.Dashboard) {
@ -247,3 +283,224 @@ func CorrectWidthHeight(c *chronograf.DashboardCell) {
c.H = DefaultHeight
}
}
// AddQueryConfigs updates all the celsl in the dashboard to have query config
// objects corresponding to their influxql queries.
func AddQueryConfigs(d *chronograf.Dashboard) {
for i, c := range d.Cells {
AddQueryConfig(&c)
d.Cells[i] = c
}
}
// AddQueryConfig updates a cell by converting InfluxQL into queryconfigs
// If influxql cannot be represented by a full query config, then, the
// query config's raw text is set to the command.
func AddQueryConfig(c *chronograf.DashboardCell) {
for i, q := range c.Queries {
qc, err := influx.Convert(q.Command)
if err == nil {
q.QueryConfig = qc
c.Queries[i] = q
} else {
q.QueryConfig = chronograf.QueryConfig{
RawText: q.Command,
Fields: []chronograf.Field{},
GroupBy: chronograf.GroupBy{
Tags: []string{},
},
Tags: make(map[string][]string, 0),
}
c.Queries[i] = q
}
}
}
// DashboardCells returns all cells from a dashboard within the store
func (s *Service) DashboardCells(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()
e, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id))
if err != nil {
notFound(w, id, s.Logger)
return
}
boards := newDashboardResponse(e)
cells := boards.Cells
encodeJSON(w, http.StatusOK, cells, s.Logger)
}
// NewDashboardCell adds a cell to an existing dashboard
func (s *Service) NewDashboardCell(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()
dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id))
if err != nil {
notFound(w, id, s.Logger)
return
}
var cell chronograf.DashboardCell
if err := json.NewDecoder(r.Body).Decode(&cell); err != nil {
invalidJSON(w, s.Logger)
return
}
if err := ValidDashboardCellRequest(&cell); err != nil {
invalidData(w, err, s.Logger)
return
}
ids := uuid.V4{}
cid, err := ids.Generate()
if err != nil {
msg := fmt.Sprintf("Error creating cell ID of dashboard %d: %v", id, err)
Error(w, http.StatusInternalServerError, msg, s.Logger)
return
}
cell.ID = cid
dash.Cells = append(dash.Cells, cell)
if err := s.DashboardsStore.Update(ctx, dash); err != nil {
msg := fmt.Sprintf("Error updating dashboard ID %d: %v", id, err)
Error(w, http.StatusInternalServerError, msg, s.Logger)
return
}
boards := newDashboardResponse(dash)
for _, cell := range boards.Cells {
if cell.ID == cid {
encodeJSON(w, http.StatusOK, cell, s.Logger)
return
}
}
}
// DashboardCellID adds a cell to an existing dashboard
func (s *Service) DashboardCellID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), s.Logger)
return
}
dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id))
if err != nil {
notFound(w, id, s.Logger)
return
}
boards := newDashboardResponse(dash)
cid := httprouter.GetParamFromContext(ctx, "cid")
for _, cell := range boards.Cells {
if cell.ID == cid {
encodeJSON(w, http.StatusOK, cell, s.Logger)
return
}
}
notFound(w, id, s.Logger)
}
// RemoveDashboardCell adds a cell to an existing dashboard
func (s *Service) RemoveDashboardCell(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()
dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id))
if err != nil {
notFound(w, id, s.Logger)
return
}
cid := httprouter.GetParamFromContext(ctx, "cid")
cellid := -1
for i, cell := range dash.Cells {
if cell.ID == cid {
cellid = i
break
}
}
if cellid == -1 {
notFound(w, id, s.Logger)
return
}
dash.Cells = append(dash.Cells[:cellid], dash.Cells[cellid+1:]...)
if err := s.DashboardsStore.Update(ctx, dash); err != nil {
msg := fmt.Sprintf("Error updating dashboard ID %d: %v", id, err)
Error(w, http.StatusInternalServerError, msg, s.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ReplaceDashboardCell adds a cell to an existing dashboard
func (s *Service) ReplaceDashboardCell(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()
dash, err := s.DashboardsStore.Get(ctx, chronograf.DashboardID(id))
if err != nil {
notFound(w, id, s.Logger)
return
}
cid := httprouter.GetParamFromContext(ctx, "cid")
cellid := -1
for i, cell := range dash.Cells {
if cell.ID == cid {
cellid = i
break
}
}
if cellid == -1 {
notFound(w, id, s.Logger)
return
}
var cell chronograf.DashboardCell
if err := json.NewDecoder(r.Body).Decode(&cell); err != nil {
invalidJSON(w, s.Logger)
return
}
if err := ValidDashboardCellRequest(&cell); err != nil {
invalidData(w, err, s.Logger)
return
}
cell.ID = cid
dash.Cells[cellid] = cell
if err := s.DashboardsStore.Update(ctx, dash); err != nil {
msg := fmt.Sprintf("Error updating dashboard ID %d: %v", id, err)
Error(w, http.StatusInternalServerError, msg, s.Logger)
return
}
boards := newDashboardResponse(dash)
for _, cell := range boards.Cells {
if cell.ID == cid {
encodeJSON(w, http.StatusOK, cell, s.Logger)
return
}
}
}

View File

@ -147,7 +147,7 @@ func TestValidDashboardRequest(t *testing.T) {
{
W: 0,
H: 0,
Queries: []chronograf.Query{
Queries: []chronograf.DashboardQuery{
{
Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00",
},
@ -156,7 +156,7 @@ func TestValidDashboardRequest(t *testing.T) {
{
W: 2,
H: 2,
Queries: []chronograf.Query{
Queries: []chronograf.DashboardQuery{
{
Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00",
},
@ -169,7 +169,7 @@ func TestValidDashboardRequest(t *testing.T) {
{
W: 4,
H: 4,
Queries: []chronograf.Query{
Queries: []chronograf.DashboardQuery{
{
Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00",
},
@ -178,7 +178,7 @@ func TestValidDashboardRequest(t *testing.T) {
{
W: 2,
H: 2,
Queries: []chronograf.Query{
Queries: []chronograf.DashboardQuery{
{
Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00",
},
@ -194,7 +194,7 @@ func TestValidDashboardRequest(t *testing.T) {
{
W: 2,
H: 2,
Queries: []chronograf.Query{},
Queries: []chronograf.DashboardQuery{},
},
},
},
@ -203,7 +203,7 @@ func TestValidDashboardRequest(t *testing.T) {
{
W: 2,
H: 2,
Queries: []chronograf.Query{},
Queries: []chronograf.DashboardQuery{},
},
},
},
@ -236,64 +236,100 @@ func Test_newDashboardResponse(t *testing.T) {
tests := []struct {
name string
d chronograf.Dashboard
want dashboardResponse
want *dashboardResponse
}{
{
name: "Updates all cell widths/heights",
name: "creates a dashboard response",
d: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{
{
W: 0,
H: 0,
Queries: []chronograf.Query{
ID: "a",
W: 0,
H: 0,
Queries: []chronograf.DashboardQuery{
{
Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00",
Command: "SELECT donors from hill_valley_preservation_society where time > '1985-10-25 08:00:00'",
},
},
},
{
W: 0,
H: 0,
Queries: []chronograf.Query{
ID: "b",
W: 0,
H: 0,
Queries: []chronograf.DashboardQuery{
{
Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00",
Command: "SELECT winning_horses from grays_sports_alamanc where time > now() - 15m",
},
},
},
},
},
want: dashboardResponse{
Dashboard: chronograf.Dashboard{
Cells: []chronograf.DashboardCell{
{
W: 4,
H: 4,
Queries: []chronograf.Query{
want: &dashboardResponse{
Cells: []dashboardCellResponse{
dashboardCellResponse{
Links: dashboardCellLinks{
Self: "/chronograf/v1/dashboards/0/cells/a",
},
DashboardCell: chronograf.DashboardCell{
ID: "a",
W: 4,
H: 4,
Queries: []chronograf.DashboardQuery{
{
Command: "SELECT donors from hill_valley_preservation_society where time > 1985-10-25T08:00:00",
Command: "SELECT donors from hill_valley_preservation_society where time > '1985-10-25 08:00:00'",
QueryConfig: chronograf.QueryConfig{
RawText: "SELECT donors FROM hill_valley_preservation_society WHERE time > '1985-10-25 08:00:00'",
Fields: []chronograf.Field{},
GroupBy: chronograf.GroupBy{
Tags: []string{},
},
Tags: make(map[string][]string, 0),
AreTagsAccepted: false,
},
},
},
},
{
W: 4,
H: 4,
Queries: []chronograf.Query{
},
dashboardCellResponse{
Links: dashboardCellLinks{
Self: "/chronograf/v1/dashboards/0/cells/b",
},
DashboardCell: chronograf.DashboardCell{
ID: "b",
W: 4,
H: 4,
Queries: []chronograf.DashboardQuery{
{
Command: "SELECT winning_horses from grays_sports_alamanc where time > 1955-11-1T00:00:00",
Command: "SELECT winning_horses from grays_sports_alamanc where time > now() - 15m",
QueryConfig: chronograf.QueryConfig{
Measurement: "grays_sports_alamanc",
Fields: []chronograf.Field{
{
Field: "winning_horses",
Funcs: []string{},
},
},
GroupBy: chronograf.GroupBy{
Tags: []string{},
},
Tags: make(map[string][]string, 0),
AreTagsAccepted: false,
},
},
},
},
},
},
Links: dashboardLinks{
Self: "/chronograf/v1/dashboards/0",
Self: "/chronograf/v1/dashboards/0",
Cells: "/chronograf/v1/dashboards/0/cells",
},
},
},
}
for _, tt := range tests {
if got := newDashboardResponse(tt.d); !reflect.DeepEqual(got, tt.want) {
t.Errorf("%q. newDashboardResponse() = %v, want %v", tt.name, got, tt.want)
t.Errorf("%q. newDashboardResponse() = \n%+v\n\n, want\n\n%+v", tt.name, got, tt.want)
}
}
}

View File

@ -130,6 +130,13 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
router.DELETE("/chronograf/v1/dashboards/:id", service.RemoveDashboard)
router.PUT("/chronograf/v1/dashboards/:id", service.ReplaceDashboard)
router.PATCH("/chronograf/v1/dashboards/:id", service.UpdateDashboard)
// Dashboard Cells
router.GET("/chronograf/v1/dashboards/:id/cells", service.DashboardCells)
router.POST("/chronograf/v1/dashboards/:id/cells", service.NewDashboardCell)
router.GET("/chronograf/v1/dashboards/:id/cells/:cid", service.DashboardCellID)
router.DELETE("/chronograf/v1/dashboards/:id/cells/:cid", service.RemoveDashboardCell)
router.PUT("/chronograf/v1/dashboards/:id/cells/:cid", service.ReplaceDashboardCell)
var authRoutes AuthRoutes

View File

@ -1,6 +1,7 @@
package server
import (
"context"
"crypto/tls"
"log"
"math/rand"
@ -176,9 +177,9 @@ func (s *Server) NewListener() (net.Listener, error) {
}
// Serve starts and runs the chronograf server
func (s *Server) Serve() error {
func (s *Server) Serve(ctx context.Context) error {
logger := clog.New(clog.ParseLevel(s.LogLevel))
service := openService(s.BoltPath, s.CannedPath, logger, s.useAuth())
service := openService(ctx, s.BoltPath, s.CannedPath, logger, s.useAuth())
basepath = s.Basepath
providerFuncs := []func(func(oauth2.Provider, oauth2.Mux)){}
@ -254,10 +255,10 @@ func (s *Server) Serve() error {
return nil
}
func openService(boltPath, cannedPath string, logger chronograf.Logger, useAuth bool) Service {
func openService(ctx context.Context, boltPath, cannedPath string, logger chronograf.Logger, useAuth bool) Service {
db := bolt.NewClient()
db.Path = boltPath
if err := db.Open(); err != nil {
if err := db.Open(ctx); err != nil {
logger.
WithField("component", "boltstore").
Error("Unable to open boltdb; is there a chronograf already running? ", err)

View File

@ -3030,6 +3030,10 @@
"type": "integer",
"format": "int32"
},
"name": {
"description": "Cell name",
"type": "string"
},
"queries": {
"description": "Time-series data queries for Cell.",
"type": "array",
@ -3039,8 +3043,7 @@
},
"type": {
"description": "Cell visualization type",
"type": "string",
"format": "uuid4"
"type": "string"
}
},
"example": {
@ -3061,7 +3064,7 @@
"LayoutQuery": {
"type": "object",
"required": [
"query"
"query",
],
"properties": {
"label": {
@ -3117,6 +3120,86 @@
]
}
},
"DashboardQuery": {
"type": "object",
"required": [
"query"
],
"properties": {
"label": {
"description": "Optional Y-axis user-facing label for this query",
"type": "string"
},
"range": {
"description": "Optional default range of the Y-axis",
"type": "object",
"required": [
"upper",
"lower"
],
"properties": {
"upper": {
"description": "Upper bound of the display range of the Y-axis",
"type": "integer",
"format": "int64"
},
"lower": {
"description": "Lower bound of the display range of the Y-axis",
"type": "integer",
"format": "int64"
}
}
},
"query": {
"type": "string"
},
"queryConfig": {
"$ref": "#/definitions/QueryConfig"
}
},
"example": {
"id": 4,
"cells": [
{
"x": 0,
"y": 0,
"w": 4,
"h": 4,
"name": "",
"queries": [
{
"query": "SELECT mean(\"usage_user\") AS \"usage_user\" FROM \"cpu\"",
"label": "%",
"queryConfig": {
"database": "",
"measurement": "cpu",
"retentionPolicy": "",
"fields": [
{
"field": "usage_user",
"funcs": [
"mean"
]
}
],
"tags": {},
"groupBy": {
"time": "",
"tags": []
},
"areTagsAccepted": false
}
}
],
"type": "line"
}
],
"name": "dashboard name",
"links": {
"self": "/chronograf/v1/dashboards/4"
}
}
},
"Dashboards": {
"description": "a list of dashboards",
"type": "object",
@ -3171,11 +3254,15 @@
"minimum": 1,
"default": 4
},
"name": {
"description": "Name of Cell in the Dashboard",
"type": "string"
},
"queries": {
"description": "Time-series data queries for Cell.",
"type": "array",
"items": {
"$ref": "#/definitions/LayoutQuery"
"$ref": "#/definitions/DashboardQuery"
}
},
"type": {
@ -3189,6 +3276,16 @@
"line-stepplot"
],
"default": "line"
},
"links": {
"type": "object",
"properties": {
"self": {
"type": "string",
"description": "Self link mapping to this resource",
"format": "url"
}
}
}
}
}
@ -3220,6 +3317,7 @@
"queries": [
{
"query": "SELECT mean(\"usage_user\") AS \"usage_user\" FROM \"cpu\"",
"db": "telegraf",
"label": "%"
}
],
@ -3234,6 +3332,7 @@
"queries": [
{
"query": "SELECT mean(\"usage_system\") AS \"usage_system\" FROM \"cpu\"",
"db": "telegraf",
"label": "%"
}
],

View File

@ -9,6 +9,7 @@ import {
updateDashboardCells,
editCell,
renameCell,
syncDashboardCell,
} from 'src/dashboards/actions'
const noopAction = () => {
@ -20,6 +21,16 @@ const timeRange = timeRanges[1];
const d1 = {id: 1, cells: [], name: "d1"}
const d2 = {id: 2, cells: [], name: "d2"}
const dashboards = [d1, d2]
const c1 = {
x: 0,
y: 0,
w: 4,
h: 4,
id: 1,
isEditing: false,
name: "Gigawatts",
}
const cells = [c1]
describe('DataExplorer.Reducers.UI', () => {
it('can load the dashboards', () => {
@ -74,19 +85,7 @@ describe('DataExplorer.Reducers.UI', () => {
})
it('can edit cell', () => {
const dash = {
id: 1,
cells: [{
x: 0,
y: 0,
w: 4,
h: 4,
id: 1,
isEditing: false,
name: "Gigawatts",
}],
}
const dash = {...d1, cells}
state = {
dashboard: dash,
dashboards: [dash],
@ -97,20 +96,27 @@ describe('DataExplorer.Reducers.UI', () => {
expect(actual.dashboard.cells[0].isEditing).to.equal(true)
})
it('can rename cells', () => {
const dash = {
id: 1,
cells: [{
x: 0,
y: 0,
w: 4,
h: 4,
id: 1,
isEditing: true,
name: "Gigawatts",
}],
it('can sync a cell', () => {
const newCellName = 'watts is kinda cool'
const newCell = {
x: c1.x,
y: c1.y,
name: newCellName
}
const dash = {...d1, cells: [c1]}
state = {
dashboard: dash,
dashboards: [dash],
}
const actual = reducer(state, syncDashboardCell(newCell))
expect(actual.dashboards[0].cells[0].name).to.equal(newCellName)
expect(actual.dashboard.cells[0].name).to.equal(newCellName)
})
it('can rename cells', () => {
const c2 = {...c1, isEditing: true}
const dash = {...d1, cells: [c2]}
state = {
dashboard: dash,
dashboards: [dash],

View File

@ -1,7 +1,7 @@
import React, {PropTypes} from 'react'
const ConfirmButtons = ({onConfirm, item, onCancel}) => (
<div>
<div className="confirm-buttons">
<button
className="btn btn-xs btn-info"
onClick={() => onCancel(item)}
@ -24,7 +24,7 @@ const {
ConfirmButtons.propTypes = {
onConfirm: func.isRequired,
item: shape({}).isRequired,
item: shape({}),
onCancel: func.isRequired,
}

View File

@ -1,6 +1,7 @@
import {
getDashboards as getDashboardsAJAX,
updateDashboard as updateDashboardAJAX,
updateDashboardCell as updateDashboardCellAJAX,
} from 'src/dashboards/apis'
export const loadDashboards = (dashboards, dashboardID) => ({
@ -46,6 +47,13 @@ export const updateDashboardCells = (cells) => ({
},
})
export const syncDashboardCell = (cell) => ({
type: 'SYNC_DASHBOARD_CELL',
payload: {
cell,
},
})
export const editCell = (x, y, isEditing) => ({
type: 'EDIT_CELL',
// x and y coords are used as a alternative to cell ids, which are not
@ -82,3 +90,10 @@ export const putDashboard = () => (dispatch, getState) => {
dispatch(updateDashboard(data))
})
}
export const updateDashboardCell = (cell) => (dispatch) => {
return updateDashboardCellAJAX(cell)
.then(({data}) => {
dispatch(syncDashboardCell(data))
})
}

View File

@ -15,6 +15,14 @@ export function updateDashboard(dashboard) {
});
}
export function updateDashboardCell(cell) {
return AJAX({
method: 'PUT',
url: cell.links.self,
data: cell,
})
}
export const createDashboard = async (dashboard) => {
try {
return await AJAX({

View File

@ -0,0 +1,152 @@
import React, {Component, PropTypes} from 'react'
import _ from 'lodash'
import uuid from 'node-uuid'
import ResizeContainer, {ResizeBottom} from 'src/shared/components/ResizeContainer'
import QueryBuilder from 'src/data_explorer/components/QueryBuilder'
import Visualization from 'src/data_explorer/components/Visualization'
import OverlayControls from 'src/dashboards/components/OverlayControls'
import * as queryModifiers from 'src/utils/queryTransitions'
import {buildSelectStatement} from 'src/data_explorer/utils/influxql/select'
import defaultQueryConfig from 'src/utils/defaultQueryConfig'
class CellEditorOverlay extends Component {
constructor(props) {
super(props)
this.queryStateReducer = ::this.queryStateReducer
this.handleAddQuery = ::this.handleAddQuery
this.handleDeleteQuery = ::this.handleDeleteQuery
this.handleSaveCell = ::this.handleSaveCell
this.handleSelectGraphType = ::this.handleSelectGraphType
this.handleSetActiveQueryIndex = ::this.handleSetActiveQueryIndex
const {cell: {name, type, queries}} = props
const queriesWorkingDraft = _.cloneDeep(queries.map(({queryConfig}) => queryConfig))
this.state = {
cellWorkingName: name,
cellWorkingType: type,
queriesWorkingDraft,
activeQueryIndex: 0,
}
}
queryStateReducer(queryModifier) {
return (queryID, payload) => {
const {queriesWorkingDraft} = this.state
const query = queriesWorkingDraft.find((q) => q.id === queryID)
const nextQuery = queryModifier(query, payload)
const nextQueries = queriesWorkingDraft.map((q) => q.id === query.id ? nextQuery : q)
this.setState({queriesWorkingDraft: nextQueries})
}
}
handleAddQuery(options) {
const newQuery = Object.assign({}, defaultQueryConfig(uuid.v4()), options)
const nextQueries = this.state.queriesWorkingDraft.concat(newQuery)
this.setState({queriesWorkingDraft: nextQueries})
}
handleDeleteQuery(index) {
const nextQueries = this.state.queriesWorkingDraft.filter((__, i) => i !== index)
this.setState({queriesWorkingDraft: nextQueries})
}
handleSaveCell() {
const {queriesWorkingDraft, cellWorkingType, cellWorkingName} = this.state
const {cell} = this.props
const newCell = _.cloneDeep(cell)
newCell.name = cellWorkingName
newCell.type = cellWorkingType
newCell.queries = queriesWorkingDraft.map((q) => {
const query = q.rawText || buildSelectStatement(q)
const label = `${q.measurement}.${q.fields[0].field}`
return {
queryConfig: q,
query,
label,
}
})
this.props.onSave(newCell)
}
handleSelectGraphType(graphType) {
this.setState({cellWorkingType: graphType})
}
handleSetActiveQueryIndex(activeQueryIndex) {
this.setState({activeQueryIndex})
}
render() {
const {onCancel, autoRefresh, timeRange} = this.props
const {activeQueryIndex, cellWorkingType, queriesWorkingDraft} = this.state
const queryActions = {
addQuery: this.handleAddQuery,
..._.mapValues(queryModifiers, (qm) => this.queryStateReducer(qm)),
}
return (
<div className="data-explorer overlay-technology">
<ResizeContainer>
<Visualization
autoRefresh={autoRefresh}
timeRange={timeRange}
queryConfigs={queriesWorkingDraft}
activeQueryIndex={0}
cellType={cellWorkingType}
/>
<ResizeBottom>
<OverlayControls
selectedGraphType={cellWorkingType}
onSelectGraphType={this.handleSelectGraphType}
onCancel={onCancel}
onSave={this.handleSaveCell}
/>
<QueryBuilder
queries={queriesWorkingDraft}
actions={queryActions}
autoRefresh={autoRefresh}
timeRange={timeRange}
setActiveQueryIndex={this.handleSetActiveQueryIndex}
onDeleteQuery={this.handleDeleteQuery}
activeQueryIndex={activeQueryIndex}
/>
</ResizeBottom>
</ResizeContainer>
</div>
)
}
}
const {
func,
number,
shape,
string,
} = PropTypes
CellEditorOverlay.propTypes = {
onCancel: func.isRequired,
onSave: func.isRequired,
cell: shape({}).isRequired,
timeRange: shape({
upper: string,
lower: string,
}).isRequired,
autoRefresh: number.isRequired,
}
export default CellEditorOverlay

View File

@ -12,6 +12,7 @@ const Dashboard = ({
onEditCell,
onRenameCell,
onUpdateCell,
onSummonOverlayTechnologies,
source,
autoRefresh,
timeRange,
@ -24,20 +25,26 @@ const Dashboard = ({
<div className={classnames({'page-contents': true, 'presentation-mode': inPresentationMode})}>
<div className={classnames('container-fluid full-width dashboard', {'dashboard-edit': isEditMode})}>
{isEditMode ? <Visualizations/> : null}
{Dashboard.renderDashboard(dashboard, autoRefresh, timeRange, source, onPositionChange, onEditCell, onRenameCell, onUpdateCell)}
{Dashboard.renderDashboard(dashboard, autoRefresh, timeRange, source, onPositionChange, onEditCell, onRenameCell, onUpdateCell, onSummonOverlayTechnologies)}
</div>
</div>
)
}
Dashboard.renderDashboard = (dashboard, autoRefresh, timeRange, source, onPositionChange, onEditCell, onRenameCell, onUpdateCell) => {
Dashboard.renderDashboard = (dashboard, autoRefresh, timeRange, source, onPositionChange, onEditCell, onRenameCell, onUpdateCell, onSummonOverlayTechnologies) => {
const cells = dashboard.cells.map((cell, i) => {
i = `${i}`
const dashboardCell = {...cell, i}
dashboardCell.queries.forEach((q) => {
q.text = q.query;
q.database = q.db;
});
dashboardCell.queries = dashboardCell.queries.map(({label, query, queryConfig, db}) =>
({
label,
query,
queryConfig,
db,
database: db,
text: query,
})
)
return dashboardCell;
})
@ -51,6 +58,7 @@ Dashboard.renderDashboard = (dashboard, autoRefresh, timeRange, source, onPositi
onEditCell={onEditCell}
onRenameCell={onRenameCell}
onUpdateCell={onUpdateCell}
onSummonOverlayTechnologies={onSummonOverlayTechnologies}
/>
)
}
@ -71,6 +79,7 @@ Dashboard.propTypes = {
onEditCell: func,
onRenameCell: func,
onUpdateCell: func,
onSummonOverlayTechnologies: func,
source: shape({
links: shape({
proxy: string,

View File

@ -52,7 +52,7 @@ const DashboardHeader = ({
<ReactTooltip id="graph-tips-tooltip" effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
<SourceIndicator sourceName={source.name} />
<AutoRefreshDropdown onChoose={handleChooseAutoRefresh} selected={autoRefresh} iconName="refresh" />
<TimeRangeDropdown onChooseTimeRange={handleChooseTimeRange} selected={timeRange.inputValue} />
<TimeRangeDropdown onChooseTimeRange={handleChooseTimeRange} selected={timeRange} />
<div className="btn btn-info btn-sm" onClick={handleClickPresentationButton}>
<span className="icon expand-a" style={{margin: 0}}></span>
</div>

View File

@ -0,0 +1,44 @@
import React, {PropTypes} from 'react'
import classnames from 'classnames'
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
import graphTypes from 'hson!shared/data/graphTypes.hson'
const OverlayControls = (props) => {
const {onCancel, onSave, selectedGraphType, onSelectGraphType} = props
return (
<div className="overlay-controls">
<h3 className="overlay--graph-name">Graph Editor</h3>
<div className="overlay-controls--right">
<p>Visualization Type:</p>
<ul className="toggle toggle-sm">
{graphTypes.map(graphType =>
<li
key={graphType.type}
className={classnames('toggle-btn', {active: graphType.type === selectedGraphType})}
onClick={() => onSelectGraphType(graphType.type)}
>
{graphType.menuOption}
</li>
)}
</ul>
<ConfirmButtons onCancel={onCancel} onConfirm={onSave} />
</div>
</div>
)
}
const {
func,
string,
} = PropTypes
OverlayControls.propTypes = {
onCancel: func.isRequired,
onSave: func.isRequired,
selectedGraphType: string.isRequired,
onSelectGraphType: func.isRequired,
}
export default OverlayControls

View File

@ -7,6 +7,7 @@ export const EMPTY_DASHBOARD = {
y: 0,
queries: [],
name: 'Loading...',
type: 'single-stat',
},
],
}

View File

@ -3,13 +3,14 @@ import {Link} from 'react-router'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import CellEditorOverlay from 'src/dashboards/components/CellEditorOverlay'
import Header from 'src/dashboards/components/DashboardHeader'
import EditHeader from 'src/dashboards/components/DashboardHeaderEdit'
import Dashboard from 'src/dashboards/components/Dashboard'
import timeRanges from 'hson!../../shared/data/timeRanges.hson'
import * as dashboardActionCreators from 'src/dashboards/actions'
import {setAutoRefresh} from 'shared/actions/app'
import {presentationButtonDispatcher} from 'shared/dispatchers'
const {
@ -53,6 +54,7 @@ const DashboardPage = React.createClass({
id: number.isRequired,
cells: arrayOf(shape({})).isRequired,
}).isRequired,
handleChooseAutoRefresh: func.isRequired,
autoRefresh: number.isRequired,
timeRange: shape({}).isRequired,
inPresentationMode: bool.isRequired,
@ -60,6 +62,25 @@ const DashboardPage = React.createClass({
handleClickPresentationButton: func,
},
childContextTypes: {
source: shape({
links: shape({
proxy: string.isRequired,
self: string.isRequired,
}).isRequired,
}).isRequired,
},
getChildContext() {
return {source: this.props.source};
},
getInitialState() {
return {
selectedCell: null,
}
},
componentDidMount() {
const {
params: {dashboardID},
@ -85,9 +106,21 @@ const DashboardPage = React.createClass({
setEditMode(nextPathname.includes('/edit'))
},
handleDismissOverlay() {
this.setState({selectedCell: null})
},
handleSaveEditedCell(newCell) {
this.props.dashboardActions.updateDashboardCell(newCell)
.then(this.handleDismissOverlay)
},
handleSummonOverlayTechnologies(cell) {
this.setState({selectedCell: cell})
},
handleChooseTimeRange({lower}) {
const timeRange = timeRanges.find((range) => range.queryValue === lower);
this.props.dashboardActions.setTimeRange(timeRange)
this.props.dashboardActions.setTimeRange({lower, upper: null})
},
handleUpdatePosition(cells) {
@ -124,17 +157,34 @@ const DashboardPage = React.createClass({
isEditMode,
handleClickPresentationButton,
source,
handleChooseAutoRefresh,
autoRefresh,
timeRange,
} = this.props
const {
selectedCell,
} = this.state
return (
<div className="page">
{
selectedCell && selectedCell.queries.length ?
<CellEditorOverlay
cell={selectedCell}
autoRefresh={autoRefresh}
timeRange={timeRange}
onCancel={this.handleDismissOverlay}
onSave={this.handleSaveEditedCell}
/> :
null
}
{
isEditMode ?
<EditHeader dashboard={dashboard} onSave={() => {}} /> :
<Header
buttonText={dashboard ? dashboard.name : ''}
handleChooseAutoRefresh={handleChooseAutoRefresh}
autoRefresh={autoRefresh}
timeRange={timeRange}
handleChooseTimeRange={this.handleChooseTimeRange}
@ -166,6 +216,7 @@ const DashboardPage = React.createClass({
onEditCell={this.handleEditCell}
onRenameCell={this.handleChangeCellName}
onUpdateCell={this.handleUpdateCell}
onSummonOverlayTechnologies={this.handleSummonOverlayTechnologies}
/>
</div>
);
@ -197,6 +248,7 @@ const mapStateToProps = (state) => {
}
const mapDispatchToProps = (dispatch) => ({
handleChooseAutoRefresh: bindActionCreators(setAutoRefresh, dispatch),
handleClickPresentationButton: presentationButtonDispatcher(dispatch),
dashboardActions: bindActionCreators(dashboardActionCreators, dispatch),
})

View File

@ -1,11 +1,13 @@
import _ from 'lodash';
import _ from 'lodash'
import {EMPTY_DASHBOARD} from 'src/dashboards/constants'
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
const {lower, upper} = timeRanges[1]
const initialState = {
dashboards: [],
dashboard: EMPTY_DASHBOARD,
timeRange: timeRanges[1],
timeRange: {lower, upper},
isEditMode: false,
};
@ -92,6 +94,23 @@ export default function ui(state = initialState, action) {
return {...state, ...newState}
}
case 'SYNC_DASHBOARD_CELL': {
const {cell} = action.payload
const {dashboard} = state
const newDashboard = {
...dashboard,
cells: dashboard.cells.map((c) => c.x === cell.x && c.y === cell.y ? cell : c),
}
const newState = {
dashboard: newDashboard,
dashboards: state.dashboards.map((d) => d.id === dashboard.id ? newDashboard : d),
}
return {...state, ...newState}
}
case 'RENAME_CELL': {
const {x, y, name} = action.payload
const {dashboard} = state

View File

@ -1,4 +1,4 @@
import uuid from 'node-uuid';
import uuid from 'node-uuid'
export function addQuery(options = {}) {
return {

View File

@ -1,15 +1,14 @@
import React, {PropTypes} from 'react';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import QueryEditor from './QueryEditor';
import QueryTabItem from './QueryTabItem';
import SimpleDropdown from 'src/shared/components/SimpleDropdown';
import * as viewActions from '../actions/view';
const {
arrayOf,
func,
node,
number,
shape,
string,
} = PropTypes;
@ -27,7 +26,6 @@ const QueryBuilder = React.createClass({
chooseTag: func.isRequired,
groupByTag: func.isRequired,
addQuery: func.isRequired,
deleteQuery: func.isRequired,
toggleField: func.isRequired,
groupByTime: func.isRequired,
toggleTagAcceptance: func.isRequired,
@ -35,32 +33,34 @@ const QueryBuilder = React.createClass({
}).isRequired,
height: string,
top: string,
setActiveQuery: func.isRequired,
activeQueryID: string,
setActiveQueryIndex: func.isRequired,
onDeleteQuery: func.isRequired,
activeQueryIndex: number,
children: node,
},
handleSetActiveQuery(query) {
this.props.setActiveQuery(query.id);
handleSetActiveQueryIndex(index) {
this.props.setActiveQueryIndex(index);
},
handleAddQuery() {
this.props.actions.addQuery();
const newIndex = this.props.queries.length
this.props.actions.addQuery()
this.handleSetActiveQueryIndex(newIndex)
},
handleAddRawQuery() {
this.props.actions.addQuery({rawText: `SELECT "fields" from "db"."rp"."measurement"`});
},
handleDeleteQuery(query) {
this.props.actions.deleteQuery(query.id);
const newIndex = this.props.queries.length
this.props.actions.addQuery({rawText: `SELECT "fields" from "db"."rp"."measurement"`})
this.handleSetActiveQueryIndex(newIndex)
},
getActiveQuery() {
const {queries, activeQueryID} = this.props;
const activeQuery = queries.find((query) => query.id === activeQueryID);
const defaultQuery = queries[0];
const {queries, activeQueryIndex} = this.props
const activeQuery = queries[activeQueryIndex]
const defaultQuery = queries[0]
return activeQuery || defaultQuery;
return activeQuery || defaultQuery
},
render() {
@ -80,7 +80,7 @@ const QueryBuilder = React.createClass({
if (!query) {
return (
<div className="qeditor--empty">
<h5>This Graph has no Queries</h5>
<h5 className="no-user-select">This Graph has no Queries</h5>
<br/>
<div className="btn btn-primary" role="button" onClick={this.handleAddQuery}>Add a Query</div>
</div>
@ -90,7 +90,7 @@ const QueryBuilder = React.createClass({
return (
<QueryEditor
timeRange={timeRange}
query={this.getActiveQuery()}
query={query}
actions={actions}
onAddQuery={this.handleAddQuery}
/>
@ -98,7 +98,7 @@ const QueryBuilder = React.createClass({
},
renderQueryTabList() {
const {queries} = this.props;
const {queries, activeQueryIndex, onDeleteQuery} = this.props;
return (
<div className="query-builder--tabs">
<div className="query-builder--tabs-heading">
@ -114,15 +114,17 @@ const QueryBuilder = React.createClass({
}
return (
<QueryTabItem
isActive={this.getActiveQuery().id === q.id}
key={q.id + i}
isActive={i === activeQueryIndex}
key={i}
queryIndex={i}
query={q}
onSelect={this.handleSetActiveQuery}
onDelete={this.handleDeleteQuery}
onSelect={this.handleSetActiveQueryIndex}
onDelete={onDeleteQuery}
queryTabText={queryTabText}
/>
);
})}
{this.props.children}
</div>
);
},
@ -147,14 +149,4 @@ const QueryBuilder = React.createClass({
},
});
function mapStateToProps() {
return {};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(viewActions, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(QueryBuilder);
export default QueryBuilder

View File

@ -15,7 +15,7 @@ const {
const QueryEditor = React.createClass({
propTypes: {
query: shape({
id: string.isRequired,
id: string,
}).isRequired,
timeRange: shape({
upper: string,

View File

@ -10,15 +10,16 @@ const QueryTabItem = React.createClass({
onSelect: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
queryTabText: PropTypes.string,
queryIndex: PropTypes.number,
},
handleSelect() {
this.props.onSelect(this.props.query);
this.props.onSelect(this.props.queryIndex)
},
handleDelete(e) {
e.stopPropagation();
this.props.onDelete(this.props.query);
this.props.onDelete(this.props.queryIndex)
},
render() {

View File

@ -3,8 +3,11 @@ import selectStatement from '../utils/influxql/select';
import classNames from 'classnames';
import AutoRefresh from 'shared/components/AutoRefresh';
import LineGraph from 'shared/components/LineGraph';
import SingleStat from 'shared/components/SingleStat';
import MultiTable from './MultiTable';
const RefreshingLineGraph = AutoRefresh(LineGraph);
const RefreshingSingleStat = AutoRefresh(SingleStat);
const {
arrayOf,
@ -15,6 +18,7 @@ const {
const Visualization = React.createClass({
propTypes: {
cellType: string,
autoRefresh: number.isRequired,
timeRange: shape({
upper: string,
@ -45,8 +49,32 @@ const Visualization = React.createClass({
this.setState({isGraphInView: !this.state.isGraphInView});
},
renderGraph(queries) {
const {cellType, autoRefresh, activeQueryIndex} = this.props
const isInDataExplorer = true;
if (cellType === 'single-stat') {
return <RefreshingSingleStat queries={[queries[0]]} autoRefresh={autoRefresh} />
}
const displayOptions = {
stepPlot: cellType === 'line-stepplot',
stackedGraph: cellType === 'line-stacked',
}
return (
<RefreshingLineGraph
queries={queries}
autoRefresh={autoRefresh}
activeQueryIndex={activeQueryIndex}
isInDataExplorer={isInDataExplorer}
showSingleStat={cellType === "line-plus-single-stat"}
displayOptions={displayOptions}
/>
)
},
render() {
const {queryConfigs, autoRefresh, timeRange, activeQueryIndex, height, heightPixels} = this.props;
const {queryConfigs, timeRange, height, heightPixels} = this.props;
const {source} = this.context;
const proxyLink = source.links.proxy;
@ -58,7 +86,6 @@ const Visualization = React.createClass({
const queries = statements.filter((s) => s.text !== null).map((s) => {
return {host: [proxyLink], text: s.text, id: s.id};
});
const isInDataExplorer = true;
return (
<div className={classNames("graph", {active: true})} style={{height}}>
@ -74,14 +101,9 @@ const Visualization = React.createClass({
</div>
</div>
<div className={classNames({"graph-container": isGraphInView, "table-container": !isGraphInView})}>
{isGraphInView ? (
<RefreshingLineGraph
queries={queries}
autoRefresh={autoRefresh}
activeQueryIndex={activeQueryIndex}
isInDataExplorer={isInDataExplorer}
/>
) : <MultiTable queries={queries} height={heightPixels} />}
{isGraphInView ?
this.renderGraph(queries) :
<MultiTable queries={queries} height={heightPixels} />}
</div>
</div>
);

View File

@ -1,13 +1,16 @@
import React, {PropTypes} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import _ from 'lodash'
import QueryBuilder from '../components/QueryBuilder';
import Visualization from '../components/Visualization';
import Header from '../containers/Header';
import ResizeContainer from 'src/shared/components/ResizeContainer';
import ResizeContainer, {ResizeBottom} from 'src/shared/components/ResizeContainer'
import {setAutoRefresh} from 'shared/actions/app'
import {setTimeRange as setTimeRangeAction} from '../actions/view';
import * as viewActions from 'src/data_explorer/actions/view'
const {
arrayOf,
@ -25,7 +28,8 @@ const DataExplorer = React.createClass({
self: string.isRequired,
}).isRequired,
}).isRequired,
queryConfigs: PropTypes.shape({}),
queryConfigs: arrayOf(shape({})).isRequired,
queryConfigActions: shape({}).isRequired,
autoRefresh: number.isRequired,
handleChooseAutoRefresh: func.isRequired,
timeRange: shape({
@ -53,18 +57,23 @@ const DataExplorer = React.createClass({
getInitialState() {
return {
activeQueryID: null,
activeQueryIndex: 0,
};
},
handleSetActiveQuery(id) {
this.setState({activeQueryID: id});
handleSetActiveQueryIndex(index) {
this.setState({activeQueryIndex: index});
},
handleDeleteQuery(index) {
const {queryConfigs} = this.props
const query = queryConfigs[index]
this.props.queryConfigActions.deleteQuery(query.id)
},
render() {
const {autoRefresh, handleChooseAutoRefresh, timeRange, setTimeRange, queryConfigs, dataExplorer} = this.props;
const {activeQueryID} = this.state;
const queries = dataExplorer.queryIDs.map((qid) => queryConfigs[qid]);
const {autoRefresh, handleChooseAutoRefresh, timeRange, setTimeRange, queryConfigs, queryConfigActions} = this.props
const {activeQueryIndex} = this.state
return (
<div className="data-explorer">
@ -77,17 +86,20 @@ const DataExplorer = React.createClass({
<Visualization
autoRefresh={autoRefresh}
timeRange={timeRange}
queryConfigs={queries}
activeQueryID={this.state.activeQueryID}
queryConfigs={queryConfigs}
activeQueryIndex={0}
/>
<QueryBuilder
queries={queries}
autoRefresh={autoRefresh}
timeRange={timeRange}
setActiveQuery={this.handleSetActiveQuery}
activeQueryID={activeQueryID}
/>
<ResizeBottom>
<QueryBuilder
queries={queryConfigs}
actions={queryConfigActions}
autoRefresh={autoRefresh}
timeRange={timeRange}
setActiveQueryIndex={this.handleSetActiveQueryIndex}
onDeleteQuery={this.handleDeleteQuery}
activeQueryIndex={activeQueryIndex}
/>
</ResizeBottom>
</ResizeContainer>
</div>
);
@ -96,11 +108,12 @@ const DataExplorer = React.createClass({
function mapStateToProps(state) {
const {app: {persisted: {autoRefresh}}, timeRange, queryConfigs, dataExplorer} = state;
const queryConfigValues = _.values(queryConfigs)
return {
autoRefresh,
timeRange,
queryConfigs,
queryConfigs: queryConfigValues,
dataExplorer,
};
}
@ -108,7 +121,8 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch) {
return {
handleChooseAutoRefresh: bindActionCreators(setAutoRefresh, dispatch),
setTimeRange: bindActionCreators(setTimeRangeAction, dispatch),
setTimeRange: bindActionCreators(viewActions.setTimeRange, dispatch),
queryConfigActions: bindActionCreators(viewActions, dispatch),
}
}

View File

@ -1,13 +1,10 @@
import React, {PropTypes} from 'react';
import moment from 'moment';
import {withRouter} from 'react-router';
import AutoRefreshDropdown from 'shared/components/AutoRefreshDropdown'
import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown';
import SourceIndicator from '../../shared/components/SourceIndicator';
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
const {
func,
number,
@ -38,16 +35,6 @@ const Header = React.createClass({
this.props.actions.setTimeRange(bounds);
},
findSelected({upper, lower}) {
if (upper && lower) {
const format = (t) => moment(t.replace(/\'/g, '')).format('YYYY-MM-DD HH:mm');
return `${format(lower)} - ${format(upper)}`;
}
const selected = timeRanges.find((range) => range.queryValue === lower);
return selected ? selected.inputValue : 'Custom';
},
render() {
const {autoRefresh, actions: {handleChooseAutoRefresh}, timeRange} = this.props;
@ -60,7 +47,7 @@ const Header = React.createClass({
<div className="page-header__right">
<SourceIndicator sourceName={this.context.source.name} />
<AutoRefreshDropdown onChoose={handleChooseAutoRefresh} selected={autoRefresh} iconName="refresh" />
<TimeRangeDropdown onChooseTimeRange={this.handleChooseTimeRange} selected={this.findSelected(timeRange)} />
<TimeRangeDropdown onChooseTimeRange={this.handleChooseTimeRange} selected={timeRange} />
</div>
</div>
</div>

View File

@ -20,12 +20,12 @@ export default function queryConfigs(state = {}, action) {
}
case 'CHOOSE_NAMESPACE': {
const {queryId, database, retentionPolicy} = action.payload;
const nextQueryConfig = chooseNamespace(state[queryId], {database, retentionPolicy});
const {queryId, database, retentionPolicy} = action.payload
const nextQueryConfig = chooseNamespace(state[queryId], {database, retentionPolicy})
return Object.assign({}, state, {
[queryId]: Object.assign(nextQueryConfig, {rawText: state[queryId].rawText}),
});
})
}
case 'CHOOSE_MEASUREMENT': {
@ -84,20 +84,20 @@ export default function queryConfigs(state = {}, action) {
}
case 'TOGGLE_TAG_ACCEPTANCE': {
const {queryId} = action.payload;
const nextQueryConfig = toggleTagAcceptance(state[queryId]);
const {queryId} = action.payload
const nextQueryConfig = toggleTagAcceptance(state[queryId])
return Object.assign({}, state, {
[queryId]: nextQueryConfig,
});
})
}
case 'DELETE_QUERY': {
const {queryID} = action.payload;
const nextState = update(state, {$apply: (configs) => {
delete configs[queryID];
delete configs[queryID]
return configs;
}});
}})
return nextState;
}

View File

@ -1,7 +1,12 @@
import timeRanges from 'hson!../../shared/data/timeRanges.hson'
const initialLower = timeRanges[1].lower
const initialUpper = timeRanges[1].upper
const initialState = {
upper: null,
lower: 'now() - 15m',
};
upper: initialUpper,
lower: initialLower,
}
export default function timeRange(state = initialState, action) {
switch (action.type) {
@ -15,5 +20,5 @@ export default function timeRange(state = initialState, action) {
return {...state, ...newState};
}
}
return state;
return state
}

View File

@ -1,21 +1,33 @@
export default function selectStatement(timeBounds, config) {
const {database, measurement, fields, groupBy, tags, retentionPolicy, areTagsAccepted} = config;
const {groupBy, tags, areTagsAccepted} = config;
const {upper, lower} = timeBounds;
if (!database || !measurement || !fields || !fields.length) {
return null;
const select = _buildSelect(config)
if (select === null) {
return null
}
const rpSegment = retentionPolicy ? `"${retentionPolicy}"` : '';
const fullyQualifiedMeasurement = `"${database}".${rpSegment}."${measurement}"`;
const fieldsClause = _buildFields(fields);
const condition = _buildWhereClause({lower, upper, tags, areTagsAccepted});
const dimensions = _buildGroupBy(groupBy);
return `SELECT ${fieldsClause} FROM ${fullyQualifiedMeasurement}${condition}${dimensions}`;
return `${select}${condition}${dimensions}`;
}
function _buildSelect({fields, database, retentionPolicy, measurement}) {
if (!database || !measurement || !fields || !fields.length) {
return null
}
const rpSegment = retentionPolicy ? `"${retentionPolicy}"` : ''
const fieldsClause = _buildFields(fields)
const fullyQualifiedMeasurement = `"${database}".${rpSegment}."${measurement}"`
const statement = `SELECT ${fieldsClause} FROM ${fullyQualifiedMeasurement}`
return statement
}
export function buildSelectStatement(config) {
return _buildSelect(config)
}
function _buildFields(fieldFuncs) {
const hasAggregate = fieldFuncs.some((f) => f.funcs && f.funcs.length);
@ -86,4 +98,3 @@ function _buildGroupByTags(groupBy) {
return ` GROUP BY ${tags}`;
}

View File

@ -88,7 +88,7 @@ export const HostPage = React.createClass({
},
handleChooseTimeRange({lower}) {
const timeRange = timeRanges.find((range) => range.queryValue === lower);
const timeRange = timeRanges.find((range) => range.lower === lower);
this.setState({timeRange});
},

View File

@ -97,8 +97,8 @@ export const DataSection = React.createClass({
},
render() {
const {query, timeRange} = this.props;
const statement = query.rawText || selectStatement({lower: timeRange.queryValue}, query) || `SELECT "fields" FROM "db"."rp"."measurement"`;
const {query, timeRange: {lower}} = this.props;
const statement = query.rawText || selectStatement({lower}, query) || `SELECT "fields" FROM "db"."rp"."measurement"`;
return (
<div className="kapacitor-rule-section">

View File

@ -73,7 +73,7 @@ export const KapacitorRule = React.createClass({
},
handleChooseTimeRange({lower}) {
const timeRange = timeRanges.find((range) => range.queryValue === lower);
const timeRange = timeRanges.find((range) => range.lower === lower);
this.setState({timeRange});
},

View File

@ -25,9 +25,9 @@ export const RuleGraph = React.createClass({
},
renderGraph() {
const {query, source, timeRange, rule} = this.props;
const {query, source, timeRange: {lower}, rule} = this.props;
const autoRefreshMs = 30000;
const queryText = selectStatement({lower: timeRange.queryValue}, query);
const queryText = selectStatement({lower}, query);
const queries = [{host: source.links.proxy, text: queryText}];
const kapacitorLineColors = ["#4ED8A0"];

View File

@ -68,7 +68,7 @@ export const RuleHeader = React.createClass({
return (
<div className="page-header__right">
<SourceIndicator sourceName={source.name} />
<TimeRangeDropdown onChooseTimeRange={onChooseTimeRange} selected={timeRange.inputValue} />
<TimeRangeDropdown onChooseTimeRange={onChooseTimeRange} selected={timeRange} />
{saveButton}
<ReactTooltip id="save-kapacitor-tooltip" effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip kapacitor-tooltip place-bottom" />
</div>

View File

@ -65,7 +65,7 @@ export const KubernetesDashboard = React.createClass({
},
handleChooseTimeRange({lower}) {
const timeRange = timeRanges.find((range) => range.queryValue === lower);
const timeRange = timeRanges.find((range) => range.lower === lower);
this.setState({timeRange});
},

View File

@ -165,6 +165,8 @@ export default React.createClass({
valueRange: getRange(timeSeries, ranges.y2),
},
},
stepPlot: options.stepPlot,
stackedGraph: options.stackedGraph,
underlayCallback: options.underlayCallback,
series: dygraphSeries,
});

View File

@ -4,6 +4,9 @@ import LineGraph from 'shared/components/LineGraph';
import SingleStat from 'shared/components/SingleStat';
import NameableGraph from 'shared/components/NameableGraph';
import ReactGridLayout, {WidthProvider} from 'react-grid-layout';
import timeRanges from 'hson!../data/timeRanges.hson';
const GridLayout = WidthProvider(ReactGridLayout);
const RefreshingLineGraph = AutoRefresh(LineGraph);
@ -21,23 +24,15 @@ export const LayoutRenderer = React.createClass({
propTypes: {
autoRefresh: number.isRequired,
timeRange: shape({
defaultGroupBy: string.isRequired,
queryValue: string.isRequired,
lower: string.isRequired,
}).isRequired,
cells: arrayOf(
shape({
queries: arrayOf(
shape({
label: string,
range: shape({
upper: number,
lower: number,
}),
rp: string,
text: string.isRequired,
database: string.isRequired,
groupbys: arrayOf(string),
wheres: arrayOf(string),
text: string,
query: string,
}).isRequired
).isRequired,
x: number.isRequired,
@ -46,6 +41,7 @@ export const LayoutRenderer = React.createClass({
h: number.isRequired,
i: string.isRequired,
name: string.isRequired,
type: string.isRequired,
}).isRequired
),
host: string,
@ -54,15 +50,17 @@ export const LayoutRenderer = React.createClass({
onEditCell: func,
onRenameCell: func,
onUpdateCell: func,
onSummonOverlayTechnologies: func,
},
buildQuery(q) {
const {timeRange, host} = this.props;
const {wheres, groupbys} = q;
const {timeRange: {lower}, host} = this.props
const {defaultGroupBy} = timeRanges.find((range) => range.lower === lower)
const {wheres, groupbys} = q
let text = q.text;
text += ` where time > ${timeRange.queryValue}`;
text += ` where time > ${lower}`;
if (host) {
text += ` and \"host\" = '${host}'`;
@ -76,25 +74,25 @@ export const LayoutRenderer = React.createClass({
if (groupbys.find((g) => g.includes("time"))) {
text += ` group by ${groupbys.join(',')}`;
} else if (groupbys.length > 0) {
text += ` group by time(${timeRange.defaultGroupBy}),${groupbys.join(',')}`;
text += ` group by time(${defaultGroupBy}),${groupbys.join(',')}`;
} else {
text += ` group by time(${timeRange.defaultGroupBy})`;
text += ` group by time(${defaultGroupBy})`;
}
} else {
text += ` group by time(${timeRange.defaultGroupBy})`;
text += ` group by time(${defaultGroupBy})`;
}
return text;
},
generateVisualizations() {
const {autoRefresh, source, cells, onEditCell, onRenameCell, onUpdateCell} = this.props;
const {autoRefresh, source, cells, onEditCell, onRenameCell, onUpdateCell, onSummonOverlayTechnologies} = this.props;
return cells.map((cell) => {
const qs = cell.queries.map((q) => {
return Object.assign({}, q, {
const qs = cell.queries.map((query) => {
return Object.assign({}, query, {
host: source,
text: this.buildQuery(q),
text: this.buildQuery(query),
});
});
@ -105,6 +103,7 @@ export const LayoutRenderer = React.createClass({
onEditCell={onEditCell}
onRenameCell={onRenameCell}
onUpdateCell={onUpdateCell}
onSummonOverlayTechnologies={onSummonOverlayTechnologies}
cell={cell}
>
<RefreshingSingleStat queries={[qs[0]]} autoRefresh={autoRefresh} />
@ -124,12 +123,13 @@ export const LayoutRenderer = React.createClass({
onEditCell={onEditCell}
onRenameCell={onRenameCell}
onUpdateCell={onUpdateCell}
onSummonOverlayTechnologies={onSummonOverlayTechnologies}
cell={cell}
>
<RefreshingLineGraph
queries={qs}
autoRefresh={autoRefresh}
showSingleStat={cell.type === "line-plus-single-stat"}
showSingleStat={cell.type === 'line-plus-single-stat'}
displayOptions={displayOptions}
/>
</NameableGraph>

View File

@ -1,5 +1,5 @@
import React, {PropTypes} from 'react';
import Dygraph from './Dygraph';
import Dygraph from 'shared/components/Dygraph';
import classNames from 'classnames';
import shallowCompare from 'react-addons-shallow-compare';
import _ from 'lodash';

View File

@ -1,60 +1,9 @@
import React, {PropTypes} from 'react'
const NameableGraph = ({
cell: {
name,
isEditing,
x,
y,
},
cell,
children,
onEditCell,
onRenameCell,
onUpdateCell,
}) => {
let nameOrField
const isEditable = !!(onEditCell || onRenameCell || onUpdateCell)
if (isEditing && isEditable) {
nameOrField = (
<input
type="text"
value={name}
autoFocus={true}
onChange={onRenameCell(x, y)}
onBlur={onUpdateCell(cell)}
onKeyUp={(evt) => {
if (evt.key === 'Enter') {
onUpdateCell(cell)()
}
}}
/>
)
} else {
nameOrField = name
}
let onClickHandler
if (isEditable) {
onClickHandler = onEditCell
} else {
onClickHandler = () => {
// no-op
}
}
return (
<div>
<h2 className="dash-graph--heading" onClick={onClickHandler(x, y, isEditing)}>{nameOrField}</h2>
<div className="dash-graph--container">
{children}
</div>
</div>
)
}
import classnames from 'classnames'
import OnClickOutside from 'react-onclickoutside'
const {
bool,
func,
node,
number,
@ -62,16 +11,109 @@ const {
string,
} = PropTypes
NameableGraph.propTypes = {
cell: shape({
name: string.isRequired,
x: number.isRequired,
y: number.isRequired,
}).isRequired,
children: node.isRequired,
onEditCell: func,
onRenameCell: func,
onUpdateCell: func,
}
const NameableGraph = React.createClass({
propTypes: {
cell: shape({
name: string.isRequired,
isEditing: bool,
x: number.isRequired,
y: number.isRequired,
}).isRequired,
children: node.isRequired,
onEditCell: func,
onRenameCell: func,
onUpdateCell: func,
onSummonOverlayTechnologies: func,
},
getInitialState() {
return {
isMenuOpen: false,
}
},
toggleMenu() {
this.setState({
isMenuOpen: !this.state.isMenuOpen,
})
},
closeMenu() {
this.setState({
isMenuOpen: false,
})
},
render() {
const {
cell,
cell: {
x,
y,
name,
isEditing,
},
onEditCell,
onRenameCell,
onUpdateCell,
onSummonOverlayTechnologies,
children,
} = this.props
const isEditable = !!(onEditCell || onRenameCell || onUpdateCell)
let nameOrField
if (isEditing && isEditable) {
nameOrField = (
<input
className="form-control input-sm dash-graph--name-edit"
type="text"
value={name}
autoFocus={true}
onChange={onRenameCell(x, y)}
onBlur={onUpdateCell(cell)}
onKeyUp={(evt) => {
if (evt.key === 'Enter') {
onUpdateCell(cell)()
}
}}
/>
)
} else {
nameOrField = (<span className="dash-graph--name">{name}</span>)
}
let onClickHandler
if (isEditable) {
onClickHandler = onEditCell
} else {
onClickHandler = () => {
// no-op
}
}
return (
<div className="dash-graph">
<div className="dash-graph--heading">
<div onClick={onClickHandler(x, y, isEditing)}>{nameOrField}</div>
<ContextMenu isOpen={this.state.isMenuOpen} toggleMenu={this.toggleMenu} onSummonOverlayTechnologies={onSummonOverlayTechnologies} cell={cell} handleClickOutside={this.closeMenu}/>
</div>
<div className="dash-graph--container">
{children}
</div>
</div>
)
},
})
const ContextMenu = OnClickOutside(({isOpen, toggleMenu, onSummonOverlayTechnologies, cell}) => (
<div className={classnames("dash-graph--options", {"dash-graph--options-show": isOpen})} onClick={toggleMenu}>
<button className="btn btn-info btn-xs">
<span className="icon caret-down"></span>
</button>
<ul className="dash-graph--options-menu">
<li onClick={() => onSummonOverlayTechnologies(cell)}>Edit</li>
</ul>
</div>
))
export default NameableGraph;

View File

@ -1,7 +1,11 @@
import React, {PropTypes} from 'react';
import ResizeHandle from 'shared/components/ResizeHandle';
const {node} = PropTypes;
const {
node,
string,
} = PropTypes;
const ResizeContainer = React.createClass({
propTypes: {
children: node.isRequired,
@ -47,7 +51,7 @@ const ResizeContainer = React.createClass({
// Don't trigger a resize if the new sizes are too small
const minTopPanelHeight = 200;
const minBottomPanelHeight = 100;
const minBottomPanelHeight = 200;
const topHeightPixels = ((newTopPanelPercent / turnToPercent) * appHeight);
const bottomHeightPixels = ((newBottomPanelPercent / turnToPercent) * appHeight);
@ -58,20 +62,38 @@ const ResizeContainer = React.createClass({
this.setState({topHeight: `${(newTopPanelPercent)}%`, bottomHeight: `${(newBottomPanelPercent)}%`, topHeightPixels});
},
render() {
const {topHeight, bottomHeight, isDragging, topHeightPixels} = this.state;
const top = React.cloneElement(this.props.children[0], {height: topHeight, heightPixels: topHeightPixels});
const bottom = React.cloneElement(this.props.children[1], {height: bottomHeight, top: topHeight});
const handle = <ResizeHandle isDragging={isDragging} onHandleStartDrag={this.handleStartDrag} top={topHeight} />;
renderHandle() {
const {isDragging, topHeight} = this.state
return (
<ResizeHandle isDragging={isDragging} onHandleStartDrag={this.handleStartDrag} top={topHeight} />
)
},
render() {
const {topHeight, topHeightPixels, bottomHeight} = this.state
const top = React.cloneElement(this.props.children[0], {height: topHeight, heightPixels: topHeightPixels})
const bottom = React.cloneElement(this.props.children[1], {height: bottomHeight})
return (
<div className="resize-container page-contents" onMouseLeave={this.handleMouseLeave} onMouseUp={this.handleStopDrag} onMouseMove={this.handleDrag} ref="resizeContainer" >
{top}
{handle}
{this.renderHandle()}
{bottom}
</div>
);
)
},
});
})
export default ResizeContainer;
const ResizeBottom = (props) => (
<div className="resize-bottom" style={{height: props.height}}>
{props.children}
</div>
)
ResizeBottom.propTypes = {
children: node.isRequired,
height: string,
}
export {ResizeBottom}
export default ResizeContainer

View File

@ -2,13 +2,15 @@ import React from 'react';
import classnames from 'classnames';
import OnClickOutside from 'shared/components/OnClickOutside';
import moment from 'moment';
import timeRanges from 'hson!../data/timeRanges.hson';
const TimeRangeDropdown = React.createClass({
autobind: false,
propTypes: {
selected: React.PropTypes.string.isRequired,
selected: React.PropTypes.shape().isRequired,
onChooseTimeRange: React.PropTypes.func.isRequired,
},
@ -18,16 +20,26 @@ const TimeRangeDropdown = React.createClass({
};
},
findTimeRangeInputValue({upper, lower}) {
if (upper && lower) {
const format = (t) => moment(t.replace(/\'/g, '')).format('YYYY-MM-DD HH:mm');
return `${format(lower)} - ${format(upper)}`;
}
const selected = timeRanges.find((range) => range.lower === lower);
return selected ? selected.inputValue : 'Custom';
},
handleClickOutside() {
this.setState({isOpen: false});
},
handleSelection(params) {
const {queryValue, menuOption} = params;
const {lower, upper, menuOption} = params;
if (menuOption.toLowerCase() === 'custom') {
this.props.onChooseTimeRange({custom: true});
} else {
this.props.onChooseTimeRange({lower: queryValue, upper: null});
this.props.onChooseTimeRange({lower, upper});
}
this.setState({isOpen: false});
},
@ -45,7 +57,7 @@ const TimeRangeDropdown = React.createClass({
<div className="dropdown time-range-dropdown">
<div className="btn btn-sm btn-info dropdown-toggle" onClick={() => self.toggleMenu()}>
<span className="icon clock"></span>
<span className="selected-time-range">{selected}</span>
<span className="selected-time-range">{self.findTimeRangeInputValue(selected)}</span>
<span className="caret" />
</div>
<ul className={classnames("dropdown-menu", {show: isOpen})}>

View File

@ -0,0 +1,7 @@
[
{type: "line", menuOption: "Line"},
{type: "line-stacked", menuOption: "Stacked"},
{type: "line-stepplot", menuOption: "Step-Plot"},
{type: "single-stat", menuOption: "SingleStat"},
{type: "line-plus-single-stat", menuOption: "Line + Stat"},
]

View File

@ -1,11 +1,11 @@
[
{defaultGroupBy: '10s', seconds: 300, inputValue: 'Past 5 minutes', queryValue: 'now() - 5m', menuOption: 'Past 5 minutes'},
{defaultGroupBy: '1m', seconds: 900, inputValue: 'Past 15 minutes', queryValue: 'now() - 15m', menuOption: 'Past 15 minutes'},
{defaultGroupBy: '1m', seconds: 3600, inputValue: 'Past hour', queryValue: 'now() - 1h', menuOption: 'Past hour'},
{defaultGroupBy: '1m', seconds: 21600, inputValue: 'Past 6 hours', queryValue: 'now() - 6h', menuOption: 'Past 6 hours'},
{defaultGroupBy: '5m', seconds: 43200, inputValue: 'Past 12 hours', queryValue: 'now() - 12h', menuOption: 'Past 12 hours'},
{defaultGroupBy: '10m', seconds: 86400, inputValue: 'Past 24 hours', queryValue: 'now() - 24h', menuOption: 'Past 24 hours'},
{defaultGroupBy: '30m', seconds: 172800, inputValue: 'Past 2 days', queryValue: 'now() - 2d', menuOption: 'Past 2 days'},
{defaultGroupBy: '1h', seconds: 604800, inputValue: 'Past 7 days', queryValue: 'now() - 7d', menuOption: 'Past 7 days'},
{defaultGroupBy: '6h', seconds: 2592000, inputValue: 'Past 30 days', queryValue: 'now() - 30d', menuOption: 'Past 30 days'},
{defaultGroupBy: '10s', seconds: 300, inputValue: 'Past 5 minutes', lower: 'now() - 5m', upper: null, menuOption: 'Past 5 minutes'},
{defaultGroupBy: '1m', seconds: 900, inputValue: 'Past 15 minutes', lower: 'now() - 15m', upper: null, menuOption: 'Past 15 minutes'},
{defaultGroupBy: '1m', seconds: 3600, inputValue: 'Past hour', lower: 'now() - 1h', upper: null, menuOption: 'Past hour'},
{defaultGroupBy: '1m', seconds: 21600, inputValue: 'Past 6 hours', lower: 'now() - 6h', upper: null, menuOption: 'Past 6 hours'},
{defaultGroupBy: '5m', seconds: 43200, inputValue: 'Past 12 hours', lower: 'now() - 12h', upper: null, menuOption: 'Past 12 hours'},
{defaultGroupBy: '10m', seconds: 86400, inputValue: 'Past 24 hours', lower: 'now() - 24h', upper: null, menuOption: 'Past 24 hours'},
{defaultGroupBy: '30m', seconds: 172800, inputValue: 'Past 2 days', lower: 'now() - 2d', upper: null, menuOption: 'Past 2 days'},
{defaultGroupBy: '1h', seconds: 604800, inputValue: 'Past 7 days', lower: 'now() - 7d', upper: null, menuOption: 'Past 7 days'},
{defaultGroupBy: '6h', seconds: 2592000, inputValue: 'Past 30 days', lower: 'now() - 30d', upper: null, menuOption: 'Past 30 days'},
]

View File

@ -38,6 +38,7 @@
@import 'components/tables';
@import 'components/resizer';
@import 'components/source-indicator';
@import 'components/confirm-buttons';
// Pages
@import 'pages/alerts';

View File

@ -0,0 +1,21 @@
/*
Confirmation Buttons
------------------------------------------------------
*/
.confirm-buttons {
display: flex !important;
align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
& > * {
margin: 0 0 0 4px !important;
&:first-child {
margin: 0 !important;
}
}
span.icon {
margin: 0 !important;
}
}

View File

@ -86,3 +86,22 @@ $resizer-color-active: $c-pool;
flex-direction: row;
align-items: stretch;
}
.resize-container {
.resize-top {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
height: 60%;
width: 100%;
}
.resize-bottom {
display: flex;
flex-direction: column;
position: absolute;
height: 40%;
bottom: 0;
width: 100%;
}
}

View File

@ -48,7 +48,15 @@ $dash-graph-heading: 30px;
left: 0;
}
}
.dash-graph {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.dash-graph--container {
z-index: 1;
user-select: none !important;
-o-user-select: none !important;
-moz-user-select: none !important;
@ -83,6 +91,7 @@ $dash-graph-heading: 30px;
}
}
.dash-graph--heading {
z-index: 2;
user-select: none !important;
-o-user-select: none !important;
-moz-user-select: none !important;
@ -97,6 +106,7 @@ $dash-graph-heading: 30px;
margin: 0;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: $radius;
font-weight: 600;
font-size: 13px;
@ -108,11 +118,158 @@ $dash-graph-heading: 30px;
cursor: default;
}
}
.dash-graph--name-edit,
.dash-graph--name {
display: inline-block;
padding: 0 6px;
height: 26px !important;
line-height: (26px - 4px) !important;
font-size: 13px;
font-weight: 600;
position: relative;
left: -6px;
border-radius: $radius;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dash-graph--name {
transition:
color 0.25s ease,
background-color 0.25s ease,
border-color 0.25s ease;
border: 2px solid $g3-castle;
top: 2px;
&:after {
display: inline-block;
content: "\f058";
font-family: 'icomoon' !important;
font-style: normal;
font-weight: normal;
font-variant: normal;
line-height: 1;
text-transform: none;
color: $g11-sidewalk;
font-size: 14px;
opacity: 0;
transition: opacity 0.25s ease;
margin-left: 5px;
}
&:hover {
cursor: text;
color: $g20-white;
&:after {
opacity: 1;
}
}
}
.dash-graph--name-edit {
top: -1px;
width: 190px;
}
.dash-graph--options {
height: $dash-graph-heading;
position: relative;
width: ($dash-graph-heading - 8px);
right: -6px;
> .btn {
background-color: transparent !important;
padding: 0;
margin: 4px 0;
height: ($dash-graph-heading - 8px);
width: ($dash-graph-heading - 8px);
line-height: ($dash-graph-heading - 8px);
transition:
background-color 0.25s ease,
color 0.25s ease !important;
&:hover {
background-color: $g5-pepper !important;
color: $g20-white;
}
}
}
$dash-graph-options-arrow: 8px;
.presentation-mode .dash-graph--options {
display: none;
visibility: hidden;
}
.dash-graph--options-menu {
position: absolute;
top: ($dash-graph-heading + $dash-graph-options-arrow);
left: 50%;
transform: translateX(-50%);
display: block;
list-style: none;
padding: 0;
width: 90px;
visibility: hidden;
transition-property: all;
> li {
position: relative;
width: 100%;
height: 28px;
line-height: 28px;
background-color: $g5-pepper;
padding: 0 11px;
color: $g15-platinum;
opacity: 0;
transition:
opacity 0.25s ease,
color 0.25s ease,
background-color 0.25s ease;
&:first-child {
border-radius: $radius $radius 0 0;
&:before {
content: '';
width: 0;
height: 0;
border-width: $dash-graph-options-arrow;
border-style: solid;
border-color: transparent transparent $g5-pepper transparent;
position: absolute;
left: 50%;
transform: translateX(-50%);
top: -($dash-graph-options-arrow * 2);
}
}
&:last-child {
border-radius: 0 0 $radius $radius;
}
&:hover {
cursor: pointer;
background-color: $g6-smoke;
color: $g20-white;
}
}
}
/* Menu Open State */
.dash-graph--options.dash-graph--options-show {
> .btn {
color: $g20-white;
background-color: $g5-pepper !important;
}
.dash-graph--options-menu { visibility: visible; }
.dash-graph--options-menu > li { opacity: 1; }
}
.graph-panel__refreshing {
position: absolute;
top: -18px !important;
transform: translate(0,0);
right: 16px !important;
right: 50px !important;
width: 16px;
height: 18px;
@ -227,3 +384,91 @@ $dash-graph-heading: 30px;
}
}
}
/*
Cell Edit Mode
------------------------------------------------------
*/
$overlay-controls-height: 50px;
$overlay-controls-bg: $g2-kevlar;
$overlay-bg: rgba($g0-obsidian, 0.7);
.overlay-technology {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
background: $overlay-bg;
.overlay-controls {
padding: 0 16px;
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
flex: 0 0 $overlay-controls-height;
width: calc(100% - #{($explorer-page-padding * 2)});
left: $explorer-page-padding;
margin-top: $de-vertical-margin;
border: 0;
border-radius: $radius;
@include gradient-h($g3-castle,$overlay-controls-bg);
/* Hack for making the adjacent query builder have less margin on top */
& + .query-builder .query-builder--tabs,
& + .query-builder .query-builder--tab-contents,
& + .query-builder .qeditor--empty {
margin-top: 2px;
height: calc(100% - 18px);
}
}
.overlay-controls--right {
display: flex;
align-items: center;
flex-wrap: nowrap;
.toggle {
margin: 0 0 0 5px;
}
p {
font-weight: 600;
color: $g13-mist;
margin: 0;
@include no-user-select;
}
}
.overlay--graph-name {
margin: 0;
font-size: 17px;
font-weight: 400;
text-transform: uppercase;
@include no-user-select;
}
.confirm-buttons {
margin-left: 32px;
}
.confirm-buttons .btn {
height: 30px;
line-height: 30px;
padding: 0 9px;
& > span.icon {
font-size: 16px;
}
}
.overlay-controls .toggle {
.toggle-btn {
background-color: $overlay-controls-bg;
&:hover {
background-color: $g4-onyx;
}
&.active {
background-color: $g5-pepper;
}
}
}
}

View File

@ -1,9 +1,8 @@
.query-builder {
position: absolute;
position: relative;
flex: 1 0 0;
width: calc(100% - #{($explorer-page-padding * 2)});
left: $explorer-page-padding;
height: 40%;
top: 60%;
border: 0;
display: flex;
align-items: stretch;
@ -206,6 +205,8 @@ $query-builder--column-heading-height: 60px;
top: $query-builder--column-heading-height;
left: 0;
background-color: transparent;
p,h1,h2,h3,h4,h5,h6 { @include no-user-select(); }
}
}
.query-builder--column:nth-of-type(1) { left: 0; }
@ -226,4 +227,4 @@ $query-builder--column-heading-height: 60px;
border-color: $g5-pepper;
border-color: $g6-smoke;
color: $g12-forge;
}
}

View File

@ -1,9 +1,8 @@
.graph {
position: absolute;
top: 0;
width: calc(100% - #{($explorer-page-padding * 2)});
left: $explorer-page-padding;
top: 0;
height: 60%;
}
.graph-heading {
position: relative;