diff --git a/ui/src/flux/components/TimeMachineEditor.tsx b/ui/src/flux/components/TimeMachineEditor.tsx index daf8066e7..eb4195e49 100644 --- a/ui/src/flux/components/TimeMachineEditor.tsx +++ b/ui/src/flux/components/TimeMachineEditor.tsx @@ -71,7 +71,7 @@ class TimeMachineEditor extends PureComponent { } public render() { - const {script} = this.props + const {script, children} = this.props const options = { tabIndex: 1, @@ -96,6 +96,7 @@ class TimeMachineEditor extends PureComponent { editorDidMount={this.handleMount} onKeyUp={this.handleKeyUp} /> + {children} ) } diff --git a/ui/src/flux/constants/ast.ts b/ui/src/flux/constants/ast.ts index 1b7250d3e..5b16f559d 100644 --- a/ui/src/flux/constants/ast.ts +++ b/ui/src/flux/constants/ast.ts @@ -1,19 +1,3 @@ -export const emptyAST = { - type: 'Program', - location: { - start: { - line: 1, - column: 1, - }, - end: { - line: 1, - column: 1, - }, - source: '', - }, - body: [], -} - export const ast = { type: 'File', start: 0, diff --git a/ui/src/reusable_ui/components/dropdowns/Dropdown.scss b/ui/src/reusable_ui/components/dropdowns/Dropdown.scss index ca5edb054..3a6fdc430 100644 --- a/ui/src/reusable_ui/components/dropdowns/Dropdown.scss +++ b/ui/src/reusable_ui/components/dropdowns/Dropdown.scss @@ -171,3 +171,30 @@ .dropdown--menu-container .fancy-scroll--track-h { display: none; } + +@keyframes loading-dots { + 0% { + content: "Loading " + } + + 25% { + content: "Loading." + } + + 50% { + content: "Loading.." + } + + 75% { + content: "Loading..." + } + + 100% { + content: "Loading " + } +} + +.dropdown--loading::after { + animation: 1.7s linear loading-dots infinite; + content: "Loading..." +} diff --git a/ui/src/reusable_ui/components/dropdowns/Dropdown.tsx b/ui/src/reusable_ui/components/dropdowns/Dropdown.tsx index f2372672c..dd06e35cf 100644 --- a/ui/src/reusable_ui/components/dropdowns/Dropdown.tsx +++ b/ui/src/reusable_ui/components/dropdowns/Dropdown.tsx @@ -123,8 +123,17 @@ class Dropdown extends Component { const {expanded} = this.state const selectedChild = children.find(child => child.props.id === selectedID) - const dropdownLabel = - (selectedChild && selectedChild.props.children) || titleText + const isLoading = status === ComponentStatus.Loading + + let dropdownLabel + + if (isLoading) { + dropdownLabel =
+ } else if (selectedChild) { + dropdownLabel = selectedChild.props.children + } else { + dropdownLabel = titleText + } return ( { } } + private get shouldHaveChildren(): boolean { + const {status} = this.props + + return ( + status === ComponentStatus.Default || status === ComponentStatus.Valid + ) + } + private handleItemClick = (value: any): void => { const {onChange} = this.props onChange(value) @@ -219,7 +236,7 @@ class Dropdown extends Component { private validateChildCount = (): void => { const {children} = this.props - if (React.Children.count(children) === 0) { + if (this.shouldHaveChildren && React.Children.count(children) === 0) { throw new Error( 'Dropdowns require at least 1 child element. We recommend using Dropdown.Item and/or Dropdown.Divider.' ) @@ -233,7 +250,11 @@ class Dropdown extends Component { throw new Error('Dropdowns in ActionList mode require a titleText prop.') } - if (mode === DropdownMode.Radio && selectedID === '') { + if ( + mode === DropdownMode.Radio && + this.shouldHaveChildren && + selectedID === '' + ) { throw new Error('Dropdowns in Radio mode require a selectedID prop.') } } diff --git a/ui/src/reusable_ui/components/dropdowns/DropdownButton.tsx b/ui/src/reusable_ui/components/dropdowns/DropdownButton.tsx index b524625f7..650c19002 100644 --- a/ui/src/reusable_ui/components/dropdowns/DropdownButton.tsx +++ b/ui/src/reusable_ui/components/dropdowns/DropdownButton.tsx @@ -34,33 +34,55 @@ class DropdownButton extends Component { } public render() { - const {onClick, status, children, title} = this.props + const {onClick, children, title} = this.props return ( ) } private get classname(): string { - const {status, active, color, size} = this.props + const {active, color, size} = this.props return classnames('dropdown--button button', { 'button-stretch': true, + 'button--disabled': this.isDisabled, [`button-${color}`]: color, [`button-${size}`]: size, - disabled: status === ComponentStatus.Disabled, active, }) } + private get caret(): JSX.Element { + const {active} = this.props + + if (active) { + return + } + + return + } + + private get isDisabled(): boolean { + const {status} = this.props + + const isDisabled = [ + ComponentStatus.Disabled, + ComponentStatus.Loading, + ComponentStatus.Error, + ].includes(status) + + return isDisabled + } + private get icon(): JSX.Element { const {icon} = this.props diff --git a/ui/src/reusable_ui/components/dropdowns/MultiSelectDropdown.tsx b/ui/src/reusable_ui/components/dropdowns/MultiSelectDropdown.tsx index 60afc342b..a08377b2f 100644 --- a/ui/src/reusable_ui/components/dropdowns/MultiSelectDropdown.tsx +++ b/ui/src/reusable_ui/components/dropdowns/MultiSelectDropdown.tsx @@ -135,9 +135,11 @@ class MultiSelectDropdown extends Component { _.includes(selectedIDs, child.props.id) ) - let label: string | Array + let label - if (selectedChildren.length) { + if (status === ComponentStatus.Loading) { + label =
+ } else if (selectedChildren.length) { label = selectedChildren.map((sc, i) => { if (i < selectedChildren.length - 1) { return ( @@ -238,6 +240,14 @@ class MultiSelectDropdown extends Component { } } + private get shouldHaveChildren(): boolean { + const {status} = this.props + + return ( + status === ComponentStatus.Default || status === ComponentStatus.Valid + ) + } + private handleItemClick = (value: any): void => { const {onChange, selectedIDs} = this.props let updatedSelection @@ -254,7 +264,7 @@ class MultiSelectDropdown extends Component { private validateChildCount = (): void => { const {children} = this.props - if (React.Children.count(children) === 0) { + if (this.shouldHaveChildren && React.Children.count(children) === 0) { throw new Error( 'Dropdowns require at least 1 child element. We recommend using Dropdown.Item and/or Dropdown.Divider.' ) diff --git a/ui/src/shared/components/TimeMachine/FluxQueryMaker.tsx b/ui/src/shared/components/TimeMachine/FluxQueryMaker.tsx index 86b0019aa..5d9d4c3aa 100644 --- a/ui/src/shared/components/TimeMachine/FluxQueryMaker.tsx +++ b/ui/src/shared/components/TimeMachine/FluxQueryMaker.tsx @@ -4,21 +4,16 @@ import React, {PureComponent} from 'react' // Components import SchemaExplorer from 'src/flux/components/SchemaExplorer' import TimeMachineEditor from 'src/flux/components/TimeMachineEditor' +import FluxScriptWizard from 'src/shared/components/TimeMachine/FluxScriptWizard' import Threesizer from 'src/shared/components/threesizer/Threesizer' -import { - Button, - ComponentSize, - ComponentColor, - ComponentStatus, -} from 'src/reusable_ui' +import {Button, ComponentSize, ComponentColor} from 'src/reusable_ui' // Constants import {HANDLE_VERTICAL} from 'src/shared/constants' -import {emptyAST} from 'src/flux/constants' // Utils import {getSuggestions, getAST} from 'src/flux/apis' -import Restarter from 'src/shared/utils/Restarter' +import {restartable} from 'src/shared/utils/restartable' import DefaultDebouncer, {Debouncer} from 'src/shared/utils/debouncer' import {parseError} from 'src/flux/helpers/scriptBuilder' @@ -26,7 +21,8 @@ import {parseError} from 'src/flux/helpers/scriptBuilder' import {NotificationAction, Source} from 'src/types' import {Suggestion, Links, ScriptStatus} from 'src/types/flux' -const AST_DEBOUNCE_DELAY = 600 +const CHECK_SCRIPT_DELAY = 600 +const VALID_SCRIPT_STATUS = {type: 'success', text: ''} interface Props { script: string @@ -41,29 +37,27 @@ interface State { suggestions: Suggestion[] draftScript: string draftScriptStatus: ScriptStatus - ast: object - hasChangedScript: boolean + isWizardActive: boolean } class FluxQueryMaker extends PureComponent { - private restarter: Restarter = new Restarter() private debouncer: Debouncer = new DefaultDebouncer() + private getAST = restartable(getAST) public constructor(props: Props) { super(props) this.state = { suggestions: [], - ast: {}, draftScript: props.script, draftScriptStatus: {type: 'none', text: ''}, - hasChangedScript: false, + isWizardActive: false, } } public componentDidMount() { this.fetchSuggestions() - this.updateBody() + this.checkDraftScript() } public render() { @@ -72,26 +66,27 @@ class FluxQueryMaker extends PureComponent { suggestions, draftScript, draftScriptStatus, - hasChangedScript, + isWizardActive, } = this.state - const submitStatus = hasChangedScript - ? ComponentStatus.Default - : ComponentStatus.Disabled - const divisions = [ { name: 'Script', + size: 0.66, headerOrientation: HANDLE_VERTICAL, headerButtons: [
+
+ + ) + } + + private get bucketDropdownItems(): JSX.Element[] { + const {dbsToRPs} = this.state + + const itemData = flatten( + Object.entries(dbsToRPs).map(([db, rps]) => rps.map(rp => [db, rp])) + ) + + const bucketDropdownItems = itemData.map(([db, rp]) => { + const name = formatDBwithRP(db, rp) + + return ( + + {name} + + ) + }) + + return bucketDropdownItems + } + + private get bucketDropdownSelectedID(): string { + const {selectedDB, selectedRP} = this.state + return formatDBwithRP(selectedDB, selectedRP) + } + + private get bucketDropdownStatus(): ComponentStatus { + const {dbsToRPsStatus} = this.state + const bucketDropdownStatus = toComponentStatus(dbsToRPsStatus) + + return bucketDropdownStatus + } + + private get measurementDropdownStatus(): ComponentStatus { + const {measurementsStatus} = this.state + const measurementDropdownStatus = toComponentStatus(measurementsStatus) + + return measurementDropdownStatus + } + + private get fieldsDropdownStatus(): ComponentStatus { + const {fieldsStatus} = this.state + const fieldsDropdownStatus = toComponentStatus(fieldsStatus) + + return fieldsDropdownStatus + } + + private get buttonStatus(): ComponentStatus { + const {dbsToRPsStatus, measurementsStatus, fieldsStatus} = this.state + const allDone = [dbsToRPsStatus, measurementsStatus, fieldsStatus].every( + s => s === RemoteDataState.Done + ) + + if (allDone) { + return ComponentStatus.Default + } + + return ComponentStatus.Disabled + } + + private handleClose = () => { + this.props.onSetIsWizardActive(false) + } + + private handleSelectBucket = ([selectedDB, selectedRP]: [string, string]) => { + this.setState({selectedDB, selectedRP}, this.fetchAndSetMeasurements) + } + + private handleSelectMeasurement = (selectedMeasurement: string) => { + this.setState({selectedMeasurement}, this.fetchAndSetFields) + } + + private handleSelectFields = (selectedFields: string[]) => { + this.setState({selectedFields}) + } + + private handleAddToScript = () => { + const {onSetIsWizardActive, onAddToScript} = this.props + const { + selectedDB, + selectedRP, + selectedMeasurement, + selectedFields, + } = this.state + const selectedBucket = formatDBwithRP(selectedDB, selectedRP) + const script = renderScript( + selectedBucket, + selectedMeasurement, + selectedFields + ) + + onAddToScript(script) + onSetIsWizardActive(false) + } + + private fetchAndSetDBsToRPs = async () => { + const {source} = this.props + + this.setState({ + dbsToRPs: {}, + dbsToRPsStatus: RemoteDataState.Loading, + selectedDB: null, + selectedRP: null, + measurements: [], + measurementsStatus: RemoteDataState.NotStarted, + selectedMeasurement: null, + fields: [], + fieldsStatus: RemoteDataState.NotStarted, + selectedFields: [], + }) + + let dbsToRPs + + try { + dbsToRPs = await this.fetchDBsToRPs(source.links.proxy) + } catch { + this.setState({dbsToRPsStatus: RemoteDataState.Error}) + + return + } + + const [selectedDB, selectedRP] = getDefaultDBandRP(dbsToRPs) + + this.setState( + { + dbsToRPs, + dbsToRPsStatus: RemoteDataState.Done, + selectedDB, + selectedRP, + }, + this.fetchAndSetMeasurements + ) + } + + private fetchAndSetMeasurements = async () => { + const {source} = this.props + const {selectedDB} = this.state + + this.setState({ + measurements: [], + measurementsStatus: RemoteDataState.Loading, + selectedMeasurement: null, + fields: [], + fieldsStatus: RemoteDataState.NotStarted, + selectedFields: [], + }) + + let measurements + + try { + measurements = await this.fetchMeasurements( + source.links.proxy, + selectedDB + ) + } catch { + this.setState({ + measurements: [], + measurementsStatus: RemoteDataState.Error, + selectedMeasurement: null, + }) + + return + } + + this.setState( + { + measurements, + measurementsStatus: RemoteDataState.Done, + selectedMeasurement: measurements[0], + }, + this.fetchAndSetFields + ) + } + + private fetchAndSetFields = async () => { + const {source} = this.props + const {selectedDB, selectedMeasurement} = this.state + + this.setState({ + fields: [], + fieldsStatus: RemoteDataState.Loading, + selectedFields: [], + }) + + let fields + + try { + fields = await this.fetchFields( + source.links.proxy, + selectedDB, + selectedMeasurement + ) + } catch { + this.setState({ + fields: [], + fieldsStatus: RemoteDataState.Error, + selectedFields: [], + }) + + return + } + + this.setState({ + fields, + fieldsStatus: RemoteDataState.Done, + selectedFields: [], + }) + } +} + +export default FluxScriptWizard diff --git a/ui/src/shared/components/threesizer/Division.tsx b/ui/src/shared/components/threesizer/Division.tsx index ef6ae6fbb..53241f211 100644 --- a/ui/src/shared/components/threesizer/Division.tsx +++ b/ui/src/shared/components/threesizer/Division.tsx @@ -282,7 +282,7 @@ class Division extends PureComponent { return true } - if (!this.divisionRef || this.props.size >= 0.33) { + if (!this.divisionRef.current || this.props.size >= 0.33) { return false } diff --git a/ui/src/shared/utils/Restarter.ts b/ui/src/shared/utils/Restarter.ts deleted file mode 100644 index ba152a682..000000000 --- a/ui/src/shared/utils/Restarter.ts +++ /dev/null @@ -1,43 +0,0 @@ -import Deferred from 'src/worker/Deferred' - -class Restarter { - private deferred?: Deferred - private id: number = 0 - - public perform(promise: Promise): Promise { - if (!this.deferred) { - this.deferred = new Deferred() - } - - this.id += 1 - this.awaitResult(promise, this.id) - - return this.deferred.promise - } - - private awaitResult = async (promise: Promise, id: number) => { - let result - let shouldReject = false - - try { - result = await promise - } catch (error) { - result = error - shouldReject = true - } - - if (id !== this.id) { - return - } - - if (shouldReject) { - this.deferred.reject(result) - } else { - this.deferred.resolve(result) - } - - this.deferred = null - } -} - -export default Restarter diff --git a/ui/src/shared/utils/fluxScriptWizard.ts b/ui/src/shared/utils/fluxScriptWizard.ts new file mode 100644 index 000000000..6b1362740 --- /dev/null +++ b/ui/src/shared/utils/fluxScriptWizard.ts @@ -0,0 +1,117 @@ +import {proxy} from 'src/utils/queryUrlGenerator' +import {parseMetaQuery} from 'src/tempVars/parsing' + +import {RemoteDataState} from 'src/types' +import {ComponentStatus} from 'src/reusable_ui' + +export interface DBsToRPs { + [databaseName: string]: string[] +} + +export async function fetchDBsToRPs(proxyLink: string): Promise { + const dbsQuery = 'SHOW DATABASES' + const dbsResp = await proxy({source: proxyLink, query: dbsQuery}) + const dbs = parseMetaQuery(dbsQuery, dbsResp.data).sort() + + const rpsQuery = dbs + .map(db => `SHOW RETENTION POLICIES ON "${db}"`) + .join('; ') + + const rpsResp = await proxy({source: proxyLink, query: rpsQuery}) + + const dbsToRPs: DBsToRPs = dbs.reduce((acc, db, i) => { + const series = rpsResp.data.results[i].series[0] + const namesIndex = series.columns.indexOf('name') + const rpNames = series.values.map(row => row[namesIndex]) + + return {...acc, [db]: rpNames} + }, {}) + + return dbsToRPs +} + +export async function fetchMeasurements( + proxyLink: string, + database: string +): Promise { + const query = `SHOW MEASUREMENTS ON "${database}"` + const resp = await proxy({source: proxyLink, query}) + const measurements = parseMetaQuery(query, resp.data) + + measurements.sort() + + return measurements +} + +export async function fetchFields( + proxyLink: string, + database: string, + measurement: string +): Promise { + const query = `SHOW FIELD KEYS ON "${database}" FROM "${measurement}"` + const resp = await proxy({source: proxyLink, query}) + const fields = parseMetaQuery(query, resp.data) + + fields.sort() + + return fields +} + +export function formatDBwithRP(db: string, rp: string): string { + return `${db}/${rp}` +} + +export function toComponentStatus(state: RemoteDataState): ComponentStatus { + switch (state) { + case RemoteDataState.NotStarted: + return ComponentStatus.Disabled + case RemoteDataState.Loading: + return ComponentStatus.Loading + case RemoteDataState.Error: + return ComponentStatus.Error + case RemoteDataState.Done: + return ComponentStatus.Default + } +} + +export function getDefaultDBandRP( + dbsToRPs: DBsToRPs +): [string, string] | [null, null] { + const dbs = Object.keys(dbsToRPs) + + // Pick telegraf if it exists + if (dbs.includes('telegraf')) { + return ['telegraf', dbsToRPs.telegraf[0]] + } + + // Pick nothing if nothing exists + if (!dbs.length || !dbsToRPs[dbs[0][0]]) { + return [null, null] + } + + // Otherwise pick the first available DB and RP + return [dbs[0], dbsToRPs[dbs[0]][0]] +} + +export function renderScript( + selectedBucket: string, + selectedMeasurement: string, + selectedFields: string[] +): string { + let filterPredicate = `r._measurement == "${selectedMeasurement}"` + + if (selectedFields.length) { + const fieldsPredicate = selectedFields + .map(f => `r._field == "${f}"`) + .join(' or ') + + filterPredicate += ` and (${fieldsPredicate})` + } + + const from = `from(bucket: "${selectedBucket}")` + const range = `|> range(start: -1h)` + const filter = `|> filter(fn: (r) => ${filterPredicate})` + const script = [from, range, filter].join('\n ') + + return script +} diff --git a/ui/src/shared/utils/restartable.ts b/ui/src/shared/utils/restartable.ts new file mode 100644 index 000000000..b7ea83224 --- /dev/null +++ b/ui/src/shared/utils/restartable.ts @@ -0,0 +1,45 @@ +import Deferred from 'src/worker/Deferred' + +export function restartable( + f: (...args: T) => Promise +): ((...args: T) => Promise) { + let deferred: Deferred + let id: number = 0 + + const checkResult = async (promise: Promise, promiseID: number) => { + let result + let isOk = true + + try { + result = await promise + } catch (error) { + result = error + isOk = false + } + + if (promiseID !== id) { + return + } + + if (isOk) { + deferred.resolve(result) + } else { + deferred.reject(result) + } + + deferred = null + } + + return (...args: T): Promise => { + if (!deferred) { + deferred = new Deferred() + } + + const promise = f(...args) + + id += 1 + checkResult(promise, id) + + return deferred.promise + } +} diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index f39a4cf33..a5ab41a20 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -87,8 +87,10 @@ @import 'components/annotation-control-bar'; @import 'components/annotation-editor'; @import 'src/shared/components/TimeMachine/RawFluxDataTable'; +@import 'src/shared/components/TimeMachine/FluxScriptWizard'; @import 'src/shared/components/Spinner'; + // Reusable UI Components @import '../reusable_ui/components/panel/Panel.scss'; @import '../reusable_ui/components/overlays/Overlay.scss'; diff --git a/ui/src/style/components/time-machine/flux-editor.scss b/ui/src/style/components/time-machine/flux-editor.scss index 4543a2624..c0f6e8fe6 100644 --- a/ui/src/style/components/time-machine/flux-editor.scss +++ b/ui/src/style/components/time-machine/flux-editor.scss @@ -18,6 +18,7 @@ .time-machine-editor { width: 100%; height: 100%; + position: relative; } .error-warning { @@ -28,4 +29,4 @@ .inline-error-message { color: white; background-color: red; -} \ No newline at end of file +}