InfluxDB: Convert the InfluxQL query editor from Angular to React (#32168)

Co-authored-by: Giordano Ricci <me@giordanoricci.com>
This commit is contained in:
Gábor Farkas 2021-05-11 08:15:44 +02:00 committed by GitHub
parent a469fa8416
commit 3e59ae7e56
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 2257 additions and 856 deletions

View File

@ -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<Props> {
);
}
}
coreModule.directive('fluxQueryEditor', [
'reactDirective',
(reactDirective: any) => {
return reactDirective(FluxQueryEditor, ['query', 'onChange', 'onRunQuery']);
},
]);

View File

@ -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<InfluxDatasource, InfluxQuery, InfluxOptions>;
export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range, data }: Props): JSX.Element => {
if (datasource.isFlux) {
return (
<div className="gf-form-query-content">
<FluxQueryEditor query={query} onChange={onChange} onRunQuery={onRunQuery} datasource={datasource} />
</div>
);
}
return (
<div className={css({ display: 'flex' })}>
<div className={css({ flexGrow: 1 })}>
{query.rawQuery ? (
<RawInfluxQLEditor query={query} onChange={onChange} onRunQuery={onRunQuery} />
) : (
<VisualInfluxQLEditor query={query} onChange={onChange} onRunQuery={onRunQuery} datasource={datasource} />
)}
</div>
<QueryEditorModeSwitcher
isRaw={query.rawQuery ?? false}
onChange={(value) => {
onChange({ ...query, query: buildRawQuery(query), rawQuery: value });
onRunQuery();
}}
/>
</div>
);
};

View File

@ -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 (
<>
<Button
icon="pen"
variant="secondary"
type="button"
onClick={() => {
// we show the are-you-sure modal
setModalOpen(true);
}}
></Button>
<ConfirmModal
isOpen={isModalOpen}
title="Switch to visual editor mode"
body="Are you sure to switch to visual editor mode? You will loose the changes done in raw query mode."
confirmText="Yes, switch to editor mode"
dismissText="No, stay in raw query mode"
onConfirm={() => {
onChange(false);
}}
onDismiss={() => {
setModalOpen(false);
}}
/>
</>
);
} else {
return (
<Button
icon="pen"
variant="secondary"
type="button"
onClick={() => {
onChange(true);
}}
></Button>
);
}
};

View File

@ -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<SelectableValue<ResultFormat>> = [
{ 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<Props> = ({ 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();

View File

@ -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<SelectableValue[]>;
allowCustomValue?: boolean;
onAdd: (v: string) => void;
};
export const AddButton = ({ loadOptions, allowCustomValue, onAdd }: Props): JSX.Element => {
return (
<Seg
value="+"
loadOptions={loadOptions}
allowCustomValue={allowCustomValue}
onChange={(v) => {
onAdd(unwrap(v.value));
}}
/>
);
};

View File

@ -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 }) => (
<span>[{value || placeholder}]</span>
);
const WithContextMenu = ({ children }: { children: (x: unknown) => JSX.Element }) => (
<span>[{children({ openMenu: undefined })}]</span>
);
const Select = ({ value }: { value: string }) => <span>[{value}]</span>;
const orig = jest.requireActual('@grafana/ui');
return {
...orig,
Input,
WithContextMenu,
Select,
};
});
jest.mock('./Seg', () => {
const Seg = ({ value }: { value: string }) => <span>[{value}]</span>;
return {
Seg,
};
});
function assertEditor(query: InfluxQuery, textContent: string) {
const onChange = jest.fn();
const onRunQuery = jest.fn();
const datasource: InfluxDatasource = {} as InfluxDatasource;
const { container } = render(
<Editor query={query} datasource={datasource} onChange={onChange} onRunQuery={onRunQuery} />
);
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]'
);
});
});

View File

