LokiLabelBrowser.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. import { css, cx } from '@emotion/css';
  2. import { sortBy } from 'lodash';
  3. import React, { ChangeEvent } from 'react';
  4. import { FixedSizeList } from 'react-window';
  5. import { CoreApp, GrafanaTheme2 } from '@grafana/data';
  6. import { reportInteraction } from '@grafana/runtime';
  7. import {
  8. Button,
  9. HighlightPart,
  10. HorizontalGroup,
  11. Input,
  12. Label,
  13. LoadingPlaceholder,
  14. withTheme2,
  15. BrowserLabel as LokiLabel,
  16. fuzzyMatch,
  17. } from '@grafana/ui';
  18. import PromQlLanguageProvider from '../../prometheus/language_provider';
  19. import LokiLanguageProvider from '../language_provider';
  20. import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from '../language_utils';
  21. // Hard limit on labels to render
  22. const MAX_LABEL_COUNT = 1000;
  23. const MAX_VALUE_COUNT = 10000;
  24. const MAX_AUTO_SELECT = 4;
  25. const EMPTY_SELECTOR = '{}';
  26. export interface BrowserProps {
  27. // TODO #33976: Is it possible to use a common interface here? For example: LabelsLanguageProvider
  28. languageProvider: LokiLanguageProvider | PromQlLanguageProvider;
  29. onChange: (selector: string) => void;
  30. theme: GrafanaTheme2;
  31. app?: CoreApp;
  32. autoSelect?: number;
  33. hide?: () => void;
  34. lastUsedLabels: string[];
  35. storeLastUsedLabels: (labels: string[]) => void;
  36. deleteLastUsedLabels: () => void;
  37. }
  38. interface BrowserState {
  39. labels: SelectableLabel[];
  40. searchTerm: string;
  41. status: string;
  42. error: string;
  43. validationStatus: string;
  44. }
  45. interface FacettableValue {
  46. name: string;
  47. selected?: boolean;
  48. highlightParts?: HighlightPart[];
  49. order?: number;
  50. }
  51. export interface SelectableLabel {
  52. name: string;
  53. selected?: boolean;
  54. loading?: boolean;
  55. values?: FacettableValue[];
  56. hidden?: boolean;
  57. facets?: number;
  58. }
  59. export function buildSelector(labels: SelectableLabel[]): string {
  60. const selectedLabels = [];
  61. for (const label of labels) {
  62. if (label.selected && label.values && label.values.length > 0) {
  63. const selectedValues = label.values.filter((value) => value.selected).map((value) => value.name);
  64. if (selectedValues.length > 1) {
  65. selectedLabels.push(`${label.name}=~"${selectedValues.map(escapeLabelValueInRegexSelector).join('|')}"`);
  66. } else if (selectedValues.length === 1) {
  67. selectedLabels.push(`${label.name}="${escapeLabelValueInExactSelector(selectedValues[0])}"`);
  68. }
  69. }
  70. }
  71. return ['{', selectedLabels.join(','), '}'].join('');
  72. }
  73. export function facetLabels(
  74. labels: SelectableLabel[],
  75. possibleLabels: Record<string, string[]>,
  76. lastFacetted?: string
  77. ): SelectableLabel[] {
  78. return labels.map((label) => {
  79. const possibleValues = possibleLabels[label.name];
  80. if (possibleValues) {
  81. let existingValues: FacettableValue[];
  82. if (label.name === lastFacetted && label.values) {
  83. // Facetting this label, show all values
  84. existingValues = label.values;
  85. } else {
  86. // Keep selection in other facets
  87. const selectedValues: Set<string> = new Set(
  88. label.values?.filter((value) => value.selected).map((value) => value.name) || []
  89. );
  90. // Values for this label have not been requested yet, let's use the facetted ones as the initial values
  91. existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) }));
  92. }
  93. return { ...label, loading: false, values: existingValues, facets: existingValues.length };
  94. }
  95. // Label is facetted out, hide all values
  96. return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 };
  97. });
  98. }
  99. const getStyles = (theme: GrafanaTheme2) => ({
  100. wrapper: css`
  101. background-color: ${theme.colors.background.secondary};
  102. padding: ${theme.spacing(2)};
  103. width: 100%;
  104. `,
  105. list: css`
  106. margin-top: ${theme.spacing(1)};
  107. display: flex;
  108. flex-wrap: wrap;
  109. max-height: 200px;
  110. overflow: auto;
  111. `,
  112. section: css`
  113. & + & {
  114. margin: ${theme.spacing(2, 0)};
  115. }
  116. position: relative;
  117. `,
  118. selector: css`
  119. font-family: ${theme.typography.fontFamilyMonospace};
  120. margin-bottom: ${theme.spacing(1)};
  121. `,
  122. status: css`
  123. padding: ${theme.spacing(0.5)};
  124. color: ${theme.colors.text.secondary};
  125. white-space: nowrap;
  126. overflow: hidden;
  127. text-overflow: ellipsis;
  128. /* using absolute positioning because flex interferes with ellipsis */
  129. position: absolute;
  130. width: 50%;
  131. right: 0;
  132. text-align: right;
  133. transition: opacity 100ms linear;
  134. opacity: 0;
  135. `,
  136. statusShowing: css`
  137. opacity: 1;
  138. `,
  139. error: css`
  140. color: ${theme.colors.error.main};
  141. `,
  142. valueList: css`
  143. margin-right: ${theme.spacing(1)};
  144. `,
  145. valueListWrapper: css`
  146. border-left: 1px solid ${theme.colors.border.medium};
  147. margin: ${theme.spacing(1, 0)};
  148. padding: ${theme.spacing(1, 0, 1, 1)};
  149. `,
  150. valueListArea: css`
  151. display: flex;
  152. flex-wrap: wrap;
  153. margin-top: ${theme.spacing(1)};
  154. `,
  155. valueTitle: css`
  156. margin-left: -${theme.spacing(0.5)};
  157. margin-bottom: ${theme.spacing(1)};
  158. `,
  159. validationStatus: css`
  160. padding: ${theme.spacing(0.5)};
  161. margin-bottom: ${theme.spacing(1)};
  162. color: ${theme.colors.text.maxContrast};
  163. white-space: nowrap;
  164. overflow: hidden;
  165. text-overflow: ellipsis;
  166. `,
  167. });
  168. export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, BrowserState> {
  169. state: BrowserState = {
  170. labels: [] as SelectableLabel[],
  171. searchTerm: '',
  172. status: 'Ready',
  173. error: '',
  174. validationStatus: '',
  175. };
  176. onChangeSearch = (event: ChangeEvent<HTMLInputElement>) => {
  177. this.setState({ searchTerm: event.target.value });
  178. };
  179. onClickRunLogsQuery = () => {
  180. reportInteraction('grafana_loki_log_browser_closed', {
  181. app: this.props.app,
  182. closeType: 'showLogsButton',
  183. });
  184. const selector = buildSelector(this.state.labels);
  185. this.props.onChange(selector);
  186. };
  187. onClickRunMetricsQuery = () => {
  188. reportInteraction('grafana_loki_log_browser_closed', {
  189. app: this.props.app,
  190. closeType: 'showLogsRateButton',
  191. });
  192. const selector = buildSelector(this.state.labels);
  193. const query = `rate(${selector}[$__interval])`;
  194. this.props.onChange(query);
  195. };
  196. onClickClear = () => {
  197. reportInteraction('grafana_loki_log_browser_closed', {
  198. app: this.props.app,
  199. closeType: 'clearButton',
  200. });
  201. this.setState((state) => {
  202. const labels: SelectableLabel[] = state.labels.map((label) => ({
  203. ...label,
  204. values: undefined,
  205. selected: false,
  206. loading: false,
  207. hidden: false,
  208. facets: undefined,
  209. }));
  210. return { labels, searchTerm: '', status: '', error: '', validationStatus: '' };
  211. });
  212. this.props.deleteLastUsedLabels();
  213. };
  214. onClickLabel = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
  215. const label = this.state.labels.find((l) => l.name === name);
  216. if (!label) {
  217. return;
  218. }
  219. // Toggle selected state
  220. const selected = !label.selected;
  221. let nextValue: Partial<SelectableLabel> = { selected };
  222. if (label.values && !selected) {
  223. // Deselect all values if label was deselected
  224. const values = label.values.map((value) => ({ ...value, selected: false }));
  225. nextValue = { ...nextValue, facets: 0, values };
  226. }
  227. // Resetting search to prevent empty results
  228. this.setState({ searchTerm: '' });
  229. this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name));
  230. };
  231. onClickValue = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => {
  232. const label = this.state.labels.find((l) => l.name === name);
  233. if (!label || !label.values) {
  234. return;
  235. }
  236. // Resetting search to prevent empty results
  237. this.setState({ searchTerm: '' });
  238. // Toggling value for selected label, leaving other values intact
  239. const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected }));
  240. this.updateLabelState(name, { values }, '', () => this.doFacetting(name));
  241. };
  242. onClickValidate = () => {
  243. const selector = buildSelector(this.state.labels);
  244. this.validateSelector(selector);
  245. };
  246. updateLabelState(name: string, updatedFields: Partial<SelectableLabel>, status = '', cb?: () => void) {
  247. this.setState((state) => {
  248. const labels: SelectableLabel[] = state.labels.map((label) => {
  249. if (label.name === name) {
  250. return { ...label, ...updatedFields };
  251. }
  252. return label;
  253. });
  254. // New status overrides errors
  255. const error = status ? '' : state.error;
  256. return { labels, status, error, validationStatus: '' };
  257. }, cb);
  258. }
  259. componentDidMount() {
  260. const { languageProvider, autoSelect = MAX_AUTO_SELECT, lastUsedLabels } = this.props;
  261. if (languageProvider) {
  262. const selectedLabels: string[] = lastUsedLabels;
  263. languageProvider.start().then(() => {
  264. let rawLabels: string[] = languageProvider.getLabelKeys();
  265. if (rawLabels.length > MAX_LABEL_COUNT) {
  266. const error = `Too many labels found (showing only ${MAX_LABEL_COUNT} of ${rawLabels.length})`;
  267. rawLabels = rawLabels.slice(0, MAX_LABEL_COUNT);
  268. this.setState({ error });
  269. }
  270. // Auto-select all labels if label list is small enough
  271. const labels: SelectableLabel[] = rawLabels.map((label, i, arr) => ({
  272. name: label,
  273. selected: (arr.length <= autoSelect && selectedLabels.length === 0) || selectedLabels.includes(label),
  274. loading: false,
  275. }));
  276. // Pre-fetch values for selected labels
  277. this.setState({ labels }, () => {
  278. this.state.labels.forEach((label) => {
  279. if (label.selected) {
  280. this.fetchValues(label.name, EMPTY_SELECTOR);
  281. }
  282. });
  283. });
  284. });
  285. }
  286. }
  287. doFacettingForLabel(name: string) {
  288. const label = this.state.labels.find((l) => l.name === name);
  289. if (!label) {
  290. return;
  291. }
  292. const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name);
  293. this.props.storeLastUsedLabels(selectedLabels);
  294. if (label.selected) {
  295. // Refetch values for newly selected label...
  296. if (!label.values) {
  297. this.fetchValues(name, buildSelector(this.state.labels));
  298. }
  299. } else {
  300. // Only need to facet when deselecting labels
  301. this.doFacetting();
  302. }
  303. }
  304. doFacetting = (lastFacetted?: string) => {
  305. const selector = buildSelector(this.state.labels);
  306. if (selector === EMPTY_SELECTOR) {
  307. // Clear up facetting
  308. const labels: SelectableLabel[] = this.state.labels.map((label) => {
  309. return { ...label, facets: 0, values: undefined, hidden: false };
  310. });
  311. this.setState({ labels }, () => {
  312. // Get fresh set of values
  313. this.state.labels.forEach((label) => label.selected && this.fetchValues(label.name, selector));
  314. });
  315. } else {
  316. // Do facetting
  317. this.fetchSeries(selector, lastFacetted);
  318. }
  319. };
  320. async fetchValues(name: string, selector: string) {
  321. const { languageProvider } = this.props;
  322. this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`);
  323. try {
  324. let rawValues = await languageProvider.getLabelValues(name);
  325. // If selector changed, clear loading state and discard result by returning early
  326. if (selector !== buildSelector(this.state.labels)) {
  327. this.updateLabelState(name, { loading: false }, '');
  328. return;
  329. }
  330. if (rawValues.length > MAX_VALUE_COUNT) {
  331. const error = `Too many values for ${name} (showing only ${MAX_VALUE_COUNT} of ${rawValues.length})`;
  332. rawValues = rawValues.slice(0, MAX_VALUE_COUNT);
  333. this.setState({ error });
  334. }
  335. const values: FacettableValue[] = rawValues.map((value) => ({ name: value }));
  336. this.updateLabelState(name, { values, loading: false });
  337. } catch (error) {
  338. console.error(error);
  339. }
  340. }
  341. async fetchSeries(selector: string, lastFacetted?: string) {
  342. const { languageProvider } = this.props;
  343. if (lastFacetted) {
  344. this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`);
  345. }
  346. try {
  347. const possibleLabels = await languageProvider.fetchSeriesLabels(selector, true);
  348. // If selector changed, clear loading state and discard result by returning early
  349. if (selector !== buildSelector(this.state.labels)) {
  350. if (lastFacetted) {
  351. this.updateLabelState(lastFacetted, { loading: false });
  352. }
  353. return;
  354. }
  355. if (Object.keys(possibleLabels).length === 0) {
  356. this.setState({ error: `Empty results, no matching label for ${selector}` });
  357. return;
  358. }
  359. const labels: SelectableLabel[] = facetLabels(this.state.labels, possibleLabels, lastFacetted);
  360. this.setState({ labels, error: '' });
  361. if (lastFacetted) {
  362. this.updateLabelState(lastFacetted, { loading: false });
  363. }
  364. } catch (error) {
  365. console.error(error);
  366. }
  367. }
  368. async validateSelector(selector: string) {
  369. const { languageProvider } = this.props;
  370. this.setState({ validationStatus: `Validating selector ${selector}`, error: '' });
  371. const streams = await languageProvider.fetchSeries(selector);
  372. this.setState({ validationStatus: `Selector is valid (${streams.length} streams found)` });
  373. }
  374. render() {
  375. const { theme } = this.props;
  376. const { labels, searchTerm, status, error, validationStatus } = this.state;
  377. if (labels.length === 0) {
  378. return <LoadingPlaceholder text="Loading labels..." />;
  379. }
  380. const styles = getStyles(theme);
  381. const selector = buildSelector(this.state.labels);
  382. const empty = selector === EMPTY_SELECTOR;
  383. let selectedLabels = labels.filter((label) => label.selected && label.values);
  384. if (searchTerm) {
  385. selectedLabels = selectedLabels.map((label) => {
  386. const searchResults = label.values!.filter((value) => {
  387. // Always return selected values
  388. if (value.selected) {
  389. value.highlightParts = undefined;
  390. return true;
  391. }
  392. const fuzzyMatchResult = fuzzyMatch(value.name.toLowerCase(), searchTerm.toLowerCase());
  393. if (fuzzyMatchResult.found) {
  394. value.highlightParts = fuzzyMatchResult.ranges;
  395. value.order = fuzzyMatchResult.distance;
  396. return true;
  397. } else {
  398. return false;
  399. }
  400. });
  401. return {
  402. ...label,
  403. values: sortBy(searchResults, (value) => (value.selected ? -Infinity : value.order)),
  404. };
  405. });
  406. } else {
  407. // Clear highlight parts when searchTerm is cleared
  408. selectedLabels = this.state.labels
  409. .filter((label) => label.selected && label.values)
  410. .map((label) => ({
  411. ...label,
  412. values: label?.values ? label.values.map((value) => ({ ...value, highlightParts: undefined })) : [],
  413. }));
  414. }
  415. return (
  416. <div className={styles.wrapper}>
  417. <div className={styles.section}>
  418. <Label description="Which labels would you like to consider for your search?">
  419. 1. Select labels to search in
  420. </Label>
  421. <div className={styles.list}>
  422. {labels.map((label) => (
  423. <LokiLabel
  424. key={label.name}
  425. name={label.name}
  426. loading={label.loading}
  427. active={label.selected}
  428. hidden={label.hidden}
  429. facets={label.facets}
  430. onClick={this.onClickLabel}
  431. />
  432. ))}
  433. </div>
  434. </div>
  435. <div className={styles.section}>
  436. <Label description="Choose the label values that you would like to use for the query. Use the search field to find values across selected labels.">
  437. 2. Find values for the selected labels
  438. </Label>
  439. <div>
  440. <Input onChange={this.onChangeSearch} aria-label="Filter expression for values" value={searchTerm} />
  441. </div>
  442. <div className={styles.valueListArea}>
  443. {selectedLabels.map((label) => (
  444. <div role="list" key={label.name} className={styles.valueListWrapper}>
  445. <div className={styles.valueTitle} aria-label={`Values for ${label.name}`}>
  446. <LokiLabel
  447. name={label.name}
  448. loading={label.loading}
  449. active={label.selected}
  450. hidden={label.hidden}
  451. //If no facets, we want to show number of all label values
  452. facets={label.facets || label.values?.length}
  453. onClick={this.onClickLabel}
  454. />
  455. </div>
  456. <FixedSizeList
  457. height={200}
  458. itemCount={label.values?.length || 0}
  459. itemSize={28}
  460. itemKey={(i) => (label.values as FacettableValue[])[i].name}
  461. width={200}
  462. className={styles.valueList}
  463. >
  464. {({ index, style }) => {
  465. const value = label.values?.[index];
  466. if (!value) {
  467. return null;
  468. }
  469. return (
  470. <div style={style}>
  471. <LokiLabel
  472. name={label.name}
  473. value={value?.name}
  474. active={value?.selected}
  475. highlightParts={value?.highlightParts}
  476. onClick={this.onClickValue}
  477. searchTerm={searchTerm}
  478. />
  479. </div>
  480. );
  481. }}
  482. </FixedSizeList>
  483. </div>
  484. ))}
  485. </div>
  486. </div>
  487. <div className={styles.section}>
  488. <Label>3. Resulting selector</Label>
  489. <div aria-label="selector" className={styles.selector}>
  490. {selector}
  491. </div>
  492. {validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>}
  493. <HorizontalGroup>
  494. <Button aria-label="Use selector as logs button" disabled={empty} onClick={this.onClickRunLogsQuery}>
  495. Show logs
  496. </Button>
  497. <Button
  498. aria-label="Use selector as metrics button"
  499. variant="secondary"
  500. disabled={empty}
  501. onClick={this.onClickRunMetricsQuery}
  502. >
  503. Show logs rate
  504. </Button>
  505. <Button
  506. aria-label="Validate submit button"
  507. variant="secondary"
  508. disabled={empty}
  509. onClick={this.onClickValidate}
  510. >
  511. Validate selector
  512. </Button>
  513. <Button aria-label="Selector clear button" variant="secondary" onClick={this.onClickClear}>
  514. Clear
  515. </Button>
  516. <div className={cx(styles.status, (status || error) && styles.statusShowing)}>
  517. <span className={error ? styles.error : ''}>{error || status}</span>
  518. </div>
  519. </HorizontalGroup>
  520. </div>
  521. </div>
  522. );
  523. }
  524. }
  525. export const LokiLabelBrowser = withTheme2(UnthemedLokiLabelBrowser);