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
David 4 years ago committed by GitHub
parent d306f417d3
commit 091e3cf4f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      public/app/plugins/datasource/loki/components/AnnotationsQueryEditor.tsx
  2. 121
      public/app/plugins/datasource/loki/components/LokiLabel.tsx
  3. 241
      public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx
  4. 494
      public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx
  5. 25
      public/app/plugins/datasource/loki/components/LokiQueryField.tsx
  6. 83
      public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx
  7. 2
      public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap
  8. 90
      public/app/plugins/datasource/loki/components/useLokiLabels.test.ts
  9. 147
      public/app/plugins/datasource/loki/components/useLokiLabels.ts
  10. 10
      public/app/plugins/datasource/loki/datasource.test.ts
  11. 19
      public/app/plugins/datasource/loki/datasource.ts
  12. 108
      public/app/plugins/datasource/loki/language_provider.test.ts
  13. 56
      public/app/plugins/datasource/loki/language_provider.ts
  14. 11
      public/app/plugins/datasource/loki/mocks.ts

@ -3,7 +3,6 @@ import React, { memo } from 'react';
// Types
import { LokiQuery } from '../types';
import { useLokiLabels } from './useLokiLabels';
import { LokiQueryFieldForm } from './LokiQueryFieldForm';
import LokiDatasource from '../datasource';
@ -24,11 +23,6 @@ export const LokiAnnotationsQueryEditor = memo(function LokiAnnotationQueryEdito
to: Date.now(),
};
const { setActiveOption, refreshLabels, logLabelOptions, labelsLoaded } = useLokiLabels(
datasource.languageProvider,
absolute
);
const queryWithRefId: LokiQuery = {
refId: '',
expr,
@ -43,11 +37,7 @@ export const LokiAnnotationsQueryEditor = memo(function LokiAnnotationQueryEdito
onChange={onChange}
onRunQuery={() => {}}
history={[]}
onLoadOptions={setActiveOption}
onLabelsRefresh={refreshLabels}
absoluteRange={absolute}
labelsLoaded={labelsLoaded}
logLabelOptions={logLabelOptions}
/>
</div>
);

@ -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,7 +1,5 @@
import React, { FunctionComponent } from 'react';
import { LokiQueryFieldForm, LokiQueryFieldFormProps } from './LokiQueryFieldForm';
import { useLokiLabels } from './useLokiLabels';
import LokiLanguageProvider from '../language_provider';
type LokiQueryFieldProps = Omit<
LokiQueryFieldFormProps,
@ -12,28 +10,7 @@ export const LokiQueryField: FunctionComponent<LokiQueryFieldProps> = (props) =>
const { datasource, range, ...otherProps } = props;
const absoluteTimeRange = { from: range!.from!.valueOf(), to: range!.to!.valueOf() }; // Range here is never optional
const { setActiveOption, refreshLabels, logLabelOptions, labelsLoaded } = useLokiLabels(
datasource.languageProvider as LokiLanguageProvider,
absoluteTimeRange
);
return (
<LokiQueryFieldForm
datasource={datasource}
/**
* setActiveOption name is intentional. Because of the way rc-cascader requests additional data
* https://github.com/react-component/cascader/blob/master/src/Cascader.jsx#L165
* we are notyfing useLokiSyntax hook, what the active option is, and then it's up to the hook logic
* to fetch data of options that aren't fetched yet
*/
onLoadOptions={setActiveOption}
onLabelsRefresh={refreshLabels}
absoluteRange={absoluteTimeRange}
labelsLoaded={labelsLoaded}
logLabelOptions={logLabelOptions}
{...otherProps}
/>
);
return <LokiQueryFieldForm datasource={datasource} absoluteRange={absoluteTimeRange} {...otherProps} />;
};
export default LokiQueryField;

