mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
feat(Explore): make sure Loki labels are up to date (#16131)
* Migrated loki syntax and labels logic to useLokiSyntax hook * Enable loki labels refresh after specified interval has passed * Enable periodic loki labels refresh when labels selector is opened * Fix prettier * Add react-hooks-testing-library and disable lib check on typecheck * Add tests for loki syntax/label hooks * Move tsc's skipLibCheck option to tsconfig for webpack to pick it up * Set log labels refresh marker variable when log labels fetch start * Fix prettier issues * Fix type on activeOption in useLokiLabel hook * Typo fixes and types in useLokiSyntax hook test fixes * Make sure effect's setState is not performed on unmounted component * Extract logic for checking if is component mounted to a separate hook
This commit is contained in:
parent
cf7a5b552b
commit
c2e9daad1e
@ -20,6 +20,7 @@
|
||||
"@types/angular": "^1.6.6",
|
||||
"@types/chalk": "^2.2.0",
|
||||
"@types/classnames": "^2.2.6",
|
||||
"@types/clipboard": "^2.0.1",
|
||||
"@types/commander": "^2.12.2",
|
||||
"@types/d3": "^4.10.1",
|
||||
"@types/enzyme": "^3.1.13",
|
||||
@ -34,7 +35,6 @@
|
||||
"@types/react-select": "^2.0.4",
|
||||
"@types/react-transition-group": "^2.0.15",
|
||||
"@types/react-virtualized": "^9.18.12",
|
||||
"@types/clipboard": "^2.0.1",
|
||||
"angular-mocks": "1.6.6",
|
||||
"autoprefixer": "^9.4.10",
|
||||
"axios": "^0.18.0",
|
||||
@ -95,6 +95,7 @@
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-reporter": "^6.0.1",
|
||||
"prettier": "1.16.4",
|
||||
"react-hooks-testing-library": "^0.3.7",
|
||||
"react-hot-loader": "^4.3.6",
|
||||
"react-test-renderer": "^16.5.0",
|
||||
"redux-mock-store": "^1.5.3",
|
||||
|
12
public/app/core/hooks/useRefMounted.ts
Normal file
12
public/app/core/hooks/useRefMounted.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
export const useRefMounted = () => {
|
||||
const refMounted = useRef(false);
|
||||
useEffect(() => {
|
||||
refMounted.current = true;
|
||||
return () => {
|
||||
refMounted.current = false;
|
||||
};
|
||||
});
|
||||
return refMounted;
|
||||
};
|
@ -1,266 +1,26 @@
|
||||
// Libraries
|
||||
import React from 'react';
|
||||
import Cascader from 'rc-cascader';
|
||||
import PluginPrism from 'slate-prism';
|
||||
import Prism from 'prismjs';
|
||||
import React, { FunctionComponent } from 'react';
|
||||
import { LokiQueryFieldForm, LokiQueryFieldFormProps } from './LokiQueryFieldForm';
|
||||
import { useLokiSyntax } from './useLokiSyntax';
|
||||
|
||||
// Components
|
||||
import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
|
||||
const LokiQueryField: FunctionComponent<LokiQueryFieldFormProps> = ({ datasource, ...otherProps }) => {
|
||||
const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax(datasource.languageProvider);
|
||||
|
||||
// Utils & Services
|
||||
// dom also includes Element polyfills
|
||||
import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
|
||||
import BracesPlugin from 'app/features/explore/slate-plugins/braces';
|
||||
import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
|
||||
|
||||
// Types
|
||||
import { LokiQuery } from '../types';
|
||||
import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
|
||||
import { makePromiseCancelable, CancelablePromise } from 'app/core/utils/CancelablePromise';
|
||||
import { ExploreDataSourceApi, ExploreQueryFieldProps } from '@grafana/ui';
|
||||
|
||||
const PRISM_SYNTAX = 'promql';
|
||||
|
||||
function getChooserText(hasSytax, hasLogLabels) {
|
||||
if (!hasSytax) {
|
||||
return 'Loading labels...';
|
||||
}
|
||||
if (!hasLogLabels) {
|
||||
return '(No labels found)';
|
||||
}
|
||||
return 'Log labels';
|
||||
}
|
||||
|
||||
export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string {
|
||||
// Modify suggestion based on context
|
||||
switch (typeaheadContext) {
|
||||
case 'context-labels': {
|
||||
const nextChar = getNextCharacter();
|
||||
if (!nextChar || nextChar === '}' || nextChar === ',') {
|
||||
suggestion += '=';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'context-label-values': {
|
||||
// Always add quotes and remove existing ones instead
|
||||
if (!typeaheadText.match(/^(!?=~?"|")/)) {
|
||||
suggestion = `"${suggestion}`;
|
||||
}
|
||||
if (getNextCharacter() !== '"') {
|
||||
suggestion = `${suggestion}"`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
}
|
||||
return suggestion;
|
||||
}
|
||||
|
||||
interface CascaderOption {
|
||||
label: string;
|
||||
value: string;
|
||||
children?: CascaderOption[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface LokiQueryFieldProps extends ExploreQueryFieldProps<ExploreDataSourceApi, LokiQuery> {
|
||||
history: HistoryItem[];
|
||||
}
|
||||
|
||||
interface LokiQueryFieldState {
|
||||
logLabelOptions: any[];
|
||||
syntaxLoaded: boolean;
|
||||
}
|
||||
|
||||
export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryFieldState> {
|
||||
plugins: any[];
|
||||
pluginsSearch: any[];
|
||||
languageProvider: any;
|
||||
modifiedSearch: string;
|
||||
modifiedQuery: string;
|
||||
languageProviderInitializationPromise: CancelablePromise<any>;
|
||||
|
||||
constructor(props: LokiQueryFieldProps, context) {
|
||||
super(props, context);
|
||||
|
||||
if (props.datasource.languageProvider) {
|
||||
this.languageProvider = props.datasource.languageProvider;
|
||||
}
|
||||
|
||||
this.plugins = [
|
||||
BracesPlugin(),
|
||||
RunnerPlugin({ handler: props.onExecuteQuery }),
|
||||
PluginPrism({
|
||||
onlyIn: node => node.type === 'code_block',
|
||||
getSyntax: node => 'promql',
|
||||
}),
|
||||
];
|
||||
|
||||
this.pluginsSearch = [RunnerPlugin({ handler: props.onExecuteQuery })];
|
||||
|
||||
this.state = {
|
||||
logLabelOptions: [],
|
||||
syntaxLoaded: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.languageProvider) {
|
||||
this.languageProviderInitializationPromise = makePromiseCancelable(this.languageProvider.start());
|
||||
|
||||
this.languageProviderInitializationPromise.promise
|
||||
.then(remaining => {
|
||||
remaining.map(task => task.then(this.onUpdateLanguage).catch(() => {}));
|
||||
})
|
||||
.then(() => this.onUpdateLanguage())
|
||||
.catch(({ isCanceled }) => {
|
||||
if (isCanceled) {
|
||||
console.warn('LokiQueryField has unmounted, language provider intialization was canceled');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.languageProviderInitializationPromise) {
|
||||
this.languageProviderInitializationPromise.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
loadOptions = (selectedOptions: CascaderOption[]) => {
|
||||
const targetOption = selectedOptions[selectedOptions.length - 1];
|
||||
|
||||
this.setState(state => {
|
||||
const nextOptions = state.logLabelOptions.map(option => {
|
||||
if (option.value === targetOption.value) {
|
||||
return {
|
||||
...option,
|
||||
loading: true,
|
||||
};
|
||||
}
|
||||
return option;
|
||||
});
|
||||
return { logLabelOptions: nextOptions };
|
||||
});
|
||||
|
||||
this.languageProvider
|
||||
.fetchLabelValues(targetOption.value)
|
||||
.then(this.onUpdateLanguage)
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
onChangeQuery = (value: string, override?: boolean) => {
|
||||
// Send text change to parent
|
||||
const { query, onQueryChange, onExecuteQuery } = this.props;
|
||||
if (onQueryChange) {
|
||||
const nextQuery = { ...query, expr: value };
|
||||
onQueryChange(nextQuery);
|
||||
|
||||
if (override && onExecuteQuery) {
|
||||
onExecuteQuery();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onClickHintFix = () => {
|
||||
const { hint, onExecuteHint } = this.props;
|
||||
if (onExecuteHint && hint && hint.fix) {
|
||||
onExecuteHint(hint.fix.action);
|
||||
}
|
||||
};
|
||||
|
||||
onUpdateLanguage = () => {
|
||||
Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax();
|
||||
const { logLabelOptions } = this.languageProvider;
|
||||
this.setState({
|
||||
logLabelOptions,
|
||||
syntaxLoaded: true,
|
||||
});
|
||||
};
|
||||
|
||||
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
|
||||
if (!this.languageProvider) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
const { history } = this.props;
|
||||
const { prefix, text, value, wrapperNode } = typeahead;
|
||||
|
||||
// Get DOM-dependent context
|
||||
const wrapperClasses = Array.from(wrapperNode.classList);
|
||||
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
|
||||
const labelKey = labelKeyNode && labelKeyNode.textContent;
|
||||
const nextChar = getNextCharacter();
|
||||
|
||||
const result = this.languageProvider.provideCompletionItems(
|
||||
{ text, value, prefix, wrapperClasses, labelKey },
|
||||
{ history }
|
||||
);
|
||||
|
||||
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
render() {
|
||||
const { error, hint, query } = this.props;
|
||||
const { logLabelOptions, syntaxLoaded } = this.state;
|
||||
const cleanText = this.languageProvider ? this.languageProvider.cleanText : undefined;
|
||||
const hasLogLabels = logLabelOptions && logLabelOptions.length > 0;
|
||||
const chooserText = getChooserText(syntaxLoaded, hasLogLabels);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<Cascader options={logLabelOptions} onChange={this.onChangeLogLabels} loadData={this.loadOptions}>
|
||||
<button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}>
|
||||
{chooserText} <i className="fa fa-caret-down" />
|
||||
</button>
|
||||
</Cascader>
|
||||
</div>
|
||||
<div className="gf-form gf-form--grow">
|
||||
<QueryField
|
||||
additionalPlugins={this.plugins}
|
||||
cleanText={cleanText}
|
||||
initialQuery={query.expr}
|
||||
onTypeahead={this.onTypeahead}
|
||||
onWillApplySuggestion={willApplySuggestion}
|
||||
onQueryChange={this.onChangeQuery}
|
||||
onExecuteQuery={this.props.onExecuteQuery}
|
||||
placeholder="Enter a Loki query"
|
||||
portalOrigin="loki"
|
||||
syntaxLoaded={syntaxLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{error ? <div className="prom-query-field-info text-error">{error}</div> : null}
|
||||
{hint ? (
|
||||
<div className="prom-query-field-info text-warning">
|
||||
{hint.label}{' '}
|
||||
{hint.fix ? (
|
||||
<a className="text-link muted" onClick={this.onClickHintFix}>
|
||||
{hint.fix.label}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<LokiQueryFieldForm
|
||||
datasource={datasource}
|
||||
syntaxLoaded={isSyntaxReady}
|
||||
/**
|
||||
* 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}
|
||||
{...syntaxProps}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LokiQueryField;
|
||||
|
@ -0,0 +1,217 @@
|
||||
// Libraries
|
||||
import React from 'react';
|
||||
import Cascader from 'rc-cascader';
|
||||
import PluginPrism from 'slate-prism';
|
||||
|
||||
// Components
|
||||
import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
|
||||
|
||||
// Utils & Services
|
||||
// dom also includes Element polyfills
|
||||
import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
|
||||
import BracesPlugin from 'app/features/explore/slate-plugins/braces';
|
||||
import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
|
||||
|
||||
// Types
|
||||
import { LokiQuery } from '../types';
|
||||
import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
|
||||
import { ExploreDataSourceApi, ExploreQueryFieldProps } from '@grafana/ui';
|
||||
|
||||
function getChooserText(hasSytax, hasLogLabels) {
|
||||
if (!hasSytax) {
|
||||
return 'Loading labels...';
|
||||
}
|
||||
if (!hasLogLabels) {
|
||||
return '(No labels found)';
|
||||
}
|
||||
return 'Log labels';
|
||||
}
|
||||
|
||||
function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string {
|
||||
// Modify suggestion based on context
|
||||
switch (typeaheadContext) {
|
||||
case 'context-labels': {
|
||||
const nextChar = getNextCharacter();
|
||||
if (!nextChar || nextChar === '}' || nextChar === ',') {
|
||||
suggestion += '=';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'context-label-values': {
|
||||
// Always add quotes and remove existing ones instead
|
||||
if (!typeaheadText.match(/^(!?=~?"|")/)) {
|
||||
suggestion = `"${suggestion}`;
|
||||
}
|
||||
if (getNextCharacter() !== '"') {
|
||||
suggestion = `${suggestion}"`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
}
|
||||
return suggestion;
|
||||
}
|
||||
|
||||
export interface CascaderOption {
|
||||
label: string;
|
||||
value: string;
|
||||
children?: CascaderOption[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface LokiQueryFieldFormProps extends ExploreQueryFieldProps<ExploreDataSourceApi, LokiQuery> {
|
||||
history: HistoryItem[];
|
||||
syntax: any;
|
||||
logLabelOptions: any[];
|
||||
syntaxLoaded: any;
|
||||
onLoadOptions: (selectedOptions: CascaderOption[]) => void;
|
||||
onLabelsRefresh?: () => void;
|
||||
}
|
||||
|
||||
export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormProps> {
|
||||
plugins: any[];
|
||||
pluginsSearch: any[];
|
||||
modifiedSearch: string;
|
||||
modifiedQuery: string;
|
||||
|
||||
constructor(props: LokiQueryFieldFormProps, context) {
|
||||
super(props, context);
|
||||
|
||||
this.plugins = [
|
||||
BracesPlugin(),
|
||||
RunnerPlugin({ handler: props.onExecuteQuery }),
|
||||
PluginPrism({
|
||||
onlyIn: node => node.type === 'code_block',
|
||||
getSyntax: node => 'promql',
|
||||
}),
|
||||
];
|
||||
|
||||
this.pluginsSearch = [RunnerPlugin({ handler: props.onExecuteQuery })];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
onChangeQuery = (value: string, override?: boolean) => {
|
||||
// Send text change to parent
|
||||
const { query, onQueryChange, onExecuteQuery } = this.props;
|
||||
if (onQueryChange) {
|
||||
const nextQuery = { ...query, expr: value };
|
||||
onQueryChange(nextQuery);
|
||||
|
||||
if (override && onExecuteQuery) {
|
||||
onExecuteQuery();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onClickHintFix = () => {
|
||||
const { hint, onExecuteHint } = this.props;
|
||||
if (onExecuteHint && hint && hint.fix) {
|
||||
onExecuteHint(hint.fix.action);
|
||||
}
|
||||
};
|
||||
|
||||
onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
|
||||
const { datasource } = this.props;
|
||||
if (!datasource.languageProvider) {
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
const { history } = this.props;
|
||||
const { prefix, text, value, wrapperNode } = typeahead;
|
||||
|
||||
// Get DOM-dependent context
|
||||
const wrapperClasses = Array.from(wrapperNode.classList);
|
||||
const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
|
||||
const labelKey = labelKeyNode && labelKeyNode.textContent;
|
||||
const nextChar = getNextCharacter();
|
||||
|
||||
const result = datasource.languageProvider.provideCompletionItems(
|
||||
{ text, value, prefix, wrapperClasses, labelKey },
|
||||
{ history }
|
||||
);
|
||||
|
||||
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
error,
|
||||
hint,
|
||||
query,
|
||||
syntaxLoaded,
|
||||
logLabelOptions,
|
||||
onLoadOptions,
|
||||
onLabelsRefresh,
|
||||
datasource,
|
||||
} = this.props;
|
||||
const cleanText = datasource.languageProvider ? datasource.languageProvider.cleanText : undefined;
|
||||
const hasLogLabels = logLabelOptions && logLabelOptions.length > 0;
|
||||
const chooserText = getChooserText(syntaxLoaded, hasLogLabels);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<Cascader
|
||||
options={logLabelOptions}
|
||||
onChange={this.onChangeLogLabels}
|
||||
loadData={onLoadOptions}
|
||||
onPopupVisibleChange={isVisible => {
|
||||
if (isVisible && onLabelsRefresh) {
|
||||
onLabelsRefresh();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}>
|
||||
{chooserText} <i className="fa fa-caret-down" />
|
||||
</button>
|
||||
</Cascader>
|
||||
</div>
|
||||
<div className="gf-form gf-form--grow">
|
||||
<QueryField
|
||||
additionalPlugins={this.plugins}
|
||||
cleanText={cleanText}
|
||||
initialQuery={query.expr}
|
||||
onTypeahead={this.onTypeahead}
|
||||
onWillApplySuggestion={willApplySuggestion}
|
||||
onQueryChange={this.onChangeQuery}
|
||||
onExecuteQuery={this.props.onExecuteQuery}
|
||||
placeholder="Enter a Loki query"
|
||||
portalOrigin="loki"
|
||||
syntaxLoaded={syntaxLoaded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{error ? <div className="prom-query-field-info text-error">{error}</div> : null}
|
||||
{hint ? (
|
||||
<div className="prom-query-field-info text-warning">
|
||||
{hint.label}{' '}
|
||||
{hint.fix ? (
|
||||
<a className="text-link muted" onClick={this.onClickHintFix}>
|
||||
{hint.fix.label}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import { renderHook, act } from 'react-hooks-testing-library';
|
||||
import LanguageProvider from 'app/plugins/datasource/loki/language_provider';
|
||||
import { useLokiLabels } from './useLokiLabels';
|
||||
|
||||
describe('useLokiLabels hook', () => {
|
||||
const datasource = {
|
||||
metadataRequest: () => ({ data: { data: [] } }),
|
||||
};
|
||||
const languageProvider = new LanguageProvider(datasource);
|
||||
const logLabelOptionsMock = ['Holy mock!'];
|
||||
|
||||
languageProvider.refreshLogLabels = () => {
|
||||
languageProvider.logLabelOptions = logLabelOptionsMock;
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
it('should refresh labels', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => useLokiLabels(languageProvider, true, []));
|
||||
act(() => result.current.refreshLabels());
|
||||
expect(result.current.logLabelOptions).toEqual([]);
|
||||
await waitForNextUpdate();
|
||||
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock);
|
||||
});
|
||||
});
|
@ -0,0 +1,79 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider';
|
||||
import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
|
||||
import { useRefMounted } from 'app/core/hooks/useRefMounted';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param languageProvider
|
||||
* @param languageProviderInitialised
|
||||
* @param activeOption rc-cascader provided option used to fetch option's values that hasn't been loaded yet
|
||||
*
|
||||
* @description Fetches missing labels and enables labels refresh
|
||||
*/
|
||||
export const useLokiLabels = (
|
||||
languageProvider: LokiLanguageProvider,
|
||||
languageProviderInitialised: boolean,
|
||||
activeOption: CascaderOption[]
|
||||
) => {
|
||||
const mounted = useRefMounted();
|
||||
|
||||
// State
|
||||
const [logLabelOptions, setLogLabelOptions] = useState([]);
|
||||
const [shouldTryRefreshLabels, setRefreshLabels] = useState(false);
|
||||
|
||||
// Async
|
||||
const fetchOptionValues = async option => {
|
||||
await languageProvider.fetchLabelValues(option);
|
||||
if (mounted.current) {
|
||||
setLogLabelOptions(languageProvider.logLabelOptions);
|
||||
}
|
||||
};
|
||||
|
||||
const tryLabelsRefresh = async () => {
|
||||
await languageProvider.refreshLogLabels();
|
||||
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 (languageProviderInitialised) {
|
||||
const targetOption = activeOption[activeOption.length - 1];
|
||||
if (targetOption) {
|
||||
const nextOptions = logLabelOptions.map(option => {
|
||||
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]);
|
||||
|
||||
return {
|
||||
logLabelOptions,
|
||||
setLogLabelOptions,
|
||||
refreshLabels: () => setRefreshLabels(true),
|
||||
};
|
||||
};
|
@ -0,0 +1,66 @@
|
||||
import { renderHook, act } from 'react-hooks-testing-library';
|
||||
import LanguageProvider from 'app/plugins/datasource/loki/language_provider';
|
||||
import { useLokiSyntax } from './useLokiSyntax';
|
||||
import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
|
||||
|
||||
describe('useLokiSyntax hook', () => {
|
||||
const datasource = {
|
||||
metadataRequest: () => ({ data: { data: [] } }),
|
||||
};
|
||||
const languageProvider = new LanguageProvider(datasource);
|
||||
const logLabelOptionsMock = ['Holy mock!'];
|
||||
const logLabelOptionsMock2 = ['Mock the hell?!'];
|
||||
const logLabelOptionsMock3 = ['Oh my mock!'];
|
||||
|
||||
languageProvider.refreshLogLabels = () => {
|
||||
languageProvider.logLabelOptions = logLabelOptionsMock;
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
languageProvider.fetchLogLabels = () => {
|
||||
languageProvider.logLabelOptions = logLabelOptionsMock2;
|
||||
return Promise.resolve([]);
|
||||
};
|
||||
|
||||
const activeOptionMock: CascaderOption = {
|
||||
label: '',
|
||||
value: '',
|
||||
};
|
||||
|
||||
it('should provide Loki syntax when used', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider));
|
||||
expect(result.current.syntax).toEqual(null);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.syntax).toEqual(languageProvider.getSyntax());
|
||||
});
|
||||
|
||||
it('should fetch labels on first call', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider));
|
||||
expect(result.current.isSyntaxReady).toBeFalsy();
|
||||
expect(result.current.logLabelOptions).toEqual([]);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.isSyntaxReady).toBeTruthy();
|
||||
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock2);
|
||||
});
|
||||
|
||||
it('should try to fetch missing options when active option changes', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider));
|
||||
await waitForNextUpdate();
|
||||
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock2);
|
||||
|
||||
languageProvider.fetchLabelValues = (key: string) => {
|
||||
languageProvider.logLabelOptions = logLabelOptionsMock3;
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
act(() => result.current.setActiveOption([activeOptionMock]));
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock3);
|
||||
});
|
||||
});
|
@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider';
|
||||
import Prism from 'prismjs';
|
||||
import { useLokiLabels } from 'app/plugins/datasource/loki/components/useLokiLabels';
|
||||
import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
|
||||
import { useRefMounted } from 'app/core/hooks/useRefMounted';
|
||||
|
||||
const PRISM_SYNTAX = 'promql';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param languageProvider
|
||||
* @description Initializes given language provider, exposes Loki syntax and enables loading label option values
|
||||
*/
|
||||
export const useLokiSyntax = (languageProvider: LokiLanguageProvider) => {
|
||||
const mounted = useRefMounted();
|
||||
// State
|
||||
const [languageProviderInitialized, setLanguageProviderInitilized] = useState(false);
|
||||
const [syntax, setSyntax] = useState(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[]>();
|
||||
|
||||
const { logLabelOptions, setLogLabelOptions, refreshLabels } = useLokiLabels(
|
||||
languageProvider,
|
||||
languageProviderInitialized,
|
||||
activeOption
|
||||
);
|
||||
|
||||
// Async
|
||||
const initializeLanguageProvider = async () => {
|
||||
await languageProvider.start();
|
||||
Prism.languages[PRISM_SYNTAX] = languageProvider.getSyntax();
|
||||
if (mounted.current) {
|
||||
setLogLabelOptions(languageProvider.logLabelOptions);
|
||||
setSyntax(languageProvider.getSyntax());
|
||||
setLanguageProviderInitilized(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
initializeLanguageProvider();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isSyntaxReady: languageProviderInitialized,
|
||||
syntax,
|
||||
logLabelOptions,
|
||||
setActiveOption,
|
||||
refreshLabels,
|
||||
};
|
||||
};
|
@ -1,6 +1,8 @@
|
||||
import Plain from 'slate-plain-serializer';
|
||||
|
||||
import LanguageProvider from './language_provider';
|
||||
import LanguageProvider, { LABEL_REFRESH_INTERVAL } from './language_provider';
|
||||
import { advanceTo, clear, advanceBy } from 'jest-date-mock';
|
||||
import { beforeEach } from 'test/lib/common';
|
||||
|
||||
describe('Language completion provider', () => {
|
||||
const datasource = {
|
||||
@ -133,3 +135,33 @@ describe('Query imports', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Labels refresh', () => {
|
||||
const datasource = {
|
||||
metadataRequest: () => ({ data: { data: [] } }),
|
||||
};
|
||||
const instance = new LanguageProvider(datasource);
|
||||
|
||||
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();
|
||||
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();
|
||||
expect(instance.fetchLogLabels).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
@ -21,6 +21,7 @@ const DEFAULT_KEYS = ['job', 'namespace'];
|
||||
const EMPTY_SELECTOR = '{}';
|
||||
const HISTORY_ITEM_COUNT = 10;
|
||||
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
|
||||
export const LABEL_REFRESH_INTERVAL = 1000 * 30; // 30sec
|
||||
|
||||
const wrapLabel = (label: string) => ({ label });
|
||||
|
||||
@ -46,6 +47,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
|
||||
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
|
||||
logLabelOptions: any[];
|
||||
logLabelFetchTs?: number;
|
||||
started: boolean;
|
||||
|
||||
constructor(datasource: any, initialValues?: any) {
|
||||
@ -226,6 +228,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
async fetchLogLabels() {
|
||||
const url = '/api/prom/label';
|
||||
try {
|
||||
this.logLabelFetchTs = Date.now();
|
||||
const res = await this.request(url);
|
||||
const body = await (res.data || res.json());
|
||||
const labelKeys = body.data.slice().sort();
|
||||
@ -236,13 +239,21 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
this.logLabelOptions = labelKeys.map(key => ({ label: key, value: key, isLeaf: false }));
|
||||
|
||||
// Pre-load values for default labels
|
||||
return labelKeys.filter(key => DEFAULT_KEYS.indexOf(key) > -1).map(key => this.fetchLabelValues(key));
|
||||
return Promise.all(
|
||||
labelKeys.filter(key => DEFAULT_KEYS.indexOf(key) > -1).map(key => this.fetchLabelValues(key))
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async refreshLogLabels() {
|
||||
if (this.labelKeys && Date.now() - this.logLabelFetchTs > LABEL_REFRESH_INTERVAL) {
|
||||
await this.fetchLogLabels();
|
||||
}
|
||||
}
|
||||
|
||||
async fetchLabelValues(key: string) {
|
||||
const url = `/api/prom/label/${key}/values`;
|
||||
try {
|
||||
|
@ -31,7 +31,8 @@
|
||||
"paths": {
|
||||
"app": ["app"],
|
||||
"sass": ["sass"]
|
||||
}
|
||||
},
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["public/app/**/*.ts", "public/app/**/*.tsx", "public/test/**/*.ts", "public/vendor/**/*.ts"]
|
||||
}
|
||||
|
41
yarn.lock
41
yarn.lock
@ -6424,6 +6424,16 @@ dom-serializer@0, dom-serializer@~0.1.0:
|
||||
domelementtype "^1.3.0"
|
||||
entities "^1.1.1"
|
||||
|
||||
dom-testing-library@^3.13.1:
|
||||
version "3.18.2"
|
||||
resolved "https://registry.yarnpkg.com/dom-testing-library/-/dom-testing-library-3.18.2.tgz#07d65166743ad3299b7bee5b488e9622c31241bc"
|
||||
integrity sha512-+nYUgGhHarrCY8kLVmyHlgM+IGwBXXrYsWIJB6vpAx2ne9WFgKfwMGcOkkTKQhuAro0sP6RIuRGfm5NF3+ccmQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.3.4"
|
||||
"@sheerun/mutationobserver-shim" "^0.3.2"
|
||||
pretty-format "^24.5.0"
|
||||
wait-for-expect "^1.1.0"
|
||||
|
||||
dom-walk@^0.1.0:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018"
|
||||
@ -13304,6 +13314,16 @@ pretty-format@^24.5.0:
|
||||
ansi-styles "^3.2.0"
|
||||
react-is "^16.8.4"
|
||||
|
||||
pretty-format@^24.5.0:
|
||||
version "24.5.0"
|
||||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.5.0.tgz#cc69a0281a62cd7242633fc135d6930cd889822d"
|
||||
integrity sha512-/3RuSghukCf8Riu5Ncve0iI+BzVkbRU5EeUoArKARZobREycuH5O4waxvaNIloEXdb0qwgmEAed5vTpX1HNROQ==
|
||||
dependencies:
|
||||
"@jest/types" "^24.5.0"
|
||||
ansi-regex "^4.0.0"
|
||||
ansi-styles "^3.2.0"
|
||||
react-is "^16.8.4"
|
||||
|
||||
pretty-hrtime@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
|
||||
@ -13841,6 +13861,14 @@ react-highlight-words@0.11.0:
|
||||
highlight-words-core "^1.2.0"
|
||||
prop-types "^15.5.8"
|
||||
|
||||
react-hooks-testing-library@^0.3.7:
|
||||
version "0.3.7"
|
||||
resolved "https://registry.yarnpkg.com/react-hooks-testing-library/-/react-hooks-testing-library-0.3.7.tgz#583d6b9026e458c6cdc28874b952b2359647867f"
|
||||
integrity sha512-SjmPBb0ars9sh37n0MBYz3VZC5QuzUFF6/8LZlprKgsg0YRNXGKsKbuAV8k7dqX8qmprMKzXQqfZmZDFbvZkVg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.3.4"
|
||||
react-testing-library "^6.0.0"
|
||||
|
||||
react-hot-loader@^4.3.6:
|
||||
version "4.8.0"
|
||||
resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.8.0.tgz#0b7c7dd9407415e23eb8246fdd28b0b839f54cb6"
|
||||
@ -13983,6 +14011,14 @@ react-test-renderer@^16.0.0-0, react-test-renderer@^16.5.0, react-test-renderer@
|
||||
react-is "^16.8.4"
|
||||
scheduler "^0.13.4"
|
||||
|
||||
react-testing-library@^6.0.0:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-testing-library/-/react-testing-library-6.0.1.tgz#0ddf155cb609529e37359a82cc63eb3f830397fd"
|
||||
integrity sha512-Asyrmdj059WnD8q4pVsKoPtvWfXEk+OCCNKSo9bh5tZ0pb80iXvkr4oppiL8H2qWL+MJUV2PTMneHYxsTeAa/A==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.3.1"
|
||||
dom-testing-library "^3.13.1"
|
||||
|
||||
react-textarea-autosize@^7.0.4:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-7.1.0.tgz#3132cb77e65d94417558d37c0bfe415a5afd3445"
|
||||
@ -16995,6 +17031,11 @@ w3c-hr-time@^1.0.1:
|
||||
dependencies:
|
||||
browser-process-hrtime "^0.1.2"
|
||||
|
||||
wait-for-expect@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-1.1.0.tgz#6607375c3f79d32add35cd2c87ce13f351a3d453"
|
||||
integrity sha512-vQDokqxyMyknfX3luCDn16bSaRcOyH6gGuUXMIbxBLeTo6nWuEWYqMTT9a+44FmW8c2m6TRWBdNvBBjA1hwEKg==
|
||||
|
||||
walkdir@^0.0.11:
|
||||
version "0.0.11"
|
||||
resolved "https://registry.yarnpkg.com/walkdir/-/walkdir-0.0.11.tgz#a16d025eb931bd03b52f308caed0f40fcebe9532"
|
||||
|
Loading…
Reference in New Issue
Block a user