@ -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<string[]>): Promise<string[]> {
return optionsPromise.then((options) => [...getTemplateVariableOptions(), ...options]);
}
const SectionWrap = ({ initialName, children }: { initialName: string; children: React.ReactNode }) => (
<div className="gf-form-inline">
<SectionLabel name={initialName} isInitial={true} />
{children}
<SectionFill />
</div>
);
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 (
<div>
<SectionWrap initialName="from">
<FromSection
policy={policy}
measurement={measurement}
getPolicyOptions={() => getAllPolicies(datasource)}
getMeasurementOptions={(filter) =>
withTemplateVariableOptions(
getAllMeasurementsForTags(filter === '' ? undefined : filter, query.tags ?? [], datasource)
)
}
onChange={handleFromSectionChange}
/>
<SectionLabel name="where" />
<TagsSection
tags={query.tags ?? []}
onChange={handleTagsSectionChange}
getTagKeyOptions={getTagKeys}
getTagValueOptions={(key: string) =>
withTemplateVariableOptions(getTagValues(key, measurement, policy, datasource))
}
/>
</SectionWrap>
{selectLists.map((sel, index) => (
<SectionWrap key={index} initialName={index === 0 ? 'select' : ''}>
<PartListSection
parts={sel}
getNewPartOptions={() => 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));
}}
/>
</SectionWrap>
))}
<SectionWrap initialName="group by">
<PartListSection
parts={groupByList}
getNewPartOptions={() => 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));
}}
/>
</SectionWrap>
<SectionWrap initialName="timezone">
<InputSection
placeholder="(optional)"
value={query.tz}
onChange={(tz) => {
onAppliedChange({ ...query, tz });
}}
/>
<SectionLabel name="order by time" />
<OrderByTimeSection
value={query.orderByTime === 'DESC' ? 'DESC' : 'ASC' /* FIXME: make this shared with influx_query_model */}
onChange={(v) => {
onAppliedChange({ ...query, orderByTime: v });
}}
/>
</SectionWrap>
{/* 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. */}
<SectionWrap initialName="limit">
<InputSection
placeholder="(optional)"
value={query.limit?.toString()}
onChange={(limit) => {
onAppliedChange({ ...query, limit });
}}
/>
<SectionLabel name="slimit" />
<InputSection
placeholder="(optional)"
value={query.slimit?.toString()}
onChange={(slimit) => {
onAppliedChange({ ...query, slimit });
}}
/>
</SectionWrap>
<SectionWrap initialName="format as">
<FormatAsSection
format={query.resultFormat ?? DEFAULT_RESULT_FORMAT}
onChange={(format) => {
onAppliedChange({ ...query, resultFormat: format });
}}
/>
{query.resultFormat !== 'table' && (
<>
<SectionLabel name="alias" />
<InputSection
isWide
placeholder="Naming pattern"
value={query.alias}
onChange={(alias) => {
onAppliedChange({ ...query, alias });
}}
/>
</>
)}
</SectionWrap>
</div>
);
};

View File

@ -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 (
<Select<ResultFormat>
className={className}
onChange={(v) => {
onChange(unwrap(v.value));
}}
value={format}
options={RESULT_FORMATS}
/>
);
};

View File

@ -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<string[]>;
getMeasurementOptions: (filter: string) => Promise<string[]>;
};
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 (
<>
<Seg
allowCustomValue
value={policy ?? 'using default policy'}
loadOptions={handlePolicyLoadOptions}
onChange={(v) => {
onChange(v.value, measurement);
}}
/>
<Seg
allowCustomValue
value={measurement ?? 'select measurement'}
loadOptions={handleMeasurementLoadOptions}
filterByLoadOptions
onChange={(v) => {
onChange(policy, v.value);
}}
/>
</>
);
};

View File

@ -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 (
<>
<Input
placeholder={placeholder}
className={cx(isWide ?? false ? 'width-14' : 'width-8', paddingRightClass)}
type="text"
spellCheck={false}
onBlur={onBlur}
onChange={(e) => {
setCurrentValue(e.currentTarget.value);
}}
value={currentValue ?? ''}
/>
</>
);
};

View File

