Graphite: Migrate to React (part 1: move state to redux) (#36484)

* Add UMLs

* Add rendered diagrams

* Move QueryCtrl to flux

* Remove redundant param in the reducer

* Use named imports for lodash and fix typing for GraphiteTagOperator

* Add missing async/await

* Extract providers to a separate file

* Clean up async await

* Rename controller functions back to main

* Simplify creating actions

* Re-order controller functions

* Separate helpers from actions

* Rename vars

* Simplify helpers

* Move controller methods to state reducers

* Remove docs (they are added in design doc)

* Move actions.ts to state folder

* Add docs

* Add old methods stubs for easier review

* Check how state dependencies will be mapped

* Rename state to store

* Rename state to store

* Rewrite spec tests for Graphite Query Controller

* Update docs

* Update docs

* Update public/app/plugins/datasource/graphite/state/helpers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/helpers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/helpers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/providers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/providers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/providers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/providers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/providers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Update public/app/plugins/datasource/graphite/state/providers.ts

Co-authored-by: Giordano Ricci <me@giordanoricci.com>

* Add more type definitions

* Load function definitions before parsing the target on initial load

* Change targetChanged to updateQuery to avoid mutating state directly

It's also needed for extra refresh/runQuery execution as handleTargetChanged doesn't handle changing the raw query

* Fix updating query after adding a function

* Simplify updating function params

* Remove redundant awaits

* Use redux Action

* Use more specific type for GraphiteTag

Co-authored-by: Giordano Ricci <me@giordanoricci.com>
pull/36797/head^2
Piotr Jamróz 4 years ago committed by GitHub
parent 71a9f7ce3c
commit dbe7d3298d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      public/app/plugins/datasource/graphite/add_graphite_func.ts
  2. 12
      public/app/plugins/datasource/graphite/func_editor.ts
  3. 7
      public/app/plugins/datasource/graphite/gfunc.ts
  4. 28
      public/app/plugins/datasource/graphite/graphite_query.ts
  5. 21
      public/app/plugins/datasource/graphite/partials/query.editor.html
  6. 485
      public/app/plugins/datasource/graphite/query_ctrl.ts
  7. 250
      public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts
  8. 45
      public/app/plugins/datasource/graphite/state/actions.ts
  9. 213
      public/app/plugins/datasource/graphite/state/helpers.ts
  10. 190
      public/app/plugins/datasource/graphite/state/providers.ts
  11. 173
      public/app/plugins/datasource/graphite/state/store.ts
  12. 33
      public/app/plugins/datasource/graphite/types.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
}

@ -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 === '') {

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

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

@ -1,7 +1,7 @@
<query-editor-row query-ctrl="ctrl" has-text-edit-mode="true">
<div class="gf-form" ng-show="ctrl.target.textEditor">
<input type="text" class="gf-form-input" style="font-family: monospace;" ng-model="ctrl.target.target" spellcheck="false" ng-blur="ctrl.targetTextChanged()"></input>
<div class="gf-form" ng-show="ctrl.state.target.textEditor">
<input type="text" class="gf-form-input" style="font-family: monospace;" ng-value="ctrl.state.target.target" spellcheck="false" ng-blur="ctrl.targetTextChanged($event)"></input>
</div>
<div ng-hide="ctrl.target.textEditor">
@ -10,7 +10,7 @@
<label class="gf-form-label width-6 query-keyword">Series</label>
</div>
<div ng-if="ctrl.queryModel.seriesByTagUsed" ng-repeat="tag in ctrl.queryModel.tags" class="gf-form">
<div ng-if="ctrl.state.queryModel.seriesByTagUsed" ng-repeat="tag in ctrl.state.queryModel.tags" class="gf-form">
<gf-form-dropdown
model="tag.key"
allow-custom="true"
@ -21,7 +21,7 @@
get-options="ctrl.getTags($index, $query)"
on-change="ctrl.tagChanged(tag, $index)"
></gf-form-dropdown>
<gf-form-dropdown
model="tag.operator"
label-mode="true"
@ -39,19 +39,19 @@
placeholder="Tag value"
get-options="ctrl.getTagValues(tag, $index, $query)"
on-change="ctrl.tagChanged(tag, $index)"
/>
<label class="gf-form-label query-keyword" ng-if="ctrl.showDelimiter($index)">AND</label>
></gf-form-dropdown>
<label class="gf-form-label query-keyword" ng-if="$index !== ctrl.state.queryModel.tags.length - 1">AND</label>
</div>
<div ng-if="ctrl.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.addTagSegments" role="menuitem" class="gf-form">
<div ng-if="ctrl.state.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.state.addTagSegments" role="menuitem" class="gf-form">
<metric-segment segment="segment" get-options="ctrl.getTagsAsSegments($query)" on-change="ctrl.addNewTag(segment)" debounce="true" />
</div>
<div ng-if="!ctrl.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.segments" role="menuitem" class="gf-form">
<div ng-if="!ctrl.state.queryModel.seriesByTagUsed" ng-repeat="segment in ctrl.state.segments" role="menuitem" class="gf-form">
<metric-segment segment="segment" get-options="ctrl.getAltSegments($index, $query)" on-change="ctrl.segmentValueChanged(segment, $index)" />
</div>
<div ng-if="ctrl.paused" class="gf-form">
<div ng-if="ctrl.state.paused" class="gf-form">
<a ng-click="ctrl.unpause()" class="gf-form-label query-part"><icon name="'play'"></icon></a>
</div>
@ -65,7 +65,7 @@
<label class="gf-form-label width-6 query-keyword">Functions</label>
</div>
<div ng-repeat="func in ctrl.queryModel.functions" class="gf-form">
<div ng-repeat="func in ctrl.state.queryModel.functions" class="gf-form">
<span graphite-func-editor class="gf-form-label query-part" ng-hide="func.hidden"></span>
</div>
@ -78,5 +78,4 @@
</div>
</div>
</div>
</query-editor-row>

@ -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 <metric-segment> component (view models are MetricSegment objects)
// It will be simplified to produce data needed by React <SegmentAsync/> 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<GraphiteSegment[]> {
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<HTMLInputElement>) {
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 <gf-form-dropdown>
*/
async getTags(index: number, query: string): Promise<AngularDropdownOptions[]> {
return await getTags(this.state, index, query);
}
/**
* Get tag list when adding a new tag with <metric-segment>
*/
async getTagsAsSegments(query: string): Promise<GraphiteSegment[]> {
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<AngularDropdownOptions[]> {
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 {}

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

@ -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<GraphiteQueryEditorAngularDependencies>('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,
};

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

@ -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<GraphiteSegment[]> {
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<AngularDropdownOptions[]> {
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<GraphiteSegment[]> {
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<AngularDropdownOptions[]> {
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<GraphiteSegment[]> {
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 };
});
}

@ -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<GraphiteQueryEditorState> => {
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];
};

@ -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<void>;
export type GraphiteQueryEditorAngularDependencies = {
panelCtrl: any;
target: any;
datasource: GraphiteDatasource;
uiSegmentSrv: any;
templateSrv: TemplateSrv;
};
export type AngularDropdownOptions = {
text: string;
value: string;
};

Loading…
Cancel
Save