Commit c2e9daad by Dominik Prokop Committed by GitHub

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
parent cf7a5b55
...@@ -20,6 +20,7 @@ ...@@ -20,6 +20,7 @@
"@types/angular": "^1.6.6", "@types/angular": "^1.6.6",
"@types/chalk": "^2.2.0", "@types/chalk": "^2.2.0",
"@types/classnames": "^2.2.6", "@types/classnames": "^2.2.6",
"@types/clipboard": "^2.0.1",
"@types/commander": "^2.12.2", "@types/commander": "^2.12.2",
"@types/d3": "^4.10.1", "@types/d3": "^4.10.1",
"@types/enzyme": "^3.1.13", "@types/enzyme": "^3.1.13",
...@@ -34,7 +35,6 @@ ...@@ -34,7 +35,6 @@
"@types/react-select": "^2.0.4", "@types/react-select": "^2.0.4",
"@types/react-transition-group": "^2.0.15", "@types/react-transition-group": "^2.0.15",
"@types/react-virtualized": "^9.18.12", "@types/react-virtualized": "^9.18.12",
"@types/clipboard": "^2.0.1",
"angular-mocks": "1.6.6", "angular-mocks": "1.6.6",
"autoprefixer": "^9.4.10", "autoprefixer": "^9.4.10",
"axios": "^0.18.0", "axios": "^0.18.0",
...@@ -95,6 +95,7 @@ ...@@ -95,6 +95,7 @@
"postcss-loader": "^3.0.0", "postcss-loader": "^3.0.0",
"postcss-reporter": "^6.0.1", "postcss-reporter": "^6.0.1",
"prettier": "1.16.4", "prettier": "1.16.4",
"react-hooks-testing-library": "^0.3.7",
"react-hot-loader": "^4.3.6", "react-hot-loader": "^4.3.6",
"react-test-renderer": "^16.5.0", "react-test-renderer": "^16.5.0",
"redux-mock-store": "^1.5.3", "redux-mock-store": "^1.5.3",
......
import { useRef, useEffect } from 'react';
export const useRefMounted = () => {
const refMounted = useRef(false);
useEffect(() => {
refMounted.current = true;
return () => {
refMounted.current = false;
};
});
return refMounted;
};
// Libraries import React, { FunctionComponent } from 'react';
import React from 'react'; import { LokiQueryFieldForm, LokiQueryFieldFormProps } from './LokiQueryFieldForm';
import Cascader from 'rc-cascader'; import { useLokiSyntax } from './useLokiSyntax';
import PluginPrism from 'slate-prism';
import Prism from 'prismjs';
// Components const LokiQueryField: FunctionComponent<LokiQueryFieldFormProps> = ({ datasource, ...otherProps }) => {
import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField'; 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 ( return (
<> <LokiQueryFieldForm
<div className="gf-form-inline"> datasource={datasource}
<div className="gf-form"> syntaxLoaded={isSyntaxReady}
<Cascader options={logLabelOptions} onChange={this.onChangeLogLabels} loadData={this.loadOptions}> /**
<button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}> * setActiveOption name is intentional. Because of the way rc-cascader requests additional data
{chooserText} <i className="fa fa-caret-down" /> * https://github.com/react-component/cascader/blob/master/src/Cascader.jsx#L165
</button> * we are notyfing useLokiSyntax hook, what the active option is, and then it's up to the hook logic
</Cascader> * to fetch data of options that aren't fetched yet
</div> */
<div className="gf-form gf-form--grow"> onLoadOptions={setActiveOption}
<QueryField onLabelsRefresh={refreshLabels}
additionalPlugins={this.plugins} {...syntaxProps}
cleanText={cleanText} {...otherProps}
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>
</>
); );
} };
}
export default LokiQueryField; export default LokiQueryField;
// 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>
</>
);
}
}
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);
});
});
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),
};
};
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);
});
});
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,
};
};
import Plain from 'slate-plain-serializer'; 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', () => { describe('Language completion provider', () => {
const datasource = { const datasource = {
...@@ -133,3 +135,33 @@ describe('Query imports', () => { ...@@ -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']; ...@@ -21,6 +21,7 @@ const DEFAULT_KEYS = ['job', 'namespace'];
const EMPTY_SELECTOR = '{}'; const EMPTY_SELECTOR = '{}';
const HISTORY_ITEM_COUNT = 10; const HISTORY_ITEM_COUNT = 10;
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
export const LABEL_REFRESH_INTERVAL = 1000 * 30; // 30sec
const wrapLabel = (label: string) => ({ label }); const wrapLabel = (label: string) => ({ label });
...@@ -46,6 +47,7 @@ export default class LokiLanguageProvider extends LanguageProvider { ...@@ -46,6 +47,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...] labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
logLabelOptions: any[]; logLabelOptions: any[];
logLabelFetchTs?: number;
started: boolean; started: boolean;
constructor(datasource: any, initialValues?: any) { constructor(datasource: any, initialValues?: any) {
...@@ -226,6 +228,7 @@ export default class LokiLanguageProvider extends LanguageProvider { ...@@ -226,6 +228,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
async fetchLogLabels() { async fetchLogLabels() {
const url = '/api/prom/label'; const url = '/api/prom/label';
try { try {
this.logLabelFetchTs = Date.now();
const res = await this.request(url); const res = await this.request(url);
const body = await (res.data || res.json()); const body = await (res.data || res.json());
const labelKeys = body.data.slice().sort(); const labelKeys = body.data.slice().sort();
...@@ -236,13 +239,21 @@ export default class LokiLanguageProvider extends LanguageProvider { ...@@ -236,13 +239,21 @@ export default class LokiLanguageProvider extends LanguageProvider {
this.logLabelOptions = labelKeys.map(key => ({ label: key, value: key, isLeaf: false })); this.logLabelOptions = labelKeys.map(key => ({ label: key, value: key, isLeaf: false }));
// Pre-load values for default labels // 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) { } catch (e) {
console.error(e); console.error(e);
} }
return []; return [];
} }
async refreshLogLabels() {
if (this.labelKeys && Date.now() - this.logLabelFetchTs > LABEL_REFRESH_INTERVAL) {
await this.fetchLogLabels();
}
}
async fetchLabelValues(key: string) { async fetchLabelValues(key: string) {
const url = `/api/prom/label/${key}/values`; const url = `/api/prom/label/${key}/values`;
try { try {
......
...@@ -31,7 +31,8 @@ ...@@ -31,7 +31,8 @@
"paths": { "paths": {
"app": ["app"], "app": ["app"],
"sass": ["sass"] "sass": ["sass"]
} },
"skipLibCheck": true
}, },
"include": ["public/app/**/*.ts", "public/app/**/*.tsx", "public/test/**/*.ts", "public/vendor/**/*.ts"] "include": ["public/app/**/*.ts", "public/app/**/*.tsx", "public/test/**/*.ts", "public/vendor/**/*.ts"]
} }
...@@ -6424,6 +6424,16 @@ dom-serializer@0, dom-serializer@~0.1.0: ...@@ -6424,6 +6424,16 @@ dom-serializer@0, dom-serializer@~0.1.0:
domelementtype "^1.3.0" domelementtype "^1.3.0"
entities "^1.1.1" 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: dom-walk@^0.1.0:
version "0.1.1" version "0.1.1"
resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018" resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018"
...@@ -13304,6 +13314,16 @@ pretty-format@^24.5.0: ...@@ -13304,6 +13314,16 @@ pretty-format@^24.5.0:
ansi-styles "^3.2.0" ansi-styles "^3.2.0"
react-is "^16.8.4" 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: pretty-hrtime@^1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
...@@ -13841,6 +13861,14 @@ react-highlight-words@0.11.0: ...@@ -13841,6 +13861,14 @@ react-highlight-words@0.11.0:
highlight-words-core "^1.2.0" highlight-words-core "^1.2.0"
prop-types "^15.5.8" 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: react-hot-loader@^4.3.6:
version "4.8.0" version "4.8.0"
resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.8.0.tgz#0b7c7dd9407415e23eb8246fdd28b0b839f54cb6" 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@ ...@@ -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" react-is "^16.8.4"
scheduler "^0.13.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: react-textarea-autosize@^7.0.4:
version "7.1.0" version "7.1.0"
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-7.1.0.tgz#3132cb77e65d94417558d37c0bfe415a5afd3445" 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: ...@@ -16995,6 +17031,11 @@ w3c-hr-time@^1.0.1:
dependencies: dependencies:
browser-process-hrtime "^0.1.2" 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: walkdir@^0.0.11:
version "0.0.11" version "0.0.11"
resolved "https://registry.yarnpkg.com/walkdir/-/walkdir-0.0.11.tgz#a16d025eb931bd03b52f308caed0f40fcebe9532" resolved "https://registry.yarnpkg.com/walkdir/-/walkdir-0.0.11.tgz#a16d025eb931bd03b52f308caed0f40fcebe9532"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment