mirror of https://github.com/grafana/grafana
Loki: Label browser (#30351)
* Loki: Label browser - replaces stream cascader widget which made it hard to find relevant streams - multi-step selection allows for selecting a couple of labels first, then find the relevant values - supports facetting which makes impossible label combinations hard to choose * Remove unused label hook * Remove unused label styles * Use global time range for metadata requests * Preselect labels if not many exist * Status and error messages * Status fixes * Remove unused import * Added logs rate button * Close popup when clicked outside (not working for timepicker :( ) * Change button label * Get rid of popup and render browser inline * Review feedback * Wrap label values and prevent empty lists Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>pull/31477/head
parent
d306f417d3
commit
091e3cf4f8
@ -0,0 +1,121 @@ |
||||
import React, { forwardRef, HTMLAttributes } from 'react'; |
||||
import { cx, css } from 'emotion'; |
||||
import { GrafanaTheme } from '@grafana/data'; |
||||
import { useTheme } from '@grafana/ui'; |
||||
// @ts-ignore
|
||||
import Highlighter from 'react-highlight-words'; |
||||
|
||||
/** |
||||
* @public |
||||
*/ |
||||
export type OnLabelClick = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => void; |
||||
|
||||
export interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onClick'> { |
||||
name: string; |
||||
active?: boolean; |
||||
loading?: boolean; |
||||
searchTerm?: RegExp; |
||||
value?: string; |
||||
facets?: number; |
||||
onClick?: OnLabelClick; |
||||
} |
||||
|
||||
export const LokiLabel = forwardRef<HTMLElement, Props>( |
||||
({ name, value, hidden, facets, onClick, className, loading, searchTerm, active, style, ...rest }, ref) => { |
||||
const theme = useTheme(); |
||||
const styles = getLabelStyles(theme); |
||||
|
||||
const onLabelClick = (event: React.MouseEvent<HTMLElement>) => { |
||||
if (onClick && !hidden) { |
||||
onClick(name, value, event); |
||||
} |
||||
}; |
||||
// Using this component for labels and label values. If value is given use value for display text.
|
||||
let text = value || name; |
||||
if (facets) { |
||||
text = `${text} (${facets})`; |
||||
} |
||||
|
||||
return ( |
||||
<span |
||||
key={text} |
||||
ref={ref} |
||||
onClick={onLabelClick} |
||||
style={style} |
||||
title={text} |
||||
role="option" |
||||
aria-selected={!!active} |
||||
className={cx( |
||||
styles.base, |
||||
active && styles.active, |
||||
loading && styles.loading, |
||||
hidden && styles.hidden, |
||||
className, |
||||
onClick && !hidden && styles.hover |
||||
)} |
||||
{...rest} |
||||
> |
||||
<Highlighter textToHighlight={text} searchWords={[searchTerm]} highlightClassName={styles.matchHighLight} /> |
||||
</span> |
||||
); |
||||
} |
||||
); |
||||
|
||||
LokiLabel.displayName = 'LokiLabel'; |
||||
|
||||
const getLabelStyles = (theme: GrafanaTheme) => ({ |
||||
base: css` |
||||
cursor: pointer; |
||||
font-size: ${theme.typography.size.sm}; |
||||
line-height: ${theme.typography.lineHeight.xs}; |
||||
border: 1px solid ${theme.colors.border2}; |
||||
vertical-align: baseline; |
||||
color: ${theme.colors.text}; |
||||
white-space: nowrap; |
||||
text-shadow: none; |
||||
padding: ${theme.spacing.xs}; |
||||
border-radius: ${theme.border.radius.md}; |
||||
margin-right: ${theme.spacing.sm}; |
||||
margin-bottom: ${theme.spacing.xs}; |
||||
text-overflow: ellipsis; |
||||
overflow: hidden; |
||||
`,
|
||||
loading: css` |
||||
font-weight: ${theme.typography.weight.semibold}; |
||||
background-color: ${theme.colors.formSwitchBgHover}; |
||||
color: ${theme.palette.gray98}; |
||||
animation: pulse 3s ease-out 0s infinite normal forwards; |
||||
@keyframes pulse { |
||||
0% { |
||||
color: ${theme.colors.textSemiWeak}; |
||||
} |
||||
50% { |
||||
color: ${theme.colors.textFaint}; |
||||
} |
||||
100% { |
||||
color: ${theme.colors.textSemiWeak}; |
||||
} |
||||
} |
||||
`,
|
||||
active: css` |
||||
font-weight: ${theme.typography.weight.semibold}; |
||||
background-color: ${theme.colors.formSwitchBgActive}; |
||||
color: ${theme.colors.formSwitchDot}; |
||||
`,
|
||||
matchHighLight: css` |
||||
background: inherit; |
||||
color: ${theme.palette.yellow}; |
||||
background-color: rgba(${theme.palette.yellow}, 0.1); |
||||
`,
|
||||
hidden: css` |
||||
opacity: 0.6; |
||||
cursor: default; |
||||
border: 1px solid transparent; |
||||
`,
|
||||
hover: css` |
||||
&:hover { |
||||
opacity: 0.85; |
||||
cursor: pointer; |
||||
} |
||||
`,
|
||||
}); |
@ -0,0 +1,241 @@ |
||||
import React from 'react'; |
||||
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
|
||||
import { getTheme } from '@grafana/ui'; |
||||
import { |
||||
buildSelector, |
||||
facetLabels, |
||||
SelectableLabel, |
||||
UnthemedLokiLabelBrowser, |
||||
BrowserProps, |
||||
} from './LokiLabelBrowser'; |
||||
import LokiLanguageProvider from '../language_provider'; |
||||
|
||||
describe('buildSelector()', () => { |
||||
it('returns an empty selector for no labels', () => { |
||||
expect(buildSelector([])).toEqual('{}'); |
||||
}); |
||||
it('returns an empty selector for selected labels with no values', () => { |
||||
const labels: SelectableLabel[] = [{ name: 'foo', selected: true }]; |
||||
expect(buildSelector(labels)).toEqual('{}'); |
||||
}); |
||||
it('returns an empty selector for one selected label with no selected values', () => { |
||||
const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar' }] }]; |
||||
expect(buildSelector(labels)).toEqual('{}'); |
||||
}); |
||||
it('returns a simple selector from a selected label with a selected value', () => { |
||||
const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar', selected: true }] }]; |
||||
expect(buildSelector(labels)).toEqual('{foo="bar"}'); |
||||
}); |
||||
}); |
||||
|
||||
describe('facetLabels()', () => { |
||||
const possibleLabels = { |
||||
cluster: ['dev'], |
||||
namespace: ['alertmanager'], |
||||
}; |
||||
const labels: SelectableLabel[] = [ |
||||
{ name: 'foo', selected: true, values: [{ name: 'bar' }] }, |
||||
{ name: 'cluster', values: [{ name: 'dev' }, { name: 'ops' }, { name: 'prod' }] }, |
||||
{ name: 'namespace', values: [{ name: 'alertmanager' }] }, |
||||
]; |
||||
|
||||
it('returns no labels given an empty label set', () => { |
||||
expect(facetLabels([], {})).toEqual([]); |
||||
}); |
||||
|
||||
it('marks all labels as hidden when no labels are possible', () => { |
||||
const result = facetLabels(labels, {}); |
||||
expect(result.length).toEqual(labels.length); |
||||
expect(result[0].hidden).toBeTruthy(); |
||||
expect(result[0].values).toBeUndefined(); |
||||
}); |
||||
|
||||
it('keeps values as facetted when they are possible', () => { |
||||
const result = facetLabels(labels, possibleLabels); |
||||
expect(result.length).toEqual(labels.length); |
||||
expect(result[0].hidden).toBeTruthy(); |
||||
expect(result[0].values).toBeUndefined(); |
||||
expect(result[1].hidden).toBeFalsy(); |
||||
expect(result[1].values!.length).toBe(1); |
||||
expect(result[1].values![0].name).toBe('dev'); |
||||
}); |
||||
|
||||
it('does not facet out label values that are currently being facetted', () => { |
||||
const result = facetLabels(labels, possibleLabels, 'cluster'); |
||||
expect(result.length).toEqual(labels.length); |
||||
expect(result[0].hidden).toBeTruthy(); |
||||
expect(result[1].hidden).toBeFalsy(); |
||||
// 'cluster' is being facetted, should show all 3 options even though only 1 is possible
|
||||
expect(result[1].values!.length).toBe(3); |
||||
expect(result[2].values!.length).toBe(1); |
||||
}); |
||||
}); |
||||
|
||||
describe('LokiLabelBrowser', () => { |
||||
const setupProps = (): BrowserProps => { |
||||
const mockLanguageProvider = { |
||||
start: () => Promise.resolve(), |
||||
getLabelValues: (name: string) => { |
||||
switch (name) { |
||||
case 'label1': |
||||
return ['value1-1', 'value1-2']; |
||||
case 'label2': |
||||
return ['value2-1', 'value2-2']; |
||||
case 'label3': |
||||
return ['value3-1', 'value3-2']; |
||||
} |
||||
return []; |
||||
}, |
||||
fetchSeriesLabels: (selector: string) => { |
||||
switch (selector) { |
||||
case '{label1="value1-1"}': |
||||
return { label1: ['value1-1'], label2: ['value2-1'], label3: ['value3-1'] }; |
||||
case '{label1=~"value1-1|value1-2"}': |
||||
return { label1: ['value1-1', 'value1-2'], label2: ['value2-1'], label3: ['value3-1', 'value3-2'] }; |
||||
} |
||||
// Allow full set by default
|
||||
return { |
||||
label1: ['value1-1', 'value1-2'], |
||||
label2: ['value2-1', 'value2-2'], |
||||
}; |
||||
}, |
||||
getLabelKeys: () => ['label1', 'label2', 'label3'], |
||||
}; |
||||
|
||||
const defaults: BrowserProps = { |
||||
theme: getTheme(), |
||||
onChange: () => {}, |
||||
autoSelect: 0, |
||||
languageProvider: (mockLanguageProvider as unknown) as LokiLanguageProvider, |
||||
}; |
||||
|
||||
return defaults; |
||||
}; |
||||
|
||||
// Clear label selection manually because it's saved in localStorage
|
||||
afterEach(() => { |
||||
const clearBtn = screen.getByLabelText('Selector clear button'); |
||||
userEvent.click(clearBtn); |
||||
}); |
||||
|
||||
it('renders and loader shows when empty, and then first set of labels', async () => { |
||||
const props = setupProps(); |
||||
render(<UnthemedLokiLabelBrowser {...props} />); |
||||
// Loading appears and dissappears
|
||||
screen.getByText(/Loading labels/); |
||||
await waitFor(() => { |
||||
expect(screen.queryByText(/Loading labels/)).not.toBeInTheDocument(); |
||||
}); |
||||
// Initial set of labels is available and not selected
|
||||
expect(screen.queryByRole('option', { name: 'label1' })).toBeInTheDocument(); |
||||
expect(screen.queryByRole('option', { name: 'label1', selected: true })).not.toBeInTheDocument(); |
||||
expect(screen.queryByRole('option', { name: 'label2' })).toBeInTheDocument(); |
||||
expect(screen.queryByRole('option', { name: 'label2', selected: true })).not.toBeInTheDocument(); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); |
||||
}); |
||||
|
||||
it('allows label and value selection/deselection', async () => { |
||||
const props = setupProps(); |
||||
render(<UnthemedLokiLabelBrowser {...props} />); |
||||
// Selecting label2
|
||||
const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); |
||||
expect(screen.queryByRole('list')).not.toBeInTheDocument(); |
||||
userEvent.click(label2); |
||||
expect(screen.queryByRole('option', { name: /label2/, selected: true })).toBeInTheDocument(); |
||||
// List of values for label2 appears
|
||||
expect(await screen.findAllByRole('list')).toHaveLength(1); |
||||
expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2'); |
||||
expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument(); |
||||
expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument(); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); |
||||
// Selecting label1, list for its values appears
|
||||
const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); |
||||
userEvent.click(label1); |
||||
expect(screen.queryByRole('option', { name: /label1/, selected: true })).toBeInTheDocument(); |
||||
await screen.findByLabelText('Values for label1'); |
||||
expect(await screen.findAllByRole('list')).toHaveLength(2); |
||||
// Selecting value2-2 of label2
|
||||
const value = await screen.findByRole('option', { name: 'value2-2', selected: false }); |
||||
userEvent.click(value); |
||||
await screen.findByRole('option', { name: 'value2-2', selected: true }); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-2"}'); |
||||
// Selecting value2-1 of label2, both values now selected
|
||||
const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false }); |
||||
userEvent.click(value2); |
||||
// await screen.findByRole('option', {name: 'value2-1', selected: true});
|
||||
await screen.findByText('{label2=~"value2-1|value2-2"}'); |
||||
// Deselecting value2-2, one value should remain
|
||||
const selectedValue = await screen.findByRole('option', { name: 'value2-2', selected: true }); |
||||
userEvent.click(selectedValue); |
||||
await screen.findByRole('option', { name: 'value2-1', selected: true }); |
||||
await screen.findByRole('option', { name: 'value2-2', selected: false }); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}'); |
||||
// Selecting value from label1 for combined selector
|
||||
const value1 = await screen.findByRole('option', { name: 'value1-2', selected: false }); |
||||
userEvent.click(value1); |
||||
await screen.findByRole('option', { name: 'value1-2', selected: true }); |
||||
await screen.findByText('{label1="value1-2",label2="value2-1"}'); |
||||
// Deselect label1 should remove label and value
|
||||
const selectedLabel = (await screen.findAllByRole('option', { name: /label1/, selected: true }))[0]; |
||||
userEvent.click(selectedLabel); |
||||
await screen.findByRole('option', { name: /label1/, selected: false }); |
||||
expect(await screen.findAllByRole('list')).toHaveLength(1); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}'); |
||||
// Clear selector
|
||||
const clearBtn = screen.getByLabelText('Selector clear button'); |
||||
userEvent.click(clearBtn); |
||||
await screen.findByRole('option', { name: /label2/, selected: false }); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); |
||||
}); |
||||
|
||||
it('filters values by input text', async () => { |
||||
const props = setupProps(); |
||||
render(<UnthemedLokiLabelBrowser {...props} />); |
||||
// Selecting label2 and label1
|
||||
const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); |
||||
userEvent.click(label2); |
||||
const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); |
||||
userEvent.click(label1); |
||||
await screen.findByLabelText('Values for label1'); |
||||
await screen.findByLabelText('Values for label2'); |
||||
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4); |
||||
// Typing '1' to filter for values
|
||||
userEvent.type(screen.getByLabelText('Filter expression for values'), '1'); |
||||
expect(screen.getByLabelText('Filter expression for values')).toHaveValue('1'); |
||||
expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument(); |
||||
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(3); |
||||
}); |
||||
|
||||
it('facets labels', async () => { |
||||
const props = setupProps(); |
||||
render(<UnthemedLokiLabelBrowser {...props} />); |
||||
// Selecting label2 and label1
|
||||
const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); |
||||
userEvent.click(label2); |
||||
const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); |
||||
userEvent.click(label1); |
||||
await screen.findByLabelText('Values for label1'); |
||||
await screen.findByLabelText('Values for label2'); |
||||
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4); |
||||
expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3'); |
||||
// Click value1-1 which triggers facetting for value3-x, and still show all value1-x
|
||||
const value1 = await screen.findByRole('option', { name: 'value1-1', selected: false }); |
||||
userEvent.click(value1); |
||||
await waitForElementToBeRemoved(screen.queryByRole('option', { name: 'value2-2' })); |
||||
expect(screen.queryByRole('option', { name: 'value1-2' })).toBeInTheDocument(); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1="value1-1"}'); |
||||
expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3 (1)'); |
||||
// Click value1-2 for which facetting will allow more values for value3-x
|
||||
const value12 = await screen.findByRole('option', { name: 'value1-2', selected: false }); |
||||
userEvent.click(value12); |
||||
await screen.findByRole('option', { name: 'value1-2', selected: true }); |
||||
userEvent.click(screen.getByRole('option', { name: /label3/ })); |
||||
await screen.findByLabelText('Values for label3'); |
||||
expect(screen.queryByRole('option', { name: 'value1-1', selected: true })).toBeInTheDocument(); |
||||
expect(screen.queryByRole('option', { name: 'value1-2', selected: true })).toBeInTheDocument(); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1=~"value1-1|value1-2"}'); |
||||
expect(screen.queryAllByRole('option', { name: /label3/ })[0]).toHaveTextContent('label3 (2)'); |
||||
}); |
||||
}); |
@ -0,0 +1,494 @@ |
||||
import React, { ChangeEvent } from 'react'; |
||||
import { Button, HorizontalGroup, Input, Label, LoadingPlaceholder, stylesFactory, withTheme } from '@grafana/ui'; |
||||
import LokiLanguageProvider from '../language_provider'; |
||||
import { css, cx } from 'emotion'; |
||||
import store from 'app/core/store'; |
||||
import { FixedSizeList } from 'react-window'; |
||||
|
||||
import { GrafanaTheme } from '@grafana/data'; |
||||
import { LokiLabel } from './LokiLabel'; |
||||
|
||||
// Hard limit on labels to render
|
||||
const MAX_LABEL_COUNT = 100; |
||||
const MAX_VALUE_COUNT = 10000; |
||||
const MAX_AUTO_SELECT = 4; |
||||
const EMPTY_SELECTOR = '{}'; |
||||
export const LAST_USED_LABELS_KEY = 'grafana.datasources.loki.browser.labels'; |
||||
|
||||
export interface BrowserProps { |
||||
languageProvider: LokiLanguageProvider; |
||||
onChange: (selector: string) => void; |
||||
theme: GrafanaTheme; |
||||
autoSelect?: number; |
||||
hide?: () => void; |
||||
} |
||||
|
||||
interface BrowserState { |
||||
labels: SelectableLabel[]; |
||||
searchTerm: string; |
||||
status: string; |
||||
error: string; |
||||
validationStatus: string; |
||||
} |
||||
|
||||
interface FacettableValue { |
||||
name: string; |
||||
selected?: boolean; |
||||
} |
||||
|
||||
export interface SelectableLabel { |
||||
name: string; |
||||
selected?: boolean; |
||||
loading?: boolean; |
||||
values?: FacettableValue[]; |
||||
hidden?: boolean; |
||||
facets?: number; |
||||
} |
||||
|
||||
export function buildSelector(labels: SelectableLabel[]): string { |
||||
const selectedLabels = []; |
||||
for (const label of labels) { |
||||
if (label.selected && label.values && label.values.length > 0) { |
||||
const selectedValues = label.values.filter((value) => value.selected).map((value) => value.name); |
||||
if (selectedValues.length > 1) { |
||||
selectedLabels.push(`${label.name}=~"${selectedValues.join('|')}"`); |
||||
} else if (selectedValues.length === 1) { |
||||
selectedLabels.push(`${label.name}="${selectedValues[0]}"`); |
||||
} |
||||
} |
||||
} |
||||
return ['{', selectedLabels.join(','), '}'].join(''); |
||||
} |
||||
|
||||
export function facetLabels( |
||||
labels: SelectableLabel[], |
||||
possibleLabels: Record<string, string[]>, |
||||
lastFacetted?: string |
||||
): SelectableLabel[] { |
||||
return labels.map((label) => { |
||||
const possibleValues = possibleLabels[label.name]; |
||||
if (possibleValues) { |
||||
let existingValues: FacettableValue[]; |
||||
if (label.name === lastFacetted && label.values) { |
||||
// Facetting this label, show all values
|
||||
existingValues = label.values; |
||||
} else { |
||||
// Keep selection in other facets
|
||||
const selectedValues: Set<string> = new Set( |
||||
label.values?.filter((value) => value.selected).map((value) => value.name) || [] |
||||
); |
||||
// Values for this label have not been requested yet, let's use the facetted ones as the initial values
|
||||
existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) })); |
||||
} |
||||
return { ...label, loading: false, values: existingValues, facets: existingValues.length }; |
||||
} |
||||
|
||||
// Label is facetted out, hide all values
|
||||
return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 }; |
||||
}); |
||||
} |
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => ({ |
||||
wrapper: css` |
||||
background-color: ${theme.colors.bg2}; |
||||
padding: ${theme.spacing.md}; |
||||
`,
|
||||
list: css` |
||||
margin-top: ${theme.spacing.sm}; |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
max-height: 200px; |
||||
overflow: auto; |
||||
`,
|
||||
section: css` |
||||
& + & { |
||||
margin: ${theme.spacing.md} 0; |
||||
} |
||||
position: relative; |
||||
`,
|
||||
selector: css` |
||||
font-family: ${theme.typography.fontFamily.monospace}; |
||||
margin-bottom: ${theme.spacing.sm}; |
||||
`,
|
||||
status: css` |
||||
padding: ${theme.spacing.xs}; |
||||
color: ${theme.colors.textSemiWeak}; |
||||
white-space: nowrap; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
/* using absolute positioning because flex interferes with ellipsis */ |
||||
position: absolute; |
||||
width: 50%; |
||||
right: 0; |
||||
text-align: right; |
||||
transition: opacity 100ms linear; |
||||
opacity: 0; |
||||
`,
|
||||
statusShowing: css` |
||||
opacity: 1; |
||||
`,
|
||||
error: css` |
||||
color: ${theme.palette.brandDanger}; |
||||
`,
|
||||
valueCell: css` |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
`,
|
||||
valueList: css` |
||||
margin-right: ${theme.spacing.sm}; |
||||
`,
|
||||
valueListWrapper: css` |
||||
padding: ${theme.spacing.sm}; |
||||
& + & { |
||||
border-left: 1px solid ${theme.colors.border2}; |
||||
} |
||||
`,
|
||||
valueListArea: css` |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
margin-top: ${theme.spacing.sm}; |
||||
`,
|
||||
valueTitle: css` |
||||
margin-left: -${theme.spacing.xs}; |
||||
margin-bottom: ${theme.spacing.sm}; |
||||
`,
|
||||
validationStatus: css` |
||||
padding: ${theme.spacing.xs}; |
||||
margin-bottom: ${theme.spacing.sm}; |
||||
color: ${theme.colors.textStrong}; |
||||
white-space: nowrap; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
`,
|
||||
})); |
||||
|
||||
export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, BrowserState> { |
||||
state = { |
||||
labels: [] as SelectableLabel[], |
||||
searchTerm: '', |
||||
status: 'Ready', |
||||
error: '', |
||||
validationStatus: '', |
||||
}; |
||||
|
||||
onChangeSearch = (event: ChangeEvent<HTMLInputElement>) => { |
||||
this.setState({ searchTerm: event.target.value }); |
||||
}; |
||||
|
||||
onClickRunLogsQuery = () => { |
||||
const selector = buildSelector(this.state.labels); |
||||
this.props.onChange(selector); |
||||
}; |
||||
|
||||
onClickRunMetricsQuery = () => { |
||||
const selector = buildSelector(this.state.labels); |
||||
const query = `rate(${selector}[$__interval])`; |
||||
this.props.onChange(query); |
||||
}; |
||||
|
||||
onClickClear = () => { |
||||
this.setState((state) => { |
||||
const labels: SelectableLabel[] = state.labels.map((label) => ({ |
||||
...label, |
||||
values: undefined, |
||||
selected: false, |
||||
loading: false, |
||||
hidden: false, |
||||
facets: undefined, |
||||
})); |
||||
return { labels, searchTerm: '', status: '', error: '', validationStatus: '' }; |
||||
}); |
||||
store.delete(LAST_USED_LABELS_KEY); |
||||
}; |
||||
|
||||
onClickLabel = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => { |
||||
const label = this.state.labels.find((l) => l.name === name); |
||||
if (!label) { |
||||
return; |
||||
} |
||||
// Toggle selected state
|
||||
const selected = !label.selected; |
||||
let nextValue: Partial<SelectableLabel> = { selected }; |
||||
if (label.values && !selected) { |
||||
// Deselect all values if label was deselected
|
||||
const values = label.values.map((value) => ({ ...value, selected: false })); |
||||
nextValue = { ...nextValue, facets: 0, values }; |
||||
} |
||||
// Resetting search to prevent empty results
|
||||
this.setState({ searchTerm: '' }); |
||||
this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name)); |
||||
}; |
||||
|
||||
onClickValue = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => { |
||||
const label = this.state.labels.find((l) => l.name === name); |
||||
if (!label || !label.values) { |
||||
return; |
||||
} |
||||
// Resetting search to prevent empty results
|
||||
this.setState({ searchTerm: '' }); |
||||
// Toggling value for selected label, leaving other values intact
|
||||
const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected })); |
||||
this.updateLabelState(name, { values }, '', () => this.doFacetting(name)); |
||||
}; |
||||
|
||||
onClickValidate = () => { |
||||
const selector = buildSelector(this.state.labels); |
||||
this.validateSelector(selector); |
||||
}; |
||||
|
||||
updateLabelState(name: string, updatedFields: Partial<SelectableLabel>, status = '', cb?: () => void) { |
||||
this.setState((state) => { |
||||
const labels: SelectableLabel[] = state.labels.map((label) => { |
||||
if (label.name === name) { |
||||
return { ...label, ...updatedFields }; |
||||
} |
||||
return label; |
||||
}); |
||||
// New status overrides errors
|
||||
const error = status ? '' : state.error; |
||||
return { labels, status, error, validationStatus: '' }; |
||||
}, cb); |
||||
} |
||||
|
||||
componentDidMount() { |
||||
const { languageProvider, autoSelect = MAX_AUTO_SELECT } = this.props; |
||||
if (languageProvider) { |
||||
const selectedLabels: string[] = store.getObject(LAST_USED_LABELS_KEY, []); |
||||
languageProvider.start().then(() => { |
||||
let rawLabels: string[] = languageProvider.getLabelKeys(); |
||||
if (rawLabels.length > MAX_LABEL_COUNT) { |
||||
const error = `Too many labels found (showing only ${MAX_LABEL_COUNT} of ${rawLabels.length})`; |
||||
rawLabels = rawLabels.slice(0, MAX_LABEL_COUNT); |
||||
this.setState({ error }); |
||||
} |
||||
// Auto-select all labels if label list is small enough
|
||||
const labels: SelectableLabel[] = rawLabels.map((label, i, arr) => ({ |
||||
name: label, |
||||
selected: (arr.length <= autoSelect && selectedLabels.length === 0) || selectedLabels.includes(label), |
||||
loading: false, |
||||
})); |
||||
// Pre-fetch values for selected labels
|
||||
this.setState({ labels }, () => { |
||||
this.state.labels.forEach((label) => { |
||||
if (label.selected) { |
||||
this.fetchValues(label.name); |
||||
} |
||||
}); |
||||
}); |
||||
}); |
||||
} |
||||
} |
||||
|
||||
doFacettingForLabel(name: string) { |
||||
const label = this.state.labels.find((l) => l.name === name); |
||||
if (!label) { |
||||
return; |
||||
} |
||||
const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name); |
||||
store.setObject(LAST_USED_LABELS_KEY, selectedLabels); |
||||
if (label.selected) { |
||||
// Refetch values for newly selected label...
|
||||
if (!label.values) { |
||||
this.fetchValues(name); |
||||
} |
||||
} else { |
||||
// Only need to facet when deselecting labels
|
||||
this.doFacetting(); |
||||
} |
||||
} |
||||
|
||||
doFacetting = (lastFacetted?: string) => { |
||||
const selector = buildSelector(this.state.labels); |
||||
if (selector === EMPTY_SELECTOR) { |
||||
// Clear up facetting
|
||||
const labels: SelectableLabel[] = this.state.labels.map((label) => { |
||||
return { ...label, facets: 0, values: undefined, hidden: false }; |
||||
}); |
||||
this.setState({ labels }, () => { |
||||
// Get fresh set of values
|
||||
this.state.labels.forEach((label) => label.selected && this.fetchValues(label.name)); |
||||
}); |
||||
} else { |
||||
// Do facetting
|
||||
this.fetchSeries(selector, lastFacetted); |
||||
} |
||||
}; |
||||
|
||||
async fetchValues(name: string) { |
||||
const { languageProvider } = this.props; |
||||
this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`); |
||||
try { |
||||
let rawValues = await languageProvider.getLabelValues(name); |
||||
if (rawValues.length > MAX_VALUE_COUNT) { |
||||
const error = `Too many values for ${name} (showing only ${MAX_VALUE_COUNT} of ${rawValues.length})`; |
||||
rawValues = rawValues.slice(0, MAX_VALUE_COUNT); |
||||
this.setState({ error }); |
||||
} |
||||
const values: FacettableValue[] = rawValues.map((value) => ({ name: value })); |
||||
this.updateLabelState(name, { values, loading: false }, ''); |
||||
} catch (error) { |
||||
console.error(error); |
||||
} |
||||
} |
||||
|
||||
async fetchSeries(selector: string, lastFacetted?: string) { |
||||
const { languageProvider } = this.props; |
||||
if (lastFacetted) { |
||||
this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`); |
||||
} |
||||
try { |
||||
const possibleLabels = await languageProvider.fetchSeriesLabels(selector); |
||||
if (Object.keys(possibleLabels).length === 0) { |
||||
// Sometimes the backend does not return a valid set
|
||||
console.error('No results for label combination, but should not occur.'); |
||||
this.setState({ error: `Facetting failed for ${selector}` }); |
||||
return; |
||||
} |
||||
const labels: SelectableLabel[] = facetLabels(this.state.labels, possibleLabels, lastFacetted); |
||||
this.setState({ labels, error: '' }); |
||||
if (lastFacetted) { |
||||
this.updateLabelState(lastFacetted, { loading: false }); |
||||
} |
||||
} catch (error) { |
||||
console.error(error); |
||||
} |
||||
} |
||||
|
||||
async validateSelector(selector: string) { |
||||
const { languageProvider } = this.props; |
||||
this.setState({ validationStatus: `Validating selector ${selector}`, error: '' }); |
||||
const streams = await languageProvider.fetchSeries(selector); |
||||
this.setState({ validationStatus: `Selector is valid (${streams.length} streams found)` }); |
||||
} |
||||
|
||||
render() { |
||||
const { theme } = this.props; |
||||
const { labels, searchTerm, status, error, validationStatus } = this.state; |
||||
if (labels.length === 0) { |
||||
return <LoadingPlaceholder text="Loading labels..." />; |
||||
} |
||||
const styles = getStyles(theme); |
||||
let matcher: RegExp; |
||||
let selectedLabels = labels.filter((label) => label.selected && label.values); |
||||
if (searchTerm) { |
||||
// TODO extract from render() and debounce
|
||||
try { |
||||
matcher = new RegExp(searchTerm.split('').join('.*'), 'i'); |
||||
selectedLabels = selectedLabels.map((label) => ({ |
||||
...label, |
||||
values: label.values?.filter((value) => value.selected || matcher.test(value.name)), |
||||
})); |
||||
} catch (error) {} |
||||
} |
||||
const selector = buildSelector(this.state.labels); |
||||
const empty = selector === EMPTY_SELECTOR; |
||||
return ( |
||||
<div className={styles.wrapper}> |
||||
<div className={styles.section}> |
||||
<Label description="Which labels would you like to consider for your search?"> |
||||
1. Select labels to search in |
||||
</Label> |
||||
<div className={styles.list}> |
||||
{labels.map((label) => ( |
||||
<LokiLabel |
||||
key={label.name} |
||||
name={label.name} |
||||
loading={label.loading} |
||||
active={label.selected} |
||||
hidden={label.hidden} |
||||
facets={label.facets} |
||||
onClick={this.onClickLabel} |
||||
/> |
||||
))} |
||||
</div> |
||||
</div> |
||||
<div className={styles.section}> |
||||
<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."> |
||||
2. Find values for the selected labels |
||||
</Label> |
||||
<div> |
||||
<Input onChange={this.onChangeSearch} aria-label="Filter expression for values" value={searchTerm} /> |
||||
</div> |
||||
<div className={styles.valueListArea}> |
||||
{selectedLabels.map((label) => ( |
||||
<div role="list" key={label.name} className={styles.valueListWrapper}> |
||||
<div className={styles.valueTitle} aria-label={`Values for ${label.name}`}> |
||||
<LokiLabel |
||||
name={label.name} |
||||
loading={label.loading} |
||||
active={label.selected} |
||||
hidden={label.hidden} |
||||
facets={label.facets} |
||||
onClick={this.onClickLabel} |
||||
/> |
||||
</div> |
||||
<FixedSizeList |
||||
height={200} |
||||
itemCount={label.values?.length || 0} |
||||
itemSize={25} |
||||
itemKey={(i) => (label.values as FacettableValue[])[i].name} |
||||
width={200} |
||||
className={styles.valueList} |
||||
> |
||||
{({ index, style }) => { |
||||
const value = label.values?.[index]; |
||||
if (!value) { |
||||
return null; |
||||
} |
||||
return ( |
||||
<div style={style} className={styles.valueCell}> |
||||
<LokiLabel |
||||
name={label.name} |
||||
value={value?.name} |
||||
active={value?.selected} |
||||
onClick={this.onClickValue} |
||||
searchTerm={matcher} |
||||
/> |
||||
</div> |
||||
); |
||||
}} |
||||
</FixedSizeList> |
||||
</div> |
||||
))} |
||||
</div> |
||||
</div> |
||||
<div className={styles.section}> |
||||
<Label>3. Resulting selector</Label> |
||||
<div aria-label="selector" className={styles.selector}> |
||||
{selector} |
||||
</div> |
||||
{validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>} |
||||
<HorizontalGroup> |
||||
<Button aria-label="Use selector as logs button" disabled={empty} onClick={this.onClickRunLogsQuery}> |
||||
Show logs |
||||
</Button> |
||||
<Button |
||||
aria-label="Use selector as metrics button" |
||||
variant="secondary" |
||||
disabled={empty} |
||||
onClick={this.onClickRunMetricsQuery} |
||||
> |
||||
Show logs rate |
||||
</Button> |
||||
<Button |
||||
aria-label="Validate submit button" |
||||
variant="secondary" |
||||
disabled={empty} |
||||
onClick={this.onClickValidate} |
||||
> |
||||
Validate selector |
||||
</Button> |
||||
<Button aria-label="Selector clear button" variant="secondary" onClick={this.onClickClear}> |
||||
Clear |
||||
</Button> |
||||
<div className={cx(styles.status, (status || error) && styles.statusShowing)}> |
||||
<span className={error ? styles.error : ''}>{error || status}</span> |
||||
</div> |
||||
</HorizontalGroup> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export const LokiLabelBrowser = withTheme(UnthemedLokiLabelBrowser); |
@ -1,90 +0,0 @@ |
||||
import { renderHook, act } from '@testing-library/react-hooks'; |
||||
import LanguageProvider from 'app/plugins/datasource/loki/language_provider'; |
||||
import { getLokiLabels, useLokiLabels } from './useLokiLabels'; |
||||
import { AbsoluteTimeRange } from '@grafana/data'; |
||||
import { makeMockLokiDatasource } from '../mocks'; |
||||
import { CascaderOption } from '@grafana/ui'; |
||||
|
||||
// Mocks
|
||||
const datasource = makeMockLokiDatasource({}); |
||||
const languageProvider = new LanguageProvider(datasource); |
||||
|
||||
const logLabelOptionsMock = ['Holy mock!']; |
||||
const logLabelOptionsMock2 = ['Mock the hell?!']; |
||||
const logLabelOptionsMock3 = ['Oh my mock!']; |
||||
|
||||
const rangeMock: AbsoluteTimeRange = { |
||||
from: 1560153109000, |
||||
to: 1560153109000, |
||||
}; |
||||
|
||||
describe('getLokiLabels hook', () => { |
||||
it('should refresh labels', async () => { |
||||
languageProvider.logLabelOptions = ['initial']; |
||||
|
||||
languageProvider.refreshLogLabels = () => { |
||||
languageProvider.logLabelOptions = logLabelOptionsMock; |
||||
return Promise.resolve(); |
||||
}; |
||||
|
||||
const { result, waitForNextUpdate } = renderHook(() => getLokiLabels(languageProvider, true, rangeMock)); |
||||
expect(result.current.logLabelOptions).toEqual(['initial']); |
||||
act(() => result.current.refreshLabels()); |
||||
await waitForNextUpdate(); |
||||
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock); |
||||
}); |
||||
}); |
||||
|
||||
describe('useLokiLabels hook', () => { |
||||
languageProvider.refreshLogLabels = () => { |
||||
languageProvider.logLabelOptions = logLabelOptionsMock; |
||||
return Promise.resolve(); |
||||
}; |
||||
|
||||
languageProvider.fetchLogLabels = () => { |
||||
languageProvider.logLabelOptions = logLabelOptionsMock2; |
||||
return Promise.resolve([]); |
||||
}; |
||||
|
||||
const activeOptionMock: CascaderOption = { |
||||
label: '', |
||||
value: '', |
||||
}; |
||||
|
||||
it('should fetch labels on first call', async () => { |
||||
const { result, waitForNextUpdate } = renderHook(() => useLokiLabels(languageProvider, rangeMock)); |
||||
expect(result.current.logLabelOptions).toEqual([]); |
||||
|
||||
await waitForNextUpdate(); |
||||
|
||||
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock2); |
||||
}); |
||||
|
||||
it('should try to fetch missing options when active option changes', async () => { |
||||
const { result, waitForNextUpdate } = renderHook(() => useLokiLabels(languageProvider, rangeMock)); |
||||
await waitForNextUpdate(); |
||||
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock2); |
||||
|
||||
languageProvider.fetchLabelValues = (key: string, absoluteRange: AbsoluteTimeRange) => { |
||||
languageProvider.logLabelOptions = logLabelOptionsMock3; |
||||
return Promise.resolve([]); |
||||
}; |
||||
|
||||
act(() => result.current.setActiveOption([activeOptionMock])); |
||||
|
||||
await waitForNextUpdate(); |
||||
|
||||
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock3); |
||||
}); |
||||
|
||||
it('should refresh labels', async () => { |
||||
const { result, waitForNextUpdate } = renderHook(() => useLokiLabels(languageProvider, rangeMock)); |
||||
|
||||
expect(result.current.logLabelOptions).toEqual([]); |
||||
|
||||
act(() => result.current.refreshLabels()); |
||||
await waitForNextUpdate(); |
||||
|
||||
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock); |
||||
}); |
||||
}); |
@ -1,147 +0,0 @@ |
||||
import { useState, useEffect } from 'react'; |
||||
import { isEqual } from 'lodash'; |
||||
import { AbsoluteTimeRange } from '@grafana/data'; |
||||
import { CascaderOption } from '@grafana/ui'; |
||||
|
||||
import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider'; |
||||
import { useRefMounted } from 'app/core/hooks/useRefMounted'; |
||||
|
||||
/** |
||||
* Initialise the language provider. Returns a languageProviderInitialized boolean cause there does not seem other way |
||||
* to know if the provider is already initialised or not. By the initialisation it modifies the provided |
||||
* languageProvider directly. |
||||
*/ |
||||
const useInitLanguageProvider = (languageProvider: LokiLanguageProvider, absoluteRange: AbsoluteTimeRange) => { |
||||
const mounted = useRefMounted(); |
||||
|
||||
const [languageProviderInitialized, setLanguageProviderInitialized] = useState(false); |
||||
|
||||
// Async
|
||||
const initializeLanguageProvider = async () => { |
||||
languageProvider.initialRange = absoluteRange; |
||||
await languageProvider.start(); |
||||
if (mounted.current) { |
||||
setLanguageProviderInitialized(true); |
||||
} |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
initializeLanguageProvider(); |
||||
}, []); |
||||
|
||||
return languageProviderInitialized; |
||||
}; |
||||
|
||||
/** |
||||
* |
||||
* @param languageProvider |
||||
* @param languageProviderInitialized |
||||
* @param absoluteRange |
||||
* |
||||
* @description Fetches missing labels and enables labels refresh |
||||
*/ |
||||
export const getLokiLabels = ( |
||||
languageProvider: LokiLanguageProvider, |
||||
languageProviderInitialized: boolean, |
||||
absoluteRange: AbsoluteTimeRange |
||||
) => { |
||||
const mounted = useRefMounted(); |
||||
|
||||
// State
|
||||
const [logLabelOptions, setLogLabelOptions] = useState<any>([]); |
||||
const [labelsLoaded, setLabelsLoaded] = useState(false); |
||||
const [shouldTryRefreshLabels, setRefreshLabels] = useState(false); |
||||
const [prevAbsoluteRange, setPrevAbsoluteRange] = useState<AbsoluteTimeRange | null>(null); |
||||
/** |
||||
* Holds information about currently selected option from rc-cascader to perform effect |
||||
* that loads option values not fetched yet. Based on that useLokiLabels hook decides whether or not |
||||
* the option requires additional data fetching |
||||
*/ |
||||
const [activeOption, setActiveOption] = useState<CascaderOption[]>([]); |
||||
|
||||
// Async
|
||||
const fetchOptionValues = async (option: string) => { |
||||
await languageProvider.fetchLabelValues(option, absoluteRange); |
||||
if (mounted.current) { |
||||
setLogLabelOptions(languageProvider.logLabelOptions); |
||||
setLabelsLoaded(true); |
||||
} |
||||
}; |
||||
|
||||
const tryLabelsRefresh = async () => { |
||||
await languageProvider.refreshLogLabels(absoluteRange, !isEqual(absoluteRange, prevAbsoluteRange)); |
||||
setPrevAbsoluteRange(absoluteRange); |
||||
|
||||
if (mounted.current) { |
||||
setRefreshLabels(false); |
||||
setLogLabelOptions(languageProvider.logLabelOptions); |
||||
} |
||||
}; |
||||
|
||||
// Effects
|
||||
|
||||
// This effect performs loading of options that hasn't been loaded yet
|
||||
// It's a subject of activeOption state change only. This is because of specific behavior or rc-cascader
|
||||
// https://github.com/react-component/cascader/blob/master/src/Cascader.jsx#L165
|
||||
useEffect(() => { |
||||
if (languageProviderInitialized) { |
||||
const targetOption = activeOption[activeOption.length - 1]; |
||||
if (targetOption) { |
||||
const nextOptions = logLabelOptions.map((option: any) => { |
||||
if (option.value === targetOption.value) { |
||||
return { |
||||
...option, |
||||
loading: true, |
||||
}; |
||||
} |
||||
return option; |
||||
}); |
||||
setLogLabelOptions(nextOptions); // to set loading
|
||||
fetchOptionValues(targetOption.value); |
||||
} |
||||
} |
||||
}, [activeOption]); |
||||
|
||||
// This effect is performed on shouldTryRefreshLabels state change only.
|
||||
// Since shouldTryRefreshLabels is reset AFTER the labels are refreshed we are secured in case of trying to refresh
|
||||
// when previous refresh hasn't finished yet
|
||||
useEffect(() => { |
||||
if (shouldTryRefreshLabels) { |
||||
tryLabelsRefresh(); |
||||
} |
||||
}, [shouldTryRefreshLabels]); |
||||
|
||||
// Initialize labels from the provider after it gets initialized (it's initialisation happens outside of this hook)
|
||||
useEffect(() => { |
||||
if (languageProviderInitialized) { |
||||
setLogLabelOptions(languageProvider.logLabelOptions); |
||||
setLabelsLoaded(true); |
||||
} |
||||
}, [languageProviderInitialized]); |
||||
|
||||
return { |
||||
logLabelOptions, |
||||
refreshLabels: () => setRefreshLabels(true), |
||||
setActiveOption, |
||||
labelsLoaded, |
||||
}; |
||||
}; |
||||
|
||||
/** |
||||
* Initializes given language provider and enables loading label option values |
||||
*/ |
||||
export const useLokiLabels = (languageProvider: LokiLanguageProvider, absoluteRange: AbsoluteTimeRange) => { |
||||
const languageProviderInitialized = useInitLanguageProvider(languageProvider, absoluteRange); |
||||
const { logLabelOptions, refreshLabels, setActiveOption, labelsLoaded } = getLokiLabels( |
||||
languageProvider, |
||||
languageProviderInitialized, |
||||
absoluteRange |
||||
); |
||||
|
||||
return { |
||||
logLabelOptions, |
||||
refreshLabels, |
||||
setActiveOption, |
||||
labelsLoaded, |
||||
}; |
||||
}; |
Loading…
Reference in new issue