@ -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<SelectableValue<Mode>> = [
{ 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 (
<>
<Select<Mode>
className={className}
onChange={(v) => {
onChange(unwrap(v.value));
}}
value={value}
options={OPTIONS}
/>
</>
);
};

View File

@ -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<string[]>) | null;
}>;
type Props = {
parts: Array<{
name: string;
params: PartParams;
}>;
getNewPartOptions: () => Promise<SelectableValue[]>;
onChange: (partIndex: number, paramValues: string[]) => void;
onRemovePart: (index: number) => void;
onAddNewPart: (type: string) => void;
};
const renderRemovableNameMenuItems = (onClick: () => void) => {
return (
<MenuGroup label="" ariaLabel="">
<MenuItem label="remove" ariaLabel="remove" onClick={onClick} />
</MenuGroup>
);
};
const noRightMarginPaddingClass = css({
paddingRight: '0',
marginRight: '0',
});
const RemovableName = ({ name, onRemove }: { name: string; onRemove: () => void }) => {
return (
<WithContextMenu renderMenuItems={() => renderRemovableNameMenuItems(onRemove)}>
{({ openMenu }) => (
<button className={cx('gf-form-label', noRightMarginPaddingClass)} onClick={openMenu}>
{name}
</button>
)}
</WithContextMenu>
);
};
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 (
<div className={partClass}>
<RemovableName name={name} onRemove={onRemove} />(
{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 (
<React.Fragment key={i}>
<Seg
allowCustomValue
value={value}
buttonClassName={noHorizMarginPaddingClass}
loadOptions={loadOptions}
onChange={(v) => {
onParamChange(unwrap(v.value), i);
}}
/>
{!isLast && ','}
</React.Fragment>
);
})}
)
</div>
);
};
export const PartListSection = ({
parts,
getNewPartOptions,
onAddNewPart,
onRemovePart,
onChange,
}: Props): JSX.Element => {
return (
<>
{parts.map((part, index) => (
<Part
key={index}
name={part.name}
params={part.params}
onRemove={() => {
onRemovePart(index);
}}
onChange={(pars) => {
onChange(index, pars);
}}
/>
))}
<AddButton loadOptions={getNewPartOptions} onAdd={onAddNewPart} />
</>
);
};

View File

@ -0,0 +1,7 @@
import React from 'react';
export const SectionFill = () => (
<div className="gf-form gf-form--grow">
<label className="gf-form-label gf-form-label--grow"></label>
</div>
);

View File

@ -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) => (
<label className={cx('gf-form-label query-keyword', { 'width-7': isInitial ?? false }, uppercaseClass)}>{name}</label>
);

View File

