Loki: Add fuzzy search to label browser (#36864)

This commit is contained in:
Connor Lindsey 2021-07-26 08:04:03 -06:00 committed by GitHub
parent a65975cca0
commit b644ea9e6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 130 additions and 164 deletions

View File

@ -1,16 +1,15 @@
import React, { forwardRef, HTMLAttributes } from 'react';
import React, { forwardRef, HTMLAttributes, useCallback } from 'react';
import { cx, css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { useTheme2 } from '../../themes';
// @ts-ignore
import Highlighter from 'react-highlight-words';
import { PartialHighlighter } from '../Typeahead/PartialHighlighter';
import { HighlightPart } from '../../types';
/**
* @public
*/
export type OnLabelClick = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => void;
type OnLabelClick = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => void;
export interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onClick'> {
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onClick'> {
name: string;
active?: boolean;
loading?: boolean;
@ -18,23 +17,45 @@ export interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onClick'> {
value?: string;
facets?: number;
title?: string;
highlightParts?: HighlightPart[];
onClick?: OnLabelClick;
}
/**
* TODO #33976: Create a common, shared component with public/app/plugins/datasource/loki/components/LokiLabel.tsx
* @internal
*/
export const Label = forwardRef<HTMLElement, Props>(
({ name, value, hidden, facets, onClick, className, loading, searchTerm, active, style, title, ...rest }, ref) => {
(
{
name,
value,
hidden,
facets,
onClick,
className,
loading,
searchTerm,
active,
style,
title,
highlightParts,
...rest
},
ref
) => {
const theme = useTheme2();
const styles = getLabelStyles(theme);
const searchWords = searchTerm ? [searchTerm] : [];
const onLabelClick = (event: React.MouseEvent<HTMLElement>) => {
if (onClick && !hidden) {
onClick(name, value, event);
}
};
const onLabelClick = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (onClick && !hidden) {
onClick(name, value, event);
}
},
[onClick, name, hidden, value]
);
// Using this component for labels and label values. If value is given use value for display text.
let text = value || name;
if (facets) {
@ -60,12 +81,16 @@ export const Label = forwardRef<HTMLElement, Props>(
)}
{...rest}
>
<Highlighter
textToHighlight={text}
searchWords={searchWords}
autoEscape
highlightClassName={styles.matchHighLight}
/>
{highlightParts !== undefined ? (
<PartialHighlighter text={text} highlightClassName={styles.matchHighLight} highlightParts={highlightParts} />
) : (
<Highlighter
textToHighlight={text}
searchWords={searchWords}
autoEscape
highlightClassName={styles.matchHighLight}
/>
)}
</span>
);
}
@ -75,11 +100,11 @@ Label.displayName = 'Label';
const getLabelStyles = (theme: GrafanaTheme2) => ({
base: css`
display: inline-block;
cursor: pointer;
font-size: ${theme.typography.size.sm};
line-height: ${theme.typography.bodySmall.lineHeight};
background-color: ${theme.colors.background.secondary};
vertical-align: baseline;
color: ${theme.colors.text};
white-space: nowrap;
text-shadow: none;

View File

@ -255,3 +255,4 @@ export { preparePlotFrame } from './GraphNG/utils';
export { GraphNGLegendEvent } from './GraphNG/types';
export * from './PanelChrome/types';
export { EmotionPerfTest } from './ThemeDemos/EmotionPerfTest';
export { Label as BrowserLabel } from './BrowserLabel/Label';

View File

@ -76,4 +76,15 @@ describe('Fuzzy search', () => {
found: true,
});
});
it('ignores whitespace in needle', () => {
expect(fuzzyMatch('bbaarr_bar_bbarr', 'bb bar')).toEqual({
ranges: [
{ start: 0, end: 1 },
{ start: 7, end: 9 },
],
distance: 5,
found: true,
});
});
});

View File

@ -20,10 +20,15 @@ type FuzzyMatch = {
*
* @param stack - main text to be searched
* @param needle - partial text to find in the stack
*
* @internal
*/
export function fuzzyMatch(stack: string, needle: string): FuzzyMatch {
let distance = 0,
searchIndex = stack.indexOf(needle);
// Remove whitespace from needle as a temporary solution to treat separate string
// queries as 'AND'
needle = needle.replace(/\s/g, '');
const ranges: HighlightPart[] = [];

View File

@ -16,3 +16,4 @@ export { renderOrCallToRender } from './renderOrCallToRender';
export { createLogger } from './logger';
export { attachDebugger } from './debug';
export * from './nodeGraph';
export { fuzzyMatch } from './fuzzy';

View File

@ -1,5 +1,5 @@
import { CompletionItem, SearchFunction } from '../types';
import { fuzzyMatch } from '../slate-plugins/fuzzy';
import { fuzzyMatch } from './fuzzy';
/**
* List of auto-complete search function used by SuggestionsPlugin.handleTypeahead()

View File

@ -1,125 +0,0 @@
import React, { forwardRef, HTMLAttributes } from 'react';
import { cx, css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } 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?: string;
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 = useTheme2();
const styles = getLabelStyles(theme);
const searchWords = searchTerm ? [searchTerm] : [];
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={searchWords}
autoEscape={true}
highlightClassName={styles.matchHighLight}
/>
</span>
);
}
);
LokiLabel.displayName = 'LokiLabel';
const getLabelStyles = (theme: GrafanaTheme2) => ({
base: css`
cursor: pointer;
font-size: ${theme.typography.size.sm};
line-height: ${theme.typography.bodySmall.lineHeight};
background-color: ${theme.colors.background.secondary};
vertical-align: baseline;
color: ${theme.colors.text};
white-space: nowrap;
text-shadow: none;
padding: ${theme.spacing(0.5)};
border-radius: ${theme.shape.borderRadius()};
margin-right: ${theme.spacing(1)};
margin-bottom: ${theme.spacing(0.5)};
`,
loading: css`
font-weight: ${theme.typography.fontWeightMedium};
background-color: ${theme.colors.primary.shade};
color: ${theme.colors.text.primary};
animation: pulse 3s ease-out 0s infinite normal forwards;
@keyframes pulse {
0% {
color: ${theme.colors.text.primary};
}
50% {
color: ${theme.colors.text.secondary};
}
100% {
color: ${theme.colors.text.disabled};
}
}
`,
active: css`
font-weight: ${theme.typography.fontWeightMedium};
background-color: ${theme.colors.primary.main};
color: ${theme.colors.primary.contrastText};
`,
matchHighLight: css`
background: inherit;
color: ${theme.colors.primary.text};
background-color: ${theme.colors.primary.transparent};
`,
hidden: css`
opacity: 0.6;
cursor: default;
border: 1px solid transparent;
`,
hover: css`
&:hover {
opacity: 0.85;
cursor: pointer;
}
`,
});

View File

@ -246,8 +246,8 @@ describe('LokiLabelBrowser', () => {
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');
userEvent.type(screen.getByLabelText('Filter expression for values'), 'val1');
expect(screen.getByLabelText('Filter expression for values')).toHaveValue('val1');
expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument();
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(3);
});

View File

@ -1,19 +1,29 @@
import React, { ChangeEvent } from 'react';
import { Button, HorizontalGroup, Input, Label, LoadingPlaceholder, withTheme2 } from '@grafana/ui';
import {
Button,
HighlightPart,
HorizontalGroup,
Input,
Label,
LoadingPlaceholder,
withTheme2,
BrowserLabel as LokiLabel,
fuzzyMatch,
} from '@grafana/ui';
import LokiLanguageProvider from '../language_provider';
import PromQlLanguageProvider from '../../prometheus/language_provider';
import { css, cx } from '@emotion/css';
import store from 'app/core/store';
import { FixedSizeList } from 'react-window';
import { GrafanaTheme2 } from '@grafana/data';
import { LokiLabel } from './LokiLabel';
import { sortBy } from 'lodash';
// Hard limit on labels to render
const MAX_LABEL_COUNT = 1000;
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 {
@ -36,6 +46,8 @@ interface BrowserState {
interface FacettableValue {
name: string;
selected?: boolean;
highlightParts?: HighlightPart[];
order?: number;
}
export interface SelectableLabel {
@ -378,15 +390,42 @@ export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, Brow
return <LoadingPlaceholder text="Loading labels..." />;
}
const styles = getStyles(theme);
let selectedLabels = labels.filter((label) => label.selected && label.values);
if (searchTerm) {
selectedLabels = selectedLabels.map((label) => ({
...label,
values: label.values?.filter((value) => value.selected || value.name.includes(searchTerm)),
}));
}
const selector = buildSelector(this.state.labels);
const empty = selector === EMPTY_SELECTOR;
let selectedLabels = labels.filter((label) => label.selected && label.values);
if (searchTerm) {
selectedLabels = selectedLabels.map((label) => {
const searchResults = label.values!.filter((value) => {
// Always return selected values
if (value.selected) {
value.highlightParts = undefined;
return true;
}
const fuzzyMatchResult = fuzzyMatch(value.name.toLowerCase(), searchTerm.toLowerCase());
if (fuzzyMatchResult.found) {
value.highlightParts = fuzzyMatchResult.ranges;
value.order = fuzzyMatchResult.distance;
return true;
} else {
return false;
}
});
return {
...label,
values: sortBy(searchResults, (value) => (value.selected ? -Infinity : value.order)),
};
});
} else {
// Clear highlight parts when searchTerm is cleared
selectedLabels = this.state.labels
.filter((label) => label.selected && label.values)
.map((label) => ({
...label,
values: label?.values ? label.values.map((value) => ({ ...value, highlightParts: undefined })) : [],
}));
}
return (
<div className={styles.wrapper}>
<div className={styles.section}>
@ -431,7 +470,7 @@ export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, Brow
<FixedSizeList
height={200}
itemCount={label.values?.length || 0}
itemSize={25}
itemSize={28}
itemKey={(i) => (label.values as FacettableValue[])[i].name}
width={200}
className={styles.valueList}
@ -447,6 +486,7 @@ export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, Brow
name={label.name}
value={value?.name}
active={value?.selected}
highlightParts={value?.highlightParts}
onClick={this.onClickValue}
searchTerm={searchTerm}
/>

View File

@ -1,12 +1,20 @@
import React, { ChangeEvent } from 'react';
import { Button, HorizontalGroup, Input, Label, LoadingPlaceholder, stylesFactory, withTheme } from '@grafana/ui';
import {
Button,
HorizontalGroup,
Input,
Label,
LoadingPlaceholder,
stylesFactory,
withTheme,
BrowserLabel as PromLabel,
} from '@grafana/ui';
import PromQlLanguageProvider from '../language_provider';
import { css, cx } from '@emotion/css';
import store from 'app/core/store';
import { FixedSizeList } from 'react-window';
import { GrafanaTheme } from '@grafana/data';
import { Label as PromLabel } from './Label';
// Hard limit on labels to render
const MAX_LABEL_COUNT = 10000;
@ -591,7 +599,7 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro
<FixedSizeList
height={Math.min(200, LIST_ITEM_SIZE * (label.values?.length || 0))}
itemCount={label.values?.length || 0}
itemSize={25}
itemSize={28}
itemKey={(i) => (label.values as FacettableValue[])[i].name}
width={200}
className={styles.valueList}

View File

@ -29,7 +29,7 @@ if [ ! -d "$REPORT_PATH" ]; then
fi
WARNINGS_COUNT="$(find "$REPORT_PATH" -type f -name \*.log -print0 | xargs -0 grep -o "Warning: " | wc -l | xargs)"
WARNINGS_COUNT_LIMIT=1072
WARNINGS_COUNT_LIMIT=1074
if [ "$WARNINGS_COUNT" -gt $WARNINGS_COUNT_LIMIT ]; then
echo -e "API Extractor warnings/errors $WARNINGS_COUNT exceeded $WARNINGS_COUNT_LIMIT so failing build.\n"