@ -2,8 +2,6 @@
import React, { ReactNode } from 'react';
import {
ButtonCascader,
CascaderOption,
SlatePrism,
TypeaheadOutput,
SuggestionsState,
@ -11,11 +9,13 @@ import {
TypeaheadInput,
BracesPlugin,
DOMUtil,
Icon,
} from '@grafana/ui';
// Utils & Services
// dom also includes Element polyfills
import { Plugin, Node } from 'slate';
import { LokiLabelBrowser } from './LokiLabelBrowser';
// Types
import { ExploreQueryFieldProps, AbsoluteTimeRange } from '@grafana/data';
@ -30,9 +30,9 @@ function getChooserText(hasSyntax: boolean, hasLogLabels: boolean) {
return 'Loading labels...';
}
if (!hasLogLabels) {
return '(No labels found)';
return '(No logs found)';
}
return 'Log labels';
return 'Log browser';
}
function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string {
@ -64,20 +64,23 @@ function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadTe
export interface LokiQueryFieldFormProps extends ExploreQueryFieldProps<LokiDatasource, LokiQuery, LokiOptions> {
history: LokiHistoryItem[];
logLabelOptions: CascaderOption[];
labelsLoaded: boolean;
absoluteRange: AbsoluteTimeRange;
onLoadOptions: (selectedOptions: CascaderOption[]) => void;
onLabelsRefresh?: () => void;
ExtraFieldElement?: ReactNode;
runOnBlur?: boolean;
}
export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormProps> {
interface LokiQueryFieldFormState {
labelsLoaded: boolean;
labelBrowserVisible: boolean;
}
export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormProps, LokiQueryFieldFormState> {
plugins: Plugin[];
constructor(props: LokiQueryFieldFormProps, context: React.Context<any>) {
super(props, context);
constructor(props: LokiQueryFieldFormProps) {
super(props);
this.state = { labelsLoaded: false, labelBrowserVisible: false };
this.plugins = [
BracesPlugin(),
@ -91,17 +94,14 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
];
}
loadOptions = (selectedOptions: CascaderOption[]) => {
this.props.onLoadOptions(selectedOptions);
};
onChangeLogLabels = (values: string[], selectedOptions: CascaderOption[]) => {
if (selectedOptions.length === 2) {
const key = selectedOptions[0].value;
const value = selectedOptions[1].value;
const query = `{${key}="${value}"}`;
this.onChangeQuery(query, true);
async componentDidUpdate() {
await this.props.datasource.languageProvider.start();
this.setState({ labelsLoaded: true });
}
onChangeLogLabels = (selector: string) => {
this.onChangeQuery(selector, true);
this.setState({ labelBrowserVisible: false });
};
onChangeQuery = (value: string, override?: boolean) => {
@ -117,6 +117,10 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
}
};
onClickChooserButton = () => {
this.setState((state) => ({ labelBrowserVisible: !state.labelBrowserVisible }));
};
onTypeahead = async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => {
const { datasource } = this.props;
@ -125,48 +129,36 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
}
const lokiLanguageProvider = datasource.languageProvider as LokiLanguageProvider;
const { history, absoluteRange } = this.props;
const { history } = this.props;
const { prefix, text, value, wrapperClasses, labelKey } = typeahead;
const result = await lokiLanguageProvider.provideCompletionItems(
{ text, value, prefix, wrapperClasses, labelKey },
{ history, absoluteRange }
{ history }
);
return result;
};
render() {
const {
ExtraFieldElement,
query,
labelsLoaded,
logLabelOptions,
onLoadOptions,
onLabelsRefresh,
datasource,
runOnBlur,
} = this.props;
const { ExtraFieldElement, query, datasource, runOnBlur } = this.props;
const { labelsLoaded, labelBrowserVisible } = this.state;
const lokiLanguageProvider = datasource.languageProvider as LokiLanguageProvider;
const cleanText = datasource.languageProvider ? lokiLanguageProvider.cleanText : undefined;
const hasLogLabels = logLabelOptions && logLabelOptions.length > 0;
const hasLogLabels = lokiLanguageProvider.getLabelKeys().length > 0;
const chooserText = getChooserText(labelsLoaded, hasLogLabels);
const buttonDisabled = !(labelsLoaded && hasLogLabels);
return (
<>
<div className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1">
<div className="gf-form flex-shrink-0 min-width-5">
<ButtonCascader
options={logLabelOptions || []}
<button
className="gf-form-label query-keyword pointer"
onClick={this.onClickChooserButton}
disabled={buttonDisabled}
onChange={this.onChangeLogLabels}
loadData={onLoadOptions}
onPopupVisibleChange={(isVisible) => isVisible && onLabelsRefresh && onLabelsRefresh()}
>
{chooserText}
</ButtonCascader>
</div>
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
</button>
<div className="gf-form gf-form--grow flex-shrink-1 min-width-15">
<QueryField
additionalPlugins={this.plugins}
@ -182,6 +174,11 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
/>
</div>
</div>
{labelBrowserVisible && (
<div className="gf-form">
<LokiLabelBrowser languageProvider={lokiLanguageProvider} onChange={this.onChangeLogLabels} />
</div>
)}
<LokiOptionFields
queryType={query.instant ? 'instant' : 'range'}
lineLimitValue={query?.maxLines?.toString() || ''}

@ -38,10 +38,12 @@ exports[`LokiExploreQueryEditor should render component 1`] = `
}
datasource={
Object {
"getTimeRangeParams": [Function],
"languageProvider": LokiLanguageProvider {
"addLabelValuesToOptions": [Function],
"cleanText": [Function],
"datasource": [Circular],
"fetchSeries": [Function],
"fetchSeriesLabels": [Function],
"getBeginningCompletionItems": [Function],
"getPipeCompletionItem": [Function],

@ -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,
};
};

@ -531,16 +531,6 @@ describe('LokiDatasource', () => {
expect(res).toEqual([]);
});
});
mocks.forEach((mock, index) => {
it(`should return label names according to provided rangefor Loki v${index}`, async () => {
const { ds } = getTestContext(mock);
const res = await ds.metricFindQuery('label_names()', { range: { from: new Date(2), to: new Date(3) } });
expect(res).toEqual([{ text: 'label1' }]);
});
});
});
});

@ -23,7 +23,6 @@ import {
PluginMeta,
QueryResultMeta,
ScopedVars,
TimeRange,
} from '@grafana/data';
import { getTemplateSrv, TemplateSrv, BackendSrvRequest, FetchError, getBackendSrv } from '@grafana/runtime';
import { addLabelToQuery } from 'app/plugins/datasource/prometheus/add_label_to_query';
@ -46,7 +45,7 @@ import {
LokiStreamResponse,
} from './types';
import { LiveStreams, LokiLiveTarget } from './live_streams';
import LanguageProvider, { rangeToParams } from './language_provider';
import LanguageProvider from './language_provider';
import { serializeParams } from '../../../core/utils/fetch';
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
import syntax from './syntax';
@ -54,6 +53,7 @@ import syntax from './syntax';
export type RangeQueryOptions = DataQueryRequest<LokiQuery> | AnnotationQueryRequest<LokiQuery>;
export const DEFAULT_MAX_LINES = 1000;
export const LOKI_ENDPOINT = '/loki/api/v1';
const NS_IN_MS = 1000000;
const RANGE_QUERY_ENDPOINT = `${LOKI_ENDPOINT}/query_range`;
const INSTANT_QUERY_ENDPOINT = `${LOKI_ENDPOINT}/query`;
@ -287,6 +287,11 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
return query.expr;
}
getTimeRangeParams() {
const timeRange = this.timeSrv.timeRange();
return { from: timeRange.from.valueOf() * NS_IN_MS, to: timeRange.to.valueOf() * NS_IN_MS };
}
async importQueries(queries: LokiQuery[], originMeta: PluginMeta): Promise<LokiQuery[]> {
return this.languageProvider.importQueries(queries, originMeta.id);
}
@ -296,21 +301,19 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
return res.data.data || res.data.values || [];
}
async metricFindQuery(query: string, optionalOptions?: any) {
async metricFindQuery(query: string) {
if (!query) {
return Promise.resolve([]);
}
const interpolated = this.templateSrv.replace(query, {}, this.interpolateQueryExpr);
return await this.processMetricFindQuery(interpolated, optionalOptions?.range);
return await this.processMetricFindQuery(interpolated);
}
async processMetricFindQuery(query: string, range?: TimeRange) {
async processMetricFindQuery(query: string) {
const labelNamesRegex = /^label_names\(\)\s*$/;
const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\)\s*$/;
const timeRange = range || this.timeSrv.timeRange();
const params = rangeToParams({ from: timeRange.from.valueOf(), to: timeRange.to.valueOf() });
const params = this.getTimeRangeParams();
const labelNames = query.match(labelNamesRegex);
if (labelNames) {
return await this.labelNamesQuery(params);

@ -1,10 +1,7 @@
import Plain from 'slate-plain-serializer';
import LanguageProvider, { LABEL_REFRESH_INTERVAL, LokiHistoryItem, rangeToParams } from './language_provider';
import { AbsoluteTimeRange } from '@grafana/data';
import LanguageProvider, { LokiHistoryItem } from './language_provider';
import { TypeaheadInput } from '@grafana/ui';
import { advanceTo, clear, advanceBy } from 'jest-date-mock';
import { beforeEach } from 'test/lib/common';
import { makeMockLokiDatasource } from './mocks';
import LokiDatasource from './datasource';
@ -24,11 +21,6 @@ jest.mock('app/store/store', () => ({
describe('Language completion provider', () => {
const datasource = makeMockLokiDatasource({});
const rangeMock: AbsoluteTimeRange = {
from: 1560153109000,
to: 1560163909000,
};
describe('query suggestions', () => {
it('returns no suggestions on empty context', async () => {
const instance = new LanguageProvider(datasource);
@ -50,7 +42,7 @@ describe('Language completion provider', () => {
];
const result = await instance.provideCompletionItems(
{ text: '', prefix: '', value, wrapperClasses: [] },
{ history, absoluteRange: rangeMock }
{ history }
);
expect(result.context).toBeUndefined();
@ -86,7 +78,7 @@ describe('Language completion provider', () => {
it('returns pipe operations on pipe context', async () => {
const instance = new LanguageProvider(datasource);
const input = createTypeaheadInput('{app="test"} | ', ' ', '', 15, ['context-pipe']);
const result = await instance.provideCompletionItems(input, { absoluteRange: rangeMock });
const result = await instance.provideCompletionItems(input);
expect(result.context).toBeUndefined();
expect(result.suggestions.length).toEqual(2);
expect(result.suggestions[0].label).toEqual('Operators');
@ -99,7 +91,7 @@ describe('Language completion provider', () => {
const datasource = makeMockLokiDatasource({ label1: [], label2: [] });
const provider = await getLanguageProvider(datasource);
const input = createTypeaheadInput('{}', '', '', 1);
const result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock });
const result = await provider.provideCompletionItems(input);
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([
{
@ -116,7 +108,7 @@ describe('Language completion provider', () => {
const datasource = makeMockLokiDatasource({ label1: [], label2: [] });
const provider = await getLanguageProvider(datasource);
const input = createTypeaheadInput('{l}', '', '', 2);
const result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock });
const result = await provider.provideCompletionItems(input);
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([
{
@ -138,7 +130,7 @@ describe('Language completion provider', () => {
);
const provider = await getLanguageProvider(datasource);
const input = createTypeaheadInput('{foo="bar",}', '', '', 11);
const result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock });
const result = await provider.provideCompletionItems(input);
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'label1' }], label: 'Labels' }]);
});
@ -150,7 +142,7 @@ describe('Language completion provider', () => {
);
const provider = await getLanguageProvider(datasource);
const input = createTypeaheadInput('{baz="42",foo="bar",}', '', '', 20);
const result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock });
const result = await provider.provideCompletionItems(input);
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'label2' }], label: 'Labels' }]);
});
@ -161,9 +153,9 @@ describe('Language completion provider', () => {
const datasource = makeMockLokiDatasource({ label1: ['label1_val1', 'label1_val2'], label2: [] });
const provider = await getLanguageProvider(datasource);
const input = createTypeaheadInput('{label1=}', '=', 'label1');
let result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock });
let result = await provider.provideCompletionItems(input);
result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock });
result = await provider.provideCompletionItems(input);
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([
{
@ -179,33 +171,23 @@ describe('Language completion provider', () => {
describe('label values', () => {
it('should fetch label values if not cached', async () => {
const absoluteRange: AbsoluteTimeRange = {
from: 0,
to: 5000,
};
const datasource = makeMockLokiDatasource({ testkey: ['label1_val1', 'label1_val2'], label2: [] });
const provider = await getLanguageProvider(datasource);
const requestSpy = jest.spyOn(provider, 'request');
const labelValues = await provider.fetchLabelValues('testkey', absoluteRange);
const labelValues = await provider.fetchLabelValues('testkey');
expect(requestSpy).toHaveBeenCalled();
expect(labelValues).toEqual(['label1_val1', 'label1_val2']);
});
it('should return cached values', async () => {
const absoluteRange: AbsoluteTimeRange = {
from: 0,
to: 5000,
};
const datasource = makeMockLokiDatasource({ testkey: ['label1_val1', 'label1_val2'], label2: [] });
const provider = await getLanguageProvider(datasource);
const requestSpy = jest.spyOn(provider, 'request');
const labelValues = await provider.fetchLabelValues('testkey', absoluteRange);
const labelValues = await provider.fetchLabelValues('testkey');
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(labelValues).toEqual(['label1_val1', 'label1_val2']);
const nextLabelValues = await provider.fetchLabelValues('testkey', absoluteRange);
const nextLabelValues = await provider.fetchLabelValues('testkey');
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(nextLabelValues).toEqual(['label1_val1', 'label1_val2']);
});
@ -214,106 +196,58 @@ describe('Language completion provider', () => {
describe('Request URL', () => {
it('should contain range params', async () => {
const rangeMock: AbsoluteTimeRange = {
from: 1560153109000,
to: 1560163909000,
};
const datasourceWithLabels = makeMockLokiDatasource({ other: [] });
const rangeParams = datasourceWithLabels.getTimeRangeParams();
const datasourceSpy = jest.spyOn(datasourceWithLabels as any, 'metadataRequest');
const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock });
await instance.refreshLogLabels(rangeMock, true);
const instance = new LanguageProvider(datasourceWithLabels);
instance.fetchLogLabels();
const expectedUrl = '/loki/api/v1/label';
expect(datasourceSpy).toHaveBeenCalledWith(expectedUrl, rangeToParams(rangeMock));
expect(datasourceSpy).toHaveBeenCalledWith(expectedUrl, rangeParams);
});
});
describe('Query imports', () => {
const datasource = makeMockLokiDatasource({});
const rangeMock: AbsoluteTimeRange = {
from: 1560153109000,
to: 1560163909000,
};
it('returns empty queries for unknown origin datasource', async () => {
const instance = new LanguageProvider(datasource, { initialRange: rangeMock });
const instance = new LanguageProvider(datasource);
const result = await instance.importQueries([{ refId: 'bar', expr: 'foo' }], 'unknown');
expect(result).toEqual([{ refId: 'bar', expr: '' }]);
});
describe('prometheus query imports', () => {
it('returns empty query from metric-only query', async () => {
const instance = new LanguageProvider(datasource, { initialRange: rangeMock });
const instance = new LanguageProvider(datasource);
const result = await instance.importPrometheusQuery('foo');
expect(result).toEqual('');
});
it('returns empty query from selector query if label is not available', async () => {
const datasourceWithLabels = makeMockLokiDatasource({ other: [] });
const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock });
const instance = new LanguageProvider(datasourceWithLabels);
const result = await instance.importPrometheusQuery('{foo="bar"}');
expect(result).toEqual('{}');
});
it('returns selector query from selector query with common labels', async () => {
const datasourceWithLabels = makeMockLokiDatasource({ foo: [] });
const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock });
const instance = new LanguageProvider(datasourceWithLabels);
const result = await instance.importPrometheusQuery('metric{foo="bar",baz="42"}');
expect(result).toEqual('{foo="bar"}');
});
it('returns selector query from selector query with all labels if logging label list is empty', async () => {
const datasourceWithLabels = makeMockLokiDatasource({});
const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock });
const instance = new LanguageProvider(datasourceWithLabels);
const result = await instance.importPrometheusQuery('metric{foo="bar",baz="42"}');
expect(result).toEqual('{baz="42",foo="bar"}');
});
});
});
describe('Labels refresh', () => {
const datasource = makeMockLokiDatasource({});
const instance = new LanguageProvider(datasource);
const rangeMock: AbsoluteTimeRange = {
from: 1560153109000,
to: 1560163909000,
};
beforeEach(() => {
instance.fetchLogLabels = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
clear();
});
it("should not refresh labels if refresh interval hasn't passed", () => {
advanceTo(new Date(2019, 1, 1, 0, 0, 0));
instance.logLabelFetchTs = Date.now();
advanceBy(LABEL_REFRESH_INTERVAL / 2);
instance.refreshLogLabels(rangeMock);
expect(instance.fetchLogLabels).not.toBeCalled();
});
it('should refresh labels if refresh interval passed', () => {
advanceTo(new Date(2019, 1, 1, 0, 0, 0));
instance.logLabelFetchTs = Date.now();
advanceBy(LABEL_REFRESH_INTERVAL + 1);
instance.refreshLogLabels(rangeMock);
expect(instance.fetchLogLabels).toBeCalled();
});
});
async function getLanguageProvider(datasource: LokiDatasource) {
const instance = new LanguageProvider(datasource);
instance.initialRange = {
from: Date.now() - 10000,
to: Date.now(),
};
await instance.start();
return instance;
}

@ -30,8 +30,6 @@ export const LABEL_REFRESH_INTERVAL = 1000 * 30; // 30sec
const wrapLabel = (label: string) => ({ label, filterText: `\"${label}\"` });
export const rangeToParams = (range: AbsoluteTimeRange) => ({ start: range.from * NS_IN_MS, end: range.to * NS_IN_MS });
export type LokiHistoryItem = HistoryItem<LokiQuery>;
type TypeaheadContext = {
@ -61,7 +59,6 @@ export default class LokiLanguageProvider extends LanguageProvider {
logLabelOptions: any[];
logLabelFetchTs: number;
started: boolean;
initialRange: AbsoluteTimeRange;
datasource: LokiDatasource;
lookupsDisabled: boolean; // Dynamically set to true for big/slow instances
@ -106,7 +103,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
*/
start = () => {
if (!this.startTask) {
this.startTask = this.fetchLogLabels(this.initialRange).then(() => {
this.startTask = this.fetchLogLabels().then(() => {
this.started = true;
return [];
});
@ -164,7 +161,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
return this.getRangeCompletionItems();
} else if (wrapperClasses.includes('context-labels')) {
// Suggestions for {|} and {foo=|}
return await this.getLabelCompletionItems(input, context);
return await this.getLabelCompletionItems(input);
} else if (wrapperClasses.includes('context-pipe')) {
return this.getPipeCompletionItem();
} else if (empty) {
@ -252,10 +249,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
};
}
async getLabelCompletionItems(
{ text, wrapperClasses, labelKey, value }: TypeaheadInput,
{ absoluteRange }: any
): Promise<TypeaheadOutput> {
async getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): Promise<TypeaheadOutput> {
let context = 'context-labels';
const suggestions: CompletionItemGroup[] = [];
if (!value) {
@ -288,10 +282,10 @@ export default class LokiLanguageProvider extends LanguageProvider {
// Query labels for selector
if (selector) {
if (selector === EMPTY_SELECTOR && labelKey) {
const labelValuesForKey = await this.getLabelValues(labelKey, absoluteRange);
const labelValuesForKey = await this.getLabelValues(labelKey);
labelValues = { [labelKey]: labelValuesForKey };
} else {
labelValues = await this.getSeriesLabels(selector, absoluteRange);
labelValues = await this.getSeriesLabels(selector);
}
}
@ -389,12 +383,12 @@ export default class LokiLanguageProvider extends LanguageProvider {
return ['{', cleanSelector, '}'].join('');
}
async getSeriesLabels(selector: string, absoluteRange: AbsoluteTimeRange) {
async getSeriesLabels(selector: string) {
if (this.lookupsDisabled) {
return undefined;
}
try {
return await this.fetchSeriesLabels(selector, absoluteRange);
return await this.fetchSeriesLabels(selector);
} catch (error) {
// TODO: better error handling
console.error(error);
@ -406,11 +400,11 @@ export default class LokiLanguageProvider extends LanguageProvider {
* Fetches all label keys
* @param absoluteRange Fetches
*/
async fetchLogLabels(absoluteRange: AbsoluteTimeRange): Promise<any> {
async fetchLogLabels(): Promise<any> {
const url = '/loki/api/v1/label';
try {
this.logLabelFetchTs = Date.now().valueOf();
const rangeParams = absoluteRange ? rangeToParams(absoluteRange) : {};
const rangeParams = this.datasource.getTimeRangeParams();
const res = await this.request(url, rangeParams);
this.labelKeys = res.slice().sort();
this.logLabelOptions = this.labelKeys.map((key: string) => ({ label: key, value: key, isLeaf: false }));
@ -420,9 +414,9 @@ export default class LokiLanguageProvider extends LanguageProvider {
return [];
}
async refreshLogLabels(absoluteRange: AbsoluteTimeRange, forceRefresh?: boolean) {
async refreshLogLabels(forceRefresh?: boolean) {
if ((this.labelKeys && Date.now().valueOf() - this.logLabelFetchTs > LABEL_REFRESH_INTERVAL) || forceRefresh) {
await this.fetchLogLabels(absoluteRange);
await this.fetchLogLabels();
}
}
@ -431,17 +425,16 @@ export default class LokiLanguageProvider extends LanguageProvider {
* they can change over requested time.
* @param name
*/
fetchSeriesLabels = async (match: string, absoluteRange: AbsoluteTimeRange): Promise<Record<string, string[]>> => {
const rangeParams = absoluteRange ? rangeToParams(absoluteRange) : { start: 0, end: 0 };
fetchSeriesLabels = async (match: string): Promise<Record<string, string[]>> => {
const url = '/loki/api/v1/series';
const { start, end } = rangeParams;
const { from: start, to: end } = this.datasource.getTimeRangeParams();
const cacheKey = this.generateCacheKey(url, start, end, match);
const params = { match, start, end };
let value = this.seriesCache.get(cacheKey);
if (!value) {
// Clear value when requesting new one. Empty object being truthy also makes sure we don't request twice.
this.seriesCache.set(cacheKey, {});
const params = { match, start, end };
const data = await this.request(url, params);
const { values } = processLabels(data);
value = values;
@ -450,6 +443,17 @@ export default class LokiLanguageProvider extends LanguageProvider {
return value;
};
/**
* Fetch series for a selector. Use this for raw results. Use fetchSeriesLabels() to get labels.
* @param match
*/
fetchSeries = async (match: string): Promise<Array<Record<string, string>>> => {
const url = '/loki/api/v1/series';
const { from: start, to: end } = this.datasource.getTimeRangeParams();
const params = { match, start, end };
return await this.request(url, params);
};
// Cache key is a bit different here. We round up to a minute the intervals.
// The rounding may seem strange but makes relative intervals like now-1h less prone to need separate request every
// millisecond while still actually getting all the keys for the correct interval. This still can create problems
@ -463,15 +467,15 @@ export default class LokiLanguageProvider extends LanguageProvider {
return nanos ? Math.floor(nanos / NS_IN_MS / 1000 / 60 / 5) : 0;
}
async getLabelValues(key: string, absoluteRange = this.initialRange): Promise<string[]> {
return await this.fetchLabelValues(key, absoluteRange);
async getLabelValues(key: string): Promise<string[]> {
return await this.fetchLabelValues(key);
}
async fetchLabelValues(key: string, absoluteRange: AbsoluteTimeRange): Promise<string[]> {
async fetchLabelValues(key: string): Promise<string[]> {
const url = `/loki/api/v1/label/${key}/values`;
let values: string[] = [];
const rangeParams = absoluteRange ? rangeToParams(absoluteRange) : { start: 0, end: 0 };
const { start, end } = rangeParams;
const rangeParams = this.datasource.getTimeRangeParams();
const { from: start, to: end } = rangeParams;
const cacheKey = this.generateCacheKey(url, start, end, key);
const params = { start, end };

@ -1,5 +1,5 @@
import { LokiDatasource, LOKI_ENDPOINT } from './datasource';
import { DataSourceSettings } from '@grafana/data';
import { AbsoluteTimeRange, DataSourceSettings } from '@grafana/data';
import { LokiOptions } from './types';
import { createDatasourceSettings } from '../../../features/datasources/mocks';
@ -20,15 +20,16 @@ export function makeMockLokiDatasource(labelsAndValues: Labels, series?: SeriesF
const lokiSeriesEndpointRegex = /^\/loki\/api\/v1\/series/;
const lokiLabelsEndpoint = `${LOKI_ENDPOINT}/label`;
const rangeMock: AbsoluteTimeRange = {
from: 1560153109000,
to: 1560163909000,
};
const labels = Object.keys(labelsAndValues);
return {
getTimeRangeParams: () => rangeMock,
metadataRequest: (url: string, params?: { [key: string]: string }) => {
if (url === lokiLabelsEndpoint) {
//To test custom time ranges
if (Number(params?.start) === 2000000) {
return [labels[0]];
}
return labels;
} else {
const labelsMatch = url.match(lokiLabelsAndValuesEndpointRegex);

Loading…
Cancel
Save