Merge pull request #1315 from influxdata/feature/template-variables_proxy-params

Feature/template variables dropdown ui & proxy params
pull/1326/head
Jared Scheib 2017-04-21 13:22:54 -07:00 committed by GitHub
commit 187c32a255
15 changed files with 269 additions and 143 deletions

View File

@ -10,7 +10,8 @@
1. [#1232](https://github.com/influxdata/chronograf/pull/1232): Fuse the query builder and raw query editor
1. [#1286](https://github.com/influxdata/chronograf/pull/1286): Add refreshing JWTs for authentication
1. [#1316](https://github.com/influxdata/chronograf/pull/1316): Add templates API scoped within a dashboard
1. [#1311](https://github.com/influxdata/chronograf/pull/1311): Display currently selected values in TVControlBar
1. [#1315](https://github.com/influxdata/chronograf/pull/1315): Send selected TV values to proxy
### UI Improvements
1. [#1259](https://github.com/influxdata/chronograf/pull/1259): Add default display for empty dashboard

View File

@ -29,7 +29,7 @@ define CHRONOGIRAFFE
,"" _\_
," ## | 0 0.
," ## ,-\__ `.
," / `--._;) - "HAI, I'm Chronogiraffe. Will you be my friend?"
," / `--._;) - "HAI, I'm Chronogiraffe. Let's be friends!"
," ## /
," ## /
endef

View File

@ -11,10 +11,45 @@ import {
renameDashboardCell,
syncDashboardCell,
editTemplate,
templateSelected,
} from 'src/dashboards/actions'
let state
const d1 = {id: 1, cells: [], name: 'd1', templates: []}
const d1 = {
id: 1,
cells: [],
name: 'd1',
templates: [
{
id: '1',
type: 'query',
label: 'test query',
tempVar: '$REGION',
query: {
db: 'db1',
rp: 'rp1',
measurement: 'm1',
influxql: 'SHOW TAGS WHERE CHRONOGIRAFFE = "friend"',
},
values: [
{value: 'us-west', type: 'tagKey', selected: false},
{value: 'us-east', type: 'tagKey', selected: true},
{value: 'us-mount', type: 'tagKey', selected: false},
],
},
{
id: '2',
type: 'csv',
label: 'test csv',
tempVar: '$TEMPERATURE',
values: [
{value: '98.7', type: 'measurement', selected: false},
{value: '99.1', type: 'measurement', selected: false},
{value: '101.3', type: 'measurement', selected: true},
],
},
],
}
const d2 = {id: 2, cells: [], name: 'd2', templates: []}
const dashboards = [d1, d2]
const c1 = {
@ -142,4 +177,17 @@ describe('DataExplorer.Reducers.UI', () => {
const actual = reducer(state, editTemplate(dash.id, tempVar.id, updates))
expect(actual.dashboards[0].templates[0]).to.deep.equal(expected)
})
it('can select a different template variable', () => {
const dash = _.cloneDeep(d1)
state = {
dashboards: [dash]
}
const value = dash.templates[0].values[2].value
const actual = reducer({dashboards}, templateSelected(dash.id, dash.templates[0].id, [{value}]))
expect(actual.dashboards[0].templates[0].values[0].selected).to.equal(false)
expect(actual.dashboards[0].templates[0].values[1].selected).to.equal(false)
expect(actual.dashboards[0].templates[0].values[2].selected).to.equal(true)
})
})

View File

@ -12,6 +12,10 @@ import {publishAutoDismissingNotification} from 'shared/dispatchers'
import {NEW_DEFAULT_DASHBOARD_CELL} from 'src/dashboards/constants'
import {
TEMPLATE_VARIABLE_SELECTED,
} from 'shared/constants/actionTypes'
export const loadDashboards = (dashboards, dashboardID) => ({
type: 'LOAD_DASHBOARDS',
payload: {
@ -111,6 +115,15 @@ export const editCellQueryStatus = (queryID, status) => ({
},
})
export const templateSelected = (dashboardID, templateID, values) => ({
type: TEMPLATE_VARIABLE_SELECTED,
payload: {
dashboardID,
templateID,
values,
},
})
export const editTemplate = (dashboardID, templateID, updates) => ({
type: 'EDIT_TEMPLATE',
payload: {
@ -124,23 +137,32 @@ export const editTemplate = (dashboardID, templateID, updates) => ({
const templates = [
{
id: 1,
type: 'measurement',
id: '1',
type: 'query',
label: 'test query',
code: '$HOSTS',
tempVar: '$REGION',
query: {
db: 'db1',
rp: 'rp1',
measurement: 'm1',
text: 'SHOW TAGS WHERE HUNTER = "coo"',
influxql: 'SHOW TAGS WHERE CHRONOGIRAFFE = "friend"',
},
values: ['h1', 'h2', 'h3'],
values: [
{value: 'us-west', type: 'tagKey', selected: false},
{value: 'us-east', type: 'tagKey', selected: true},
{value: 'us-mount', type: 'tagKey', selected: false},
],
},
{
id: 2,
id: '2',
type: 'csv',
label: 'test csv',
code: '$INFLX',
values: ['A', 'B', 'C'],
tempVar: '$TEMPERATURE',
values: [
{value: '98.7', type: 'measurement', selected: false},
{value: '99.1', type: 'measurement', selected: false},
{value: '101.3', type: 'measurement', selected: true},
],
},
]

View File

@ -1,7 +1,10 @@
import React, {PropTypes} from 'react'
import classnames from 'classnames'
import omit from 'lodash/omit'
import LayoutRenderer from 'shared/components/LayoutRenderer'
import Dropdown from 'shared/components/Dropdown'
const Dashboard = ({
source,
@ -17,11 +20,14 @@ const Dashboard = ({
inPresentationMode,
onOpenTemplateManager,
onSummonOverlayTechnologies,
onSelectTemplate,
}) => {
if (dashboard.id === 0) {
return null
}
const {templates} = dashboard
const cells = dashboard.cells.map((cell) => {
const dashboardCell = {...cell}
dashboardCell.queries = dashboardCell.queries.map(({label, query, queryConfig, db}) =>
@ -39,13 +45,40 @@ const Dashboard = ({
return (
<div className={classnames('dashboard container-fluid full-width page-contents', {'presentation-mode': inPresentationMode})}>
<div className="tv-control-bar">
Template Variables
<button className="btn btn-primary btn-sm" onClick={onOpenTemplateManager}>Manage</button>
<div className="template-control-bar">
<div className="page-header__left">
Template Variables
</div>
<div className="page-header__right">
{
templates.map(({id, values}) => {
let selected
const items = values.map((value) => {
if (value.selected) {
selected = value.value
}
return {...value, text: value.value}
})
// TODO: change Dropdown to a MultiSelectDropdown, `selected` to
// the full array, and [item] to all `selected` values when we update
// this component to support multiple values
return (
<Dropdown
key={id}
items={items}
selected={selected || "Loading..."}
onChoose={(item) => onSelectTemplate(id, [item].map((x) => omit(x, 'text')))}
/>
)
})
}
<button className="btn btn-primary btn-sm" onClick={onOpenTemplateManager}>Manage</button>
</div>
</div>
{cells.length ?
<LayoutRenderer
timeRange={timeRange}
templates={templates}
cells={cells}
autoRefresh={autoRefresh}
source={source.links.proxy}
@ -71,6 +104,7 @@ const Dashboard = ({
}
const {
arrayOf,
bool,
func,
shape,
@ -96,6 +130,22 @@ Dashboard.propTypes = {
autoRefresh: number.isRequired,
timeRange: shape({}).isRequired,
onOpenTemplateManager: func.isRequired,
onSelectTemplate: func.isRequired,
templates: arrayOf(shape({
type: string.isRequired,
label: string.isRequired,
tempVar: string.isRequired,
query: shape({
db: string.isRequired,
rp: string,
influxql: string.isRequired,
}),
values: arrayOf(shape({
type: string.isRequired,
value: string.isRequired,
selected: bool,
})).isRequired,
})),
}
export default Dashboard

View File

@ -40,8 +40,8 @@ class DashboardPage extends Component {
this.handleRenameDashboardCell = ::this.handleRenameDashboardCell
this.handleUpdateDashboardCell = ::this.handleUpdateDashboardCell
this.handleCloseTemplateManager = ::this.handleCloseTemplateManager
this.handleSummonOverlayTechnologies = ::this
.handleSummonOverlayTechnologies
this.handleSummonOverlayTechnologies = ::this.handleSummonOverlayTechnologies
this.handleSelectTemplate = ::this.handleSelectTemplate
}
componentDidMount() {
@ -143,6 +143,11 @@ class DashboardPage extends Component {
this.props.dashboardActions.deleteDashboardCellAsync(cell)
}
handleSelectTemplate(templateID, values) {
const {params: {dashboardID}} = this.props
this.props.dashboardActions.templateSelected(+dashboardID, templateID, values)
}
getActiveDashboard() {
const {params: {dashboardID}, dashboards} = this.props
return dashboards.find(d => d.id === +dashboardID)
@ -237,6 +242,7 @@ class DashboardPage extends Component {
onUpdateCell={this.handleUpdateDashboardCell}
onOpenTemplateManager={this.handleOpenTemplateManager}
onSummonOverlayTechnologies={this.handleSummonOverlayTechnologies}
onSelectTemplate={this.handleSelectTemplate}
/>
: null}
</div>
@ -279,10 +285,15 @@ DashboardPage.propTypes = {
code: string.isRequired,
query: shape({
db: string.isRequired,
text: string.isRequired,
rp: string,
influxql: string.isRequired,
}),
values: arrayOf(string.isRequired),
})
values: arrayOf(shape({
type: string.isRequired,
value: string.isRequired,
selected: bool,
})).isRequired,
}),
),
})
),

View File

@ -10,6 +10,10 @@ const initialState = {
cellQueryStatus: {queryID: null, status: null},
}
import {
TEMPLATE_VARIABLE_SELECTED,
} from 'shared/constants/actionTypes'
export default function ui(state = initialState, action) {
switch (action.type) {
case 'LOAD_DASHBOARDS': {
@ -180,6 +184,33 @@ export default function ui(state = initialState, action) {
return {...state, cellQueryStatus: {queryID, status}}
}
case TEMPLATE_VARIABLE_SELECTED: {
const {dashboardID, templateID, values: updatedSelectedValues} = action.payload
const newDashboards = state.dashboards.map((dashboard) => {
if (dashboard.id === dashboardID) {
const newTemplates = dashboard.templates.map((staleTemplate) => {
if (staleTemplate.id === templateID) {
const newValues = staleTemplate.values.map((staleValue) => {
let selected = false
for (let i = 0; i < updatedSelectedValues.length; i++) {
if (updatedSelectedValues[i].value === staleValue.value) {
selected = true
break
}
}
return {...staleValue, selected}
})
return {...staleTemplate, values: newValues}
}
return staleTemplate
})
return {...dashboard, templates: newTemplates}
}
return dashboard
})
return {...state, dashboards: newDashboards}
}
case 'EDIT_TEMPLATE': {
const {dashboardID, templateID, updates} = action.payload

View File

@ -35,10 +35,10 @@ export const handleError = (error, query, editQueryStatus) => {
console.error(error)
}
export const fetchTimeSeriesAsync = async ({source, db, rp, query}, editQueryStatus = noop) => {
export const fetchTimeSeriesAsync = async ({source, db, rp, query, templates}, editQueryStatus = noop) => {
handleLoading(query, editQueryStatus)
try {
const {data} = await proxy({source, db, rp, query: query.text})
const {data} = await proxy({source, db, rp, query: query.text, templates})
return handleSuccess(data, query, editQueryStatus)
} catch (error) {
handleError(error, query, editQueryStatus)

View File

@ -1,12 +0,0 @@
import {proxy} from 'utils/queryUrlGenerator'
const fetchTimeSeries = async (source, database, query) => {
try {
return await proxy({source, query, database})
} catch (error) {
console.error('error from proxy: ', error)
throw error
}
}
export default fetchTimeSeries

View File

@ -4,6 +4,7 @@ import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries'
const {
arrayOf,
bool,
element,
func,
number,
@ -17,18 +18,35 @@ const AutoRefresh = (ComposedComponent) => {
propTypes: {
children: element,
autoRefresh: number.isRequired,
templates: arrayOf(shape({
type: string.isRequired,
label: string.isRequired,
tempVar: string.isRequired,
query: shape({
db: string.isRequired,
rp: string,
influxql: string.isRequired,
}),
values: arrayOf(shape({
type: string.isRequired,
value: string.isRequired,
selected: bool,
})).isRequired,
})),
queries: arrayOf(shape({
host: oneOfType([string, arrayOf(string)]),
text: string,
}).isRequired).isRequired,
editQueryStatus: func,
},
getInitialState() {
return {
lastQuerySuccessful: false,
timeSeries: [],
}
},
componentDidMount() {
const {queries, autoRefresh} = this.props
this.executeQueries(queries)
@ -36,6 +54,7 @@ const AutoRefresh = (ComposedComponent) => {
this.intervalID = setInterval(() => this.executeQueries(queries), autoRefresh)
}
},
componentWillReceiveProps(nextProps) {
const shouldRefetch = this.queryDifference(this.props.queries, nextProps.queries).length
@ -51,41 +70,45 @@ const AutoRefresh = (ComposedComponent) => {
}
}
},
queryDifference(left, right) {
const leftStrs = left.map((q) => `${q.host}${q.text}`)
const rightStrs = right.map((q) => `${q.host}${q.text}`)
return _.difference(_.union(leftStrs, rightStrs), _.intersection(leftStrs, rightStrs))
},
async executeQueries(queries) {
const {templates, editQueryStatus} = this.props
if (!queries.length) {
this.setState({
timeSeries: [],
})
this.setState({timeSeries: []})
return
}
this.setState({isFetching: true})
let count = 0
const newSeries = []
for (const query of queries) {
const timeSeriesPromises = queries.map((query) => {
const {host, database, rp} = query
const response = await fetchTimeSeriesAsync({source: host, db: database, rp, query}, this.props.editQueryStatus)
newSeries.push({response})
count += 1
if (count === queries.length) {
const querySuccessful = !this._noResultsForQuery(newSeries)
this.setState({
lastQuerySuccessful: querySuccessful,
isFetching: false,
timeSeries: newSeries,
})
}
}
return fetchTimeSeriesAsync({source: host, db: database, rp, query, templates}, editQueryStatus)
})
Promise.all(timeSeriesPromises).then(timeSeries => {
const newSeries = timeSeries.map((response) => ({response}))
const lastQuerySuccessful = !this._noResultsForQuery(newSeries)
this.setState({
timeSeries: newSeries,
lastQuerySuccessful,
isFetching: false,
})
})
},
componentWillUnmount() {
clearInterval(this.intervalID)
this.intervalID = false
},
render() {
const {timeSeries} = this.state

View File

@ -46,6 +46,7 @@ export const LayoutRenderer = React.createClass({
type: string.isRequired,
}).isRequired
),
templates: arrayOf(shape()).isRequired,
host: string,
source: string,
onPositionChange: func,
@ -89,11 +90,40 @@ export const LayoutRenderer = React.createClass({
return text
},
renderRefreshingGraph(type, queries) {
const {autoRefresh, templates} = this.props
if (type === 'single-stat') {
return (
<RefreshingSingleStat
queries={[queries[0]]}
templates={templates}
autoRefresh={autoRefresh}
/>
)
}
const displayOptions = {
stepPlot: type === 'line-stepplot',
stackedGraph: type === 'line-stacked',
}
return (
<RefreshingLineGraph
queries={queries}
templates={templates}
autoRefresh={autoRefresh}
showSingleStat={type === 'line-plus-single-stat'}
displayOptions={displayOptions}
/>
)
},
generateVisualizations() {
const {autoRefresh, timeRange, source, cells, onEditCell, onRenameCell, onUpdateCell, onDeleteCell, onSummonOverlayTechnologies, shouldNotBeEditable} = this.props
const {timeRange, source, cells, onEditCell, onRenameCell, onUpdateCell, onDeleteCell, onSummonOverlayTechnologies, shouldNotBeEditable} = this.props
return cells.map((cell) => {
const qs = cell.queries.map((query) => {
const queries = cell.queries.map((query) => {
// TODO: Canned dashboards (and possibly Kubernetes dashboard) use an old query schema,
// which does not have enough information for the new `buildInfluxQLQuery` function
// to operate on. We will use `buildQueryForOldQuerySchema` until we conform
@ -112,29 +142,6 @@ export const LayoutRenderer = React.createClass({
})
})
if (cell.type === 'single-stat') {
return (
<div key={cell.i}>
<NameableGraph
onEditCell={onEditCell}
onRenameCell={onRenameCell}
onUpdateCell={onUpdateCell}
onDeleteCell={onDeleteCell}
onSummonOverlayTechnologies={onSummonOverlayTechnologies}
shouldNotBeEditable={shouldNotBeEditable}
cell={cell}
>
<RefreshingSingleStat queries={[qs[0]]} autoRefresh={autoRefresh} />
</NameableGraph>
</div>
)
}
const displayOptions = {
stepPlot: cell.type === 'line-stepplot',
stackedGraph: cell.type === 'line-stacked',
}
return (
<div key={cell.i}>
<NameableGraph
@ -146,12 +153,7 @@ export const LayoutRenderer = React.createClass({
shouldNotBeEditable={shouldNotBeEditable}
cell={cell}
>
<RefreshingLineGraph
queries={qs}
autoRefresh={autoRefresh}
showSingleStat={cell.type === 'line-plus-single-stat'}
displayOptions={displayOptions}
/>
{this.renderRefreshingGraph(cell.type, queries)}
</NameableGraph>
</div>
)

View File

@ -1,59 +0,0 @@
import React, {PropTypes} from 'react'
import classNames from 'classnames'
import OnClickOutside from 'shared/components/OnClickOutside'
const Dropdown = React.createClass({
propTypes: {
children: PropTypes.node.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
text: PropTypes.string.isRequired,
})).isRequired,
onChoose: PropTypes.func.isRequired,
className: PropTypes.string,
},
getInitialState() {
return {
isOpen: false,
}
},
handleClickOutside() {
this.setState({isOpen: false})
},
handleSelection(item) {
this.toggleMenu()
this.props.onChoose(item)
},
toggleMenu(e) {
if (e) {
e.stopPropagation()
}
this.setState({isOpen: !this.state.isOpen})
},
render() {
const self = this
const {items, className} = self.props
return (
<div onClick={this.toggleMenu} className={classNames(`dropdown ${className}`, {open: self.state.isOpen})}>
<div className="btn btn-sm btn-info dropdown-toggle">
{this.props.children}
</div>
{self.state.isOpen ?
<ul className="dropdown-menu show">
{items.map((item, i) => {
return (
<li className="dropdown-item" key={i} onClick={() => self.handleSelection(item)}>
<a href="#">
{item.text}
</a>
</li>
)
})}
</ul>
: null}
</div>
)
},
})
export default OnClickOutside(Dropdown)

View File

@ -0,0 +1 @@
export const TEMPLATE_VARIABLE_SELECTED = 'TEMPLATE_VARIABLE_SELECTED'

View File

@ -66,7 +66,7 @@ $dash-graph-options-arrow: 8px;
}
.dashboard {
.tv-control-bar {
.template-control-bar {
height: 50px;
font-size: 18px;
font-weight: 400;
@ -77,6 +77,13 @@ $dash-graph-options-arrow: 8px;
margin-bottom: 4px;
padding: 10px 15px;
@extend .cell-shell;
.dropdown {
flex: 0 1 auto;
min-width: 100px;
}
.dropdown-toggle {
width: 100%;
}
}
.react-grid-item {
@extend .cell-shell;

View File

@ -1,11 +1,12 @@
import AJAX from 'utils/ajax'
export const proxy = async ({source, query, db, rp}) => {
export const proxy = async ({source, query, db, rp, templates}) => {
try {
return await AJAX({
method: 'POST',
url: source,
data: {
templates,
query,
db,
rp,