@ -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<string>;
// 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<SelVal[]>;
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<SelVal[]>;
onClose: () => void;
onChange: (v: SelVal) => void;
allowCustomValue?: boolean;
};
const SelReload = ({ loadOptions, allowCustomValue, onChange, onClose }: SelReloadProps): JSX.Element => {
const ref = useRef<HTMLDivElement | null>(null);
useClickAway(ref, onClose);
// here we rely on the fact that writing text into the <AsyncSelect/>
// 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 (
<div ref={ref} className={selectClass}>
<AsyncSelect
defaultOptions
autoFocus
isOpen
allowCustomValue={allowCustomValue}
loadOptions={debouncedLoadOptions}
onChange={onChange}
/>
</div>
);
};
type SelSingleLoadProps = {
loadOptions: (filter: string) => Promise<SelVal[]>;
onClose: () => void;
onChange: (v: SelVal) => void;
allowCustomValue?: boolean;
};
const SelSingleLoad = ({ loadOptions, allowCustomValue, onChange, onClose }: SelSingleLoadProps): JSX.Element => {
const ref = useRef<HTMLDivElement | null>(null);
const [loadState, doLoad] = useAsyncFn(loadOptions, [loadOptions]);
useClickAway(ref, onClose);
useEffect(() => {
doLoad();
}, [doLoad, loadOptions]);
return (
<div ref={ref} className={selectClass}>
<Select
autoFocus
isOpen
allowCustomValue={allowCustomValue}
options={loadState.value ?? []}
onChange={onChange}
/>
</div>
);
};
const Sel = ({ loadOptions, filterByLoadOptions, allowCustomValue, onChange, onClose }: SelProps): JSX.Element => {
// unfortunately <Segment/> and <SegmentAsync/> have somewhat different behavior,
// so the simplest approach was to just create two separate wrapper-components
return filterByLoadOptions ? (
<SelReload loadOptions={loadOptions} allowCustomValue={allowCustomValue} onChange={onChange} onClose={onClose} />
) : (
<SelSingleLoad
loadOptions={loadOptions}
allowCustomValue={allowCustomValue}
onChange={onChange}
onClose={onClose}
/>
);
};
type InpProps = {
initialValue: string;
onChange: (newVal: string) => void;
};
const Inp = ({ initialValue, onChange }: InpProps): JSX.Element => {
const [currentValue, setCurrentValue] = useShadowedState(initialValue);
const onBlur = () => {
// we send empty-string as undefined
onChange(currentValue);
};
return (
<Input
autoFocus
type="text"
spellCheck={false}
onBlur={onBlur}
onChange={(e) => {
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 (
<InlineLabel
className={className}
onClick={() => {
setOpen(true);
}}
>
{value}
</InlineLabel>
);
} else {
if (loadOptions !== undefined) {
return (
<Sel
loadOptions={loadOptions}
filterByLoadOptions={filterByLoadOptions ?? false}
allowCustomValue={allowCustomValue}
onChange={(v) => {
setOpen(false);
onChange(v);
}}
onClose={() => {
setOpen(false);
}}
/>
);
} else {
return (
<Inp
initialValue={value}
onChange={(v) => {
setOpen(false);
onChange({ value: v, label: v });
}}
/>
);
}
}
};

View File

@ -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(
<TagsSection
tags={tags}
getTagKeyOptions={getTagKeys}
getTagValueOptions={getTagValuesForKey}
onChange={() => 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(
<TagsSection
tags={tags}
getTagKeyOptions={getTagKeys}
getTagValueOptions={getTagValuesForKey}
onChange={onChange}
/>
);
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(
<TagsSection
tags={tags}
getTagKeyOptions={getTagKeys}
getTagValueOptions={getTagValuesForKey}
onChange={onChange}
/>
);
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(
<TagsSection
tags={tags}
getTagKeyOptions={getTagKeys}
getTagValueOptions={getTagValuesForKey}
onChange={onChange}
/>
);
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(
<TagsSection
tags={tags}
getTagKeyOptions={getTagKeys}
getTagValueOptions={getTagValuesForKey}
onChange={onChange}
/>
);
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(
<TagsSection
tags={tags}
getTagKeyOptions={getTagKeys}
getTagValueOptions={getTagValuesForKey}
onChange={onChange}
/>
);
const newTags = [...tags];
newTags[0] = { ...newTags[0], value: 't1_v5' };
await assertSegmentSelect('t1_v1', 't1_v5', onChange, newTags);
});
});

View File

@ -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<SelectableValue<KnownOperator>> = knownOperators.map(toSelectableValue);
const condititonOptions: Array<SelectableValue<KnownCondition>> = knownConditions.map(toSelectableValue);
type Props = {
tags: InfluxQueryTag[];
onChange: (tags: InfluxQueryTag[]) => void;
getTagKeyOptions: () => Promise<string[]>;
getTagValueOptions: (key: string) => Promise<string[]>;
};
type TagProps = {
tag: InfluxQueryTag;
isFirst: boolean;
onRemove: () => void;
onChange: (tag: InfluxQueryTag) => void;
getTagKeyOptions: () => Promise<string[]>;
getTagValueOptions: (key: string) => Promise<string[]>;
};
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 (
<div className="gf-form">
{condition != null && (
<Seg
value={condition}
loadOptions={loadConditionOptions}
onChange={(v) => {
onChange({ ...tag, condition: v.value });
}}
/>
)}
<Seg
allowCustomValue
value={tag.key}
loadOptions={getTagKeySegmentOptions}
onChange={(v) => {
const { value } = v;
if (value === undefined) {
onRemove();
} else {
onChange({ ...tag, key: value ?? '' });
}
}}
/>
<Seg
value={operator}
loadOptions={loadOperatorOptions}
onChange={(op) => {
onChange({ ...tag, operator: op.value });
}}
/>
<Seg
allowCustomValue
value={tag.value}
loadOptions={getTagValueSegmentOptions}
onChange={(v) => {
const value = v.value ?? '';
onChange({ ...tag, value, operator: adjustOperatorIfNeeded(operator, value) });
}}
/>
</div>
);
};
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) => (
<Tag
tag={t}
isFirst={i === 0}
key={i}
onChange={(newT) => {
onTagChange(newT, i);
}}
onRemove={() => {
onTagRemove(i);
}}
getTagKeyOptions={getTagKeyOptions}
getTagValueOptions={getTagValueOptions}
/>
))}
<AddButton
allowCustomValue
loadOptions={getTagKeySegmentOptions}
onAdd={(v) => {
addNewTag(v, tags.length === 0);
}}
/>
</>
);
};

