mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge pull request #12846 from grafana/davkal/explore-rules-expansion
Explore: expand recording rules for queries
This commit is contained in:
commit
b9f4666821
@ -169,6 +169,10 @@ export class Explore extends React.Component<any, IExploreState> {
|
|||||||
const historyKey = `grafana.explore.history.${datasourceId}`;
|
const historyKey = `grafana.explore.history.${datasourceId}`;
|
||||||
const history = store.getObject(historyKey, []);
|
const history = store.getObject(historyKey, []);
|
||||||
|
|
||||||
|
if (datasource.init) {
|
||||||
|
datasource.init();
|
||||||
|
}
|
||||||
|
|
||||||
this.setState(
|
this.setState(
|
||||||
{
|
{
|
||||||
datasource,
|
datasource,
|
||||||
|
@ -3,7 +3,7 @@ import Enzyme, { shallow } from 'enzyme';
|
|||||||
import Adapter from 'enzyme-adapter-react-16';
|
import Adapter from 'enzyme-adapter-react-16';
|
||||||
import Plain from 'slate-plain-serializer';
|
import Plain from 'slate-plain-serializer';
|
||||||
|
|
||||||
import PromQueryField from './PromQueryField';
|
import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
Enzyme.configure({ adapter: new Adapter() });
|
||||||
|
|
||||||
@ -177,3 +177,43 @@ describe('PromQueryField typeahead handling', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('groupMetricsByPrefix()', () => {
|
||||||
|
it('returns an empty group for no metrics', () => {
|
||||||
|
expect(groupMetricsByPrefix([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns options grouped by prefix', () => {
|
||||||
|
expect(groupMetricsByPrefix(['foo_metric'])).toMatchObject([
|
||||||
|
{
|
||||||
|
value: 'foo',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
value: 'foo_metric',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns options without prefix as toplevel option', () => {
|
||||||
|
expect(groupMetricsByPrefix(['metric'])).toMatchObject([
|
||||||
|
{
|
||||||
|
value: 'metric',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns recording rules grouped separately', () => {
|
||||||
|
expect(groupMetricsByPrefix([':foo_metric:'])).toMatchObject([
|
||||||
|
{
|
||||||
|
value: RECORDING_RULES_GROUP,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
value: ':foo_metric:',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -28,6 +28,7 @@ const HISTORY_ITEM_COUNT = 5;
|
|||||||
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
|
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
|
||||||
const METRIC_MARK = 'metric';
|
const METRIC_MARK = 'metric';
|
||||||
const PRISM_LANGUAGE = 'promql';
|
const PRISM_LANGUAGE = 'promql';
|
||||||
|
export const RECORDING_RULES_GROUP = '__recording_rules__';
|
||||||
|
|
||||||
export const wrapLabel = (label: string) => ({ label });
|
export const wrapLabel = (label: string) => ({ label });
|
||||||
export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
|
export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
|
||||||
@ -52,7 +53,22 @@ export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
|
export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
|
||||||
return _.chain(metrics)
|
// Filter out recording rules and insert as first option
|
||||||
|
const ruleRegex = /:\w+:/;
|
||||||
|
const ruleNames = metrics.filter(metric => ruleRegex.test(metric));
|
||||||
|
const rulesOption = {
|
||||||
|
label: 'Recording rules',
|
||||||
|
value: RECORDING_RULES_GROUP,
|
||||||
|
children: ruleNames
|
||||||
|
.slice()
|
||||||
|
.sort()
|
||||||
|
.map(name => ({ label: name, value: name })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = ruleNames.length > 0 ? [rulesOption] : [];
|
||||||
|
|
||||||
|
const metricsOptions = _.chain(metrics)
|
||||||
|
.filter(metric => !ruleRegex.test(metric))
|
||||||
.groupBy(metric => metric.split(delimiter)[0])
|
.groupBy(metric => metric.split(delimiter)[0])
|
||||||
.map((metricsForPrefix: string[], prefix: string): CascaderOption => {
|
.map((metricsForPrefix: string[], prefix: string): CascaderOption => {
|
||||||
const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
|
const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
|
||||||
@ -65,6 +81,8 @@ export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): Cascad
|
|||||||
})
|
})
|
||||||
.sortBy('label')
|
.sortBy('label')
|
||||||
.value();
|
.value();
|
||||||
|
|
||||||
|
return [...options, ...metricsOptions];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function willApplySuggestion(
|
export function willApplySuggestion(
|
||||||
|
@ -82,7 +82,7 @@ export function addLabelToQuery(query: string, key: string, value: string): stri
|
|||||||
return parts.join('');
|
return parts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function determineQueryHints(series: any[]): any[] {
|
export function determineQueryHints(series: any[], datasource?: any): any[] {
|
||||||
const hints = series.map((s, i) => {
|
const hints = series.map((s, i) => {
|
||||||
const query: string = s.query;
|
const query: string = s.query;
|
||||||
const index: number = s.responseIndex;
|
const index: number = s.responseIndex;
|
||||||
@ -138,12 +138,56 @@ export function determineQueryHints(series: any[]): any[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for recording rules expansion
|
||||||
|
if (datasource && datasource.ruleMappings) {
|
||||||
|
const mapping = datasource.ruleMappings;
|
||||||
|
const mappingForQuery = Object.keys(mapping).reduce((acc, ruleName) => {
|
||||||
|
if (query.search(ruleName) > -1) {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[ruleName]: mapping[ruleName],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
if (_.size(mappingForQuery) > 0) {
|
||||||
|
const label = 'Query contains recording rules.';
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
index,
|
||||||
|
fix: {
|
||||||
|
label: 'Expand rules',
|
||||||
|
action: {
|
||||||
|
type: 'EXPAND_RULES',
|
||||||
|
query,
|
||||||
|
index,
|
||||||
|
mapping: mappingForQuery,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// No hint found
|
// No hint found
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
return hints;
|
return hints;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractRuleMappingFromGroups(groups: any[]) {
|
||||||
|
return groups.reduce(
|
||||||
|
(mapping, group) =>
|
||||||
|
group.rules.filter(rule => rule.type === 'recording').reduce(
|
||||||
|
(acc, rule) => ({
|
||||||
|
...acc,
|
||||||
|
[rule.name]: rule.query,
|
||||||
|
}),
|
||||||
|
mapping
|
||||||
|
),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function prometheusRegularEscape(value) {
|
export function prometheusRegularEscape(value) {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
return value.replace(/'/g, "\\\\'");
|
return value.replace(/'/g, "\\\\'");
|
||||||
@ -162,6 +206,7 @@ export class PrometheusDatasource {
|
|||||||
type: string;
|
type: string;
|
||||||
editorSrc: string;
|
editorSrc: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
ruleMappings: { [index: string]: string };
|
||||||
supportsExplore: boolean;
|
supportsExplore: boolean;
|
||||||
supportMetrics: boolean;
|
supportMetrics: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
@ -189,6 +234,11 @@ export class PrometheusDatasource {
|
|||||||
this.queryTimeout = instanceSettings.jsonData.queryTimeout;
|
this.queryTimeout = instanceSettings.jsonData.queryTimeout;
|
||||||
this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
|
this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
|
||||||
this.resultTransformer = new ResultTransformer(templateSrv);
|
this.resultTransformer = new ResultTransformer(templateSrv);
|
||||||
|
this.ruleMappings = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadRules();
|
||||||
}
|
}
|
||||||
|
|
||||||
_request(url, data?, options?: any) {
|
_request(url, data?, options?: any) {
|
||||||
@ -312,7 +362,7 @@ export class PrometheusDatasource {
|
|||||||
result = [...result, ...series];
|
result = [...result, ...series];
|
||||||
|
|
||||||
if (queries[index].hinting) {
|
if (queries[index].hinting) {
|
||||||
const queryHints = determineQueryHints(series);
|
const queryHints = determineQueryHints(series, this);
|
||||||
hints = [...hints, ...queryHints];
|
hints = [...hints, ...queryHints];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -525,6 +575,21 @@ export class PrometheusDatasource {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadRules() {
|
||||||
|
this.metadataRequest('/api/v1/rules')
|
||||||
|
.then(res => res.data || res.json())
|
||||||
|
.then(body => {
|
||||||
|
const groups = _.get(body, ['data', 'groups']);
|
||||||
|
if (groups) {
|
||||||
|
this.ruleMappings = extractRuleMappingFromGroups(groups);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.log('Rules API is experimental. Ignore next error.');
|
||||||
|
console.error(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
modifyQuery(query: string, action: any): string {
|
modifyQuery(query: string, action: any): string {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'ADD_FILTER': {
|
case 'ADD_FILTER': {
|
||||||
@ -536,6 +601,14 @@ export class PrometheusDatasource {
|
|||||||
case 'ADD_RATE': {
|
case 'ADD_RATE': {
|
||||||
return `rate(${query}[5m])`;
|
return `rate(${query}[5m])`;
|
||||||
}
|
}
|
||||||
|
case 'EXPAND_RULES': {
|
||||||
|
const mapping = action.mapping;
|
||||||
|
if (mapping) {
|
||||||
|
const ruleNames = Object.keys(mapping);
|
||||||
|
const rulesRegex = new RegExp(`(\\s|^)(${ruleNames.join('|')})(\\s|$|\\()`, 'ig');
|
||||||
|
return query.replace(rulesRegex, (match, pre, name, post) => mapping[name]);
|
||||||
|
}
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import q from 'q';
|
|||||||
import {
|
import {
|
||||||
alignRange,
|
alignRange,
|
||||||
determineQueryHints,
|
determineQueryHints,
|
||||||
|
extractRuleMappingFromGroups,
|
||||||
PrometheusDatasource,
|
PrometheusDatasource,
|
||||||
prometheusSpecialRegexEscape,
|
prometheusSpecialRegexEscape,
|
||||||
prometheusRegularEscape,
|
prometheusRegularEscape,
|
||||||
@ -229,6 +230,36 @@ describe('PrometheusDatasource', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('extractRuleMappingFromGroups()', () => {
|
||||||
|
it('returns empty mapping for no rule groups', () => {
|
||||||
|
expect(extractRuleMappingFromGroups([])).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a mapping for recording rules only', () => {
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
name: 'HighRequestLatency',
|
||||||
|
query: 'job:request_latency_seconds:mean5m{job="myjob"} > 0.5',
|
||||||
|
type: 'alerting',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'job:http_inprogress_requests:sum',
|
||||||
|
query: 'sum(http_inprogress_requests) by (job)',
|
||||||
|
type: 'recording',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
file: '/rules.yaml',
|
||||||
|
interval: 60,
|
||||||
|
name: 'example',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const mapping = extractRuleMappingFromGroups(groups);
|
||||||
|
expect(mapping).toEqual({ 'job:http_inprogress_requests:sum': 'sum(http_inprogress_requests) by (job)' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Prometheus regular escaping', () => {
|
describe('Prometheus regular escaping', () => {
|
||||||
it('should not escape non-string', () => {
|
it('should not escape non-string', () => {
|
||||||
expect(prometheusRegularEscape(12)).toEqual(12);
|
expect(prometheusRegularEscape(12)).toEqual(12);
|
||||||
|
6
public/vendor/css/rc-cascader.scss
vendored
6
public/vendor/css/rc-cascader.scss
vendored
@ -16,7 +16,7 @@
|
|||||||
}
|
}
|
||||||
.rc-cascader-menus.slide-up-enter,
|
.rc-cascader-menus.slide-up-enter,
|
||||||
.rc-cascader-menus.slide-up-appear {
|
.rc-cascader-menus.slide-up-appear {
|
||||||
animation-duration: .3s;
|
animation-duration: 0.3s;
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@ -24,7 +24,7 @@
|
|||||||
animation-play-state: paused;
|
animation-play-state: paused;
|
||||||
}
|
}
|
||||||
.rc-cascader-menus.slide-up-leave {
|
.rc-cascader-menus.slide-up-leave {
|
||||||
animation-duration: .3s;
|
animation-duration: 0.3s;
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@ -66,7 +66,7 @@
|
|||||||
.rc-cascader-menu-item {
|
.rc-cascader-menu-item {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
line-height: 32px;
|
line-height: 32px;
|
||||||
padding: 0 16px;
|
padding: 0 2.5em 0 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
Loading…
Reference in New Issue
Block a user