PromQueryField.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. import _ from 'lodash';
  2. import moment from 'moment';
  3. import React from 'react';
  4. import { Value } from 'slate';
  5. import Cascader from 'rc-cascader';
  6. import PluginPrism from 'slate-prism';
  7. import Prism from 'prismjs';
  8. // dom also includes Element polyfills
  9. import { getNextCharacter, getPreviousCousin } from './utils/dom';
  10. import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
  11. import BracesPlugin from './slate-plugins/braces';
  12. import RunnerPlugin from './slate-plugins/runner';
  13. import { processLabels, RATE_RANGES, cleanText, parseSelector } from './utils/prometheus';
  14. import TypeaheadField, {
  15. Suggestion,
  16. SuggestionGroup,
  17. TypeaheadInput,
  18. TypeaheadFieldState,
  19. TypeaheadOutput,
  20. } from './QueryField';
  21. const DEFAULT_KEYS = ['job', 'instance'];
  22. const EMPTY_SELECTOR = '{}';
  23. const HISTOGRAM_GROUP = '__histograms__';
  24. const HISTOGRAM_SELECTOR = '{le!=""}'; // Returns all timeseries for histograms
  25. const HISTORY_ITEM_COUNT = 5;
  26. const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
  27. const METRIC_MARK = 'metric';
  28. const PRISM_SYNTAX = 'promql';
  29. export const RECORDING_RULES_GROUP = '__recording_rules__';
  30. export const wrapLabel = (label: string) => ({ label });
  31. export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
  32. suggestion.move = -1;
  33. return suggestion;
  34. };
  35. // Syntax highlighting
  36. Prism.languages[PRISM_SYNTAX] = PrismPromql;
  37. function setPrismTokens(language, field, values, alias = 'variable') {
  38. Prism.languages[language][field] = {
  39. alias,
  40. pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`),
  41. };
  42. }
  43. export function addHistoryMetadata(item: Suggestion, history: any[]): Suggestion {
  44. const cutoffTs = Date.now() - HISTORY_COUNT_CUTOFF;
  45. const historyForItem = history.filter(h => h.ts > cutoffTs && h.query === item.label);
  46. const count = historyForItem.length;
  47. const recent = historyForItem[0];
  48. let hint = `Queried ${count} times in the last 24h.`;
  49. if (recent) {
  50. const lastQueried = moment(recent.ts).fromNow();
  51. hint = `${hint} Last queried ${lastQueried}.`;
  52. }
  53. return {
  54. ...item,
  55. documentation: hint,
  56. };
  57. }
  58. export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): CascaderOption[] {
  59. // Filter out recording rules and insert as first option
  60. const ruleRegex = /:\w+:/;
  61. const ruleNames = metrics.filter(metric => ruleRegex.test(metric));
  62. const rulesOption = {
  63. label: 'Recording rules',
  64. value: RECORDING_RULES_GROUP,
  65. children: ruleNames
  66. .slice()
  67. .sort()
  68. .map(name => ({ label: name, value: name })),
  69. };
  70. const options = ruleNames.length > 0 ? [rulesOption] : [];
  71. const metricsOptions = _.chain(metrics)
  72. .filter(metric => !ruleRegex.test(metric))
  73. .groupBy(metric => metric.split(delimiter)[0])
  74. .map((metricsForPrefix: string[], prefix: string): CascaderOption => {
  75. const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
  76. const children = prefixIsMetric ? [] : metricsForPrefix.sort().map(m => ({ label: m, value: m }));
  77. return {
  78. children,
  79. label: prefix,
  80. value: prefix,
  81. };
  82. })
  83. .sortBy('label')
  84. .value();
  85. return [...options, ...metricsOptions];
  86. }
  87. export function willApplySuggestion(
  88. suggestion: string,
  89. { typeaheadContext, typeaheadText }: TypeaheadFieldState
  90. ): string {
  91. // Modify suggestion based on context
  92. switch (typeaheadContext) {
  93. case 'context-labels': {
  94. const nextChar = getNextCharacter();
  95. if (!nextChar || nextChar === '}' || nextChar === ',') {
  96. suggestion += '=';
  97. }
  98. break;
  99. }
  100. case 'context-label-values': {
  101. // Always add quotes and remove existing ones instead
  102. if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
  103. suggestion = `"${suggestion}`;
  104. }
  105. if (getNextCharacter() !== '"') {
  106. suggestion = `${suggestion}"`;
  107. }
  108. break;
  109. }
  110. default:
  111. }
  112. return suggestion;
  113. }
  114. interface CascaderOption {
  115. label: string;
  116. value: string;
  117. children?: CascaderOption[];
  118. disabled?: boolean;
  119. }
  120. interface PromQueryFieldProps {
  121. error?: string;
  122. hint?: any;
  123. histogramMetrics?: string[];
  124. history?: any[];
  125. initialQuery?: string | null;
  126. labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
  127. labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
  128. metrics?: string[];
  129. metricsByPrefix?: CascaderOption[];
  130. onClickHintFix?: (action: any) => void;
  131. onPressEnter?: () => void;
  132. onQueryChange?: (value: string, override?: boolean) => void;
  133. portalPrefix?: string;
  134. request?: (url: string) => any;
  135. supportsLogs?: boolean; // To be removed after Logging gets its own query field
  136. }
  137. interface PromQueryFieldState {
  138. histogramMetrics: string[];
  139. labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
  140. labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
  141. logLabelOptions: any[];
  142. metrics: string[];
  143. metricsOptions: any[];
  144. metricsByPrefix: CascaderOption[];
  145. }
  146. interface PromTypeaheadInput {
  147. text: string;
  148. prefix: string;
  149. wrapperClasses: string[];
  150. labelKey?: string;
  151. value?: Value;
  152. }
  153. class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
  154. plugins: any[];
  155. constructor(props: PromQueryFieldProps, context) {
  156. super(props, context);
  157. this.plugins = [
  158. BracesPlugin(),
  159. RunnerPlugin({ handler: props.onPressEnter }),
  160. PluginPrism({
  161. onlyIn: node => node.type === 'code_block',
  162. getSyntax: node => 'promql',
  163. }),
  164. ];
  165. this.state = {
  166. histogramMetrics: props.histogramMetrics || [],
  167. labelKeys: props.labelKeys || {},
  168. labelValues: props.labelValues || {},
  169. logLabelOptions: [],
  170. metrics: props.metrics || [],
  171. metricsByPrefix: props.metricsByPrefix || [],
  172. metricsOptions: [],
  173. };
  174. }
  175. componentDidMount() {
  176. // Temporarily reused by logging
  177. const { supportsLogs } = this.props;
  178. if (supportsLogs) {
  179. this.fetchLogLabels();
  180. } else {
  181. // Usual actions
  182. this.fetchMetricNames();
  183. this.fetchHistogramMetrics();
  184. }
  185. }
  186. onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => {
  187. let query;
  188. if (selectedOptions.length === 1) {
  189. if (selectedOptions[0].children.length === 0) {
  190. query = selectedOptions[0].value;
  191. } else {
  192. // Ignore click on group
  193. return;
  194. }
  195. } else {
  196. const key = selectedOptions[0].value;
  197. const value = selectedOptions[1].value;
  198. query = `{${key}="${value}"}`;
  199. }
  200. this.onChangeQuery(query, true);
  201. };
  202. onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
  203. let query;
  204. if (selectedOptions.length === 1) {
  205. if (selectedOptions[0].children.length === 0) {
  206. query = selectedOptions[0].value;
  207. } else {
  208. // Ignore click on group
  209. return;
  210. }
  211. } else {
  212. const prefix = selectedOptions[0].value;
  213. const metric = selectedOptions[1].value;
  214. if (prefix === HISTOGRAM_GROUP) {
  215. query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;
  216. } else {
  217. query = metric;
  218. }
  219. }
  220. this.onChangeQuery(query, true);
  221. };
  222. onChangeQuery = (value: string, override?: boolean) => {
  223. // Send text change to parent
  224. const { onQueryChange } = this.props;
  225. if (onQueryChange) {
  226. onQueryChange(value, override);
  227. }
  228. };
  229. onClickHintFix = () => {
  230. const { hint, onClickHintFix } = this.props;
  231. if (onClickHintFix && hint && hint.fix) {
  232. onClickHintFix(hint.fix.action);
  233. }
  234. };
  235. onReceiveMetrics = () => {
  236. const { histogramMetrics, metrics, metricsByPrefix } = this.state;
  237. if (!metrics) {
  238. return;
  239. }
  240. // Update global prism config
  241. setPrismTokens(PRISM_SYNTAX, METRIC_MARK, this.state.metrics);
  242. // Build metrics tree
  243. const histogramOptions = histogramMetrics.map(hm => ({ label: hm, value: hm }));
  244. const metricsOptions = [
  245. { label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions },
  246. ...metricsByPrefix,
  247. ];
  248. this.setState({ metricsOptions });
  249. };
  250. onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
  251. const { prefix, text, value, wrapperNode } = typeahead;
  252. // Get DOM-dependent context
  253. const wrapperClasses = Array.from(wrapperNode.classList);
  254. const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
  255. const labelKey = labelKeyNode && labelKeyNode.textContent;
  256. const nextChar = getNextCharacter();
  257. const result = this.getTypeahead({ text, value, prefix, wrapperClasses, labelKey });
  258. console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
  259. return result;
  260. };
  261. // Keep this DOM-free for testing
  262. getTypeahead({ prefix, wrapperClasses, text }: PromTypeaheadInput): TypeaheadOutput {
  263. // Syntax spans have 3 classes by default. More indicate a recognized token
  264. const tokenRecognized = wrapperClasses.length > 3;
  265. // Determine candidates by CSS context
  266. if (_.includes(wrapperClasses, 'context-range')) {
  267. // Suggestions for metric[|]
  268. return this.getRangeTypeahead();
  269. } else if (_.includes(wrapperClasses, 'context-labels')) {
  270. // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
  271. return this.getLabelTypeahead.apply(this, arguments);
  272. } else if (_.includes(wrapperClasses, 'context-aggregation')) {
  273. return this.getAggregationTypeahead.apply(this, arguments);
  274. } else if (
  275. // Show default suggestions in a couple of scenarios
  276. (prefix && !tokenRecognized) || // Non-empty prefix, but not inside known token
  277. (prefix === '' && !text.match(/^[\]})\s]+$/)) || // Empty prefix, but not following a closing brace
  278. text.match(/[+\-*/^%]/) // Anything after binary operator
  279. ) {
  280. return this.getEmptyTypeahead();
  281. }
  282. return {
  283. suggestions: [],
  284. };
  285. }
  286. getEmptyTypeahead(): TypeaheadOutput {
  287. const { history } = this.props;
  288. const { metrics } = this.state;
  289. const suggestions: SuggestionGroup[] = [];
  290. if (history && history.length > 0) {
  291. const historyItems = _.chain(history)
  292. .uniqBy('query')
  293. .take(HISTORY_ITEM_COUNT)
  294. .map(h => h.query)
  295. .map(wrapLabel)
  296. .map(item => addHistoryMetadata(item, history))
  297. .value();
  298. suggestions.push({
  299. prefixMatch: true,
  300. skipSort: true,
  301. label: 'History',
  302. items: historyItems,
  303. });
  304. }
  305. suggestions.push({
  306. prefixMatch: true,
  307. label: 'Functions',
  308. items: FUNCTIONS.map(setFunctionMove),
  309. });
  310. if (metrics) {
  311. suggestions.push({
  312. label: 'Metrics',
  313. items: metrics.map(wrapLabel),
  314. });
  315. }
  316. return { suggestions };
  317. }
  318. getRangeTypeahead(): TypeaheadOutput {
  319. return {
  320. context: 'context-range',
  321. suggestions: [
  322. {
  323. label: 'Range vector',
  324. items: [...RATE_RANGES].map(wrapLabel),
  325. },
  326. ],
  327. };
  328. }
  329. getAggregationTypeahead({ value }: PromTypeaheadInput): TypeaheadOutput {
  330. let refresher: Promise<any> = null;
  331. const suggestions: SuggestionGroup[] = [];
  332. // sum(foo{bar="1"}) by (|)
  333. const line = value.anchorBlock.getText();
  334. const cursorOffset: number = value.anchorOffset;
  335. // sum(foo{bar="1"}) by (
  336. const leftSide = line.slice(0, cursorOffset);
  337. const openParensAggregationIndex = leftSide.lastIndexOf('(');
  338. const openParensSelectorIndex = leftSide.slice(0, openParensAggregationIndex).lastIndexOf('(');
  339. const closeParensSelectorIndex = leftSide.slice(openParensSelectorIndex).indexOf(')') + openParensSelectorIndex;
  340. // foo{bar="1"}
  341. const selectorString = leftSide.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
  342. const selector = parseSelector(selectorString, selectorString.length - 2).selector;
  343. const labelKeys = this.state.labelKeys[selector];
  344. if (labelKeys) {
  345. suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
  346. } else {
  347. refresher = this.fetchSeriesLabels(selector);
  348. }
  349. return {
  350. refresher,
  351. suggestions,
  352. context: 'context-aggregation',
  353. };
  354. }
  355. getLabelTypeahead({ text, wrapperClasses, labelKey, value }: PromTypeaheadInput): TypeaheadOutput {
  356. let context: string;
  357. let refresher: Promise<any> = null;
  358. const suggestions: SuggestionGroup[] = [];
  359. const line = value.anchorBlock.getText();
  360. const cursorOffset: number = value.anchorOffset;
  361. // Get normalized selector
  362. let selector;
  363. let parsedSelector;
  364. try {
  365. parsedSelector = parseSelector(line, cursorOffset);
  366. selector = parsedSelector.selector;
  367. } catch {
  368. selector = EMPTY_SELECTOR;
  369. }
  370. const containsMetric = selector.indexOf('__name__=') > -1;
  371. const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
  372. if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
  373. // Label values
  374. if (labelKey && this.state.labelValues[selector] && this.state.labelValues[selector][labelKey]) {
  375. const labelValues = this.state.labelValues[selector][labelKey];
  376. context = 'context-label-values';
  377. suggestions.push({
  378. label: `Label values for "${labelKey}"`,
  379. items: labelValues.map(wrapLabel),
  380. });
  381. }
  382. } else {
  383. // Label keys
  384. const labelKeys = this.state.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
  385. if (labelKeys) {
  386. const possibleKeys = _.difference(labelKeys, existingKeys);
  387. if (possibleKeys.length > 0) {
  388. context = 'context-labels';
  389. suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
  390. }
  391. }
  392. }
  393. // Query labels for selector
  394. // Temporarily add skip for logging
  395. if (selector && !this.state.labelValues[selector] && !this.props.supportsLogs) {
  396. if (selector === EMPTY_SELECTOR) {
  397. // Query label values for default labels
  398. refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
  399. } else {
  400. refresher = this.fetchSeriesLabels(selector, !containsMetric);
  401. }
  402. }
  403. return { context, refresher, suggestions };
  404. }
  405. request = url => {
  406. if (this.props.request) {
  407. return this.props.request(url);
  408. }
  409. return fetch(url);
  410. };
  411. fetchHistogramMetrics() {
  412. this.fetchSeriesLabels(HISTOGRAM_SELECTOR, true, () => {
  413. const histogramSeries = this.state.labelValues[HISTOGRAM_SELECTOR];
  414. if (histogramSeries && histogramSeries['__name__']) {
  415. const histogramMetrics = histogramSeries['__name__'].slice().sort();
  416. this.setState({ histogramMetrics }, this.onReceiveMetrics);
  417. }
  418. });
  419. }
  420. // Temporarily here while reusing this field for logging
  421. async fetchLogLabels() {
  422. const url = '/api/prom/label';
  423. try {
  424. const res = await this.request(url);
  425. const body = await (res.data || res.json());
  426. const labelKeys = body.data.slice().sort();
  427. const labelKeysBySelector = {
  428. ...this.state.labelKeys,
  429. [EMPTY_SELECTOR]: labelKeys,
  430. };
  431. const labelValuesByKey = {};
  432. const logLabelOptions = [];
  433. for (const key of labelKeys) {
  434. const valuesUrl = `/api/prom/label/${key}/values`;
  435. const res = await this.request(valuesUrl);
  436. const body = await (res.data || res.json());
  437. const values = body.data.slice().sort();
  438. labelValuesByKey[key] = values;
  439. logLabelOptions.push({
  440. label: key,
  441. value: key,
  442. children: values.map(value => ({ label: value, value })),
  443. });
  444. }
  445. const labelValues = { [EMPTY_SELECTOR]: labelValuesByKey };
  446. this.setState({ labelKeys: labelKeysBySelector, labelValues, logLabelOptions });
  447. } catch (e) {
  448. console.error(e);
  449. }
  450. }
  451. async fetchLabelValues(key: string) {
  452. const url = `/api/v1/label/${key}/values`;
  453. try {
  454. const res = await this.request(url);
  455. const body = await (res.data || res.json());
  456. const exisingValues = this.state.labelValues[EMPTY_SELECTOR];
  457. const values = {
  458. ...exisingValues,
  459. [key]: body.data,
  460. };
  461. const labelValues = {
  462. ...this.state.labelValues,
  463. [EMPTY_SELECTOR]: values,
  464. };
  465. this.setState({ labelValues });
  466. } catch (e) {
  467. console.error(e);
  468. }
  469. }
  470. async fetchSeriesLabels(name: string, withName?: boolean, callback?: () => void) {
  471. const url = `/api/v1/series?match[]=${name}`;
  472. try {
  473. const res = await this.request(url);
  474. const body = await (res.data || res.json());
  475. const { keys, values } = processLabels(body.data, withName);
  476. const labelKeys = {
  477. ...this.state.labelKeys,
  478. [name]: keys,
  479. };
  480. const labelValues = {
  481. ...this.state.labelValues,
  482. [name]: values,
  483. };
  484. this.setState({ labelKeys, labelValues }, callback);
  485. } catch (e) {
  486. console.error(e);
  487. }
  488. }
  489. async fetchMetricNames() {
  490. const url = '/api/v1/label/__name__/values';
  491. try {
  492. const res = await this.request(url);
  493. const body = await (res.data || res.json());
  494. const metrics = body.data;
  495. const metricsByPrefix = groupMetricsByPrefix(metrics);
  496. this.setState({ metrics, metricsByPrefix }, this.onReceiveMetrics);
  497. } catch (error) {
  498. console.error(error);
  499. }
  500. }
  501. render() {
  502. const { error, hint, supportsLogs } = this.props;
  503. const { logLabelOptions, metricsOptions } = this.state;
  504. return (
  505. <div className="prom-query-field">
  506. <div className="prom-query-field-tools">
  507. {supportsLogs ? (
  508. <Cascader options={logLabelOptions} onChange={this.onChangeLogLabels}>
  509. <button className="btn navbar-button navbar-button--tight">Log labels</button>
  510. </Cascader>
  511. ) : (
  512. <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
  513. <button className="btn navbar-button navbar-button--tight">Metrics</button>
  514. </Cascader>
  515. )}
  516. </div>
  517. <div className="prom-query-field-wrapper">
  518. <div className="slate-query-field-wrapper">
  519. <TypeaheadField
  520. additionalPlugins={this.plugins}
  521. cleanText={cleanText}
  522. initialValue={this.props.initialQuery}
  523. onTypeahead={this.onTypeahead}
  524. onWillApplySuggestion={willApplySuggestion}
  525. onValueChanged={this.onChangeQuery}
  526. placeholder="Enter a PromQL query"
  527. portalPrefix="prometheus"
  528. />
  529. </div>
  530. {error ? <div className="prom-query-field-info text-error">{error}</div> : null}
  531. {hint ? (
  532. <div className="prom-query-field-info text-warning">
  533. {hint.label}{' '}
  534. {hint.fix ? (
  535. <a className="text-link muted" onClick={this.onClickHintFix}>
  536. {hint.fix.label}
  537. </a>
  538. ) : null}
  539. </div>
  540. ) : null}
  541. </div>
  542. </div>
  543. );
  544. }
  545. }
  546. export default PromQueryField;