mirror of
https://github.com/grafana/grafana.git
synced 2025-01-16 03:32:37 -06:00
InfluxDB: Convert the InfluxQL query editor from Angular to React (#32168)
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
This commit is contained in:
parent
a469fa8416
commit
3e59ae7e56
@ -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']);
|
||||
},
|
||||
]);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
@ -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();
|
||||
|
@ -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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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]'
|
||||
);
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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 ?? ''}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
@ -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>
|
||||
);
|
@ -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 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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),
|
||||
};
|
||||
});
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
export const paddingRightClass = css({
|
||||
paddingRight: '4px',
|
||||
});
|
@ -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('=');
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
|
||||
export function toSelectableValue<T extends string>(t: T): SelectableValue<T> {
|
||||
return { label: t, value: t };
|
||||
}
|
@ -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;
|
||||
}
|
@ -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';
|
@ -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'],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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 };
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
|
@ -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> </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>
|
@ -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;
|
||||
}
|
||||
}
|
@ -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']);
|
||||
},
|
||||
]);
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user