From 3e59ae7e56ed25f2664b9d33eac10ec909d6a682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Tue, 11 May 2021 08:15:44 +0200 Subject: [PATCH] InfluxDB: Convert the InfluxQL query editor from Angular to React (#32168) Co-authored-by: Giordano Ricci --- .../influxdb/components/FluxQueryEditor.tsx | 8 - .../influxdb/components/QueryEditor.tsx | 41 ++ .../components/QueryEditorModeSwitcher.tsx | 56 +++ .../influxdb/components/RawInfluxQLEditor.tsx | 16 +- .../VisualInfluxQLEditor/AddButton.tsx | 23 + .../VisualInfluxQLEditor/Editor.test.tsx | 157 +++++++ .../VisualInfluxQLEditor/Editor.tsx | 232 ++++++++++ .../VisualInfluxQLEditor/FormatAsSection.tsx | 27 ++ .../VisualInfluxQLEditor/FromSection.tsx | 69 +++ .../VisualInfluxQLEditor/InputSection.tsx | 38 ++ .../OrderByTimeSection.tsx | 35 ++ .../VisualInfluxQLEditor/PartListSection.tsx | 140 ++++++ .../VisualInfluxQLEditor/SectionFill.tsx | 7 + .../VisualInfluxQLEditor/SectionLabel.tsx | 15 + .../components/VisualInfluxQLEditor/Seg.tsx | 215 +++++++++ .../VisualInfluxQLEditor/TagsSection.test.tsx | 206 +++++++++ .../VisualInfluxQLEditor/TagsSection.tsx | 156 +++++++ .../VisualInfluxQLEditor/partListUtils.tsx | 103 +++++ .../components/VisualInfluxQLEditor/styles.ts | 5 + .../VisualInfluxQLEditor/tagUtils.test.ts | 92 ++++ .../VisualInfluxQLEditor/tagUtils.ts | 28 ++ .../VisualInfluxQLEditor/toSelectableValue.ts | 5 + .../components/VisualInfluxQLEditor/unwrap.ts | 6 + .../influxdb/components/constants.ts | 10 + .../influxdb/components/queryUtils.test.ts | 422 +++++++++++++++++ .../influxdb/components/queryUtils.ts | 91 ++++ .../influxdb/influxQLMetadataQuery.ts | 63 +++ .../app/plugins/datasource/influxdb/module.ts | 10 +- .../influxdb/partials/query.editor.html | 208 --------- .../plugins/datasource/influxdb/query_ctrl.ts | 430 ------------------ .../influxdb/registerRawInfluxQLEditor.ts | 9 - .../influxdb/specs/query_ctrl.test.ts | 178 -------- .../app/plugins/datasource/influxdb/types.ts | 12 +- 33 files changed, 2257 insertions(+), 856 deletions(-) create mode 100644 public/app/plugins/datasource/influxdb/components/QueryEditor.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/QueryEditorModeSwitcher.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/AddButton.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.test.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/FormatAsSection.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/FromSection.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/InputSection.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/OrderByTimeSection.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/PartListSection.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/SectionFill.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/SectionLabel.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Seg.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/TagsSection.test.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/TagsSection.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/partListUtils.tsx create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/styles.ts create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/tagUtils.test.ts create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/tagUtils.ts create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/toSelectableValue.ts create mode 100644 public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/unwrap.ts create mode 100644 public/app/plugins/datasource/influxdb/components/constants.ts create mode 100644 public/app/plugins/datasource/influxdb/components/queryUtils.test.ts create mode 100644 public/app/plugins/datasource/influxdb/components/queryUtils.ts create mode 100644 public/app/plugins/datasource/influxdb/influxQLMetadataQuery.ts delete mode 100644 public/app/plugins/datasource/influxdb/partials/query.editor.html delete mode 100644 public/app/plugins/datasource/influxdb/query_ctrl.ts delete mode 100644 public/app/plugins/datasource/influxdb/registerRawInfluxQLEditor.ts delete mode 100644 public/app/plugins/datasource/influxdb/specs/query_ctrl.test.ts diff --git a/public/app/plugins/datasource/influxdb/components/FluxQueryEditor.tsx b/public/app/plugins/datasource/influxdb/components/FluxQueryEditor.tsx index de2e33bc672..86bbd78d0ab 100644 --- a/public/app/plugins/datasource/influxdb/components/FluxQueryEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/FluxQueryEditor.tsx @@ -1,5 +1,4 @@ import React, { PureComponent } from 'react'; -import coreModule from 'app/core/core_module'; import { InfluxQuery } from '../types'; import { SelectableValue } from '@grafana/data'; import { cx, css } from '@emotion/css'; @@ -213,10 +212,3 @@ export class FluxQueryEditor extends PureComponent { ); } } - -coreModule.directive('fluxQueryEditor', [ - 'reactDirective', - (reactDirective: any) => { - return reactDirective(FluxQueryEditor, ['query', 'onChange', 'onRunQuery']); - }, -]); diff --git a/public/app/plugins/datasource/influxdb/components/QueryEditor.tsx b/public/app/plugins/datasource/influxdb/components/QueryEditor.tsx new file mode 100644 index 00000000000..db3088c65fa --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/QueryEditor.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { QueryEditorProps } from '@grafana/data'; +import { InfluxOptions, InfluxQuery } from '../types'; +import InfluxDatasource from '../datasource'; +import { FluxQueryEditor } from './FluxQueryEditor'; +import { RawInfluxQLEditor } from './RawInfluxQLEditor'; +import { Editor as VisualInfluxQLEditor } from './VisualInfluxQLEditor/Editor'; +import { QueryEditorModeSwitcher } from './QueryEditorModeSwitcher'; +import { buildRawQuery } from './queryUtils'; + +type Props = QueryEditorProps; + +export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range, data }: Props): JSX.Element => { + if (datasource.isFlux) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {query.rawQuery ? ( + + ) : ( + + )} +
+ { + onChange({ ...query, query: buildRawQuery(query), rawQuery: value }); + onRunQuery(); + }} + /> +
+ ); +}; diff --git a/public/app/plugins/datasource/influxdb/components/QueryEditorModeSwitcher.tsx b/public/app/plugins/datasource/influxdb/components/QueryEditorModeSwitcher.tsx new file mode 100644 index 00000000000..b46d3a8e6d0 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/QueryEditorModeSwitcher.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import { Button, ConfirmModal } from '@grafana/ui'; + +type Props = { + isRaw: boolean; + onChange: (newIsRaw: boolean) => void; +}; + +export const QueryEditorModeSwitcher = ({ isRaw, onChange }: Props): JSX.Element => { + const [isModalOpen, setModalOpen] = useState(false); + + useEffect(() => { + // if the isRaw changes, we hide the modal + setModalOpen(false); + }, [isRaw]); + + if (isRaw) { + return ( + <> + + { + onChange(false); + }} + onDismiss={() => { + setModalOpen(false); + }} + /> + + ); + } else { + return ( + + ); + } +}; diff --git a/public/app/plugins/datasource/influxdb/components/RawInfluxQLEditor.tsx b/public/app/plugins/datasource/influxdb/components/RawInfluxQLEditor.tsx index 17f86391954..1752af528ee 100644 --- a/public/app/plugins/datasource/influxdb/components/RawInfluxQLEditor.tsx +++ b/public/app/plugins/datasource/influxdb/components/RawInfluxQLEditor.tsx @@ -1,17 +1,9 @@ -import React, { FC } from 'react'; +import React from 'react'; import { TextArea, InlineFormLabel, Input, Select, HorizontalGroup } from '@grafana/ui'; -import { SelectableValue } from '@grafana/data'; -import { ResultFormat, InfluxQuery } from '../types'; +import { InfluxQuery } from '../types'; import { useShadowedState } from './useShadowedState'; import { useUniqueId } from './useUniqueId'; - -const RESULT_FORMATS: Array> = [ - { label: 'Time series', value: 'time_series' }, - { label: 'Table', value: 'table' }, - { label: 'Logs', value: 'logs' }, -]; - -const DEFAULT_RESULT_FORMAT: ResultFormat = 'time_series'; +import { RESULT_FORMATS, DEFAULT_RESULT_FORMAT } from './constants'; type Props = { query: InfluxQuery; @@ -22,7 +14,7 @@ type Props = { // we handle 3 fields: "query", "alias", "resultFormat" // "resultFormat" changes are applied immediately // "query" and "alias" changes only happen on onblur -export const RawInfluxQLEditor: FC = ({ query, onChange, onRunQuery }) => { +export const RawInfluxQLEditor = ({ query, onChange, onRunQuery }: Props): JSX.Element => { const [currentQuery, setCurrentQuery] = useShadowedState(query.query); const [currentAlias, setCurrentAlias] = useShadowedState(query.alias); const aliasElementId = useUniqueId(); diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/AddButton.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/AddButton.tsx new file mode 100644 index 00000000000..2e7248eca68 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/AddButton.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { SelectableValue } from '@grafana/data'; +import { Seg } from './Seg'; +import { unwrap } from './unwrap'; + +type Props = { + loadOptions: () => Promise; + allowCustomValue?: boolean; + onAdd: (v: string) => void; +}; + +export const AddButton = ({ loadOptions, allowCustomValue, onAdd }: Props): JSX.Element => { + return ( + { + onAdd(unwrap(v.value)); + }} + /> + ); +}; diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.test.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.test.tsx new file mode 100644 index 00000000000..74bdc8b1847 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.test.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { InfluxQuery } from '../../types'; +import InfluxDatasource from '../../datasource'; +import { render } from '@testing-library/react'; +import { Editor } from './Editor'; + +// we mock the @grafana/ui components we use to make sure they just show their "value". +// we mostly need this for `Input`, because that one is not visible with `.textContent`, +// but i have decided to do all we use to be consistent here. +jest.mock('@grafana/ui', () => { + const Input = ({ value, placeholder }: { value: string; placeholder?: string }) => ( + [{value || placeholder}] + ); + const WithContextMenu = ({ children }: { children: (x: unknown) => JSX.Element }) => ( + [{children({ openMenu: undefined })}] + ); + const Select = ({ value }: { value: string }) => [{value}]; + + const orig = jest.requireActual('@grafana/ui'); + + return { + ...orig, + Input, + WithContextMenu, + Select, + }; +}); + +jest.mock('./Seg', () => { + const Seg = ({ value }: { value: string }) => [{value}]; + return { + Seg, + }; +}); + +function assertEditor(query: InfluxQuery, textContent: string) { + const onChange = jest.fn(); + const onRunQuery = jest.fn(); + const datasource: InfluxDatasource = {} as InfluxDatasource; + const { container } = render( + + ); + expect(container.textContent).toBe(textContent); +} + +describe('InfluxDB InfluxQL Visual Editor', () => { + it('should handle minimal query', () => { + const query: InfluxQuery = { + refId: 'A', + }; + assertEditor( + query, + 'from[default][select measurement]where[+]' + + 'select[field]([value])[mean]()[+]' + + 'group by[time]([$__interval])[fill]([null])[+]' + + 'timezone[(optional)]order by time[ASC]' + + 'limit[(optional)]slimit[(optional)]' + + 'format as[time_series]alias[Naming pattern]' + ); + }); + it('should have the alias-field hidden when format-as-table', () => { + const query: InfluxQuery = { + refId: 'A', + alias: 'test-alias', + resultFormat: 'table', + }; + assertEditor( + query, + 'from[default][select measurement]where[+]' + + 'select[field]([value])[mean]()[+]' + + 'group by[time]([$__interval])[fill]([null])[+]' + + 'timezone[(optional)]order by time[ASC]' + + 'limit[(optional)]slimit[(optional)]' + + 'format as[table]' + ); + }); + it('should handle complex query', () => { + const query: InfluxQuery = { + refId: 'A', + policy: 'default', + resultFormat: 'logs', + orderByTime: 'DESC', + tags: [ + { + key: 'cpu', + operator: '=', + value: 'cpu1', + }, + { + condition: 'AND', + key: 'cpu', + operator: '<', + value: 'cpu3', + }, + ], + groupBy: [ + { + type: 'time', + params: ['$__interval'], + }, + { + type: 'tag', + params: ['cpu'], + }, + { + type: 'tag', + params: ['host'], + }, + { + type: 'fill', + params: ['null'], + }, + ], + select: [ + [ + { + type: 'field', + params: ['usage_idle'], + }, + { + type: 'mean', + params: [], + }, + ], + [ + { + type: 'field', + params: ['usage_guest'], + }, + { + type: 'median', + params: [], + }, + { + type: 'holt_winters_with_fit', + params: [10, 2], + }, + ], + ], + measurement: 'cpu', + limit: '4', + slimit: '5', + tz: 'UTC', + alias: 'all i as', + }; + assertEditor( + query, + 'from[default][cpu]where[cpu][=][cpu1][AND][cpu][<][cpu3][+]' + + 'select[field]([usage_idle])[mean]()[+]' + + '[field]([usage_guest])[median]()[holt_winters_with_fit]([10],[2])[+]' + + 'group by[time]([$__interval])[tag]([cpu])[tag]([host])[fill]([null])[+]' + + 'timezone[UTC]order by time[DESC]' + + 'limit[4]slimit[5]' + + 'format as[logs]alias[all i as]' + ); + }); +}); diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.tsx new file mode 100644 index 00000000000..b9d3aaaaad0 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Editor.tsx @@ -0,0 +1,232 @@ +import React, { useMemo } from 'react'; +import { InfluxQuery, InfluxQueryTag } from '../../types'; +import { getTemplateSrv } from '@grafana/runtime'; +import InfluxDatasource from '../../datasource'; +import { FromSection } from './FromSection'; +import { TagsSection } from './TagsSection'; +import { PartListSection } from './PartListSection'; +import { OrderByTimeSection } from './OrderByTimeSection'; +import { InputSection } from './InputSection'; +import { + getAllMeasurementsForTags, + getAllPolicies, + getFieldKeysForMeasurement, + getTagKeysForMeasurementAndTags, + getTagValues, +} from '../../influxQLMetadataQuery'; +import { + normalizeQuery, + addNewSelectPart, + removeSelectPart, + addNewGroupByPart, + removeGroupByPart, + changeSelectPart, + changeGroupByPart, +} from '../queryUtils'; +import { FormatAsSection } from './FormatAsSection'; +import { SectionLabel } from './SectionLabel'; +import { SectionFill } from './SectionFill'; +import { DEFAULT_RESULT_FORMAT } from '../constants'; +import { getNewSelectPartOptions, getNewGroupByPartOptions, makePartList } from './partListUtils'; + +type Props = { + query: InfluxQuery; + onChange: (query: InfluxQuery) => void; + onRunQuery: () => void; + datasource: InfluxDatasource; +}; + +function getTemplateVariableOptions() { + return ( + getTemplateSrv() + .getVariables() + // we make them regex-params, i'm not 100% sure why. + // probably because this way multi-value variables work ok too. + .map((v) => `/^$${v.name}$/`) + ); +} + +// helper function to make it easy to call this from the widget-render-code +function withTemplateVariableOptions(optionsPromise: Promise): Promise { + return optionsPromise.then((options) => [...getTemplateVariableOptions(), ...options]); +} + +const SectionWrap = ({ initialName, children }: { initialName: string; children: React.ReactNode }) => ( +
+ + {children} + +
+); + +export const Editor = (props: Props): JSX.Element => { + const query = normalizeQuery(props.query); + const { datasource } = props; + const { measurement, policy } = query; + + const selectLists = useMemo(() => { + const dynamicSelectPartOptions = new Map([ + [ + 'field_0', + () => { + return measurement !== undefined + ? getFieldKeysForMeasurement(measurement, policy, datasource) + : Promise.resolve([]); + }, + ], + ]); + return (query.select ?? []).map((sel) => makePartList(sel, dynamicSelectPartOptions)); + }, [measurement, policy, query.select, datasource]); + + // the following function is not complicated enough to memoize, but it's result + // is used in both memoized and un-memoized parts, so we have no choice + const getTagKeys = useMemo(() => { + return () => getTagKeysForMeasurementAndTags(measurement, policy, query.tags ?? [], datasource); + }, [measurement, policy, query.tags, datasource]); + + const groupByList = useMemo(() => { + const dynamicGroupByPartOptions = new Map([['tag_0', getTagKeys]]); + + return makePartList(query.groupBy ?? [], dynamicGroupByPartOptions); + }, [getTagKeys, query.groupBy]); + + const onAppliedChange = (newQuery: InfluxQuery) => { + props.onChange(newQuery); + props.onRunQuery(); + }; + const handleFromSectionChange = (p: string | undefined, m: string | undefined) => { + onAppliedChange({ + ...query, + policy: p, + measurement: m, + }); + }; + + const handleTagsSectionChange = (tags: InfluxQueryTag[]) => { + // we set empty-arrays to undefined + onAppliedChange({ + ...query, + tags: tags.length === 0 ? undefined : tags, + }); + }; + + return ( +
+ + getAllPolicies(datasource)} + getMeasurementOptions={(filter) => + withTemplateVariableOptions( + getAllMeasurementsForTags(filter === '' ? undefined : filter, query.tags ?? [], datasource) + ) + } + onChange={handleFromSectionChange} + /> + + + withTemplateVariableOptions(getTagValues(key, measurement, policy, datasource)) + } + /> + + {selectLists.map((sel, index) => ( + + Promise.resolve(getNewSelectPartOptions())} + onChange={(partIndex, newParams) => { + const newQuery = changeSelectPart(query, index, partIndex, newParams); + onAppliedChange(newQuery); + }} + onAddNewPart={(type) => { + onAppliedChange(addNewSelectPart(query, type, index)); + }} + onRemovePart={(partIndex) => { + onAppliedChange(removeSelectPart(query, partIndex, index)); + }} + /> + + ))} + + getNewGroupByPartOptions(query, getTagKeys)} + onChange={(partIndex, newParams) => { + const newQuery = changeGroupByPart(query, partIndex, newParams); + onAppliedChange(newQuery); + }} + onAddNewPart={(type) => { + onAppliedChange(addNewGroupByPart(query, type)); + }} + onRemovePart={(partIndex) => { + onAppliedChange(removeGroupByPart(query, partIndex)); + }} + /> + + + { + onAppliedChange({ ...query, tz }); + }} + /> + + { + onAppliedChange({ ...query, orderByTime: v }); + }} + /> + + {/* query.fill is ignored in the query-editor, and it is deleted whenever + query-editor changes. the influx_query_model still handles it, but the new + approach seem to be to handle "fill" inside query.groupBy. so, if you + have a panel where in the json you have query.fill, it will be appled, + as long as you do not edit that query. */} + + { + onAppliedChange({ ...query, limit }); + }} + /> + + { + onAppliedChange({ ...query, slimit }); + }} + /> + + + { + onAppliedChange({ ...query, resultFormat: format }); + }} + /> + {query.resultFormat !== 'table' && ( + <> + + { + onAppliedChange({ ...query, alias }); + }} + /> + + )} + +
+ ); +}; diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/FormatAsSection.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/FormatAsSection.tsx new file mode 100644 index 00000000000..f87643d49ee --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/FormatAsSection.tsx @@ -0,0 +1,27 @@ +import { Select } from '@grafana/ui'; +import { cx } from '@emotion/css'; +import { ResultFormat } from '../../types'; +import React from 'react'; +import { unwrap } from './unwrap'; +import { RESULT_FORMATS } from '../constants'; +import { paddingRightClass } from './styles'; + +type Props = { + format: ResultFormat; + onChange: (newFormat: ResultFormat) => void; +}; + +const className = cx('width-8', paddingRightClass); + +export const FormatAsSection = ({ format, onChange }: Props): JSX.Element => { + return ( + + className={className} + onChange={(v) => { + onChange(unwrap(v.value)); + }} + value={format} + options={RESULT_FORMATS} + /> + ); +}; diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/FromSection.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/FromSection.tsx new file mode 100644 index 00000000000..51237c97994 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/FromSection.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { Seg } from './Seg'; +import { toSelectableValue } from './toSelectableValue'; + +const DEFAULT_POLICY = 'default'; + +// we use the value "default" as a magic-value, it means +// we use the default retention-policy. +// unfortunately, IF the user has a retention-policy named "default", +// and it is not the default-retention-policy in influxdb, +// bad things will happen. +// https://github.com/grafana/grafana/issues/4347 :-( +// FIXME: we could maybe at least detect here that problem-is-happening, +// and show an error message or something. +// unfortunately, currently the ResponseParser does not return the +// is-default info for the retention-policies, so that should change first. + +type Props = { + onChange: (policy: string | undefined, measurement: string | undefined) => void; + policy: string | undefined; + measurement: string | undefined; + getPolicyOptions: () => Promise; + getMeasurementOptions: (filter: string) => Promise; +}; + +export const FromSection = ({ + policy, + measurement, + onChange, + getPolicyOptions, + getMeasurementOptions, +}: Props): JSX.Element => { + const handlePolicyLoadOptions = async () => { + const allPolicies = await getPolicyOptions(); + // if `default` does not exist in the list of policies, we add it + const allPoliciesWithDefault = allPolicies.some((p) => p === 'default') + ? allPolicies + : [DEFAULT_POLICY, allPolicies]; + + return allPoliciesWithDefault.map(toSelectableValue); + }; + + const handleMeasurementLoadOptions = async (filter: string) => { + const allMeasurements = await getMeasurementOptions(filter); + return allMeasurements.map(toSelectableValue); + }; + + return ( + <> + { + onChange(v.value, measurement); + }} + /> + { + onChange(policy, v.value); + }} + /> + + ); +}; diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/InputSection.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/InputSection.tsx new file mode 100644 index 00000000000..70edef5b984 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/InputSection.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { cx } from '@emotion/css'; +import { Input } from '@grafana/ui'; +import { useShadowedState } from '../useShadowedState'; +import { paddingRightClass } from './styles'; + +type Props = { + value: string | undefined; + onChange: (value: string | undefined) => void; + isWide?: boolean; + placeholder?: string; +}; + +export const InputSection = ({ value, onChange, isWide, placeholder }: Props): JSX.Element => { + const [currentValue, setCurrentValue] = useShadowedState(value); + + const onBlur = () => { + // we send empty-string as undefined + const newValue = currentValue === '' ? undefined : currentValue; + onChange(newValue); + }; + + return ( + <> + { + setCurrentValue(e.currentTarget.value); + }} + value={currentValue ?? ''} + /> + + ); +}; diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/OrderByTimeSection.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/OrderByTimeSection.tsx new file mode 100644 index 00000000000..88691600a9c --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/OrderByTimeSection.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { cx } from '@emotion/css'; +import { SelectableValue } from '@grafana/data'; +import { unwrap } from './unwrap'; +import { Select } from '@grafana/ui'; +import { paddingRightClass } from './styles'; + +type Mode = 'ASC' | 'DESC'; + +const OPTIONS: Array> = [ + { label: 'ascending', value: 'ASC' }, + { label: 'descending', value: 'DESC' }, +]; + +const className = cx('width-9', paddingRightClass); + +type Props = { + value: Mode; + onChange: (value: Mode) => void; +}; + +export const OrderByTimeSection = ({ value, onChange }: Props): JSX.Element => { + return ( + <> + + className={className} + onChange={(v) => { + onChange(unwrap(v.value)); + }} + value={value} + options={OPTIONS} + /> + + ); +}; diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/PartListSection.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/PartListSection.tsx new file mode 100644 index 00000000000..a18384418a9 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/PartListSection.tsx @@ -0,0 +1,140 @@ +import React, { useMemo } from 'react'; +import { cx, css } from '@emotion/css'; +import { MenuItem, WithContextMenu, MenuGroup, useTheme2 } from '@grafana/ui'; +import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; +import { Seg } from './Seg'; +import { unwrap } from './unwrap'; +import { toSelectableValue } from './toSelectableValue'; +import { AddButton } from './AddButton'; + +export type PartParams = Array<{ + value: string; + options: (() => Promise) | null; +}>; + +type Props = { + parts: Array<{ + name: string; + params: PartParams; + }>; + getNewPartOptions: () => Promise; + onChange: (partIndex: number, paramValues: string[]) => void; + onRemovePart: (index: number) => void; + onAddNewPart: (type: string) => void; +}; + +const renderRemovableNameMenuItems = (onClick: () => void) => { + return ( + + + + ); +}; + +const noRightMarginPaddingClass = css({ + paddingRight: '0', + marginRight: '0', +}); + +const RemovableName = ({ name, onRemove }: { name: string; onRemove: () => void }) => { + return ( + renderRemovableNameMenuItems(onRemove)}> + {({ openMenu }) => ( + + )} + + ); +}; + +type PartProps = { + name: string; + params: PartParams; + onRemove: () => void; + onChange: (paramValues: string[]) => void; +}; + +const noHorizMarginPaddingClass = css({ + paddingLeft: '0', + paddingRight: '0', + marginLeft: '0', + marginRight: '0', +}); + +const getPartClass = (theme: GrafanaTheme2) => { + return cx( + 'gf-form-label', + css({ + paddingLeft: '0', + // gf-form-label class makes certain css attributes incorrect + // for the selectbox-dropdown, so we have to "reset" them back + lineHeight: theme.typography.body.lineHeight, + fontSize: theme.typography.body.fontSize, + }) + ); +}; + +const Part = ({ name, params, onChange, onRemove }: PartProps): JSX.Element => { + const theme = useTheme2(); + const partClass = useMemo(() => getPartClass(theme), [theme]); + + const onParamChange = (par: string, i: number) => { + const newParams = params.map((p) => p.value); + newParams[i] = par; + onChange(newParams); + }; + return ( +
+ ( + {params.map((p, i) => { + const { value, options } = p; + const isLast = i === params.length - 1; + const loadOptions = + options !== null ? () => options().then((items) => items.map(toSelectableValue)) : undefined; + return ( + + { + onParamChange(unwrap(v.value), i); + }} + /> + {!isLast && ','} + + ); + })} + ) +
+ ); +}; + +export const PartListSection = ({ + parts, + getNewPartOptions, + onAddNewPart, + onRemovePart, + onChange, +}: Props): JSX.Element => { + return ( + <> + {parts.map((part, index) => ( + { + onRemovePart(index); + }} + onChange={(pars) => { + onChange(index, pars); + }} + /> + ))} + + + ); +}; diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/SectionFill.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/SectionFill.tsx new file mode 100644 index 00000000000..db0de0952ac --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/SectionFill.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export const SectionFill = () => ( +
+ +
+); diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/SectionLabel.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/SectionLabel.tsx new file mode 100644 index 00000000000..93df9933cb1 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/SectionLabel.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { cx, css } from '@emotion/css'; + +type Props = { + name: string; + isInitial?: boolean; +}; + +const uppercaseClass = css({ + textTransform: 'uppercase', +}); + +export const SectionLabel = ({ name, isInitial }: Props) => ( + +); diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Seg.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Seg.tsx new file mode 100644 index 00000000000..a77eec1b5d7 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/Seg.tsx @@ -0,0 +1,215 @@ +import React, { useState, useRef, useEffect } from 'react'; +import debouncePromise from 'debounce-promise'; +import { cx, css } from '@emotion/css'; +import { SelectableValue } from '@grafana/data'; +import { useClickAway, useAsyncFn } from 'react-use'; +import { InlineLabel, Select, AsyncSelect, Input } from '@grafana/ui'; +import { useShadowedState } from '../useShadowedState'; + +// this file is a simpler version of `grafana-ui / SegmentAsync.tsx` +// with some changes: +// 1. click-outside does not select the value. i think it's better to be explicit here. +// 2. we set a min-width on the select-element to handle cases where the `value` +// is very short, like "x", and then you click on it and the select opens, +// and it tries to be as short as "x" and it does not work well. + +// NOTE: maybe these changes could be migrated into the SegmentAsync later + +type SelVal = SelectableValue; + +// when allowCustomValue is true, there is no way to enforce the selectableValue +// enum-type, so i just go with `string` + +type LoadOptions = (filter: string) => Promise; + +type Props = { + value: string; + buttonClassName?: string; + loadOptions?: LoadOptions; + // if filterByLoadOptions is false, + // loadOptions is only executed once, + // when the select-box opens, + // and as you write, the list gets filtered + // by the select-box. + // if filterByLoadOptions is true, + // as you write the loadOptions is executed again and again, + // and it is relied on to filter the results. + filterByLoadOptions?: boolean; + onChange: (v: SelVal) => void; + allowCustomValue?: boolean; +}; + +const selectClass = css({ + minWidth: '160px', +}); + +type SelProps = { + loadOptions: LoadOptions; + filterByLoadOptions?: boolean; + onClose: () => void; + onChange: (v: SelVal) => void; + allowCustomValue?: boolean; +}; + +type SelReloadProps = { + loadOptions: (filter: string) => Promise; + onClose: () => void; + onChange: (v: SelVal) => void; + allowCustomValue?: boolean; +}; + +const SelReload = ({ loadOptions, allowCustomValue, onChange, onClose }: SelReloadProps): JSX.Element => { + const ref = useRef(null); + useClickAway(ref, onClose); + + // here we rely on the fact that writing text into the + // does not cause a re-render of the current react component. + // this way there is only a single render-call, + // so there is only a single `debouncedLoadOptions`. + // if we want ot make this "re-render safe, + // we will have to put the debounced call into an useRef, + // and probably have an useEffect + const debouncedLoadOptions = debouncePromise(loadOptions, 1000, { leading: true }); + return ( +
+ +
+ ); +}; + +type SelSingleLoadProps = { + loadOptions: (filter: string) => Promise; + onClose: () => void; + onChange: (v: SelVal) => void; + allowCustomValue?: boolean; +}; + +const SelSingleLoad = ({ loadOptions, allowCustomValue, onChange, onClose }: SelSingleLoadProps): JSX.Element => { + const ref = useRef(null); + const [loadState, doLoad] = useAsyncFn(loadOptions, [loadOptions]); + useClickAway(ref, onClose); + + useEffect(() => { + doLoad(); + }, [doLoad, loadOptions]); + + return ( +
+ { + setCurrentValue(e.currentTarget.value); + }} + value={currentValue} + /> + ); +}; + +const defaultButtonClass = css({ + width: 'auto', + cursor: 'pointer', +}); + +export const Seg = ({ + value, + buttonClassName, + loadOptions, + filterByLoadOptions, + allowCustomValue, + onChange, +}: Props): JSX.Element => { + const [isOpen, setOpen] = useState(false); + if (!isOpen) { + const className = cx(defaultButtonClass, buttonClassName); + // this should not be a label, this should be a button, + // but this is what is used inside a Segment, and i just + // want the same look + return ( + { + setOpen(true); + }} + > + {value} + + ); + } else { + if (loadOptions !== undefined) { + return ( + { + setOpen(false); + onChange(v); + }} + onClose={() => { + setOpen(false); + }} + /> + ); + } else { + return ( + { + setOpen(false); + onChange({ value: v, label: v }); + }} + /> + ); + } + } +}; diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/TagsSection.test.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/TagsSection.test.tsx new file mode 100644 index 00000000000..15ea1908771 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/TagsSection.test.tsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { fireEvent, render, screen, act, waitFor } from '@testing-library/react'; +import { TagsSection } from './TagsSection'; +import { InfluxQueryTag } from '../../types'; + +function getTagKeys() { + return Promise.resolve(['t1', 't2', 't3', 't4', 't5', 't6']); +} + +function getTagValuesForKey(key: string) { + const data = ['v1', 'v2', 'v3', 'v4', 'v5', 'v6'].map((v) => `${key}_${v}`); + return Promise.resolve(data); +} + +function assertText(tags: InfluxQueryTag[], textResult: string) { + const { container } = render( + null} + /> + ); + expect(container.textContent).toBe(textResult); +} + +async function assertSegmentSelect( + segmentText: string, + optionText: string, + callback: () => void, + callbackValue: unknown +) { + // we find the segment + const segs = screen.getAllByText(segmentText, { selector: 'label' }); + expect(segs.length).toBe(1); + const seg = segs[0]; + expect(seg).toBeInTheDocument(); + + act(() => { + fireEvent.click(seg); + }); + + // find the option and click it + const option = await screen.findByText(optionText, { selector: 'span' }); + expect(option).toBeInTheDocument(); + act(() => { + fireEvent.click(option); + }); + + await waitFor(() => expect(callback).toHaveBeenCalledTimes(1)); + + expect(callback).toHaveBeenCalledWith(callbackValue); +} + +const tags: InfluxQueryTag[] = [ + { + key: 't1', + value: 't1_v1', + operator: '=', + }, + { + condition: 'AND', + key: 't2', + value: 't2_v2', + operator: '!=', + }, + { + condition: 'OR', + key: 't3', + value: 't3_v3', + operator: '<>', + }, +]; + +describe('InfluxDB InfluxQL Editor tags section', () => { + it('should display correct data', () => { + assertText(tags, 't1=t1_v1ANDt2!=t2_v2ORt3<>t3_v3+'); + }); + it('should handle incorrect data', () => { + const incorrectTags: InfluxQueryTag[] = [ + { + condition: 'OR', // extra unused condition + key: 't1', + value: 't1_v1', + operator: '=', + }, + { + // missing `condition` + key: 't2', + value: 't2_v2', + operator: '!=', + }, + { + condition: 'OR', + key: 't3', + value: 't3_v3', + // missing `operator, string-value + }, + { + condition: 'OR', + key: 't4', + value: '/t4_v4/', + // missing `operator, regex-value + }, + { + condition: 'XOR', // invalid `condition` + key: 't5', + value: 't5_v5', + operator: 'haha', // invalid `operator` + }, + ]; + + assertText(incorrectTags, 't1=t1_v1ANDt2!=t2_v2ORt3=t3_v3ORt4=~/t4_v4/XORt5hahat5_v5+'); + }); + + it('should handle adding a new tag check', async () => { + const onChange = jest.fn(); + + render( + + ); + + await assertSegmentSelect('+', 't5', onChange, [ + ...tags, + { + key: 't5', + value: 'select tag value', + operator: '=', + condition: 'AND', + }, + ]); + }); + it('should handle changing the tag-condition', async () => { + const onChange = jest.fn(); + + render( + + ); + + const newTags = [...tags]; + newTags[1] = { ...newTags[1], condition: 'OR' }; + + await assertSegmentSelect('AND', 'OR', onChange, newTags); + }); + it('should handle changing the tag-key', async () => { + const onChange = jest.fn(); + + render( + + ); + + const newTags = [...tags]; + newTags[1] = { ...newTags[1], key: 't5' }; + + await assertSegmentSelect('t2', 't5', onChange, newTags); + }); + it('should handle changing the tag-operator', async () => { + const onChange = jest.fn(); + + render( + + ); + + const newTags = [...tags]; + newTags[2] = { ...newTags[2], operator: '<' }; + + await assertSegmentSelect('<>', '<', onChange, newTags); + }); + it('should handle changing the tag-value', async () => { + const onChange = jest.fn(); + + render( + + ); + + const newTags = [...tags]; + newTags[0] = { ...newTags[0], value: 't1_v5' }; + + await assertSegmentSelect('t1_v1', 't1_v5', onChange, newTags); + }); +}); diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/TagsSection.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/TagsSection.tsx new file mode 100644 index 00000000000..f812375eeca --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/TagsSection.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import { SelectableValue } from '@grafana/data'; +import { Seg } from './Seg'; +import { InfluxQueryTag } from '../../types'; +import { toSelectableValue } from './toSelectableValue'; +import { adjustOperatorIfNeeded, getCondition, getOperator } from './tagUtils'; +import { AddButton } from './AddButton'; + +type KnownOperator = '=' | '!=' | '<>' | '<' | '>' | '=~' | '!~'; +const knownOperators: KnownOperator[] = ['=', '!=', '<>', '<', '>', '=~', '!~']; + +type KnownCondition = 'AND' | 'OR'; +const knownConditions: KnownCondition[] = ['AND', 'OR']; + +const operatorOptions: Array> = knownOperators.map(toSelectableValue); +const condititonOptions: Array> = knownConditions.map(toSelectableValue); + +type Props = { + tags: InfluxQueryTag[]; + onChange: (tags: InfluxQueryTag[]) => void; + getTagKeyOptions: () => Promise; + getTagValueOptions: (key: string) => Promise; +}; + +type TagProps = { + tag: InfluxQueryTag; + isFirst: boolean; + onRemove: () => void; + onChange: (tag: InfluxQueryTag) => void; + getTagKeyOptions: () => Promise; + getTagValueOptions: (key: string) => Promise; +}; + +const loadConditionOptions = () => Promise.resolve(condititonOptions); + +const loadOperatorOptions = () => Promise.resolve(operatorOptions); + +const Tag = ({ tag, isFirst, onRemove, onChange, getTagKeyOptions, getTagValueOptions }: TagProps): JSX.Element => { + const operator = getOperator(tag); + const condition = getCondition(tag, isFirst); + + const getTagKeySegmentOptions = () => { + return getTagKeyOptions().then((tags) => [ + { label: '-- remove filter --', value: undefined }, + ...tags.map(toSelectableValue), + ]); + }; + + const getTagValueSegmentOptions = () => { + return getTagValueOptions(tag.key).then((tags) => tags.map(toSelectableValue)); + }; + + return ( +
+ {condition != null && ( + { + onChange({ ...tag, condition: v.value }); + }} + /> + )} + { + const { value } = v; + if (value === undefined) { + onRemove(); + } else { + onChange({ ...tag, key: value ?? '' }); + } + }} + /> + { + onChange({ ...tag, operator: op.value }); + }} + /> + { + const value = v.value ?? ''; + onChange({ ...tag, value, operator: adjustOperatorIfNeeded(operator, value) }); + }} + /> +
+ ); +}; + +export const TagsSection = ({ tags, onChange, getTagKeyOptions, getTagValueOptions }: Props): JSX.Element => { + const onTagChange = (newTag: InfluxQueryTag, index: number) => { + const newTags = tags.map((tag, i) => { + return index === i ? newTag : tag; + }); + onChange(newTags); + }; + + const onTagRemove = (index: number) => { + const newTags = tags.filter((t, i) => i !== index); + onChange(newTags); + }; + + const getTagKeySegmentOptions = () => { + return getTagKeyOptions().then((tags) => tags.map(toSelectableValue)); + }; + + const addNewTag = (tagKey: string, isFirst: boolean) => { + const minimalTag: InfluxQueryTag = { + key: tagKey, + value: 'select tag value', + }; + + const newTag: InfluxQueryTag = { + key: minimalTag.key, + value: minimalTag.value, + operator: getOperator(minimalTag), + condition: getCondition(minimalTag, isFirst), + }; + + onChange([...tags, newTag]); + }; + + return ( + <> + {tags.map((t, i) => ( + { + onTagChange(newT, i); + }} + onRemove={() => { + onTagRemove(i); + }} + getTagKeyOptions={getTagKeyOptions} + getTagValueOptions={getTagValueOptions} + /> + ))} + { + addNewTag(v, tags.length === 0); + }} + /> + + ); +}; diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/partListUtils.tsx b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/partListUtils.tsx new file mode 100644 index 00000000000..57dce829764 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/partListUtils.tsx @@ -0,0 +1,103 @@ +import { InfluxQuery, InfluxQueryPart } from '../../types'; +import { SelectableValue } from '@grafana/data'; +import { PartParams } from './PartListSection'; +import InfluxQueryModel from '../../influx_query_model'; +import { unwrap } from './unwrap'; +import queryPart from '../../query_part'; +import { toSelectableValue } from './toSelectableValue'; +import { QueryPartDef } from '../../../../../core/components/query_part/query_part'; + +type Categories = Record; + +export function getNewSelectPartOptions(): SelectableValue[] { + const categories: Categories = queryPart.getCategories(); + const options: SelectableValue[] = []; + + const keys = Object.keys(categories); + + keys.forEach((key) => { + const children: SelectableValue[] = categories[key].map((x) => toSelectableValue(x.type)); + + options.push({ + label: key, + options: children, + }); + }); + + return options; +} + +export async function getNewGroupByPartOptions( + query: InfluxQuery, + getTagKeys: () => Promise +): Promise>> { + const tagKeys = await getTagKeys(); + const queryCopy = { ...query }; // the query-model mutates the query + const model = new InfluxQueryModel(queryCopy); + const options: Array> = []; + if (!model.hasFill()) { + options.push(toSelectableValue('fill(null)')); + } + if (!model.hasGroupByTime()) { + options.push(toSelectableValue('time($interval)')); + } + tagKeys.forEach((key) => { + options.push(toSelectableValue(`tag(${key})`)); + }); + return options; +} + +type Part = { + name: string; + params: PartParams; +}; + +function getPartParams(part: InfluxQueryPart, dynamicParamOptions: Map Promise>): PartParams { + // NOTE: the way the system is constructed, + // there always can only be one possible dynamic-lookup + // field. in case of select it is the field, + // in case of group-by it is the tag + const def = queryPart.create(part).def; + + // we switch the numbers to strings, it will work that way too, + // and it makes the code simpler + const paramValues = (part.params ?? []).map((p) => p.toString()); + + if (paramValues.length !== def.params.length) { + throw new Error('Invalid query-segment'); + } + + return paramValues.map((val, index) => { + const defParam = def.params[index]; + if (defParam.dynamicLookup) { + return { + value: val, + options: unwrap(dynamicParamOptions.get(`${def.type}_${index}`)), + }; + } + + if (defParam.options != null) { + return { + value: val, + options: () => Promise.resolve(defParam.options), + }; + } + + return { + value: val, + options: null, + }; + }); +} + +export function makePartList( + queryParts: InfluxQueryPart[], + dynamicParamOptions: Map Promise> +): Part[] { + return queryParts.map((qp) => { + return { + name: qp.type, + params: getPartParams(qp, dynamicParamOptions), + }; + }); +} diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/styles.ts b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/styles.ts new file mode 100644 index 00000000000..c042d110665 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/styles.ts @@ -0,0 +1,5 @@ +import { css } from '@emotion/css'; + +export const paddingRightClass = css({ + paddingRight: '4px', +}); diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/tagUtils.test.ts b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/tagUtils.test.ts new file mode 100644 index 00000000000..7da427e577a --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/tagUtils.test.ts @@ -0,0 +1,92 @@ +import { adjustOperatorIfNeeded, getCondition, getOperator } from './tagUtils'; + +describe('InfluxDB InfluxQL Editor tag utils', () => { + describe('getOperator', () => { + it('should return an existing operator', () => { + expect( + getOperator({ + key: 'key', + value: 'value', + operator: '!=', + }) + ).toBe('!='); + }); + it('should return = when missing operator', () => { + expect( + getOperator({ + key: 'key', + value: 'value', + }) + ).toBe('='); + }); + }); + + describe('getCondition', () => { + it('should return an existing condition when not first', () => { + expect( + getCondition( + { + key: 'key', + value: 'value', + operator: '=', + condition: 'OR', + }, + false + ) + ).toBe('OR'); + }); + it('should return AND when missing condition when not first', () => { + expect( + getCondition( + { + key: 'key', + value: 'value', + operator: '=', + }, + false + ) + ).toBe('AND'); + }); + it('should return undefined for an existing condition when first', () => { + expect( + getCondition( + { + key: 'key', + value: 'value', + operator: '=', + condition: 'OR', + }, + true + ) + ).toBeUndefined(); + }); + it('should return undefined when missing condition when first', () => { + expect( + getCondition( + { + key: 'key', + value: 'value', + operator: '=', + }, + true + ) + ).toBeUndefined(); + }); + }); + describe('adjustOperatorIfNeeded', () => { + it('should keep operator when both operator and value are regex', () => { + expect(adjustOperatorIfNeeded('=~', '/test/')).toBe('=~'); + expect(adjustOperatorIfNeeded('!~', '/test/')).toBe('!~'); + }); + it('should keep operator when both operator and value are not regex', () => { + expect(adjustOperatorIfNeeded('=', 'test')).toBe('='); + expect(adjustOperatorIfNeeded('!=', 'test')).toBe('!='); + }); + it('should change operator to =~ when value is regex and operator is not regex', () => { + expect(adjustOperatorIfNeeded('<>', '/test/')).toBe('=~'); + }); + it('should change operator to = when value is not regex and operator is regex', () => { + expect(adjustOperatorIfNeeded('!~', 'test')).toBe('='); + }); + }); +}); diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/tagUtils.ts b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/tagUtils.ts new file mode 100644 index 00000000000..4ad52fa7396 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/tagUtils.ts @@ -0,0 +1,28 @@ +import { InfluxQueryTag } from '../../types'; + +function isRegex(text: string): boolean { + return /^\/.*\/$/.test(text); +} + +// FIXME: sync these to the query-string-generation-code +// probably it's in influx_query_model.ts +export function getOperator(tag: InfluxQueryTag): string { + return tag.operator ?? (isRegex(tag.value) ? '=~' : '='); +} + +// FIXME: sync these to the query-string-generation-code +// probably it's in influx_query_model.ts +export function getCondition(tag: InfluxQueryTag, isFirst: boolean): string | undefined { + return isFirst ? undefined : tag.condition ?? 'AND'; +} + +export function adjustOperatorIfNeeded(currentOperator: string, newTagValue: string): string { + const isCurrentOperatorRegex = currentOperator === '=~' || currentOperator === '!~'; + const isNewTagValueRegex = isRegex(newTagValue); + + if (isNewTagValueRegex) { + return isCurrentOperatorRegex ? currentOperator : '=~'; + } else { + return isCurrentOperatorRegex ? '=' : currentOperator; + } +} diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/toSelectableValue.ts b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/toSelectableValue.ts new file mode 100644 index 00000000000..327b80432a2 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/toSelectableValue.ts @@ -0,0 +1,5 @@ +import { SelectableValue } from '@grafana/data'; + +export function toSelectableValue(t: T): SelectableValue { + return { label: t, value: t }; +} diff --git a/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/unwrap.ts b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/unwrap.ts new file mode 100644 index 00000000000..b7dcf5f8967 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/VisualInfluxQLEditor/unwrap.ts @@ -0,0 +1,6 @@ +export function unwrap(value: T | null | undefined): T { + if (value == null) { + throw new Error('value must not be nullish'); + } + return value; +} diff --git a/public/app/plugins/datasource/influxdb/components/constants.ts b/public/app/plugins/datasource/influxdb/components/constants.ts new file mode 100644 index 00000000000..9e80560a598 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/constants.ts @@ -0,0 +1,10 @@ +import { SelectableValue } from '@grafana/data'; +import { ResultFormat } from '../types'; + +export const RESULT_FORMATS: Array> = [ + { label: 'Time series', value: 'time_series' }, + { label: 'Table', value: 'table' }, + { label: 'Logs', value: 'logs' }, +]; + +export const DEFAULT_RESULT_FORMAT: ResultFormat = 'time_series'; diff --git a/public/app/plugins/datasource/influxdb/components/queryUtils.test.ts b/public/app/plugins/datasource/influxdb/components/queryUtils.test.ts new file mode 100644 index 00000000000..e1535d53593 --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/queryUtils.test.ts @@ -0,0 +1,422 @@ +import { cloneDeep } from 'lodash'; +import { InfluxQuery } from '../types'; +import { buildRawQuery, normalizeQuery, changeSelectPart, changeGroupByPart } from './queryUtils'; + +describe('InfluxDB query utils', () => { + describe('buildRawQuery', () => { + it('should handle default query', () => { + expect( + buildRawQuery({ + refId: 'A', + hide: false, + policy: 'default', + resultFormat: 'time_series', + orderByTime: 'ASC', + tags: [], + groupBy: [ + { + type: 'time', + params: ['$__interval'], + }, + { + type: 'fill', + params: ['null'], + }, + ], + select: [ + [ + { + type: 'field', + params: ['value'], + }, + { + type: 'mean', + params: [], + }, + ], + ], + }) + ).toBe('SELECT mean("value") FROM "measurement" WHERE $timeFilter GROUP BY time($__interval) fill(null)'); + }); + it('should handle small query', () => { + expect( + buildRawQuery({ + refId: 'A', + select: [ + [ + { + type: 'field', + params: ['value'], + }, + ], + ], + groupBy: [], + }) + ).toBe('SELECT "value" FROM "measurement" WHERE $timeFilter'); + }); + it('should handle string limit/slimit', () => { + expect( + buildRawQuery({ + refId: 'A', + select: [ + [ + { + type: 'field', + params: ['value'], + }, + ], + ], + groupBy: [], + limit: '12', + slimit: '23', + }) + ).toBe('SELECT "value" FROM "measurement" WHERE $timeFilter LIMIT 12 SLIMIT 23'); + }); + it('should handle number limit/slimit', () => { + expect( + buildRawQuery({ + refId: 'A', + select: [ + [ + { + type: 'field', + params: ['value'], + }, + ], + ], + groupBy: [], + limit: 12, + slimit: 23, + }) + ).toBe('SELECT "value" FROM "measurement" WHERE $timeFilter LIMIT 12 SLIMIT 23'); + }); + it('should handle all the tag-operators', () => { + expect( + buildRawQuery({ + refId: 'A', + select: [ + [ + { + type: 'field', + params: ['value'], + }, + ], + ], + tags: [ + { + key: 'cpu', + operator: '=', + value: 'cpu0', + }, + { + condition: 'AND', + key: 'cpu', + operator: '!=', + value: 'cpu0', + }, + { + condition: 'AND', + key: 'cpu', + operator: '<>', + value: 'cpu0', + }, + { + key: 'cpu', + operator: '<', + value: 'cpu0', + }, + { + condition: 'AND', + key: 'cpu', + operator: '>', + value: 'cpu0', + }, + { + key: 'cpu', + operator: '=~', + value: '/cpu0/', + }, + { + condition: 'AND', + key: 'cpu', + operator: '!~', + value: '/cpu0/', + }, + ], + groupBy: [], + }) + ).toBe( + `SELECT "value" FROM "measurement" WHERE ("cpu" = 'cpu0' AND "cpu" != 'cpu0' AND "cpu" <> 'cpu0' AND "cpu" < cpu0 AND "cpu" > cpu0 AND "cpu" =~ /cpu0/ AND "cpu" !~ /cpu0/) AND $timeFilter` + ); + }); + it('should handle a complex query', () => { + expect( + buildRawQuery({ + alias: '', + groupBy: [ + { + params: ['$__interval'], + type: 'time', + }, + { + params: ['cpu'], + type: 'tag', + }, + { + params: ['host'], + type: 'tag', + }, + { + params: ['none'], + type: 'fill', + }, + ], + hide: false, + measurement: 'cpu', + orderByTime: 'DESC', + policy: 'default', + rawQuery: false, + refId: 'A', + resultFormat: 'time_series', + select: [ + [ + { + type: 'field', + params: ['usage_idle'], + }, + { + type: 'mean', + params: [], + }, + { + type: 'holt_winters_with_fit', + params: ['30', '5'], + }, + ], + [ + { + type: 'field', + params: ['usage_guest'], + }, + { + type: 'median', + params: [], + }, + ], + ], + tags: [ + { + key: 'cpu', + operator: '=', + value: 'cpu2', + }, + { + condition: 'OR', + key: 'cpu', + operator: '=', + value: 'cpu3', + }, + { + condition: 'AND', + key: 'cpu', + operator: '=', + value: 'cpu1', + }, + ], + limit: '12', + slimit: '23', + tz: 'UTC', + }) + ).toBe( + `SELECT holt_winters_with_fit(mean("usage_idle"), 30, 5), median("usage_guest") FROM "cpu" WHERE ("cpu" = 'cpu2' OR "cpu" = 'cpu3' AND "cpu" = 'cpu1') AND $timeFilter GROUP BY time($__interval), "cpu", "host" fill(none) ORDER BY time DESC LIMIT 12 SLIMIT 23 tz('UTC')` + ); + }); + }); + + describe('normalizeQuery', () => { + it('should handle minimal query', () => { + const query: InfluxQuery = { + refId: 'A', + }; + + const queryClone = cloneDeep(query); + + expect(normalizeQuery(query)).toStrictEqual({ + refId: 'A', + policy: 'default', + resultFormat: 'time_series', + orderByTime: 'ASC', + tags: [], + groupBy: [ + { type: 'time', params: ['$__interval'] }, + { type: 'fill', params: ['null'] }, + ], + select: [ + [ + { type: 'field', params: ['value'] }, + { type: 'mean', params: [] }, + ], + ], + }); + + // make sure the call did not mutate the input + expect(query).toStrictEqual(queryClone); + }); + + it('should not change values if they already exist', () => { + const query: InfluxQuery = { + refId: 'A', + groupBy: [], + measurement: 'cpu', + orderByTime: 'ASC', + policy: 'default', + resultFormat: 'table', + select: [ + [ + { + type: 'field', + params: ['usage_idle'], + }, + ], + ], + tags: [], + }; + + const queryClone = cloneDeep(query); + + const result = normalizeQuery(query); + + // i will check two things: + // 1. that the function-call does not mutate the input + expect(query).toStrictEqual(queryClone); + + // 2. that the returned object is the same object as the object i gave it. + // (not just the same structure, literally the same object) + expect(result === query).toBeTruthy(); + }); + }); + + describe('changeSelectPart', () => { + it('should handle a normal situation', () => { + const query: InfluxQuery = { + refId: 'A', + select: [ + [ + { + type: 'field', + params: ['usage_idle'], + }, + { + type: 'math', + params: [' / 5'], + }, + { + type: 'alias', + params: ['test42'], + }, + ], + [ + { + type: 'field', + params: ['usage_guest'], + }, + { + type: 'math', + params: ['*4'], + }, + { + type: 'alias', + params: ['test43'], + }, + ], + ], + }; + + const queryClone = cloneDeep(query); + const result = changeSelectPart(query, 1, 2, ['test55']); + + // make sure the input did not get mutated + expect(query).toStrictEqual(queryClone); + + expect(result).toStrictEqual({ + refId: 'A', + select: [ + [ + { + type: 'field', + params: ['usage_idle'], + }, + { + type: 'math', + params: [' / 5'], + }, + { + type: 'alias', + params: ['test42'], + }, + ], + [ + { + type: 'field', + params: ['usage_guest'], + }, + { + type: 'math', + params: ['*4'], + }, + { + type: 'alias', + params: ['test55'], + }, + ], + ], + }); + }); + }); + + describe('changeGroupByPart', () => { + it('should handle a normal situation', () => { + const query: InfluxQuery = { + refId: 'A', + groupBy: [ + { + type: 'time', + params: ['$__interval'], + }, + { + type: 'tag', + params: ['host'], + }, + { + type: 'fill', + params: ['none'], + }, + ], + }; + + const queryClone = cloneDeep(query); + const result = changeGroupByPart(query, 1, ['cpu']); + + // make sure the input did not get mutated + expect(query).toStrictEqual(queryClone); + + expect(result).toStrictEqual({ + refId: 'A', + groupBy: [ + { + type: 'time', + params: ['$__interval'], + }, + { + type: 'tag', + params: ['cpu'], + }, + { + type: 'fill', + params: ['none'], + }, + ], + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/influxdb/components/queryUtils.ts b/public/app/plugins/datasource/influxdb/components/queryUtils.ts new file mode 100644 index 00000000000..dcdfeded4ee --- /dev/null +++ b/public/app/plugins/datasource/influxdb/components/queryUtils.ts @@ -0,0 +1,91 @@ +import { cloneDeep } from 'lodash'; +import InfluxQueryModel from '../influx_query_model'; +import { InfluxQuery } from '../types'; + +// FIXME: these functions are a beginning of a refactoring of influx_query_model.ts +// into a simpler approach with full typescript types. +// later we should be able to migrate the unit-tests +// that relate to these functions here, and then perhaps even move the implementation +// to this place + +export function buildRawQuery(query: InfluxQuery): string { + const queryCopy = cloneDeep(query); // the query-model mutates the query + const model = new InfluxQueryModel(queryCopy); + return model.render(false); +} + +export function normalizeQuery(query: InfluxQuery): InfluxQuery { + // we return the original query if there is no need to update it + if ( + query.policy !== undefined && + query.resultFormat !== undefined && + query.orderByTime !== undefined && + query.tags !== undefined && + query.groupBy !== undefined && + query.select !== undefined + ) { + return query; + } + + // FIXME: we should move the whole normalizeQuery logic here, + // and then have influxQueryModel call this function, + // to concentrate the whole logic here + + const queryCopy = cloneDeep(query); // the query-model mutates the query + return new InfluxQueryModel(queryCopy).target; +} + +export function addNewSelectPart(query: InfluxQuery, type: string, index: number): InfluxQuery { + const queryCopy = cloneDeep(query); // the query-model mutates the query + const model = new InfluxQueryModel(queryCopy); + model.addSelectPart(model.selectModels[index], type); + return model.target; +} + +export function removeSelectPart(query: InfluxQuery, partIndex: number, index: number): InfluxQuery { + const queryCopy = cloneDeep(query); // the query-model mutates the query + const model = new InfluxQueryModel(queryCopy); + const selectModel = model.selectModels[index]; + model.removeSelectPart(selectModel, selectModel[partIndex]); + return model.target; +} + +export function changeSelectPart( + query: InfluxQuery, + listIndex: number, + partIndex: number, + newParams: string[] +): InfluxQuery { + // we need to make shallow copy of `query.select` down to `query.select[listIndex][partIndex]` + const newSel = [...(query.select ?? [])]; + newSel[listIndex] = [...newSel[listIndex]]; + newSel[listIndex][partIndex] = { + ...newSel[listIndex][partIndex], + params: newParams, + }; + return { ...query, select: newSel }; +} + +export function addNewGroupByPart(query: InfluxQuery, type: string): InfluxQuery { + const queryCopy = cloneDeep(query); // the query-model mutates the query + const model = new InfluxQueryModel(queryCopy); + model.addGroupBy(type); + return model.target; +} + +export function removeGroupByPart(query: InfluxQuery, partIndex: number): InfluxQuery { + const queryCopy = cloneDeep(query); // the query-model mutates the query + const model = new InfluxQueryModel(queryCopy); + model.removeGroupByPart(model.groupByParts[partIndex], partIndex); + return model.target; +} + +export function changeGroupByPart(query: InfluxQuery, partIndex: number, newParams: string[]): InfluxQuery { + // we need to make shallow copy of `query.groupBy` down to `query.groupBy[partIndex]` + const newGroupBy = [...(query.groupBy ?? [])]; + newGroupBy[partIndex] = { + ...newGroupBy[partIndex], + params: newParams, + }; + return { ...query, groupBy: newGroupBy }; +} diff --git a/public/app/plugins/datasource/influxdb/influxQLMetadataQuery.ts b/public/app/plugins/datasource/influxdb/influxQLMetadataQuery.ts new file mode 100644 index 00000000000..859277adeda --- /dev/null +++ b/public/app/plugins/datasource/influxdb/influxQLMetadataQuery.ts @@ -0,0 +1,63 @@ +import { InfluxQueryTag } from './types'; +import InfluxDatasource from './datasource'; +import { InfluxQueryBuilder } from './query_builder'; + +const runExploreQuery = ( + type: string, + withKey: string | undefined, + withMeasurementFilter: string | undefined, + target: { measurement: string | undefined; tags: InfluxQueryTag[]; policy: string | undefined }, + datasource: InfluxDatasource +): Promise> => { + const builder = new InfluxQueryBuilder(target, datasource.database); + const q = builder.buildExploreQuery(type, withKey, withMeasurementFilter); + return datasource.metricFindQuery(q); +}; + +export async function getAllPolicies(datasource: InfluxDatasource): Promise { + const target = { tags: [], measurement: undefined, policy: undefined }; + const data = await runExploreQuery('RETENTION POLICIES', undefined, undefined, target, datasource); + return data.map((item) => item.text); +} + +export async function getAllMeasurementsForTags( + measurementFilter: string | undefined, + tags: InfluxQueryTag[], + datasource: InfluxDatasource +): Promise { + const target = { tags, measurement: undefined, policy: undefined }; + const data = await runExploreQuery('MEASUREMENTS', undefined, measurementFilter, target, datasource); + return data.map((item) => item.text); +} + +export async function getTagKeysForMeasurementAndTags( + measurement: string | undefined, + policy: string | undefined, + tags: InfluxQueryTag[], + datasource: InfluxDatasource +): Promise { + const target = { tags, measurement, policy }; + const data = await runExploreQuery('TAG_KEYS', undefined, undefined, target, datasource); + return data.map((item) => item.text); +} + +export async function getTagValues( + tagKey: string, + measurement: string | undefined, + policy: string | undefined, + datasource: InfluxDatasource +): Promise { + const target = { tags: [], measurement, policy }; + const data = await runExploreQuery('TAG_VALUES', tagKey, undefined, target, datasource); + return data.map((item) => item.text); +} + +export async function getFieldKeysForMeasurement( + measurement: string, + policy: string | undefined, + datasource: InfluxDatasource +): Promise { + const target = { tags: [], measurement, policy }; + const data = await runExploreQuery('FIELDS', undefined, undefined, target, datasource); + return data.map((item) => item.text); +} diff --git a/public/app/plugins/datasource/influxdb/module.ts b/public/app/plugins/datasource/influxdb/module.ts index 71f95eb5694..6c6dd21b6f4 100644 --- a/public/app/plugins/datasource/influxdb/module.ts +++ b/public/app/plugins/datasource/influxdb/module.ts @@ -1,23 +1,17 @@ import InfluxDatasource from './datasource'; -import { InfluxQueryCtrl } from './query_ctrl'; +import { QueryEditor } from './components/QueryEditor'; import InfluxStartPage from './components/InfluxStartPage'; import { DataSourcePlugin } from '@grafana/data'; import ConfigEditor from './components/ConfigEditor'; import VariableQueryEditor from './components/VariableQueryEditor'; -// This adds a directive that is used in the query editor -import './components/FluxQueryEditor'; - -// This adds a directive that is used in the query editor -import './registerRawInfluxQLEditor'; - class InfluxAnnotationsQueryCtrl { static templateUrl = 'partials/annotations.editor.html'; } export const plugin = new DataSourcePlugin(InfluxDatasource) .setConfigEditor(ConfigEditor) - .setQueryCtrl(InfluxQueryCtrl) + .setQueryEditor(QueryEditor) .setAnnotationQueryCtrl(InfluxAnnotationsQueryCtrl) .setVariableQueryEditor(VariableQueryEditor) .setQueryEditorHelp(InfluxStartPage); diff --git a/public/app/plugins/datasource/influxdb/partials/query.editor.html b/public/app/plugins/datasource/influxdb/partials/query.editor.html deleted file mode 100644 index 88a0df953e3..00000000000 --- a/public/app/plugins/datasource/influxdb/partials/query.editor.html +++ /dev/null @@ -1,208 +0,0 @@ - - - - - - -
- -
- -
-
-
- - - - -
- -
- -
- -
- -
- -
-
-
-
- -
-
- -
- -
- - -
- -
- -
- -
-
-
-
- -
-
- - - - -
- -
- -
- -
-
-
-
- -
-
- - -
-
-
-
-
- -
-
- - -
-
-
-
-
- -
-
- - -
-
-
-
-
- -
-
- - -
-
-
-
-
- -
-
- -
- -
-
-
-
-
-
- -
-
- - -
-
-
-
-
-
-
diff --git a/public/app/plugins/datasource/influxdb/query_ctrl.ts b/public/app/plugins/datasource/influxdb/query_ctrl.ts deleted file mode 100644 index 4c96dd574da..00000000000 --- a/public/app/plugins/datasource/influxdb/query_ctrl.ts +++ /dev/null @@ -1,430 +0,0 @@ -import angular, { auto } from 'angular'; -import { reduce, map, each } from 'lodash'; -import { InfluxQueryBuilder } from './query_builder'; -import InfluxQueryModel from './influx_query_model'; -import queryPart from './query_part'; -import { QueryCtrl } from 'app/plugins/sdk'; -import { TemplateSrv } from '@grafana/runtime'; -import { InfluxQuery } from './types'; -import InfluxDatasource from './datasource'; - -export class InfluxQueryCtrl extends QueryCtrl { - static templateUrl = 'partials/query.editor.html'; - - declare datasource: InfluxDatasource; - queryModel: InfluxQueryModel; - queryBuilder: any; - groupBySegment: any; - resultFormats: any[]; - orderByTime: any[] = []; - policySegment: any; - tagSegments: any[]; - selectMenu: any; - measurementSegment: any; - removeTagFilterSegment: any; - - /** @ngInject */ - constructor( - $scope: any, - $injector: auto.IInjectorService, - private templateSrv: TemplateSrv, - private uiSegmentSrv: any - ) { - super($scope, $injector); - this.target = this.target; - this.queryModel = new InfluxQueryModel(this.target, templateSrv, this.panel.scopedVars); - this.queryBuilder = new InfluxQueryBuilder(this.target, this.datasource.database); - this.groupBySegment = this.uiSegmentSrv.newPlusButton(); - this.resultFormats = [ - { text: 'Time series', value: 'time_series' }, - { text: 'Table', value: 'table' }, - { text: 'Logs', value: 'logs' }, - ]; - - this.policySegment = uiSegmentSrv.newSegment(this.target.policy); - - if (!this.target.measurement) { - this.measurementSegment = uiSegmentSrv.newSelectMeasurement(); - } else { - this.measurementSegment = uiSegmentSrv.newSegment(this.target.measurement); - } - - this.tagSegments = []; - for (const tag of this.target.tags) { - if (!tag.operator) { - if (/^\/.*\/$/.test(tag.value)) { - tag.operator = '=~'; - } else { - tag.operator = '='; - } - } - - if (tag.condition) { - this.tagSegments.push(uiSegmentSrv.newCondition(tag.condition)); - } - - this.tagSegments.push(uiSegmentSrv.newKey(tag.key)); - this.tagSegments.push(uiSegmentSrv.newOperator(tag.operator)); - this.tagSegments.push(uiSegmentSrv.newKeyValue(tag.value)); - } - - this.fixTagSegments(); - this.buildSelectMenu(); - this.removeTagFilterSegment = uiSegmentSrv.newSegment({ - fake: true, - value: '-- remove tag filter --', - }); - } - - /** - * Only called for flux - */ - onChange = (target: InfluxQuery) => { - this.target.query = target.query; - }; - - // only called from raw-mode influxql-editor - onRawInfluxQLChange = (target: InfluxQuery) => { - this.target.query = target.query; - this.target.resultFormat = target.resultFormat; - this.target.alias = target.alias; - }; - - onRunQuery = () => { - this.panelCtrl.refresh(); - }; - - removeOrderByTime() { - this.target.orderByTime = 'ASC'; - } - - buildSelectMenu() { - const categories = queryPart.getCategories(); - this.selectMenu = reduce( - categories, - (memo, cat, key) => { - const menu = { - text: key, - submenu: cat.map((item: any) => { - return { text: item.type, value: item.type }; - }), - }; - memo.push(menu); - return memo; - }, - [] as any - ); - } - - getGroupByOptions() { - const query = this.queryBuilder.buildExploreQuery('TAG_KEYS'); - - return this.datasource - .metricFindQuery(query) - .then((tags: any) => { - const options = []; - if (!this.queryModel.hasFill()) { - options.push(this.uiSegmentSrv.newSegment({ value: 'fill(null)' })); - } - if (!this.target.limit) { - options.push(this.uiSegmentSrv.newSegment({ value: 'LIMIT' })); - } - if (!this.target.slimit) { - options.push(this.uiSegmentSrv.newSegment({ value: 'SLIMIT' })); - } - if (!this.target.tz) { - options.push(this.uiSegmentSrv.newSegment({ value: 'tz' })); - } - if (this.target.orderByTime === 'ASC') { - options.push(this.uiSegmentSrv.newSegment({ value: 'ORDER BY time DESC' })); - } - if (!this.queryModel.hasGroupByTime()) { - options.push(this.uiSegmentSrv.newSegment({ value: 'time($interval)' })); - } - for (const tag of tags) { - options.push(this.uiSegmentSrv.newSegment({ value: 'tag(' + tag.text + ')' })); - } - return options; - }) - .catch(this.handleQueryError.bind(this)); - } - - groupByAction() { - switch (this.groupBySegment.value) { - case 'LIMIT': { - this.target.limit = 10; - break; - } - case 'SLIMIT': { - this.target.slimit = 10; - break; - } - case 'tz': { - this.target.tz = 'UTC'; - break; - } - case 'ORDER BY time DESC': { - this.target.orderByTime = 'DESC'; - break; - } - default: { - this.queryModel.addGroupBy(this.groupBySegment.value); - } - } - - const plusButton = this.uiSegmentSrv.newPlusButton(); - this.groupBySegment.value = plusButton.value; - this.groupBySegment.html = plusButton.html; - this.groupBySegment.fake = true; - this.panelCtrl.refresh(); - } - - addSelectPart(selectParts: any, cat: any, subitem: { value: any }) { - this.queryModel.addSelectPart(selectParts, subitem.value); - this.panelCtrl.refresh(); - } - - handleSelectPartEvent(selectParts: any, part: any, evt: { name: any }) { - switch (evt.name) { - case 'get-param-options': { - const fieldsQuery = this.queryBuilder.buildExploreQuery('FIELDS'); - return this.datasource - .metricFindQuery(fieldsQuery) - .then(this.transformToSegments(true)) - .catch(this.handleQueryError.bind(this)); - } - case 'part-param-changed': { - this.panelCtrl.refresh(); - break; - } - case 'action': { - this.queryModel.removeSelectPart(selectParts, part); - this.panelCtrl.refresh(); - break; - } - case 'get-part-actions': { - return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); - } - } - return Promise.resolve(); - } - - handleGroupByPartEvent(part: any, index: any, evt: { name: any }) { - switch (evt.name) { - case 'get-param-options': { - const tagsQuery = this.queryBuilder.buildExploreQuery('TAG_KEYS'); - return this.datasource - .metricFindQuery(tagsQuery) - .then(this.transformToSegments(true)) - .catch(this.handleQueryError.bind(this)); - } - case 'part-param-changed': { - this.panelCtrl.refresh(); - break; - } - case 'action': { - this.queryModel.removeGroupByPart(part, index); - this.panelCtrl.refresh(); - break; - } - case 'get-part-actions': { - return Promise.resolve([{ text: 'Remove', value: 'remove-part' }]); - } - } - return Promise.resolve(); - } - - fixTagSegments() { - const count = this.tagSegments.length; - const lastSegment = this.tagSegments[Math.max(count - 1, 0)]; - - if (!lastSegment || lastSegment.type !== 'plus-button') { - this.tagSegments.push(this.uiSegmentSrv.newPlusButton()); - } - } - - measurementChanged() { - this.target.measurement = this.measurementSegment.value; - this.panelCtrl.refresh(); - } - - getPolicySegments() { - const policiesQuery = this.queryBuilder.buildExploreQuery('RETENTION POLICIES'); - return this.datasource - .metricFindQuery(policiesQuery) - .then(this.transformToSegments(false)) - .catch(this.handleQueryError.bind(this)); - } - - policyChanged() { - this.target.policy = this.policySegment.value; - this.panelCtrl.refresh(); - } - - // Only valid for InfluxQL queries - toggleEditorMode() { - if (this.datasource.isFlux) { - return; // nothing - } - - try { - this.target.query = this.queryModel.render(false); - } catch (err) { - console.error('query render error'); - } - this.target.rawQuery = !this.target.rawQuery; - } - - getMeasurements(measurementFilter: any) { - const query = this.queryBuilder.buildExploreQuery('MEASUREMENTS', undefined, measurementFilter); - return this.datasource - .metricFindQuery(query) - .then(this.transformToSegments(true)) - .catch(this.handleQueryError.bind(this)); - } - - handleQueryError(err: any): any[] { - this.error = err.message || 'Failed to issue metric query'; - return []; - } - - transformToSegments(addTemplateVars: any) { - return (results: any) => { - const segments = map(results, (segment) => { - return this.uiSegmentSrv.newSegment({ - value: segment.text, - expandable: segment.expandable, - }); - }); - - if (addTemplateVars) { - for (const variable of this.templateSrv.getVariables()) { - segments.unshift( - this.uiSegmentSrv.newSegment({ - type: 'value', - value: '/^$' + variable.name + '$/', - expandable: true, - }) - ); - } - } - - return segments; - }; - } - - getTagsOrValues(segment: { type: string }, index: number) { - if (segment.type === 'condition') { - return Promise.resolve([this.uiSegmentSrv.newSegment('AND'), this.uiSegmentSrv.newSegment('OR')]); - } - - if (segment.type === 'operator') { - const nextValue = this.tagSegments[index + 1].value; - if (/^\/.*\/$/.test(nextValue)) { - return Promise.resolve(this.uiSegmentSrv.newOperators(['=~', '!~'])); - } else { - return Promise.resolve(this.uiSegmentSrv.newOperators(['=', '!=', '<>', '<', '>'])); - } - } - - let query, addTemplateVars; - if (segment.type === 'key' || segment.type === 'plus-button') { - query = this.queryBuilder.buildExploreQuery('TAG_KEYS'); - addTemplateVars = false; - } else if (segment.type === 'value') { - query = this.queryBuilder.buildExploreQuery('TAG_VALUES', this.tagSegments[index - 2].value); - addTemplateVars = true; - } - - return this.datasource - .metricFindQuery(query) - .then(this.transformToSegments(addTemplateVars)) - .then((results: any) => { - if (segment.type === 'key') { - results.splice(0, 0, angular.copy(this.removeTagFilterSegment)); - } - return results; - }) - .catch(this.handleQueryError.bind(this)); - } - - getFieldSegments() { - const fieldsQuery = this.queryBuilder.buildExploreQuery('FIELDS'); - return this.datasource - .metricFindQuery(fieldsQuery) - .then(this.transformToSegments(false)) - .catch(this.handleQueryError); - } - - tagSegmentUpdated(segment: { value: any; type: string; cssClass: string }, index: number) { - this.tagSegments[index] = segment; - - // handle remove tag condition - if (segment.value === this.removeTagFilterSegment.value) { - this.tagSegments.splice(index, 3); - if (this.tagSegments.length === 0) { - this.tagSegments.push(this.uiSegmentSrv.newPlusButton()); - } else if (this.tagSegments.length > 2) { - this.tagSegments.splice(Math.max(index - 1, 0), 1); - if (this.tagSegments[this.tagSegments.length - 1].type !== 'plus-button') { - this.tagSegments.push(this.uiSegmentSrv.newPlusButton()); - } - } - } else { - if (segment.type === 'plus-button') { - if (index > 2) { - this.tagSegments.splice(index, 0, this.uiSegmentSrv.newCondition('AND')); - } - this.tagSegments.push(this.uiSegmentSrv.newOperator('=')); - this.tagSegments.push(this.uiSegmentSrv.newFake('select tag value', 'value', 'query-segment-value')); - segment.type = 'key'; - segment.cssClass = 'query-segment-key'; - } - - if (index + 1 === this.tagSegments.length) { - this.tagSegments.push(this.uiSegmentSrv.newPlusButton()); - } - } - - this.rebuildTargetTagConditions(); - } - - rebuildTargetTagConditions() { - const tags: any[] = []; - let tagIndex = 0; - let tagOperator: string | null = ''; - - each(this.tagSegments, (segment2, index) => { - if (segment2.type === 'key') { - if (tags.length === 0) { - tags.push({}); - } - tags[tagIndex].key = segment2.value; - } else if (segment2.type === 'value') { - tagOperator = this.getTagValueOperator(segment2.value, tags[tagIndex].operator); - if (tagOperator) { - this.tagSegments[index - 1] = this.uiSegmentSrv.newOperator(tagOperator); - tags[tagIndex].operator = tagOperator; - } - tags[tagIndex].value = segment2.value; - } else if (segment2.type === 'condition') { - tags.push({ condition: segment2.value }); - tagIndex += 1; - } else if (segment2.type === 'operator') { - tags[tagIndex].operator = segment2.value; - } - }); - - this.target.tags = tags; - this.panelCtrl.refresh(); - } - - getTagValueOperator(tagValue: string, tagOperator: string): string | null { - if (tagOperator !== '=~' && tagOperator !== '!~' && /^\/.*\/$/.test(tagValue)) { - return '=~'; - } else if ((tagOperator === '=~' || tagOperator === '!~') && /^(?!\/.*\/$)/.test(tagValue)) { - return '='; - } - return null; - } -} diff --git a/public/app/plugins/datasource/influxdb/registerRawInfluxQLEditor.ts b/public/app/plugins/datasource/influxdb/registerRawInfluxQLEditor.ts deleted file mode 100644 index be5159034d3..00000000000 --- a/public/app/plugins/datasource/influxdb/registerRawInfluxQLEditor.ts +++ /dev/null @@ -1,9 +0,0 @@ -import coreModule from 'app/core/core_module'; -import { RawInfluxQLEditor } from './components/RawInfluxQLEditor'; - -coreModule.directive('rawInfluxEditor', [ - 'reactDirective', - (reactDirective: any) => { - return reactDirective(RawInfluxQLEditor, ['query', 'onChange', 'onRunQuery']); - }, -]); diff --git a/public/app/plugins/datasource/influxdb/specs/query_ctrl.test.ts b/public/app/plugins/datasource/influxdb/specs/query_ctrl.test.ts deleted file mode 100644 index b127396f442..00000000000 --- a/public/app/plugins/datasource/influxdb/specs/query_ctrl.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { uiSegmentSrv } from 'app/core/services/segment_srv'; -import { InfluxQueryCtrl } from '../query_ctrl'; -import InfluxDatasource from '../datasource'; - -describe('InfluxDBQueryCtrl', () => { - const ctx = {} as any; - - beforeEach(() => { - InfluxQueryCtrl.prototype.datasource = ({ - metricFindQuery: () => Promise.resolve([]), - } as unknown) as InfluxDatasource; - InfluxQueryCtrl.prototype.target = { target: {} }; - InfluxQueryCtrl.prototype.panelCtrl = { - panel: { - targets: [InfluxQueryCtrl.prototype.target], - }, - refresh: () => {}, - }; - - ctx.ctrl = new InfluxQueryCtrl( - {}, - {} as any, - {} as any, - //@ts-ignore - new uiSegmentSrv({ trustAsHtml: (html: any) => html }, { highlightVariablesAsHtml: () => {} }) - ); - }); - - describe('init', () => { - it('should init tagSegments', () => { - expect(ctx.ctrl.tagSegments.length).toBe(1); - }); - - it('should init measurementSegment', () => { - expect(ctx.ctrl.measurementSegment.value).toBe('select measurement'); - }); - }); - - describe('when first tag segment is updated', () => { - beforeEach(() => { - ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); - }); - - it('should update tag key', () => { - expect(ctx.ctrl.target.tags[0].key).toBe('asd'); - expect(ctx.ctrl.tagSegments[0].type).toBe('key'); - }); - - it('should add tagSegments', () => { - expect(ctx.ctrl.tagSegments.length).toBe(3); - }); - }); - - describe('when last tag value segment is updated', () => { - beforeEach(() => { - ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); - ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); - }); - - it('should update tag value', () => { - expect(ctx.ctrl.target.tags[0].value).toBe('server1'); - }); - - it('should set tag operator', () => { - expect(ctx.ctrl.target.tags[0].operator).toBe('='); - }); - - it('should add plus button for another filter', () => { - expect(ctx.ctrl.tagSegments[3].fake).toBe(true); - }); - }); - - describe('when last tag value segment is updated to regex', () => { - beforeEach(() => { - ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); - ctx.ctrl.tagSegmentUpdated({ value: '/server.*/', type: 'value' }, 2); - }); - - it('should update operator', () => { - expect(ctx.ctrl.tagSegments[1].value).toBe('=~'); - expect(ctx.ctrl.target.tags[0].operator).toBe('=~'); - }); - }); - - describe('when second tag key is added', () => { - beforeEach(() => { - ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); - ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); - ctx.ctrl.tagSegmentUpdated({ value: 'key2', type: 'plus-button' }, 3); - }); - - it('should update tag key', () => { - expect(ctx.ctrl.target.tags[1].key).toBe('key2'); - }); - - it('should add AND segment', () => { - expect(ctx.ctrl.tagSegments[3].value).toBe('AND'); - }); - }); - - describe('when condition is changed', () => { - beforeEach(() => { - ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); - ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); - ctx.ctrl.tagSegmentUpdated({ value: 'key2', type: 'plus-button' }, 3); - ctx.ctrl.tagSegmentUpdated({ value: 'OR', type: 'condition' }, 3); - }); - - it('should update tag condition', () => { - expect(ctx.ctrl.target.tags[1].condition).toBe('OR'); - }); - - it('should update AND segment', () => { - expect(ctx.ctrl.tagSegments[3].value).toBe('OR'); - expect(ctx.ctrl.tagSegments.length).toBe(7); - }); - }); - - describe('when deleting first tag filter after value is selected', () => { - beforeEach(() => { - ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); - ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); - ctx.ctrl.tagSegmentUpdated(ctx.ctrl.removeTagFilterSegment, 0); - }); - - it('should remove tags', () => { - expect(ctx.ctrl.target.tags.length).toBe(0); - }); - - it('should remove all segment after 2 and replace with plus button', () => { - expect(ctx.ctrl.tagSegments.length).toBe(1); - expect(ctx.ctrl.tagSegments[0].type).toBe('plus-button'); - }); - }); - - describe('when deleting second tag value before second tag value is complete', () => { - beforeEach(() => { - ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); - ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); - ctx.ctrl.tagSegmentUpdated({ value: 'key2', type: 'plus-button' }, 3); - ctx.ctrl.tagSegmentUpdated(ctx.ctrl.removeTagFilterSegment, 4); - }); - - it('should remove all segment after 2 and replace with plus button', () => { - expect(ctx.ctrl.tagSegments.length).toBe(4); - expect(ctx.ctrl.tagSegments[3].type).toBe('plus-button'); - }); - }); - - describe('when deleting second tag value before second tag value is complete', () => { - beforeEach(() => { - ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); - ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); - ctx.ctrl.tagSegmentUpdated({ value: 'key2', type: 'plus-button' }, 3); - ctx.ctrl.tagSegmentUpdated(ctx.ctrl.removeTagFilterSegment, 4); - }); - - it('should remove all segment after 2 and replace with plus button', () => { - expect(ctx.ctrl.tagSegments.length).toBe(4); - expect(ctx.ctrl.tagSegments[3].type).toBe('plus-button'); - }); - }); - - describe('when deleting second tag value after second tag filter is complete', () => { - beforeEach(() => { - ctx.ctrl.tagSegmentUpdated({ value: 'asd', type: 'plus-button' }, 0); - ctx.ctrl.tagSegmentUpdated({ value: 'server1', type: 'value' }, 2); - ctx.ctrl.tagSegmentUpdated({ value: 'key2', type: 'plus-button' }, 3); - ctx.ctrl.tagSegmentUpdated({ value: 'value', type: 'value' }, 6); - ctx.ctrl.tagSegmentUpdated(ctx.ctrl.removeTagFilterSegment, 4); - }); - - it('should remove all segment after 2 and replace with plus button', () => { - expect(ctx.ctrl.tagSegments.length).toBe(4); - expect(ctx.ctrl.tagSegments[3].type).toBe('plus-button'); - }); - }); -}); diff --git a/public/app/plugins/datasource/influxdb/types.ts b/public/app/plugins/datasource/influxdb/types.ts index 01d2a62a7fa..e05c059e54b 100644 --- a/public/app/plugins/datasource/influxdb/types.ts +++ b/public/app/plugins/datasource/influxdb/types.ts @@ -27,7 +27,10 @@ export interface InfluxSecureJsonData { export interface InfluxQueryPart { type: string; - params?: string[]; + params?: Array; + // FIXME: `interval` does not seem to be used. + // check all the influxdb parts (query-generation etc.), + // if it is really so, and if yes, remove it interval?: string; } @@ -48,9 +51,12 @@ export interface InfluxQuery extends DataQuery { tags?: InfluxQueryTag[]; groupBy?: InfluxQueryPart[]; select?: InfluxQueryPart[][]; - limit?: string; - slimit?: string; + limit?: string | number; + slimit?: string | number; tz?: string; + // NOTE: `fill` is not used in the query-editor anymore, and is removed + // if any change happens in the query-editor. the query-generation still + // supports it for now. fill?: string; rawQuery?: boolean; query?: string;