Commit b9f46668 by David Committed by GitHub

Merge pull request #12846 from grafana/davkal/explore-rules-expansion

Explore: expand recording rules for queries
parents 1c63f7a6 9d66eeb1
......@@ -169,6 +169,10 @@ export class Explore extends React.Component<any, IExploreState> {
const historyKey = `grafana.explore.history.${datasourceId}`;
const history = store.getObject(historyKey, []);
if (datasource.init) {
datasource.init();
}
this.setState(
{
datasource,
......
......@@ -3,7 +3,7 @@ import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import Plain from 'slate-plain-serializer';
import PromQueryField from './PromQueryField';
import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
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 METRIC_MARK = 'metric';
const PRISM_LANGUAGE = 'promql';
export const RECORDING_RULES_GROUP = '__recording_rules__';
export const wrapLabel = (label: string) => ({ label });
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[] {
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])
.map((metricsForPrefix: string[], prefix: string): CascaderOption => {
const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
......@@ -65,6 +81,8 @@ export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): Cascad
})
.sortBy('label')
.value();
return [...options, ...metricsOptions];
}
export function willApplySuggestion(
......
......@@ -82,7 +82,7 @@ export function addLabelToQuery(query: string, key: string, value: string): stri
return parts.join('');
}
export function determineQueryHints(series: any[]): any[] {
export function determineQueryHints(series: any[], datasource?: any): any[] {
const hints = series.map((s, i) => {
const query: string = s.query;
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
return null;
});
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) {
if (typeof value === 'string') {
return value.replace(/'/g, "\\\\'");
......@@ -162,6 +206,7 @@ export class PrometheusDatasource {
type: string;
editorSrc: string;
name: string;
ruleMappings: { [index: string]: string };
supportsExplore: boolean;
supportMetrics: boolean;
url: string;
......@@ -189,6 +234,11 @@ export class PrometheusDatasource {
this.queryTimeout = instanceSettings.jsonData.queryTimeout;
this.httpMethod = instanceSettings.jsonData.httpMethod || 'GET';
this.resultTransformer = new ResultTransformer(templateSrv);
this.ruleMappings = {};
}
init() {
this.loadRules();
}
_request(url, data?, options?: any) {
......@@ -312,7 +362,7 @@ export class PrometheusDatasource {
result = [...result, ...series];
if (queries[index].hinting) {
const queryHints = determineQueryHints(series);
const queryHints = determineQueryHints(series, this);
hints = [...hints, ...queryHints];
}
});
......@@ -525,6 +575,21 @@ export class PrometheusDatasource {
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 {
switch (action.type) {
case 'ADD_FILTER': {
......@@ -536,6 +601,14 @@ export class PrometheusDatasource {
case 'ADD_RATE': {
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:
return query;
}
......
......@@ -4,6 +4,7 @@ import q from 'q';
import {
alignRange,
determineQueryHints,
extractRuleMappingFromGroups,
PrometheusDatasource,
prometheusSpecialRegexEscape,
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', () => {
it('should not escape non-string', () => {
expect(prometheusRegularEscape(12)).toEqual(12);
......
......@@ -16,7 +16,7 @@
}
.rc-cascader-menus.slide-up-enter,
.rc-cascader-menus.slide-up-appear {
animation-duration: .3s;
animation-duration: 0.3s;
animation-fill-mode: both;
transform-origin: 0 0;
opacity: 0;
......@@ -24,7 +24,7 @@
animation-play-state: paused;
}
.rc-cascader-menus.slide-up-leave {
animation-duration: .3s;
animation-duration: 0.3s;
animation-fill-mode: both;
transform-origin: 0 0;
opacity: 1;
......@@ -66,7 +66,7 @@
.rc-cascader-menu-item {
height: 32px;
line-height: 32px;
padding: 0 16px;
padding: 0 2.5em 0 16px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment