mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
d306f417d3
commit
091e3cf4f8
@ -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>
|
||||
);
|
||||
|
121
public/app/plugins/datasource/loki/components/LokiLabel.tsx
Normal file
121
public/app/plugins/datasource/loki/components/LokiLabel.tsx
Normal file
@ -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);
|
||||
};
|
||||
async componentDidUpdate() {
|
||||
await this.props.datasource.languageProvider.start();
|
||||
this.setState({ labelsLoaded: true });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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 || []}
|
||||
disabled={buttonDisabled}
|
||||
onChange={this.onChangeLogLabels}
|
||||
loadData={onLoadOptions}
|
||||
onPopupVisibleChange={(isVisible) => isVisible && onLabelsRefresh && onLabelsRefresh()}
|
||||
>
|
||||
{chooserText}
|
||||
</ButtonCascader>
|
||||
</div>
|
||||
<button
|
||||
className="gf-form-label query-keyword pointer"
|
||||
onClick={this.onClickChooserButton}
|
||||
disabled={buttonDisabled}
|
||||
>
|
||||
{chooserText}
|
||||
<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…
Reference in New Issue
Block a user