mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55: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 React, { PureComponent } from 'react';
|
||||||
import coreModule from 'app/core/core_module';
|
|
||||||
import { InfluxQuery } from '../types';
|
import { InfluxQuery } from '../types';
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
import { cx, css } from '@emotion/css';
|
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 { TextArea, InlineFormLabel, Input, Select, HorizontalGroup } from '@grafana/ui';
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { InfluxQuery } from '../types';
|
||||||
import { ResultFormat, InfluxQuery } from '../types';
|
|
||||||
import { useShadowedState } from './useShadowedState';
|
import { useShadowedState } from './useShadowedState';
|
||||||
import { useUniqueId } from './useUniqueId';
|
import { useUniqueId } from './useUniqueId';
|
||||||
|
import { RESULT_FORMATS, DEFAULT_RESULT_FORMAT } from './constants';
|
||||||
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';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
query: InfluxQuery;
|
query: InfluxQuery;
|
||||||
@ -22,7 +14,7 @@ type Props = {
|
|||||||
// we handle 3 fields: "query", "alias", "resultFormat"
|
// we handle 3 fields: "query", "alias", "resultFormat"
|
||||||
// "resultFormat" changes are applied immediately
|
// "resultFormat" changes are applied immediately
|
||||||
// "query" and "alias" changes only happen on onblur
|
// "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 [currentQuery, setCurrentQuery] = useShadowedState(query.query);
|
||||||
const [currentAlias, setCurrentAlias] = useShadowedState(query.alias);
|
const [currentAlias, setCurrentAlias] = useShadowedState(query.alias);
|
||||||
const aliasElementId = useUniqueId();
|
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 InfluxDatasource from './datasource';
|
||||||
import { InfluxQueryCtrl } from './query_ctrl';
|
import { QueryEditor } from './components/QueryEditor';
|
||||||
import InfluxStartPage from './components/InfluxStartPage';
|
import InfluxStartPage from './components/InfluxStartPage';
|
||||||
import { DataSourcePlugin } from '@grafana/data';
|
import { DataSourcePlugin } from '@grafana/data';
|
||||||
import ConfigEditor from './components/ConfigEditor';
|
import ConfigEditor from './components/ConfigEditor';
|
||||||
import VariableQueryEditor from './components/VariableQueryEditor';
|
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 {
|
class InfluxAnnotationsQueryCtrl {
|
||||||
static templateUrl = 'partials/annotations.editor.html';
|
static templateUrl = 'partials/annotations.editor.html';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const plugin = new DataSourcePlugin(InfluxDatasource)
|
export const plugin = new DataSourcePlugin(InfluxDatasource)
|
||||||
.setConfigEditor(ConfigEditor)
|
.setConfigEditor(ConfigEditor)
|
||||||
.setQueryCtrl(InfluxQueryCtrl)
|
.setQueryEditor(QueryEditor)
|
||||||
.setAnnotationQueryCtrl(InfluxAnnotationsQueryCtrl)
|
.setAnnotationQueryCtrl(InfluxAnnotationsQueryCtrl)
|
||||||
.setVariableQueryEditor(VariableQueryEditor)
|
.setVariableQueryEditor(VariableQueryEditor)
|
||||||
.setQueryEditorHelp(InfluxStartPage);
|
.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 {
|
export interface InfluxQueryPart {
|
||||||
type: string;
|
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;
|
interval?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,9 +51,12 @@ export interface InfluxQuery extends DataQuery {
|
|||||||
tags?: InfluxQueryTag[];
|
tags?: InfluxQueryTag[];
|
||||||
groupBy?: InfluxQueryPart[];
|
groupBy?: InfluxQueryPart[];
|
||||||
select?: InfluxQueryPart[][];
|
select?: InfluxQueryPart[][];
|
||||||
limit?: string;
|
limit?: string | number;
|
||||||
slimit?: string;
|
slimit?: string | number;
|
||||||
tz?: string;
|
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;
|
fill?: string;
|
||||||
rawQuery?: boolean;
|
rawQuery?: boolean;
|
||||||
query?: string;
|
query?: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user