diff --git a/.gitignore b/.gitignore index bbef07c3ad..b580642afd 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ npm-debug.log .jssrc .dev-jssrc .bindata +ui/reports diff --git a/CHANGELOG.md b/CHANGELOG.md index f6364618d0..47d66fbbdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,14 @@ ## v1.3.7.0 [unreleased] ### Bug Fixes +1. [#1795](https://github.com/influxdata/chronograf/pull/1795): Fix uptime status on Windows hosts running Telegraf +1. [#1715](https://github.com/influxdata/chronograf/pull/1715): Chronograf now renders on IE11. 1. [#1845](https://github.com/influxdata/chronograf/pull/1845): Fix no-scroll bar appearing in the Data Explorer table 1. [#1870](https://github.com/influxdata/chronograf/pull/1870): Fix console error for placing prop on div +1. [#1866](https://github.com/influxdata/chronograf/pull/1866): Fix missing cell type (and consequently single-stat) + ### Features +1. [#1863](https://github.com/influxdata/chronograf/pull/1863): Improve 'new-sources' server flag example by adding 'type' key ### UI Improvements 1. [#1846](https://github.com/influxdata/chronograf/pull/1846): Increase screen real estate of Query Maker in the Cell Editor Overlay diff --git a/chronograf.go b/chronograf.go index 319065befc..aa9c6bc6c9 100644 --- a/chronograf.go +++ b/chronograf.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "regexp" "strconv" "strings" "time" @@ -193,32 +194,103 @@ type GroupByVar struct { // Exec is responsible for extracting the Duration from the query func (g *GroupByVar) Exec(query string) { - whereClause := "WHERE time > now() - " + whereClause := "WHERE" start := strings.Index(query, whereClause) if start == -1 { // no where clause return } - // reposition start to the END of the where clause + // reposition start to after the 'where' keyword durStr := query[start+len(whereClause):] - // advance to next space + // attempt to parse out a relative time range + dur, err := g.parseRelative(durStr) + if err == nil { + // we parsed relative duration successfully + g.Duration = dur + return + } + + dur, err = g.parseAbsolute(durStr) + if err == nil { + // we found an absolute time range + g.Duration = dur + } +} + +// parseRelative locates and extracts a duration value from a fragment of an +// InfluxQL query following the "where" keyword. For example, in the fragment +// "time > now() - 180d GROUP BY :interval:", parseRelative would return a +// duration equal to 180d +func (g *GroupByVar) parseRelative(fragment string) (time.Duration, error) { + // locate duration literal start + prefix := "time > now() - " + start := strings.Index(fragment, prefix) + if start == -1 { + return time.Duration(0), errors.New("not a relative duration") + } + + // reposition to duration literal + durFragment := fragment[start+len(prefix):] + + // init counters pos := 0 - for pos < len(durStr) { - rn, _ := utf8.DecodeRuneInString(durStr[pos:]) + + // locate end of duration literal + for pos < len(durFragment) { + rn, _ := utf8.DecodeRuneInString(durFragment[pos:]) if unicode.IsSpace(rn) { break } pos++ } - dur, err := influxql.ParseDuration(durStr[:pos]) + // attempt to parse what we suspect is a duration literal + dur, err := influxql.ParseDuration(durFragment[:pos]) if err != nil { - return + return dur, err } - g.Duration = dur + return dur, nil +} + +// parseAbsolute will determine the duration between two absolute timestamps +// found within an InfluxQL fragment following the "where" keyword. For +// example, the fragement "time > '1985-10-25T00:01:21-0800 and time < +// '1985-10-25T00:01:22-0800'" would yield a duration of 1m' +func (g *GroupByVar) parseAbsolute(fragment string) (time.Duration, error) { + timePtn := `time\s[>|<]\s'([0-9\-TZ\:]+)'` // Playground: http://gobular.com/x/41a45095-c384-46ea-b73c-54ef91ab93af + re, err := regexp.Compile(timePtn) + if err != nil { + // this is a developer error and should complain loudly + panic("Bad Regex: err:" + err.Error()) + } + + if !re.Match([]byte(fragment)) { + return time.Duration(0), errors.New("absolute duration not found") + } + + // extract at most two times + matches := re.FindAll([]byte(fragment), 2) + + // parse out absolute times + durs := make([]time.Time, 0, 2) + for _, match := range matches { + durStr := re.FindSubmatch(match) + if tm, err := time.Parse(time.RFC3339Nano, string(durStr[1])); err == nil { + durs = append(durs, tm) + } + } + + // reject more than 2 times found + if len(durs) != 2 { + return time.Duration(0), errors.New("must provide exactly two absolute times") + } + + dur := durs[1].Sub(durs[0]) + + return dur, nil } func (g *GroupByVar) String() string { diff --git a/chronograf_test.go b/chronograf_test.go new file mode 100644 index 0000000000..60164d5880 --- /dev/null +++ b/chronograf_test.go @@ -0,0 +1,49 @@ +package chronograf_test + +import ( + "testing" + "time" + + "github.com/influxdata/chronograf" +) + +func Test_GroupByVar(t *testing.T) { + gbvTests := []struct { + name string + query string + expected time.Duration + resolution uint // the screen resolution to render queries into + reportingInterval time.Duration + }{ + { + "relative time", + "SELECT mean(usage_idle) FROM cpu WHERE time > now() - 180d GROUP BY :interval:", + 4320 * time.Hour, + 1000, + 10 * time.Second, + }, + { + "absolute time", + "SELECT mean(usage_idle) FROM cpu WHERE time > '1985-10-25T00:01:00Z' and time < '1985-10-25T00:02:00Z' GROUP BY :interval:", + 1 * time.Minute, + 1000, + 10 * time.Second, + }, + } + + for _, test := range gbvTests { + t.Run(test.name, func(t *testing.T) { + gbv := chronograf.GroupByVar{ + Var: ":interval:", + Resolution: test.resolution, + ReportingInterval: test.reportingInterval, + } + + gbv.Exec(test.query) + + if gbv.Duration != test.expected { + t.Fatalf("%q - durations not equal! Want: %s, Got: %s", test.name, test.expected, gbv.Duration) + } + }) + } +} diff --git a/server/cells.go b/server/cells.go index 29c0bd57a2..29d78b000d 100644 --- a/server/cells.go +++ b/server/cells.go @@ -45,6 +45,7 @@ func newCellResponses(dID chronograf.DashboardID, dcells []chronograf.DashboardC newCell.H = cell.H newCell.Name = cell.Name newCell.ID = cell.ID + newCell.Type = cell.Type for _, lbl := range labels { if axis, found := cell.Axes[lbl]; !found { diff --git a/server/cells_test.go b/server/cells_test.go index 5ca5dce5e1..c0ade6f5f7 100644 --- a/server/cells_test.go +++ b/server/cells_test.go @@ -105,6 +105,7 @@ func Test_Service_DashboardCells(t *testing.T) { W: 4, H: 4, Name: "CPU", + Type: "bar", Queries: []chronograf.DashboardQuery{}, Axes: map[string]chronograf.Axis{}, }, @@ -117,6 +118,7 @@ func Test_Service_DashboardCells(t *testing.T) { W: 4, H: 4, Name: "CPU", + Type: "bar", Queries: []chronograf.DashboardQuery{}, Axes: map[string]chronograf.Axis{ "x": chronograf.Axis{ diff --git a/server/server.go b/server/server.go index eff14cbf38..d51d8148c6 100644 --- a/server/server.go +++ b/server/server.go @@ -50,7 +50,7 @@ type Server struct { KapacitorUsername string `long:"kapacitor-username" description:"Username of your Kapacitor instance" env:"KAPACITOR_USERNAME"` KapacitorPassword string `long:"kapacitor-password" description:"Password of your Kapacitor instance" env:"KAPACITOR_PASSWORD"` - NewSources string `long:"new-sources" description:"Config for adding a new InfluxDB source and Kapacitor server, in JSON as an array of objects, and surrounded by single quotes. E.g. --new-sources='[{\"influxdb\":{\"name\":\"Influx 1\",\"username\":\"user1\",\"password\":\"pass1\",\"url\":\"http://localhost:8086\",\"metaUrl\":\"http://metaurl.com\",\"insecureSkipVerify\":false,\"default\":true,\"telegraf\":\"telegraf\",\"sharedSecret\":\"hunter2\"},\"kapacitor\":{\"name\":\"Kapa 1\",\"url\":\"http://localhost:9092\",\"active\":true}}]'" env:"NEW_SOURCES" hidden:"true"` + NewSources string `long:"new-sources" description:"Config for adding a new InfluxDB source and Kapacitor server, in JSON as an array of objects, and surrounded by single quotes. E.g. --new-sources='[{\"influxdb\":{\"name\":\"Influx 1\",\"username\":\"user1\",\"password\":\"pass1\",\"url\":\"http://localhost:8086\",\"metaUrl\":\"http://metaurl.com\",\"type\":\"influx-enterprise\",\"insecureSkipVerify\":false,\"default\":true,\"telegraf\":\"telegraf\",\"sharedSecret\":\"cubeapples\"},\"kapacitor\":{\"name\":\"Kapa 1\",\"url\":\"http://localhost:9092\",\"active\":true}}]'" env:"NEW_SOURCES" hidden:"true"` Develop bool `short:"d" long:"develop" description:"Run server in develop mode."` BoltPath string `short:"b" long:"bolt-path" description:"Full path to boltDB file (e.g. './chronograf-v1.db')" env:"BOLT_PATH" default:"chronograf-v1.db"` diff --git a/ui/.eslintrc b/ui/.eslintrc index 2dda26b78f..a9ca0ef627 100644 --- a/ui/.eslintrc +++ b/ui/.eslintrc @@ -102,7 +102,7 @@ 'no-iterator': 2, 'no-lone-blocks': 2, 'no-loop-func': 2, - 'no-magic-numbers': [2, {ignore: [-1, 0, 1, 2]}], + 'no-magic-numbers': [0, {ignore: [-1, 0, 1, 2]}], 'no-multi-spaces': 2, 'no-multi-str': 2, 'no-native-reassign': 2, diff --git a/ui/README.md b/ui/README.md index 24f34aec5c..73698695b0 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,6 +1,8 @@ ## Packages We are using [yarn](https://yarnpkg.com/en/docs/install) 0.19.1. +Run `yarn run` to see a list of available tasks. + ### Adding new packages To add a new package, run diff --git a/ui/nightwatch.json b/ui/nightwatch.json new file mode 100644 index 0000000000..367e9e2fe2 --- /dev/null +++ b/ui/nightwatch.json @@ -0,0 +1,37 @@ +{ + "src_folders": ["tests"], + "output_folder": "reports", + "custom_commands_path": "", + "custom_assertions_path": "", + "page_objects_path": "", + "globals_path": "", + + "selenium": { + "start_process": false, + "host": "hub-cloud.browserstack.com", + "port": 80 + }, + + "live_output" : true, + + "test_settings": { + "default": { + "selenium_port": 80, + "selenium_host": "hub-cloud.browserstack.com", + "silent": false, + "screenshots": { + "enabled": true, + "path": "screenshots" + }, + "desiredCapabilities": { + "browser": "chrome", + "build": "nightwatch-browserstack", + "browserstack.user": "${BROWSERSTACK_USER}", + "browserstack.key": "${BROWSERSTACK_KEY}", + "browserstack.debug": true, + "browserstack.local": true, + "resolution": "1280x1024" + } + } + } +} diff --git a/ui/package.json b/ui/package.json index 253f0bf1dc..6484277fb9 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,15 +9,16 @@ "url": "github:influxdata/chronograf" }, "scripts": { - "build": "yarn run clean && env NODE_ENV=production node_modules/webpack/bin/webpack.js -p --config ./webpack/prodConfig.js", - "build:dev": "node_modules/webpack/bin/webpack.js --config ./webpack/devConfig.js", - "start": "node_modules/webpack/bin/webpack.js -w --config ./webpack/devConfig.js", - "lint": "node_modules/eslint/bin/eslint.js src/", + "build": "yarn run clean && env NODE_ENV=production webpack --optimize-minimize --config ./webpack/prodConfig.js", + "build:dev": "webpack --config ./webpack/devConfig.js", + "start": "webpack --watch --config ./webpack/devConfig.js", + "lint": "esw src/", "test": "karma start", + "test:integration": "nightwatch tests --skip", "test:lint": "yarn run lint; yarn run test", - "test:dev": "nodemon --exec yarn run test:lint", + "test:dev": "concurrently \"yarn run lint -- --watch\" \"yarn run test -- --no-single-run --reporters=verbose\"", "clean": "rm -rf build", - "storybook": "node ./storybook", + "storybook": "node ./storybook.js", "prettier": "prettier --single-quote --trailing-comma es5 --bracket-spacing false --semi false --write \"{src,spec}/**/*.js\"; eslint src --fix" }, "author": "", @@ -45,14 +46,16 @@ "babel-runtime": "^6.5.0", "bower": "^1.7.7", "chai": "^3.5.0", + "concurrently": "^3.5.0", "core-js": "^2.1.3", "css-loader": "^0.23.1", "envify": "^3.4.0", "enzyme": "^2.4.1", - "eslint": "3.9.1", + "eslint": "^3.14.1", "eslint-loader": "1.6.1", "eslint-plugin-prettier": "^2.1.2", "eslint-plugin-react": "6.6.0", + "eslint-watch": "^3.1.2", "express": "^4.14.0", "extract-text-webpack-plugin": "^1.0.1", "file-loader": "^0.8.5", diff --git a/ui/src/dashboards/components/CellEditorOverlay.js b/ui/src/dashboards/components/CellEditorOverlay.js index 17ec91d3cc..b21a3a39f3 100644 --- a/ui/src/dashboards/components/CellEditorOverlay.js +++ b/ui/src/dashboards/components/CellEditorOverlay.js @@ -4,8 +4,8 @@ import _ from 'lodash' import uuid from 'node-uuid' import ResizeContainer from 'shared/components/ResizeContainer' -import QueryMaker from 'src/data_explorer/components/QueryMaker' -import Visualization from 'src/data_explorer/components/Visualization' +import QueryMaker from 'src/dashboards/components/QueryMaker' +import Visualization from 'src/dashboards/components/Visualization' import OverlayControls from 'src/dashboards/components/OverlayControls' import DisplayOptions from 'src/dashboards/components/DisplayOptions' @@ -34,6 +34,7 @@ class CellEditorOverlay extends Component { this.handleEditRawText = ::this.handleEditRawText this.handleSetYAxisBounds = ::this.handleSetYAxisBounds this.handleSetLabel = ::this.handleSetLabel + this.getActiveQuery = ::this.getActiveQuery const {cell: {name, type, queries, axes}} = props @@ -111,10 +112,17 @@ class CellEditorOverlay extends Component { e.preventDefault() } - handleAddQuery(options) { - const newQuery = Object.assign({}, defaultQueryConfig(uuid.v4()), options) - const nextQueries = this.state.queriesWorkingDraft.concat(newQuery) - this.setState({queriesWorkingDraft: nextQueries}) + handleAddQuery() { + const {queriesWorkingDraft} = this.state + const newIndex = queriesWorkingDraft.length + + this.setState({ + queriesWorkingDraft: [ + ...queriesWorkingDraft, + defaultQueryConfig(uuid.v4()), + ], + }) + this.handleSetActiveQueryIndex(newIndex) } handleDeleteQuery(index) { @@ -167,6 +175,14 @@ class CellEditorOverlay extends Component { this.setState({activeQueryIndex}) } + getActiveQuery() { + const {queriesWorkingDraft, activeQueryIndex} = this.state + const activeQuery = queriesWorkingDraft[activeQueryIndex] + const defaultQuery = queriesWorkingDraft[0] + + return activeQuery || defaultQuery + } + async handleEditRawText(url, id, text) { const templates = removeUnselectedTemplateValues(this.props.templates) @@ -203,7 +219,6 @@ class CellEditorOverlay extends Component { } = this.state const queryActions = { - addQuery: this.handleAddQuery, editRawTextAsync: this.handleEditRawText, ..._.mapValues(queryModifiers, qm => this.queryStateReducer(qm)), } @@ -222,16 +237,14 @@ class CellEditorOverlay extends Component { initialBottomHeight={INITIAL_HEIGHTS.queryMaker} > } diff --git a/ui/src/dashboards/components/DashboardHeaderEdit.js b/ui/src/dashboards/components/DashboardHeaderEdit.js index a79273a39d..f1dd5bc8a4 100644 --- a/ui/src/dashboards/components/DashboardHeaderEdit.js +++ b/ui/src/dashboards/components/DashboardHeaderEdit.js @@ -38,7 +38,7 @@ class DashboardEditHeader extends Component {
text => + action(links.queries, id, text) +const buildText = q => + q.rawText || buildInfluxQLQuery(q.range || TEMPLATE_RANGE, q) || '' + +const QueryMaker = ({ + source: {links}, + actions, + queries, + timeRange, + templates, + onAddQuery, + activeQuery, + onDeleteQuery, + activeQueryIndex, + setActiveQueryIndex, +}) => +
+ + {activeQuery && activeQuery.id + ?
+ + +
+ : } +
+ +const {arrayOf, bool, func, number, shape, string} = PropTypes + +QueryMaker.propTypes = { + source: shape({ + links: shape({ + queries: string.isRequired, + }).isRequired, + }).isRequired, + queries: arrayOf(shape({})).isRequired, + timeRange: shape({ + upper: string, + lower: string, + }).isRequired, + isInDataExplorer: bool, + actions: shape({ + chooseNamespace: func.isRequired, + chooseMeasurement: func.isRequired, + chooseTag: func.isRequired, + groupByTag: func.isRequired, + toggleField: func.isRequired, + groupByTime: func.isRequired, + toggleTagAcceptance: func.isRequired, + applyFuncsToField: func.isRequired, + editRawTextAsync: func.isRequired, + }).isRequired, + setActiveQueryIndex: func.isRequired, + onDeleteQuery: func.isRequired, + activeQueryIndex: number, + activeQuery: shape({}), + onAddQuery: func.isRequired, + templates: arrayOf( + shape({ + tempVar: string.isRequired, + }) + ).isRequired, +} + +export default QueryMaker diff --git a/ui/src/dashboards/components/QueryTextArea.js b/ui/src/dashboards/components/QueryTextArea.js new file mode 100644 index 0000000000..8fe3bf39a1 --- /dev/null +++ b/ui/src/dashboards/components/QueryTextArea.js @@ -0,0 +1,263 @@ +import React, {PropTypes, Component} from 'react' +import _ from 'lodash' +import classnames from 'classnames' + +import TemplateDrawer from 'shared/components/TemplateDrawer' +import QueryStatus from 'shared/components/QueryStatus' + +import { + MATCH_INCOMPLETE_TEMPLATES, + applyMasks, + insertTempVar, + unMask, +} from 'src/dashboards/constants' + +class QueryTextArea extends Component { + constructor(props) { + super(props) + this.state = { + value: this.props.query, + isTemplating: false, + selectedTemplate: { + tempVar: _.get(this.props.templates, ['0', 'tempVar'], ''), + }, + filteredTemplates: this.props.templates, + } + + this.handleKeyDown = ::this.handleKeyDown + this.handleChange = ::this.handleChange + this.handleUpdate = ::this.handleUpdate + this.handleChooseTemplate = ::this.handleChooseTemplate + this.handleCloseDrawer = ::this.handleCloseDrawer + this.findTempVar = ::this.findTempVar + this.handleTemplateReplace = ::this.handleTemplateReplace + this.handleMouseOverTempVar = ::this.handleMouseOverTempVar + this.handleClickTempVar = ::this.handleClickTempVar + this.closeDrawer = ::this.closeDrawer + } + + componentWillReceiveProps(nextProps) { + if (this.props.query !== nextProps.query) { + this.setState({value: nextProps.query}) + } + } + + handleCloseDrawer() { + this.setState({isTemplating: false}) + } + + handleMouseOverTempVar(template) { + this.handleTemplateReplace(template) + } + + handleClickTempVar(template) { + // Clicking a tempVar does the same thing as hitting 'Enter' + this.handleTemplateReplace(template, true) + this.closeDrawer() + } + + closeDrawer() { + this.setState({ + isTemplating: false, + selectedTemplate: { + tempVar: _.get(this.props.templates, ['0', 'tempVar'], ''), + }, + }) + } + + handleKeyDown(e) { + const {isTemplating, value} = this.state + + if (isTemplating) { + switch (e.key) { + case 'Tab': + case 'ArrowRight': + case 'ArrowDown': + e.preventDefault() + return this.handleTemplateReplace(this.findTempVar('next')) + case 'ArrowLeft': + case 'ArrowUp': + e.preventDefault() + return this.handleTemplateReplace(this.findTempVar('previous')) + case 'Enter': + e.preventDefault() + this.handleTemplateReplace(this.state.selectedTemplate, true) + return this.closeDrawer() + case 'Escape': + e.preventDefault() + return this.closeDrawer() + } + } else if (e.key === 'Escape') { + e.preventDefault() + this.setState({value, isTemplating: false}) + } else if (e.key === 'Enter') { + e.preventDefault() + this.handleUpdate() + } + } + + handleTemplateReplace(selectedTemplate, replaceWholeTemplate) { + const {selectionStart, value} = this.editor + const {tempVar} = selectedTemplate + const newTempVar = replaceWholeTemplate + ? tempVar + : tempVar.substring(0, tempVar.length - 1) + + // mask matches that will confuse our regex + const masked = applyMasks(value) + const matched = masked.match(MATCH_INCOMPLETE_TEMPLATES) + + let templatedValue + if (matched) { + templatedValue = insertTempVar(masked, newTempVar) + templatedValue = unMask(templatedValue) + } + + const enterModifier = replaceWholeTemplate ? 0 : -1 + const diffInLength = + tempVar.length - _.get(matched, '0', []).length + enterModifier + + this.setState({value: templatedValue, selectedTemplate}, () => + this.editor.setSelectionRange( + selectionStart + diffInLength, + selectionStart + diffInLength + ) + ) + } + + findTempVar(direction) { + const {filteredTemplates: templates} = this.state + const {selectedTemplate} = this.state + + const i = _.findIndex(templates, selectedTemplate) + const lastIndex = templates.length - 1 + + if (i >= 0) { + if (direction === 'next') { + return templates[(i + 1) % templates.length] + } + + if (direction === 'previous') { + if (i === 0) { + return templates[lastIndex] + } + + return templates[i - 1] + } + } + + return templates[0] + } + + handleChange() { + const {templates} = this.props + const {selectedTemplate} = this.state + const value = this.editor.value + + // mask matches that will confuse our regex + const masked = applyMasks(value) + const matched = masked.match(MATCH_INCOMPLETE_TEMPLATES) + + if (matched && !_.isEmpty(templates)) { + // maintain cursor poition + const start = this.editor.selectionStart + + const end = this.editor.selectionEnd + const filterText = matched[0].substr(1).toLowerCase() + + const filteredTemplates = templates.filter(t => + t.tempVar.toLowerCase().includes(filterText) + ) + + const found = filteredTemplates.find( + t => t.tempVar === selectedTemplate && selectedTemplate.tempVar + ) + const newTemplate = found ? found : filteredTemplates[0] + + this.setState({ + isTemplating: true, + selectedTemplate: newTemplate, + filteredTemplates, + value, + }) + this.editor.setSelectionRange(start, end) + } else { + this.setState({isTemplating: false, value}) + } + } + + handleUpdate() { + this.props.onUpdate(this.state.value) + } + + handleChooseTemplate(template) { + this.setState({value: template.query}) + } + + handleSelectTempVar(tempVar) { + this.setState({selectedTemplate: tempVar}) + } + + render() { + const {config: {status}} = this.props + const { + value, + isTemplating, + selectedTemplate, + filteredTemplates, + } = this.state + + return ( +
+