Merge pull request #2116 from influxdata/feature/dashboard-download-csv
Feature/dashboard download csvpull/2117/merge
commit
f9031dc72d
|
@ -1,9 +1,11 @@
|
|||
## v1.3.10.0 [unreleased]
|
||||
### Bug Fixes
|
||||
1. [#2095](https://github.com/influxdata/chronograf/pull/2095): Improve the copy in the retention policy edit page
|
||||
1. [#2093](https://github.com/influxdata/chronograf/pull/2093): Fix when exporting `SHOW DATABASES` CSV has bad data
|
||||
|
||||
### Features
|
||||
1. [#2083](https://github.com/influxdata/chronograf/pull/2083): Every dashboard can now have its own time range
|
||||
1. [#2045](https://github.com/influxdata/chronograf/pull/2045): Add CSV download option in dashboard cells
|
||||
|
||||
### UI Improvements
|
||||
1. [#2111](https://github.com/influxdata/chronograf/pull/2111): Increase size of Cell Editor query tabs to reveal more of their query strings
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
import resultsToCSV, {formatDate} from 'shared/parsing/resultsToCSV'
|
||||
import {
|
||||
resultsToCSV,
|
||||
formatDate,
|
||||
dashboardtoCSV,
|
||||
} from 'shared/parsing/resultsToCSV'
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('converts timestamp to an excel compatible date string', () => {
|
||||
|
@ -29,6 +33,7 @@ describe('resultsToCSV', () => {
|
|||
]
|
||||
const response = resultsToCSV(results)
|
||||
const expected = {
|
||||
flag: 'ok',
|
||||
name: 'procstat',
|
||||
CSVString: `date,mean_cpu_usage\n${formatDate(
|
||||
1505262600000
|
||||
|
@ -36,10 +41,65 @@ describe('resultsToCSV', () => {
|
|||
1505264400000
|
||||
)},2.616484718180463\n${formatDate(1505266200000)},1.6174323943535571`,
|
||||
}
|
||||
expect(response).to.have.all.keys('name', 'CSVString')
|
||||
expect(response).to.have.all.keys('flag', 'name', 'CSVString')
|
||||
expect(response.flag).to.be.a('string')
|
||||
expect(response.name).to.be.a('string')
|
||||
expect(response.CSVString).to.be.a('string')
|
||||
expect(response.flag).to.equal(expected.flag)
|
||||
expect(response.name).to.equal(expected.name)
|
||||
expect(response.CSVString).to.equal(expected.CSVString)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dashboardtoCSV', () => {
|
||||
it('parses the array of timeseries data displayed by the dashboard cell to a CSVstring for download', () => {
|
||||
const data = [
|
||||
{
|
||||
results: [
|
||||
{
|
||||
statement_id: 0,
|
||||
series: [
|
||||
{
|
||||
name: 'procstat',
|
||||
columns: ['time', 'mean_cpu_usage'],
|
||||
values: [
|
||||
[1505262600000, 0.06163066773148772],
|
||||
[1505264400000, 2.616484718180463],
|
||||
[1505266200000, 1.6174323943535571],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
results: [
|
||||
{
|
||||
statement_id: 0,
|
||||
series: [
|
||||
{
|
||||
name: 'procstat',
|
||||
columns: ['not-time', 'mean_cpu_usage'],
|
||||
values: [
|
||||
[1505262600000, 0.06163066773148772],
|
||||
[1505264400000, 2.616484718180463],
|
||||
[1505266200000, 1.6174323943535571],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const result = dashboardtoCSV(data)
|
||||
const expected = `time,mean_cpu_usage,not-time,mean_cpu_usage\n${formatDate(
|
||||
1505262600000
|
||||
)},0.06163066773148772,1505262600000,0.06163066773148772\n${formatDate(
|
||||
1505264400000
|
||||
)},2.616484718180463,1505264400000,2.616484718180463\n${formatDate(
|
||||
1505266200000
|
||||
)},1.6174323943535571,1505266200000,1.6174323943535571`
|
||||
expect(result).to.be.a('string')
|
||||
expect(result).to.equal(expected)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -3,13 +3,17 @@ import classnames from 'classnames'
|
|||
import _ from 'lodash'
|
||||
|
||||
import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries'
|
||||
import resultsToCSV from 'src/shared/parsing/resultsToCSV.js'
|
||||
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)
|
||||
const {flag, name, CSVString} = resultsToCSV(results)
|
||||
if (flag === 'no_data') {
|
||||
errorThrown('no data', 'There are no data to download.')
|
||||
return
|
||||
}
|
||||
download(CSVString, `${name}.csv`, 'text/plain')
|
||||
} catch (error) {
|
||||
errorThrown(error, 'Unable to download .csv file')
|
||||
|
|
|
@ -51,6 +51,7 @@ const AutoRefresh = ComposedComponent => {
|
|||
}),
|
||||
}),
|
||||
editQueryStatus: func,
|
||||
grabDataForDownload: func,
|
||||
},
|
||||
|
||||
getInitialState() {
|
||||
|
@ -111,7 +112,7 @@ const AutoRefresh = ComposedComponent => {
|
|||
},
|
||||
|
||||
executeQueries(queries, templates = []) {
|
||||
const {editQueryStatus} = this.props
|
||||
const {editQueryStatus, grabDataForDownload} = this.props
|
||||
const {resolution} = this.state
|
||||
|
||||
if (!queries.length) {
|
||||
|
@ -150,12 +151,14 @@ const AutoRefresh = ComposedComponent => {
|
|||
Promise.all(timeSeriesPromises).then(timeSeries => {
|
||||
const newSeries = timeSeries.map(response => ({response}))
|
||||
const lastQuerySuccessful = !this._noResultsForQuery(newSeries)
|
||||
|
||||
this.setState({
|
||||
timeSeries: newSeries,
|
||||
lastQuerySuccessful,
|
||||
isFetching: false,
|
||||
})
|
||||
if (grabDataForDownload) {
|
||||
grabDataForDownload(timeSeries)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
@ -3,12 +3,16 @@ import _ from 'lodash'
|
|||
|
||||
import LayoutCellMenu from 'shared/components/LayoutCellMenu'
|
||||
import LayoutCellHeader from 'shared/components/LayoutCellHeader'
|
||||
import {errorThrown} from 'shared/actions/errors'
|
||||
import {dashboardtoCSV} from 'shared/parsing/resultsToCSV'
|
||||
import download from 'src/external/download.js'
|
||||
|
||||
class LayoutCell extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
isDeleting: false,
|
||||
celldata: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -30,22 +34,39 @@ class LayoutCell extends Component {
|
|||
this.props.onSummonOverlayTechnologies(cell)
|
||||
}
|
||||
|
||||
grabDataForDownload = celldata => {
|
||||
this.setState({celldata})
|
||||
}
|
||||
|
||||
handleCSVDownload = cell => () => {
|
||||
const joinedName = cell.name.split(' ').join('_')
|
||||
const {celldata} = this.state
|
||||
try {
|
||||
download(dashboardtoCSV(celldata), `${joinedName}.csv`, 'text/plain')
|
||||
} catch (error) {
|
||||
errorThrown(error, 'Unable to download .csv file')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {cell, children, isEditable} = this.props
|
||||
|
||||
const {isDeleting} = this.state
|
||||
const {isDeleting, celldata} = this.state
|
||||
const queries = _.get(cell, ['queries'], [])
|
||||
|
||||
return (
|
||||
<div className="dash-graph">
|
||||
<LayoutCellMenu
|
||||
cell={cell}
|
||||
dataExists={!!celldata.length}
|
||||
isDeleting={isDeleting}
|
||||
isEditable={isEditable}
|
||||
onDelete={this.handleDeleteCell}
|
||||
onEdit={this.handleSummonOverlay}
|
||||
handleClickOutside={this.closeMenu}
|
||||
onDeleteClick={this.handleDeleteClick}
|
||||
onCSVDownload={this.handleCSVDownload}
|
||||
/>
|
||||
<LayoutCellHeader
|
||||
queries={queries}
|
||||
|
@ -54,7 +75,13 @@ class LayoutCell extends Component {
|
|||
/>
|
||||
<div className="dash-graph--container">
|
||||
{queries.length
|
||||
? children
|
||||
? React.Children.map(children, child => {
|
||||
if (child && child.props && child.props.autoRefresh) {
|
||||
return React.cloneElement(child, {
|
||||
grabDataForDownload: this.grabDataForDownload,
|
||||
})
|
||||
}
|
||||
})
|
||||
: <div className="graph-empty">
|
||||
<button
|
||||
className="no-query--button btn btn-md btn-primary"
|
||||
|
|
|
@ -2,7 +2,15 @@ import React, {PropTypes} from 'react'
|
|||
import OnClickOutside from 'react-onclickoutside'
|
||||
|
||||
const LayoutCellMenu = OnClickOutside(
|
||||
({isDeleting, onEdit, onDeleteClick, onDelete, cell}) =>
|
||||
({
|
||||
isDeleting,
|
||||
onEdit,
|
||||
onDeleteClick,
|
||||
onDelete,
|
||||
onCSVDownload,
|
||||
dataExists,
|
||||
cell,
|
||||
}) =>
|
||||
<div
|
||||
className={
|
||||
isDeleting
|
||||
|
@ -13,6 +21,14 @@ const LayoutCellMenu = OnClickOutside(
|
|||
<div className="dash-graph-context--button" onClick={onEdit(cell)}>
|
||||
<span className="icon pencil" />
|
||||
</div>
|
||||
{dataExists
|
||||
? <div
|
||||
className="dash-graph-context--button"
|
||||
onClick={onCSVDownload(cell)}
|
||||
>
|
||||
<span className="icon download" />
|
||||
</div>
|
||||
: null}
|
||||
{isDeleting
|
||||
? <div className="dash-graph-context--button active">
|
||||
<span className="icon trash" />
|
||||
|
@ -46,6 +62,7 @@ LayoutCellMenuContainer.propTypes = {
|
|||
onDeleteClick: func,
|
||||
cell: shape(),
|
||||
isEditable: bool,
|
||||
dataExists: bool,
|
||||
}
|
||||
|
||||
LayoutCellMenu.propTypes = LayoutCellMenuContainer.propTypes
|
||||
|
|
|
@ -21,6 +21,7 @@ const RefreshingGraph = ({
|
|||
synchronizer,
|
||||
resizeCoords,
|
||||
editQueryStatus,
|
||||
grabDataForDownload,
|
||||
}) => {
|
||||
if (!queries.length) {
|
||||
return (
|
||||
|
@ -53,6 +54,7 @@ const RefreshingGraph = ({
|
|||
axes={axes}
|
||||
onZoom={onZoom}
|
||||
queries={queries}
|
||||
grabDataForDownload={grabDataForDownload}
|
||||
templates={templates}
|
||||
timeRange={timeRange}
|
||||
autoRefresh={autoRefresh}
|
||||
|
@ -82,6 +84,7 @@ RefreshingGraph.propTypes = {
|
|||
editQueryStatus: func,
|
||||
onZoom: func,
|
||||
resizeCoords: shape(),
|
||||
grabDataForDownload: func,
|
||||
}
|
||||
|
||||
export default RefreshingGraph
|
||||
|
|
|
@ -4,20 +4,50 @@ 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
|
||||
export const resultsToCSV = results => {
|
||||
if (!_.get(results, ['0', 'series', '0'])) {
|
||||
return {flag: 'no_data', name: '', CSVString: ''}
|
||||
}
|
||||
|
||||
const CSVString = [['date', ...cols].join(',')]
|
||||
.concat(
|
||||
values.map(([timestamp, ...measurements]) =>
|
||||
// MS Excel format
|
||||
[formatDate(timestamp), ...measurements].join(',')
|
||||
const {name, columns, values} = _.get(results, ['0', 'series', '0'])
|
||||
|
||||
if (columns[0] === 'time') {
|
||||
const [, ...cols] = columns
|
||||
const CSVString = [['date', ...cols].join(',')]
|
||||
.concat(
|
||||
values.map(([timestamp, ...measurements]) =>
|
||||
// MS Excel format
|
||||
[formatDate(timestamp), ...measurements].join(',')
|
||||
)
|
||||
)
|
||||
)
|
||||
.join('\n')
|
||||
.join('\n')
|
||||
return {flag: 'ok', name, CSVString}
|
||||
}
|
||||
|
||||
return {name, CSVString}
|
||||
const CSVString = [columns.join(',')]
|
||||
.concat(values.map(row => row.join(',')))
|
||||
.join('\n')
|
||||
return {flag: 'ok', name, CSVString}
|
||||
}
|
||||
|
||||
export default resultsToCSV
|
||||
export const dashboardtoCSV = data => {
|
||||
const columnNames = _.flatten(
|
||||
data.map(r => _.get(r, 'results[0].series[0].columns', []))
|
||||
)
|
||||
const timeIndices = columnNames
|
||||
.map((e, i) => (e === 'time' ? i : -1))
|
||||
.filter(e => e >= 0)
|
||||
|
||||
let values = data.map(r => _.get(r, 'results[0].series[0].values', []))
|
||||
values = _.unzip(values).map(v => _.flatten(v))
|
||||
if (timeIndices) {
|
||||
values.map(v => {
|
||||
timeIndices.forEach(i => (v[i] = formatDate(v[i])))
|
||||
return v
|
||||
})
|
||||
}
|
||||
const CSVString = [columnNames.join(',')]
|
||||
.concat(values.map(v => v.join(',')))
|
||||
.join('\n')
|
||||
return CSVString
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue