diff --git a/CHANGELOG.md b/CHANGELOG.md index 39f5170396..0e6d85f264 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,13 @@ ## v1.2.0 [unreleased] ### Upcoming Bug Fixes + 1. [#865](https://github.com/influxdata/chronograf/issues/865): Support for String fields compare kapacitor rules in Chronograf UI ### Upcoming Features 1. [#838](https://github.com/influxdata/chronograf/issues/838): Add detail node to kapacitor alerts 2. [#853](https://github.com/influxdata/chronograf/issues/853): Updated builds to use yarn over npm install - 3. [#864](https://github.com/influxdata/chronograf/issues/864): Add support to kapacitor rule alert config for: + 3. [#860](https://github.com/influxdata/chronograf/issues/860): Add gzip encoding and caching of static assets to server + 4. [#864](https://github.com/influxdata/chronograf/issues/864): Add support to kapacitor rule alert config for: - HTTP - TCP - Exec @@ -13,6 +15,11 @@ - Alerta ### Upcoming UI Improvements + 1. [#822](https://github.com/influxdata/chronograf/issues/822): Simplify and improve layout of the Data Explorer + - The Data Explorer's intention and purpose has always been the ad hoc and ephemeral exploration of your schema and data. + The concept of `Exploration` sessions and `Panels` betrayed this initial intention. The DE turned into a "poor man's" + dashboarding tool. In turn, this introduced complexity in the code and the UI. In the future if I want to save, manipulate, + and view multiple visualizations this will be done more efficiently and effectively in our dashboarding solution. ## v1.2.0-beta1 [2017-01-27] diff --git a/Godeps b/Godeps index f2c6cb8bad..a81e679b1e 100644 --- a/Godeps +++ b/Godeps @@ -1,3 +1,4 @@ +github.com/NYTimes/gziphandler 6710af535839f57c687b62c4c23d649f9545d885 github.com/Sirupsen/logrus 3ec0642a7fb6488f65b06f9040adc67e3990296a github.com/boltdb/bolt 5cc10bbbc5c141029940133bb33c9e969512a698 github.com/bouk/httprouter ee8b3818a7f51fbc94cc709b5744b52c2c725e91 diff --git a/LICENSE_OF_DEPENDENCIES.md b/LICENSE_OF_DEPENDENCIES.md index 5efd655a68..0f96a24c34 100644 --- a/LICENSE_OF_DEPENDENCIES.md +++ b/LICENSE_OF_DEPENDENCIES.md @@ -1,4 +1,5 @@ ### Go +* github.com/NYTimes/gziphandler [APACHE-2.0](https://github.com/NYTimes/gziphandler/blob/master/LICENSE.md) * github.com/Sirupsen/logrus [MIT](https://github.com/Sirupsen/logrus/blob/master/LICENSE) * github.com/boltdb/bolt [MIT](https://github.com/boltdb/bolt/blob/master/LICENSE) * github.com/bouk/httprouter [BSD](https://github.com/bouk/httprouter/blob/master/LICENSE) diff --git a/dist/dist.go b/dist/dist.go index 07b9d0ab41..ba3d3c938f 100644 --- a/dist/dist.go +++ b/dist/dist.go @@ -3,6 +3,7 @@ package dist //go:generate go-bindata -o dist_gen.go -ignore 'map|go' -pkg dist ../ui/build/... import ( + "fmt" "net/http" "github.com/elazarl/go-bindata-assetfs" @@ -32,6 +33,21 @@ func (b *BindataAssets) Handler() http.Handler { return b } +// addCacheHeaders requests an hour of Cache-Control and sets an ETag based on file size and modtime +func (b *BindataAssets) addCacheHeaders(filename string, w http.ResponseWriter) error { + w.Header().Add("Cache-Control", "public, max-age=3600") + fi, err := AssetInfo(filename) + if err != nil { + return err + } + + hour, minute, second := fi.ModTime().Clock() + etag := fmt.Sprintf(`"%d%d%d%d%d"`, fi.Size(), fi.ModTime().Day(), hour, minute, second) + + w.Header().Set("ETag", etag) + return nil +} + // ServeHTTP wraps http.FileServer by returning a default asset if the asset // doesn't exist. This supports single-page react-apps with its own // built-in router. Additionally, we override the content-type if the @@ -52,8 +68,14 @@ func (b *BindataAssets) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Additionally, because we know we are returning the default asset, // we need to set the default asset's content-type. w.Header().Set("Content-Type", b.DefaultContentType) + if err := b.addCacheHeaders(b.Default, w); err != nil { + return nil, err + } return Asset(b.Default) } + if err := b.addCacheHeaders(name, w); err != nil { + return nil, err + } return octets, nil } var dir http.FileSystem = &assetfs.AssetFS{ diff --git a/kapacitor/tickscripts_test.go b/kapacitor/tickscripts_test.go index c00e043782..d201d44b89 100644 --- a/kapacitor/tickscripts_test.go +++ b/kapacitor/tickscripts_test.go @@ -199,6 +199,280 @@ trigger } } +func TestThresholdStringCrit(t *testing.T) { + alert := chronograf.AlertRule{ + Name: "haproxy", + Trigger: "threshold", + Alerts: []string{"email"}, + TriggerValues: chronograf.TriggerValues{ + Operator: "equal to", + Value: "DOWN", + }, + Every: "10s", + Message: `Haproxy monitor : {{.ID}} : {{ index .Tags "server" }} : {{ index .Tags "pxname" }} is {{ .Level }} `, + Details: "Email template", + Query: chronograf.QueryConfig{ + Database: "influxdb", + RetentionPolicy: "autogen", + Measurement: "haproxy", + Fields: []chronograf.Field{ + { + Field: "status", + Funcs: []string{"last"}, + }, + }, + GroupBy: chronograf.GroupBy{ + Time: "10s", + Tags: []string{"pxname"}, + }, + AreTagsAccepted: true, + }, + } + + tests := []struct { + name string + alert chronograf.AlertRule + want chronograf.TICKScript + wantErr bool + }{ + { + name: "Test valid template alert", + alert: alert, + want: `var db = 'influxdb' + +var rp = 'autogen' + +var measurement = 'haproxy' + +var groupBy = ['pxname'] + +var whereFilter = lambda: TRUE + +var period = 10s + +var every = 10s + +var name = 'haproxy' + +var idVar = name + ':{{.Group}}' + +var message = 'Haproxy monitor : {{.ID}} : {{ index .Tags "server" }} : {{ index .Tags "pxname" }} is {{ .Level }} ' + +var idTag = 'alertID' + +var levelTag = 'level' + +var messageField = 'message' + +var durationField = 'duration' + +var outputDB = 'chronograf' + +var outputRP = 'autogen' + +var outputMeasurement = 'alerts' + +var triggerType = 'threshold' + +var details = 'Email template' + +var crit = 'DOWN' + +var data = stream + |from() + .database(db) + .retentionPolicy(rp) + .measurement(measurement) + .groupBy(groupBy) + .where(whereFilter) + |window() + .period(period) + .every(every) + .align() + |last('status') + .as('value') + +var trigger = data + |alert() + .crit(lambda: "value" == crit) + .stateChangesOnly() + .message(message) + .id(idVar) + .idTag(idTag) + .levelTag(levelTag) + .messageField(messageField) + .durationField(durationField) + .details(details) + .email() + +trigger + |influxDBOut() + .create() + .database(outputDB) + .retentionPolicy(outputRP) + .measurement(outputMeasurement) + .tag('alertName', name) + .tag('triggerType', triggerType) + +trigger + |httpOut('output') +`, + wantErr: false, + }, + } + for _, tt := range tests { + gen := Alert{} + got, err := gen.Generate(tt.alert) + if (err != nil) != tt.wantErr { + t.Errorf("%q. Threshold() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if got != tt.want { + diff := diffmatchpatch.New() + delta := diff.DiffMain(string(tt.want), string(got), true) + t.Errorf("%q\n%s", tt.name, diff.DiffPrettyText(delta)) + } + } +} + +// TODO: Check with Nathaniel if kapacitor can do inequalities on strings +// If it cannot, I think we should add operator checks. +func TestThresholdStringCritGreater(t *testing.T) { + alert := chronograf.AlertRule{ + Name: "haproxy", + Trigger: "threshold", + Alerts: []string{"email"}, + TriggerValues: chronograf.TriggerValues{ + Operator: "greater than", + Value: "DOWN", + }, + Every: "10s", + Message: `Haproxy monitor : {{.ID}} : {{ index .Tags "server" }} : {{ index .Tags "pxname" }} is {{ .Level }} `, + Details: "Email template", + Query: chronograf.QueryConfig{ + Database: "influxdb", + RetentionPolicy: "autogen", + Measurement: "haproxy", + Fields: []chronograf.Field{ + { + Field: "status", + Funcs: []string{"last"}, + }, + }, + GroupBy: chronograf.GroupBy{ + Time: "10s", + Tags: []string{"pxname"}, + }, + AreTagsAccepted: true, + }, + } + + tests := []struct { + name string + alert chronograf.AlertRule + want chronograf.TICKScript + wantErr bool + }{ + { + name: "Test valid template alert", + alert: alert, + want: `var db = 'influxdb' + +var rp = 'autogen' + +var measurement = 'haproxy' + +var groupBy = ['pxname'] + +var whereFilter = lambda: TRUE + +var period = 10s + +var every = 10s + +var name = 'haproxy' + +var idVar = name + ':{{.Group}}' + +var message = 'Haproxy monitor : {{.ID}} : {{ index .Tags "server" }} : {{ index .Tags "pxname" }} is {{ .Level }} ' + +var idTag = 'alertID' + +var levelTag = 'level' + +var messageField = 'message' + +var durationField = 'duration' + +var outputDB = 'chronograf' + +var outputRP = 'autogen' + +var outputMeasurement = 'alerts' + +var triggerType = 'threshold' + +var details = 'Email template' + +var crit = 'DOWN' + +var data = stream + |from() + .database(db) + .retentionPolicy(rp) + .measurement(measurement) + .groupBy(groupBy) + .where(whereFilter) + |window() + .period(period) + .every(every) + .align() + |last('status') + .as('value') + +var trigger = data + |alert() + .crit(lambda: "value" > crit) + .stateChangesOnly() + .message(message) + .id(idVar) + .idTag(idTag) + .levelTag(levelTag) + .messageField(messageField) + .durationField(durationField) + .details(details) + .email() + +trigger + |influxDBOut() + .create() + .database(outputDB) + .retentionPolicy(outputRP) + .measurement(outputMeasurement) + .tag('alertName', name) + .tag('triggerType', triggerType) + +trigger + |httpOut('output') +`, + wantErr: false, + }, + } + for _, tt := range tests { + gen := Alert{} + got, err := gen.Generate(tt.alert) + if (err != nil) != tt.wantErr { + t.Errorf("%q. Threshold() error = %v, wantErr %v", tt.name, err, tt.wantErr) + continue + } + if got != tt.want { + diff := diffmatchpatch.New() + delta := diff.DiffMain(string(tt.want), string(got), true) + t.Errorf("%q\n%s", tt.name, diff.DiffPrettyText(delta)) + } + } +} + func TestThresholdDetail(t *testing.T) { alert := chronograf.AlertRule{ Name: "name", diff --git a/kapacitor/vars.go b/kapacitor/vars.go index f9239e6bfd..081f349fb7 100644 --- a/kapacitor/vars.go +++ b/kapacitor/vars.go @@ -3,6 +3,7 @@ package kapacitor import ( "fmt" "sort" + "strconv" "strings" "github.com/influxdata/chronograf" @@ -39,15 +40,13 @@ func Vars(rule chronograf.AlertRule) (string, error) { %s var crit = %s ` - return fmt.Sprintf(vars, - common, - rule.TriggerValues.Value), nil + return fmt.Sprintf(vars, common, formatValue(rule.TriggerValues.Value)), nil } else { vars := ` %s var lower = %s var upper = %s - ` +` return fmt.Sprintf(vars, common, rule.TriggerValues.Value, @@ -178,3 +177,13 @@ func whereFilter(q chronograf.QueryConfig) string { return "lambda: TRUE" } + +// formatValue return the same string if a numeric type or if it is a string +// will return it as a kapacitor formatted single-quoted string +func formatValue(value string) string { + // Test if numeric if it can be converted to a float + if _, err := strconv.ParseFloat(value, 64); err == nil { + return value + } + return "'" + value + "'" +} diff --git a/kapacitor/vars_test.go b/kapacitor/vars_test.go new file mode 100644 index 0000000000..e19104aeff --- /dev/null +++ b/kapacitor/vars_test.go @@ -0,0 +1,50 @@ +package kapacitor + +import ( + "fmt" + "testing" + + "github.com/influxdata/chronograf" +) + +func TestVarsCritStringEqual(t *testing.T) { + alert := chronograf.AlertRule{ + Name: "name", + Trigger: "threshold", + TriggerValues: chronograf.TriggerValues{ + Operator: "equal to", + Value: "DOWN", + }, + Every: "30s", + Query: chronograf.QueryConfig{ + Database: "telegraf", + Measurement: "haproxy", + RetentionPolicy: "autogen", + Fields: []chronograf.Field{ + { + Field: "status", + }, + }, + GroupBy: chronograf.GroupBy{ + Time: "10m", + Tags: []string{"pxname"}, + }, + AreTagsAccepted: true, + }, + } + + raw, err := Vars(alert) + if err != nil { + fmt.Printf("%s", raw) + t.Fatalf("Error generating alert: %v %s", err, raw) + } + + tick, err := formatTick(raw) + if err != nil { + t.Errorf("Error formatting alert: %v %s", err, raw) + } + + if err := validateTick(tick); err != nil { + t.Errorf("Error validating alert: %v %s", err, tick) + } +} diff --git a/server/mux.go b/server/mux.go index 927f935798..0c28a53027 100644 --- a/server/mux.go +++ b/server/mux.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/NYTimes/gziphandler" "github.com/bouk/httprouter" "github.com/influxdata/chronograf" // When julienschmidt/httprouter v2 w/ context is out, switch "github.com/influxdata/chronograf/jwt" @@ -119,7 +120,10 @@ func NewMux(opts MuxOpts, service Service) http.Handler { auth := AuthAPI(opts, router) return Logger(opts.Logger, auth) } - return Logger(opts.Logger, router) + + compressed := gziphandler.GzipHandler(router) + logged := Logger(opts.Logger, compressed) + return logged } // AuthAPI adds the OAuth routes if auth is enabled. diff --git a/server/url_prefixer.go b/server/url_prefixer.go index 54e2a35f8e..d20563fc15 100644 --- a/server/url_prefixer.go +++ b/server/url_prefixer.go @@ -22,16 +22,27 @@ type wrapResponseWriter struct { Substitute *io.PipeWriter headerWritten bool - dupHeader http.Header + dupHeader *http.Header } -func (wrw wrapResponseWriter) Write(p []byte) (int, error) { +func (wrw *wrapResponseWriter) Write(p []byte) (int, error) { return wrw.Substitute.Write(p) } -func (wrw wrapResponseWriter) WriteHeader(code int) { +func (wrw *wrapResponseWriter) WriteHeader(code int) { if !wrw.headerWritten { - wrw.ResponseWriter.Header().Set("Content-Type", wrw.Header().Get("Content-Type")) + wrw.ResponseWriter.Header().Set("Content-Type", wrw.dupHeader.Get("Content-Type")) + header := wrw.ResponseWriter.Header() + // Filter out content length header to prevent stopping writing + if wrw.dupHeader != nil { + for k, v := range *wrw.dupHeader { + if k == "Content-Length" { + continue + } + header[k] = v + } + } + wrw.headerWritten = true } wrw.ResponseWriter.WriteHeader(code) @@ -39,13 +50,16 @@ func (wrw wrapResponseWriter) WriteHeader(code int) { // Header() copies the Header map from the underlying ResponseWriter to prevent // modifications to it by callers -func (wrw wrapResponseWriter) Header() http.Header { - wrw.dupHeader = http.Header{} - origHeader := wrw.ResponseWriter.Header() - for k, v := range origHeader { - wrw.dupHeader[k] = v +func (wrw *wrapResponseWriter) Header() http.Header { + if wrw.dupHeader == nil { + h := http.Header{} + origHeader := wrw.ResponseWriter.Header() + for k, v := range origHeader { + h[k] = v + } + wrw.dupHeader = &h } - return wrw.dupHeader + return *wrw.dupHeader } const CHUNK_SIZE int = 512 @@ -73,7 +87,7 @@ func (up *URLPrefixer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { nextRead, nextWrite := io.Pipe() go func() { defer nextWrite.Close() - up.Next.ServeHTTP(wrapResponseWriter{ResponseWriter: rw, Substitute: nextWrite}, r) + up.Next.ServeHTTP(&wrapResponseWriter{ResponseWriter: rw, Substitute: nextWrite}, r) }() // setup a buffer which is the max length of our target attrs diff --git a/ui/spec/data_explorer/reducers/dataExplorerUISpec.js b/ui/spec/data_explorer/reducers/dataExplorerUISpec.js deleted file mode 100644 index b0f4404676..0000000000 --- a/ui/spec/data_explorer/reducers/dataExplorerUISpec.js +++ /dev/null @@ -1,11 +0,0 @@ -import reducer from 'src/data_explorer/reducers/dataExplorerUI'; -import {activatePanel} from 'src/data_explorer/actions/view'; - -describe('DataExplorer.Reducers.UI', () => { - it('can set the active panel', () => { - const activePanel = 123; - const actual = reducer({}, activatePanel(activePanel)); - - expect(actual).to.deep.equal({activePanel}); - }); -}); diff --git a/ui/spec/data_explorer/reducers/panelSpec.js b/ui/spec/data_explorer/reducers/panelSpec.js deleted file mode 100644 index 8182edd052..0000000000 --- a/ui/spec/data_explorer/reducers/panelSpec.js +++ /dev/null @@ -1,34 +0,0 @@ -import reducer from 'src/data_explorer/reducers/panels'; -import {deletePanel} from 'src/data_explorer/actions/view'; - -const fakeAddPanelAction = (panelID, queryID) => { - return { - type: 'CREATE_PANEL', - payload: {panelID, queryID}, - }; -}; - -describe('Chronograf.Reducers.Panel', () => { - let state; - const panelID = 123; - const queryID = 456; - - beforeEach(() => { - state = reducer({}, fakeAddPanelAction(panelID, queryID)); - }); - - it('can add a panel', () => { - const actual = state[panelID]; - expect(actual).to.deep.equal({ - id: panelID, - queryIds: [queryID], - }); - }); - - it('can delete a panel', () => { - const nextState = reducer(state, deletePanel(panelID)); - - const actual = nextState[panelID]; - expect(actual).to.equal(undefined); - }); -}); diff --git a/ui/src/data_explorer/actions/view/index.js b/ui/src/data_explorer/actions/view/index.js index f79fc537ad..8e5f36b224 100644 --- a/ui/src/data_explorer/actions/view/index.js +++ b/ui/src/data_explorer/actions/view/index.js @@ -1,51 +1,20 @@ import uuid from 'node-uuid'; -export function createPanel() { - return { - type: 'CREATE_PANEL', - payload: { - panelID: uuid.v4(), // for the default Panel - queryID: uuid.v4(), // for the default Query - }, - }; -} - -export function renamePanel(panelId, name) { - return { - type: 'RENAME_PANEL', - payload: { - panelId, - name, - }, - }; -} - -export function deletePanel(panelId) { - return { - type: 'DELETE_PANEL', - payload: { - panelId, - }, - }; -} - -export function addQuery(panelId, options) { +export function addQuery(options = {}) { return { type: 'ADD_QUERY', payload: { - panelId, - queryId: uuid.v4(), + queryID: uuid.v4(), options, }, }; } -export function deleteQuery(panelId, queryId) { +export function deleteQuery(queryID) { return { type: 'DELETE_QUERY', payload: { - queryId, - panelId, + queryID, }, }; } @@ -159,12 +128,3 @@ export function updateRawQuery(queryID, text) { }, }; } - -export function activatePanel(panelID) { - return { - type: 'ACTIVATE_PANEL', - payload: { - panelID, - }, - }; -} diff --git a/ui/src/data_explorer/components/DatabaseList.js b/ui/src/data_explorer/components/DatabaseList.js index c903290ea0..ca08c79c19 100644 --- a/ui/src/data_explorer/components/DatabaseList.js +++ b/ui/src/data_explorer/components/DatabaseList.js @@ -60,18 +60,21 @@ const DatabaseList = React.createClass({ const {onChooseNamespace, query} = this.props; return ( - <ul className="qeditor--list"> - {this.state.namespaces.map((namespace) => { - const {database, retentionPolicy} = namespace; - const isActive = database === query.database && retentionPolicy === query.retentionPolicy; + <div className="query-builder--column"> + <div className="query-builder--column-heading">Databases</div> + <ul className="qeditor--list"> + {this.state.namespaces.map((namespace) => { + const {database, retentionPolicy} = namespace; + const isActive = database === query.database && retentionPolicy === query.retentionPolicy; - return ( - <li className={classNames('qeditor--list-item qeditor--list-radio', {active: isActive})} key={`${database}..${retentionPolicy}`} onClick={_.wrap(namespace, onChooseNamespace)}> - {database}.{retentionPolicy} - </li> - ); - })} - </ul> + return ( + <li className={classNames('qeditor--list-item qeditor--list-radio', {active: isActive})} key={`${database}..${retentionPolicy}`} onClick={_.wrap(namespace, onChooseNamespace)}> + {database}.{retentionPolicy} + </li> + ); + })} + </ul> + </div> ); }, }); diff --git a/ui/src/data_explorer/components/FieldList.js b/ui/src/data_explorer/components/FieldList.js index ee700c638d..8253d0bf6a 100644 --- a/ui/src/data_explorer/components/FieldList.js +++ b/ui/src/data_explorer/components/FieldList.js @@ -41,25 +41,26 @@ const FieldList = React.createClass({ }, componentDidMount() { - const {database, measurement, retentionPolicy} = this.props.query; + const {database, measurement} = this.props.query; if (!database || !measurement) { return; } - const {source} = this.context; - const proxySource = source.links.proxy; - showFieldKeys(proxySource, database, measurement, retentionPolicy).then((resp) => { - const {errors, fieldSets} = showFieldKeysParser(resp.data); - if (errors.length) { - // TODO: do something - } + this._getFields(); + }, - this.setState({ - fields: fieldSets[measurement].map((f) => { - return {field: f, funcs: []}; - }), - }); - }); + componentDidUpdate(prevProps) { + const {database, measurement, retentionPolicy} = this.props.query; + const {database: prevDB, measurement: prevMeas, retentionPolicy: prevRP} = prevProps.query; + if (!database || !measurement) { + return; + } + + if (database === prevDB && measurement === prevMeas && retentionPolicy === prevRP) { + return; + } + + this._getFields(); }, handleGroupByTime(groupBy) { @@ -72,7 +73,8 @@ const FieldList = React.createClass({ const hasGroupByTime = query.groupBy.time; return ( - <div> + <div className="query-builder--column"> + <div className="query-builder--column-heading">Fields</div> { hasAggregates ? <div className="qeditor--list-header"> @@ -94,23 +96,43 @@ const FieldList = React.createClass({ return <div className="qeditor--empty">No <strong>Measurement</strong> selected</div>; } - return (<ul className="qeditor--list"> - {this.state.fields.map((fieldFunc) => { - const selectedField = this.props.query.fields.find((f) => f.field === fieldFunc.field); - return ( - <FieldListItem - key={fieldFunc.field} - onToggleField={this.props.onToggleField} - onApplyFuncsToField={this.props.applyFuncsToField} - isSelected={!!selectedField} - fieldFunc={selectedField || fieldFunc} - isKapacitorRule={this.props.isKapacitorRule} - /> - ); - })} - </ul> + return ( + <ul className="qeditor--list"> + {this.state.fields.map((fieldFunc) => { + const selectedField = this.props.query.fields.find((f) => f.field === fieldFunc.field); + return ( + <FieldListItem + key={fieldFunc.field} + onToggleField={this.props.onToggleField} + onApplyFuncsToField={this.props.applyFuncsToField} + isSelected={!!selectedField} + fieldFunc={selectedField || fieldFunc} + isKapacitorRule={this.props.isKapacitorRule} + /> + ); + })} + </ul> ); }, + + _getFields() { + const {database, measurement, retentionPolicy} = this.props.query; + const {source} = this.context; + const proxySource = source.links.proxy; + + showFieldKeys(proxySource, database, measurement, retentionPolicy).then((resp) => { + const {errors, fieldSets} = showFieldKeysParser(resp.data); + if (errors.length) { + // TODO: do something + } + + this.setState({ + fields: fieldSets[measurement].map((f) => { + return {field: f, funcs: []}; + }), + }); + }); + }, }); export default FieldList; diff --git a/ui/src/data_explorer/components/MeasurementList.js b/ui/src/data_explorer/components/MeasurementList.js index 58e7857214..91521eeacc 100644 --- a/ui/src/data_explorer/components/MeasurementList.js +++ b/ui/src/data_explorer/components/MeasurementList.js @@ -34,19 +34,21 @@ const MeasurementList = React.createClass({ return; } - const {source} = this.context; - const proxy = source.links.proxy; - showMeasurements(proxy, this.props.query.database).then((resp) => { - const {errors, measurementSets} = showMeasurementsParser(resp.data); - if (errors.length) { - // TODO: display errors in the UI. - return console.error('InfluxDB returned error(s): ', errors); // eslint-disable-line no-console - } + this._getMeasurements(); + }, - this.setState({ - measurements: measurementSets[0].measurements, - }); - }); + componentDidUpdate(prevProps) { + const {query} = this.props; + + if (!query.database) { + return; + } + + if (prevProps.query.database === query.database) { + return; + } + + this._getMeasurements(); }, handleFilterText(e) { @@ -69,9 +71,10 @@ const MeasurementList = React.createClass({ render() { return ( - <div> + <div className="query-builder--column"> + <div className="query-builder--column-heading">Measurements</div> {this.props.query.database ? <div className="qeditor--list-header"> - <input className="qeditor--filter" ref="filterText" placeholder="Filter Measurements..." type="text" value={this.state.filterText} onChange={this.handleFilterText} onKeyUp={this.handleEscape} /> + <input className="qeditor--filter" ref="filterText" placeholder="Filter" type="text" value={this.state.filterText} onChange={this.handleFilterText} onKeyUp={this.handleEscape} /> <span className="icon search"></span> </div> : null } {this.renderList()} @@ -97,6 +100,23 @@ const MeasurementList = React.createClass({ </ul> ); }, + + _getMeasurements() { + const {source} = this.context; + const proxy = source.links.proxy; + showMeasurements(proxy, this.props.query.database).then((resp) => { + const {errors, measurementSets} = showMeasurementsParser(resp.data); + if (errors.length) { + // TODO: display errors in the UI. + return console.error('InfluxDB returned error(s): ', errors); // eslint-disable-line no-console + } + + this.setState({ + measurements: measurementSets[0].measurements, + }); + }); + }, + }); export default MeasurementList; diff --git a/ui/src/data_explorer/components/MultiTable.js b/ui/src/data_explorer/components/MultiTable.js index aa12fb7fb5..3fe57d310b 100644 --- a/ui/src/data_explorer/components/MultiTable.js +++ b/ui/src/data_explorer/components/MultiTable.js @@ -2,7 +2,14 @@ import React, {PropTypes} from 'react'; import Table from './Table'; import classNames from 'classnames'; -const {bool, string, shape, arrayOf, func} = PropTypes; +const { + arrayOf, + bool, + func, + number, + shape, + string, +} = PropTypes; const MultiTable = React.createClass({ propTypes: { @@ -10,6 +17,7 @@ const MultiTable = React.createClass({ host: arrayOf(string.isRequired).isRequired, text: string.isRequired, })), + height: number, }, getInitialState() { @@ -40,13 +48,14 @@ const MultiTable = React.createClass({ }, renderTable() { + const {height} = this.props; const query = this.getActiveQuery(); const noQuery = !query || !query.text; if (noQuery) { return null; } - return <Table key={query.text} query={query} />; + return <Table key={query.text} query={query} height={height} />; }, renderTabs() { diff --git a/ui/src/data_explorer/components/Panel.js b/ui/src/data_explorer/components/Panel.js deleted file mode 100644 index 50ddf8621f..0000000000 --- a/ui/src/data_explorer/components/Panel.js +++ /dev/null @@ -1,183 +0,0 @@ -import React, {PropTypes} from 'react'; -import classNames from 'classnames'; -import QueryEditor from './QueryEditor'; -import QueryTabItem from './QueryTabItem'; -import RenamePanelModal from './RenamePanelModal'; -import SimpleDropdown from 'src/shared/components/SimpleDropdown'; - -const Panel = React.createClass({ - propTypes: { - panel: PropTypes.shape({ - id: PropTypes.string.isRequired, - }).isRequired, - queries: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - timeRange: PropTypes.shape({ - upper: PropTypes.string, - lower: PropTypes.string, - }).isRequired, - isExpanded: PropTypes.bool.isRequired, - onTogglePanel: PropTypes.func.isRequired, - actions: PropTypes.shape({ - chooseNamespace: PropTypes.func.isRequired, - chooseMeasurement: PropTypes.func.isRequired, - chooseTag: PropTypes.func.isRequired, - groupByTag: PropTypes.func.isRequired, - addQuery: PropTypes.func.isRequired, - deleteQuery: PropTypes.func.isRequired, - toggleField: PropTypes.func.isRequired, - groupByTime: PropTypes.func.isRequired, - toggleTagAcceptance: PropTypes.func.isRequired, - applyFuncsToField: PropTypes.func.isRequired, - deletePanel: PropTypes.func.isRequired, - renamePanel: PropTypes.func.isRequired, - }).isRequired, - setActiveQuery: PropTypes.func.isRequired, - activeQueryID: PropTypes.string, - }, - - handleSetActiveQuery(query) { - this.props.setActiveQuery(query.id); - }, - - handleAddQuery() { - this.props.actions.addQuery(); - }, - - handleAddRawQuery() { - this.props.actions.addQuery({rawText: `SELECT "fields" from "db"."rp"."measurement"`}); - }, - - handleDeleteQuery(query) { - this.props.actions.deleteQuery(query.id); - }, - - handleSelectPanel() { - this.props.onTogglePanel(this.props.panel); - }, - - handleDeletePanel(e) { - e.stopPropagation(); - this.props.actions.deletePanel(this.props.panel.id); - }, - - getActiveQuery() { - const {queries, activeQueryID} = this.props; - const activeQuery = queries.find((query) => query.id === activeQueryID); - const defaultQuery = queries[0]; - - return activeQuery || defaultQuery; - }, - - openRenamePanelModal(e) { - e.stopPropagation(); - $(`#renamePanelModal-${this.props.panel.id}`).modal('show'); // eslint-disable-line no-undef - }, - - handleRename(newName) { - this.props.actions.renamePanel(this.props.panel.id, newName); - }, - - - render() { - const {panel, isExpanded} = this.props; - - return ( - <div className={classNames('panel', {active: isExpanded})}> - <div className="panel--header" onClick={this.handleSelectPanel}> - <div className="panel--name"> - <span className="icon caret-right"></span> - {panel.name || "Graph"} - </div> - <div className="panel--actions"> - {/* <div title="Export Queries to Dashboard" className="panel--action"><span className="icon export"></span></div> */} - <div title="Rename Graph" className="panel--action" onClick={this.openRenamePanelModal}><span className="icon pencil"></span></div> - <div title="Delete Graph" className="panel--action" onClick={this.handleDeletePanel}><span className="icon trash"></span></div> - </div> - </div> - {this.renderQueryTabList()} - {this.renderQueryEditor()} - <RenamePanelModal panel={panel} onConfirm={this.handleRename} /> - </div> - ); - }, - - renderQueryEditor() { - if (!this.props.isExpanded) { - return null; - } - - const {timeRange, actions} = this.props; - const query = this.getActiveQuery(); - - if (!query) { - return ( - <div className="qeditor--empty"> - <h5>This Graph has no Queries</h5> - <br/> - <div className="btn btn-primary" role="button" onClick={this.handleAddQuery}>Add a Query</div> - </div> - ); - } - - return ( - <QueryEditor - timeRange={timeRange} - query={this.getActiveQuery()} - actions={actions} - onAddQuery={this.handleAddQuery} - /> - ); - }, - - renderQueryTabList() { - const {isExpanded, queries} = this.props; - if (!isExpanded) { - return null; - } - return ( - <div className="panel--tabs"> - {queries.map((q) => { - let queryTabText; - if (q.rawText) { - queryTabText = 'InfluxQL'; - } else { - queryTabText = (q.measurement && q.fields.length !== 0) ? `${q.measurement}.${q.fields[0].field}` : 'Query'; - } - return ( - <QueryTabItem - isActive={this.getActiveQuery().id === q.id} - key={q.id} - query={q} - onSelect={this.handleSetActiveQuery} - onDelete={this.handleDeleteQuery} - queryTabText={queryTabText} - /> - ); - })} - - {this.renderAddQuery()} - </div> - ); - }, - - onChoose(item) { - switch (item.text) { - case 'Query Builder': - this.handleAddQuery(); - break; - case 'InfluxQL': - this.handleAddRawQuery(); - break; - } - }, - - renderAddQuery() { - return ( - <SimpleDropdown onChoose={this.onChoose} items={[{text: 'Query Builder'}, {text: 'InfluxQL'}]} className="panel--tab-new"> - <span className="icon plus"></span> - </SimpleDropdown> - ); - }, -}); - -export default Panel; diff --git a/ui/src/data_explorer/components/PanelBuilder.js b/ui/src/data_explorer/components/PanelBuilder.js deleted file mode 100644 index e5b7890707..0000000000 --- a/ui/src/data_explorer/components/PanelBuilder.js +++ /dev/null @@ -1,63 +0,0 @@ -import React, {PropTypes} from 'react'; -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import PanelList from './PanelList'; -import * as viewActions from '../actions/view'; - -const {string, func} = PropTypes; -const PanelBuilder = React.createClass({ - propTypes: { - width: string, - actions: PropTypes.shape({ - activatePanel: func.isRequired, - createPanel: func.isRequired, - deleteQuery: func.isRequired, - addQuery: func.isRequired, - editRawText: func.isRequired, - chooseNamespace: func.isRequired, - chooseMeasurement: func.isRequired, - toggleField: func.isRequired, - groupByTime: func.isRequired, - applyFuncsToField: func.isRequired, - chooseTag: func.isRequired, - groupByTag: func.isRequired, - toggleTagAcceptance: func.isRequired, - deletePanel: func.isRequired, - }).isRequired, - setActiveQuery: func.isRequired, - activePanelID: string, - activeQueryID: string, - }, - - handleCreateExplorer() { - this.props.actions.createPanel(); - }, - - render() { - const {width, actions, setActiveQuery, activePanelID, activeQueryID} = this.props; - - return ( - <div className="panel-builder" style={{width}}> - <div className="btn btn-block btn-primary" onClick={this.handleCreateExplorer}><span className="icon graphline"></span> Create Graph</div> - <PanelList - actions={actions} - setActiveQuery={setActiveQuery} - activePanelID={activePanelID} - activeQueryID={activeQueryID} - /> - </div> - ); - }, -}); - -function mapStateToProps() { - return {}; -} - -function mapDispatchToProps(dispatch) { - return { - actions: bindActionCreators(viewActions, dispatch), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(PanelBuilder); diff --git a/ui/src/data_explorer/components/PanelList.js b/ui/src/data_explorer/components/PanelList.js deleted file mode 100644 index c3d365e2d6..0000000000 --- a/ui/src/data_explorer/components/PanelList.js +++ /dev/null @@ -1,76 +0,0 @@ -import React, {PropTypes} from 'react'; -import {connect} from 'react-redux'; -import _ from 'lodash'; - -import Panel from './Panel'; - -const {func, string, shape} = PropTypes; -const PanelList = React.createClass({ - propTypes: { - timeRange: shape({ - upper: string, - lower: string, - }).isRequired, - panels: shape({}).isRequired, - queryConfigs: PropTypes.shape({}), - actions: shape({ - activatePanel: func.isRequired, - deleteQuery: func.isRequired, - addQuery: func.isRequired, - }).isRequired, - setActiveQuery: func.isRequired, - activePanelID: string, - activeQueryID: string, - }, - - handleTogglePanel(panel) { - const panelID = panel.id === this.props.activePanelID ? null : panel.id; - this.props.actions.activatePanel(panelID); - - // Reset the activeQueryID when toggling Exporations - this.props.setActiveQuery(null); - }, - - render() { - const {actions, panels, timeRange, queryConfigs, setActiveQuery, activeQueryID, activePanelID} = this.props; - - return ( - <div> - {Object.keys(panels).map((panelID) => { - const panel = panels[panelID]; - const queries = panel.queryIds.map((configId) => queryConfigs[configId]); - const deleteQueryFromPanel = _.partial(actions.deleteQuery, panelID); - const addQueryToPanel = _.partial(actions.addQuery, panelID); - const allActions = Object.assign({}, actions, { - addQuery: addQueryToPanel, - deleteQuery: deleteQueryFromPanel, - }); - - return ( - <Panel - key={panelID} - panel={panel} - queries={queries} - timeRange={timeRange} - onTogglePanel={this.handleTogglePanel} - setActiveQuery={setActiveQuery} - isExpanded={panelID === activePanelID} - actions={allActions} - activeQueryID={activeQueryID} - /> - ); - })} - </div> - ); - }, -}); - -function mapStateToProps(state) { - return { - timeRange: state.timeRange, - panels: state.panels, - queryConfigs: state.queryConfigs, - }; -} - -export default connect(mapStateToProps)(PanelList); diff --git a/ui/src/data_explorer/components/QueryBuilder.js b/ui/src/data_explorer/components/QueryBuilder.js new file mode 100644 index 0000000000..97b29958ac --- /dev/null +++ b/ui/src/data_explorer/components/QueryBuilder.js @@ -0,0 +1,160 @@ +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, + shape, + string, +} = PropTypes; + +const QueryBuilder = React.createClass({ + propTypes: { + queries: arrayOf(shape({})).isRequired, + timeRange: shape({ + upper: string, + lower: string, + }).isRequired, + actions: shape({ + chooseNamespace: func.isRequired, + chooseMeasurement: func.isRequired, + chooseTag: func.isRequired, + groupByTag: func.isRequired, + addQuery: func.isRequired, + deleteQuery: func.isRequired, + toggleField: func.isRequired, + groupByTime: func.isRequired, + toggleTagAcceptance: func.isRequired, + applyFuncsToField: func.isRequired, + }).isRequired, + height: string, + top: string, + setActiveQuery: func.isRequired, + activeQueryID: string, + }, + + handleSetActiveQuery(query) { + this.props.setActiveQuery(query.id); + }, + + handleAddQuery() { + this.props.actions.addQuery(); + }, + + handleAddRawQuery() { + this.props.actions.addQuery({rawText: `SELECT "fields" from "db"."rp"."measurement"`}); + }, + + handleDeleteQuery(query) { + this.props.actions.deleteQuery(query.id); + }, + + getActiveQuery() { + const {queries, activeQueryID} = this.props; + const activeQuery = queries.find((query) => query.id === activeQueryID); + const defaultQuery = queries[0]; + + return activeQuery || defaultQuery; + }, + + render() { + const {height, top} = this.props; + return ( + <div className="query-builder" style={{height, top}}> + {this.renderQueryTabList()} + {this.renderQueryEditor()} + </div> + ); + }, + + renderQueryEditor() { + const {timeRange, actions} = this.props; + const query = this.getActiveQuery(); + + if (!query) { + return ( + <div className="qeditor--empty"> + <h5>This Graph has no Queries</h5> + <br/> + <div className="btn btn-primary" role="button" onClick={this.handleAddQuery}>Add a Query</div> + </div> + ); + } + + return ( + <QueryEditor + timeRange={timeRange} + query={this.getActiveQuery()} + actions={actions} + onAddQuery={this.handleAddQuery} + /> + ); + }, + + renderQueryTabList() { + const {queries} = this.props; + return ( + <div className="query-builder--tabs"> + <div className="query-builder--tabs-heading"> + <h1>Queries</h1> + {this.renderAddQuery()} + </div> + {queries.map((q, i) => { + let queryTabText; + if (q.rawText) { + queryTabText = 'InfluxQL'; + } else { + queryTabText = (q.measurement && q.fields.length !== 0) ? `${q.measurement}.${q.fields[0].field}` : 'Query'; + } + return ( + <QueryTabItem + isActive={this.getActiveQuery().id === q.id} + key={q.id + i} + query={q} + onSelect={this.handleSetActiveQuery} + onDelete={this.handleDeleteQuery} + queryTabText={queryTabText} + /> + ); + })} + </div> + ); + }, + + onChoose(item) { + switch (item.text) { + case 'Query Builder': + this.handleAddQuery(); + break; + case 'InfluxQL': + this.handleAddRawQuery(); + break; + } + }, + + renderAddQuery() { + return ( + <SimpleDropdown onChoose={this.onChoose} items={[{text: 'Query Builder'}, {text: 'InfluxQL'}]} className="panel--tab-new"> + <span className="icon plus"></span> + </SimpleDropdown> + ); + }, +}); + +function mapStateToProps() { + return {}; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators(viewActions, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(QueryBuilder); diff --git a/ui/src/data_explorer/components/QueryEditor.js b/ui/src/data_explorer/components/QueryEditor.js index d3bcf4d7e6..1d8046c51b 100644 --- a/ui/src/data_explorer/components/QueryEditor.js +++ b/ui/src/data_explorer/components/QueryEditor.js @@ -1,6 +1,4 @@ import React, {PropTypes} from 'react'; -import classNames from 'classnames'; -import _ from 'lodash'; import selectStatement from '../utils/influxql/select'; import DatabaseList from './DatabaseList'; @@ -9,12 +7,11 @@ import FieldList from './FieldList'; import TagList from './TagList'; import RawQueryEditor from './RawQueryEditor'; -const DB_TAB = 'databases'; -const MEASUREMENTS_TAB = 'measurments'; -const FIELDS_TAB = 'fields'; -const TAGS_TAB = 'tags'; - -const {string, shape, func} = PropTypes; +const { + string, + shape, + func, +} = PropTypes; const QueryEditor = React.createClass({ propTypes: { query: shape({ @@ -38,29 +35,17 @@ const QueryEditor = React.createClass({ getInitialState() { return { - activeTab: DB_TAB, database: null, measurement: null, }; }, - componentWillReceiveProps(nextProps) { - const changingQueries = this.props.query.id !== nextProps.query.id; - if (changingQueries) { - this.setState({activeTab: DB_TAB}); - } - }, - handleChooseNamespace(namespace) { this.props.actions.chooseNamespace(this.props.query.id, namespace); - - this.setState({activeTab: MEASUREMENTS_TAB}); }, handleChooseMeasurement(measurement) { this.props.actions.chooseMeasurement(this.props.query.id, measurement); - - this.setState({activeTab: FIELDS_TAB}); }, handleToggleField(field) { @@ -91,15 +76,13 @@ const QueryEditor = React.createClass({ this.props.actions.editRawText(this.props.query.id, text); }, - handleClickTab(tab) { - this.setState({activeTab: tab}); - }, - render() { return ( - <div className="panel--tab-contents"> - {this.renderQuery()} - {this.renderLists()} + <div className="query-builder--tab-contents"> + <div> + {this.renderQuery()} + {this.renderLists()} + </div> </div> ); }, @@ -110,7 +93,7 @@ const QueryEditor = React.createClass({ if (!query.rawText) { return ( - <div className="qeditor--query-preview"> + <div className="query-builder--query-preview"> <pre><code>{statement}</code></pre> </div> ); @@ -120,60 +103,32 @@ const QueryEditor = React.createClass({ }, renderLists() { - const {activeTab} = this.state; - return ( - <div> - <div className="qeditor--tabs"> - <div className="qeditor--tabs-heading">Schema Explorer</div> - <div onClick={_.wrap(DB_TAB, this.handleClickTab)} className={classNames("qeditor--tab", {active: activeTab === DB_TAB})}>Databases</div> - <div onClick={_.wrap(MEASUREMENTS_TAB, this.handleClickTab)} className={classNames("qeditor--tab", {active: activeTab === MEASUREMENTS_TAB})}>Measurements</div> - <div onClick={_.wrap(FIELDS_TAB, this.handleClickTab)} className={classNames("qeditor--tab", {active: activeTab === FIELDS_TAB})}>Fields</div> - <div onClick={_.wrap(TAGS_TAB, this.handleClickTab)} className={classNames("qeditor--tab", {active: activeTab === TAGS_TAB})}>Tags</div> - </div> - {this.renderList()} - </div> - ); - }, - - renderList() { const {query} = this.props; - switch (this.state.activeTab) { - case DB_TAB: - return ( - <DatabaseList - query={query} - onChooseNamespace={this.handleChooseNamespace} - /> - ); - case MEASUREMENTS_TAB: - return ( - <MeasurementList - query={query} - onChooseMeasurement={this.handleChooseMeasurement} - /> - ); - case FIELDS_TAB: - return ( - <FieldList - query={query} - onToggleField={this.handleToggleField} - onGroupByTime={this.handleGroupByTime} - applyFuncsToField={this.handleApplyFuncsToField} - /> - ); - case TAGS_TAB: - return ( - <TagList - query={query} - onChooseTag={this.handleChooseTag} - onGroupByTag={this.handleGroupByTag} - onToggleTagAcceptance={this.handleToggleTagAcceptance} - /> - ); - default: - return <ul className="qeditor--list"></ul>; - } + return ( + <div className="query-builder--columns"> + <DatabaseList + query={query} + onChooseNamespace={this.handleChooseNamespace} + /> + <MeasurementList + query={query} + onChooseMeasurement={this.handleChooseMeasurement} + /> + <FieldList + query={query} + onToggleField={this.handleToggleField} + onGroupByTime={this.handleGroupByTime} + applyFuncsToField={this.handleApplyFuncsToField} + /> + <TagList + query={query} + onChooseTag={this.handleChooseTag} + onGroupByTag={this.handleGroupByTag} + onToggleTagAcceptance={this.handleToggleTagAcceptance} + /> + </div> + ); }, }); diff --git a/ui/src/data_explorer/components/QueryTabItem.js b/ui/src/data_explorer/components/QueryTabItem.js index c3ac85fe34..e8edaad041 100644 --- a/ui/src/data_explorer/components/QueryTabItem.js +++ b/ui/src/data_explorer/components/QueryTabItem.js @@ -23,9 +23,9 @@ const QueryTabItem = React.createClass({ render() { return ( - <div className={classNames('panel--tab', {active: this.props.isActive})} onClick={this.handleSelect}> - <span className="panel--tab-label">{this.props.queryTabText}</span> - <span className="panel--tab-delete" onClick={this.handleDelete}></span> + <div className={classNames('query-builder--tab', {active: this.props.isActive})} onClick={this.handleSelect}> + <span className="query-builder--tab-label">{this.props.queryTabText}</span> + <span className="query-builder--tab-delete" onClick={this.handleDelete}></span> </div> ); }, diff --git a/ui/src/data_explorer/components/RenamePanelModal.js b/ui/src/data_explorer/components/RenamePanelModal.js deleted file mode 100644 index bffa495cca..0000000000 --- a/ui/src/data_explorer/components/RenamePanelModal.js +++ /dev/null @@ -1,67 +0,0 @@ -import React, {PropTypes} from 'react'; - -const RenamePanelModal = React.createClass({ - propTypes: { - onConfirm: PropTypes.func.isRequired, - panel: PropTypes.shape({ - id: PropTypes.string.isRequired, - }), - }, - - getInitialState() { - return {error: null}; - }, - - componentDidMount() { - this.refs.name.focus(); - }, - - render() { - const {panel} = this.props; - - return ( - <div className="modal fade in" id={`renamePanelModal-${panel.id}`} tabIndex="-1" role="dialog"> - <div className="modal-dialog"> - <div className="modal-content"> - <div className="modal-header"> - <button type="button" className="close" data-dismiss="modal" aria-label="Close"> - <span aria-hidden="true">×</span> - </button> - <h4 className="modal-title">Rename Panel</h4> - </div> - <div className="modal-body"> - {this.state.error ? - <div className="alert alert-danger" role="alert">{this.state.error}</div> - : null} - <div className="form-grid padding-top"> - <div className="form-group col-md-8 col-md-offset-2"> - <input ref="name" name="renameExplorer" type="text" placeholder={panel.name} className="form-control input-lg" id="renameExplorer" required={true} /> - </div> - </div> - </div> - <div className="modal-footer"> - <button className="btn btn-info" data-dismiss="modal">Cancel</button> - <button onClick={this.handleConfirm} className="btn btn-success">Rename</button> - </div> - </div> - </div> - </div> - ); - }, - - handleConfirm() { - const name = this.refs.name.value; - - if (name === '') { - this.setState({error: "Name can't be blank"}); - return; - } - - $(`#renamePanelModal-${this.props.panel.id}`).modal('hide'); // eslint-disable-line no-undef - this.refs.name.value = ''; - this.setState({error: null}); - this.props.onConfirm(name); - }, -}); - -export default RenamePanelModal; diff --git a/ui/src/data_explorer/components/Table.js b/ui/src/data_explorer/components/Table.js index 12f71084f9..e236cf647a 100644 --- a/ui/src/data_explorer/components/Table.js +++ b/ui/src/data_explorer/components/Table.js @@ -33,6 +33,7 @@ const ChronoTable = React.createClass({ text: string.isRequired, }), containerWidth: number.isRequired, + height: number, }, getInitialState() { @@ -45,6 +46,12 @@ const ChronoTable = React.createClass({ }; }, + getDefaultProps() { + return { + height: 600, + }; + }, + fetchCellData(query) { this.setState({isLoading: true}); // second param is db, we want to leave this blank @@ -81,30 +88,33 @@ const ChronoTable = React.createClass({ // Table data as a list of array. render() { - const {containerWidth} = this.props; + const {containerWidth, height} = this.props; const {cellData, columnWidths, isLoading} = this.state; const {columns, values} = cellData; - const ownerHeight = 300; + // adjust height to proper value by subtracting the heights of the UI around it + // tab height, graph-container vertical padding, graph-heading height, multitable-header height + const stylePixelOffset = 136; + const rowHeight = 34; - const height = 300; const width = 200; - const headerHeight = 40; + const headerHeight = 30; const minWidth = 70; + const styleAdjustedHeight = height - stylePixelOffset; if (!isLoading && !values.length) { - return <div>Your query returned no data</div>; + return <div className="generic-empty-state">Your query returned no data</div>; } return ( <Table onColumnResizeEndCallback={this.handleColumnResize} isColumnResizing={false} - ownerHeight={ownerHeight} rowHeight={rowHeight} rowsCount={values.length} width={containerWidth} - height={height} + ownerHeight={styleAdjustedHeight} + height={styleAdjustedHeight} headerHeight={headerHeight}> {columns.map((columnName, colIndex) => { return ( diff --git a/ui/src/data_explorer/components/TagList.js b/ui/src/data_explorer/components/TagList.js index 6667064174..066b0d00f5 100644 --- a/ui/src/data_explorer/components/TagList.js +++ b/ui/src/data_explorer/components/TagList.js @@ -36,14 +36,11 @@ const TagList = React.createClass({ }; }, - componentDidMount() { + _getTags() { const {database, measurement, retentionPolicy} = this.props.query; const {source} = this.context; - if (!database || !measurement || !retentionPolicy) { - return; - } - const sourceProxy = source.links.proxy; + showTagKeys({source: sourceProxy, database, retentionPolicy, measurement}).then((resp) => { const {errors, tagKeys} = showTagKeysParser(resp.data); if (errors.length) { @@ -61,6 +58,29 @@ const TagList = React.createClass({ }); }, + componentDidMount() { + const {database, measurement, retentionPolicy} = this.props.query; + if (!database || !measurement || !retentionPolicy) { + return; + } + + this._getTags(); + }, + + componentDidUpdate(prevProps) { + const {database, measurement, retentionPolicy} = this.props.query; + const {database: prevDB, measurement: prevMeas, retentionPolicy: prevRP} = prevProps.query; + if (!database || !measurement || !retentionPolicy) { + return; + } + + if (database === prevDB && measurement === prevMeas && retentionPolicy === prevRP) { + return; + } + + this._getTags(); + }, + handleAcceptReject(e) { e.stopPropagation(); this.props.onToggleTagAcceptance(); @@ -70,11 +90,12 @@ const TagList = React.createClass({ const {query} = this.props; return ( - <div> + <div className="query-builder--column"> + <div className="query-builder--column-heading">Tags</div> {(!query.database || !query.measurement || !query.retentionPolicy) ? null : <div className="qeditor--list-header"> <div className="toggle toggle-sm"> - <div onClick={this.handleAcceptReject} className={cx("toggle-btn", {active: query.areTagsAccepted})}>Accept</div> - <div onClick={this.handleAcceptReject} className={cx("toggle-btn", {active: !query.areTagsAccepted})}>Reject</div> + <div onClick={this.handleAcceptReject} className={cx("toggle-btn", {active: query.areTagsAccepted})}>=</div> + <div onClick={this.handleAcceptReject} className={cx("toggle-btn", {active: !query.areTagsAccepted})}>!=</div> </div> </div>} {this.renderList()} diff --git a/ui/src/data_explorer/components/TagListItem.js b/ui/src/data_explorer/components/TagListItem.js index 5970fd6ce3..4d2c3d6aa0 100644 --- a/ui/src/data_explorer/components/TagListItem.js +++ b/ui/src/data_explorer/components/TagListItem.js @@ -81,7 +81,10 @@ const TagListItem = React.createClass({ }, render() { - const itemClasses = classNames("qeditor--list-item tag-list__item", {open: this.state.isOpen}); + const {tagKey, tagValues} = this.props; + const {isOpen} = this.state; + const itemClasses = classNames("qeditor--list-item tag-list__item", {open: isOpen}); + return ( <div> <li className={itemClasses} onClick={this.handleClickKey}> @@ -89,12 +92,15 @@ const TagListItem = React.createClass({ <div className="tag-list__caret"> <div className="icon caret-right"></div> </div> - {this.props.tagKey} - <span className="badge">{this.props.tagValues.length}</span> + {tagKey} + <span className="badge">{tagValues.length}</span> + </div> + <div + className={classNames('btn btn-info btn-xs tag-list__group-by', {active: this.props.isUsingGroupBy})} + onClick={this.handleGroupBy}>Group By {tagKey} </div> - <div className={classNames('btn btn-info btn-xs tag-list__group-by', {active: this.props.isUsingGroupBy})} onClick={this.handleGroupBy}>Group By</div> </li> - {this.state.isOpen ? this.renderTagValues() : null} + {isOpen ? this.renderTagValues() : null} </div> ); }, diff --git a/ui/src/data_explorer/components/Visualization.js b/ui/src/data_explorer/components/Visualization.js index 027a171c1a..d2f94d99a3 100644 --- a/ui/src/data_explorer/components/Visualization.js +++ b/ui/src/data_explorer/components/Visualization.js @@ -6,16 +6,24 @@ import LineGraph from 'shared/components/LineGraph'; import MultiTable from './MultiTable'; const RefreshingLineGraph = AutoRefresh(LineGraph); +const { + arrayOf, + number, + shape, + string, +} = PropTypes; + const Visualization = React.createClass({ propTypes: { - timeRange: PropTypes.shape({ - upper: PropTypes.string, - lower: PropTypes.string, + timeRange: shape({ + upper: string, + lower: string, }).isRequired, - queryConfigs: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - isActive: PropTypes.bool.isRequired, - name: PropTypes.string, - activeQueryIndex: PropTypes.number, + queryConfigs: arrayOf(shape({})).isRequired, + name: string, + activeQueryIndex: number, + height: string, + heightPixels: number, }, contextTypes: { @@ -32,20 +40,12 @@ const Visualization = React.createClass({ }; }, - componentDidUpdate() { - if (this.props.isActive) { - this.panel.scrollIntoView(); - // scrollIntoView scrolls slightly *too* far, so this adds some top offset. - this.panel.parentNode.scrollTop -= 10; - } - }, - handleToggleView() { this.setState({isGraphInView: !this.state.isGraphInView}); }, render() { - const {queryConfigs, timeRange, isActive, name, activeQueryIndex} = this.props; + const {queryConfigs, timeRange, activeQueryIndex, height, heightPixels} = this.props; const {source} = this.context; const proxyLink = source.links.proxy; @@ -61,7 +61,7 @@ const Visualization = React.createClass({ const isInDataExplorer = true; return ( - <div ref={(p) => this.panel = p} className={classNames("graph", {active: isActive})}> + <div className={classNames("graph", {active: true})} style={{height}}> <div className="graph-heading"> <div className="graph-title"> {name || "Graph"} @@ -73,7 +73,7 @@ const Visualization = React.createClass({ </ul> </div> </div> - <div className="graph-container"> + <div className={classNames("", {"graph-container": isGraphInView, "table-container": !isGraphInView})}> {isGraphInView ? ( <RefreshingLineGraph queries={queries} @@ -81,7 +81,7 @@ const Visualization = React.createClass({ activeQueryIndex={activeQueryIndex} isInDataExplorer={isInDataExplorer} /> - ) : <MultiTable queries={queries} />} + ) : <MultiTable queries={queries} height={heightPixels} />} </div> </div> ); diff --git a/ui/src/data_explorer/components/Visualizations.js b/ui/src/data_explorer/components/Visualizations.js deleted file mode 100644 index aa577b0d6e..0000000000 --- a/ui/src/data_explorer/components/Visualizations.js +++ /dev/null @@ -1,61 +0,0 @@ -import React, {PropTypes} from 'react'; -import {connect} from 'react-redux'; -import Visualization from './Visualization'; - -const {shape, string} = PropTypes; - -const Visualizations = React.createClass({ - propTypes: { - timeRange: shape({ - upper: string, - lower: string, - }).isRequired, - panels: shape({}).isRequired, - queryConfigs: shape({}).isRequired, - width: string, - activePanelID: string, - activeQueryID: string, - }, - - render() { - const {panels, queryConfigs, timeRange, width, activePanelID} = this.props; - - const visualizations = Object.keys(panels).map((panelID) => { - const panel = panels[panelID]; - const queries = panel.queryIds.map((id) => queryConfigs[id]); - const isActive = panelID === activePanelID; - - return <Visualization activeQueryIndex={this.getActiveQueryIndex(panelID)} name={panel.name} key={panelID} queryConfigs={queries} timeRange={timeRange} isActive={isActive} />; - }); - - return ( - <div className="panels" style={{width}}> - {visualizations} - </div> - ); - }, - - getActiveQueryIndex(panelID) { - const {activeQueryID, activePanelID, panels} = this.props; - const isPanelActive = panelID === activePanelID; - - if (!isPanelActive) { - return -1; - } - - if (activeQueryID === null) { - return 0; - } - - return panels[panelID].queryIds.indexOf(activeQueryID); - }, -}); - -function mapStateToProps(state) { - return { - panels: state.panels, - queryConfigs: state.queryConfigs, - }; -} - -export default connect(mapStateToProps)(Visualizations); diff --git a/ui/src/data_explorer/containers/DataExplorer.js b/ui/src/data_explorer/containers/DataExplorer.js index 2c7d37f4c9..1b56a7efba 100644 --- a/ui/src/data_explorer/containers/DataExplorer.js +++ b/ui/src/data_explorer/containers/DataExplorer.js @@ -1,9 +1,9 @@ import React, {PropTypes} from 'react'; import {connect} from 'react-redux'; -import PanelBuilder from '../components/PanelBuilder'; -import Visualizations from '../components/Visualizations'; +import QueryBuilder from '../components/QueryBuilder'; +import Visualization from '../components/Visualization'; import Header from '../containers/Header'; -import ResizeContainer from 'shared/components/ResizeContainer'; +import ResizeContainer from 'src/shared/components/ResizeContainer'; import { setTimeRange as setTimeRangeAction, @@ -23,11 +23,11 @@ const DataExplorer = React.createClass({ self: string.isRequired, }).isRequired, }).isRequired, + queryConfigs: PropTypes.shape({}), timeRange: shape({ upper: string, lower: string, }).isRequired, - activePanel: string, setTimeRange: func.isRequired, }, @@ -55,7 +55,9 @@ const DataExplorer = React.createClass({ }, render() { - const {timeRange, setTimeRange, activePanel} = this.props; + const {timeRange, setTimeRange, queryConfigs} = this.props; + const {activeQueryID} = this.state; + const queries = Object.keys(queryConfigs).map((q) => queryConfigs[q]); return ( <div className="data-explorer"> @@ -64,16 +66,17 @@ const DataExplorer = React.createClass({ timeRange={timeRange} /> <ResizeContainer> - <PanelBuilder + <Visualization timeRange={timeRange} - activePanelID={activePanel} + queryConfigs={queries} activeQueryID={this.state.activeQueryID} - setActiveQuery={this.handleSetActiveQuery} + activeQueryIndex={0} /> - <Visualizations + <QueryBuilder + queries={queries} timeRange={timeRange} - activePanelID={activePanel} - activeQueryID={this.state.activeQueryID} + setActiveQuery={this.handleSetActiveQuery} + activeQueryID={activeQueryID} /> </ResizeContainer> </div> @@ -82,11 +85,11 @@ const DataExplorer = React.createClass({ }); function mapStateToProps(state) { - const {timeRange, dataExplorerUI} = state; + const {timeRange, queryConfigs} = state; return { timeRange, - activePanel: dataExplorerUI.activePanel, + queryConfigs, }; } diff --git a/ui/src/data_explorer/reducers/dataExplorerUI.js b/ui/src/data_explorer/reducers/dataExplorerUI.js deleted file mode 100644 index 2140a9eba7..0000000000 --- a/ui/src/data_explorer/reducers/dataExplorerUI.js +++ /dev/null @@ -1,11 +0,0 @@ -export default function dataExplorerUI(state = {}, action) { - switch (action.type) { - case 'ACTIVATE_PANEL': - case 'CREATE_PANEL': { - const {panelID} = action.payload; - return {...state, activePanel: panelID}; - } - } - - return state; -} diff --git a/ui/src/data_explorer/reducers/index.js b/ui/src/data_explorer/reducers/index.js index 4e4483f3bf..4a0171f2a7 100644 --- a/ui/src/data_explorer/reducers/index.js +++ b/ui/src/data_explorer/reducers/index.js @@ -1,11 +1,7 @@ import queryConfigs from './queryConfigs'; -import panels from './panels'; import timeRange from './timeRange'; -import dataExplorerUI from './dataExplorerUI'; export { queryConfigs, - panels, timeRange, - dataExplorerUI, }; diff --git a/ui/src/data_explorer/reducers/panels.js b/ui/src/data_explorer/reducers/panels.js deleted file mode 100644 index e04c585813..0000000000 --- a/ui/src/data_explorer/reducers/panels.js +++ /dev/null @@ -1,51 +0,0 @@ -import update from 'react-addons-update'; - -export default function panels(state = {}, action) { - switch (action.type) { - case 'CREATE_PANEL': { - const {panelID, queryID} = action.payload; - return { - ...state, - [panelID]: {id: panelID, queryIds: [queryID]}, - }; - } - - case 'RENAME_PANEL': { - const {panelId, name} = action.payload; - return update(state, { - [panelId]: { - name: {$set: name}, - }, - }); - } - - case 'DELETE_PANEL': { - const {panelId} = action.payload; - return update(state, {$apply: (p) => { - const panelsCopy = Object.assign({}, p); - delete panelsCopy[panelId]; - return panelsCopy; - }}); - } - - case 'ADD_QUERY': { - const {panelId, queryId} = action.payload; - return update(state, { - [panelId]: { - queryIds: {$push: [queryId]}, - }, - }); - } - - case 'DELETE_QUERY': { - const {panelId, queryId} = action.payload; - return update(state, { - [panelId]: { - queryIds: {$set: state[panelId].queryIds.filter((id) => id !== queryId)}, - }, - }); - } - } - - return state; -} diff --git a/ui/src/data_explorer/reducers/queryConfigs.js b/ui/src/data_explorer/reducers/queryConfigs.js index 82c14d0ec1..370923e4bc 100644 --- a/ui/src/data_explorer/reducers/queryConfigs.js +++ b/ui/src/data_explorer/reducers/queryConfigs.js @@ -46,7 +46,6 @@ export default function queryConfigs(state = {}, action) { return nextState; } - case 'CREATE_PANEL': case 'ADD_KAPACITOR_QUERY': case 'ADD_QUERY': { const {queryID, options} = action.payload; @@ -94,9 +93,9 @@ export default function queryConfigs(state = {}, action) { } case 'DELETE_QUERY': { - const {queryId} = action.payload; + const {queryID} = action.payload; const nextState = update(state, {$apply: (configs) => { - delete configs[queryId]; + delete configs[queryID]; return configs; }}); diff --git a/ui/src/localStorage.js b/ui/src/localStorage.js index 2ab3593653..64c2622d82 100644 --- a/ui/src/localStorage.js +++ b/ui/src/localStorage.js @@ -19,13 +19,11 @@ export const loadLocalStorage = () => { } }; -export const saveToLocalStorage = ({panels, queryConfigs, timeRange, dataExplorerUI}) => { +export const saveToLocalStorage = ({queryConfigs, timeRange}) => { try { window.localStorage.setItem('state', JSON.stringify({ - panels, queryConfigs, timeRange, - dataExplorerUI, })); } catch (err) { console.error('Unable to save data explorer: ', JSON.parse(err)); // eslint-disable-line no-console diff --git a/ui/src/shared/components/LineGraph.js b/ui/src/shared/components/LineGraph.js index 20bba644cc..ea36c088d5 100644 --- a/ui/src/shared/components/LineGraph.js +++ b/ui/src/shared/components/LineGraph.js @@ -79,7 +79,6 @@ export default React.createClass({ labels, connectSeparatedPoints: true, labelsKMB: true, - height: 300, axisLineColor: '#383846', gridLineColor: '#383846', title, diff --git a/ui/src/shared/components/ResizeContainer.js b/ui/src/shared/components/ResizeContainer.js index 223402d582..5b30257bac 100644 --- a/ui/src/shared/components/ResizeContainer.js +++ b/ui/src/shared/components/ResizeContainer.js @@ -9,8 +9,8 @@ const ResizeContainer = React.createClass({ getInitialState() { return { - leftWidth: '34%', - rightWidth: '66%', + topHeight: '60%', + bottomHeight: '40%', isDragging: false, }; }, @@ -32,40 +32,43 @@ const ResizeContainer = React.createClass({ return; } - const appWidth = parseInt(getComputedStyle(this.refs.resizeContainer).width, 10); - // handleOffSet moves the resize handle as many pixels as the side bar is taking up. - const handleOffSet = window.innerWidth - appWidth; + const appHeight = parseInt(getComputedStyle(this.refs.resizeContainer).height, 10); + // headingOffset moves the resize handle as many pixels as the page-heading is taking up. + const headingOffset = window.innerHeight - appHeight; const turnToPercent = 100; - const newLeftPanelPercent = Math.ceil(((e.pageX - handleOffSet) / (appWidth)) * turnToPercent); - const newRightPanelPercent = (turnToPercent - newLeftPanelPercent); + const newTopPanelPercent = Math.ceil(((e.pageY - headingOffset) / (appHeight)) * turnToPercent); + const newBottomPanelPercent = (turnToPercent - newTopPanelPercent); // Don't trigger a resize unless the change in size is greater than minResizePercentage const minResizePercentage = 0.5; - if (Math.abs(newLeftPanelPercent - parseFloat(this.state.leftWidth)) < minResizePercentage) { + if (Math.abs(newTopPanelPercent - parseFloat(this.state.topHeight)) < minResizePercentage) { return; } // Don't trigger a resize if the new sizes are too small - const minLeftPanelWidth = 371; - const minRightPanelWidth = 389; - if (((newLeftPanelPercent / turnToPercent) * appWidth) < minLeftPanelWidth || ((newRightPanelPercent / turnToPercent) * appWidth) < minRightPanelWidth) { + const minTopPanelHeight = 200; + const minBottomPanelHeight = 100; + const topHeightPixels = ((newTopPanelPercent / turnToPercent) * appHeight); + const bottomHeightPixels = ((newBottomPanelPercent / turnToPercent) * appHeight); + + if (topHeightPixels < minTopPanelHeight || bottomHeightPixels < minBottomPanelHeight) { return; } - this.setState({leftWidth: `${(newLeftPanelPercent)}%`, rightWidth: `${(newRightPanelPercent)}%`}); + this.setState({topHeight: `${(newTopPanelPercent)}%`, bottomHeight: `${(newBottomPanelPercent)}%`, topHeightPixels}); }, render() { - const {leftWidth, rightWidth, isDragging} = this.state; - const left = React.cloneElement(this.props.children[0], {width: leftWidth}); - const right = React.cloneElement(this.props.children[1], {width: rightWidth}); - const handle = <ResizeHandle isDragging={isDragging} onHandleStartDrag={this.handleStartDrag} />; + 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} />; return ( <div className="resize-container page-contents" onMouseLeave={this.handleMouseLeave} onMouseUp={this.handleStopDrag} onMouseMove={this.handleDrag} ref="resizeContainer" > - {left} + {top} {handle} - {right} + {bottom} </div> ); }, diff --git a/ui/src/shared/components/ResizeHandle.js b/ui/src/shared/components/ResizeHandle.js index e1c0a54ecf..da51e23584 100644 --- a/ui/src/shared/components/ResizeHandle.js +++ b/ui/src/shared/components/ResizeHandle.js @@ -1,15 +1,24 @@ import React from 'react'; import cx from 'classnames'; -const {func, bool} = React.PropTypes; +const {func, bool, string} = React.PropTypes; const ResizeHandle = React.createClass({ propTypes: { onHandleStartDrag: func.isRequired, isDragging: bool.isRequired, + top: string, }, render() { - return <div className={cx("resizer__handle", {dragging: this.props.isDragging})} ref="resizer" onMouseDown={this.props.onHandleStartDrag} />; + const {isDragging, onHandleStartDrag, top} = this.props; + + return ( + <div + className={cx("resizer__handle", {dragging: isDragging})} + onMouseDown={onHandleStartDrag} + style={{top}} + /> + ); }, }); diff --git a/ui/src/style/components/dygraphs.scss b/ui/src/style/components/dygraphs.scss index 16301b2257..991c3a9e67 100644 --- a/ui/src/style/components/dygraphs.scss +++ b/ui/src/style/components/dygraphs.scss @@ -82,21 +82,25 @@ .dygraph-axis-label { color: $g11-sidewalk !important; font-weight: 500 !important; + user-select: none; } .dygraph-axis-label-y { padding: 0 9px 0 0 !important; text-align: left !important; left: 0 !important; + user-select: none; } .dygraph-axis-label-y2 { padding: 0 0 0 9px !important; text-align: right !important; + user-select: none; } .graph-container > div > div > div > div {} /* Vertical Axis Labels */ .dygraph-ylabel, .dygraph-y2label { + user-select: none; position: absolute; width: 100%; text-align: center; diff --git a/ui/src/style/components/group-by-time-dropdown.scss b/ui/src/style/components/group-by-time-dropdown.scss index 134be12dc4..12650a7566 100644 --- a/ui/src/style/components/group-by-time-dropdown.scss +++ b/ui/src/style/components/group-by-time-dropdown.scss @@ -1,4 +1,4 @@ -.group-by-time-dropdown .dropdown-toggle { + .group-by-time-dropdown .dropdown-toggle { width: 70px; } .group-by-time { @@ -17,6 +17,7 @@ border-style: solid; border-color: $g5-pepper; border-width: 2px; + background-color: $g3-castle; } .dropdown-toggle { border-radius: 0px 3px 3px 0; diff --git a/ui/src/style/components/resizer.scss b/ui/src/style/components/resizer.scss index 245622977c..1822ba1c99 100644 --- a/ui/src/style/components/resizer.scss +++ b/ui/src/style/components/resizer.scss @@ -10,12 +10,12 @@ $resizer-color-hover: $g8-storm; $resizer-color-active: $c-pool; .resizer__handle { - top: 0; + top: 60%; left: 0; - width: $resizer-click-area; - margin-left: -$resizer-click-area/2; - margin-right: -$resizer-click-area/2; - height: 100%; + height: $resizer-click-area; + margin-top: -$resizer-click-area/2; + margin-bottom: -$resizer-click-area/2; + width: 100%; z-index: 2; user-select: none; -webkit-user-select: none; @@ -31,7 +31,7 @@ $resizer-color-active: $c-pool; position: absolute; top: 50%; left: 50%; - transform: translate(-50%,-50%) rotate(90deg); + transform: translate(-50%,-50%); width: 130px; height: $resizer-handle-width; line-height: $resizer-handle-width; @@ -48,18 +48,18 @@ $resizer-color-active: $c-pool; content: ''; display: block; position: absolute; - top: 0; - left: 50%; - transform: translateX(-50%); - height: 100%; - width: $resizer-line-width; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 100%; + height: $resizer-line-width; background-color: $resizer-color; box-shadow: 0 0 0 transparent; transition: background-color 0.19s ease; } &:hover { - cursor: ew-resize; + cursor: ns-resize; &:before { background-color: $resizer-color-hover; @@ -85,4 +85,4 @@ $resizer-color-active: $c-pool; display: flex; flex-direction: row; align-items: stretch; -} \ No newline at end of file +} diff --git a/ui/src/style/modules/variables.scss b/ui/src/style/modules/variables.scss index 352528d929..a8ecc3faed 100644 --- a/ui/src/style/modules/variables.scss +++ b/ui/src/style/modules/variables.scss @@ -11,4 +11,4 @@ $chronograf-page-header-height: 60px; $sidebar-tier1-height: 56px; // Data Explorer -$explorer-page-padding: 18px; +$explorer-page-padding: $page-wrapper-padding; diff --git a/ui/src/style/pages/data-explorer.scss b/ui/src/style/pages/data-explorer.scss index 918bb23cba..ae420deee0 100644 --- a/ui/src/style/pages/data-explorer.scss +++ b/ui/src/style/pages/data-explorer.scss @@ -15,18 +15,30 @@ .page-header { padding-left: $explorer-page-padding; - padding-right: ($explorer-page-padding + $scrollbar-width); + padding-right: $explorer-page-padding; } .page-header__container { max-width: 100%; } + .page-contents { + overflow: hidden; + } } +$query-editor-gutter: 16px; +$query-editor-tab-inactive: $g2-kevlar; +$query-editor-tab-active: $g3-castle; +$query-editor-height: 250px; +$graph-bg-color: $g3-castle; +$graph-active-color: $g4-onyx; +$graph-radius: 4px; +$de-vertical-margin: 16px; +$dygraphs-legend-offset: 32px; +$de-graph-heading-height: 44px; // DE Specific components +@import 'data-explorer/query-builder'; @import 'data-explorer/page-header'; -@import 'data-explorer/panel-builder'; -@import 'data-explorer/panel'; @import 'data-explorer/query-editor'; @import 'data-explorer/raw-text'; @import 'data-explorer/tag-list'; diff --git a/ui/src/style/pages/data-explorer/panel-builder.scss b/ui/src/style/pages/data-explorer/panel-builder.scss deleted file mode 100644 index d71cf7bc7e..0000000000 --- a/ui/src/style/pages/data-explorer/panel-builder.scss +++ /dev/null @@ -1,15 +0,0 @@ -.panel-builder { - width: 399px; - overflow-x: hidden; - background: $g1-raven; - padding: $explorer-page-padding; - @include gradient-v($g2-kevlar,$g0-obsidian); - - &::-webkit-scrollbar { - display: none; - } - - > .btn { - margin-bottom: 6px; - } -} \ No newline at end of file diff --git a/ui/src/style/pages/data-explorer/panel.scss b/ui/src/style/pages/data-explorer/panel.scss deleted file mode 100644 index 0e318e4aad..0000000000 --- a/ui/src/style/pages/data-explorer/panel.scss +++ /dev/null @@ -1,218 +0,0 @@ -.panels { - padding: $explorer-page-padding; - overflow: auto; - user-select: none; - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - width: 100%; - @include gradient-v($g2-kevlar,$g0-obsidian); - @include custom-scrollbar($g2-kevlar,$c-pool); -} - -.panel { - display: block; - background-color: $g3-castle; - border-radius: $radius; - margin-bottom: 6px; - transition: background-color 0.25s ease; - border: 0; - - &:hover { - background-color: $g4-onyx; - } - - // For when an panel item is open - &.active { - background-color: $g4-onyx; - - .panel--name { - color: $g20-white; - - .icon { - transform: translateY(-50%) rotate(90deg); - } - } - } -} -// panel Header Bar -.panel--header { - align-items: center; - text-align: center; - display: flex; - height: 36px; - padding: 0 11px; - cursor: pointer; - align-items: center; - justify-content: space-between; - border-radius: $radius; -} -.panel--name { - color: $g13-mist; - font-weight: 600; - font-size: 14px; - position: relative; - padding-left: 16px; - transition: color 0.25s ease; - - .icon.caret-right { - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%) rotate(0deg); - font-size: 0.75em; - transition: transform 0.25s ease; - } - - &:hover { - color: $g17-whisper; - } -} -.panel--actions { - display: flex; - align-items: center; -} -.panel--action { - width: 24px; - height: 24px; - border: 0; - background-color: transparent; - color: $g9-mountain; - margin-left: 2px; - transition: color 0.25s ease; - - &:hover { - cursor: pointer; - color: $g18-cloud; - } -} - -// Tabs -.panel--tabs { - display: flex; - background-color: $g4-onyx; - padding: 0 11px; -} -.panel--tab { - display: flex; - align-items: center; - color: $g11-sidewalk; - background: $g5-pepper; - height: 28px; - margin-right: 2px; - border-radius: $radius $radius 0 0; - cursor: pointer; - padding: 0 8px 0 8px; - transition: - color 0.25s ease, - background-color 0.25s ease; - - &:hover { - color: $g18-cloud; - background-color: $g6-smoke; - } - &.active { - background: $g6-smoke; - color: $g15-platinum; - } - - &-delete { - margin: 0 -4px 0 1px; - width: 16px; - height: 16px; - background-color: transparent; - display: inline-block; - vertical-align: text-top; - position: relative; - - &:before, - &:after { - display: block; - content: ''; - width: 8px; - height: 2px; - background-color: $g8-storm; - transition: - background-color 0.25s ease; - position: absolute; - top: 50%; - left: 50%; - } - &:before { - transform: translate(-50%,-50%) rotate(45deg); - } - &:after { - transform: translate(-50%,-50%) rotate(-45deg); - } - - &:hover { - &:before, - &:after { - background-color: $c-dreamsicle; - } - } - } -} -.panel--tab-new { - > .dropdown-toggle { - height: 28px !important; - border-radius: $radius $radius 0 0; - - > .icon { - margin: 0; - font-size: 12px; - position: relative; - top: -1px; - } - } - > .dropdown-menu { - width: 108px !important; - min-width: 108px !important; - max-width: 108px !important; - } -} -.panel--tab-label { - display: inline-block; - font-size: 12px; - font-weight: 600; - white-space: nowrap; - overflow: hidden; - max-width: 177px; - text-overflow: ellipsis; -} - -/* - Tab Contents - ------------------------------------------- -*/ -.panel--tab-contents { - padding: 6px; - background-color: $g6-smoke; - border-radius: 0 0 $radius $radius; -} - - -/* Time Range Selector */ -.time-range-dropdown { - display: inline-block; - - .dropdown-toggle { - width: 160px; - } -} - -.panel__header-actions { - display: flex; - - * { - margin-left: 5px; - } -} - -.alert.alert-rawquery { - border-color: $g5-pepper; - border-color: $g6-smoke; - color: $g12-forge; -} diff --git a/ui/src/style/pages/data-explorer/query-builder.scss b/ui/src/style/pages/data-explorer/query-builder.scss new file mode 100644 index 0000000000..185b337d6e --- /dev/null +++ b/ui/src/style/pages/data-explorer/query-builder.scss @@ -0,0 +1,218 @@ +.query-builder { + position: absolute; + width: calc(100% - #{($explorer-page-padding * 2)}); + left: $explorer-page-padding; + height: 40%; + top: 60%; + border: 0; + display: flex; + align-items: stretch; + justify-content: space-between; +} + +// Tabs +.query-builder--tabs { + display: flex; + width: 250px; + margin-top: $de-vertical-margin; + height: calc(100% - #{($de-vertical-margin * 2)}); + flex-direction: column; + align-items: stretch; + @include gradient-v($g3-castle,$g1-raven); + border-radius: $radius 0 0 $radius; +} +.query-builder--tabs-heading { + height: 60px; + padding: 0 9px 0 16px; + display: flex; + align-items: center; + justify-content: space-between; + + h1 { + font-size: 17px; + font-weight: 400; + text-transform: uppercase; + color: $g18-cloud; + margin: 0; + } +} +.query-builder--tab { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + color: $g11-sidewalk; + background: transparent; + height: 30px; + cursor: pointer; + padding: 0 8px 0 16px; + transition: + color 0.25s ease, + background-color 0.25s ease; + + &:hover { + color: $g15-platinum; + background-color: $g4-onyx; + } + &.active { + color: $g18-cloud; + background: $g5-pepper; + } + + &-delete { + margin: 0; + width: 16px; + height: 16px; + background-color: transparent; + display: inline-block; + vertical-align: text-top; + position: relative; + + &:before, + &:after { + display: block; + content: ''; + width: 8px; + height: 2px; + background-color: $g8-storm; + transition: + background-color 0.25s ease; + position: absolute; + top: 50%; + left: 50%; + } + &:before { + transform: translate(-50%,-50%) rotate(45deg); + } + &:after { + transform: translate(-50%,-50%) rotate(-45deg); + } + + &:hover { + &:before, + &:after { + background-color: $c-dreamsicle; + } + } + } +} +.panel--tab-new { + > .dropdown-toggle { + height: 30px !important; + border-radius: $radius; + background-color: $c-pool; + color: $g20-white !important; + padding: 0; + + > .icon { + margin: 0; + font-size: 12px; + position: relative; + top: -1px; + } + &:hover { + background-color: $c-laser; + } + } + > .dropdown-menu { + width: 108px !important; + min-width: 108px !important; + max-width: 108px !important; + } +} +.panel--tab-new.open { + > .dropdown-toggle, + > .dropdown-toggle:hover { + background-color: $c-laser !important; + color: $g20-white !important; + } +} +.query-builder--tab-label { + display: inline-block; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + max-width: 177px; + text-overflow: ellipsis; +} + +/* + Tab Contents + ------------------------------------------- +*/ +$query-builder--column-heading-height: 60px; +.query-builder--tab-contents { + width: 100%; + margin-top: $de-vertical-margin; + height: calc(100% - #{($de-vertical-margin * 2)}); + background-color: $g4-onyx; + border-radius: 0 $radius $radius 0; + overflow: hidden; + position: relative; +} +.query-builder--tab-contents > div { + position: absolute; + top: 4px; + left: 4px; + width: calc(100% - 8px); + height: calc(100% - 8px); +} +.query-builder--columns { + position: absolute; + width: 100%; + height: calc(100% - 60px); + top: 60px; +} +.query-builder--column-heading { + width: 100%; + height: $query-builder--column-heading-height; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 14px; + font-weight: 600; + color: $g13-mist; + padding: 0 9px; + line-height: $query-builder--column-heading-height; + border-bottom: 2px solid $g5-pepper; +} +.query-builder--column { + position: absolute; + width: 25%; + height: 100%; + top: 0; + + .qeditor--list { + position: absolute; + top: $query-builder--column-heading-height; + height: calc(100% - #{$query-builder--column-heading-height}); + width: 100%; + left: 0; + padding: 0; + overflow: auto; + overflow-x: hidden; + overflow-y: scroll; + @include custom-scrollbar($g4-onyx,$c-pool); + background-color: $g4-onyx; + } +} +.query-builder--column:nth-of-type(1) { left: 0; } +.query-builder--column:nth-of-type(2) { left: 25%; } +.query-builder--column:nth-of-type(3) { left: 50%; } +.query-builder--column:nth-of-type(4) { left: 75%; } + +/* Time Range Selector */ +.time-range-dropdown { + display: inline-block; + + .dropdown-toggle { + width: 160px; + } +} + +.alert.alert-rawquery { + border-color: $g5-pepper; + border-color: $g6-smoke; + color: $g12-forge; +} diff --git a/ui/src/style/pages/data-explorer/query-editor.scss b/ui/src/style/pages/data-explorer/query-editor.scss index 80172b717a..5891466da0 100644 --- a/ui/src/style/pages/data-explorer/query-editor.scss +++ b/ui/src/style/pages/data-explorer/query-editor.scss @@ -5,12 +5,8 @@ Abbreviated as "qeditor" */ -$query-editor-gutter: 16px; -$query-editor-tab-inactive: $g2-kevlar; -$query-editor-tab-active: $g3-castle; -$query-editor-height: 250px; -.qeditor--query-preview { +.query-builder--query-preview { position: relative; pre { @@ -18,11 +14,10 @@ $query-editor-height: 250px; border: 0; background-color: $query-editor-tab-inactive; color: $c-pool; - border-radius: $radius-small $radius-small 0 0; - border-bottom: 2px solid $query-editor-tab-active; + border-radius: $radius; margin-bottom: 0; overflow: auto; - min-height: 3.25em; + height: 56px; @include custom-scrollbar($query-editor-tab-inactive, $c-pool); code { @@ -90,22 +85,23 @@ $query-editor-height: 250px; list-style-type: none; margin: 0; font-size: 12px; - padding: 4px 9px 4px 18px; + font-weight: 500; + padding: 4px 9px; transition: color 0.25s ease, background-color 0.25s ease; &:hover { - background-color: $g4-onyx; - color: $g17-whisper; + background-color: $g5-pepper; + color: $g15-platinum; cursor: pointer; } } &-radio { &.active { color: $g20-white; - background-color: $g4-onyx; - font-weight: 600; + background-color: $g5-pepper; + font-weight: 700; } } &-checkbox { @@ -169,11 +165,18 @@ $query-editor-height: 250px; } } } - &-header { - position: relative; - background-color: $query-editor-tab-active; - padding: 8px 18px 0px 18px; - } +} +.qeditor--list-header { + position: absolute; + top: 15px; + right: 16px; + width: calc(60% - 16px); + height: 30px; + padding: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: flex-end; } // List empty state @@ -184,8 +187,7 @@ $query-editor-height: 250px; width: 100%; padding: 18px 0; height: $query-editor-height; - background-color: $query-editor-tab-active; - border-radius: 0 0 $radius $radius; + background-color: transparent; } // Hidden dropdowns @@ -210,7 +212,7 @@ $query-editor-height: 250px; height: 30px; border-radius: 15px; font-size: 13px; - padding-left: 38px; + padding-left: 28px; outline: none; color: $g20-white; font-weight: 700; @@ -244,12 +246,12 @@ $query-editor-height: 250px; } + .icon { position: absolute; - top: calc(50% + 5px); - left: calc(19px * 2); + top: 50%; + left: 11px; transform: translateY(-50%); color: $g10-wolf; transition: color 0.25s ease; font-size: 12px; z-index: 2; } -} \ No newline at end of file +} diff --git a/ui/src/style/pages/data-explorer/raw-text.scss b/ui/src/style/pages/data-explorer/raw-text.scss index 7157001e62..4419924728 100644 --- a/ui/src/style/pages/data-explorer/raw-text.scss +++ b/ui/src/style/pages/data-explorer/raw-text.scss @@ -26,12 +26,12 @@ $raw-text-color: $c-comet; @include custom-scrollbar($g2-kevlar, $raw-text-color); display: block; width: 100%; - height: 100px; + height: 56px; background-color: $g2-kevlar; border: 2px solid $g2-kevlar; color: $raw-text-color; padding: (9px - 2px); - border-radius: 3px 3px 0 0; + border-radius: $radius; line-height: 1.5em; -webkit-appearance: none; -moz-appearance: none; diff --git a/ui/src/style/pages/data-explorer/tag-list.scss b/ui/src/style/pages/data-explorer/tag-list.scss index 3dbf18c8e1..1d011cc14b 100644 --- a/ui/src/style/pages/data-explorer/tag-list.scss +++ b/ui/src/style/pages/data-explorer/tag-list.scss @@ -1,10 +1,15 @@ .tag-list { &__item { + height: 30px; display: flex; align-items: center; justify-content: space-between; + &:hover .tag-list__group-by { + display: flex; + } + &.open { font-weight: 600; color: $g20-white; @@ -63,7 +68,7 @@ opacity: 0; background-color: $c-pool; border-radius: 50%; - transition: + transition: transform 0.25s ease, opacity 0.25s ease; } @@ -124,7 +129,7 @@ color: $g10-wolf; font-weight: 500; } - &:-ms-input-placeholder { + &:-ms-input-placeholder { color: $g10-wolf; font-weight: 500; } @@ -148,6 +153,7 @@ } .tag-list__group-by { + display: none; background-color: $g5-pepper; border-color: $g5-pepper; color: $g13-mist !important; @@ -165,6 +171,7 @@ border-color: $g6-smoke; } &.active { + display: flex; background: $c-pool; border-color: $c-pool; @@ -173,4 +180,4 @@ border-color: $c-laser; } } -} \ No newline at end of file +} diff --git a/ui/src/style/pages/data-explorer/visualization.scss b/ui/src/style/pages/data-explorer/visualization.scss index 8665bf3fb8..a6bb70e953 100644 --- a/ui/src/style/pages/data-explorer/visualization.scss +++ b/ui/src/style/pages/data-explorer/visualization.scss @@ -1,24 +1,19 @@ -$graph-bg-color: $g3-castle; -$graph-active-color: $g4-onyx; -$graph-radius: 4px; - -$dygraphs-legend-offset: 32px; - .graph { - position: relative; - margin-bottom: 18px; - - &:last-child { - margin-bottom: 100%; - } + position: absolute; + width: calc(100% - #{($explorer-page-padding * 2)}); + left: $explorer-page-padding; + top: 0; + height: 60%; } .graph-heading { + position: relative; + top: $de-vertical-margin; background-color: $graph-bg-color; border-radius: $graph-radius $graph-radius 0 0; display: flex; align-items: center; justify-content: space-between; - height: 44px; + height: $de-graph-heading-height; padding: 0 16px; transition: background-color 0.25s ease; @@ -39,6 +34,41 @@ $dygraphs-legend-offset: 32px; display: flex; align-items: center; } +.table-container { + background-color: $graph-bg-color; + border-radius: 0 0 $graph-radius $graph-radius; + padding: 8px 16px; + position: relative; + top: $de-vertical-margin; + height: calc(100% - #{$de-graph-heading-height} - #{($de-vertical-margin * 2)}); + + & > div { + position: absolute; + width: calc(100% - #{($de-vertical-margin * 2)}); + height: calc(100% - #{$de-vertical-margin}); + top: ($de-vertical-margin/2); + left: $de-vertical-margin;; + } + & > div .multi-table__tabs { + position: absolute; + height: 30px; + width: 100%; + } + & > div > div:last-child { + position: absolute; + top: 30px; + height: calc(100% - 30px) !important; + width: 100%; + } + .fixedDataTableLayout_main { + height: 100% !important; + } + .generic-empty-state { + background-color: $g6-smoke; + padding: 50px 0; + height: 100%; + } +} .graph-container { background-color: $graph-bg-color; border-radius: 0 0 $graph-radius $graph-radius; @@ -48,6 +78,30 @@ $dygraphs-legend-offset: 32px; transition: background-color 0.25s ease; } +.data-explorer .graph-container { + top: $de-vertical-margin; + height: calc(100% - #{$de-graph-heading-height} - #{($de-vertical-margin * 2)}); + padding: 0; + + & > div { + position: absolute; + width: 100%; + height: 100%; + } + & > div > div { + position: absolute; + width: 100%; + height: 100%; + padding: 8px 16px; + // width: calc(100% - #{($de-vertical-margin * 2)}); + // height: calc(100% - #{$de-vertical-margin}); + // top: ($de-vertical-margin / 2); + // left: $de-vertical-margin; + } + & > div > div > div:first-child { + height: 100% !important; + } +} /* Active State */ @@ -144,4 +198,4 @@ $dygraphs-legend-offset: 32px; background-color: $g6-smoke; color: $g14-chromium; } -} \ No newline at end of file +} diff --git a/ui/src/style/pages/kapacitor.scss b/ui/src/style/pages/kapacitor.scss index 27873e039a..3e73641059 100644 --- a/ui/src/style/pages/kapacitor.scss +++ b/ui/src/style/pages/kapacitor.scss @@ -219,6 +219,34 @@ div.qeditor.kapacitor-metric-selector { .kapacitor-tab-list { background-color: $kapacitor-graphic-color; border-radius: 0 0 $kap-radius-lg $kap-radius-lg; + + .query-builder--column { + position: relative; + top: initial; + left: initial; + width: 100%; + height: 190px; + + .qeditor--list-header { + width: 50%; + top: -34px; + right: 0; + z-index: 5; + } + .qeditor--list { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + .group-by-time-dropdown { + width: 70px; + } + } + .query-builder--column-heading { + display: none; + } } .qeditor--list { overflow: auto; diff --git a/ui/src/style/theme/theme-dark.scss b/ui/src/style/theme/theme-dark.scss index 80fccd25d9..361c833c0a 100644 --- a/ui/src/style/theme/theme-dark.scss +++ b/ui/src/style/theme/theme-dark.scss @@ -443,7 +443,7 @@ $toggle-border: 2px; .toggle-btn { height: ($toggle-height-sm - ($toggle-border * 2)); line-height: ($toggle-height-sm - ($toggle-border * 2)); - padding: 0 $toggle-padding-sm; + padding: 0 16px; font-size: $toggle-font-sm; font-weight: 600; }