Merge branch 'master' into gtk/verify
* master: Docs: Documents new features available with Loki data source in Explore (#17984) Prometheus: added time range filter to series labels query (#16851) Explore: Adds support for new loki 'start' and 'end' params for labels endpoint (#17512) Chore: Removes custom debounce utility in favor of lodash/debounce (#17977) Api: Fix auth tokens returning wrong seenAt value (#17980)
This commit is contained in:
@@ -92,6 +92,23 @@ Example queries:
|
||||
* `{name="kafka"} tsdb-ops.*io:2003`
|
||||
* `{instance=~"kafka-[23]",name="kafka"} kafka.server:type=ReplicaManager`
|
||||
|
||||
## Live Tailing
|
||||
|
||||
To view your logs live as they are added, choose `Live` from the refresh dropdown, and you should see your logs be displayed in real time.
|
||||
|
||||
Note that Live Tailing relies on two Websocket connections: one between the browser and the Grafana server, and another between the Grafana server and the Loki server. If you run any reverse proxies, please configure them accordingly.
|
||||
|
||||
|
||||
> Note: This feature is only available in Grafana v6.3+
|
||||
|
||||
## Log Context
|
||||
|
||||
When using a search expression as detailed above, you now have the ability to retrieve the context surrounding your filtered results.
|
||||
By clicking the `Show Context` link on the filtered rows, you'll be able to investigate the log messages that came before and after the
|
||||
log message you're interested in.
|
||||
|
||||
> Note: This feature is only available in Grafana v6.3+
|
||||
|
||||
## Templating
|
||||
|
||||
Template variables are not yet supported by Loki.
|
||||
|
||||
@@ -84,6 +84,13 @@ func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID
|
||||
}
|
||||
}
|
||||
|
||||
createdAt := time.Unix(token.CreatedAt, 0)
|
||||
seenAt := time.Unix(token.SeenAt, 0)
|
||||
|
||||
if token.SeenAt == 0 {
|
||||
seenAt = createdAt
|
||||
}
|
||||
|
||||
result = append(result, &dtos.UserToken{
|
||||
Id: token.Id,
|
||||
IsActive: isActive,
|
||||
@@ -93,8 +100,8 @@ func (server *HTTPServer) getUserAuthTokensInternal(c *models.ReqContext, userID
|
||||
OperatingSystemVersion: osVersion,
|
||||
Browser: client.UserAgent.Family,
|
||||
BrowserVersion: browserVersion,
|
||||
CreatedAt: time.Unix(token.CreatedAt, 0),
|
||||
SeenAt: time.Unix(token.SeenAt, 0),
|
||||
CreatedAt: createdAt,
|
||||
SeenAt: seenAt,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ func TestUserTokenApiEndpoint(t *testing.T) {
|
||||
ClientIp: "127.0.0.2",
|
||||
UserAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
SeenAt: time.Now().Unix(),
|
||||
SeenAt: 0,
|
||||
},
|
||||
}
|
||||
sc.userAuthTokenService.GetUserTokensProvider = func(ctx context.Context, userId int64) ([]*m.UserToken, error) {
|
||||
@@ -154,7 +154,7 @@ func TestUserTokenApiEndpoint(t *testing.T) {
|
||||
So(resultTwo.Get("isActive").MustBool(), ShouldBeFalse)
|
||||
So(resultTwo.Get("clientIp").MustString(), ShouldEqual, "127.0.0.2")
|
||||
So(resultTwo.Get("createdAt").MustString(), ShouldEqual, time.Unix(tokens[1].CreatedAt, 0).Format(time.RFC3339))
|
||||
So(resultTwo.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[1].SeenAt, 0).Format(time.RFC3339))
|
||||
So(resultTwo.Get("seenAt").MustString(), ShouldEqual, time.Unix(tokens[1].CreatedAt, 0).Format(time.RFC3339))
|
||||
|
||||
So(resultTwo.Get("device").MustString(), ShouldEqual, "iPhone")
|
||||
So(resultTwo.Get("browser").MustString(), ShouldEqual, "Mobile Safari")
|
||||
|
||||
@@ -13,7 +13,7 @@ import { changeQuery, modifyQueries, runQueries, addQueryRow } from './state/act
|
||||
|
||||
// Types
|
||||
import { StoreState } from 'app/types';
|
||||
import { TimeRange } from '@grafana/data';
|
||||
import { TimeRange, AbsoluteTimeRange } from '@grafana/data';
|
||||
import { DataQuery, DataSourceApi, QueryFixAction, DataSourceStatus, PanelData, DataQueryError } from '@grafana/ui';
|
||||
import { HistoryItem, ExploreItemState, ExploreId, ExploreMode } from 'app/types/explore';
|
||||
import { Emitter } from 'app/core/utils/emitter';
|
||||
@@ -38,6 +38,7 @@ interface QueryRowProps extends PropsFromParent {
|
||||
query: DataQuery;
|
||||
modifyQueries: typeof modifyQueries;
|
||||
range: TimeRange;
|
||||
absoluteRange: AbsoluteTimeRange;
|
||||
removeQueryRowAction: typeof removeQueryRowAction;
|
||||
runQueries: typeof runQueries;
|
||||
queryResponse: PanelData;
|
||||
@@ -116,6 +117,7 @@ export class QueryRow extends PureComponent<QueryRowProps, QueryRowState> {
|
||||
query,
|
||||
exploreEvents,
|
||||
range,
|
||||
absoluteRange,
|
||||
datasourceStatus,
|
||||
queryResponse,
|
||||
latency,
|
||||
@@ -148,6 +150,7 @@ export class QueryRow extends PureComponent<QueryRowProps, QueryRowState> {
|
||||
onChange={this.onChange}
|
||||
panelData={null}
|
||||
queryResponse={queryResponse}
|
||||
absoluteRange={absoluteRange}
|
||||
/>
|
||||
) : (
|
||||
<QueryEditor
|
||||
@@ -202,6 +205,7 @@ function mapStateToProps(state: StoreState, { exploreId, index }: QueryRowProps)
|
||||
history,
|
||||
queries,
|
||||
range,
|
||||
absoluteRange,
|
||||
datasourceError,
|
||||
graphResult,
|
||||
loadingState,
|
||||
@@ -224,6 +228,7 @@ function mapStateToProps(state: StoreState, { exploreId, index }: QueryRowProps)
|
||||
history,
|
||||
query,
|
||||
range,
|
||||
absoluteRange,
|
||||
datasourceStatus,
|
||||
queryResponse,
|
||||
latency,
|
||||
|
||||
@@ -250,6 +250,7 @@ export function initializeExplore(
|
||||
ui,
|
||||
})
|
||||
);
|
||||
dispatch(updateTimeRangeAction({ exploreId }));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// Based on underscore.js debounce()
|
||||
export default function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function(this: any) {
|
||||
const context = this;
|
||||
const args = arguments;
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
func.apply(context, args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
+1
-3
@@ -3,9 +3,7 @@ import _ from 'lodash';
|
||||
import Plain from 'slate-plain-serializer';
|
||||
|
||||
import QueryField from './query_field';
|
||||
// import debounce from './utils/debounce';
|
||||
// import {getNextCharacter} from './utils/dom';
|
||||
import debounce from 'app/features/explore/utils/debounce';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { getNextCharacter } from 'app/features/explore/utils/dom';
|
||||
|
||||
import { KEYWORDS, functionTokens, operatorTokens, grafanaMacros } from './kusto/kusto';
|
||||
|
||||
@@ -9,7 +9,8 @@ const LokiQueryField: FunctionComponent<LokiQueryFieldFormProps> = ({
|
||||
}) => {
|
||||
const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax(
|
||||
datasource.languageProvider,
|
||||
datasourceStatus
|
||||
datasourceStatus,
|
||||
otherProps.absoluteRange
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,6 +17,7 @@ import BracesPlugin from 'app/features/explore/slate-plugins/braces';
|
||||
import { LokiQuery } from '../types';
|
||||
import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
|
||||
import { DataSourceApi, ExploreQueryFieldProps, DataSourceStatus } from '@grafana/ui';
|
||||
import { AbsoluteTimeRange } from '@grafana/data';
|
||||
|
||||
function getChooserText(hasSyntax: boolean, hasLogLabels: boolean, datasourceStatus: DataSourceStatus) {
|
||||
if (datasourceStatus === DataSourceStatus.Disconnected) {
|
||||
@@ -70,6 +71,7 @@ export interface LokiQueryFieldFormProps extends ExploreQueryFieldProps<DataSour
|
||||
syntax: any;
|
||||
logLabelOptions: any[];
|
||||
syntaxLoaded: any;
|
||||
absoluteRange: AbsoluteTimeRange;
|
||||
onLoadOptions: (selectedOptions: CascaderOption[]) => void;
|
||||
onLabelsRefresh?: () => void;
|
||||
}
|
||||
@@ -123,7 +125,7 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
|
||||
return { suggestions: [] };
|
||||
}
|
||||
|
||||
const { history } = this.props;
|
||||
const { history, absoluteRange } = this.props;
|
||||
const { prefix, text, value, wrapperNode } = typeahead;
|
||||
|
||||
// Get DOM-dependent context
|
||||
@@ -134,7 +136,7 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
|
||||
|
||||
const result = datasource.languageProvider.provideCompletionItems(
|
||||
{ text, value, prefix, wrapperClasses, labelKey },
|
||||
{ history }
|
||||
{ history, absoluteRange }
|
||||
);
|
||||
|
||||
console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { renderHook, act } from 'react-hooks-testing-library';
|
||||
import LanguageProvider from 'app/plugins/datasource/loki/language_provider';
|
||||
import { useLokiLabels } from './useLokiLabels';
|
||||
import { DataSourceStatus } from '@grafana/ui/src/types/datasource';
|
||||
import { AbsoluteTimeRange } from '@grafana/data';
|
||||
|
||||
describe('useLokiLabels hook', () => {
|
||||
it('should refresh labels', async () => {
|
||||
@@ -10,6 +11,10 @@ describe('useLokiLabels hook', () => {
|
||||
};
|
||||
const languageProvider = new LanguageProvider(datasource);
|
||||
const logLabelOptionsMock = ['Holy mock!'];
|
||||
const rangeMock: AbsoluteTimeRange = {
|
||||
from: 1560153109000,
|
||||
to: 1560153109000,
|
||||
};
|
||||
|
||||
languageProvider.refreshLogLabels = () => {
|
||||
languageProvider.logLabelOptions = logLabelOptionsMock;
|
||||
@@ -17,7 +22,7 @@ describe('useLokiLabels hook', () => {
|
||||
};
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useLokiLabels(languageProvider, true, [], DataSourceStatus.Connected, DataSourceStatus.Connected)
|
||||
useLokiLabels(languageProvider, true, [], rangeMock, DataSourceStatus.Connected, DataSourceStatus.Connected)
|
||||
);
|
||||
act(() => result.current.refreshLabels());
|
||||
expect(result.current.logLabelOptions).toEqual([]);
|
||||
@@ -29,26 +34,38 @@ describe('useLokiLabels hook', () => {
|
||||
const datasource = {
|
||||
metadataRequest: () => ({ data: { data: [] as any[] } }),
|
||||
};
|
||||
|
||||
const rangeMock: AbsoluteTimeRange = {
|
||||
from: 1560153109000,
|
||||
to: 1560153109000,
|
||||
};
|
||||
|
||||
const languageProvider = new LanguageProvider(datasource);
|
||||
languageProvider.refreshLogLabels = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useLokiLabels(languageProvider, true, [], DataSourceStatus.Connected, DataSourceStatus.Disconnected)
|
||||
useLokiLabels(languageProvider, true, [], rangeMock, DataSourceStatus.Connected, DataSourceStatus.Disconnected)
|
||||
);
|
||||
|
||||
expect(languageProvider.refreshLogLabels).toBeCalledTimes(1);
|
||||
expect(languageProvider.refreshLogLabels).toBeCalledWith(true);
|
||||
expect(languageProvider.refreshLogLabels).toBeCalledWith(rangeMock, true);
|
||||
});
|
||||
|
||||
it('should not force refresh labels after a connect', () => {
|
||||
const datasource = {
|
||||
metadataRequest: () => ({ data: { data: [] as any[] } }),
|
||||
};
|
||||
|
||||
const rangeMock: AbsoluteTimeRange = {
|
||||
from: 1560153109000,
|
||||
to: 1560153109000,
|
||||
};
|
||||
|
||||
const languageProvider = new LanguageProvider(datasource);
|
||||
languageProvider.refreshLogLabels = jest.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useLokiLabels(languageProvider, true, [], DataSourceStatus.Disconnected, DataSourceStatus.Connected)
|
||||
useLokiLabels(languageProvider, true, [], rangeMock, DataSourceStatus.Disconnected, DataSourceStatus.Connected)
|
||||
);
|
||||
|
||||
expect(languageProvider.refreshLogLabels).not.toBeCalled();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { DataSourceStatus } from '@grafana/ui/src/types/datasource';
|
||||
import { AbsoluteTimeRange } from '@grafana/data';
|
||||
|
||||
import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider';
|
||||
import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
|
||||
@@ -17,6 +18,7 @@ export const useLokiLabels = (
|
||||
languageProvider: LokiLanguageProvider,
|
||||
languageProviderInitialised: boolean,
|
||||
activeOption: CascaderOption[],
|
||||
absoluteRange: AbsoluteTimeRange,
|
||||
datasourceStatus: DataSourceStatus,
|
||||
initialDatasourceStatus?: DataSourceStatus // used for test purposes
|
||||
) => {
|
||||
@@ -32,14 +34,14 @@ export const useLokiLabels = (
|
||||
|
||||
// Async
|
||||
const fetchOptionValues = async (option: string) => {
|
||||
await languageProvider.fetchLabelValues(option);
|
||||
await languageProvider.fetchLabelValues(option, absoluteRange);
|
||||
if (mounted.current) {
|
||||
setLogLabelOptions(languageProvider.logLabelOptions);
|
||||
}
|
||||
};
|
||||
|
||||
const tryLabelsRefresh = async () => {
|
||||
await languageProvider.refreshLogLabels(shouldForceRefreshLabels);
|
||||
await languageProvider.refreshLogLabels(absoluteRange, shouldForceRefreshLabels);
|
||||
|
||||
if (mounted.current) {
|
||||
setRefreshLabels(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { renderHook, act } from 'react-hooks-testing-library';
|
||||
import { DataSourceStatus } from '@grafana/ui/src/types/datasource';
|
||||
import { AbsoluteTimeRange } from '@grafana/data';
|
||||
|
||||
import LanguageProvider from 'app/plugins/datasource/loki/language_provider';
|
||||
import { useLokiSyntax } from './useLokiSyntax';
|
||||
@@ -14,6 +15,11 @@ describe('useLokiSyntax hook', () => {
|
||||
const logLabelOptionsMock2 = ['Mock the hell?!'];
|
||||
const logLabelOptionsMock3 = ['Oh my mock!'];
|
||||
|
||||
const rangeMock: AbsoluteTimeRange = {
|
||||
from: 1560153109000,
|
||||
to: 1560163909000,
|
||||
};
|
||||
|
||||
languageProvider.refreshLogLabels = () => {
|
||||
languageProvider.logLabelOptions = logLabelOptionsMock;
|
||||
return Promise.resolve();
|
||||
@@ -30,7 +36,9 @@ describe('useLokiSyntax hook', () => {
|
||||
};
|
||||
|
||||
it('should provide Loki syntax when used', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider, DataSourceStatus.Connected));
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useLokiSyntax(languageProvider, DataSourceStatus.Connected, rangeMock)
|
||||
);
|
||||
expect(result.current.syntax).toEqual(null);
|
||||
|
||||
await waitForNextUpdate();
|
||||
@@ -39,7 +47,9 @@ describe('useLokiSyntax hook', () => {
|
||||
});
|
||||
|
||||
it('should fetch labels on first call', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider, DataSourceStatus.Connected));
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useLokiSyntax(languageProvider, DataSourceStatus.Connected, rangeMock)
|
||||
);
|
||||
expect(result.current.isSyntaxReady).toBeFalsy();
|
||||
expect(result.current.logLabelOptions).toEqual([]);
|
||||
|
||||
@@ -50,7 +60,9 @@ describe('useLokiSyntax hook', () => {
|
||||
});
|
||||
|
||||
it('should try to fetch missing options when active option changes', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => useLokiSyntax(languageProvider, DataSourceStatus.Connected));
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useLokiSyntax(languageProvider, DataSourceStatus.Connected, rangeMock)
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
expect(result.current.logLabelOptions).toEqual(logLabelOptionsMock2);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
// @ts-ignore
|
||||
import Prism from 'prismjs';
|
||||
import { DataSourceStatus } from '@grafana/ui/src/types/datasource';
|
||||
|
||||
import { AbsoluteTimeRange } from '@grafana/data';
|
||||
import LokiLanguageProvider from 'app/plugins/datasource/loki/language_provider';
|
||||
import { useLokiLabels } from 'app/plugins/datasource/loki/components/useLokiLabels';
|
||||
import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
|
||||
@@ -15,7 +15,11 @@ 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, datasourceStatus: DataSourceStatus) => {
|
||||
export const useLokiSyntax = (
|
||||
languageProvider: LokiLanguageProvider,
|
||||
datasourceStatus: DataSourceStatus,
|
||||
absoluteRange: AbsoluteTimeRange
|
||||
) => {
|
||||
const mounted = useRefMounted();
|
||||
// State
|
||||
const [languageProviderInitialized, setLanguageProviderInitilized] = useState(false);
|
||||
@@ -32,11 +36,13 @@ export const useLokiSyntax = (languageProvider: LokiLanguageProvider, datasource
|
||||
languageProvider,
|
||||
languageProviderInitialized,
|
||||
activeOption,
|
||||
absoluteRange,
|
||||
datasourceStatus
|
||||
);
|
||||
|
||||
// Async
|
||||
const initializeLanguageProvider = async () => {
|
||||
languageProvider.initialRange = absoluteRange;
|
||||
await languageProvider.start();
|
||||
Prism.languages[PRISM_SYNTAX] = languageProvider.getSyntax();
|
||||
if (mounted.current) {
|
||||
|
||||
@@ -78,6 +78,7 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
|
||||
...options,
|
||||
url,
|
||||
};
|
||||
|
||||
return this.backendSrv.datasourceRequest(req);
|
||||
}
|
||||
|
||||
@@ -254,10 +255,10 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
|
||||
return this.languageProvider.importQueries(queries, originMeta.id);
|
||||
}
|
||||
|
||||
metadataRequest(url: string) {
|
||||
metadataRequest(url: string, params?: any) {
|
||||
// HACK to get label values for {job=|}, will be replaced when implementing LokiQueryField
|
||||
const apiUrl = url.replace('v1', 'prom');
|
||||
return this._request(apiUrl, { silent: true }).then((res: DataQueryResponse) => {
|
||||
return this._request(apiUrl, params, { silent: true }).then((res: DataQueryResponse) => {
|
||||
const data: any = { data: { data: res.data.values || [] } };
|
||||
return data;
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// @ts-ignore
|
||||
import Plain from 'slate-plain-serializer';
|
||||
|
||||
import LanguageProvider, { LABEL_REFRESH_INTERVAL } from './language_provider';
|
||||
import LanguageProvider, { LABEL_REFRESH_INTERVAL, rangeToParams } from './language_provider';
|
||||
import { AbsoluteTimeRange } from '@grafana/data';
|
||||
import { advanceTo, clear, advanceBy } from 'jest-date-mock';
|
||||
import { beforeEach } from 'test/lib/common';
|
||||
import { DataQueryResponseData } from '@grafana/ui';
|
||||
@@ -11,8 +12,13 @@ describe('Language completion provider', () => {
|
||||
metadataRequest: () => ({ data: { data: [] as DataQueryResponseData[] } }),
|
||||
};
|
||||
|
||||
const rangeMock: AbsoluteTimeRange = {
|
||||
from: 1560153109000,
|
||||
to: 1560163909000,
|
||||
};
|
||||
|
||||
describe('empty query suggestions', () => {
|
||||
it('returns no suggestions on emtpty context', () => {
|
||||
it('returns no suggestions on empty context', () => {
|
||||
const instance = new LanguageProvider(datasource);
|
||||
const value = Plain.deserialize('');
|
||||
const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] });
|
||||
@@ -21,7 +27,7 @@ describe('Language completion provider', () => {
|
||||
expect(result.suggestions.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('returns default suggestions with history on emtpty context when history was provided', () => {
|
||||
it('returns default suggestions with history on empty context when history was provided', () => {
|
||||
const instance = new LanguageProvider(datasource);
|
||||
const value = Plain.deserialize('');
|
||||
const history = [
|
||||
@@ -29,7 +35,10 @@ describe('Language completion provider', () => {
|
||||
query: { refId: '1', expr: '{app="foo"}' },
|
||||
},
|
||||
];
|
||||
const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }, { history });
|
||||
const result = instance.provideCompletionItems(
|
||||
{ text: '', prefix: '', value, wrapperClasses: [] },
|
||||
{ history, absoluteRange: rangeMock }
|
||||
);
|
||||
expect(result.context).toBeUndefined();
|
||||
expect(result.refresher).toBeUndefined();
|
||||
expect(result.suggestions).toMatchObject([
|
||||
@@ -79,64 +88,102 @@ describe('Language completion provider', () => {
|
||||
anchorOffset: 1,
|
||||
});
|
||||
const valueWithSelection = value.change().select(range).value;
|
||||
const result = instance.provideCompletionItems({
|
||||
text: '',
|
||||
prefix: '',
|
||||
wrapperClasses: ['context-labels'],
|
||||
value: valueWithSelection,
|
||||
});
|
||||
const result = instance.provideCompletionItems(
|
||||
{
|
||||
text: '',
|
||||
prefix: '',
|
||||
wrapperClasses: ['context-labels'],
|
||||
value: valueWithSelection,
|
||||
},
|
||||
{ absoluteRange: rangeMock }
|
||||
);
|
||||
expect(result.context).toBe('context-labels');
|
||||
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'namespace' }], label: 'Labels' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request URL', () => {
|
||||
it('should contain range params', async () => {
|
||||
const rangeMock: AbsoluteTimeRange = {
|
||||
from: 1560153109000,
|
||||
to: 1560163909000,
|
||||
};
|
||||
|
||||
const datasourceWithLabels = {
|
||||
metadataRequest: url => {
|
||||
if (url.slice(0, 15) === '/api/prom/label') {
|
||||
return { data: { data: ['other'] } };
|
||||
} else {
|
||||
return { data: { data: [] } };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const datasourceSpy = jest.spyOn(datasourceWithLabels, 'metadataRequest');
|
||||
|
||||
const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock });
|
||||
await instance.refreshLogLabels(rangeMock, true);
|
||||
const expectedUrl = '/api/prom/label';
|
||||
expect(datasourceSpy).toHaveBeenCalledWith(expectedUrl, rangeToParams(rangeMock));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Query imports', () => {
|
||||
const datasource = {
|
||||
metadataRequest: () => ({ data: { data: [] as DataQueryResponseData[] } }),
|
||||
};
|
||||
|
||||
const rangeMock: AbsoluteTimeRange = {
|
||||
from: 1560153109000,
|
||||
to: 1560163909000,
|
||||
};
|
||||
|
||||
it('returns empty queries for unknown origin datasource', async () => {
|
||||
const instance = new LanguageProvider(datasource);
|
||||
const instance = new LanguageProvider(datasource, { initialRange: rangeMock });
|
||||
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);
|
||||
const instance = new LanguageProvider(datasource, { initialRange: rangeMock });
|
||||
const result = await instance.importPrometheusQuery('foo');
|
||||
expect(result).toEqual('');
|
||||
});
|
||||
|
||||
it('returns empty query from selector query if label is not available', async () => {
|
||||
const datasourceWithLabels = {
|
||||
metadataRequest: (url: string) =>
|
||||
url === '/api/prom/label' ? { data: { data: ['other'] } } : { data: { data: [] as DataQueryResponseData[] } },
|
||||
metadataRequest: url =>
|
||||
url.slice(0, 15) === '/api/prom/label'
|
||||
? { data: { data: ['other'] } }
|
||||
: { data: { data: [] as DataQueryResponseData[] } },
|
||||
};
|
||||
const instance = new LanguageProvider(datasourceWithLabels);
|
||||
const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock });
|
||||
const result = await instance.importPrometheusQuery('{foo="bar"}');
|
||||
expect(result).toEqual('{}');
|
||||
});
|
||||
|
||||
it('returns selector query from selector query with common labels', async () => {
|
||||
const datasourceWithLabels = {
|
||||
metadataRequest: (url: string) =>
|
||||
url === '/api/prom/label' ? { data: { data: ['foo'] } } : { data: { data: [] as DataQueryResponseData[] } },
|
||||
metadataRequest: url =>
|
||||
url.slice(0, 15) === '/api/prom/label'
|
||||
? { data: { data: ['foo'] } }
|
||||
: { data: { data: [] as DataQueryResponseData[] } },
|
||||
};
|
||||
const instance = new LanguageProvider(datasourceWithLabels);
|
||||
const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock });
|
||||
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 = {
|
||||
metadataRequest: (url: string) =>
|
||||
url === '/api/prom/label'
|
||||
metadataRequest: url =>
|
||||
url.slice(0, 15) === '/api/prom/label'
|
||||
? { data: { data: [] as DataQueryResponseData[] } }
|
||||
: { data: { data: [] as DataQueryResponseData[] } },
|
||||
};
|
||||
const instance = new LanguageProvider(datasourceWithLabels);
|
||||
const instance = new LanguageProvider(datasourceWithLabels, { initialRange: rangeMock });
|
||||
const result = await instance.importPrometheusQuery('metric{foo="bar",baz="42"}');
|
||||
expect(result).toEqual('{baz="42",foo="bar"}');
|
||||
});
|
||||
@@ -149,6 +196,11 @@ describe('Labels refresh', () => {
|
||||
};
|
||||
const instance = new LanguageProvider(datasource);
|
||||
|
||||
const rangeMock: AbsoluteTimeRange = {
|
||||
from: 1560153109000,
|
||||
to: 1560163909000,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
instance.fetchLogLabels = jest.fn();
|
||||
});
|
||||
@@ -157,18 +209,20 @@ describe('Labels refresh', () => {
|
||||
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();
|
||||
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();
|
||||
instance.refreshLogLabels(rangeMock);
|
||||
expect(instance.fetchLogLabels).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,16 +15,18 @@ import {
|
||||
HistoryItem,
|
||||
} from 'app/types/explore';
|
||||
import { LokiQuery } from './types';
|
||||
import { dateTime } from '@grafana/data';
|
||||
import { dateTime, AbsoluteTimeRange } from '@grafana/data';
|
||||
import { PromQuery } from '../prometheus/types';
|
||||
|
||||
const DEFAULT_KEYS = ['job', 'namespace'];
|
||||
const EMPTY_SELECTOR = '{}';
|
||||
const HISTORY_ITEM_COUNT = 10;
|
||||
const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
|
||||
const NS_IN_MS = 1_000_000;
|
||||
export const LABEL_REFRESH_INTERVAL = 1000 * 30; // 30sec
|
||||
|
||||
const wrapLabel = (label: string) => ({ label });
|
||||
export const rangeToParams = (range: AbsoluteTimeRange) => ({ start: range.from * NS_IN_MS, end: range.to * NS_IN_MS });
|
||||
|
||||
type LokiHistoryItem = HistoryItem<LokiQuery>;
|
||||
|
||||
@@ -50,6 +52,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
logLabelOptions: any[];
|
||||
logLabelFetchTs?: number;
|
||||
started: boolean;
|
||||
initialRange: AbsoluteTimeRange;
|
||||
|
||||
constructor(datasource: any, initialValues?: any) {
|
||||
super();
|
||||
@@ -67,13 +70,13 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
return syntax;
|
||||
}
|
||||
|
||||
request = (url: string) => {
|
||||
return this.datasource.metadataRequest(url);
|
||||
request = (url: string, params?: any) => {
|
||||
return this.datasource.metadataRequest(url, params);
|
||||
};
|
||||
|
||||
start = () => {
|
||||
if (!this.startTask) {
|
||||
this.startTask = this.fetchLogLabels();
|
||||
this.startTask = this.fetchLogLabels(this.initialRange);
|
||||
}
|
||||
return this.startTask;
|
||||
};
|
||||
@@ -120,7 +123,10 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput {
|
||||
getLabelCompletionItems(
|
||||
{ text, wrapperClasses, labelKey, value }: TypeaheadInput,
|
||||
{ absoluteRange }: any
|
||||
): TypeaheadOutput {
|
||||
let context: string;
|
||||
let refresher: Promise<any> = null;
|
||||
const suggestions: CompletionItemGroup[] = [];
|
||||
@@ -146,7 +152,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
items: labelValues.map(wrapLabel),
|
||||
});
|
||||
} else {
|
||||
refresher = this.fetchLabelValues(labelKey);
|
||||
refresher = this.fetchLabelValues(labelKey, absoluteRange);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -206,7 +212,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
if (existingKeys && existingKeys.length > 0) {
|
||||
// Check for common labels
|
||||
for (const key in labels) {
|
||||
if (existingKeys && existingKeys.indexOf(key) > -1) {
|
||||
if (existingKeys && existingKeys.includes(key)) {
|
||||
// Should we check for label value equality here?
|
||||
labelsToKeep[key] = labels[key];
|
||||
}
|
||||
@@ -227,11 +233,12 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
return '';
|
||||
}
|
||||
|
||||
async fetchLogLabels(): Promise<any> {
|
||||
async fetchLogLabels(absoluteRange: AbsoluteTimeRange): Promise<any> {
|
||||
const url = '/api/prom/label';
|
||||
try {
|
||||
this.logLabelFetchTs = Date.now();
|
||||
const res = await this.request(url);
|
||||
|
||||
const res = await this.request(url, rangeToParams(absoluteRange));
|
||||
const body = await (res.data || res.json());
|
||||
const labelKeys = body.data.slice().sort();
|
||||
this.labelKeys = {
|
||||
@@ -244,7 +251,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
return Promise.all(
|
||||
labelKeys
|
||||
.filter((key: string) => DEFAULT_KEYS.indexOf(key) > -1)
|
||||
.map((key: string) => this.fetchLabelValues(key))
|
||||
.map((key: string) => this.fetchLabelValues(key, absoluteRange))
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -252,16 +259,16 @@ export default class LokiLanguageProvider extends LanguageProvider {
|
||||
return [];
|
||||
}
|
||||
|
||||
async refreshLogLabels(forceRefresh?: boolean) {
|
||||
async refreshLogLabels(absoluteRange: AbsoluteTimeRange, forceRefresh?: boolean) {
|
||||
if ((this.labelKeys && Date.now() - this.logLabelFetchTs > LABEL_REFRESH_INTERVAL) || forceRefresh) {
|
||||
await this.fetchLogLabels();
|
||||
await this.fetchLogLabels(absoluteRange);
|
||||
}
|
||||
}
|
||||
|
||||
async fetchLabelValues(key: string) {
|
||||
async fetchLabelValues(key: string, absoluteRange: AbsoluteTimeRange) {
|
||||
const url = `/api/prom/label/${key}/values`;
|
||||
try {
|
||||
const res = await this.request(url);
|
||||
const res = await this.request(url, rangeToParams(absoluteRange));
|
||||
const body = await (res.data || res.json());
|
||||
const values = body.data.slice().sort();
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export function addHistoryMetadata(item: CompletionItem, history: any[]): Comple
|
||||
|
||||
export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
histogramMetrics?: string[];
|
||||
timeRange?: { start: number; end: number };
|
||||
labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...]
|
||||
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
|
||||
metrics?: string[];
|
||||
@@ -52,6 +53,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
|
||||
this.datasource = datasource;
|
||||
this.histogramMetrics = [];
|
||||
this.timeRange = { start: 0, end: 0 };
|
||||
this.labelKeys = {};
|
||||
this.labelValues = {};
|
||||
this.metrics = [];
|
||||
@@ -205,6 +207,18 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
};
|
||||
}
|
||||
|
||||
roundToMinutes(seconds: number): number {
|
||||
return Math.floor(seconds / 60);
|
||||
}
|
||||
|
||||
timeRangeChanged(): boolean {
|
||||
const dsRange = this.datasource.getTimeRange();
|
||||
return (
|
||||
this.roundToMinutes(dsRange.end) !== this.roundToMinutes(this.timeRange.end) ||
|
||||
this.roundToMinutes(dsRange.start) !== this.roundToMinutes(this.timeRange.start)
|
||||
);
|
||||
}
|
||||
|
||||
getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput {
|
||||
const refresher: Promise<any> = null;
|
||||
const suggestions: CompletionItemGroup[] = [];
|
||||
@@ -253,7 +267,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
|
||||
|
||||
const labelKeys = this.labelKeys[selector];
|
||||
if (labelKeys) {
|
||||
if (labelKeys && !this.timeRangeChanged()) {
|
||||
suggestions.push({ label: 'Labels', items: labelKeys.map(wrapLabel) });
|
||||
} else {
|
||||
result.refresher = this.fetchSeriesLabels(selector);
|
||||
@@ -304,7 +318,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
}
|
||||
|
||||
// Query labels for selector
|
||||
if (selector && !this.labelValues[selector]) {
|
||||
if (selector && (!this.labelValues[selector] || this.timeRangeChanged())) {
|
||||
if (selector === EMPTY_SELECTOR) {
|
||||
// Query label values for default labels
|
||||
refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
|
||||
@@ -332,10 +346,12 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
|
||||
fetchSeriesLabels = async (name: string, withName?: boolean) => {
|
||||
try {
|
||||
const data = await this.request(`/api/v1/series?match[]=${name}`);
|
||||
const tRange = this.datasource.getTimeRange();
|
||||
const data = await this.request(`/api/v1/series?match[]=${name}&start=${tRange['start']}&end=${tRange['end']}`);
|
||||
const { keys, values } = processLabels(data, withName);
|
||||
this.labelKeys[name] = keys;
|
||||
this.labelValues[name] = values;
|
||||
this.timeRange = tRange;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import LanguageProvider from '../language_provider';
|
||||
describe('Language completion provider', () => {
|
||||
const datasource = {
|
||||
metadataRequest: () => ({ data: { data: [] as any[] } }),
|
||||
getTimeRange: () => ({ start: 0, end: 1 }),
|
||||
};
|
||||
|
||||
describe('empty query suggestions', () => {
|
||||
|
||||
@@ -286,7 +286,7 @@ export interface HistoryItem<TQuery extends DataQuery = DataQuery> {
|
||||
|
||||
export abstract class LanguageProvider {
|
||||
datasource: any;
|
||||
request: (url: any) => Promise<any>;
|
||||
request: (url: string, params?: any) => Promise<any>;
|
||||
/**
|
||||
* Returns startTask that resolves with a task list when main syntax is loaded.
|
||||
* Task list consists of secondary promises that load more detailed language features.
|
||||
|
||||
Reference in New Issue
Block a user