PromQueryField.tsx 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. import _ from 'lodash';
  2. import React from 'react';
  3. // dom also includes Element polyfills
  4. import { getNextCharacter, getPreviousCousin } from './utils/dom';
  5. import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
  6. import PrismPromql, { FUNCTIONS } from './slate-plugins/prism/promql';
  7. import RunnerPlugin from './slate-plugins/runner';
  8. import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
  9. import TypeaheadField, {
  10. Suggestion,
  11. SuggestionGroup,
  12. TypeaheadInput,
  13. TypeaheadFieldState,
  14. TypeaheadOutput,
  15. } from './QueryField';
  16. const EMPTY_METRIC = '';
  17. const METRIC_MARK = 'metric';
  18. const PRISM_LANGUAGE = 'promql';
  19. export const wrapLabel = label => ({ label });
  20. export const setFunctionMove = (suggestion: Suggestion): Suggestion => {
  21. suggestion.move = -1;
  22. return suggestion;
  23. };
  24. export function willApplySuggestion(
  25. suggestion: string,
  26. { typeaheadContext, typeaheadText }: TypeaheadFieldState
  27. ): string {
  28. // Modify suggestion based on context
  29. switch (typeaheadContext) {
  30. case 'context-labels': {
  31. const nextChar = getNextCharacter();
  32. if (!nextChar || nextChar === '}' || nextChar === ',') {
  33. suggestion += '=';
  34. }
  35. break;
  36. }
  37. case 'context-label-values': {
  38. // Always add quotes and remove existing ones instead
  39. if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
  40. suggestion = `"${suggestion}`;
  41. }
  42. if (getNextCharacter() !== '"') {
  43. suggestion = `${suggestion}"`;
  44. }
  45. break;
  46. }
  47. default:
  48. }
  49. return suggestion;
  50. }
  51. interface PromQueryFieldProps {
  52. initialQuery?: string | null;
  53. labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
  54. labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
  55. metrics?: string[];
  56. onPressEnter?: () => void;
  57. onQueryChange?: (value: string) => void;
  58. portalPrefix?: string;
  59. request?: (url: string) => any;
  60. }
  61. interface PromQueryFieldState {
  62. labelKeys: { [index: string]: string[] }; // metric -> [labelKey,...]
  63. labelValues: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
  64. metrics: string[];
  65. }
  66. interface PromTypeaheadInput {
  67. text: string;
  68. prefix: string;
  69. wrapperClasses: string[];
  70. metric?: string;
  71. labelKey?: string;
  72. }
  73. class PromQueryField extends React.Component<PromQueryFieldProps, PromQueryFieldState> {
  74. plugins: any[];
  75. constructor(props, context) {
  76. super(props, context);
  77. this.plugins = [
  78. RunnerPlugin({ handler: props.onPressEnter }),
  79. PluginPrism({ definition: PrismPromql, language: PRISM_LANGUAGE }),
  80. ];
  81. this.state = {
  82. labelKeys: props.labelKeys || {},
  83. labelValues: props.labelValues || {},
  84. metrics: props.metrics || [],
  85. };
  86. }
  87. componentDidMount() {
  88. this.fetchMetricNames();
  89. }
  90. onChangeQuery = value => {
  91. // Send text change to parent
  92. const { onQueryChange } = this.props;
  93. if (onQueryChange) {
  94. onQueryChange(value);
  95. }
  96. };
  97. onReceiveMetrics = () => {
  98. if (!this.state.metrics) {
  99. return;
  100. }
  101. setPrismTokens(PRISM_LANGUAGE, METRIC_MARK, this.state.metrics);
  102. };
  103. onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
  104. const { editorNode, prefix, text, wrapperNode } = typeahead;
  105. // Get DOM-dependent context
  106. const wrapperClasses = Array.from(wrapperNode.classList);
  107. // Take first metric as lucky guess
  108. const metricNode = editorNode.querySelector(`.${METRIC_MARK}`);
  109. const metric = metricNode && metricNode.textContent;
  110. const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
  111. const labelKey = labelKeyNode && labelKeyNode.textContent;
  112. const result = this.getTypeahead({ text, prefix, wrapperClasses, metric, labelKey });
  113. console.log('handleTypeahead', wrapperClasses, text, prefix, result.context);
  114. return result;
  115. };
  116. // Keep this DOM-free for testing
  117. getTypeahead({ prefix, wrapperClasses, metric, text }: PromTypeaheadInput): TypeaheadOutput {
  118. // Determine candidates by CSS context
  119. if (_.includes(wrapperClasses, 'context-range')) {
  120. // Suggestions for metric[|]
  121. return this.getRangeTypeahead();
  122. } else if (_.includes(wrapperClasses, 'context-labels')) {
  123. // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
  124. return this.getLabelTypeahead.apply(this, arguments);
  125. } else if (metric && _.includes(wrapperClasses, 'context-aggregation')) {
  126. return this.getAggregationTypeahead.apply(this, arguments);
  127. } else if (
  128. // Non-empty but not inside known token unless it's a metric
  129. (prefix && !_.includes(wrapperClasses, 'token')) ||
  130. prefix === metric ||
  131. (prefix === '' && !text.match(/^[)\s]+$/)) || // Empty context or after ')'
  132. text.match(/[+\-*/^%]/) // After binary operator
  133. ) {
  134. return this.getEmptyTypeahead();
  135. }
  136. return {
  137. suggestions: [],
  138. };
  139. }
  140. getEmptyTypeahead(): TypeaheadOutput {
  141. const suggestions: SuggestionGroup[] = [];
  142. suggestions.push({
  143. prefixMatch: true,
  144. label: 'Functions',
  145. items: FUNCTIONS.map(setFunctionMove),
  146. });
  147. if (this.state.metrics) {
  148. suggestions.push({
  149. label: 'Metrics',
  150. items: this.state.metrics.map(wrapLabel),
  151. });
  152. }
  153. return { suggestions };
  154. }
  155. getRangeTypeahead(): TypeaheadOutput {
  156. return {
  157. context: 'context-range',
  158. suggestions: [
  159. {
  160. label: 'Range vector',
  161. items: [...RATE_RANGES].map(wrapLabel),
  162. },
  163. ],
  164. };
  165. }
  166. getAggregationTypeahead({ metric }: PromTypeaheadInput): TypeaheadOutput {
  167. let refresher: Promise<any> = null;
  168. const suggestions: SuggestionGroup[] = [];
  169. const labelKeys = this.state.labelKeys[metric];
  170. if (labelKeys) {
  171. suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
  172. } else {
  173. refresher = this.fetchMetricLabels(metric);
  174. }
  175. return {
  176. refresher,
  177. suggestions,
  178. context: 'context-aggregation',
  179. };
  180. }
  181. getLabelTypeahead({ metric, text, wrapperClasses, labelKey }: PromTypeaheadInput): TypeaheadOutput {
  182. let context: string;
  183. let refresher: Promise<any> = null;
  184. const suggestions: SuggestionGroup[] = [];
  185. if (metric) {
  186. const labelKeys = this.state.labelKeys[metric];
  187. if (labelKeys) {
  188. if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
  189. // Label values
  190. if (labelKey) {
  191. const labelValues = this.state.labelValues[metric][labelKey];
  192. context = 'context-label-values';
  193. suggestions.push({
  194. label: 'Label values',
  195. items: labelValues.map(wrapLabel),
  196. });
  197. }
  198. } else {
  199. // Label keys
  200. context = 'context-labels';
  201. suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
  202. }
  203. } else {
  204. refresher = this.fetchMetricLabels(metric);
  205. }
  206. } else {
  207. // Metric-independent label queries
  208. const defaultKeys = ['job', 'instance'];
  209. // Munge all keys that we have seen together
  210. const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
  211. return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
  212. }, defaultKeys);
  213. if ((text && text.startsWith('=')) || _.includes(wrapperClasses, 'attr-value')) {
  214. // Label values
  215. if (labelKey) {
  216. if (this.state.labelValues[EMPTY_METRIC]) {
  217. const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
  218. context = 'context-label-values';
  219. suggestions.push({
  220. label: 'Label values',
  221. items: labelValues.map(wrapLabel),
  222. });
  223. } else {
  224. // Can only query label values for now (API to query keys is under development)
  225. refresher = this.fetchLabelValues(labelKey);
  226. }
  227. }
  228. } else {
  229. // Label keys
  230. context = 'context-labels';
  231. suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
  232. }
  233. }
  234. return { context, refresher, suggestions };
  235. }
  236. request = url => {
  237. if (this.props.request) {
  238. return this.props.request(url);
  239. }
  240. return fetch(url);
  241. };
  242. async fetchLabelValues(key) {
  243. const url = `/api/v1/label/${key}/values`;
  244. try {
  245. const res = await this.request(url);
  246. const body = await (res.data || res.json());
  247. const pairs = this.state.labelValues[EMPTY_METRIC];
  248. const values = {
  249. ...pairs,
  250. [key]: body.data,
  251. };
  252. const labelValues = {
  253. ...this.state.labelValues,
  254. [EMPTY_METRIC]: values,
  255. };
  256. this.setState({ labelValues });
  257. } catch (e) {
  258. console.error(e);
  259. }
  260. }
  261. async fetchMetricLabels(name) {
  262. const url = `/api/v1/series?match[]=${name}`;
  263. try {
  264. const res = await this.request(url);
  265. const body = await (res.data || res.json());
  266. const { keys, values } = processLabels(body.data);
  267. const labelKeys = {
  268. ...this.state.labelKeys,
  269. [name]: keys,
  270. };
  271. const labelValues = {
  272. ...this.state.labelValues,
  273. [name]: values,
  274. };
  275. this.setState({ labelKeys, labelValues });
  276. } catch (e) {
  277. console.error(e);
  278. }
  279. }
  280. async fetchMetricNames() {
  281. const url = '/api/v1/label/__name__/values';
  282. try {
  283. const res = await this.request(url);
  284. const body = await (res.data || res.json());
  285. this.setState({ metrics: body.data }, this.onReceiveMetrics);
  286. } catch (error) {
  287. console.error(error);
  288. }
  289. }
  290. render() {
  291. return (
  292. <TypeaheadField
  293. additionalPlugins={this.plugins}
  294. cleanText={cleanText}
  295. initialValue={this.props.initialQuery}
  296. onTypeahead={this.onTypeahead}
  297. onWillApplySuggestion={willApplySuggestion}
  298. onValueChanged={this.onChangeQuery}
  299. placeholder="Enter a PromQL query"
  300. />
  301. );
  302. }
  303. }
  304. export default PromQueryField;