View File

@ -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<string, QueryPartDef[]>;
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<string[]>
): Promise<Array<SelectableValue<string>>> {
const tagKeys = await getTagKeys();
const queryCopy = { ...query }; // the query-model mutates the query
const model = new InfluxQueryModel(queryCopy);
const options: Array<SelectableValue<string>> = [];
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<string, () => Promise<string[]>>): 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<string, () => Promise<string[]>>
): Part[] {
return queryParts.map((qp) => {
return {
name: qp.type,
params: getPartParams(qp, dynamicParamOptions),
};
});
}

View File

@ -0,0 +1,5 @@
import { css } from '@emotion/css';
export const paddingRightClass = css({
paddingRight: '4px',
});

View File

@ -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('=');
});
});
});

View File

@ -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;
}
}

View File

@ -0,0 +1,5 @@
import { SelectableValue } from '@grafana/data';
export function toSelectableValue<T extends string>(t: T): SelectableValue<T> {
return { label: t, value: t };
}

View File

@ -0,0 +1,6 @@
export function unwrap<T>(value: T | null | undefined): T {
if (value == null) {
throw new Error('value must not be nullish');
}
return value;
}

View File

@ -0,0 +1,10 @@
import { SelectableValue } from '@grafana/data';
import { ResultFormat } from '../types';
export const RESULT_FORMATS: Array<SelectableValue<ResultFormat>> = [
{ label: 'Time series', value: 'time_series' },
{ label: 'Table', value: 'table' },
{ label: 'Logs', value: 'logs' },
];
export const DEFAULT_RESULT_FORMAT: ResultFormat = 'time_series';

View File

@ -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'],
},
],
});
});
});
});

View File

@ -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 };
}

View File

