diff --git a/public/app/containers/Explore/Explore.tsx b/public/app/containers/Explore/Explore.tsx index dcee963e2e7..9620ac4f91b 100644 --- a/public/app/containers/Explore/Explore.tsx +++ b/public/app/containers/Explore/Explore.tsx @@ -169,6 +169,10 @@ export class Explore extends React.Component { const historyKey = `grafana.explore.history.${datasourceId}`; const history = store.getObject(historyKey, []); + if (datasource.init) { + datasource.init(); + } + this.setState( { datasource, diff --git a/public/app/containers/Explore/PromQueryField.jest.tsx b/public/app/containers/Explore/PromQueryField.jest.tsx index cd0d940961e..350a529c89e 100644 --- a/public/app/containers/Explore/PromQueryField.jest.tsx +++ b/public/app/containers/Explore/PromQueryField.jest.tsx @@ -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:', + }, + ], + }, + ]); + }); +}); diff --git a/public/app/containers/Explore/PromQueryField.tsx b/public/app/containers/Explore/PromQueryField.tsx index 003f6345f13..1b3ff33971d 100644 --- a/public/app/containers/Explore/PromQueryField.tsx +++ b/public/app/containers/Explore/PromQueryField.tsx @@ -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( diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index ee5cc4375be..ef440ab515d 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -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; } diff --git a/public/app/plugins/datasource/prometheus/specs/datasource.jest.ts b/public/app/plugins/datasource/prometheus/specs/datasource.jest.ts index 6b3418ee6f9..a108909e6e1 100644 --- a/public/app/plugins/datasource/prometheus/specs/datasource.jest.ts +++ b/public/app/plugins/datasource/prometheus/specs/datasource.jest.ts @@ -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); diff --git a/public/vendor/css/rc-cascader.scss b/public/vendor/css/rc-cascader.scss index 5cfaaf4961a..f6e55c62d23 100644 --- a/public/vendor/css/rc-cascader.scss +++ b/public/vendor/css/rc-cascader.scss @@ -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;