PrometheusMetricsBrowser.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. import { css, cx } from '@emotion/css';
  2. import React, { ChangeEvent } from 'react';
  3. import { FixedSizeList } from 'react-window';
  4. import { GrafanaTheme } from '@grafana/data';
  5. import {
  6. Button,
  7. HorizontalGroup,
  8. Input,
  9. Label,
  10. LoadingPlaceholder,
  11. stylesFactory,
  12. withTheme,
  13. BrowserLabel as PromLabel,
  14. } from '@grafana/ui';
  15. import PromQlLanguageProvider from '../language_provider';
  16. import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from '../language_utils';
  17. // Hard limit on labels to render
  18. const EMPTY_SELECTOR = '{}';
  19. const METRIC_LABEL = '__name__';
  20. const LIST_ITEM_SIZE = 25;
  21. export interface BrowserProps {
  22. languageProvider: PromQlLanguageProvider;
  23. onChange: (selector: string) => void;
  24. theme: GrafanaTheme;
  25. autoSelect?: number;
  26. hide?: () => void;
  27. lastUsedLabels: string[];
  28. storeLastUsedLabels: (labels: string[]) => void;
  29. deleteLastUsedLabels: () => void;
  30. }
  31. interface BrowserState {
  32. labels: SelectableLabel[];
  33. labelSearchTerm: string;
  34. metricSearchTerm: string;
  35. status: string;
  36. error: string;
  37. validationStatus: string;
  38. valueSearchTerm: string;
  39. }
  40. interface FacettableValue {
  41. name: string;
  42. selected?: boolean;
  43. details?: string;
  44. }
  45. export interface SelectableLabel {
  46. name: string;
  47. selected?: boolean;
  48. loading?: boolean;
  49. values?: FacettableValue[];
  50. hidden?: boolean;
  51. facets?: number;
  52. }
  53. export function buildSelector(labels: SelectableLabel[]): string {
  54. let singleMetric = '';
  55. const selectedLabels = [];
  56. for (const label of labels) {
  57. if ((label.name === METRIC_LABEL || label.selected) && label.values && label.values.length > 0) {
  58. const selectedValues = label.values.filter((value) => value.selected).map((value) => value.name);
  59. if (selectedValues.length > 1) {
  60. selectedLabels.push(`${label.name}=~"${selectedValues.map(escapeLabelValueInRegexSelector).join('|')}"`);
  61. } else if (selectedValues.length === 1) {
  62. if (label.name === METRIC_LABEL) {
  63. singleMetric = selectedValues[0];
  64. } else {
  65. selectedLabels.push(`${label.name}="${escapeLabelValueInExactSelector(selectedValues[0])}"`);
  66. }
  67. }
  68. }
  69. }
  70. return [singleMetric, '{', selectedLabels.join(','), '}'].join('');
  71. }
  72. export function facetLabels(
  73. labels: SelectableLabel[],
  74. possibleLabels: Record<string, string[]>,
  75. lastFacetted?: string
  76. ): SelectableLabel[] {
  77. return labels.map((label) => {
  78. const possibleValues = possibleLabels[label.name];
  79. if (possibleValues) {
  80. let existingValues: FacettableValue[];
  81. if (label.name === lastFacetted && label.values) {
  82. // Facetting this label, show all values
  83. existingValues = label.values;
  84. } else {
  85. // Keep selection in other facets
  86. const selectedValues: Set<string> = new Set(
  87. label.values?.filter((value) => value.selected).map((value) => value.name) || []
  88. );
  89. // Values for this label have not been requested yet, let's use the facetted ones as the initial values
  90. existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) }));
  91. }
  92. return {
  93. ...label,
  94. loading: false,
  95. values: existingValues,
  96. hidden: !possibleValues,
  97. facets: existingValues.length,
  98. };
  99. }
  100. // Label is facetted out, hide all values
  101. return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 };
  102. });
  103. }
  104. const getStyles = stylesFactory((theme: GrafanaTheme) => ({
  105. wrapper: css`
  106. background-color: ${theme.colors.bg2};
  107. padding: ${theme.spacing.sm};
  108. width: 100%;
  109. `,
  110. list: css`
  111. margin-top: ${theme.spacing.sm};
  112. display: flex;
  113. flex-wrap: wrap;
  114. max-height: 200px;
  115. overflow: auto;
  116. align-content: flex-start;
  117. `,
  118. section: css`
  119. & + & {
  120. margin: ${theme.spacing.md} 0;
  121. }
  122. position: relative;
  123. `,
  124. selector: css`
  125. font-family: ${theme.typography.fontFamily.monospace};
  126. margin-bottom: ${theme.spacing.sm};
  127. `,
  128. status: css`
  129. padding: ${theme.spacing.xs};
  130. color: ${theme.colors.textSemiWeak};
  131. white-space: nowrap;
  132. overflow: hidden;
  133. text-overflow: ellipsis;
  134. /* using absolute positioning because flex interferes with ellipsis */
  135. position: absolute;
  136. width: 50%;
  137. right: 0;
  138. text-align: right;
  139. transition: opacity 100ms linear;
  140. opacity: 0;
  141. `,
  142. statusShowing: css`
  143. opacity: 1;
  144. `,
  145. error: css`
  146. color: ${theme.palette.brandDanger};
  147. `,
  148. valueList: css`
  149. margin-right: ${theme.spacing.sm};
  150. `,
  151. valueListWrapper: css`
  152. border-left: 1px solid ${theme.colors.border2};
  153. margin: ${theme.spacing.sm} 0;
  154. padding: ${theme.spacing.sm} 0 ${theme.spacing.sm} ${theme.spacing.sm};
  155. `,
  156. valueListArea: css`
  157. display: flex;
  158. flex-wrap: wrap;
  159. margin-top: ${theme.spacing.sm};
  160. `,
  161. valueTitle: css`
  162. margin-left: -${theme.spacing.xs};
  163. margin-bottom: ${theme.spacing.sm};
  164. `,
  165. validationStatus: css`
  166. padding: ${theme.spacing.xs};
  167. margin-bottom: ${theme.spacing.sm};
  168. color: ${theme.colors.textStrong};
  169. white-space: nowrap;
  170. overflow: hidden;
  171. text-overflow: ellipsis;
  172. `,
  173. }));
  174. /**
  175. * TODO #33976: Remove duplicated code. The component is very similar to LokiLabelBrowser.tsx. Check if it's possible
  176. * to create a single, generic component.
  177. */
  178. export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserProps, BrowserState> {
  179. valueListsRef = React.createRef<HTMLDivElement>();
  180. state: BrowserState = {
  181. labels: [] as SelectableLabel[],
  182. labelSearchTerm: '',
  183. metricSearchTerm: '',
  184. status: 'Ready',
  185. error: '',
  186. validationStatus: '',
  187. valueSearchTerm: '',
  188. };
  189. onChangeLabelSearch = (event: ChangeEvent<HTMLInputElement>) => {
  190. this.setState({ labelSearchTerm: event.target.value });
  191. };
  192. onChangeMetricSearch = (event: ChangeEvent<HTMLInputElement>) => {
  193. this.setState({ metricSearchTerm: event.target.value });
  194. };
  195. onChangeValueSearch = (event: ChangeEvent<HTMLInputElement>) => {
  196. this.setState({ valueSearchTerm: event.target.value });
  197. };
  198. onClickRunQuery = () => {
  199. const selector = buildSelector(this.state.labels);
  200. this.props.onChange(selector);
  201. };
  202. onClickRunRateQuery = () => {
  203. const selector = buildSelector(this.state.labels);
  204. const query = `rate(${selector}[$__interval])`;
  205. this.props.onChange(query);
  206. };
  207. onClickClear = () => {
  208. this.setState((state) => {
  209. const labels: SelectableLabel[] = state.labels.map((label) => ({
  210. ...label,
  211. values: undefined,
  212. selected: false,
  213. loading: false,
  214. hidden: false,
  215. facets: undefined,
  216. }));
  217. return {
  218. labels,
  219. labelSearchTerm: '',
  220. metricSearchTerm: '',
  221. status: '',
  222. error: '',
  223. validationStatus: '',
  224. valueSearchTerm: '',
  225. };
  226. });
  227. this.props.deleteLastUsedLabels();
  228. // Get metrics
  229. this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
  230. };
  231. onClickLabel = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
  232. const label = this.state.labels.find((l) => l.name === name);
  233. if (!label) {
  234. return;
  235. }
  236. // Toggle selected state
  237. const selected = !label.selected;
  238. let nextValue: Partial<SelectableLabel> = { selected };
  239. if (label.values && !selected) {
  240. // Deselect all values if label was deselected
  241. const values = label.values.map((value) => ({ ...value, selected: false }));
  242. nextValue = { ...nextValue, facets: 0, values };
  243. }
  244. // Resetting search to prevent empty results
  245. this.setState({ labelSearchTerm: '' });
  246. this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name));
  247. };
  248. onClickValue = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
  249. const label = this.state.labels.find((l) => l.name === name);
  250. if (!label || !label.values) {
  251. return;
  252. }
  253. // Resetting search to prevent empty results
  254. this.setState({ labelSearchTerm: '' });
  255. // Toggling value for selected label, leaving other values intact
  256. const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected }));
  257. this.updateLabelState(name, { values }, '', () => this.doFacetting(name));
  258. };
  259. onClickMetric = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
  260. // Finding special metric label
  261. const label = this.state.labels.find((l) => l.name === name);
  262. if (!label || !label.values) {
  263. return;
  264. }
  265. // Resetting search to prevent empty results
  266. this.setState({ metricSearchTerm: '' });
  267. // Toggling value for selected label, leaving other values intact
  268. const values = label.values.map((v) => ({
  269. ...v,
  270. selected: v.name === value || v.selected ? !v.selected : v.selected,
  271. }));
  272. // Toggle selected state of special metrics label
  273. const selected = values.some((v) => v.selected);
  274. this.updateLabelState(name, { selected, values }, '', () => this.doFacetting(name));
  275. };
  276. onClickValidate = () => {
  277. const selector = buildSelector(this.state.labels);
  278. this.validateSelector(selector);
  279. };
  280. updateLabelState(name: string, updatedFields: Partial<SelectableLabel>, status = '', cb?: () => void) {
  281. this.setState((state) => {
  282. const labels: SelectableLabel[] = state.labels.map((label) => {
  283. if (label.name === name) {
  284. return { ...label, ...updatedFields };
  285. }
  286. return label;
  287. });
  288. // New status overrides errors
  289. const error = status ? '' : state.error;
  290. return { labels, status, error, validationStatus: '' };
  291. }, cb);
  292. }
  293. componentDidMount() {
  294. const { languageProvider, lastUsedLabels } = this.props;
  295. if (languageProvider) {
  296. const selectedLabels: string[] = lastUsedLabels;
  297. languageProvider.start().then(() => {
  298. let rawLabels: string[] = languageProvider.getLabelKeys();
  299. // Get metrics
  300. this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
  301. // Auto-select previously selected labels
  302. const labels: SelectableLabel[] = rawLabels.map((label, i, arr) => ({
  303. name: label,
  304. selected: selectedLabels.includes(label),
  305. loading: false,
  306. }));
  307. // Pre-fetch values for selected labels
  308. this.setState({ labels }, () => {
  309. this.state.labels.forEach((label) => {
  310. if (label.selected) {
  311. this.fetchValues(label.name, EMPTY_SELECTOR);
  312. }
  313. });
  314. });
  315. });
  316. }
  317. }
  318. doFacettingForLabel(name: string) {
  319. const label = this.state.labels.find((l) => l.name === name);
  320. if (!label) {
  321. return;
  322. }
  323. const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name);
  324. this.props.storeLastUsedLabels(selectedLabels);
  325. if (label.selected) {
  326. // Refetch values for newly selected label...
  327. if (!label.values) {
  328. this.fetchValues(name, buildSelector(this.state.labels));
  329. }
  330. } else {
  331. // Only need to facet when deselecting labels
  332. this.doFacetting();
  333. }
  334. }
  335. doFacetting = (lastFacetted?: string) => {
  336. const selector = buildSelector(this.state.labels);
  337. if (selector === EMPTY_SELECTOR) {
  338. // Clear up facetting
  339. const labels: SelectableLabel[] = this.state.labels.map((label) => {
  340. return { ...label, facets: 0, values: undefined, hidden: false };
  341. });
  342. this.setState({ labels }, () => {
  343. // Get fresh set of values
  344. this.state.labels.forEach(
  345. (label) => (label.selected || label.name === METRIC_LABEL) && this.fetchValues(label.name, selector)
  346. );
  347. });
  348. } else {
  349. // Do facetting
  350. this.fetchSeries(selector, lastFacetted);
  351. }
  352. };
  353. async fetchValues(name: string, selector: string) {
  354. const { languageProvider } = this.props;
  355. this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`);
  356. try {
  357. let rawValues = await languageProvider.getLabelValues(name);
  358. // If selector changed, clear loading state and discard result by returning early
  359. if (selector !== buildSelector(this.state.labels)) {
  360. this.updateLabelState(name, { loading: false });
  361. return;
  362. }
  363. const values: FacettableValue[] = [];
  364. const { metricsMetadata } = languageProvider;
  365. for (const labelValue of rawValues) {
  366. const value: FacettableValue = { name: labelValue };
  367. // Adding type/help text to metrics
  368. if (name === METRIC_LABEL && metricsMetadata) {
  369. const meta = metricsMetadata[labelValue];
  370. if (meta) {
  371. value.details = `(${meta.type}) ${meta.help}`;
  372. }
  373. }
  374. values.push(value);
  375. }
  376. this.updateLabelState(name, { values, loading: false });
  377. } catch (error) {
  378. console.error(error);
  379. }
  380. }
  381. async fetchSeries(selector: string, lastFacetted?: string) {
  382. const { languageProvider } = this.props;
  383. if (lastFacetted) {
  384. this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`);
  385. }
  386. try {
  387. const possibleLabels = await languageProvider.fetchSeriesLabels(selector, true);
  388. // If selector changed, clear loading state and discard result by returning early
  389. if (selector !== buildSelector(this.state.labels)) {
  390. if (lastFacetted) {
  391. this.updateLabelState(lastFacetted, { loading: false });
  392. }
  393. return;
  394. }
  395. if (Object.keys(possibleLabels).length === 0) {
  396. this.setState({ error: `Empty results, no matching label for ${selector}` });
  397. return;
  398. }
  399. const labels: SelectableLabel[] = facetLabels(this.state.labels, possibleLabels, lastFacetted);
  400. this.setState({ labels, error: '' });
  401. if (lastFacetted) {
  402. this.updateLabelState(lastFacetted, { loading: false });
  403. }
  404. } catch (error) {
  405. console.error(error);
  406. }
  407. }
  408. async validateSelector(selector: string) {
  409. const { languageProvider } = this.props;
  410. this.setState({ validationStatus: `Validating selector ${selector}`, error: '' });
  411. const streams = await languageProvider.fetchSeries(selector);
  412. this.setState({ validationStatus: `Selector is valid (${streams.length} series found)` });
  413. }
  414. render() {
  415. const { theme } = this.props;
  416. const { labels, labelSearchTerm, metricSearchTerm, status, error, validationStatus, valueSearchTerm } = this.state;
  417. const styles = getStyles(theme);
  418. if (labels.length === 0) {
  419. return (
  420. <div className={styles.wrapper}>
  421. <LoadingPlaceholder text="Loading labels..." />
  422. </div>
  423. );
  424. }
  425. // Filter metrics
  426. let metrics = labels.find((label) => label.name === METRIC_LABEL);
  427. if (metrics && metricSearchTerm) {
  428. metrics = {
  429. ...metrics,
  430. values: metrics.values?.filter((value) => value.selected || value.name.includes(metricSearchTerm)),
  431. };
  432. }
  433. // Filter labels
  434. let nonMetricLabels = labels.filter((label) => !label.hidden && label.name !== METRIC_LABEL);
  435. if (labelSearchTerm) {
  436. nonMetricLabels = nonMetricLabels.filter((label) => label.selected || label.name.includes(labelSearchTerm));
  437. }
  438. // Filter non-metric label values
  439. let selectedLabels = nonMetricLabels.filter((label) => label.selected && label.values);
  440. if (valueSearchTerm) {
  441. selectedLabels = selectedLabels.map((label) => ({
  442. ...label,
  443. values: label.values?.filter((value) => value.selected || value.name.includes(valueSearchTerm)),
  444. }));
  445. }
  446. const selector = buildSelector(this.state.labels);
  447. const empty = selector === EMPTY_SELECTOR;
  448. const metricCount = metrics?.values?.length || 0;
  449. return (
  450. <div className={styles.wrapper}>
  451. <HorizontalGroup align="flex-start" spacing="lg">
  452. <div>
  453. <div className={styles.section}>
  454. <Label description="Once a metric is selected only possible labels are shown.">1. Select a metric</Label>
  455. <div>
  456. <Input
  457. onChange={this.onChangeMetricSearch}
  458. aria-label="Filter expression for metric"
  459. value={metricSearchTerm}
  460. />
  461. </div>
  462. <div role="list" className={styles.valueListWrapper}>
  463. <FixedSizeList
  464. height={Math.min(450, metricCount * LIST_ITEM_SIZE)}
  465. itemCount={metricCount}
  466. itemSize={LIST_ITEM_SIZE}
  467. itemKey={(i) => (metrics!.values as FacettableValue[])[i].name}
  468. width={300}
  469. className={styles.valueList}
  470. >
  471. {({ index, style }) => {
  472. const value = metrics?.values?.[index];
  473. if (!value) {
  474. return null;
  475. }
  476. return (
  477. <div style={style}>
  478. <PromLabel
  479. name={metrics!.name}
  480. value={value?.name}
  481. title={value.details}
  482. active={value?.selected}
  483. onClick={this.onClickMetric}
  484. searchTerm={metricSearchTerm}
  485. />
  486. </div>
  487. );
  488. }}
  489. </FixedSizeList>
  490. </div>
  491. </div>
  492. </div>
  493. <div>
  494. <div className={styles.section}>
  495. <Label description="Once label values are selected, only possible label combinations are shown.">
  496. 2. Select labels to search in
  497. </Label>
  498. <div>
  499. <Input
  500. onChange={this.onChangeLabelSearch}
  501. aria-label="Filter expression for label"
  502. value={labelSearchTerm}
  503. />
  504. </div>
  505. {/* Using fixed height here to prevent jumpy layout */}
  506. <div className={styles.list} style={{ height: 120 }}>
  507. {nonMetricLabels.map((label) => (
  508. <PromLabel
  509. key={label.name}
  510. name={label.name}
  511. loading={label.loading}
  512. active={label.selected}
  513. hidden={label.hidden}
  514. facets={label.facets}
  515. onClick={this.onClickLabel}
  516. searchTerm={labelSearchTerm}
  517. />
  518. ))}
  519. </div>
  520. </div>
  521. <div className={styles.section}>
  522. <Label description="Use the search field to find values across selected labels.">
  523. 3. Select (multiple) values for your labels
  524. </Label>
  525. <div>
  526. <Input
  527. onChange={this.onChangeValueSearch}
  528. aria-label="Filter expression for label values"
  529. value={valueSearchTerm}
  530. />
  531. </div>
  532. <div className={styles.valueListArea} ref={this.valueListsRef}>
  533. {selectedLabels.map((label) => (
  534. <div
  535. role="list"
  536. key={label.name}
  537. aria-label={`Values for ${label.name}`}
  538. className={styles.valueListWrapper}
  539. >
  540. <div className={styles.valueTitle}>
  541. <PromLabel
  542. name={label.name}
  543. loading={label.loading}
  544. active={label.selected}
  545. hidden={label.hidden}
  546. //If no facets, we want to show number of all label values
  547. facets={label.facets || label.values?.length}
  548. onClick={this.onClickLabel}
  549. />
  550. </div>
  551. <FixedSizeList
  552. height={Math.min(200, LIST_ITEM_SIZE * (label.values?.length || 0))}
  553. itemCount={label.values?.length || 0}
  554. itemSize={28}
  555. itemKey={(i) => (label.values as FacettableValue[])[i].name}
  556. width={200}
  557. className={styles.valueList}
  558. >
  559. {({ index, style }) => {
  560. const value = label.values?.[index];
  561. if (!value) {
  562. return null;
  563. }
  564. return (
  565. <div style={style}>
  566. <PromLabel
  567. name={label.name}
  568. value={value?.name}
  569. active={value?.selected}
  570. onClick={this.onClickValue}
  571. searchTerm={valueSearchTerm}
  572. />
  573. </div>
  574. );
  575. }}
  576. </FixedSizeList>
  577. </div>
  578. ))}
  579. </div>
  580. </div>
  581. </div>
  582. </HorizontalGroup>
  583. <div className={styles.section}>
  584. <Label>4. Resulting selector</Label>
  585. <div aria-label="selector" className={styles.selector}>
  586. {selector}
  587. </div>
  588. {validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>}
  589. <HorizontalGroup>
  590. <Button aria-label="Use selector for query button" disabled={empty} onClick={this.onClickRunQuery}>
  591. Use query
  592. </Button>
  593. <Button
  594. aria-label="Use selector as metrics button"
  595. variant="secondary"
  596. disabled={empty}
  597. onClick={this.onClickRunRateQuery}
  598. >
  599. Use as rate query
  600. </Button>
  601. <Button
  602. aria-label="Validate submit button"
  603. variant="secondary"
  604. disabled={empty}
  605. onClick={this.onClickValidate}
  606. >
  607. Validate selector
  608. </Button>
  609. <Button aria-label="Selector clear button" variant="secondary" onClick={this.onClickClear}>
  610. Clear
  611. </Button>
  612. <div className={cx(styles.status, (status || error) && styles.statusShowing)}>
  613. <span className={error ? styles.error : ''}>{error || status}</span>
  614. </div>
  615. </HorizontalGroup>
  616. </div>
  617. </div>
  618. );
  619. }
  620. }
  621. export const PrometheusMetricsBrowser = withTheme(UnthemedPrometheusMetricsBrowser);