diff --git a/ui/package-lock.json b/ui/package-lock.json index d4dbbce908..46ae2e98ee 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -6656,6 +6656,11 @@ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.2.3.tgz", "integrity": "sha1-0UD6j2FGWb1lQSMwl92qwlzdmRw=" }, + "immer": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-1.9.3.tgz", + "integrity": "sha512-bUyz3fOHGn82V7h4oVgJGmFglZt53JWwSyVNAT4sO0d7IovHLwLuHbh14uYKY0tewFoDcEdiQW7HuL0NsRVziw==" + }, "import-fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index 296fa540de..58b574e3f6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -123,6 +123,7 @@ "encoding-down": "^5.0.4", "fast.js": "^0.1.1", "history": "^3.2", + "immer": "^1.9.3", "level-js": "^3.0.0", "levelup": "^3.1.1", "lodash": "^4.3.0", diff --git a/ui/src/clockface/components/dropdowns/Dropdown.tsx b/ui/src/clockface/components/dropdowns/Dropdown.tsx index d7f31e9502..9ace7536e2 100644 --- a/ui/src/clockface/components/dropdowns/Dropdown.tsx +++ b/ui/src/clockface/components/dropdowns/Dropdown.tsx @@ -9,6 +9,7 @@ import DropdownDivider from 'src/clockface/components/dropdowns/DropdownDivider' import DropdownItem from 'src/clockface/components/dropdowns/DropdownItem' import DropdownButton from 'src/clockface/components/dropdowns/DropdownButton' import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar' +import WaitingText from 'src/shared/components/WaitingText' // Types import { @@ -124,8 +125,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) @@ -220,7 +238,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.' ) @@ -236,6 +254,7 @@ class Dropdown extends Component { if ( mode === DropdownMode.Radio && + this.shouldHaveChildren && (isUndefined(selectedID) || isNull(selectedID)) ) { throw new Error('Dropdowns in Radio mode require a selectedID prop.') diff --git a/ui/src/clockface/components/dropdowns/DropdownButton.tsx b/ui/src/clockface/components/dropdowns/DropdownButton.tsx index 7d5239a27e..69874b46e9 100644 --- a/ui/src/clockface/components/dropdowns/DropdownButton.tsx +++ b/ui/src/clockface/components/dropdowns/DropdownButton.tsx @@ -34,29 +34,51 @@ class DropdownButton extends Component { } public render() { - const {onClick, status, children, title} = this.props + const {onClick, children, title} = this.props return ( ) } + 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 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, }) } diff --git a/ui/src/dashboards/resources.ts b/ui/src/dashboards/resources.ts index 6a978e4644..af9fee5e3d 100644 --- a/ui/src/dashboards/resources.ts +++ b/ui/src/dashboards/resources.ts @@ -142,8 +142,7 @@ export const query: DashboardQuery = { editMode: QueryEditMode.Builder, builderConfig: { buckets: [], - measurements: [], - fields: [], + tags: [], functions: [], }, } diff --git a/ui/src/shared/actions/v2/queryBuilder.ts b/ui/src/shared/actions/v2/queryBuilder.ts new file mode 100644 index 0000000000..ba95072daf --- /dev/null +++ b/ui/src/shared/actions/v2/queryBuilder.ts @@ -0,0 +1,348 @@ +// APIs +import { + QueryBuilderFetcher, + CancellationError, +} from 'src/shared/apis/v2/queryBuilder' + +// Utils +import { + getActiveQuerySource, + getActiveQuery, +} from 'src/shared/selectors/timeMachines' + +// Types +import {Dispatch} from 'redux-thunk' +import {GetState} from 'src/types/v2' +import {RemoteDataState} from 'src/types' + +const fetcher = new QueryBuilderFetcher() + +export type Action = + | SetBuilderBucketSelectionAction + | SetBuilderBucketsAction + | SetBuilderBucketsStatusAction + | SetBuilderTagKeysAction + | SetBuilderTagKeysStatusAction + | SetBuilderTagValuesAction + | SetBuilderTagValuesStatusAction + | SetBuilderTagKeySelectionAction + | SetBuilderTagValuesSelectionAction + | AddTagSelectorAction + | RemoveTagSelectorAction + | SelectFunctionAction + +interface SetBuilderBucketsStatusAction { + type: 'SET_BUILDER_BUCKETS_STATUS' + payload: {bucketsStatus: RemoteDataState} +} + +const setBuilderBucketsStatus = ( + bucketsStatus: RemoteDataState +): SetBuilderBucketsStatusAction => ({ + type: 'SET_BUILDER_BUCKETS_STATUS', + payload: {bucketsStatus}, +}) + +interface SetBuilderBucketsAction { + type: 'SET_BUILDER_BUCKETS' + payload: {buckets: string[]} +} + +const setBuilderBuckets = (buckets: string[]): SetBuilderBucketsAction => ({ + type: 'SET_BUILDER_BUCKETS', + payload: {buckets}, +}) + +interface SetBuilderBucketSelectionAction { + type: 'SET_BUILDER_BUCKET_SELECTION' + payload: {bucket: string} +} + +const setBuilderBucket = (bucket: string): SetBuilderBucketSelectionAction => ({ + type: 'SET_BUILDER_BUCKET_SELECTION', + payload: {bucket}, +}) + +interface SetBuilderTagKeysAction { + type: 'SET_BUILDER_TAG_KEYS' + payload: {index: number; keys: string[]} +} + +const setBuilderTagKeys = ( + index: number, + keys: string[] +): SetBuilderTagKeysAction => ({ + type: 'SET_BUILDER_TAG_KEYS', + payload: {index, keys}, +}) + +interface SetBuilderTagKeysStatusAction { + type: 'SET_BUILDER_TAG_KEYS_STATUS' + payload: {index: number; status: RemoteDataState} +} + +const setBuilderTagKeysStatus = ( + index: number, + status: RemoteDataState +): SetBuilderTagKeysStatusAction => ({ + type: 'SET_BUILDER_TAG_KEYS_STATUS', + payload: {index, status}, +}) + +interface SetBuilderTagValuesAction { + type: 'SET_BUILDER_TAG_VALUES' + payload: {index: number; values: string[]} +} + +const setBuilderTagValues = ( + index: number, + values: string[] +): SetBuilderTagValuesAction => ({ + type: 'SET_BUILDER_TAG_VALUES', + payload: {index, values}, +}) + +interface SetBuilderTagValuesStatusAction { + type: 'SET_BUILDER_TAG_VALUES_STATUS' + payload: {index: number; status: RemoteDataState} +} + +const setBuilderTagValuesStatus = ( + index: number, + status: RemoteDataState +): SetBuilderTagValuesStatusAction => ({ + type: 'SET_BUILDER_TAG_VALUES_STATUS', + payload: {index, status}, +}) + +interface SetBuilderTagKeySelectionAction { + type: 'SET_BUILDER_TAG_KEY_SELECTION' + payload: {index: number; key: string} +} + +const setBuilderTagKeySelection = ( + index: number, + key: string +): SetBuilderTagKeySelectionAction => ({ + type: 'SET_BUILDER_TAG_KEY_SELECTION', + payload: {index, key}, +}) + +interface SetBuilderTagValuesSelectionAction { + type: 'SET_BUILDER_TAG_VALUES_SELECTION' + payload: {index: number; values: string[]} +} + +const setBuilderTagValuesSelection = ( + index: number, + values: string[] +): SetBuilderTagValuesSelectionAction => ({ + type: 'SET_BUILDER_TAG_VALUES_SELECTION', + payload: {index, values}, +}) + +interface AddTagSelectorAction { + type: 'ADD_TAG_SELECTOR' +} + +const addTagSelectorSync = (): AddTagSelectorAction => ({ + type: 'ADD_TAG_SELECTOR', +}) + +interface RemoveTagSelectorAction { + type: 'REMOVE_TAG_SELECTOR' + payload: {index: number} +} + +const removeTagSelectorSync = (index: number): RemoveTagSelectorAction => ({ + type: 'REMOVE_TAG_SELECTOR', + payload: {index}, +}) + +interface SelectFunctionAction { + type: 'SELECT_BUILDER_FUNCTION' + payload: {name: string} +} + +export const selectFunction = (name: string): SelectFunctionAction => ({ + type: 'SELECT_BUILDER_FUNCTION', + payload: {name}, +}) + +export const loadBuckets = () => async ( + dispatch: Dispatch, + getState: GetState +) => { + const queryURL = getActiveQuerySource(getState()).links.query + + dispatch(setBuilderBucketsStatus(RemoteDataState.Loading)) + + try { + const buckets = await fetcher.findBuckets(queryURL) + const selectedBucket = getActiveQuery(getState()).builderConfig.buckets[0] + + dispatch(setBuilderBuckets(buckets)) + + if (selectedBucket && buckets.includes(selectedBucket)) { + dispatch(selectBucket(selectedBucket)) + } else { + dispatch(selectBucket(buckets[0])) + } + } catch (e) { + if (e instanceof CancellationError) { + return + } + + console.error(e) + dispatch(setBuilderBucketsStatus(RemoteDataState.Error)) + } +} + +export const selectBucket = (bucket: string) => async ( + dispatch: Dispatch +) => { + dispatch(setBuilderBucket(bucket)) + dispatch(loadTagSelector(0)) +} + +export const loadTagSelector = (index: number) => async ( + dispatch: Dispatch, + getState: GetState +) => { + const {buckets, tags} = getActiveQuery(getState()).builderConfig + + if (!tags[index] || !buckets[0]) { + return + } + + const tagPredicates = tags.slice(0, index) + const queryURL = getActiveQuerySource(getState()).links.query + + dispatch(setBuilderTagKeysStatus(index, RemoteDataState.Loading)) + + try { + const keys = await fetcher.findKeys( + index, + queryURL, + buckets[0], + tagPredicates + ) + + const {key} = tags[index] + + if (!key) { + dispatch(setBuilderTagKeySelection(index, keys[0])) + } else if (!keys.includes(key)) { + // Even if the selected key didn't come back in the results, let it be + // selected anyway + keys.unshift(key) + } + + dispatch(setBuilderTagKeys(index, keys)) + dispatch(loadTagSelectorValues(index)) + } catch (e) { + if (e instanceof CancellationError) { + return + } + + console.error(e) + dispatch(setBuilderTagKeysStatus(index, RemoteDataState.Error)) + } +} + +const loadTagSelectorValues = (index: number) => async ( + dispatch: Dispatch, + getState: GetState +) => { + const {buckets, tags} = getActiveQuery(getState()).builderConfig + const tagPredicates = tags.slice(0, index) + const queryURL = getActiveQuerySource(getState()).links.query + + dispatch(setBuilderTagValuesStatus(index, RemoteDataState.Loading)) + + try { + const key = getActiveQuery(getState()).builderConfig.tags[index].key + const values = await fetcher.findValues( + index, + queryURL, + buckets[0], + tagPredicates, + key + ) + + const {values: selectedValues} = tags[index] + + for (const selectedValue of selectedValues) { + // Even if the selected values didn't come back in the results, let them + // be selected anyway + if (!values.includes(selectedValue)) { + values.unshift(selectedValue) + } + } + + dispatch(setBuilderTagValues(index, values)) + dispatch(loadTagSelector(index + 1)) + } catch (e) { + if (e instanceof CancellationError) { + return + } + + console.error(e) + dispatch(setBuilderTagValuesStatus(index, RemoteDataState.Error)) + } +} + +export const selectTagValue = (index: number, value: string) => async ( + dispatch: Dispatch, + getState: GetState +) => { + const tags = getActiveQuery(getState()).builderConfig.tags + const values = tags[index].values + + let newValues: string[] + + if (values.includes(value)) { + newValues = values.filter(v => v !== value) + } else { + newValues = [...values, value] + } + + dispatch(setBuilderTagValuesSelection(index, newValues)) + + if (index === tags.length - 1 && newValues.length) { + dispatch(addTagSelector()) + } else { + dispatch(loadTagSelector(index + 1)) + } +} + +export const selectTagKey = (index: number, key: string) => async ( + dispatch: Dispatch +) => { + dispatch(setBuilderTagKeySelection(index, key)) + dispatch(loadTagSelectorValues(index)) +} + +// TODO +export const searchTagValues = (/* index: number, searchTerm: string */) => async () => {} + +export const addTagSelector = () => async ( + dispatch: Dispatch, + getState: GetState +) => { + dispatch(addTagSelectorSync()) + + const newIndex = getActiveQuery(getState()).builderConfig.tags.length - 1 + + dispatch(loadTagSelector(newIndex)) +} + +export const removeTagSelector = (index: number) => async ( + dispatch: Dispatch +) => { + fetcher.cancelFindValues(index) + fetcher.cancelFindKeys(index) + + dispatch(removeTagSelectorSync(index)) + dispatch(loadTagSelector(index)) +} diff --git a/ui/src/shared/actions/v2/timeMachines.ts b/ui/src/shared/actions/v2/timeMachines.ts index c25df693b6..83ed59dc0d 100644 --- a/ui/src/shared/actions/v2/timeMachines.ts +++ b/ui/src/shared/actions/v2/timeMachines.ts @@ -1,6 +1,11 @@ +// Actions +import {loadBuckets} from 'src/shared/actions/v2/queryBuilder' + // Types +import {Dispatch} from 'redux-thunk' import {TimeMachineState} from 'src/shared/reducers/v2/timeMachines' -import {TimeRange, ViewType, BuilderConfig} from 'src/types/v2' +import {Action as QueryBuilderAction} from 'src/shared/actions/v2/queryBuilder' +import {TimeRange, ViewType} from 'src/types/v2' import { Axes, DecimalPlaces, @@ -12,6 +17,7 @@ import {TimeMachineTab} from 'src/types/v2/timeMachine' import {Color} from 'src/types/colors' export type Action = + | QueryBuilderAction | SetActiveTimeMachineAction | SetActiveTabAction | SetNameAction @@ -34,7 +40,6 @@ export type Action = | SetYAxisSuffix | SetYAxisBase | SetYAxisScale - | SetQuerySourceAction | SetPrefix | SetSuffix | IncrementSubmitToken @@ -44,7 +49,6 @@ export type Action = | EditActiveQueryAsFluxAction | EditActiveQueryAsInfluxQLAction | EditActiveQueryWithBuilderAction - | BuildQueryAction | UpdateActiveQueryNameAction | SetFieldOptionsAction | SetTableOptionsAction @@ -296,16 +300,6 @@ export const setTextThresholdColoring = (): SetTextThresholdColoringAction => ({ type: 'SET_TEXT_THRESHOLD_COLORING', }) -interface SetQuerySourceAction { - type: 'SET_QUERY_SOURCE' - payload: {sourceID: string} -} - -export const setQuerySource = (sourceID: string): SetQuerySourceAction => ({ - type: 'SET_QUERY_SOURCE', - payload: {sourceID}, -}) - interface IncrementSubmitToken { type: 'INCREMENT_SUBMIT_TOKEN' } @@ -343,41 +337,50 @@ interface SetActiveQueryIndexAction { payload: {activeQueryIndex: number} } -export const setActiveQueryIndex = ( +export const setActiveQueryIndexSync = ( activeQueryIndex: number ): SetActiveQueryIndexAction => ({ type: 'SET_ACTIVE_QUERY_INDEX', payload: {activeQueryIndex}, }) +export const setActiveQueryIndex = (activeQueryIndex: number) => ( + dispatch: Dispatch +) => { + dispatch(setActiveQueryIndexSync(activeQueryIndex)) + dispatch(loadBuckets()) +} + interface AddQueryAction { type: 'ADD_QUERY' } -export const addQuery = (): AddQueryAction => ({ +export const addQuerySync = (): AddQueryAction => ({ type: 'ADD_QUERY', }) +export const addQuery = () => (dispatch: Dispatch) => { + dispatch(addQuerySync()) + dispatch(loadBuckets()) +} + interface RemoveQueryAction { type: 'REMOVE_QUERY' payload: {queryIndex: number} } -export const removeQuery = (queryIndex: number): RemoveQueryAction => ({ +export const removeQuerySync = (queryIndex: number): RemoveQueryAction => ({ type: 'REMOVE_QUERY', payload: {queryIndex}, }) -interface BuildQueryAction { - type: 'BUILD_QUERY' - payload: {builderConfig: BuilderConfig} +export const removeQuery = (queryIndex: number) => ( + dispatch: Dispatch +) => { + dispatch(removeQuerySync(queryIndex)) + dispatch(loadBuckets()) } -export const buildQuery = (builderConfig: BuilderConfig): BuildQueryAction => ({ - type: 'BUILD_QUERY', - payload: {builderConfig}, -}) - interface UpdateActiveQueryNameAction { type: 'UPDATE_ACTIVE_QUERY_NAME' payload: {queryName: string} diff --git a/ui/src/shared/apis/v2/queryBuilder.ts b/ui/src/shared/apis/v2/queryBuilder.ts index 74fba0a08b..5f29c2f02e 100644 --- a/ui/src/shared/apis/v2/queryBuilder.ts +++ b/ui/src/shared/apis/v2/queryBuilder.ts @@ -1,135 +1,81 @@ // Libraries import {get} from 'lodash' +import uuid from 'uuid' // APIs import {executeQuery, ExecuteFluxQueryResult} from 'src/shared/apis/v2/query' import {parseResponse} from 'src/shared/parsing/flux/response' -// Types -import {InfluxLanguage} from 'src/types/v2' -import {Source} from 'src/api' +// Utils +import {formatTagFilterCall} from 'src/shared/utils/queryBuilder' -export const SEARCH_DURATION = '30d' +// Types +import {InfluxLanguage, BuilderConfig} from 'src/types/v2' + +export const SEARCH_DURATION = '5m' export const LIMIT = 200 -export async function findBuckets( - url: string, - sourceType: Source.TypeEnum, - searchTerm?: string -) { - if (sourceType === Source.TypeEnum.V1) { - throw new Error('metaqueries not yet implemented for SourceType.V1') - } +async function findBuckets(url: string): Promise { + const query = `buckets() + |> sort(columns: ["name"]) + |> limit(n: ${LIMIT})` - const resp = await findBucketsFlux(url, searchTerm) + const resp = await executeQuery(url, query, InfluxLanguage.Flux) const parsed = extractCol(resp, 'name') return parsed } -export async function findMeasurements( +async function findKeys( url: string, - sourceType: Source.TypeEnum, bucket: string, + tagsSelections: BuilderConfig['tags'], searchTerm: string = '' ): Promise { - if (sourceType === Source.TypeEnum.V1) { - throw new Error('metaqueries not yet implemented for SourceType.V1') - } + const tagFilters = formatTagFilterCall(tagsSelections) + const searchFilter = formatSearchFilterCall(searchTerm) + const previousKeyFilter = formatTagKeyFilterCall(tagsSelections) - const resp = await findMeasurementsFlux(url, bucket, searchTerm) - const parsed = extractCol(resp, '_measurement') + const query = `from(bucket: "${bucket}") + |> range(start: -${SEARCH_DURATION})${tagFilters} + |> keys(except: ["_time", "_start", "_stop", "_value"]) + |> group() + |> distinct() + |> keep(columns: ["_value"])${searchFilter}${previousKeyFilter} + |> sort() + |> limit(n: ${LIMIT})` + + const resp = await executeQuery(url, query, InfluxLanguage.Flux) + const parsed = extractCol(resp, '_value') return parsed } -export async function findFields( +async function findValues( url: string, - sourceType: Source.TypeEnum, bucket: string, - measurements: string[], + tagsSelections: BuilderConfig['tags'], + key: string, searchTerm: string = '' ): Promise { - if (sourceType === Source.TypeEnum.V1) { - throw new Error('metaqueries not yet implemented for SourceType.V1') - } + const tagFilters = formatTagFilterCall(tagsSelections) + const searchFilter = formatSearchFilterCall(searchTerm) - const resp = await findFieldsFlux(url, bucket, measurements, searchTerm) - const parsed = extractCol(resp, '_field') + const query = `from(bucket: "${bucket}") + |> range(start: -${SEARCH_DURATION})${tagFilters} + |> group(columns: ["${key}"]) + |> distinct(column: "${key}") + |> group() + |> keep(columns: ["_value"])${searchFilter} + |> sort() + |> limit(n: ${LIMIT})` + + const resp = await executeQuery(url, query, InfluxLanguage.Flux) + const parsed = extractCol(resp, '_value') return parsed } -function findBucketsFlux( - url: string, - searchTerm: string -): Promise { - let query = 'buckets()' - - if (searchTerm) { - query += ` - |> filter(fn: (r) => r.name =~ /(?i:${searchTerm})/)` - } - - query += ` - |> sort(columns: ["name"]) - |> limit(n: ${LIMIT})` - - return executeQuery(url, query, InfluxLanguage.Flux) -} - -function findMeasurementsFlux( - url: string, - bucket: string, - searchTerm: string -): Promise { - let query = `from(bucket: "${bucket}") - |> range(start: -${SEARCH_DURATION})` - - if (searchTerm) { - query += ` - |> filter(fn: (r) => r._measurement =~ /(?i:${searchTerm})/)` - } - - query += ` - |> group(columns: ["_measurement"]) - |> distinct(column: "_measurement") - |> group() - |> sort(columns: ["_measurement"]) - |> limit(n: ${LIMIT})` - - return executeQuery(url, query, InfluxLanguage.Flux) -} - -function findFieldsFlux( - url: string, - bucket: string, - measurements: string[], - searchTerm: string -): Promise { - const measurementPredicate = measurements - .map(m => `r._measurement == "${m}"`) - .join(' or ') - - let query = `from(bucket: "${bucket}") - |> range(start: -${SEARCH_DURATION}) - |> filter(fn: (r) => ${measurementPredicate})` - - if (searchTerm) { - query += ` - |> filter(fn: (r) => r._field =~ /(?i:${searchTerm})/)` - } - - query += ` - |> group(columns: ["_field"]) - |> distinct(column: "_field") - |> group() - |> sort(columns: ["_field"]) - |> limit(n: ${LIMIT})` - - return executeQuery(url, query, InfluxLanguage.Flux) -} - function extractCol(resp: ExecuteFluxQueryResult, colName: string): string[] { const tables = parseResponse(resp.csv) const data = get(tables, '0.data', []) @@ -152,3 +98,100 @@ function extractCol(resp: ExecuteFluxQueryResult, colName: string): string[] { return colValues } + +function formatTagKeyFilterCall(tagsSelections: BuilderConfig['tags']) { + const keys = tagsSelections.map(({key}) => key) + + if (!keys.length) { + return '' + } + + const fnBody = keys.map(key => `r._value != "${key}"`).join(' and ') + + return `\n |> filter(fn: (r) => ${fnBody})` +} + +function formatSearchFilterCall(searchTerm: string) { + if (!searchTerm) { + return '' + } + + return `\n |> filter(fn: (r) => r._value =~ /(?i:${searchTerm})/)` +} + +export class CancellationError extends Error {} + +export class QueryBuilderFetcher { + private findBucketsToken: string = '' + private findKeysTokens = [] + private findValuesTokens = [] + + public async findBuckets(url: string): Promise { + const token = uuid.v4() + + this.findBucketsToken = token + + const result = await findBuckets(url) + + if (token !== this.findBucketsToken) { + throw new CancellationError() + } + + return result + } + + public async findKeys( + index: number, + url: string, + bucket: string, + tagsSelections: BuilderConfig['tags'], + searchTerm: string = '' + ): Promise { + const token = uuid.v4() + + this.findKeysTokens[index] = token + + const result = await findKeys(url, bucket, tagsSelections, searchTerm) + + if (token !== this.findKeysTokens[index]) { + throw new CancellationError() + } + + return result + } + + public cancelFindKeys(index) { + this.findKeysTokens[index] = uuid.v4() + } + + public async findValues( + index: number, + url: string, + bucket: string, + tagsSelections: BuilderConfig['tags'], + key: string, + searchTerm: string = '' + ): Promise { + const token = uuid.v4() + + this.findValuesTokens[index] = token + + const result = await findValues( + url, + bucket, + tagsSelections, + key, + searchTerm + ) + + if (token !== this.findValuesTokens[index]) { + throw new CancellationError() + } + + return result + } + + public cancelFindValues(index) { + this.findValuesTokens[index] = uuid.v4() + } +} diff --git a/ui/src/shared/components/BuilderCard.scss b/ui/src/shared/components/BuilderCard.scss deleted file mode 100644 index bdf2f5b747..0000000000 --- a/ui/src/shared/components/BuilderCard.scss +++ /dev/null @@ -1,73 +0,0 @@ -@import "src/style/modules"; - -.builder-card { - position: relative; - overflow: hidden; - min-height: 150px; - background: $g4-onyx; - border-radius: 4px; - display: flex; - flex-direction: column; - font-size: 13px; -} - -.builder-card-search-bar { - margin: 10px; - flex-grow: 0; - flex-shrink: 0; -} - -.builder-card--items { - flex: 1 1 0; - overflow: scroll; -} - -.builder-card--item { - font-weight: 500; - padding: 5px 15px; - margin-bottom: 1px; - cursor: pointer; - color: $g14-chromium; - font-family: $code-font; - - &.selected { - background: $c-pool; - color: $g18-cloud; - } - - &.selected:hover { - background: $c-pool; - opacity: 0.9; - } - - &:hover { - background: $g4-onyx; - } -} - -.builder-card--empty { - position: absolute; - top: 50%; - transform: translate(0, -50%); - color: $g8-storm; - font-weight: 500; - font-size: 16px; - text-transform: uppercase; - text-align: center; - width: 100%; - padding: 20px; - - .waiting-text { - text-align: start; - width: 85px; - margin: 0 auto; - - } -} - -.builder-card-limit-message { - padding: 15px; - color: $g9-mountain; - text-align: center; - font-style: italic; -} diff --git a/ui/src/shared/components/BuilderCard.tsx b/ui/src/shared/components/BuilderCard.tsx deleted file mode 100644 index 3e86925a3e..0000000000 --- a/ui/src/shared/components/BuilderCard.tsx +++ /dev/null @@ -1,119 +0,0 @@ -// Libraries -import React, {PureComponent} from 'react' - -// Components -import BuilderCardSearchBar from 'src/shared/components/BuilderCardSearchBar' -import BuilderCardLimitMessage from 'src/shared/components/BuilderCardLimitMessage' -import WaitingText from 'src/shared/components/WaitingText' -import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar' - -// Styles -import 'src/shared/components/BuilderCard.scss' - -// Types -import {RemoteDataState} from 'src/types' - -interface Props { - items: string[] - selectedItems: string[] - onSelectItems: (selection: string[]) => void - onSearch: (searchTerm: string) => Promise - status?: RemoteDataState - emptyText?: string - limitCount?: number - singleSelect?: boolean -} - -class BuilderCard extends PureComponent { - public static defaultProps: Partial = { - status: RemoteDataState.Done, - emptyText: 'None Found', - limitCount: Infinity, - singleSelect: false, - } - - public render() { - return
{this.body}
- } - - private get body(): JSX.Element { - const {status, onSearch, emptyText} = this.props - - if (status === RemoteDataState.Error) { - return
Failed to load
- } - - if (status === RemoteDataState.NotStarted) { - return
{emptyText}
- } - - return ( - <> - - {this.items} - - ) - } - - private get items(): JSX.Element { - const {status, items, limitCount} = this.props - - if (status === RemoteDataState.Loading) { - return ( -
- -
- ) - } - - return ( - <> -
- {items.length ? ( - - {items.map(item => ( -
- {item} -
- ))} -
- ) : ( -
Nothing found
- )} -
- - - ) - } - - private itemClass = (item): string => { - if (this.props.selectedItems.includes(item)) { - return 'builder-card--item selected' - } - - return 'builder-card--item' - } - - private handleToggleItem = (item: string) => (): void => { - const {singleSelect, selectedItems, onSelectItems} = this.props - - if (singleSelect && selectedItems[0] === item) { - onSelectItems([]) - } else if (singleSelect) { - onSelectItems([item]) - } else if (selectedItems.includes(item)) { - onSelectItems(selectedItems.filter(x => x !== item)) - } else { - onSelectItems([...selectedItems, item]) - } - } -} - -export default BuilderCard diff --git a/ui/src/shared/components/BuilderCardLimitMessage.tsx b/ui/src/shared/components/BuilderCardLimitMessage.tsx deleted file mode 100644 index b2204df16f..0000000000 --- a/ui/src/shared/components/BuilderCardLimitMessage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React, {SFC} from 'react' - -interface Props { - itemCount: number - limitCount: number -} - -const BuilderCardLimitMessage: SFC = ({itemCount, limitCount}) => { - if (itemCount < limitCount) { - return null - } - - return ( -
- Showing first {limitCount} results. Use the search bar to find more. -
- ) -} - -export default BuilderCardLimitMessage diff --git a/ui/src/shared/components/BuilderCardSearchBar.tsx b/ui/src/shared/components/BuilderCardSearchBar.tsx deleted file mode 100644 index c320bba6cc..0000000000 --- a/ui/src/shared/components/BuilderCardSearchBar.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// Libraries -import React, {PureComponent, ChangeEvent} from 'react' - -// Components -import {Input, ComponentSize} from 'src/clockface' - -// Utils -import DefaultDebouncer, {Debouncer} from 'src/shared/utils/debouncer' -import {CancellationError} from 'src/utils/restartable' - -const SEARCH_DEBOUNCE_MS = 350 - -interface Props { - onSearch: (searchTerm: string) => Promise -} - -interface State { - searchTerm: string -} - -class BuilderCardSearchBar extends PureComponent { - public state: State = {searchTerm: ''} - - private debouncer: Debouncer = new DefaultDebouncer() - - public render() { - const {searchTerm} = this.state - - return ( -
- -
- ) - } - - private handleChange = (e: ChangeEvent) => { - this.setState({searchTerm: e.target.value}, () => - this.debouncer.call(this.emitChange, SEARCH_DEBOUNCE_MS) - ) - } - - private emitChange = async () => { - const {onSearch} = this.props - const {searchTerm} = this.state - - try { - await onSearch(searchTerm) - } catch (e) { - if (e instanceof CancellationError) { - return - } - - this.setState({searchTerm: ''}) - } - } -} - -export default BuilderCardSearchBar diff --git a/ui/src/shared/components/FunctionSelector.scss b/ui/src/shared/components/FunctionSelector.scss new file mode 100644 index 0000000000..486306231a --- /dev/null +++ b/ui/src/shared/components/FunctionSelector.scss @@ -0,0 +1,26 @@ +@import "src/style/modules"; + +.function-selector { + display: flex; + flex-direction: column; + border-radius: 4px; + background: $g4-onyx; + + h3 { + margin: 10px 5px 10px 10px; + padding: 5px 0 0 0; + text-transform: uppercase; + color: $g10-wolf; + font-weight: 500; + font-size: 16px; + } +} + +.function-selector--search { + margin: 0 10px 10px 10px; + width: initial !important; +} + +.function-selector .selector-list { + flex: 1 1 0; +} diff --git a/ui/src/shared/components/FunctionSelector.tsx b/ui/src/shared/components/FunctionSelector.tsx new file mode 100644 index 0000000000..4070d424e2 --- /dev/null +++ b/ui/src/shared/components/FunctionSelector.tsx @@ -0,0 +1,91 @@ +// Libraries +import React, {PureComponent, ChangeEvent} from 'react' +import {connect} from 'react-redux' + +// Components +import {Input} from 'src/clockface' +import SelectorList from 'src/shared/components/SelectorList' + +// Actions +import {selectFunction} from 'src/shared/actions/v2/queryBuilder' + +// Utils +import {getActiveQuery} from 'src/shared/selectors/timeMachines' + +// Constants +import {FUNCTIONS} from 'src/shared/constants/queryBuilder' + +// Styles +import 'src/shared/components/FunctionSelector.scss' + +// Types +import {AppState, BuilderConfig} from 'src/types/v2' + +const FUNCTION_NAMES = FUNCTIONS.map(f => f.name) + +interface StateProps { + selectedFunctions: BuilderConfig['functions'] +} + +interface DispatchProps { + onSelectFunction: (fnName: string) => void +} + +type Props = StateProps & DispatchProps + +interface State { + searchTerm: string +} + +class FunctionSelector extends PureComponent { + public state: State = {searchTerm: ''} + + public render() { + const {onSelectFunction} = this.props + const {searchTerm} = this.state + + return ( +
+

