diff --git a/public/app/plugins/datasource/graphite/add_graphite_func.ts b/public/app/plugins/datasource/graphite/add_graphite_func.ts index e8ddf6f8b96..5a254628eb0 100644 --- a/public/app/plugins/datasource/graphite/add_graphite_func.ts +++ b/public/app/plugins/datasource/graphite/add_graphite_func.ts @@ -25,6 +25,7 @@ export function graphiteAddFunc($compile: any) { $input.appendTo(elem); $button.appendTo(elem); + // TODO: ctrl.state is not ready yet when link() is called. This will be moved to a separate provider. ctrl.datasource.getFuncDefs().then((funcDefs: FuncDef[]) => { const allFunctions = map(funcDefs, 'name').sort(); @@ -36,7 +37,7 @@ export function graphiteAddFunc($compile: any) { minLength: 1, items: 10, updater: (value: any) => { - let funcDef: any = ctrl.datasource.getFuncDef(value); + let funcDef: any = ctrl.state.datasource.getFuncDef(value); if (!funcDef) { // try find close match value = value.toLowerCase(); @@ -96,7 +97,7 @@ export function graphiteAddFunc($compile: any) { let funcDef; try { - funcDef = ctrl.datasource.getFuncDef($('a', this).text()); + funcDef = ctrl.state.datasource.getFuncDef($('a', this).text()); } catch (e) { // ignore } diff --git a/public/app/plugins/datasource/graphite/func_editor.ts b/public/app/plugins/datasource/graphite/func_editor.ts index 8d23043a5ee..2c7a9d76450 100644 --- a/public/app/plugins/datasource/graphite/func_editor.ts +++ b/public/app/plugins/datasource/graphite/func_editor.ts @@ -2,6 +2,7 @@ import { assign, clone, each, last, map, partial } from 'lodash'; import $ from 'jquery'; import coreModule from 'app/core/core_module'; import { TemplateSrv } from 'app/features/templating/template_srv'; +import { actions } from './state/actions'; /** @ngInject */ export function graphiteFuncEditor($compile: any, templateSrv: TemplateSrv) { @@ -28,15 +29,15 @@ export function graphiteFuncEditor($compile: any, templateSrv: TemplateSrv) { let cancelBlur: any = null; ctrl.handleRemoveFunction = (func: any) => { - ctrl.removeFunction(func); + ctrl.dispatch(actions.removeFunction({ func })); }; ctrl.handleMoveLeft = (func: any) => { - ctrl.moveFunction(func, -1); + ctrl.dispatch(actions.moveFunction({ func, offset: -1 })); }; ctrl.handleMoveRight = (func: any) => { - ctrl.moveFunction(func, 1); + ctrl.dispatch(actions.moveFunction({ func, offset: 1 })); }; function clickFuncParam(this: any, paramIndex: any) { @@ -102,7 +103,10 @@ export function graphiteFuncEditor($compile: any, templateSrv: TemplateSrv) { scheduledRelinkIfNeeded(); $scope.$apply(() => { - ctrl.targetChanged(); + // WIP: at the moment function params are mutated directly by func_editor + // after migrating to react it will be done by passing param value to + // updateFunctionParam action + ctrl.dispatch(actions.updateFunctionParam({ func })); }); if ($link.hasClass('query-part__last') && newValue === '') { diff --git a/public/app/plugins/datasource/graphite/gfunc.ts b/public/app/plugins/datasource/graphite/gfunc.ts index 0f8630fcd9c..8c599025810 100644 --- a/public/app/plugins/datasource/graphite/gfunc.ts +++ b/public/app/plugins/datasource/graphite/gfunc.ts @@ -974,6 +974,13 @@ export class FuncInstance { params: any; text: any; added: boolean; + /** + * Hidden functions are not displayed in UI but available in text editor + * This is used for seriesByTagUsed function which when used switches + * the editor to tag-only mode. Defined tags are provided to seriesByTagUsed + * as parameters. + */ + hidden?: boolean; constructor(funcDef: any, options?: { withDefaultParams: any }) { this.def = funcDef; diff --git a/public/app/plugins/datasource/graphite/graphite_query.ts b/public/app/plugins/datasource/graphite/graphite_query.ts index 093ef0edb12..7bedf438973 100644 --- a/public/app/plugins/datasource/graphite/graphite_query.ts +++ b/public/app/plugins/datasource/graphite/graphite_query.ts @@ -3,6 +3,9 @@ import { arrayMove } from 'app/core/utils/arrayMove'; import { Parser } from './parser'; import { TemplateSrv } from '@grafana/runtime'; import { ScopedVars } from '@grafana/data'; +import { FuncInstance } from './gfunc'; +import { GraphiteSegment } from './types'; +import { GraphiteDatasource } from './datasource'; export type GraphiteTagOperator = '=' | '=~' | '!=' | '!=~'; @@ -12,11 +15,22 @@ export type GraphiteTag = { value: string; }; +type GraphiteTarget = { + refId: string | number; + target: string; + /** + * Contains full query after interpolating sub-queries (e.g. "function(#A)" referencing query with refId=A) + */ + targetFull: string; + textEditor: boolean; + paused: boolean; +}; + export default class GraphiteQuery { - datasource: any; - target: any; - functions: any[] = []; - segments: any[] = []; + datasource: GraphiteDatasource; + target: GraphiteTarget; + functions: FuncInstance[] = []; + segments: GraphiteSegment[] = []; tags: GraphiteTag[] = []; error: any; seriesByTagUsed = false; @@ -272,12 +286,12 @@ export default class GraphiteQuery { addTag(tag: { key: any; operator: GraphiteTagOperator; value: string }) { const newTagParam = renderTagString(tag); - this.getSeriesByTagFunc().params.push(newTagParam); + this.getSeriesByTagFunc()!.params.push(newTagParam); this.tags.push(tag); } removeTag(index: number) { - this.getSeriesByTagFunc().params.splice(index, 1); + this.getSeriesByTagFunc()!.params.splice(index, 1); this.tags.splice(index, 1); } @@ -290,7 +304,7 @@ export default class GraphiteQuery { } const newTagParam = renderTagString(tag); - this.getSeriesByTagFunc().params[tagIndex] = newTagParam; + this.getSeriesByTagFunc()!.params[tagIndex] = newTagParam; this.tags[tagIndex] = tag; } diff --git a/public/app/plugins/datasource/graphite/partials/query.editor.html b/public/app/plugins/datasource/graphite/partials/query.editor.html index c06a423ce00..e852794de19 100644 --- a/public/app/plugins/datasource/graphite/partials/query.editor.html +++ b/public/app/plugins/datasource/graphite/partials/query.editor.html @@ -1,7 +1,7 @@ -
- +
+
@@ -10,7 +10,7 @@
-
+
- + - + > +
- - diff --git a/public/app/plugins/datasource/graphite/query_ctrl.ts b/public/app/plugins/datasource/graphite/query_ctrl.ts index d0ce572d042..3196b7c50f2 100644 --- a/public/app/plugins/datasource/graphite/query_ctrl.ts +++ b/public/app/plugins/datasource/graphite/query_ctrl.ts @@ -1,19 +1,28 @@ import './add_graphite_func'; import './func_editor'; -import { each, eachRight, map, remove } from 'lodash'; -import GraphiteQuery, { GraphiteTagOperator } from './graphite_query'; +import GraphiteQuery from './graphite_query'; import { QueryCtrl } from 'app/plugins/sdk'; -import { promiseToDigest } from 'app/core/utils/promiseToDigest'; import { auto } from 'angular'; import { TemplateSrv } from '@grafana/runtime'; -import { dispatch } from 'app/store/store'; -import { notifyApp } from 'app/core/actions'; -import { createErrorNotification } from 'app/core/copy/appNotification'; - -const GRAPHITE_TAG_OPERATORS = ['=', '!=', '=~', '!=~']; -const TAG_PREFIX = 'tag: '; - +import { actions } from './state/actions'; +import { getAltSegments, getTagOperators, getTags, getTagsAsSegments, getTagValues } from './state/providers'; +import { createStore, GraphiteQueryEditorState } from './state/store'; +import { + AngularDropdownOptions, + GraphiteActionDispatcher, + GraphiteQueryEditorAngularDependencies, + GraphiteSegment, + GraphiteTag, +} from './types'; +import { ChangeEvent } from 'react'; + +/** + * @deprecated Moved to state/store + * + * Note: methods marked with WIP are kept for easier diffing with previous changes. They will be removed when + * GraphiteQueryCtrl is replaced with a react component. + */ export class GraphiteQueryCtrl extends QueryCtrl { static templateUrl = 'partials/query.editor.html'; @@ -24,437 +33,227 @@ export class GraphiteQueryCtrl extends QueryCtrl { supportsTags = false; paused = false; - // to avoid error flooding, these errors are shown only once per session - private _tagsAutoCompleteErrorShown = false; - private _metricAutoCompleteErrorShown = false; + private state: GraphiteQueryEditorState; + private readonly dispatch: GraphiteActionDispatcher; /** @ngInject */ constructor( $scope: any, $injector: auto.IInjectorService, private uiSegmentSrv: any, - private templateSrv: TemplateSrv, - $timeout: any + private templateSrv: TemplateSrv ) { super($scope, $injector); - this.supportsTags = this.datasource.supportsTags; - this.paused = false; - this.target.target = this.target.target || ''; - this.datasource.waitForFuncDefsLoaded().then(() => { - this.queryModel = new GraphiteQuery(this.datasource, this.target, templateSrv); - this.buildSegments(false); + // This controller will be removed once it's root partial (query.editor.html) renders only React components. + // All component will be wrapped in ReactQueryEditor receiving DataSourceApi in QueryRow.renderQueryEditor + // The init() action will be removed and the store will be created in ReactQueryEditor. Note that properties + // passed to React component in QueryRow.renderQueryEditor are different than properties passed to Angular editor + // and will be mapped/provided in a way described below: + const deps = { + // WIP: to be removed. It's not passed to ReactQueryEditor but it's used only to: + // - get refId of the query (refId be passed in query property), + // - and to refresh changes (this will be handled by onChange passed to ReactQueryEditor) + // - it's needed to get other targets to interpolate the query (this will be added in QueryRow) + panelCtrl: this.panelCtrl, + + // WIP: to be replaced with query property passed to ReactQueryEditor + target: this.target, + + // WIP: same object will be passed to ReactQueryEditor + datasource: this.datasource, + + // This is used to create view models for Angular component (view models are MetricSegment objects) + // It will be simplified to produce data needed by React component + uiSegmentSrv: this.uiSegmentSrv, + + // WIP: will be replaced with: + // import { getTemplateSrv } from 'app/features/templating/template_srv'; + templateSrv: this.templateSrv, + }; + + const [dispatch, state] = createStore((state) => { + this.state = state; + // HACK: inefficient but not invoked frequently. It's needed to inform angular watcher about state changes + // for state shared between React/AngularJS. Actions invoked from React component will not mark the scope + // as dirty and the view won't be updated. It has to happen manually on each state change. + this.$scope.$digest(); }); - this.removeTagValue = '-- remove tag --'; + this.state = state; + this.dispatch = dispatch; + + this.dispatch(actions.init(deps as GraphiteQueryEditorAngularDependencies)); } parseTarget() { - this.queryModel.parseTarget(); - this.buildSegments(); + // WIP: moved to state/helpers (the same name) } - toggleEditorMode() { - this.target.textEditor = !this.target.textEditor; - this.parseTarget(); + async toggleEditorMode() { + await this.dispatch(actions.toggleEditorMode()); } buildSegments(modifyLastSegment = true) { - this.segments = map(this.queryModel.segments, (segment) => { - return this.uiSegmentSrv.newSegment(segment); - }); - - const checkOtherSegmentsIndex = this.queryModel.checkOtherSegmentsIndex || 0; - - promiseToDigest(this.$scope)(this.checkOtherSegments(checkOtherSegmentsIndex, modifyLastSegment)); - - if (this.queryModel.seriesByTagUsed) { - this.fixTagSegments(); - } + // WIP: moved to state/helpers (the same name) } addSelectMetricSegment() { - this.queryModel.addSelectMetricSegment(); - this.segments.push(this.uiSegmentSrv.newSelectMetric()); + // WIP: moved to state/helpers (the same name) } checkOtherSegments(fromIndex: number, modifyLastSegment = true) { - if (this.queryModel.segments.length === 1 && this.queryModel.segments[0].type === 'series-ref') { - return Promise.resolve(); - } - - if (fromIndex === 0) { - this.addSelectMetricSegment(); - return Promise.resolve(); - } - - const path = this.queryModel.getSegmentPathUpTo(fromIndex + 1); - if (path === '') { - return Promise.resolve(); - } - - return this.datasource - .metricFindQuery(path) - .then((segments: any) => { - if (segments.length === 0) { - if (path !== '' && modifyLastSegment) { - this.queryModel.segments = this.queryModel.segments.splice(0, fromIndex); - this.segments = this.segments.splice(0, fromIndex); - this.addSelectMetricSegment(); - } - } else if (segments[0].expandable) { - if (this.segments.length === fromIndex) { - this.addSelectMetricSegment(); - } else { - return this.checkOtherSegments(fromIndex + 1); - } - } - }) - .catch((err: any) => { - this.handleMetricsAutoCompleteError(err); - }); + // WIP: moved to state/helpers (the same name) } setSegmentFocus(segmentIndex: any) { - each(this.segments, (segment, index) => { - segment.focus = segmentIndex === index; - }); + // WIP: moved to state/helpers (the same name) } - getAltSegments(index: number, prefix: string) { - let query = prefix && prefix.length > 0 ? '*' + prefix + '*' : '*'; - if (index > 0) { - query = this.queryModel.getSegmentPathUpTo(index) + '.' + query; - } - const options = { - range: this.panelCtrl.range, - requestId: 'get-alt-segments', - }; - - return this.datasource - .metricFindQuery(query, options) - .then((segments: any[]) => { - const altSegments = map(segments, (segment) => { - return this.uiSegmentSrv.newSegment({ - value: segment.text, - expandable: segment.expandable, - }); - }); - - if (index > 0 && altSegments.length === 0) { - return altSegments; - } - - // add query references - if (index === 0) { - eachRight(this.panelCtrl.panel.targets, (target) => { - if (target.refId === this.queryModel.target.refId) { - return; - } - - altSegments.unshift( - this.uiSegmentSrv.newSegment({ - type: 'series-ref', - value: '#' + target.refId, - expandable: false, - }) - ); - }); - } - - // add template variables - eachRight(this.templateSrv.getVariables(), (variable) => { - altSegments.unshift( - this.uiSegmentSrv.newSegment({ - type: 'template', - value: '$' + variable.name, - expandable: true, - }) - ); - }); - - // add wildcard option - altSegments.unshift(this.uiSegmentSrv.newSegment('*')); - - if (this.supportsTags && index === 0) { - this.removeTaggedEntry(altSegments); - return this.addAltTagSegments(prefix, altSegments); - } else { - return altSegments; - } - }) - .catch((err: any): any[] => { - this.handleMetricsAutoCompleteError(err); - return []; - }); + /** + * Get list of options for an empty segment or a segment with metric when it's clicked/opened. + * + * This is used for new segments and segments with metrics selected. + */ + async getAltSegments(index: number, text: string): Promise { + return await getAltSegments(this.state, index, text); } addAltTagSegments(prefix: string, altSegments: any[]) { - return this.getTagsAsSegments(prefix).then((tagSegments: any[]) => { - tagSegments = map(tagSegments, (segment) => { - segment.value = TAG_PREFIX + segment.value; - return segment; - }); - return altSegments.concat(...tagSegments); - }); + // WIP: moved to state/providers (the same name) } removeTaggedEntry(altSegments: any[]) { - altSegments = remove(altSegments, (s) => s.value === '_tagged'); + // WIP: moved to state/providers (the same name) } - segmentValueChanged(segment: { type: string; value: string; expandable: any }, segmentIndex: number) { - this.error = null; - this.queryModel.updateSegmentValue(segment, segmentIndex); - - if (this.queryModel.functions.length > 0 && this.queryModel.functions[0].def.fake) { - this.queryModel.functions = []; - } - - if (segment.type === 'tag') { - const tag = removeTagPrefix(segment.value); - this.pause(); - this.addSeriesByTagFunc(tag); - return null; - } - - if (segment.expandable) { - return promiseToDigest(this.$scope)( - this.checkOtherSegments(segmentIndex + 1).then(() => { - this.setSegmentFocus(segmentIndex + 1); - this.targetChanged(); - }) - ); - } else { - this.spliceSegments(segmentIndex + 1); - } - - this.setSegmentFocus(segmentIndex + 1); - this.targetChanged(); - - return null; + /** + * Apply changes to a given metric segment + */ + async segmentValueChanged(segment: GraphiteSegment, index: number) { + await this.dispatch(actions.segmentValueChanged({ segment, index })); } spliceSegments(index: any) { - this.segments = this.segments.splice(0, index); - this.queryModel.segments = this.queryModel.segments.splice(0, index); + // WIP: moved to state/helpers (the same name) } emptySegments() { - this.queryModel.segments = []; - this.segments = []; + // WIP: moved to state/helpers (the same name) } - targetTextChanged() { - this.updateModelTarget(); - this.refresh(); + async targetTextChanged(event: ChangeEvent) { + await this.dispatch(actions.updateQuery({ query: event.target.value })); } updateModelTarget() { - this.queryModel.updateModelTarget(this.panelCtrl.panel.targets); + // WIP: moved to state/helpers as handleTargetChanged() } - targetChanged() { - if (this.queryModel.error) { - return; - } - - const oldTarget = this.queryModel.target.target; - this.updateModelTarget(); - - if (this.queryModel.target !== oldTarget && !this.paused) { - this.panelCtrl.refresh(); - } - } - - addFunction(funcDef: any) { - const newFunc = this.datasource.createFuncInstance(funcDef, { - withDefaultParams: true, - }); - newFunc.added = true; - this.queryModel.addFunction(newFunc); - this.smartlyHandleNewAliasByNode(newFunc); - - if (this.segments.length === 1 && this.segments[0].fake) { - this.emptySegments(); - } - - if (!newFunc.params.length && newFunc.added) { - this.targetChanged(); - } - - if (newFunc.def.name === 'seriesByTag') { - this.parseTarget(); - } + async addFunction(name: string) { + await this.dispatch(actions.addFunction({ name })); } removeFunction(func: any) { - this.queryModel.removeFunction(func); - this.targetChanged(); + // WIP: converted to "removeFunction" action and handled in state/store reducer + // It's now dispatched in func_editor } moveFunction(func: any, offset: any) { - this.queryModel.moveFunction(func, offset); - this.targetChanged(); + // WIP: converted to "moveFunction" action and handled in state/store reducer + // It's now dispatched in func_editor } addSeriesByTagFunc(tag: string) { - const newFunc = this.datasource.createFuncInstance('seriesByTag', { - withDefaultParams: false, - }); - const tagParam = `${tag}=`; - newFunc.params = [tagParam]; - this.queryModel.addFunction(newFunc); - newFunc.added = true; - - this.emptySegments(); - this.targetChanged(); - this.parseTarget(); + // WIP: moved to state/helpers (the same name) + // It's now dispatched in func_editor } smartlyHandleNewAliasByNode(func: { def: { name: string }; params: number[]; added: boolean }) { - if (func.def.name !== 'aliasByNode') { - return; - } - - for (let i = 0; i < this.segments.length; i++) { - if (this.segments[i].value.indexOf('*') >= 0) { - func.params[0] = i; - func.added = false; - this.targetChanged(); - return; - } - } + // WIP: moved to state/helpers (the same name) } getAllTags() { - return this.datasource.getTags().then((values: any[]) => { - const altTags = map(values, 'text'); - altTags.splice(0, 0, this.removeTagValue); - return mapToDropdownOptions(altTags); - }); + // WIP: removed. It was not used. } - getTags(index: number, tagPrefix: any) { - const tagExpressions = this.queryModel.renderTagExpressions(index); - return this.datasource - .getTagsAutoComplete(tagExpressions, tagPrefix) - .then((values: any) => { - const altTags = map(values, 'text'); - altTags.splice(0, 0, this.removeTagValue); - return mapToDropdownOptions(altTags); - }) - .catch((err: any) => { - this.handleTagsAutoCompleteError(err); - }); - } - - getTagsAsSegments(tagPrefix: string) { - const tagExpressions = this.queryModel.renderTagExpressions(); - return this.datasource - .getTagsAutoComplete(tagExpressions, tagPrefix) - .then((values: any) => { - return map(values, (val) => { - return this.uiSegmentSrv.newSegment({ - value: val.text, - type: 'tag', - expandable: false, - }); - }); - }) - .catch((err: any) => { - this.handleTagsAutoCompleteError(err); - }); - } - - getTagOperators() { - return mapToDropdownOptions(GRAPHITE_TAG_OPERATORS); + /** + * Get list of tags for editing exiting tag with + */ + async getTags(index: number, query: string): Promise { + return await getTags(this.state, index, query); + } + + /** + * Get tag list when adding a new tag with + */ + async getTagsAsSegments(query: string): Promise { + return await getTagsAsSegments(this.state, query); + } + + /** + * Get list of available tag operators + */ + getTagOperators(): AngularDropdownOptions[] { + return getTagOperators(); } getAllTagValues(tag: { key: any }) { - const tagKey = tag.key; - return this.datasource.getTagValues(tagKey).then((values: any[]) => { - const altValues = map(values, 'text'); - return mapToDropdownOptions(altValues); - }); + // WIP: removed. It was not used. } - getTagValues(tag: { key: any }, index: number, valuePrefix: any) { - const tagExpressions = this.queryModel.renderTagExpressions(index); - const tagKey = tag.key; - return this.datasource.getTagValuesAutoComplete(tagExpressions, tagKey, valuePrefix).then((values: any[]) => { - const altValues = map(values, 'text'); - // Add template variables as additional values - eachRight(this.templateSrv.getVariables(), (variable) => { - altValues.push('${' + variable.name + ':regex}'); - }); - return mapToDropdownOptions(altValues); - }); + /** + * Get list of available tag values + */ + async getTagValues(tag: GraphiteTag, index: number, query: string): Promise { + return await getTagValues(this.state, tag, index, query); } - tagChanged(tag: any, tagIndex: any) { - this.queryModel.updateTag(tag, tagIndex); - this.targetChanged(); + /** + * Apply changes when a tag is changed + */ + async tagChanged(tag: GraphiteTag, index: number) { + await this.dispatch(actions.tagChanged({ tag, index })); } - addNewTag(segment: { value: any }) { - const newTagKey = segment.value; - const newTag = { key: newTagKey, operator: '=' as GraphiteTagOperator, value: '' }; - this.queryModel.addTag(newTag); - this.targetChanged(); - this.fixTagSegments(); + async addNewTag(segment: GraphiteSegment) { + await this.dispatch(actions.addNewTag({ segment })); } removeTag(index: any) { - this.queryModel.removeTag(index); - this.targetChanged(); + // WIP: removed. It was not used. + // Tags are removed by selecting the segment called "-- remove tag --" } fixTagSegments() { - // Adding tag with the same name as just removed works incorrectly if single segment is used (instead of array) - this.addTagSegments = [this.uiSegmentSrv.newPlusButton()]; + // WIP: moved to state/helpers (the same name) } showDelimiter(index: number) { - return index !== this.queryModel.tags.length - 1; + // WIP: removed. It was not used because of broken syntax in the template. The logic has been moved directly to the template } pause() { - this.paused = true; + // WIP: moved to state/helpers (the same name) } - unpause() { - this.paused = false; - this.panelCtrl.refresh(); + async unpause() { + await this.dispatch(actions.unpause()); } getCollapsedText() { - return this.target.target; + // WIP: removed. It was not used. } - private handleTagsAutoCompleteError(error: Error): void { - console.error(error); - if (!this._tagsAutoCompleteErrorShown) { - this._tagsAutoCompleteErrorShown = true; - dispatch(notifyApp(createErrorNotification(`Fetching tags failed: ${error.message}.`))); - } + handleTagsAutoCompleteError(error: Error): void { + // WIP: moved to state/helpers (the same name) } - private handleMetricsAutoCompleteError(error: Error): void { - console.error(error); - if (!this._metricAutoCompleteErrorShown) { - this._metricAutoCompleteErrorShown = true; - dispatch(notifyApp(createErrorNotification(`Fetching metrics failed: ${error.message}.`))); - } + handleMetricsAutoCompleteError(error: Error): void { + // WIP: moved to state/helpers (the same name) } } -function mapToDropdownOptions(results: any[]) { - return map(results, (value) => { - return { text: value, value: value }; - }); -} - -function removeTagPrefix(value: string): string { - return value.replace(TAG_PREFIX, ''); -} +// WIP: moved to state/providers (the same names) +// function mapToDropdownOptions(results: any[]) {} +// function removeTagPrefix(value: string): string {} diff --git a/public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts b/public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts index 322f078d780..f84aff4b6fd 100644 --- a/public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts +++ b/public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts @@ -16,6 +16,15 @@ jest.mock('app/store/store', () => ({ })); const mockDispatch = dispatch as jest.Mock; +async function changeTarget(ctx: any, target: string, refId?: string): Promise { + await ctx.ctrl.toggleEditorMode(); + ctx.ctrl.state.target.target = target; + if (refId) { + ctx.ctrl.state.target.refId = refId; + } + await ctx.ctrl.toggleEditorMode(); +} + describe('GraphiteQueryCtrl', () => { const ctx = { datasource: { @@ -36,21 +45,23 @@ describe('GraphiteQueryCtrl', () => { targets: [ctx.target], }; - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); GraphiteQueryCtrl.prototype.target = ctx.target; GraphiteQueryCtrl.prototype.datasource = ctx.datasource; GraphiteQueryCtrl.prototype.panelCtrl = ctx.panelCtrl; ctx.ctrl = new GraphiteQueryCtrl( - {}, + { $digest: jest.fn() }, {} as any, //@ts-ignore new uiSegmentSrv({ trustAsHtml: (html) => html }, { highlightVariablesAsHtml: () => {} }), //@ts-ignore - new TemplateSrvStub(), - {} + new TemplateSrvStub() ); + + // resolve async code called by the constructor + await Promise.resolve(); }); describe('init', () => { @@ -59,19 +70,19 @@ describe('GraphiteQueryCtrl', () => { }); it('should not delete last segment if no metrics are found', () => { - expect(ctx.ctrl.segments[2].value).not.toBe('select metric'); - expect(ctx.ctrl.segments[2].value).toBe('*'); + expect(ctx.ctrl.state.segments[2].value).not.toBe('select metric'); + expect(ctx.ctrl.state.segments[2].value).toBe('*'); }); it('should parse expression and build function model', () => { - expect(ctx.ctrl.queryModel.functions.length).toBe(2); + expect(ctx.ctrl.state.queryModel.functions.length).toBe(2); }); }); describe('when toggling edit mode to raw and back again', () => { - beforeEach(() => { - ctx.ctrl.toggleEditorMode(); - ctx.ctrl.toggleEditorMode(); + beforeEach(async () => { + await ctx.ctrl.toggleEditorMode(); + await ctx.ctrl.toggleEditorMode(); }); it('should validate metric key exists', () => { @@ -80,20 +91,20 @@ describe('GraphiteQueryCtrl', () => { }); it('should delete last segment if no metrics are found', () => { - expect(ctx.ctrl.segments[0].value).toBe('test'); - expect(ctx.ctrl.segments[1].value).toBe('prod'); - expect(ctx.ctrl.segments[2].value).toBe('select metric'); + expect(ctx.ctrl.state.segments[0].value).toBe('test'); + expect(ctx.ctrl.state.segments[1].value).toBe('prod'); + expect(ctx.ctrl.state.segments[2].value).toBe('select metric'); }); it('should parse expression and build function model', () => { - expect(ctx.ctrl.queryModel.functions.length).toBe(2); + expect(ctx.ctrl.state.queryModel.functions.length).toBe(2); }); }); describe('when middle segment value of test.prod.* is changed', () => { - beforeEach(() => { + beforeEach(async () => { const segment = { type: 'segment', value: 'test', expandable: true }; - ctx.ctrl.segmentValueChanged(segment, 1); + await ctx.ctrl.segmentValueChanged(segment, 1); }); it('should validate metric key exists', () => { @@ -102,30 +113,29 @@ describe('GraphiteQueryCtrl', () => { }); it('should delete last segment if no metrics are found', () => { - expect(ctx.ctrl.segments[0].value).toBe('test'); - expect(ctx.ctrl.segments[1].value).toBe('test'); - expect(ctx.ctrl.segments[2].value).toBe('select metric'); + expect(ctx.ctrl.state.segments[0].value).toBe('test'); + expect(ctx.ctrl.state.segments[1].value).toBe('test'); + expect(ctx.ctrl.state.segments[2].value).toBe('select metric'); }); it('should parse expression and build function model', () => { - expect(ctx.ctrl.queryModel.functions.length).toBe(2); + expect(ctx.ctrl.state.queryModel.functions.length).toBe(2); }); }); describe('when adding function', () => { - beforeEach(() => { - ctx.ctrl.target.target = 'test.prod.*.count'; - ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); - ctx.ctrl.parseTarget(); - ctx.ctrl.addFunction(gfunc.getFuncDef('aliasByNode')); + beforeEach(async () => { + ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + await changeTarget(ctx, 'test.prod.*.count'); + await ctx.ctrl.addFunction(gfunc.getFuncDef('aliasByNode')); }); it('should add function with correct node number', () => { - expect(ctx.ctrl.queryModel.functions[0].params[0]).toBe(2); + expect(ctx.ctrl.state.queryModel.functions[0].params[0]).toBe(2); }); it('should update target', () => { - expect(ctx.ctrl.target.target).toBe('aliasByNode(test.prod.*.count, 2)'); + expect(ctx.ctrl.state.target.target).toBe('aliasByNode(test.prod.*.count, 2)'); }); it('should call refresh', () => { @@ -134,58 +144,52 @@ describe('GraphiteQueryCtrl', () => { }); describe('when adding function before any metric segment', () => { - beforeEach(() => { - ctx.ctrl.target.target = ''; - ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: true }]); - ctx.ctrl.parseTarget(); - ctx.ctrl.addFunction(gfunc.getFuncDef('asPercent')); + beforeEach(async () => { + ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: true }]); + await changeTarget(ctx, ''); + await ctx.ctrl.addFunction(gfunc.getFuncDef('asPercent')); }); it('should add function and remove select metric link', () => { - expect(ctx.ctrl.segments.length).toBe(0); + expect(ctx.ctrl.state.segments.length).toBe(0); }); }); describe('when initializing a target with single param func using variable', () => { - beforeEach(() => { - ctx.ctrl.target.target = 'movingAverage(prod.count, $var)'; - ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([]); - ctx.ctrl.parseTarget(); + beforeEach(async () => { + ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([]); + await changeTarget(ctx, 'movingAverage(prod.count, $var)'); }); it('should add 2 segments', () => { - expect(ctx.ctrl.segments.length).toBe(2); + expect(ctx.ctrl.state.segments.length).toBe(2); }); it('should add function param', () => { - expect(ctx.ctrl.queryModel.functions[0].params.length).toBe(1); + expect(ctx.ctrl.state.queryModel.functions[0].params.length).toBe(1); }); }); describe('when initializing target without metric expression and function with series-ref', () => { - beforeEach(() => { - ctx.ctrl.target.target = 'asPercent(metric.node.count, #A)'; - ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([]); - ctx.ctrl.parseTarget(); + beforeEach(async () => { + ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([]); + await changeTarget(ctx, 'asPercent(metric.node.count, #A)'); }); it('should add segments', () => { - expect(ctx.ctrl.segments.length).toBe(3); + expect(ctx.ctrl.state.segments.length).toBe(3); }); it('should have correct func params', () => { - expect(ctx.ctrl.queryModel.functions[0].params.length).toBe(1); + expect(ctx.ctrl.state.queryModel.functions[0].params.length).toBe(1); }); }); describe('when getting altSegments and metricFindQuery returns empty array', () => { - beforeEach(() => { - ctx.ctrl.target.target = 'test.count'; - ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([]); - ctx.ctrl.parseTarget(); - ctx.ctrl.getAltSegments(1).then((results: any) => { - ctx.altSegments = results; - }); + beforeEach(async () => { + ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([]); + await changeTarget(ctx, 'test.count'); + ctx.altSegments = await ctx.ctrl.getAltSegments(1, ''); }); it('should have no segments', () => { @@ -196,8 +200,8 @@ describe('GraphiteQueryCtrl', () => { describe('when autocomplete for metric names is not available', () => { silenceConsoleOutput(); beforeEach(() => { - ctx.ctrl.datasource.getTagsAutoComplete = jest.fn().mockReturnValue(Promise.resolve([])); - ctx.ctrl.datasource.metricFindQuery = jest.fn().mockReturnValue( + ctx.ctrl.state.datasource.getTagsAutoComplete = jest.fn().mockReturnValue(Promise.resolve([])); + ctx.ctrl.state.datasource.metricFindQuery = jest.fn().mockReturnValue( new Promise(() => { throw new Error(); }) @@ -222,25 +226,6 @@ describe('GraphiteQueryCtrl', () => { await ctx.ctrl.getAltSegments(0, 'any'); expect(mockDispatch.mock.calls.length).toBe(1); }); - - it('checkOtherSegments should handle autocomplete errors', async () => { - await expect(async () => { - await ctx.ctrl.checkOtherSegments(1, false); - expect(mockDispatch).toBeCalledWith( - expect.objectContaining({ - type: 'appNotifications/notifyApp', - }) - ); - }).not.toThrow(); - }); - - it('checkOtherSegments should display the error message only once', async () => { - await ctx.ctrl.checkOtherSegments(1, false); - expect(mockDispatch.mock.calls.length).toBe(1); - - await ctx.ctrl.checkOtherSegments(1, false); - expect(mockDispatch.mock.calls.length).toBe(1); - }); }); describe('when autocomplete for tags is not available', () => { @@ -294,16 +279,17 @@ describe('GraphiteQueryCtrl', () => { }); describe('targetChanged', () => { - beforeEach(() => { - ctx.ctrl.target.target = 'aliasByNode(scaleToSeconds(test.prod.*, 1), 2)'; - ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); - ctx.ctrl.parseTarget(); - ctx.ctrl.target.target = ''; - ctx.ctrl.targetChanged(); + beforeEach(async () => { + const newQuery = 'aliasByNode(scaleToSeconds(test.prod.*, 1), 2)'; + ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + await changeTarget(ctx, newQuery); + await ctx.ctrl.targetTextChanged({ + target: { value: newQuery }, + } as any); }); it('should rebuild target after expression model', () => { - expect(ctx.ctrl.target.target).toBe('aliasByNode(scaleToSeconds(test.prod.*, 1), 2)'); + expect(ctx.ctrl.state.target.target).toBe('aliasByNode(scaleToSeconds(test.prod.*, 1), 2)'); }); it('should call panelCtrl.refresh', () => { @@ -312,75 +298,71 @@ describe('GraphiteQueryCtrl', () => { }); describe('when updating targets with nested query', () => { - beforeEach(() => { - ctx.ctrl.target.target = 'scaleToSeconds(#A, 60)'; - ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); - ctx.ctrl.parseTarget(); + beforeEach(async () => { + ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + await changeTarget(ctx, 'scaleToSeconds(#A, 60)'); }); it('should add function params', () => { - expect(ctx.ctrl.queryModel.segments.length).toBe(1); - expect(ctx.ctrl.queryModel.segments[0].value).toBe('#A'); + expect(ctx.ctrl.state.queryModel.segments.length).toBe(1); + expect(ctx.ctrl.state.queryModel.segments[0].value).toBe('#A'); - expect(ctx.ctrl.queryModel.functions[0].params.length).toBe(1); - expect(ctx.ctrl.queryModel.functions[0].params[0]).toBe(60); + expect(ctx.ctrl.state.queryModel.functions[0].params.length).toBe(1); + expect(ctx.ctrl.state.queryModel.functions[0].params[0]).toBe(60); }); it('target should remain the same', () => { - expect(ctx.ctrl.target.target).toBe('scaleToSeconds(#A, 60)'); + expect(ctx.ctrl.state.target.target).toBe('scaleToSeconds(#A, 60)'); }); - it('targetFull should include nested queries', () => { - ctx.ctrl.panelCtrl.panel.targets = [ + it('targetFull should include nested queries', async () => { + ctx.ctrl.state.panelCtrl.panel.targets = [ { target: 'nested.query.count', refId: 'A', }, ]; - ctx.ctrl.updateModelTarget(); + await ctx.ctrl.targetTextChanged({ target: { value: 'nested.query.count' } } as any); - expect(ctx.ctrl.target.target).toBe('scaleToSeconds(#A, 60)'); + expect(ctx.ctrl.state.target.target).toBe('scaleToSeconds(#A, 60)'); - expect(ctx.ctrl.target.targetFull).toBe('scaleToSeconds(nested.query.count, 60)'); + expect(ctx.ctrl.state.target.targetFull).toBe('scaleToSeconds(nested.query.count, 60)'); }); }); describe('when updating target used in other query', () => { - beforeEach(() => { - ctx.ctrl.target.target = 'metrics.a.count'; - ctx.ctrl.target.refId = 'A'; + beforeEach(async () => { ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); - ctx.ctrl.parseTarget(); + await changeTarget(ctx, 'metrics.a.count', 'A'); - ctx.ctrl.panelCtrl.panel.targets = [ctx.ctrl.target, { target: 'sumSeries(#A)', refId: 'B' }]; + ctx.ctrl.state.panelCtrl.panel.targets = [ctx.ctrl.target, { target: 'sumSeries(#A)', refId: 'B' }]; - ctx.ctrl.updateModelTarget(); + await ctx.ctrl.targetTextChanged({ target: { value: 'metrics.a.count' } } as any); }); it('targetFull of other query should update', () => { - expect(ctx.ctrl.panel.targets[1].targetFull).toBe('sumSeries(metrics.a.count)'); + expect(ctx.ctrl.state.panelCtrl.panel.targets[1].targetFull).toBe('sumSeries(metrics.a.count)'); }); }); describe('when adding seriesByTag function', () => { - beforeEach(() => { - ctx.ctrl.target.target = ''; - ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); - ctx.ctrl.parseTarget(); - ctx.ctrl.addFunction(gfunc.getFuncDef('seriesByTag')); + beforeEach(async () => { + ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + await changeTarget(ctx, ''); + await ctx.ctrl.addFunction(gfunc.getFuncDef('seriesByTag')); }); it('should update functions', () => { - expect(ctx.ctrl.queryModel.getSeriesByTagFuncIndex()).toBe(0); + expect(ctx.ctrl.state.queryModel.getSeriesByTagFuncIndex()).toBe(0); }); it('should update seriesByTagUsed flag', () => { - expect(ctx.ctrl.queryModel.seriesByTagUsed).toBe(true); + expect(ctx.ctrl.state.queryModel.seriesByTagUsed).toBe(true); }); it('should update target', () => { - expect(ctx.ctrl.target.target).toBe('seriesByTag()'); + expect(ctx.ctrl.state.target.target).toBe('seriesByTag()'); }); it('should call refresh', () => { @@ -389,10 +371,9 @@ describe('GraphiteQueryCtrl', () => { }); describe('when parsing seriesByTag function', () => { - beforeEach(() => { - ctx.ctrl.target.target = "seriesByTag('tag1=value1', 'tag2!=~value2')"; - ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); - ctx.ctrl.parseTarget(); + beforeEach(async () => { + ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + await changeTarget(ctx, "seriesByTag('tag1=value1', 'tag2!=~value2')"); }); it('should add tags', () => { @@ -400,39 +381,37 @@ describe('GraphiteQueryCtrl', () => { { key: 'tag1', operator: '=', value: 'value1' }, { key: 'tag2', operator: '!=~', value: 'value2' }, ]; - expect(ctx.ctrl.queryModel.tags).toEqual(expected); + expect(ctx.ctrl.state.queryModel.tags).toEqual(expected); }); it('should add plus button', () => { - expect(ctx.ctrl.addTagSegments.length).toBe(1); + expect(ctx.ctrl.state.addTagSegments.length).toBe(1); }); }); describe('when tag added', () => { - beforeEach(() => { - ctx.ctrl.target.target = 'seriesByTag()'; - ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); - ctx.ctrl.parseTarget(); - ctx.ctrl.addNewTag({ value: 'tag1' }); + beforeEach(async () => { + ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + await changeTarget(ctx, 'seriesByTag()'); + await ctx.ctrl.addNewTag({ value: 'tag1' }); }); it('should update tags with default value', () => { const expected = [{ key: 'tag1', operator: '=', value: '' }]; - expect(ctx.ctrl.queryModel.tags).toEqual(expected); + expect(ctx.ctrl.state.queryModel.tags).toEqual(expected); }); it('should update target', () => { const expected = "seriesByTag('tag1=')"; - expect(ctx.ctrl.target.target).toEqual(expected); + expect(ctx.ctrl.state.target.target).toEqual(expected); }); }); describe('when tag changed', () => { - beforeEach(() => { - ctx.ctrl.target.target = "seriesByTag('tag1=value1', 'tag2!=~value2')"; - ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); - ctx.ctrl.parseTarget(); - ctx.ctrl.tagChanged({ key: 'tag1', operator: '=', value: 'new_value' }, 0); + beforeEach(async () => { + ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + await changeTarget(ctx, "seriesByTag('tag1=value1', 'tag2!=~value2')"); + await ctx.ctrl.tagChanged({ key: 'tag1', operator: '=', value: 'new_value' }, 0); }); it('should update tags', () => { @@ -440,31 +419,30 @@ describe('GraphiteQueryCtrl', () => { { key: 'tag1', operator: '=', value: 'new_value' }, { key: 'tag2', operator: '!=~', value: 'value2' }, ]; - expect(ctx.ctrl.queryModel.tags).toEqual(expected); + expect(ctx.ctrl.state.queryModel.tags).toEqual(expected); }); it('should update target', () => { const expected = "seriesByTag('tag1=new_value', 'tag2!=~value2')"; - expect(ctx.ctrl.target.target).toEqual(expected); + expect(ctx.ctrl.state.target.target).toEqual(expected); }); }); describe('when tag removed', () => { - beforeEach(() => { - ctx.ctrl.target.target = "seriesByTag('tag1=value1', 'tag2!=~value2')"; - ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); - ctx.ctrl.parseTarget(); - ctx.ctrl.tagChanged({ key: ctx.ctrl.removeTagValue }); + beforeEach(async () => { + ctx.ctrl.state.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]); + await changeTarget(ctx, "seriesByTag('tag1=value1', 'tag2!=~value2')"); + await ctx.ctrl.tagChanged({ key: ctx.ctrl.state.removeTagValue }); }); it('should update tags', () => { const expected = [{ key: 'tag2', operator: '!=~', value: 'value2' }]; - expect(ctx.ctrl.queryModel.tags).toEqual(expected); + expect(ctx.ctrl.state.queryModel.tags).toEqual(expected); }); it('should update target', () => { const expected = "seriesByTag('tag2!=~value2')"; - expect(ctx.ctrl.target.target).toEqual(expected); + expect(ctx.ctrl.state.target.target).toEqual(expected); }); }); }); diff --git a/public/app/plugins/datasource/graphite/state/actions.ts b/public/app/plugins/datasource/graphite/state/actions.ts new file mode 100644 index 00000000000..6caa597dfe2 --- /dev/null +++ b/public/app/plugins/datasource/graphite/state/actions.ts @@ -0,0 +1,45 @@ +import { GraphiteQueryEditorAngularDependencies, GraphiteSegment, GraphiteTag } from '../types'; +import { createAction } from '@reduxjs/toolkit'; +import { FuncInstance } from '../gfunc'; + +/** + * List of possible actions changing the state of QueryEditor + */ + +/** + * This is used only during the transition to react. It will be removed after migrating all components. + */ +const init = createAction('init'); + +// Metrics & Tags +const segmentValueChanged = createAction<{ segment: GraphiteSegment; index: number }>('segment-value-changed'); + +// Tags +const addNewTag = createAction<{ segment: GraphiteSegment }>('add-new-tag'); +const tagChanged = createAction<{ tag: GraphiteTag; index: number }>('tag-changed'); +const unpause = createAction('unpause'); + +// Functions +const addFunction = createAction<{ name: string }>('add-function'); +const removeFunction = createAction<{ func: FuncInstance }>('remove-function'); +const moveFunction = createAction<{ func: FuncInstance; offset: number }>('move-function'); +// TODO: at the moment parameters are modified directly, new value of the param will be passed in the action +const updateFunctionParam = createAction<{ func: FuncInstance }>('update-function-param'); + +// Text editor +const updateQuery = createAction<{ query: string }>('update-query'); +const toggleEditorMode = createAction('toggle-editor'); + +export const actions = { + init, + segmentValueChanged, + tagChanged, + addNewTag, + unpause, + addFunction, + removeFunction, + moveFunction, + updateFunctionParam, + updateQuery, + toggleEditorMode, +}; diff --git a/public/app/plugins/datasource/graphite/state/helpers.ts b/public/app/plugins/datasource/graphite/state/helpers.ts new file mode 100644 index 00000000000..d7fae17095c --- /dev/null +++ b/public/app/plugins/datasource/graphite/state/helpers.ts @@ -0,0 +1,213 @@ +import { GraphiteQueryEditorState } from './store'; +import { each, map } from 'lodash'; +import { dispatch } from '../../../../store/store'; +import { notifyApp } from '../../../../core/reducers/appNotification'; +import { createErrorNotification } from '../../../../core/copy/appNotification'; + +/** + * Helpers used by reducers and providers. They modify state object directly so should operate on a copy of the state. + */ + +export const GRAPHITE_TAG_OPERATORS = ['=', '!=', '=~', '!=~']; + +/** + * Tag names and metric names are displayed in a single dropdown. This prefix is used to + * distinguish both in the UI. + */ +export const TAG_PREFIX = 'tag: '; + +/** + * Create new AST based on new query. + * Build segments from parsed metric name and functions. + */ +export async function parseTarget(state: GraphiteQueryEditorState): Promise { + state.queryModel.parseTarget(); + await buildSegments(state); +} + +/** + * Create segments out of the current metric path + add "select metrics" if it's possible to add more to the path + */ +export async function buildSegments(state: GraphiteQueryEditorState, modifyLastSegment = true): Promise { + state.segments = map(state.queryModel.segments, (segment) => { + return state.uiSegmentSrv.newSegment(segment); + }); + + const checkOtherSegmentsIndex = state.queryModel.checkOtherSegmentsIndex || 0; + + await checkOtherSegments(state, checkOtherSegmentsIndex, modifyLastSegment); + + if (state.queryModel.seriesByTagUsed) { + fixTagSegments(state); + } +} + +/** + * Add "select metric" segment at the end + */ +export function addSelectMetricSegment(state: GraphiteQueryEditorState): void { + state.queryModel.addSelectMetricSegment(); + state.segments.push(state.uiSegmentSrv.newSelectMetric()); +} + +/** + * Validates the state after adding or changing a segment: + * - adds "select metric" only when more segments can be added to the metric name + * - check if subsequent segments are still valid if in-between segment changes and + * removes invalid segments. + */ +export async function checkOtherSegments( + state: GraphiteQueryEditorState, + fromIndex: number, + modifyLastSegment = true +): Promise { + if (state.queryModel.segments.length === 1 && state.queryModel.segments[0].type === 'series-ref') { + return; + } + + if (fromIndex === 0) { + addSelectMetricSegment(state); + return; + } + + const path = state.queryModel.getSegmentPathUpTo(fromIndex + 1); + if (path === '') { + return; + } + + try { + const segments = await state.datasource.metricFindQuery(path); + if (segments.length === 0) { + if (path !== '' && modifyLastSegment) { + state.queryModel.segments = state.queryModel.segments.splice(0, fromIndex); + state.segments = state.segments.splice(0, fromIndex); + addSelectMetricSegment(state); + } + } else if (segments[0].expandable) { + if (state.segments.length === fromIndex) { + addSelectMetricSegment(state); + } else { + await checkOtherSegments(state, fromIndex + 1); + } + } + } catch (err) { + handleMetricsAutoCompleteError(state, err); + } +} + +/** + * Changes segment being in focus. After changing the value, next segment gets focus. + * + * Note: It's a bit hidden feature. After selecting one metric, and pressing down arrow the dropdown can be expanded. + * But there's nothing indicating what's in focus and how to expand the dropdown. + */ +export function setSegmentFocus(state: GraphiteQueryEditorState, segmentIndex: number): void { + each(state.segments, (segment, index) => { + segment.focus = segmentIndex === index; + }); +} + +export function spliceSegments(state: GraphiteQueryEditorState, index: number): void { + state.segments = state.segments.splice(0, index); + state.queryModel.segments = state.queryModel.segments.splice(0, index); +} + +export function emptySegments(state: GraphiteQueryEditorState): void { + state.queryModel.segments = []; + state.segments = []; +} + +/** + * When seriesByTag function is added the UI changes it's state and only tags can be added from now. + */ +export async function addSeriesByTagFunc(state: GraphiteQueryEditorState, tag: string): Promise { + const newFunc = state.datasource.createFuncInstance('seriesByTag', { + withDefaultParams: false, + }); + const tagParam = `${tag}=`; + newFunc.params = [tagParam]; + state.queryModel.addFunction(newFunc); + newFunc.added = true; + + emptySegments(state); + handleTargetChanged(state); + await parseTarget(state); +} + +export function smartlyHandleNewAliasByNode( + state: GraphiteQueryEditorState, + func: { def: { name: string }; params: number[]; added: boolean } +): void { + if (func.def.name !== 'aliasByNode') { + return; + } + + for (let i = 0; i < state.segments.length; i++) { + if (state.segments[i].value.indexOf('*') >= 0) { + func.params[0] = i; + func.added = false; + handleTargetChanged(state); + return; + } + } +} + +/** + * Add "+" button for adding tags once at least one tag is selected + */ +export function fixTagSegments(state: GraphiteQueryEditorState): void { + // Adding tag with the same name as just removed works incorrectly if single segment is used (instead of array) + state.addTagSegments = [state.uiSegmentSrv.newPlusButton()]; +} + +/** + * Pauses running the query to allow selecting tag value. This is to prevent getting errors if the query is run + * for a tag with no selected value. + */ +export function pause(state: GraphiteQueryEditorState): void { + state.paused = true; +} + +export function removeTagPrefix(value: string): string { + return value.replace(TAG_PREFIX, ''); +} + +export function handleTargetChanged(state: GraphiteQueryEditorState): void { + if (state.queryModel.error) { + return; + } + + const oldTarget = state.queryModel.target.target; + state.queryModel.updateModelTarget(state.panelCtrl.panel.targets); + + if (state.queryModel.target.target !== oldTarget && !state.paused) { + state.panelCtrl.refresh(); + } +} + +/** + * When metrics autocomplete fails - the error is shown, but only once per page view + */ +export function handleMetricsAutoCompleteError( + state: GraphiteQueryEditorState, + error: Error +): GraphiteQueryEditorState { + console.error(error); + if (!state.metricAutoCompleteErrorShown) { + state.metricAutoCompleteErrorShown = true; + dispatch(notifyApp(createErrorNotification(`Fetching metrics failed: ${error.message}.`))); + } + return state; +} + +/** + * When tags autocomplete fails - the error is shown, but only once per page view + */ +export function handleTagsAutoCompleteError(state: GraphiteQueryEditorState, error: Error): GraphiteQueryEditorState { + console.error(error); + if (!state.tagsAutoCompleteErrorShown) { + state.tagsAutoCompleteErrorShown = true; + dispatch(notifyApp(createErrorNotification(`Fetching tags failed: ${error.message}.`))); + } + return state; +} diff --git a/public/app/plugins/datasource/graphite/state/providers.ts b/public/app/plugins/datasource/graphite/state/providers.ts new file mode 100644 index 00000000000..e63490cb7f9 --- /dev/null +++ b/public/app/plugins/datasource/graphite/state/providers.ts @@ -0,0 +1,190 @@ +import { GraphiteQueryEditorState } from './store'; +import { eachRight, map, remove } from 'lodash'; +import { + TAG_PREFIX, + GRAPHITE_TAG_OPERATORS, + handleMetricsAutoCompleteError, + handleTagsAutoCompleteError, +} from './helpers'; +import { AngularDropdownOptions, GraphiteSegment, GraphiteTag } from '../types'; + +/** + * Providers are hooks for views to provide temporal data for autocomplete. They don't modify the state. + */ + +/** + * Return list of available options for a segment with given index + * + * It may be: + * - mixed list of metrics and tags (only when nothing was selected) + * - list of metric names (if a metric name was selected for this segment) + */ +export async function getAltSegments( + state: GraphiteQueryEditorState, + index: number, + prefix: string +): Promise { + let query = prefix.length > 0 ? '*' + prefix + '*' : '*'; + if (index > 0) { + query = state.queryModel.getSegmentPathUpTo(index) + '.' + query; + } + const options = { + range: state.panelCtrl.range, + requestId: 'get-alt-segments', + }; + + try { + const segments = await state.datasource.metricFindQuery(query, options); + const altSegments = map(segments, (segment) => { + return state.uiSegmentSrv.newSegment({ + value: segment.text, + expandable: segment.expandable, + }); + }); + + if (index > 0 && altSegments.length === 0) { + return altSegments; + } + + // add query references + if (index === 0) { + eachRight(state.panelCtrl.panel.targets, (target) => { + if (target.refId === state.queryModel.target.refId) { + return; + } + + altSegments.unshift( + state.uiSegmentSrv.newSegment({ + type: 'series-ref', + value: '#' + target.refId, + expandable: false, + }) + ); + }); + } + + // add template variables + eachRight(state.templateSrv.getVariables(), (variable) => { + altSegments.unshift( + state.uiSegmentSrv.newSegment({ + type: 'template', + value: '$' + variable.name, + expandable: true, + }) + ); + }); + + // add wildcard option + altSegments.unshift(state.uiSegmentSrv.newSegment('*')); + + if (state.supportsTags && index === 0) { + removeTaggedEntry(altSegments); + return await addAltTagSegments(state, prefix, altSegments); + } else { + return altSegments; + } + } catch (err) { + handleMetricsAutoCompleteError(state, err); + } + + return []; +} + +export function getTagOperators(): AngularDropdownOptions[] { + return mapToDropdownOptions(GRAPHITE_TAG_OPERATORS); +} + +/** + * Returns tags as dropdown options + */ +export async function getTags( + state: GraphiteQueryEditorState, + index: number, + tagPrefix: string +): Promise { + try { + const tagExpressions = state.queryModel.renderTagExpressions(index); + const values = await state.datasource.getTagsAutoComplete(tagExpressions, tagPrefix); + + const altTags = map(values, 'text'); + altTags.splice(0, 0, state.removeTagValue); + return mapToDropdownOptions(altTags); + } catch (err) { + handleTagsAutoCompleteError(state, err); + } + + return []; +} + +/** + * List of tags when a tag is added. getTags is used for editing. + * When adding - segment is used. When editing - dropdown is used. + */ +export async function getTagsAsSegments( + state: GraphiteQueryEditorState, + tagPrefix: string +): Promise { + let tagsAsSegments: GraphiteSegment[] = []; + try { + const tagExpressions = state.queryModel.renderTagExpressions(); + const values = await state.datasource.getTagsAutoComplete(tagExpressions, tagPrefix); + tagsAsSegments = map(values, (val) => { + return state.uiSegmentSrv.newSegment({ + value: val.text, + type: 'tag', + expandable: false, + }); + }); + } catch (err) { + tagsAsSegments = []; + handleTagsAutoCompleteError(state, err); + } + + return tagsAsSegments; +} + +export async function getTagValues( + state: GraphiteQueryEditorState, + tag: GraphiteTag, + index: number, + valuePrefix: string +): Promise { + const tagExpressions = state.queryModel.renderTagExpressions(index); + const tagKey = tag.key; + const values = await state.datasource.getTagValuesAutoComplete(tagExpressions, tagKey, valuePrefix, {}); + const altValues = map(values, 'text'); + // Add template variables as additional values + eachRight(state.templateSrv.getVariables(), (variable) => { + altValues.push('${' + variable.name + ':regex}'); + }); + + return mapToDropdownOptions(altValues); +} + +/** + * Add segments with tags prefixed with "tag: " to include them in the same list as metrics + */ +async function addAltTagSegments( + state: GraphiteQueryEditorState, + prefix: string, + altSegments: GraphiteSegment[] +): Promise { + let tagSegments = await getTagsAsSegments(state, prefix); + + tagSegments = map(tagSegments, (segment) => { + segment.value = TAG_PREFIX + segment.value; + return segment; + }); + + return altSegments.concat(...tagSegments); +} + +function removeTaggedEntry(altSegments: GraphiteSegment[]) { + remove(altSegments, (s) => s.value === '_tagged'); +} + +function mapToDropdownOptions(results: string[]) { + return map(results, (value) => { + return { text: value, value: value }; + }); +} diff --git a/public/app/plugins/datasource/graphite/state/store.ts b/public/app/plugins/datasource/graphite/state/store.ts new file mode 100644 index 00000000000..446db59447b --- /dev/null +++ b/public/app/plugins/datasource/graphite/state/store.ts @@ -0,0 +1,173 @@ +import GraphiteQuery from '../graphite_query'; +import { GraphiteActionDispatcher, GraphiteSegment, GraphiteTagOperator } from '../types'; +import { GraphiteDatasource } from '../datasource'; +import { TemplateSrv } from '../../../../features/templating/template_srv'; +import { actions } from './actions'; +import { getTemplateSrv } from '@grafana/runtime'; +import { + addSeriesByTagFunc, + buildSegments, + checkOtherSegments, + emptySegments, + fixTagSegments, + handleTargetChanged, + parseTarget, + pause, + removeTagPrefix, + setSegmentFocus, + smartlyHandleNewAliasByNode, + spliceSegments, +} from './helpers'; +import { Action } from 'redux'; + +export type GraphiteQueryEditorState = { + /** + * Extra segment with plus button when tags are rendered + */ + addTagSegments: GraphiteSegment[]; + + supportsTags: boolean; + paused: boolean; + removeTagValue: string; + + datasource: GraphiteDatasource; + + uiSegmentSrv: any; + templateSrv: TemplateSrv; + panelCtrl: any; + + target: { target: string; textEditor: boolean }; + + segments: GraphiteSegment[]; + queryModel: GraphiteQuery; + + error: Error | null; + + tagsAutoCompleteErrorShown: boolean; + metricAutoCompleteErrorShown: boolean; +}; + +const reducer = async (action: Action, state: GraphiteQueryEditorState): Promise => { + state = { ...state }; + + if (actions.init.match(action)) { + const deps = action.payload; + deps.target.target = deps.target.target || ''; + + await deps.datasource.waitForFuncDefsLoaded(); + + state = { + ...state, + ...deps, + queryModel: new GraphiteQuery(deps.datasource, deps.target, getTemplateSrv()), + supportsTags: deps.datasource.supportsTags, + paused: false, + removeTagValue: '-- remove tag --', + }; + + await buildSegments(state, false); + } + if (actions.segmentValueChanged.match(action)) { + const { segment, index: segmentIndex } = action.payload; + + state.error = null; + state.queryModel.updateSegmentValue(segment, segmentIndex); + + if (state.queryModel.functions.length > 0 && state.queryModel.functions[0].def.fake) { + state.queryModel.functions = []; + } + + if (segment.type === 'tag') { + const tag = removeTagPrefix(segment.value); + pause(state); + await addSeriesByTagFunc(state, tag); + return state; + } + + if (segment.expandable) { + await checkOtherSegments(state, segmentIndex + 1); + setSegmentFocus(state, segmentIndex + 1); + handleTargetChanged(state); + } else { + spliceSegments(state, segmentIndex + 1); + } + + setSegmentFocus(state, segmentIndex + 1); + handleTargetChanged(state); + } + if (actions.tagChanged.match(action)) { + const { tag, index: tagIndex } = action.payload; + state.queryModel.updateTag(tag, tagIndex); + handleTargetChanged(state); + } + if (actions.addNewTag.match(action)) { + const segment = action.payload.segment; + const newTagKey = segment.value; + const newTag = { key: newTagKey, operator: '=' as GraphiteTagOperator, value: '' }; + state.queryModel.addTag(newTag); + handleTargetChanged(state); + fixTagSegments(state); + } + if (actions.unpause.match(action)) { + state.paused = false; + state.panelCtrl.refresh(); + } + if (actions.addFunction.match(action)) { + const newFunc = state.datasource.createFuncInstance(action.payload.name, { + withDefaultParams: true, + }); + newFunc.added = true; + state.queryModel.addFunction(newFunc); + smartlyHandleNewAliasByNode(state, newFunc); + + if (state.segments.length === 1 && state.segments[0].fake) { + emptySegments(state); + } + + if (!newFunc.params.length && newFunc.added) { + handleTargetChanged(state); + } + + if (newFunc.def.name === 'seriesByTag') { + await parseTarget(state); + } + } + if (actions.removeFunction.match(action)) { + state.queryModel.removeFunction(action.payload.func); + handleTargetChanged(state); + } + if (actions.moveFunction.match(action)) { + const { func, offset } = action.payload; + state.queryModel.moveFunction(func, offset); + handleTargetChanged(state); + } + if (actions.updateFunctionParam.match(action)) { + handleTargetChanged(state); + } + if (actions.updateQuery.match(action)) { + state.target.target = action.payload.query; + handleTargetChanged(state); + // handleTargetChanged() builds target from segments/tags/functions only, + // it doesn't handle refresh when target is change explicitly + state.panelCtrl.refresh(); + } + if (actions.toggleEditorMode.match(action)) { + state.target.textEditor = !state.target.textEditor; + await parseTarget(state); + } + + return { ...state }; +}; + +export const createStore = ( + onChange: (state: GraphiteQueryEditorState) => void +): [GraphiteActionDispatcher, GraphiteQueryEditorState] => { + let state = {} as GraphiteQueryEditorState; + + const dispatch = async (action: Action) => { + state = await reducer(action, state); + onChange(state); + }; + + return [dispatch, state]; +}; diff --git a/public/app/plugins/datasource/graphite/types.ts b/public/app/plugins/datasource/graphite/types.ts index 6c75a86034f..66774016e2e 100644 --- a/public/app/plugins/datasource/graphite/types.ts +++ b/public/app/plugins/datasource/graphite/types.ts @@ -1,4 +1,6 @@ import { DataQuery, DataSourceJsonData } from '@grafana/data'; +import { GraphiteDatasource } from './datasource'; +import { TemplateSrv } from '../../../features/templating/template_srv'; export interface GraphiteQuery extends DataQuery { target?: string; @@ -53,3 +55,34 @@ export type GraphiteMetricLokiMatcher = { value: string; labelName?: string; }; + +export type GraphiteSegment = { + value: string; + type?: 'tag' | 'metric' | 'series-ref'; + expandable?: boolean; + focus?: boolean; + fake?: boolean; +}; + +export type GraphiteTagOperator = '=' | '!=' | '=~' | '!=~'; + +export type GraphiteTag = { + key: string; + operator: GraphiteTagOperator; + value: string; +}; + +export type GraphiteActionDispatcher = (action: any) => Promise; + +export type GraphiteQueryEditorAngularDependencies = { + panelCtrl: any; + target: any; + datasource: GraphiteDatasource; + uiSegmentSrv: any; + templateSrv: TemplateSrv; +}; + +export type AngularDropdownOptions = { + text: string; + value: string; +};