@ -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<Array<{ text: string }>> => {
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<string[]> {
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<string[]> {
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<string[]> {
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<string[]> {
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<string[]> {
const target = { tags: [], measurement, policy };
const data = await runExploreQuery('FIELDS', undefined, undefined, target, datasource);
return data.map((item) => item.text);
}

View File

@ -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);

View File

@ -1,208 +0,0 @@
<query-editor-row ng-if="ctrl.datasource.isFlux" query-ctrl="ctrl" can-collapse="true" has-text-edit-mode="true">
<flux-query-editor
query="ctrl.target"
on-change="ctrl.onChange"
onRunQuery="ctrl.onRunQuery"
></flux-query-editor>
</query-editor-row>
<query-editor-row ng-if="!ctrl.datasource.isFlux" query-ctrl="ctrl" can-collapse="true" has-text-edit-mode="true">
<div ng-if="ctrl.target.rawQuery">
<raw-influx-editor
query="ctrl.target"
on-change="ctrl.onRawInfluxQLChange"
onRunQuery="ctrl.onRunQuery"
></raw-influx-editor>
</div>
<div ng-if="!ctrl.target.rawQuery">
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">FROM</label>
<metric-segment
segment="ctrl.policySegment"
get-options="ctrl.getPolicySegments()"
on-change="ctrl.policyChanged()"
></metric-segment>
<metric-segment
segment="ctrl.measurementSegment"
get-options="ctrl.getMeasurements($query)"
on-change="ctrl.measurementChanged()"
debounce="true"
></metric-segment>
</div>
<div class="gf-form">
<label class="gf-form-label query-keyword">WHERE</label>
</div>
<div class="gf-form" ng-repeat="segment in ctrl.tagSegments">
<metric-segment
segment="segment"
get-options="ctrl.getTagsOrValues(segment, $index)"
on-change="ctrl.tagSegmentUpdated(segment, $index)"
></metric-segment>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline" ng-repeat="selectParts in ctrl.queryModel.selectModels">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7"> <span ng-show="$index === 0">SELECT</span>&nbsp; </label>
</div>
<div class="gf-form" ng-repeat="part in selectParts">
<query-part-editor
class="gf-form-label query-part"
part="part"
handle-event="ctrl.handleSelectPartEvent(selectParts, part, $event)"
>
</query-part-editor>
</div>
<div class="gf-form">
<label
class="dropdown"
dropdown-typeahead2="ctrl.selectMenu"
dropdown-typeahead-on-select="ctrl.addSelectPart(selectParts, $item, $subItem)"
button-template-class="gf-form-label query-part"
>
</label>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">
<span>GROUP BY</span>
</label>
<query-part-editor
ng-repeat="part in ctrl.queryModel.groupByParts"
part="part"
class="gf-form-label query-part"
handle-event="ctrl.handleGroupByPartEvent(part, $index, $event)"
>
</query-part-editor>
</div>
<div class="gf-form">
<metric-segment
segment="ctrl.groupBySegment"
get-options="ctrl.getGroupByOptions()"
on-change="ctrl.groupByAction(part, $index)"
></metric-segment>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline" ng-if="ctrl.target.orderByTime === 'DESC'">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">ORDER BY</label>
<label class="gf-form-label pointer" ng-click="ctrl.removeOrderByTime()"
>time <span class="query-keyword">DESC</span> <icon name="'times'" style="margin-bottom: 0;"></icon
></label>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline" ng-if="ctrl.target.limit">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">LIMIT</label>
<input
type="text"
class="gf-form-input width-9"
ng-model="ctrl.target.limit"
spellcheck="false"
placeholder="No Limit"
ng-blur="ctrl.refresh()"
/>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline" ng-if="ctrl.target.slimit">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">SLIMIT</label>
<input
type="text"
class="gf-form-input width-9"
ng-model="ctrl.target.slimit"
spellcheck="false"
placeholder="No Limit"
ng-blur="ctrl.refresh()"
/>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline" ng-if="ctrl.target.tz">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">tz</label>
<input
type="text"
class="gf-form-input width-9"
ng-model="ctrl.target.tz"
spellcheck="false"
placeholder="No Timezone"
ng-blur="ctrl.refresh()"
/>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline">
<div class="gf-form">
<label class="gf-form-label query-keyword width-7">FORMAT AS</label>
<div class="gf-form-select-wrapper">
<select
class="gf-form-input gf-size-auto"
ng-model="ctrl.target.resultFormat"
ng-options="f.value as f.text for f in ctrl.resultFormats"
ng-change="ctrl.refresh()"
></select>
</div>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
<div class="gf-form-inline" ng-hide="ctrl.target.resultFormat === 'table'">
<div class="gf-form max-width-30">
<label class="gf-form-label query-keyword width-7">ALIAS BY</label>
<input
type="text"
class="gf-form-input"
ng-model="ctrl.target.alias"
spellcheck="false"
placeholder="Naming pattern"
ng-blur="ctrl.refresh()"
/>
</div>
<div class="gf-form gf-form--grow">
<div class="gf-form-label gf-form-label--grow"></div>
</div>
</div>
</div>
</query-editor-row>

View File

@ -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;
}
}

View File

@ -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']);
},
]);

View File

@ -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');
});
});
});

View File

@ -27,7 +27,10 @@ export interface InfluxSecureJsonData {
export interface InfluxQueryPart {
type: string;
params?: string[];
params?: Array<string | number>;
// 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;