Aggregate Functions

+ + +
+ ) + } + + private get functions(): string[] { + return FUNCTION_NAMES.filter(f => f.includes(this.state.searchTerm)) + } + + private get selectedFunctions(): string[] { + return this.props.selectedFunctions.map(f => f.name) + } + + private handleSetSearchTerm = (e: ChangeEvent) => { + this.setState({searchTerm: e.target.value}) + } +} + +const mstp = (state: AppState) => { + const selectedFunctions = getActiveQuery(state).builderConfig.functions + + return {selectedFunctions} +} + +const mdtp = { + onSelectFunction: selectFunction, +} + +export default connect( + mstp, + mdtp +)(FunctionSelector) diff --git a/ui/src/shared/components/QueryBuilderBucketDropdown.tsx b/ui/src/shared/components/QueryBuilderBucketDropdown.tsx new file mode 100644 index 0000000000..5e979f0d55 --- /dev/null +++ b/ui/src/shared/components/QueryBuilderBucketDropdown.tsx @@ -0,0 +1,70 @@ +// Libraries +import React, {SFC} from 'react' +import {connect} from 'react-redux' + +// Components +import {Dropdown, ComponentSize} from 'src/clockface' + +// Actions +import {selectBucket} from 'src/shared/actions/v2/queryBuilder' + +// Utils +import { + getActiveTimeMachine, + getActiveQuery, +} from 'src/shared/selectors/timeMachines' +import {toComponentStatus} from 'src/shared/utils/toComponentStatus' + +// Types +import {AppState} from 'src/types/v2' +import {RemoteDataState} from 'src/types' + +interface StateProps { + selectedBucket: string + buckets: string[] + bucketsStatus: RemoteDataState +} + +interface DispatchProps { + onSelectBucket: (bucket: string) => void +} + +interface OwnProps {} + +type Props = StateProps & DispatchProps & OwnProps + +const QueryBuilderBucketDropdown: SFC = props => { + const {selectedBucket, buckets, bucketsStatus, onSelectBucket} = props + + return ( + + {buckets.map(bucket => ( + + {bucket} + + ))} + + ) +} + +const mstp = (state: AppState) => { + const {buckets, bucketsStatus} = getActiveTimeMachine(state).queryBuilder + const selectedBucket = + getActiveQuery(state).builderConfig.buckets[0] || buckets[0] + + return {selectedBucket, buckets, bucketsStatus} +} + +const mdtp = { + onSelectBucket: selectBucket as any, +} + +export default connect( + mstp, + mdtp +)(QueryBuilderBucketDropdown) diff --git a/ui/src/shared/components/SelectorList.scss b/ui/src/shared/components/SelectorList.scss new file mode 100644 index 0000000000..e850a39cf4 --- /dev/null +++ b/ui/src/shared/components/SelectorList.scss @@ -0,0 +1,25 @@ +@import "src/style/modules"; + +.selector-list--item { + font-weight: 500; + padding: 5px 15px; + margin-bottom: 1px; + cursor: pointer; + color: $g14-chromium; + font-family: $code-font; + font-size: 13px; + + &.selected { + background: $c-pool; + color: $g18-cloud; + } + + &.selected:hover { + background: $c-pool; + opacity: 0.9; + } + + &:hover { + background: $g5-pepper; + } +} diff --git a/ui/src/shared/components/SelectorList.tsx b/ui/src/shared/components/SelectorList.tsx new file mode 100644 index 0000000000..ece4e26392 --- /dev/null +++ b/ui/src/shared/components/SelectorList.tsx @@ -0,0 +1,37 @@ +import React, {SFC} from 'react' + +import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar' + +import 'src/shared/components/SelectorList.scss' + +interface Props { + items: string[] + selectedItems: string[] + onSelectItem: (item: string) => void +} + +const SelectorList: SFC = props => { + const {items, selectedItems, onSelectItem} = props + + return ( +
+ + {items.map(item => { + const selectedClass = selectedItems.includes(item) ? 'selected' : '' + + return ( +
onSelectItem(item)} + > + {item} +
+ ) + })} +
+
+ ) +} + +export default SelectorList diff --git a/ui/src/shared/components/TagSelector.scss b/ui/src/shared/components/TagSelector.scss new file mode 100644 index 0000000000..cee35a8348 --- /dev/null +++ b/ui/src/shared/components/TagSelector.scss @@ -0,0 +1,53 @@ +@import "src/style/modules"; + +.tag-selector { + min-height: 130px; + background: $g4-onyx; + border-radius: 4px; + padding: 10px 0; + overflow: hidden; + display: flex; + flex-direction: column; + font-size: 13px; + + input { + margin-top: 5px; + } +} + +.tag-selector--top, .tag-selector--search { + margin: 0 10px; +} + +.tag-selector--top { + display: flex; +} + +.tag-selector--search { + margin-bottom: 10px; + width: initial !important; +} + +button.tag-selector--remove { + margin-left: 3px; +} + +.tag-selector .selector-list { + flex: 1 1 0; +} + + +.tag-selector--empty { + flex: 1 1 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + color: $g8-storm; + font-weight: 500; + font-size: 16px; + text-transform: uppercase; + text-align: center; + padding: 10px; +} diff --git a/ui/src/shared/components/TagSelector.tsx b/ui/src/shared/components/TagSelector.tsx new file mode 100644 index 0000000000..1549694179 --- /dev/null +++ b/ui/src/shared/components/TagSelector.tsx @@ -0,0 +1,233 @@ +// Libraries +import React, {PureComponent, ChangeEvent} from 'react' +import {connect} from 'react-redux' + +// Components +import {Dropdown, Input, Button, ButtonShape, IconFont} from 'src/clockface' +import WaitingText from 'src/shared/components/WaitingText' +import SelectorList from 'src/shared/components/SelectorList' + +// Actions +import { + selectTagKey, + selectTagValue, + searchTagValues, + removeTagSelector, +} from 'src/shared/actions/v2/queryBuilder' + +// Utils +import {toComponentStatus} from 'src/shared/utils/toComponentStatus' +import DefaultDebouncer from 'src/shared/utils/debouncer' +import { + getActiveQuery, + getActiveTimeMachine, +} from 'src/shared/selectors/timeMachines' + +// Styles +import 'src/shared/components/TagSelector.scss' + +// Types +import {AppState} from 'src/types/v2' +import {RemoteDataState} from 'src/types' + +const SEARCH_DEBOUNCE_MS = 500 + +interface StateProps { + emptyText: string + keys: string[] + keysStatus: RemoteDataState + selectedKey: string + values: string[] + valuesStatus: RemoteDataState + selectedValues: string[] +} + +interface DispatchProps { + onSelectValue: (index: number, value: string) => void + onSelectTag: (index: number, tag: string) => void + onSearchValues: (index: number, searchTerm: string) => void + onRemoveTagSelector: (index: number) => void +} + +interface OwnProps { + index: number +} + +type Props = StateProps & DispatchProps & OwnProps + +interface State { + searchTerm: string +} + +class TagSelector extends PureComponent { + public state: State = {searchTerm: ''} + + private debouncer = new DefaultDebouncer() + + public render() { + return
{this.body}
+ } + + private get body() { + const {index, keys, keysStatus, selectedKey, emptyText} = this.props + const {searchTerm} = this.state + + if (keysStatus === RemoteDataState.NotStarted) { + return
{emptyText}
+ } + + if (keysStatus === RemoteDataState.Loading) { + return ( +
+ +
+ ) + } + + if (keysStatus === RemoteDataState.Error) { + return
Failed to load tag keys
+ } + + if (keysStatus === RemoteDataState.Done && !keys.length) { + return
No more tag keys found
+ } + + return ( + <> +
+ + {keys.map(key => ( + + {key} + + ))} + + {index !== 0 && ( +
+ + {this.values} + + ) + } + + private get values() { + const {selectedKey, values, valuesStatus, selectedValues} = this.props + + if (valuesStatus === RemoteDataState.Error) { + return ( +
+ {`Failed to load tag values for ${selectedKey}`} +
+ ) + } + + if (valuesStatus === RemoteDataState.Loading) { + return ( +
+ +
+ ) + } + + if (valuesStatus === RemoteDataState.Done && !values.length) { + return
Nothing found
+ } + + return ( + + ) + } + + private handleSelectTag = (tag: string): void => { + const {index, onSelectTag} = this.props + + onSelectTag(index, tag) + } + + private handleSelectValue = (value: string): void => { + const {index, onSelectValue} = this.props + + onSelectValue(index, value) + } + + private handleRemoveTagSelector = () => { + const {index, onRemoveTagSelector} = this.props + + onRemoveTagSelector(index) + } + + private handleSearch = (e: ChangeEvent) => { + const {value} = e.target + + this.setState({searchTerm: value}, () => { + this.debouncer.call(this.emitSearch, SEARCH_DEBOUNCE_MS) + }) + } + + private emitSearch = () => { + const {index, onSearchValues} = this.props + const {searchTerm} = this.state + + onSearchValues(index, searchTerm) + } +} + +const mstp = (state: AppState, ownProps: OwnProps): StateProps => { + const {keys, keysStatus, values, valuesStatus} = getActiveTimeMachine( + state + ).queryBuilder.tags[ownProps.index] + + const tags = getActiveQuery(state).builderConfig.tags + const {key: selectedKey, values: selectedValues} = tags[ownProps.index] + + let emptyText: string + + if (ownProps.index === 0 || !tags[ownProps.index - 1].key) { + emptyText = '' + } else { + emptyText = `Select a ${tags[ownProps.index - 1].key} value first` + } + + return { + emptyText, + keys, + keysStatus, + selectedKey, + values, + valuesStatus, + selectedValues, + } +} + +const mdtp = { + onSelectValue: selectTagValue, + onSelectTag: selectTagKey, + onSearchValues: searchTagValues, + onRemoveTagSelector: removeTagSelector, +} + +export default connect( + mstp, + mdtp +)(TagSelector) diff --git a/ui/src/shared/components/TimeMachine.tsx b/ui/src/shared/components/TimeMachine.tsx index 10d0f8c3f7..8ddb085ffd 100644 --- a/ui/src/shared/components/TimeMachine.tsx +++ b/ui/src/shared/components/TimeMachine.tsx @@ -11,7 +11,7 @@ import TimeMachineVis from 'src/shared/components/TimeMachineVis' import TimeSeries from 'src/shared/components/TimeSeries' // Constants -const INITIAL_RESIZER_HANDLE = 0.6 +const INITIAL_RESIZER_HANDLE = 0.5 // Utils import {getActiveTimeMachine} from 'src/shared/selectors/timeMachines' diff --git a/ui/src/shared/components/TimeMachineControls.tsx b/ui/src/shared/components/TimeMachineControls.tsx index cc61a12e33..d43804bf99 100644 --- a/ui/src/shared/components/TimeMachineControls.tsx +++ b/ui/src/shared/components/TimeMachineControls.tsx @@ -11,7 +11,6 @@ import { ComponentSpacer, Alignment, } from 'src/clockface' -import TimeMachineSourceDropdown from 'src/shared/components/TimeMachineSourceDropdown' import TimeMachineRefreshDropdown from 'src/shared/components/TimeMachineRefreshDropdown' import ViewTypeDropdown from 'src/shared/components/view_options/ViewTypeDropdown' @@ -56,7 +55,6 @@ class TimeMachineControls extends PureComponent { return (
- diff --git a/ui/src/shared/components/TimeMachineQueries.tsx b/ui/src/shared/components/TimeMachineQueries.tsx index ff6f34b746..7098575ae7 100644 --- a/ui/src/shared/components/TimeMachineQueries.tsx +++ b/ui/src/shared/components/TimeMachineQueries.tsx @@ -88,7 +88,9 @@ const TimeMachineQueries: SFC = props => {
- + {activeQuery.editMode !== QueryEditMode.Builder && ( + + )}
{queryEditor}
diff --git a/ui/src/shared/components/TimeMachineQueriesSwitcher.tsx b/ui/src/shared/components/TimeMachineQueriesSwitcher.tsx index 7434b1b933..9afc03e6e5 100644 --- a/ui/src/shared/components/TimeMachineQueriesSwitcher.tsx +++ b/ui/src/shared/components/TimeMachineQueriesSwitcher.tsx @@ -3,7 +3,7 @@ import React, {PureComponent} from 'react' import {connect} from 'react-redux' // Components -import {Button, Dropdown} from 'src/clockface' +import {Button} from 'src/clockface' // Actions import { @@ -13,14 +13,18 @@ import { } from 'src/shared/actions/v2/timeMachines' // Utils -import {getActiveQuery} from 'src/shared/selectors/timeMachines' +import { + getActiveQuery, + getActiveQuerySource, +} from 'src/shared/selectors/timeMachines' import {CONFIRM_LEAVE_ADVANCED_MODE} from 'src/shared/copy/v2' // Types -import {AppState, QueryEditMode} from 'src/types/v2' +import {AppState, QueryEditMode, Source} from 'src/types/v2' interface StateProps { editMode: QueryEditMode + sourceType: Source.TypeEnum } interface DispatchProps { @@ -33,7 +37,7 @@ type Props = StateProps & DispatchProps class TimeMachineQueriesSwitcher extends PureComponent { public render() { - const {editMode, onEditAsFlux, onEditAsInfluxQL} = this.props + const {editMode, sourceType, onEditAsFlux, onEditAsInfluxQL} = this.props if (editMode !== QueryEditMode.Builder) { return ( @@ -44,21 +48,11 @@ class TimeMachineQueriesSwitcher extends PureComponent { ) } - return ( - - - Flux - - - InfluxQL - - - ) + if (sourceType === Source.TypeEnum.V1) { + return