Merge branch 'master' into responsive-admin-tabs

pull/10616/head
Andrew Watkins 2017-10-18 10:05:58 -07:00 committed by GitHub
commit 511777396c
34 changed files with 794 additions and 506 deletions

View File

@ -1,13 +1,19 @@
## 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. [#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. [#2120](https://github.com/influxdata/chronograf/pull/2120): Improve appearance of Admin Page tabs on smaller screens
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 +36,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

@ -26,7 +26,7 @@ import {
TickscriptPage,
} from 'src/kapacitor'
import {AdminPage} from 'src/admin'
import {CreateSource, SourcePage, ManageSources} from 'src/sources'
import {SourcePage, ManageSources} from 'src/sources'
import NotFound from 'shared/components/NotFound'
import {getMe} from 'shared/apis'
@ -127,7 +127,7 @@ const Root = React.createClass({
<Route path="/login" component={UserIsNotAuthenticated(Login)} />
<Route
path="/sources/new"
component={UserIsAuthenticated(CreateSource)}
component={UserIsAuthenticated(SourcePage)}
/>
<Route path="/sources/:sourceID" component={UserIsAuthenticated(App)}>
<Route component={CheckSources}>

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

@ -1,4 +1,5 @@
import React, {PropTypes} from 'react'
import TickscriptHeader from 'src/kapacitor/components/TickscriptHeader'
import TickscriptEditor from 'src/kapacitor/components/TickscriptEditor'
@ -48,7 +49,9 @@ const {arrayOf, bool, func, shape, string} = PropTypes
Tickscript.propTypes = {
onSave: func.isRequired,
source: shape(),
source: shape({
id: string,
}),
task: shape({
id: string,
script: string,

View File

@ -1,4 +1,6 @@
import React, {PropTypes} from 'react'
import {Link} from 'react-router'
import SourceIndicator from 'shared/components/SourceIndicator'
import TickscriptType from 'src/kapacitor/components/TickscriptType'
import MultiSelectDBDropdown from 'shared/components/MultiSelectDBDropdown'
@ -11,6 +13,7 @@ const addName = list => list.map(l => ({...l, name: `${l.db}.${l.rp}`}))
const TickscriptHeader = ({
task: {id, type, dbrps},
task,
source,
onSave,
onChangeType,
onChangeID,
@ -31,6 +34,12 @@ const TickscriptHeader = ({
selectedItems={addName(dbrps)}
onApply={onSelectDbrps}
/>
<Link
className="btn btn-sm btn-default"
to={`/sources/${source.id}/alert-rules`}
>
Cancel
</Link>
<button
className="btn btn-success btn-sm"
title={id ? '' : 'ID your TICKscript to save'}
@ -47,6 +56,9 @@ const {arrayOf, bool, func, shape, string} = PropTypes
TickscriptHeader.propTypes = {
onSave: func,
source: shape({
id: string,
}),
onSelectDbrps: func.isRequired,
task: shape({
dbrps: arrayOf(

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

@ -411,3 +411,14 @@ export const PAGE_HEADER_HEIGHT = 60 // TODO: get this dynamically to ensure lon
export const PAGE_CONTAINER_MARGIN = 30 // TODO: get this dynamically to ensure longevity
export const LAYOUT_MARGIN = 4
export const DASHBOARD_LAYOUT_ROW_HEIGHT = 83.5
export const DEFAULT_SOURCE = {
url: 'http://localhost:8086',
name: 'Influx 1',
username: '',
password: '',
default: true,
telegraf: 'telegraf',
insecureSkipVerify: false,
metaUrl: '',
}

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

@ -8,32 +8,8 @@ class SourceForm extends Component {
super(props)
}
handleSubmitForm = e => {
e.preventDefault()
const newSource = {
...this.props.source,
url: this.sourceURL.value.trim(),
name: this.sourceName.value,
username: this.sourceUsername.value,
password: this.sourcePassword.value,
default: this.sourceDefault.checked,
telegraf: this.sourceTelegraf.value,
insecureSkipVerify: this.sourceInsecureSkipVerify
? this.sourceInsecureSkipVerify.checked
: false,
metaUrl: this.metaUrl && this.metaUrl.value.trim(),
}
this.props.onSubmit(newSource)
}
handleUseDefaultValues = () => {
this.sourceURL.value = 'http://localhost:8086'
this.sourceName.value = 'My InfluxDB'
}
handleBlurSourceURL = () => {
const url = this.sourceURL.value.trim()
const url = this.props.source.url.trim()
if (!url) {
return
@ -41,32 +17,31 @@ class SourceForm extends Component {
const newSource = {
...this.props.source,
url: this.sourceURL.value.trim(),
url,
}
this.props.onBlurSourceURL(newSource)
}
render() {
const {source, editMode, onInputChange} = this.props
const {source, editMode, onSubmit, onInputChange} = this.props
return (
<div className="panel-body">
<h4 className="text-center">Connection Details</h4>
<br />
<form onSubmit={this.handleSubmitForm}>
<form onSubmit={onSubmit}>
<div className="form-group col-xs-12 col-sm-6">
<label htmlFor="connect-string">Connection String</label>
<input
type="text"
name="url"
ref={r => (this.sourceURL = r)}
className="form-control"
id="connect-string"
placeholder="Address of InfluxDB"
onChange={onInputChange}
value={source.url || ''}
value={source.url}
onBlur={this.handleBlurSourceURL}
required={true}
/>
@ -76,12 +51,11 @@ class SourceForm extends Component {
<input
type="text"
name="name"
ref={r => (this.sourceName = r)}
className="form-control"
id="name"
placeholder="Name this source"
onChange={onInputChange}
value={source.name || ''}
value={source.name}
required={true}
/>
</div>
@ -90,11 +64,10 @@ class SourceForm extends Component {
<input
type="text"
name="username"
ref={r => (this.sourceUsername = r)}
className="form-control"
id="username"
onChange={onInputChange}
value={source.username || ''}
value={source.username}
/>
</div>
<div className="form-group col-xs-12 col-sm-6">
@ -102,11 +75,10 @@ class SourceForm extends Component {
<input
type="password"
name="password"
ref={r => (this.sourcePassword = r)}
className="form-control"
id="password"
onChange={onInputChange}
value={source.password || ''}
value={source.password}
/>
</div>
{_.get(source, 'type', '').includes('enterprise')
@ -115,12 +87,11 @@ class SourceForm extends Component {
<input
type="text"
name="metaUrl"
ref={r => (this.metaUrl = r)}
className="form-control"
id="meta-url"
placeholder="http://localhost:8091"
onChange={onInputChange}
value={source.metaUrl || ''}
value={source.metaUrl}
/>
</div>
: null}
@ -129,11 +100,10 @@ class SourceForm extends Component {
<input
type="text"
name="telegraf"
ref={r => (this.sourceTelegraf = r)}
className="form-control"
id="telegraf"
onChange={onInputChange}
value={source.telegraf || 'telegraf'}
value={source.telegraf}
/>
</div>
<div className="form-group col-xs-12">
@ -141,8 +111,9 @@ class SourceForm extends Component {
<input
type="checkbox"
id="defaultSourceCheckbox"
defaultChecked={source.default}
ref={r => (this.sourceDefault = r)}
name="default"
checked={source.default}
onChange={onInputChange}
/>
<label htmlFor="defaultSourceCheckbox">
Make this the default source
@ -155,8 +126,9 @@ class SourceForm extends Component {
<input
type="checkbox"
id="insecureSkipVerifyCheckbox"
defaultChecked={source.insecureSkipVerify}
ref={r => (this.sourceInsecureSkipVerify = r)}
name="insecureSkipVerify"
checked={source.insecureSkipVerify}
onChange={onInputChange}
/>
<label htmlFor="insecureSkipVerifyCheckbox">Unsafe SSL</label>
</div>
@ -176,13 +148,6 @@ class SourceForm extends Component {
{editMode ? 'Save Changes' : 'Add Source'}
</button>
<br />
<a
href="#"
className="btn btn-link btn-sm"
onClick={this.handleUseDefaultValues}
>
Use Default Values
</a>
</div>
</form>
</div>
@ -190,10 +155,19 @@ class SourceForm extends Component {
}
}
const {bool, func, shape} = PropTypes
const {bool, func, shape, string} = PropTypes
SourceForm.propTypes = {
source: shape({}).isRequired,
source: shape({
url: string.isRequired,
name: string.isRequired,
username: string.isRequired,
password: string.isRequired,
telegraf: string.isRequired,
insecureSkipVerify: bool.isRequired,
default: bool.isRequired,
metaUrl: string.isRequired,
}).isRequired,
editMode: bool.isRequired,
onInputChange: func.isRequired,
onSubmit: func.isRequired,

View File

@ -1,149 +0,0 @@
import React, {PropTypes} from 'react'
import {withRouter} from 'react-router'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import {
createSource as createSourceAJAX,
updateSource as updateSourceAJAX,
} from 'shared/apis'
import SourceForm from 'src/sources/components/SourceForm'
import Notifications from 'shared/components/Notifications'
import {
addSource as addSourceAction,
updateSource as updateSourceAction,
} from 'shared/actions/sources'
import {publishNotification} from 'shared/actions/notifications'
const {func, shape, string} = PropTypes
export const CreateSource = React.createClass({
propTypes: {
router: shape({
push: func.isRequired,
}).isRequired,
location: shape({
query: shape({
redirectPath: string,
}).isRequired,
}).isRequired,
addSource: func,
updateSource: func,
notify: func,
},
getInitialState() {
return {
source: {},
}
},
redirectToApp(source) {
const {redirectPath} = this.props.location.query
if (!redirectPath) {
return this.props.router.push(`/sources/${source.id}/hosts`)
}
const fixedPath = redirectPath.replace(
/\/sources\/[^/]*/,
`/sources/${source.id}`
)
return this.props.router.push(fixedPath)
},
handleInputChange(e) {
const val = e.target.value
const name = e.target.name
this.setState(prevState => {
const newSource = Object.assign({}, prevState.source, {
[name]: val,
})
return Object.assign({}, prevState, {source: newSource})
})
},
handleBlurSourceURL(newSource) {
if (this.state.editMode) {
return
}
if (!newSource.url) {
return
}
// if there is a type on source it has already been created
if (newSource.type) {
return
}
createSourceAJAX(newSource)
.then(({data: sourceFromServer}) => {
this.props.addSource(sourceFromServer)
this.setState({source: sourceFromServer, error: null})
})
.catch(({data: error}) => {
this.setState({error: error.message})
})
},
handleSubmit(newSource) {
const {error} = this.state
const {notify, updateSource} = this.props
if (error) {
return notify('error', error)
}
updateSourceAJAX(newSource)
.then(({data: sourceFromServer}) => {
updateSource(sourceFromServer)
this.redirectToApp(sourceFromServer)
})
.catch(() => {
notify(
'error',
'There was a problem updating the source. Check the settings'
)
})
},
render() {
const {source} = this.state
return (
<div>
<Notifications />
<div className="select-source-page">
<div className="container-fluid">
<div className="row">
<div className="col-md-8 col-md-offset-2">
<div className="panel panel-minimal">
<div className="panel-heading text-center">
<h2 className="deluxe">Welcome to Chronograf</h2>
</div>
<SourceForm
source={source}
editMode={false}
onInputChange={this.handleInputChange}
onSubmit={this.handleSubmit}
onBlurSourceURL={this.handleBlurSourceURL}
/>
</div>
</div>
</div>
</div>
</div>
</div>
)
},
})
function mapDispatchToProps(dispatch) {
return {
addSource: bindActionCreators(addSourceAction, dispatch),
updateSource: bindActionCreators(updateSourceAction, dispatch),
notify: bindActionCreators(publishNotification, dispatch),
}
}
export default connect(null, mapDispatchToProps)(withRouter(CreateSource))

View File

@ -1,67 +1,73 @@
import React, {PropTypes} from 'react'
import React, {PropTypes, Component} from 'react'
import {withRouter} from 'react-router'
import _ from 'lodash'
import {getSource} from 'shared/apis'
import {createSource, updateSource} from 'shared/apis'
import {
addSource as addSourceAction,
updateSource as updateSourceAction,
} from 'shared/actions/sources'
import {publishNotification} from 'shared/actions/notifications'
import {connect} from 'react-redux'
import SourceForm from 'src/sources/components/SourceForm'
import FancyScrollbar from 'shared/components/FancyScrollbar'
import SourceIndicator from 'shared/components/SourceIndicator'
import {DEFAULT_SOURCE} from 'shared/constants'
const initialPath = '/sources/new'
const {func, shape, string} = PropTypes
class SourcePage extends Component {
constructor(props) {
super(props)
export const SourcePage = React.createClass({
propTypes: {
params: shape({
id: string,
sourceID: string,
}),
router: shape({
push: func.isRequired,
}).isRequired,
location: shape({
query: shape({
redirectPath: string,
}).isRequired,
}).isRequired,
addFlashMessage: func.isRequired,
addSourceAction: func,
updateSourceAction: func,
},
getInitialState() {
return {
source: {},
editMode: this.props.params.id !== undefined,
error: '',
this.state = {
isLoading: true,
source: DEFAULT_SOURCE,
editMode: props.params.id !== undefined,
isInitialSource: props.router.location.pathname === initialPath,
}
},
}
componentDidMount() {
if (!this.state.editMode) {
return
const {editMode} = this.state
const {params} = this.props
if (!editMode) {
return this.setState({isLoading: false})
}
getSource(this.props.params.id).then(({data: source}) => {
this.setState({source})
})
},
handleInputChange(e) {
const val = e.target.value
const name = e.target.name
this.setState(prevState => {
const newSource = Object.assign({}, prevState.source, {
[name]: val,
getSource(params.id)
.then(({data: source}) => {
this.setState({
source: {...DEFAULT_SOURCE, ...source},
isLoading: false,
})
})
return Object.assign({}, prevState, {source: newSource})
})
},
.catch(error => {
this.handleError('Could not connect to source', error)
this.setState({isLoading: false})
})
}
handleBlurSourceURL(newSource) {
handleInputChange = e => {
let val = e.target.value
const name = e.target.name
if (e.target.type === 'checkbox') {
val = e.target.checked
}
this.setState(prevState => {
const source = {
...prevState.source,
[name]: val,
}
return {...prevState, source}
})
}
handleBlurSourceURL = newSource => {
if (this.state.editMode) {
return
}
@ -78,57 +84,108 @@ export const SourcePage = React.createClass({
createSource(newSource)
.then(({data: sourceFromServer}) => {
this.props.addSourceAction(sourceFromServer)
this.setState({source: sourceFromServer, error: null})
})
.catch(({data: error}) => {
// dont want to flash this until they submit
this.setState({error: error.message})
})
},
handleSubmit(newSource) {
const {router, params, addFlashMessage} = this.props
const {error} = this.state
if (error) {
return addFlashMessage({type: 'error', text: error})
}
updateSource(newSource)
.then(({data: sourceFromServer}) => {
this.props.updateSourceAction(sourceFromServer)
router.push(`/sources/${params.sourceID}/manage-sources`)
addFlashMessage({type: 'success', text: 'The source info saved'})
})
.catch(() => {
addFlashMessage({
type: 'error',
text: 'There was a problem updating the source. Check the settings',
this.setState({
source: {...DEFAULT_SOURCE, ...sourceFromServer},
isCreated: true,
})
})
},
.catch(err => {
// dont want to flash this until they submit
const error = this.parseError(err)
console.error('Error on source creation: ', error)
})
}
handleSubmit = e => {
e.preventDefault()
const {notify} = this.props
const {isCreated, source, editMode} = this.state
const isNewSource = !editMode
if (!isCreated && isNewSource) {
return createSource(source)
.then(({data: sourceFromServer}) => {
this.props.addSourceAction(sourceFromServer)
this._redirect(sourceFromServer)
})
.catch(error => {
this.handleError('Unable to create source', error)
})
}
updateSource(source)
.then(({data: sourceFromServer}) => {
this.props.updateSourceAction(sourceFromServer)
this._redirect(sourceFromServer)
notify('success', `New source ${source.name} added`)
})
.catch(error => {
this.handleError('Unable to update source', error)
})
}
handleError = (bannerText, err) => {
const {notify} = this.props
const error = this.parseError(err)
console.error('Error: ', error)
notify('error', `${bannerText}: ${error}`)
}
_redirect = source => {
const {isInitialSource} = this.state
const {params, router} = this.props
if (isInitialSource) {
return this._redirectToApp(source)
}
router.push(`/sources/${params.sourceID}/manage-sources`)
}
_redirectToApp = source => {
const {location, router} = this.props
const {redirectPath} = location.query
if (!redirectPath) {
return router.push(`/sources/${source.id}/hosts`)
}
const fixedPath = redirectPath.replace(
/\/sources\/[^/]*/,
`/sources/${source.id}`
)
return router.push(fixedPath)
}
parseError = error => {
return _.get(error, ['data', 'message'], error)
}
render() {
const {source, editMode} = this.state
const {isLoading, source, editMode, isInitialSource} = this.state
if (editMode && !source.id) {
if (isLoading) {
return <div className="page-spinner" />
}
return (
<div className="page" id="source-form-page">
<div className="page-header">
<div className="page-header__container">
<div className="page-header__left">
<h1 className="page-header__title">
{editMode ? 'Edit Source' : 'Add a New Source'}
</h1>
</div>
<div className="page-header__right">
<SourceIndicator />
</div>
</div>
</div>
<div className={`${isInitialSource ? '' : 'page'}`}>
{isInitialSource
? null
: <div className="page-header">
<div className="page-header__container page-header__source-page">
<div className="page-header__col-md-8">
<div className="page-header__left">
<h1 className="page-header__title">
{editMode ? 'Edit Source' : 'Add a New Source'}
</h1>
</div>
<div className="page-header__right">
<SourceIndicator />
</div>
</div>
</div>
</div>}
<FancyScrollbar className="page-contents">
<div className="container-fluid">
<div className="row">
@ -148,13 +205,33 @@ export const SourcePage = React.createClass({
</FancyScrollbar>
</div>
)
},
})
function mapStateToProps(_) {
return {}
}
}
export default connect(mapStateToProps, {addSourceAction, updateSourceAction})(
withRouter(SourcePage)
)
const {func, shape, string} = PropTypes
SourcePage.propTypes = {
params: shape({
id: string,
sourceID: string,
}),
router: shape({
push: func.isRequired,
}).isRequired,
location: shape({
query: shape({
redirectPath: string,
}).isRequired,
}).isRequired,
notify: func,
addSourceAction: func,
updateSourceAction: func,
}
const mapStateToProps = () => ({})
export default connect(mapStateToProps, {
notify: publishNotification,
addSourceAction,
updateSourceAction,
})(withRouter(SourcePage))

View File

@ -1,4 +1,3 @@
import CreateSource from './containers/CreateSource'
import SourcePage from './containers/SourcePage'
import ManageSources from './containers/ManageSources'
export {CreateSource, SourcePage, ManageSources}
export {SourcePage, ManageSources}

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

@ -23,6 +23,7 @@ $page-header-weight: 400 !important;
max-width: 100%;
}
.page-header__container {
position: relative;
width: 100%;
display: flex;
align-items: center;
@ -31,6 +32,26 @@ $page-header-weight: 400 !important;
width: 100%;
max-width: ($page-wrapper-max-width - $page-wrapper-padding - $page-wrapper-padding);
}
.page-header__container.page-header__source-page {
justify-content: center;
}
.page-header__col-md-8 {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
@media screen and (min-width: 992px) {
/*
NOTE:
Breakpoint and % width are based on the bootstrap grid
If the source form column sizing is ever changed, this
will have to be manually updated
*/
.page-header__col-md-8 {
width: 66.66667%;
}
}
.page-header__left,
.page-header__right {
display: flex;

View File

@ -22,6 +22,11 @@
width: 100%;
height: calc(100% - #{$chronograf-page-header-height}) !important;
@include gradient-v($g2-kevlar,$g0-obsidian);
&:only-child {
top: 0;
height: 100%;
}
}
.container-fluid {
padding: ($chronograf-page-header-height / 2) $page-wrapper-padding;
@ -36,7 +41,7 @@
.page-contents.presentation-mode {
top: 0;
height: 100% !important;
.container-fluid {padding: 8px !important;}
.template-control--manage {display: none;}
}
}

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"