Merge branch 'master' into bugfix/unsafe-ssl

pull/10616/head
Andrew Watkins 2017-10-18 09:06:26 -07:00 committed by GitHub
commit 8e1cb0b298
24 changed files with 537 additions and 204 deletions

View File

@ -2,12 +2,16 @@
### Bug Fixes
1. [#2095](https://github.com/influxdata/chronograf/pull/2095): Improve the copy in the retention policy edit page
1. [#2122](https://github.com/influxdata/chronograf/pull/2122): Fix 'could not connect to source' bug on source creation with unsafe-ssl
1. [#2093](https://github.com/influxdata/chronograf/pull/2093): Fix when exporting `SHOW DATABASES` CSV has bad data
1. [#2098](https://github.com/influxdata/chronograf/pull/2098): Fix not-equal-to highlighting in Kapacitor Rule Builder
### 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. [#2119](https://github.com/influxdata/chronograf/pull/2119): Add support for graph zooming and point display on the millisecond-level
## v1.3.9.0 [2017-10-06]
### Bug Fixes
@ -30,6 +34,7 @@
1. [#1992](https://github.com/influxdata/chronograf/pull/1992): Add .csv download button to data explorer
1. [#2082](https://github.com/influxdata/chronograf/pull/2082): Add Data Explorer InfluxQL query and location query synchronization, so queries can be shared via a a URL
1. [#1996](https://github.com/influxdata/chronograf/pull/1996): Able to switch InfluxDB sources on a per graph basis
1. [#2041](https://github.com/influxdata/chronograf/pull/2041): Add now() as an option in the Dashboard date picker
### UI Improvements
1. [#2002](https://github.com/influxdata/chronograf/pull/2002): Require a second click when deleting a dashboard cell

View File

@ -205,14 +205,23 @@ func (g *GroupByVar) Exec(query string) {
durStr := query[start+len(whereClause):]
// attempt to parse out a relative time range
dur, err := g.parseRelative(durStr)
// locate duration literal start
prefix := "time > now() - "
lowerDuration, err := g.parseRelative(durStr, prefix)
if err == nil {
// we parsed relative duration successfully
g.Duration = dur
return
prefix := "time < now() - "
upperDuration, err := g.parseRelative(durStr, prefix)
if err != nil {
g.Duration = lowerDuration
return
}
g.Duration = lowerDuration - upperDuration
if g.Duration < 0 {
g.Duration = -g.Duration
}
}
dur, err = g.parseAbsolute(durStr)
dur, err := g.parseAbsolute(durStr)
if err == nil {
// we found an absolute time range
g.Duration = dur
@ -223,9 +232,7 @@ func (g *GroupByVar) Exec(query string) {
// 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() - "
func (g *GroupByVar) parseRelative(fragment string, prefix string) (time.Duration, error) {
start := strings.Index(fragment, prefix)
if start == -1 {
return time.Duration(0), errors.New("not a relative duration")
@ -298,11 +305,19 @@ func (g *GroupByVar) parseAbsolute(fragment string) (time.Duration, error) {
}
func (g *GroupByVar) String() string {
duration := int64(g.Duration/time.Second) / int64(g.Resolution) * 3
if duration == 0 {
duration = 1
// The function is: ((total_seconds * millisecond_converstion) / group_by) = pixels / 3
// Number of points given the pixels
pixels := float64(g.Resolution) / 3.0
msPerPixel := float64(g.Duration/time.Millisecond) / pixels
secPerPixel := float64(g.Duration/time.Second) / pixels
if secPerPixel < 1.0 {
if msPerPixel < 1.0 {
msPerPixel = 1.0
}
return "time(" + strconv.FormatInt(int64(msPerPixel), 10) + "ms)"
}
return "time(" + strconv.Itoa(int(duration)) + "s)"
// If groupby is more than 1 second round to the second
return "time(" + strconv.FormatInt(int64(secPerPixel), 10) + "s)"
}
func (g *GroupByVar) Name() string {

View File

@ -2,54 +2,61 @@ 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
name string
query string
want string
resolution uint // the screen resolution to render queries into
}{
{
"relative time",
"SELECT mean(usage_idle) FROM cpu WHERE time > now() - 180d GROUP BY :interval:",
4320 * time.Hour,
1000,
10 * time.Second,
name: "relative time only lower bound with one day of duration",
query: "SELECT mean(usage_idle) FROM cpu WHERE time > now() - 1d GROUP BY :interval:",
resolution: 1000,
want: "time(259s)",
},
{
"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,
name: "relative time with relative upper bound with one minute of duration",
query: "SELECT mean(usage_idle) FROM cpu WHERE time > now() - 3m AND time < now() - 2m GROUP BY :interval:",
resolution: 1000,
want: "time(180ms)",
},
{
"absolute time with nano",
"SELECT mean(usage_idle) FROM cpu WHERE time > '2017-07-24T15:33:42.994Z' and time < '2017-08-24T15:33:42.994Z' GROUP BY :interval:",
744 * time.Hour,
1000,
10 * time.Second,
name: "relative time with relative lower bound and now upper with one day of duration",
query: "SELECT mean(usage_idle) FROM cpu WHERE time > now() - 1d AND time < now() GROUP BY :interval:",
resolution: 1000,
want: "time(259s)",
},
{
name: "absolute time with one minute of duration",
query: "SELECT mean(usage_idle) FROM cpu WHERE time > '1985-10-25T00:01:00Z' and time < '1985-10-25T00:02:00Z' GROUP BY :interval:",
resolution: 1000,
want: "time(180ms)",
},
{
name: "absolute time with nano seconds and zero duraiton",
query: "SELECT mean(usage_idle) FROM cpu WHERE time > '2017-07-24T15:33:42.994Z' and time < '2017-07-24T15:33:42.994Z' GROUP BY :interval:",
resolution: 1000,
want: "time(1ms)",
},
}
for _, test := range gbvTests {
t.Run(test.name, func(t *testing.T) {
gbv := chronograf.GroupByVar{
Var: ":interval:",
Resolution: test.resolution,
ReportingInterval: test.reportingInterval,
Var: ":interval:",
Resolution: test.resolution,
}
gbv.Exec(test.query)
got := gbv.String()
if gbv.Duration != test.expected {
t.Fatalf("%q - durations not equal! Want: %s, Got: %s", test.name, test.expected, gbv.Duration)
if got != test.want {
t.Fatalf("%q - durations not equal! Want: %s, Got: %s", test.name, test.want, got)
}
})
}

View File

@ -274,16 +274,25 @@ func TestGroupByVarString(t *testing.T) {
ReportingInterval: 10 * time.Second,
Duration: 24 * time.Hour,
},
want: "time(369s)",
want: "time(370s)",
},
{
name: "String() outputs a minimum of 1s intervals",
name: "String() milliseconds if less than one second intervals",
tvar: &chronograf.GroupByVar{
Resolution: 100000,
ReportingInterval: 10 * time.Second,
Duration: time.Hour,
},
want: "time(1s)",
want: "time(107ms)",
},
{
name: "String() milliseconds if less than one millisecond",
tvar: &chronograf.GroupByVar{
Resolution: 100000,
ReportingInterval: 10 * time.Second,
Duration: time.Second,
},
want: "time(1ms)",
},
}
for _, tt := range tests {

View File

@ -101,7 +101,7 @@
"bootstrap": "^3.3.7",
"calculate-size": "^1.1.1",
"classnames": "^2.2.3",
"dygraphs": "^2.0.0",
"dygraphs": "influxdata/dygraphs",
"eslint-plugin-babel": "^4.1.2",
"fast.js": "^0.1.1",
"fixed-data-table": "^0.6.1",

View File

@ -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)
})
})

View File

@ -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')

View File

@ -59,6 +59,7 @@ const Header = React.createClass({
<TimeRangeDropdown
onChooseTimeRange={this.handleChooseTimeRange}
selected={timeRange}
page="DataExplorer"
/>
</div>
</div>

View File

@ -3,132 +3,66 @@ import buildInfluxQLQuery from 'utils/influxql'
import AutoRefresh from 'shared/components/AutoRefresh'
import LineGraph from 'shared/components/LineGraph'
import TimeRangeDropdown from 'shared/components/TimeRangeDropdown'
import underlayCallback from 'src/kapacitor/helpers/ruleGraphUnderlay'
const RefreshingLineGraph = AutoRefresh(LineGraph)
const {shape, string, func} = PropTypes
const RuleGraph = ({
query,
source,
timeRange: {lower},
timeRange,
rule,
onChooseTimeRange,
}) => {
const autoRefreshMs = 30000
const queryText = buildInfluxQLQuery({lower}, query)
const queries = [{host: source.links.proxy, text: queryText}]
const kapacitorLineColors = ['#4ED8A0']
export const RuleGraph = React.createClass({
propTypes: {
source: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}).isRequired,
query: shape({}).isRequired,
rule: shape({}).isRequired,
timeRange: shape({}).isRequired,
onChooseTimeRange: func.isRequired,
},
render() {
const {
query,
source,
timeRange: {lower},
timeRange,
rule,
onChooseTimeRange,
} = this.props
const autoRefreshMs = 30000
const queryText = buildInfluxQLQuery({lower}, query)
const queries = [{host: source.links.proxy, text: queryText}]
const kapacitorLineColors = ['#4ED8A0']
if (!queryText) {
return (
<div className="rule-builder--graph-empty">
<p>
Select a <strong>Time-Series</strong> to preview on a graph
</p>
</div>
)
}
if (!queryText) {
return (
<div className="rule-builder--graph">
<div className="rule-builder--graph-options">
<p>Preview Data from</p>
<TimeRangeDropdown
onChooseTimeRange={onChooseTimeRange}
selected={timeRange}
preventCustomTimeRange={true}
/>
</div>
<RefreshingLineGraph
queries={queries}
autoRefresh={autoRefreshMs}
underlayCallback={this.createUnderlayCallback()}
isGraphFilled={false}
overrideLineColors={kapacitorLineColors}
ruleValues={rule.values}
/>
<div className="rule-builder--graph-empty">
<p>
Select a <strong>Time-Series</strong> to preview on a graph
</p>
</div>
)
},
}
createUnderlayCallback() {
const {rule} = this.props
return (canvas, area, dygraph) => {
if (rule.trigger !== 'threshold' || rule.values.value === '') {
return
}
return (
<div className="rule-builder--graph">
<div className="rule-builder--graph-options">
<p>Preview Data from</p>
<TimeRangeDropdown
onChooseTimeRange={onChooseTimeRange}
selected={timeRange}
preventCustomTimeRange={true}
/>
</div>
<RefreshingLineGraph
queries={queries}
isGraphFilled={false}
ruleValues={rule.values}
autoRefresh={autoRefreshMs}
overrideLineColors={kapacitorLineColors}
underlayCallback={underlayCallback(rule)}
/>
</div>
)
}
const theOnePercent = 0.01
let highlightStart = 0
let highlightEnd = 0
switch (rule.values.operator) {
case 'equal to or greater':
case 'greater than': {
highlightStart = rule.values.value
highlightEnd = dygraph.yAxisRange()[1]
break
}
case 'equal to or less than':
case 'less than': {
highlightStart = dygraph.yAxisRange()[0]
highlightEnd = rule.values.value
break
}
case 'not equal to':
case 'equal to': {
const width =
theOnePercent * (dygraph.yAxisRange()[1] - dygraph.yAxisRange()[0])
highlightStart = +rule.values.value - width
highlightEnd = +rule.values.value + width
break
}
case 'outside range': {
const {rangeValue, value} = rule.values
highlightStart = Math.min(+value, +rangeValue)
highlightEnd = Math.max(+value, +rangeValue)
canvas.fillStyle = 'rgba(78, 216, 160, 0.3)'
canvas.fillRect(area.x, area.y, area.w, area.h)
break
}
case 'inside range': {
const {rangeValue, value} = rule.values
highlightStart = Math.min(+value, +rangeValue)
highlightEnd = Math.max(+value, +rangeValue)
break
}
}
const bottom = dygraph.toDomYCoord(highlightStart)
const top = dygraph.toDomYCoord(highlightEnd)
canvas.fillStyle =
rule.values.operator === 'outside range'
? 'rgba(41, 41, 51, 1)'
: 'rgba(78, 216, 160, 0.3)'
canvas.fillRect(area.x, top, area.w, bottom - top)
}
},
})
RuleGraph.propTypes = {
source: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}).isRequired,
query: shape({}).isRequired,
rule: shape({}).isRequired,
timeRange: shape({}).isRequired,
onChooseTimeRange: func.isRequired,
}
export default RuleGraph

View File

@ -4,6 +4,7 @@ import Dropdown from 'shared/components/Dropdown'
const mapToItems = (arr, type) => arr.map(text => ({text, type}))
const operators = mapToItems(OPERATORS, 'operator')
const noopSubmit = e => e.preventDefault()
const Threshold = ({
rule: {values: {operator, value, rangeValue}},
@ -24,7 +25,7 @@ const Threshold = ({
selected={operator}
onChoose={onDropdownChange}
/>
<form style={{display: 'flex'}}>
<form style={{display: 'flex'}} onSubmit={noopSubmit}>
<input
className="form-control input-sm form-malachite monotype"
style={{width: '160px', marginLeft: '6px'}}

View File

@ -21,16 +21,27 @@ export const defaultRuleConfigs = {
export const defaultEveryFrequency = '30s'
// constants taken from https://github.com/influxdata/chronograf/blob/870dbc72d1a8b784eaacad5eeea79fc54968b656/kapacitor/operators.go#L13
export const EQUAL_TO = 'equal to'
export const LESS_THAN = 'less than'
export const GREATER_THAN = 'greater than'
export const NOT_EQUAL_TO = 'not equal to'
export const INSIDE_RANGE = 'inside range'
export const OUTSIDE_RANGE = 'outside range'
export const EQUAL_TO_OR_GREATER_THAN = 'equal to or greater'
export const EQUAL_TO_OR_LESS_THAN = 'equal to or less than'
export const OPERATORS = [
'greater than',
'equal to or greater',
'equal to or less than',
'less than',
'equal to',
'not equal to',
'inside range',
'outside range',
GREATER_THAN,
EQUAL_TO_OR_GREATER_THAN,
EQUAL_TO_OR_LESS_THAN,
LESS_THAN,
EQUAL_TO,
NOT_EQUAL_TO,
INSIDE_RANGE,
OUTSIDE_RANGE,
]
// export const RELATIONS = ['once', 'more than ', 'less than'];
export const PERIODS = ['1m', '5m', '10m', '30m', '1h', '2h', '24h']
export const CHANGES = ['change', '% change']

View File

@ -0,0 +1,100 @@
import {
EQUAL_TO,
LESS_THAN,
NOT_EQUAL_TO,
GREATER_THAN,
INSIDE_RANGE,
OUTSIDE_RANGE,
EQUAL_TO_OR_LESS_THAN,
EQUAL_TO_OR_GREATER_THAN,
} from 'src/kapacitor/constants'
const HIGHLIGHT = 'rgba(78, 216, 160, 0.3)'
const BACKGROUND = 'rgba(41, 41, 51, 1)'
const getFillColor = operator => {
const backgroundColor = BACKGROUND
const highlightColor = HIGHLIGHT
if (operator === OUTSIDE_RANGE) {
return backgroundColor
}
if (operator === NOT_EQUAL_TO) {
return backgroundColor
}
return highlightColor
}
const underlayCallback = rule => (canvas, area, dygraph) => {
const {values} = rule
const {operator, value} = values
if (rule.trigger !== 'threshold' || value === '' || !isFinite(value)) {
return
}
const theOnePercent = 0.01
let highlightStart = 0
let highlightEnd = 0
switch (operator) {
case `${EQUAL_TO_OR_GREATER_THAN}`:
case `${GREATER_THAN}`: {
highlightStart = value
highlightEnd = dygraph.yAxisRange()[1]
break
}
case `${EQUAL_TO_OR_LESS_THAN}`:
case `${LESS_THAN}`: {
highlightStart = dygraph.yAxisRange()[0]
highlightEnd = value
break
}
case `${EQUAL_TO}`: {
const width =
theOnePercent * (dygraph.yAxisRange()[1] - dygraph.yAxisRange()[0])
highlightStart = +value - width
highlightEnd = +value + width
break
}
case `${NOT_EQUAL_TO}`: {
const width =
theOnePercent * (dygraph.yAxisRange()[1] - dygraph.yAxisRange()[0])
highlightStart = +value - width
highlightEnd = +value + width
canvas.fillStyle = HIGHLIGHT
canvas.fillRect(area.x, area.y, area.w, area.h)
break
}
case `${OUTSIDE_RANGE}`: {
highlightStart = Math.min(+value, +values.rangeValue)
highlightEnd = Math.max(+value, +values.rangeValue)
canvas.fillStyle = HIGHLIGHT
canvas.fillRect(area.x, area.y, area.w, area.h)
break
}
case `${INSIDE_RANGE}`: {
highlightStart = Math.min(+value, +values.rangeValue)
highlightEnd = Math.max(+value, +values.rangeValue)
break
}
}
const bottom = dygraph.toDomYCoord(highlightStart)
const top = dygraph.toDomYCoord(highlightEnd)
const fillColor = getFillColor(operator)
canvas.fillStyle = fillColor
canvas.fillRect(area.x, top, area.w, bottom - top)
}
export default underlayCallback

View 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)
}
})
},

View File

@ -8,6 +8,9 @@ const dateFormat = 'YYYY-MM-DD HH:mm'
class CustomTimeRange extends Component {
constructor(props) {
super(props)
this.state = {
isNow: this.props.timeRange.upper === 'now()',
}
}
componentDidMount() {
@ -68,6 +71,14 @@ class CustomTimeRange extends Component {
this.upperCal.refresh()
}
handleToggleNow = () => {
this.setState({isNow: !this.state.isNow})
}
handleNowOff = () => {
this.setState({isNow: false})
}
/*
* Upper and lower time ranges are passed in with single quotes as part of
* the string literal, i.e. "'2015-09-23T18:00:00.000Z'". Remove them
@ -78,6 +89,10 @@ class CustomTimeRange extends Component {
return ''
}
if (timeRange === 'now()') {
return moment(new Date()).format(dateFormat)
}
// If the given time range is relative, create a fixed timestamp based on its value
if (timeRange.match(/^now/)) {
const [, duration, unitOfTime] = timeRange.match(/(\d+)(\w+)/)
@ -89,10 +104,16 @@ class CustomTimeRange extends Component {
handleClick = () => {
const {onApplyTimeRange, onClose} = this.props
const {isNow} = this.state
const lower = this.lowerCal.getDate().toISOString()
const upper = this.upperCal.getDate().toISOString()
onApplyTimeRange({lower, upper})
if (isNow) {
onApplyTimeRange({lower, upper: 'now()'})
} else {
onApplyTimeRange({lower, upper})
}
if (onClose) {
onClose()
@ -142,6 +163,10 @@ class CustomTimeRange extends Component {
}
render() {
const {isNow} = this.state
const {page} = this.props
const isNowDisplayed = page !== 'DataExplorer'
return (
<div className="custom-time--container">
<div className="custom-time--shortcuts">
@ -159,7 +184,7 @@ class CustomTimeRange extends Component {
<div className="custom-time--wrap">
<div className="custom-time--dates" onClick={this.handleRefreshCals}>
<div
className="lower-container"
className="custom-time--lower-container"
ref={r => (this.lowerContainer = r)}
>
<input
@ -170,15 +195,33 @@ class CustomTimeRange extends Component {
/>
</div>
<div
className="upper-container"
className="custom-time--upper-container"
ref={r => (this.upperContainer = r)}
disabled={isNow}
>
{isNowDisplayed
? <div
className={`btn btn-xs custom-time--now ${isNow
? 'btn-primary'
: 'btn-default'}`}
onClick={this.handleToggleNow}
>
Now
</div>
: null}
<input
className="custom-time--upper form-control input-sm"
ref={r => (this.upper = r)}
placeholder="to"
onKeyUp={this.handleRefreshCals}
disabled={isNow}
/>
{isNow && page !== 'DataExplorer'
? <div
className="custom-time--mask"
onClick={this.handleNowOff}
/>
: null}
</div>
</div>
<div
@ -202,6 +245,7 @@ CustomTimeRange.propTypes = {
upper: string,
}).isRequired,
onClose: func,
page: string,
}
export default CustomTimeRange

View File

@ -13,7 +13,7 @@ class CustomTimeRangeOverlay extends Component {
}
render() {
const {onClose, timeRange, onApplyTimeRange} = this.props
const {onClose, timeRange, onApplyTimeRange, page} = this.props
return (
<div className="custom-time--overlay">
@ -21,6 +21,7 @@ class CustomTimeRangeOverlay extends Component {
onApplyTimeRange={onApplyTimeRange}
timeRange={timeRange}
onClose={onClose}
page={page}
/>
</div>
)
@ -36,6 +37,7 @@ CustomTimeRangeOverlay.propTypes = {
upper: string,
}).isRequired,
onClose: func,
page: string,
}
export default OnClickOutside(CustomTimeRangeOverlay)

View File

@ -1,4 +1,4 @@
import React, {PropTypes} from 'react'
import React, {Component, PropTypes} from 'react'
import WidgetCell from 'shared/components/WidgetCell'
import LayoutCell from 'shared/components/LayoutCell'
import RefreshingGraph from 'shared/components/RefreshingGraph'
@ -15,6 +15,30 @@ const getSource = (cell, source, sources, defaultSource) => {
return sources.find(src => src.links.self === s) || defaultSource
}
class LayoutState extends Component {
constructor(props) {
super(props)
this.state = {
celldata: [],
}
}
grabDataForDownload = celldata => {
this.setState({celldata})
}
render() {
const {celldata} = this.state
return (
<Layout
{...this.props}
celldata={celldata}
grabDataForDownload={this.grabDataForDownload}
/>
)
}
}
const Layout = (
{
host,
@ -33,6 +57,8 @@ const Layout = (
resizeCoords,
onCancelEditCell,
onSummonOverlayTechnologies,
grabDataForDownload,
celldata,
},
{source: defaultSource}
) =>
@ -40,6 +66,7 @@ const Layout = (
cell={cell}
isEditable={isEditable}
onEditCell={onEditCell}
celldata={celldata}
onDeleteCell={onDeleteCell}
onCancelEditCell={onCancelEditCell}
onSummonOverlayTechnologies={onSummonOverlayTechnologies}
@ -56,6 +83,7 @@ const Layout = (
templates={templates}
autoRefresh={autoRefresh}
synchronizer={synchronizer}
grabDataForDownload={grabDataForDownload}
resizeCoords={resizeCoords}
queries={buildQueriesForLayouts(
cell,
@ -72,7 +100,7 @@ Layout.contextTypes = {
source: shape(),
}
Layout.propTypes = {
const propTypes = {
autoRefresh: number.isRequired,
timeRange: shape({
lower: string.isRequired,
@ -115,4 +143,11 @@ Layout.propTypes = {
sources: arrayOf(shape()),
}
export default Layout
LayoutState.propTypes = {...propTypes}
Layout.propTypes = {
...propTypes,
grabDataForDownload: func,
celldata: arrayOf(shape()),
}
export default LayoutState

View File

@ -3,6 +3,9 @@ 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) {
@ -30,8 +33,19 @@ class LayoutCell extends Component {
this.props.onSummonOverlayTechnologies(cell)
}
handleCSVDownload = cell => () => {
const joinedName = cell.name.split(' ').join('_')
const {celldata} = this.props
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 {cell, children, isEditable, celldata} = this.props
const {isDeleting} = this.state
const queries = _.get(cell, ['queries'], [])
@ -40,12 +54,14 @@ class LayoutCell extends Component {
<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}
@ -84,6 +100,7 @@ LayoutCell.propTypes = {
onSummonOverlayTechnologies: func,
isEditable: bool,
onCancelEditCell: func,
celldata: arrayOf(shape()),
}
export default LayoutCell

View File

@ -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

View File

@ -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

View File

@ -9,7 +9,9 @@ import CustomTimeRangeOverlay from 'shared/components/CustomTimeRangeOverlay'
import timeRanges from 'hson!shared/data/timeRanges.hson'
import {DROPDOWN_MENU_MAX_HEIGHT} from 'shared/constants/index'
const dateFormat = 'YYYY-MM-DD HH:mm'
const emptyTime = {lower: '', upper: ''}
const format = t => moment(t.replace(/\'/g, '')).format(dateFormat)
class TimeRangeDropdown extends Component {
constructor(props) {
@ -29,8 +31,10 @@ class TimeRangeDropdown extends Component {
findTimeRangeInputValue = ({upper, lower}) => {
if (upper && lower) {
const format = t =>
moment(t.replace(/\'/g, '')).format('YYYY-MM-DD HH:mm')
if (upper === 'now()') {
return `${format(lower)} - Now`
}
return `${format(lower)} - ${format(upper)}`
}
@ -44,7 +48,7 @@ class TimeRangeDropdown extends Component {
handleSelection = timeRange => () => {
this.props.onChooseTimeRange(timeRange)
this.setState({isOpen: false})
this.setState({customTimeRange: emptyTime, isOpen: false})
}
toggleMenu = () => {
@ -69,16 +73,18 @@ class TimeRangeDropdown extends Component {
}
render() {
const {selected, preventCustomTimeRange} = this.props
const {selected, preventCustomTimeRange, page} = this.props
const {isOpen, customTimeRange, isCustomTimeRangeOpen} = this.state
const isRelativeTimeRange = selected.upper === null
const isNow = selected.upper === 'now()'
return (
<div className="time-range-dropdown">
<div
className={classnames('dropdown', {
'dropdown-160': isRelativeTimeRange,
'dropdown-290': !isRelativeTimeRange,
'dropdown-210': isNow,
'dropdown-290': !isRelativeTimeRange && !isNow,
open: isOpen,
})}
>
@ -136,6 +142,7 @@ class TimeRangeDropdown extends Component {
isVisible={isCustomTimeRangeOpen}
onToggle={this.handleToggleCustomTimeRange}
onClose={this.handleCloseCustomTimeRange}
page={page}
/>
: null}
</div>
@ -145,6 +152,10 @@ class TimeRangeDropdown extends Component {
const {bool, func, shape, string} = PropTypes
TimeRangeDropdown.defaultProps = {
page: 'default',
}
TimeRangeDropdown.propTypes = {
selected: shape({
lower: string,
@ -152,6 +163,7 @@ TimeRangeDropdown.propTypes = {
}).isRequired,
onChooseTimeRange: func.isRequired,
preventCustomTimeRange: bool,
page: string,
}
export default OnClickOutside(TimeRangeDropdown)

View File

@ -3,6 +3,8 @@ import BigNumber from 'bignumber.js'
const ADD_FACTOR = 1.1
const SUB_FACTOR = 0.9
const checkNumeric = num => (isFinite(num) ? num : null)
const considerEmpty = (userNumber, number) => {
if (userNumber) {
return +userNumber
@ -17,13 +19,15 @@ const getRange = (
ruleValues = {value: null, rangeValue: null, operator: ''}
) => {
const {value, rangeValue, operator} = ruleValues
const [userMin, userMax] = userSelectedRange
const [uMin, uMax] = userSelectedRange
const userMin = checkNumeric(uMin)
const userMax = checkNumeric(uMax)
const addPad = bigNum => bigNum.times(ADD_FACTOR).toNumber()
const subPad = bigNum => bigNum.times(SUB_FACTOR).toNumber()
const pad = v => {
if (v === null || v === '' || v === undefined) {
if (v === null || v === '' || !isFinite(v)) {
return null
}

View File

@ -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
}

View File

@ -33,6 +33,10 @@
align-items: flex-start;
justify-content: space-between;
}
.custom-time--upper-container,
.custom-time--lower-container {
position: relative;
}
.custom-time--lower {
margin-right: 4px;
text-align: center;
@ -41,6 +45,21 @@
margin-left: 4px;
text-align: center;
}
.custom-time--mask {
position: absolute;
width: 100%;
height: calc(100% - 30px);
top: 30px;
left: 0;
opacity: 0.5;
background-color: $g5-pepper;
z-index: 2;
}
.custom-time--now {
position: absolute !important;
right: 0;
top: 4px;
}
.custom-time--shortcuts {
@include no-user-select();
align-items: stretch;

View File

@ -2516,9 +2516,9 @@ dot-prop@^3.0.0:
dependencies:
is-obj "^1.0.0"
dygraphs@^2.0.0:
dygraphs@influxdata/dygraphs:
version "2.0.0"
resolved "https://registry.yarnpkg.com/dygraphs/-/dygraphs-2.0.0.tgz#30be283c7e32aa7536d4ea9da61ebe9f363d7c98"
resolved "https://codeload.github.com/influxdata/dygraphs/tar.gz/9cc90443f58c11b45473516a97d4bb3a68a620c2"
ecc-jsbn@~0.1.1:
version "0.1.1"