Merge branch 'master' into bugfix/unsafe-ssl
commit
8e1cb0b298
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -59,6 +59,7 @@ const Header = React.createClass({
|
|||
<TimeRangeDropdown
|
||||
onChooseTimeRange={this.handleChooseTimeRange}
|
||||
selected={timeRange}
|
||||
page="DataExplorer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'}}
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue