diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0c8093c3..cc98df4b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ 1. [#1885](https://github.com/influxdata/chronograf/pull/1885): Add `fill` options to data explorer and dashboard queries 1. [#1978](https://github.com/influxdata/chronograf/pull/1978): Support editing kapacitor TICKScript 1. [#1721](https://github.com/influxdata/chronograf/pull/1721): Introduce the TICKscript editor UI +1. [#1992](https://github.com/influxdata/chronograf/pull/1992): Add .csv download button to data explorer ### UI Improvements diff --git a/LICENSE_OF_DEPENDENCIES.md b/LICENSE_OF_DEPENDENCIES.md index 3217a25668..3476dcda8f 100644 --- a/LICENSE_OF_DEPENDENCIES.md +++ b/LICENSE_OF_DEPENDENCIES.md @@ -391,6 +391,7 @@ * domutils 1.1.6 [Unknown](http://github.com/FB55/domutils) * domutils 1.5.1 [Unknown](http://github.com/FB55/domutils) * dot-prop 3.0.0 [MIT](https://github.com/sindresorhus/dot-prop) +* download 1.4.7 [MIT](https://github.com/rndme/download) * dygraphs 1.1.1 [Apache;BSD;MIT;MPL](http://github.com/danvk/dygraphs) * ecc-jsbn 0.1.1 [MIT](https://github.com/quartzjer/ecc-jsbn) * ee-first 1.1.1 [MIT](https://github.com/jonathanong/ee-first) diff --git a/ui/spec/shared/parsing/resultsToCSVSpec.js b/ui/spec/shared/parsing/resultsToCSVSpec.js new file mode 100644 index 0000000000..e2e2bae2b1 --- /dev/null +++ b/ui/spec/shared/parsing/resultsToCSVSpec.js @@ -0,0 +1,45 @@ +import resultsToCSV, {formatDate} from 'shared/parsing/resultsToCSV' + +describe('formatDate', () => { + it('converts timestamp to an excel compatible date string', () => { + const timestamp = 1000000000000 + const result = formatDate(timestamp) + expect(result).to.be.a('string') + expect(+new Date(result)).to.equal(timestamp) + }) +}) + +describe('resultsToCSV', () => { + it('parses results, a time series data structure, to an object with name and CSVString keys', () => { + const results = [ + { + statement_id: 0, + series: [ + { + name: 'procstat', + columns: ['time', 'mean_cpu_usage'], + values: [ + [1505262600000, 0.06163066773148772], + [1505264400000, 2.616484718180463], + [1505266200000, 1.6174323943535571], + ], + }, + ], + }, + ] + const response = resultsToCSV(results) + const expected = { + name: 'procstat', + CSVString: `date,mean_cpu_usage\n${formatDate( + 1505262600000 + )},0.06163066773148772\n${formatDate( + 1505264400000 + )},2.616484718180463\n${formatDate(1505266200000)},1.6174323943535571`, + } + expect(response).to.have.all.keys('name', 'CSVString') + expect(response.name).to.be.a('string') + expect(response.CSVString).to.be.a('string') + expect(response.name).to.equal(expected.name) + expect(response.CSVString).to.equal(expected.CSVString) + }) +}) diff --git a/ui/src/data_explorer/components/VisHeader.js b/ui/src/data_explorer/components/VisHeader.js index 450b0d1740..855cf6b1eb 100644 --- a/ui/src/data_explorer/components/VisHeader.js +++ b/ui/src/data_explorer/components/VisHeader.js @@ -2,34 +2,60 @@ import React, {PropTypes} from 'react' import classnames from 'classnames' import _ from 'lodash' -const VisHeader = ({views, view, onToggleView, name}) => +import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries' +import resultsToCSV from 'src/shared/parsing/resultsToCSV.js' +import download from 'src/external/download.js' + +const getCSV = (query, errorThrown) => async () => { + try { + const {results} = await fetchTimeSeriesAsync({source: query.host, query}) + const {name, CSVString} = resultsToCSV(results) + download(CSVString, `${name}.csv`, 'text/plain') + } catch (error) { + errorThrown(error, 'Unable to download .csv file') + console.error(error) + } +} + +const VisHeader = ({views, view, onToggleView, name, query, errorThrown}) =>
{views.length - ? + ?
+ +
+ + .csv +
+
: null}
{name}
-const {arrayOf, func, string} = PropTypes +const {arrayOf, func, shape, string} = PropTypes VisHeader.propTypes = { views: arrayOf(string).isRequired, view: string.isRequired, onToggleView: func.isRequired, name: string.isRequired, + query: shape().isRequired, + errorThrown: func.isRequired, } export default VisHeader diff --git a/ui/src/data_explorer/components/VisView.js b/ui/src/data_explorer/components/VisView.js index 1f9ddcdb12..c6c9714a89 100644 --- a/ui/src/data_explorer/components/VisView.js +++ b/ui/src/data_explorer/components/VisView.js @@ -6,19 +6,15 @@ import RefreshingGraph from 'shared/components/RefreshingGraph' const VisView = ({ axes, view, + query, queries, cellType, templates, autoRefresh, heightPixels, editQueryStatus, - activeQueryIndex, resizerBottomHeight, }) => { - const activeQuery = queries[activeQueryIndex] - const defaultQuery = queries[0] - const query = activeQuery || defaultQuery - if (view === 'table') { if (!query) { return ( @@ -55,6 +51,7 @@ const {arrayOf, func, number, shape, string} = PropTypes VisView.propTypes = { view: string.isRequired, axes: shape(), + query: shape().isRequired, queries: arrayOf(shape()).isRequired, cellType: string, templates: arrayOf(shape()), diff --git a/ui/src/data_explorer/components/Visualization.js b/ui/src/data_explorer/components/Visualization.js index c4bba5e649..5d106e301e 100644 --- a/ui/src/data_explorer/components/Visualization.js +++ b/ui/src/data_explorer/components/Visualization.js @@ -59,6 +59,7 @@ class Visualization extends Component { activeQueryIndex, isInDataExplorer, resizerBottomHeight, + errorThrown, } = this.props const {source: {links: {proxy}}} = this.context const {view} = this.state @@ -73,6 +74,10 @@ class Visualization extends Component { return {host: [proxy], text: s.text, id: s.id, queryConfig: s.queryConfig} }) + const activeQuery = queries[activeQueryIndex] + const defaultQuery = queries[0] + const query = activeQuery || defaultQuery + return (
@@ -148,6 +155,7 @@ Visualization.propTypes = { }), }), resizerBottomHeight: number, + errorThrown: func.isRequired, } export default Visualization diff --git a/ui/src/data_explorer/containers/DataExplorer.js b/ui/src/data_explorer/containers/DataExplorer.js index 13b3c0408c..c8312a3efa 100644 --- a/ui/src/data_explorer/containers/DataExplorer.js +++ b/ui/src/data_explorer/containers/DataExplorer.js @@ -124,6 +124,7 @@ class DataExplorer extends Component { autoRefresh={autoRefresh} timeRange={timeRange} queryConfigs={queryConfigs} + errorThrown={errorThrownAction} activeQueryIndex={activeQueryIndex} editQueryStatus={queryConfigActions.editQueryStatus} views={VIS_VIEWS} diff --git a/ui/src/external/download.js b/ui/src/external/download.js new file mode 100644 index 0000000000..358a3dd9ad --- /dev/null +++ b/ui/src/external/download.js @@ -0,0 +1,137 @@ +// download.js v4.2, by dandavis; 2008-2016. [CCBY2] see http://danml.com/download.html for tests/usage +// v1 landed a FF+Chrome compat way of downloading strings to local un-named files, upgraded to use a hidden frame and optional mime +// v2 added named files via a[download], msSaveBlob, IE (10+) support, and window.URL support for larger+faster saves than dataURLs +// v3 added dataURL and Blob Input, bind-toggle arity, and legacy dataURL fallback was improved with force-download mime and base64 support. 3.1 improved safari handling. +// v4 adds AMD/UMD, commonJS, and plain browser support +// v4.1 adds url download capability via solo URL argument (same domain/CORS only) +// v4.2 adds semantic variable names, long (over 2MB) dataURL support, and hidden by default temp anchors +// https://github.com/rndme/download + +const dataUrlToBlob = (myBlob, strUrl) => { + const parts = strUrl.split(/[:;,]/), + type = parts[1], + decoder = parts[2] === 'base64' ? atob : decodeURIComponent, + binData = decoder(parts.pop()), + mx = binData.length, + uiArr = new Uint8Array(mx) + + for (let i = 0; i < mx; ++i) { + uiArr[i] = binData.charCodeAt(i) + } + + return new myBlob([uiArr], {type}) +} + +const download = (data, strFileName, strMimeType) => { + const _window = window // this script is only for browsers anyway... + const defaultMime = 'application/octet-stream' // this default mime also triggers iframe downloads + let mimeType = strMimeType || defaultMime + let payload = data + let url = !strFileName && !strMimeType && payload + const anchor = document.createElement('a') + const toString = a => `${a}` + let myBlob = _window.Blob || _window.MozBlob || _window.WebKitBlob || toString + let fileName = strFileName || 'download' + let reader + myBlob = myBlob.call ? myBlob.bind(_window) : Blob + + if (url && url.length < 2048) { + // if no filename and no mime, assume a url was passed as the only argument + fileName = url.split('/').pop().split('?')[0] + anchor.href = url // assign href prop to temp anchor + if (anchor.href.indexOf(url) !== -1) { + // if the browser determines that it's a potentially valid url path: + const ajax = new XMLHttpRequest() + ajax.open('GET', url, true) + ajax.responseType = 'blob' + ajax.onload = function(e) { + download(e.target.response, fileName, defaultMime) + } + setTimeout(function() { + ajax.send() + }, 0) // allows setting custom ajax headers using the return: + return ajax + } // end if valid url? + } // end if url? + + const saver = (saverUrl, winMode) => { + if ('download' in anchor) { + // html5 A[download] + anchor.href = saverUrl + anchor.setAttribute('download', fileName) + anchor.className = 'download-js-link' + anchor.innerHTML = 'downloading...' + anchor.style.display = 'none' + document.body.appendChild(anchor) + setTimeout(function() { + anchor.click() + document.body.removeChild(anchor) + if (winMode === true) { + setTimeout(function() { + _window.URL.revokeObjectURL(anchor.href) + }, 250) + } + }, 66) + return true + } + + // do iframe dataURL download (old ch+FF): + const f = document.createElement('iframe') + document.body.appendChild(f) + + if (!winMode) { + // force a mime that will download: + url = `data:${url.replace(/^data:([\w\/\-\+]+)/, defaultMime)}` + } + f.src = url + setTimeout(function() { + document.body.removeChild(f) + }, 333) + } // end saver + + // go ahead and download dataURLs right away + if (/^data\:[\w+\-]+\/[\w+\-]+[,;]/.test(payload)) { + if (payload.length > 1024 * 1024 * 1.999 && myBlob !== toString) { + payload = dataUrlToBlob(myBlob, payload) + mimeType = payload.type || defaultMime + } else { + return navigator.msSaveBlob // IE10 can't do a[download], only Blobs: + ? navigator.msSaveBlob(dataUrlToBlob(myBlob, payload), fileName) + : saver(payload) // everyone else can save dataURLs un-processed + } + } // end if dataURL passed? + + const blob = + payload instanceof myBlob + ? payload + : new myBlob([payload], {type: mimeType}) + + if (navigator.msSaveBlob) { + // IE10+ : (has Blob, but not a[download] or URL) + return navigator.msSaveBlob(blob, fileName) + } + + if (_window.URL) { + // simple fast and modern way using Blob and URL: + saver(_window.URL.createObjectURL(blob), true) + } else { + // handle non-Blob()+non-URL browsers: + if (typeof blob === 'string' || blob.constructor === toString) { + try { + return saver(`data:${mimeType};base64,${_window.btoa(blob)}`) + } catch (y) { + return saver(`data:${mimeType},${encodeURIComponent(blob)}`) + } + } + + // Blob but not URL support: + reader = new FileReader() + reader.onload = function() { + saver(this.result) + } + reader.readAsDataURL(blob) + } + return true +} /* end download() */ + +export default download diff --git a/ui/src/shared/parsing/resultsToCSV.js b/ui/src/shared/parsing/resultsToCSV.js new file mode 100644 index 0000000000..47596b16ec --- /dev/null +++ b/ui/src/shared/parsing/resultsToCSV.js @@ -0,0 +1,23 @@ +import _ from 'lodash' +import moment from 'moment' + +export const formatDate = timestamp => + moment(timestamp).format('M/D/YYYY h:mm:ss A') + +const resultsToCSV = results => { + const {name, columns, values} = _.get(results, ['0', 'series', '0'], {}) + const [, ...cols] = columns + + const CSVString = [['date', ...cols].join(',')] + .concat( + values.map(([timestamp, ...measurements]) => + // MS Excel format + [formatDate(timestamp), ...measurements].join(',') + ) + ) + .join('\n') + + return {name, CSVString} +} + +export default resultsToCSV diff --git a/ui/src/style/unsorted.scss b/ui/src/style/unsorted.scss index df1245aaa2..41cd865de9 100644 --- a/ui/src/style/unsorted.scss +++ b/ui/src/style/unsorted.scss @@ -293,3 +293,15 @@ $tick-script-overlay-margin: 30px; display: none; } } +/* + Data Explorer download CSV button + ----------------------------------------------------------------------------- +*/ +.icon.download.dlcsv:before { + content: "\e91d"; + font-weight: bold; + color: #bec2cc; +} +.btn.btn-sm.btn-default.dlcsv { + margin-left: 10px; +}