diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index ff0f05e68b1..480c79ede20 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -7,6 +7,7 @@ export * from './dashboard'; export * from './query'; export * from './annotations'; export * from './logs'; +export * from './logsVolume'; export * from './navModel'; export * from './select'; export * from './time'; diff --git a/packages/grafana-data/src/types/logsVolume.ts b/packages/grafana-data/src/types/logsVolume.ts new file mode 100644 index 00000000000..bbb73eef7a1 --- /dev/null +++ b/packages/grafana-data/src/types/logsVolume.ts @@ -0,0 +1,22 @@ +import { DataQuery } from './query'; +import { DataQueryRequest, DataQueryResponse } from './datasource'; +import { Observable } from 'rxjs'; + +/** + * TODO: This should be added to ./logs.ts but because of cross reference between ./datasource.ts and ./logs.ts it can + * be done only after decoupling "logs" from "datasource" (https://github.com/grafana/grafana/pull/39536) + * + * @internal + */ +export interface DataSourceWithLogsVolumeSupport { + getLogsVolumeDataProvider(request: DataQueryRequest): Observable | undefined; +} + +/** + * @internal + */ +export const hasLogsVolumeSupport = ( + datasource: any +): datasource is DataSourceWithLogsVolumeSupport => { + return (datasource as DataSourceWithLogsVolumeSupport).getLogsVolumeDataProvider !== undefined; +}; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 952bd65d7b4..75b83c7e718 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -67,6 +67,7 @@ export class GrafanaBootConfig implements GrafanaConfig { recordedQueries: false, prometheusMonaco: false, newNavigation: false, + fullRangeLogsVolume: false, }; licenseInfo: LicenseInfo = {} as LicenseInfo; rendererAvailable = false; diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 393ca9b8a58..a29aaaf9d18 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -83,6 +83,11 @@ const dummyProps: Props = { showTrace: true, showNodeGraph: true, splitOpen: (() => {}) as any, + autoLoadLogsVolume: false, + logsVolumeData: undefined, + logsVolumeDataProvider: undefined, + loadLogsVolumeData: () => {}, + changeAutoLogsVolume: () => {}, }; describe('Explore', () => { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 380f3be4f92..12911ff1860 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -5,8 +5,8 @@ import { connect, ConnectedProps } from 'react-redux'; import AutoSizer from 'react-virtualized-auto-sizer'; import memoizeOne from 'memoize-one'; import { selectors } from '@grafana/e2e-selectors'; -import { ErrorBoundaryAlert, CustomScrollbar, Collapse, withTheme2, Themeable2 } from '@grafana/ui'; -import { AbsoluteTimeRange, DataQuery, LoadingState, RawTimeRange, DataFrame, GrafanaTheme2 } from '@grafana/data'; +import { Collapse, CustomScrollbar, ErrorBoundaryAlert, Themeable2, withTheme2 } from '@grafana/ui'; +import { AbsoluteTimeRange, DataFrame, DataQuery, GrafanaTheme2, LoadingState, RawTimeRange } from '@grafana/data'; import LogsContainer from './LogsContainer'; import { QueryRows } from './QueryRows'; @@ -16,7 +16,15 @@ import ExploreQueryInspector from './ExploreQueryInspector'; import { splitOpen } from './state/main'; import { changeSize } from './state/explorePane'; import { updateTimeRange } from './state/time'; -import { scanStopAction, addQueryRow, modifyQueries, setQueries, scanStart } from './state/query'; +import { + addQueryRow, + changeAutoLogsVolume, + loadLogsVolumeData, + modifyQueries, + scanStart, + scanStopAction, + setQueries, +} from './state/query'; import { ExploreId, ExploreItemState } from 'app/types/explore'; import { StoreState } from 'app/types'; import { ExploreToolbar } from './ExploreToolbar'; @@ -28,6 +36,7 @@ import { NodeGraphContainer } from './NodeGraphContainer'; import { ResponseErrorContainer } from './ResponseErrorContainer'; import { TraceViewContainer } from './TraceView/TraceViewContainer'; import { ExploreGraph } from './ExploreGraph'; +import { LogsVolumePanel } from './LogsVolumePanel'; const getStyles = (theme: GrafanaTheme2) => { return { @@ -205,6 +214,36 @@ export class Explore extends React.PureComponent { ); } + renderLogsVolume(width: number) { + const { + logsVolumeData, + exploreId, + loadLogsVolumeData, + autoLoadLogsVolume, + changeAutoLogsVolume, + absoluteRange, + timeZone, + splitOpen, + } = this.props; + + return ( + { + changeAutoLogsVolume(exploreId, autoLoadLogsVolume); + }} + /> + ); + } + renderTablePanel(width: number) { const { exploreId, datasourceInstance } = this.props; return ( @@ -277,12 +316,14 @@ export class Explore extends React.PureComponent { showLogs, showTrace, showNodeGraph, + logsVolumeDataProvider, } = this.props; const { openDrawer } = this.state; const styles = getStyles(theme); const showPanels = queryResponse && queryResponse.state !== LoadingState.NotStarted; const showRichHistory = openDrawer === ExploreDrawer.RichHistory; const showQueryInspector = openDrawer === ExploreDrawer.QueryInspector; + const showLogsVolume = !!logsVolumeDataProvider; return ( @@ -319,6 +360,7 @@ export class Explore extends React.PureComponent { {showMetrics && graphResult && ( {this.renderGraphPanel(width)} )} + {showLogsVolume && {this.renderLogsVolume(width)}} {showTable && {this.renderTablePanel(width)}} {showLogs && {this.renderLogsPanel(width)}} {showNodeGraph && {this.renderNodeGraphPanel()}} @@ -353,7 +395,7 @@ export class Explore extends React.PureComponent { function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { const explore = state.explore; - const { syncedTimes } = explore; + const { syncedTimes, autoLoadLogsVolume } = explore; const item: ExploreItemState = explore[exploreId]!; const timeZone = getTimeZone(state.user); const { @@ -362,6 +404,8 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { queryKeys, isLive, graphResult, + logsVolumeDataProvider, + logsVolumeData, logsResult, showLogs, showMetrics, @@ -379,6 +423,9 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { queryKeys, isLive, graphResult, + autoLoadLogsVolume, + logsVolumeDataProvider, + logsVolumeData, logsResult: logsResult ?? undefined, absoluteRange, queryResponse, @@ -400,6 +447,8 @@ const mapDispatchToProps = { scanStopAction, setQueries, updateTimeRange, + loadLogsVolumeData, + changeAutoLogsVolume, addQueryRow, splitOpen, }; diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 33b5645a694..533c1c5b298 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -213,7 +213,7 @@ export class UnConnectedExploreToolbar extends PureComponent { } const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => { - const syncedTimes = state.explore.syncedTimes; + const { syncedTimes, autoLoadLogsVolume } = state.explore; const exploreItem: ExploreItemState = state.explore[exploreId]!; const { datasourceInstance, @@ -242,6 +242,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => { isPaused, syncedTimes, containerWidth, + autoLoadLogsVolume, }; }; diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index adc9097089c..a7d34cb4584 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -136,8 +136,8 @@ export class UnthemedLogs extends PureComponent { this.setState({ dedupStrategy }); }; - onChangeLabels = (event?: React.SyntheticEvent) => { - const target = event && (event.target as HTMLInputElement); + onChangeLabels = (event: React.ChangeEvent) => { + const { target } = event; if (target) { const showLabels = target.checked; this.setState({ @@ -147,8 +147,8 @@ export class UnthemedLogs extends PureComponent { } }; - onChangeTime = (event?: React.SyntheticEvent) => { - const target = event && (event.target as HTMLInputElement); + onChangeTime = (event: React.ChangeEvent) => { + const { target } = event; if (target) { const showTime = target.checked; this.setState({ @@ -158,8 +158,8 @@ export class UnthemedLogs extends PureComponent { } }; - onChangewrapLogMessage = (event?: React.SyntheticEvent) => { - const target = event && (event.target as HTMLInputElement); + onChangewrapLogMessage = (event: React.ChangeEvent) => { + const { target } = event; if (target) { const wrapLogMessage = target.checked; this.setState({ @@ -169,8 +169,8 @@ export class UnthemedLogs extends PureComponent { } }; - onChangePrettifyLogMessage = (event?: React.SyntheticEvent) => { - const target = event && (event.target as HTMLInputElement); + onChangePrettifyLogMessage = (event: React.ChangeEvent) => { + const { target } = event; if (target) { const prettifyLogMessage = target.checked; this.setState({ @@ -294,21 +294,24 @@ export class UnthemedLogs extends PureComponent { return ( <> -
- This datasource does not support full-range histograms. The graph is based on the logs seen in the response. -
{logsSeries && logsSeries.length ? ( - + <> +
+ This datasource does not support full-range histograms. The graph is based on the logs seen in the + response. +
+ + ) : undefined}
diff --git a/public/app/features/explore/LogsVolumePanel.test.tsx b/public/app/features/explore/LogsVolumePanel.test.tsx new file mode 100644 index 00000000000..3c100bd2abc --- /dev/null +++ b/public/app/features/explore/LogsVolumePanel.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { LogsVolumePanel } from './LogsVolumePanel'; +import { ExploreId } from '../../types'; +import { DataQueryResponse, LoadingState } from '@grafana/data'; + +jest.mock('./ExploreGraph', () => { + const ExploreGraph = () => ExploreGraph; + return { + ExploreGraph, + }; +}); + +function renderPanel(logsVolumeData?: DataQueryResponse) { + render( + {}} + absoluteRange={{ from: 0, to: 1 }} + timeZone="timeZone" + splitOpen={() => {}} + width={100} + onUpdateTimeRange={() => {}} + logsVolumeData={logsVolumeData} + autoLoadLogsVolume={false} + onChangeAutoLogsVolume={() => {}} + /> + ); +} + +describe('LogsVolumePanel', () => { + it('shows loading message', () => { + renderPanel({ state: LoadingState.Loading, error: undefined, data: [] }); + expect(screen.getByText('Logs volume is loading...')).toBeInTheDocument(); + }); + + it('shows no volume data', () => { + renderPanel({ state: LoadingState.Done, error: undefined, data: [] }); + expect(screen.getByText('No volume data.')).toBeInTheDocument(); + }); + + it('renders logs volume histogram graph', () => { + renderPanel({ state: LoadingState.Done, error: undefined, data: [{}] }); + expect(screen.getByText('ExploreGraph')).toBeInTheDocument(); + }); + + it('shows error message', () => { + renderPanel({ state: LoadingState.Error, error: { data: { message: 'Error message' } }, data: [] }); + expect(screen.getByText('Failed to load volume logs for this query: Error message')).toBeInTheDocument(); + }); + + it('shows button to load logs volume', () => { + renderPanel(undefined); + expect(screen.getByText('Load logs volume')).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/explore/LogsVolumePanel.tsx b/public/app/features/explore/LogsVolumePanel.tsx new file mode 100644 index 00000000000..2baa72832e7 --- /dev/null +++ b/public/app/features/explore/LogsVolumePanel.tsx @@ -0,0 +1,114 @@ +import { AbsoluteTimeRange, DataQueryResponse, LoadingState, SplitOpen, TimeZone } from '@grafana/data'; +import { Button, Collapse, InlineField, InlineFieldRow, InlineSwitch, useTheme2 } from '@grafana/ui'; +import { ExploreGraph } from './ExploreGraph'; +import React, { useCallback } from 'react'; +import { ExploreId } from '../../types'; +import { css } from '@emotion/css'; + +type Props = { + exploreId: ExploreId; + loadLogsVolumeData: (exploreId: ExploreId) => void; + logsVolumeData?: DataQueryResponse; + absoluteRange: AbsoluteTimeRange; + timeZone: TimeZone; + splitOpen: SplitOpen; + width: number; + onUpdateTimeRange: (timeRange: AbsoluteTimeRange) => void; + autoLoadLogsVolume: boolean; + onChangeAutoLogsVolume: (value: boolean) => void; +}; + +export function LogsVolumePanel(props: Props) { + const { + width, + logsVolumeData, + exploreId, + loadLogsVolumeData, + absoluteRange, + timeZone, + splitOpen, + onUpdateTimeRange, + autoLoadLogsVolume, + onChangeAutoLogsVolume, + } = props; + const theme = useTheme2(); + const spacing = parseInt(theme.spacing(2).slice(0, -2), 10); + const height = 150; + + let LogsVolumePanelContent; + + if (!logsVolumeData) { + LogsVolumePanelContent = ( + + ); + } else if (logsVolumeData?.error) { + LogsVolumePanelContent = ( + + Failed to load volume logs for this query:{' '} + {logsVolumeData.error.data?.message || logsVolumeData.error.statusText} + + ); + } else if (logsVolumeData?.state === LoadingState.Loading) { + LogsVolumePanelContent = Logs volume is loading...; + } else if (logsVolumeData?.data) { + if (logsVolumeData.data.length > 0) { + LogsVolumePanelContent = ( + + ); + } else { + LogsVolumePanelContent = No volume data.; + } + } + + const handleOnChangeAutoLogsVolume = useCallback( + (event: React.ChangeEvent) => { + const { target } = event; + if (target) { + onChangeAutoLogsVolume(target.checked); + } + }, + [onChangeAutoLogsVolume] + ); + + return ( + +
+ {LogsVolumePanelContent} +
+
+ + + + + +
+
+ ); +} diff --git a/public/app/features/explore/QueryRows.test.tsx b/public/app/features/explore/QueryRows.test.tsx index 7242c2ef907..0b4c5d896df 100644 --- a/public/app/features/explore/QueryRows.test.tsx +++ b/public/app/features/explore/QueryRows.test.tsx @@ -48,6 +48,7 @@ function setup(queries: DataQuery[]) { syncedTimes: false, right: undefined, richHistory: [], + autoLoadLogsVolume: false, }; const store = configureStore({ explore: initialState, user: { orgId: 1 } as UserState }); diff --git a/public/app/features/explore/Wrapper.tsx b/public/app/features/explore/Wrapper.tsx index 94772b6f85b..84c4491e69d 100644 --- a/public/app/features/explore/Wrapper.tsx +++ b/public/app/features/explore/Wrapper.tsx @@ -2,7 +2,13 @@ import React, { PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { ExploreId, ExploreQueryParams } from 'app/types/explore'; import { ErrorBoundaryAlert } from '@grafana/ui'; -import { lastSavedUrl, resetExploreAction, richHistoryUpdatedAction } from './state/main'; +import { + AUTO_LOAD_LOGS_VOLUME_SETTING_KEY, + lastSavedUrl, + resetExploreAction, + richHistoryUpdatedAction, + storeAutoLoadLogsVolumeAction, +} from './state/main'; import { getRichHistory } from '../../core/utils/richHistory'; import { ExplorePaneContainer } from './ExplorePaneContainer'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; @@ -10,6 +16,7 @@ import { Branding } from '../../core/components/Branding/Branding'; import { getNavModel } from '../../core/selectors/navModel'; import { StoreState } from 'app/types'; +import store from '../../core/store'; interface RouteProps extends GrafanaRouteComponentProps<{}, ExploreQueryParams> {} interface OwnProps {} @@ -18,12 +25,14 @@ const mapStateToProps = (state: StoreState) => { return { navModel: getNavModel(state.navIndex, 'explore'), exploreState: state.explore, + autoLoadLogsVolume: state.explore.autoLoadLogsVolume, }; }; const mapDispatchToProps = { resetExploreAction, richHistoryUpdatedAction, + storeAutoLoadLogsVolumeAction, }; const connector = connect(mapStateToProps, mapDispatchToProps); @@ -42,7 +51,8 @@ class WrapperUnconnected extends PureComponent { this.props.richHistoryUpdatedAction({ richHistory }); } - componentDidUpdate() { + componentDidUpdate(prevProps: Props) { + const { autoLoadLogsVolume } = this.props; const { left, right } = this.props.queryParams; const hasSplit = Boolean(left) && Boolean(right); const datasourceTitle = hasSplit @@ -50,6 +60,10 @@ class WrapperUnconnected extends PureComponent { : `${this.props.exploreState.left.datasourceInstance?.name}`; const documentTitle = `${this.props.navModel.main.text} - ${datasourceTitle} - ${Branding.AppTitle}`; document.title = documentTitle; + + if (prevProps.autoLoadLogsVolume !== autoLoadLogsVolume) { + store.set(AUTO_LOAD_LOGS_VOLUME_SETTING_KEY, autoLoadLogsVolume); + } } render() { diff --git a/public/app/features/explore/state/main.ts b/public/app/features/explore/state/main.ts index d6a640dc14e..2de1e049379 100644 --- a/public/app/features/explore/state/main.ts +++ b/public/app/features/explore/state/main.ts @@ -9,6 +9,7 @@ import { getUrlStateFromPaneState, makeExplorePaneState } from './utils'; import { ThunkResult } from '../../../types'; import { TimeSrv } from '../../dashboard/services/TimeSrv'; import { PanelModel } from 'app/features/dashboard/state'; +import store from '../../../core/store'; // // Actions and Payloads @@ -21,6 +22,12 @@ export const syncTimesAction = createAction('explore/syncTimes export const richHistoryUpdatedAction = createAction('explore/richHistoryUpdated'); +/** + * Stores new value of auto-load logs volume switch. Used only internally. changeAutoLogsVolume() is used to + * update auto-load and load logs volume if it hasn't been loaded. + */ +export const storeAutoLoadLogsVolumeAction = createAction('explore/storeAutoLoadLogsVolumeAction'); + /** * Resets state for explore. */ @@ -154,6 +161,8 @@ export const navigateToExplore = ( }; }; +export const AUTO_LOAD_LOGS_VOLUME_SETTING_KEY = 'grafana.explore.logs.autoLoadLogsVolume'; + /** * Global Explore state that handles multiple Explore areas and the split state */ @@ -163,6 +172,7 @@ export const initialExploreState: ExploreState = { left: initialExploreItemState, right: undefined, richHistory: [], + autoLoadLogsVolume: store.getBool(AUTO_LOAD_LOGS_VOLUME_SETTING_KEY, false), }; /** @@ -217,6 +227,14 @@ export const exploreReducer = (state = initialExploreState, action: AnyAction): }; } + if (storeAutoLoadLogsVolumeAction.match(action)) { + const autoLoadLogsVolume = action.payload; + return { + ...state, + autoLoadLogsVolume, + }; + } + if (resetExploreAction.match(action)) { const payload: ResetExplorePayload = action.payload; const leftState = state[ExploreId.left]; diff --git a/public/app/features/explore/state/query.test.ts b/public/app/features/explore/state/query.test.ts index a80de95bc84..4d5c161567b 100644 --- a/public/app/features/explore/state/query.test.ts +++ b/public/app/features/explore/state/query.test.ts @@ -3,15 +3,17 @@ import { addResultsToCache, cancelQueries, cancelQueriesAction, + changeAutoLogsVolume, clearCache, importQueries, + loadLogsVolumeData, queryReducer, runQueries, scanStartAction, scanStopAction, } from './query'; import { ExploreId, ExploreItemState, StoreState, ThunkDispatch } from 'app/types'; -import { interval, of } from 'rxjs'; +import { interval, Observable, of } from 'rxjs'; import { ArrayVector, DataFrame, @@ -33,6 +35,16 @@ import { configureStore } from '../../../store/configureStore'; import { setTimeSrv } from '../../dashboard/services/TimeSrv'; import Mock = jest.Mock; +jest.mock('@grafana/runtime', () => ({ + ...((jest.requireActual('@grafana/runtime') as unknown) as object), + config: { + ...((jest.requireActual('@grafana/runtime') as unknown) as any).config, + featureToggles: { + fullRangeLogsVolume: true, + }, + }, +})); + const t = toUtc(); const testRange = { from: t, @@ -69,6 +81,22 @@ const defaultInitialState = { }, }; +function setupQueryResponse(state: StoreState) { + (state.explore[ExploreId.left].datasourceInstance?.query as Mock).mockReturnValueOnce( + of({ + error: { message: 'test error' }, + data: [ + new MutableDataFrame({ + fields: [{ name: 'test', values: new ArrayVector() }], + meta: { + preferredVisualisationType: 'graph', + }, + }), + ], + } as DataQueryResponse) + ); +} + describe('runQueries', () => { it('should pass dataFrames to state even if there is error in response', async () => { setTimeSrv({ @@ -77,19 +105,7 @@ describe('runQueries', () => { const { dispatch, getState }: { dispatch: ThunkDispatch; getState: () => StoreState } = configureStore({ ...(defaultInitialState as any), }); - (getState().explore[ExploreId.left].datasourceInstance?.query as Mock).mockReturnValueOnce( - of({ - error: { message: 'test error' }, - data: [ - new MutableDataFrame({ - fields: [{ name: 'test', values: new ArrayVector() }], - meta: { - preferredVisualisationType: 'graph', - }, - }), - ], - } as DataQueryResponse) - ); + setupQueryResponse(getState()); await dispatch(runQueries(ExploreId.left)); expect(getState().explore[ExploreId.left].showMetrics).toBeTruthy(); expect(getState().explore[ExploreId.left].graphResult).toBeDefined(); @@ -303,4 +319,136 @@ describe('reducer', () => { expect(getState().explore[ExploreId.left].cache).toEqual([]); }); }); + + describe('logs volume', () => { + let dispatch: ThunkDispatch, + getState: () => StoreState, + mockLogsVolumeDataProvider: () => Observable; + + beforeEach(() => { + mockLogsVolumeDataProvider = () => { + return of( + { state: LoadingState.Loading, error: undefined, data: [] }, + { state: LoadingState.Done, error: undefined, data: [{}] } + ); + }; + + const store: { dispatch: ThunkDispatch; getState: () => StoreState } = configureStore({ + ...(defaultInitialState as any), + explore: { + [ExploreId.left]: { + ...defaultInitialState.explore[ExploreId.left], + autoLoadLogsVolume: false, + datasourceInstance: { + query: jest.fn(), + meta: { + id: 'something', + }, + getLogsVolumeDataProvider: () => { + return mockLogsVolumeDataProvider(); + }, + }, + }, + }, + }); + + dispatch = store.dispatch; + getState = store.getState; + }); + + it('should not load logs volume automatically after running the query if auto-loading is disabled', async () => { + setupQueryResponse(getState()); + getState().explore.autoLoadLogsVolume = false; + + await dispatch(runQueries(ExploreId.left)); + + expect(getState().explore[ExploreId.left].logsVolumeData).not.toBeDefined(); + }); + + it('should load logs volume automatically after running the query if auto-loading is enabled', async () => { + setupQueryResponse(getState()); + getState().explore.autoLoadLogsVolume = true; + + await dispatch(runQueries(ExploreId.left)); + + expect(getState().explore[ExploreId.left].logsVolumeData).toMatchObject({ + state: LoadingState.Done, + error: undefined, + data: [{}], + }); + }); + + it('when auto-load is enabled after running the query it should load logs volume data after changing auto-load option', async () => { + setupQueryResponse(getState()); + + await dispatch(runQueries(ExploreId.left)); + + expect(getState().explore[ExploreId.left].logsVolumeDataProvider).toBeDefined(); + expect(getState().explore[ExploreId.left].logsVolumeData).not.toBeDefined(); + + await dispatch(changeAutoLogsVolume(ExploreId.left, true)); + + expect(getState().explore.autoLoadLogsVolume).toEqual(true); + expect(getState().explore[ExploreId.left].logsVolumeData).toMatchObject({ + state: LoadingState.Done, + error: undefined, + data: [{}], + }); + }); + + it('should allow loading logs volume on demand if auto-load is disabled', async () => { + setupQueryResponse(getState()); + getState().explore.autoLoadLogsVolume = false; + + await dispatch(runQueries(ExploreId.left)); + expect(getState().explore[ExploreId.left].logsVolumeData).not.toBeDefined(); + + await dispatch(loadLogsVolumeData(ExploreId.left)); + + expect(getState().explore.autoLoadLogsVolume).toEqual(false); + expect(getState().explore[ExploreId.left].logsVolumeData).toMatchObject({ + state: LoadingState.Done, + error: undefined, + data: [{}], + }); + }); + + it('should cancel any unfinished logs volume queries', async () => { + setupQueryResponse(getState()); + let unsubscribes: Function[] = []; + + mockLogsVolumeDataProvider = () => { + return ({ + subscribe: () => { + const unsubscribe = jest.fn(); + unsubscribes.push(unsubscribe); + return { + unsubscribe, + }; + }, + } as unknown) as Observable; + }; + + await dispatch(runQueries(ExploreId.left)); + // no subscriptions created yet + expect(unsubscribes).toHaveLength(0); + + await dispatch(loadLogsVolumeData(ExploreId.left)); + // loading in progress - one subscription created, not cleaned up yet + expect(unsubscribes).toHaveLength(1); + expect(unsubscribes[0]).not.toBeCalled(); + + setupQueryResponse(getState()); + await dispatch(runQueries(ExploreId.left)); + // new query was run - first subscription is cleaned up, no new subscriptions yet + expect(unsubscribes).toHaveLength(1); + expect(unsubscribes[0]).toBeCalled(); + + await dispatch(loadLogsVolumeData(ExploreId.left)); + // new subscription is created, only the old was was cleaned up + expect(unsubscribes).toHaveLength(2); + expect(unsubscribes[0]).toBeCalled(); + expect(unsubscribes[1]).not.toBeCalled(); + }); + }); }); diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index d3deb6394c0..3dd4fca9e4d 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -1,9 +1,11 @@ import { mergeMap, throttleTime } from 'rxjs/operators'; -import { identity, Unsubscribable, of } from 'rxjs'; +import { identity, Observable, of, SubscriptionLike, Unsubscribable } from 'rxjs'; import { DataQuery, DataQueryErrorType, + DataQueryResponse, DataSourceApi, + hasLogsVolumeSupport, LoadingState, PanelData, PanelEvents, @@ -30,11 +32,12 @@ import { notifyApp } from '../../../core/actions'; import { runRequest } from '../../query/state/runRequest'; import { decorateData } from '../utils/decorators'; import { createErrorNotification } from '../../../core/copy/appNotification'; -import { richHistoryUpdatedAction, stateSave } from './main'; +import { richHistoryUpdatedAction, stateSave, storeAutoLoadLogsVolumeAction } from './main'; import { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit'; import { updateTime } from './time'; import { historyUpdatedAction } from './history'; -import { createEmptyQueryResponse, createCacheKey, getResultsFromCache } from './utils'; +import { createCacheKey, createEmptyQueryResponse, getResultsFromCache } from './utils'; +import { config } from '@grafana/runtime'; // // Actions and Payloads @@ -98,10 +101,43 @@ export interface QueryStoreSubscriptionPayload { exploreId: ExploreId; querySubscription: Unsubscribable; } + export const queryStoreSubscriptionAction = createAction( 'explore/queryStoreSubscription' ); +export interface StoreLogsVolumeDataProvider { + exploreId: ExploreId; + logsVolumeDataProvider?: Observable; +} + +/** + * Stores available logs volume provider after running the query. Used internally by runQueries(). + */ +const storeLogsVolumeDataProviderAction = createAction( + 'explore/storeLogsVolumeDataProviderAction' +); + +export interface StoreLogsVolumeDataSubscriptionPayload { + exploreId: ExploreId; + logsVolumeDataSubscription?: SubscriptionLike; +} + +/** + * Stores current logs volume subscription for given explore pane. + */ +const storeLogsVolumeDataSubscriptionAction = createAction( + 'explore/storeLogsVolumeDataSubscriptionAction' +); + +/** + * Stores data returned by the provider. Used internally by loadLogsVolumeData(). + */ +const updateLogsVolumeDataAction = createAction<{ + exploreId: ExploreId; + logsVolumeData: DataQueryResponse; +}>('explore/updateLogsVolumeDataAction'); + export interface QueryEndedPayload { exploreId: ExploreId; response: ExplorePanelData; @@ -166,6 +202,7 @@ export interface ClearCachePayload { exploreId: ExploreId; } export const clearCacheAction = createAction('explore/clearCache'); + // // Action creators // @@ -281,7 +318,7 @@ export const runQueries = ( dispatch(clearCache(exploreId)); } - const richHistory = getState().explore.richHistory; + const { richHistory, autoLoadLogsVolume } = getState().explore; const exploreItemState = getState().explore[exploreId]!; const { datasourceInstance, @@ -296,6 +333,7 @@ export const runQueries = ( refreshInterval, absoluteRange, cache, + logsVolumeDataProvider, } = exploreItemState; let newQuerySub; @@ -304,7 +342,11 @@ export const runQueries = ( // If we have results saved in cache, we are going to use those results instead of running queries if (cachedValue) { newQuerySub = of(cachedValue) - .pipe(mergeMap((data: PanelData) => decorateData(data, queryResponse, absoluteRange, refreshInterval, queries))) + .pipe( + mergeMap((data: PanelData) => + decorateData(data, queryResponse, absoluteRange, refreshInterval, queries, !!logsVolumeDataProvider) + ) + ) .subscribe((data) => { if (!data.error) { dispatch(stateSave()); @@ -357,7 +399,16 @@ export const runQueries = ( // rendering. In case this is optimized this can be tweaked, but also it should be only as fast as user // actually can see what is happening. live ? throttleTime(500) : identity, - mergeMap((data: PanelData) => decorateData(data, queryResponse, absoluteRange, refreshInterval, queries)) + mergeMap((data: PanelData) => + decorateData( + data, + queryResponse, + absoluteRange, + refreshInterval, + queries, + !!getState().explore[exploreId]!.logsVolumeDataProvider + ) + ) ) .subscribe( (data) => { @@ -402,6 +453,26 @@ export const runQueries = ( console.error(error); } ); + + if (config.featureToggles.fullRangeLogsVolume && hasLogsVolumeSupport(datasourceInstance)) { + const logsVolumeDataProvider = datasourceInstance.getLogsVolumeDataProvider(transaction.request); + dispatch( + storeLogsVolumeDataProviderAction({ + exploreId, + logsVolumeDataProvider, + }) + ); + if (autoLoadLogsVolume && logsVolumeDataProvider) { + dispatch(loadLogsVolumeData(exploreId)); + } + } else { + dispatch( + storeLogsVolumeDataProviderAction({ + exploreId, + logsVolumeDataProvider: undefined, + }) + ); + } } dispatch(queryStoreSubscriptionAction({ exploreId, querySubscription: newQuerySub })); @@ -458,6 +529,40 @@ export function clearCache(exploreId: ExploreId): ThunkResult { }; } +/** + * Uses storeLogsVolumeDataProviderAction to update the state and load logs volume when auto-load + * is enabled and logs volume hasn't been loaded yet. + */ +export function changeAutoLogsVolume(exploreId: ExploreId, autoLoadLogsVolume: boolean): ThunkResult { + return (dispatch, getState) => { + dispatch(storeAutoLoadLogsVolumeAction(autoLoadLogsVolume)); + const state = getState().explore[exploreId]!; + + // load logs volume automatically after switching + const logsVolumeData = state.logsVolumeData; + if (!logsVolumeData?.data && autoLoadLogsVolume) { + dispatch(loadLogsVolumeData(exploreId)); + } + }; +} + +/** + * Initializes loading logs volume data and stores emitted value. + */ +export function loadLogsVolumeData(exploreId: ExploreId): ThunkResult { + return (dispatch, getState) => { + const { logsVolumeDataProvider } = getState().explore[exploreId]!; + if (logsVolumeDataProvider) { + const logsVolumeDataSubscription = logsVolumeDataProvider.subscribe({ + next: (logsVolumeData: DataQueryResponse) => { + dispatch(updateLogsVolumeDataAction({ exploreId, logsVolumeData })); + }, + }); + dispatch(storeLogsVolumeDataSubscriptionAction({ exploreId, logsVolumeDataSubscription })); + } + }; +} + // // Reducer // @@ -569,6 +674,37 @@ export const queryReducer = (state: ExploreItemState, action: AnyAction): Explor }; } + if (storeLogsVolumeDataProviderAction.match(action)) { + let { logsVolumeDataProvider } = action.payload; + if (state.logsVolumeDataSubscription) { + state.logsVolumeDataSubscription.unsubscribe(); + } + return { + ...state, + logsVolumeDataProvider, + logsVolumeDataSubscription: undefined, + // clear previous data, with a new provider the previous data becomes stale + logsVolumeData: undefined, + }; + } + + if (storeLogsVolumeDataSubscriptionAction.match(action)) { + const { logsVolumeDataSubscription } = action.payload; + return { + ...state, + logsVolumeDataSubscription, + }; + } + + if (updateLogsVolumeDataAction.match(action)) { + let { logsVolumeData } = action.payload; + + return { + ...state, + logsVolumeData, + }; + } + if (queryStreamUpdatedAction.match(action)) { return processQueryResponse(state, action); } diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index 9f959f975fe..f9b0aab0ae2 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -1,4 +1,5 @@ import { + AbsoluteTimeRange, DataSourceApi, EventBusExtended, ExploreUrlState, @@ -6,7 +7,6 @@ import { HistoryItem, LoadingState, PanelData, - AbsoluteTimeRange, } from '@grafana/data'; import { ExploreItemState } from 'app/types/explore'; @@ -50,6 +50,8 @@ export const makeExplorePaneState = (): ExploreItemState => ({ logsResult: null, eventBridge: (null as unknown) as EventBusExtended, cache: [], + logsVolumeDataProvider: undefined, + logsVolumeData: undefined, }); export const createEmptyQueryResponse = (): PanelData => ({ diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index 61fc0498cfc..c35e03149d5 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -131,7 +131,12 @@ export const decorateWithTableResult = (data: ExplorePanelData): Observable (data: ExplorePanelData): ExplorePanelData => { if (data.logsFrames.length === 0) { return { ...data, logsResult: null }; @@ -142,7 +147,10 @@ export const decorateWithLogsResult = ( const sortOrder = refreshIntervalToSortOrder(options.refreshInterval); const sortedNewResults = sortLogsResult(newResults, sortOrder); const rows = sortedNewResults.rows; - const series = sortedNewResults.series; + const series = + config.featureToggles.fullRangeLogsVolume && options.fullRangeLogsVolumeAvailable + ? undefined + : sortedNewResults.series; const logsResult = { ...sortedNewResults, rows, series }; return { ...data, logsResult }; @@ -154,13 +162,14 @@ export function decorateData( queryResponse: PanelData, absoluteRange: AbsoluteTimeRange, refreshInterval: string | undefined, - queries: DataQuery[] | undefined + queries: DataQuery[] | undefined, + fullRangeLogsVolumeAvailable: boolean ): Observable { return of(data).pipe( map((data: PanelData) => preProcessPanelData(data, queryResponse)), map(decorateWithFrameTypeMetadata), map(decorateWithGraphResult), - map(decorateWithLogsResult({ absoluteRange, refreshInterval, queries })), + map(decorateWithLogsResult({ absoluteRange, refreshInterval, queries, fullRangeLogsVolumeAvailable })), mergeMap(decorateWithTableResult) ); } diff --git a/public/app/plugins/datasource/loki/dataProviders/logsVolumeProvider.test.ts b/public/app/plugins/datasource/loki/dataProviders/logsVolumeProvider.test.ts new file mode 100644 index 00000000000..0367139bff8 --- /dev/null +++ b/public/app/plugins/datasource/loki/dataProviders/logsVolumeProvider.test.ts @@ -0,0 +1,107 @@ +import { MockObservableDataSourceApi } from '../../../../../test/mocks/datasource_srv'; +import { createLokiLogsVolumeProvider } from './logsVolumeProvider'; +import LokiDatasource from '../datasource'; +import { DataQueryRequest, DataQueryResponse, FieldType, LoadingState, toDataFrame } from '@grafana/data'; +import { LokiQuery } from '../types'; +import { Observable } from 'rxjs'; + +function createFrame(labels: object, timestamps: number[], values: number[]) { + return toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: timestamps }, + { + name: 'Number', + type: FieldType.number, + values, + labels, + }, + ], + }); +} + +function createExpectedFields(levelName: string, timestamps: number[], values: number[]) { + return [ + { name: 'Time', values: { buffer: timestamps } }, + { + name: 'Value', + config: { displayNameFromDS: levelName }, + values: { buffer: values }, + }, + ]; +} + +describe('LokiLogsVolumeProvider', () => { + let volumeProvider: Observable, + datasource: MockObservableDataSourceApi, + request: DataQueryRequest; + + function setup(datasourceSetup: () => void) { + datasourceSetup(); + request = ({ + targets: [{ expr: '{app="app01"}' }, { expr: '{app="app02"}' }], + } as unknown) as DataQueryRequest; + volumeProvider = createLokiLogsVolumeProvider((datasource as unknown) as LokiDatasource, request); + } + + function setupMultipleResults() { + // level=unknown + const resultAFrame1 = createFrame({ app: 'app01' }, [100, 200, 300], [5, 5, 5]); + // level=error + const resultAFrame2 = createFrame({ app: 'app01', level: 'error' }, [100, 200, 300], [0, 1, 0]); + // level=unknown + const resultBFrame1 = createFrame({ app: 'app02' }, [100, 200, 300], [1, 2, 3]); + // level=error + const resultBFrame2 = createFrame({ app: 'app02', level: 'error' }, [100, 200, 300], [1, 1, 1]); + + datasource = new MockObservableDataSourceApi('loki', [ + { + data: [resultAFrame1, resultAFrame2], + }, + { + data: [resultBFrame1, resultBFrame2], + }, + ]); + } + + function setupErrorResponse() { + datasource = new MockObservableDataSourceApi('loki', [], undefined, 'Error message'); + } + + it('aggregates data frames by level', async () => { + setup(setupMultipleResults); + + await expect(volumeProvider).toEmitValuesWith((received) => { + expect(received).toMatchObject([ + { state: LoadingState.Loading, error: undefined, data: [] }, + { + state: LoadingState.Done, + error: undefined, + data: [ + { + fields: createExpectedFields('unknown', [100, 200, 300], [6, 7, 8]), + }, + { + fields: createExpectedFields('error', [100, 200, 300], [1, 2, 1]), + }, + ], + }, + ]); + }); + }); + + it('returns error', async () => { + setup(setupErrorResponse); + + await expect(volumeProvider).toEmitValuesWith((received) => { + expect(received).toMatchObject([ + { state: LoadingState.Loading, error: undefined, data: [] }, + { + state: LoadingState.Error, + error: 'Error message', + data: [], + }, + 'Error message', + ]); + }); + }); +}); diff --git a/public/app/plugins/datasource/loki/dataProviders/logsVolumeProvider.ts b/public/app/plugins/datasource/loki/dataProviders/logsVolumeProvider.ts new file mode 100644 index 00000000000..49f0f6aac2d --- /dev/null +++ b/public/app/plugins/datasource/loki/dataProviders/logsVolumeProvider.ts @@ -0,0 +1,175 @@ +import { + DataFrame, + DataQueryRequest, + DataQueryResponse, + FieldCache, + FieldColorModeId, + FieldConfig, + FieldType, + getLogLevelFromKey, + Labels, + LoadingState, + LogLevel, + MutableDataFrame, + toDataFrame, +} from '@grafana/data'; +import { LokiQuery } from '../types'; +import { Observable } from 'rxjs'; +import { cloneDeep } from 'lodash'; +import LokiDatasource, { isMetricsQuery } from '../datasource'; +import { LogLevelColor } from '../../../../core/logs_model'; +import { BarAlignment, GraphDrawStyle, StackingMode } from '@grafana/schema'; + +export function createLokiLogsVolumeProvider( + datasource: LokiDatasource, + dataQueryRequest: DataQueryRequest +): Observable { + const logsVolumeRequest = cloneDeep(dataQueryRequest); + logsVolumeRequest.targets = logsVolumeRequest.targets + .filter((target) => target.expr && !isMetricsQuery(target.expr)) + .map((target) => { + return { + ...target, + expr: `sum by (level) (count_over_time(${target.expr}[$__interval]))`, + }; + }); + + return new Observable((observer) => { + let rawLogsVolume: DataFrame[] = []; + observer.next({ + state: LoadingState.Loading, + error: undefined, + data: [], + }); + + const subscription = datasource.query(logsVolumeRequest).subscribe({ + complete: () => { + const aggregatedLogsVolume = aggregateRawLogsVolume(rawLogsVolume); + observer.next({ + state: LoadingState.Done, + error: undefined, + data: aggregatedLogsVolume, + }); + observer.complete(); + }, + next: (dataQueryResponse: DataQueryResponse) => { + rawLogsVolume = rawLogsVolume.concat(dataQueryResponse.data.map(toDataFrame)); + }, + error: (error) => { + observer.next({ + state: LoadingState.Error, + error: error, + data: [], + }); + observer.error(error); + }, + }); + return () => { + subscription?.unsubscribe(); + }; + }); +} + +/** + * Add up values for the same level and create a single data frame for each level + */ +function aggregateRawLogsVolume(rawLogsVolume: DataFrame[]): DataFrame[] { + const logsVolumeByLevelMap: { [level in LogLevel]?: DataFrame[] } = {}; + let levels = 0; + rawLogsVolume.forEach((dataFrame) => { + let valueField; + try { + valueField = new FieldCache(dataFrame).getFirstFieldOfType(FieldType.number); + } catch {} + // If value field doesn't exist skip the frame (it may happen with instant queries) + if (!valueField) { + return; + } + const level: LogLevel = valueField.labels ? getLogLevelFromLabels(valueField.labels) : LogLevel.unknown; + if (!logsVolumeByLevelMap[level]) { + logsVolumeByLevelMap[level] = []; + levels++; + } + logsVolumeByLevelMap[level]!.push(dataFrame); + }); + + return Object.keys(logsVolumeByLevelMap).map((level: string) => { + return aggregateFields(logsVolumeByLevelMap[level as LogLevel]!, getFieldConfig(level as LogLevel, levels)); + }); +} + +function getFieldConfig(level: LogLevel, levels: number) { + const name = levels === 1 && level === LogLevel.unknown ? 'logs' : level; + const color = LogLevelColor[level]; + return { + displayNameFromDS: name, + color: { + mode: FieldColorModeId.Fixed, + fixedColor: color, + }, + custom: { + drawStyle: GraphDrawStyle.Bars, + barAlignment: BarAlignment.Center, + barWidthFactor: 0.9, + barMaxWidth: 5, + lineColor: color, + pointColor: color, + fillColor: color, + lineWidth: 1, + fillOpacity: 100, + stacking: { + mode: StackingMode.Normal, + group: 'A', + }, + }, + }; +} + +/** + * Create a new data frame with a single field and values creating by adding field values + * from all provided data frames + */ +function aggregateFields(dataFrames: DataFrame[], config: FieldConfig): DataFrame { + const aggregatedDataFrame = new MutableDataFrame(); + if (!dataFrames.length) { + return aggregatedDataFrame; + } + + const totalLength = dataFrames[0].length; + const timeField = new FieldCache(dataFrames[0]).getFirstFieldOfType(FieldType.time); + + if (!timeField) { + return aggregatedDataFrame; + } + + aggregatedDataFrame.addField({ name: 'Time', type: FieldType.time }, totalLength); + aggregatedDataFrame.addField({ name: 'Value', type: FieldType.number, config }, totalLength); + + dataFrames.forEach((dataFrame) => { + dataFrame.fields.forEach((field) => { + if (field.type === FieldType.number) { + for (let pointIndex = 0; pointIndex < totalLength; pointIndex++) { + const currentValue = aggregatedDataFrame.get(pointIndex).Value; + const valueToAdd = field.values.get(pointIndex); + const totalValue = + currentValue === null && valueToAdd === null ? null : (currentValue || 0) + (valueToAdd || 0); + aggregatedDataFrame.set(pointIndex, { Value: totalValue, Time: timeField.values.get(pointIndex) }); + } + } + }); + }); + + return aggregatedDataFrame; +} + +function getLogLevelFromLabels(labels: Labels): LogLevel { + const labelNames = ['level', 'lvl', 'loglevel']; + let levelLabel; + for (let labelName of labelNames) { + if (labelName in labels) { + levelLabel = labelName; + break; + } + } + return levelLabel ? getLogLevelFromKey(labels[levelLabel]) : LogLevel.unknown; +} diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts index 31d870cb794..36d117892ee 100644 --- a/public/app/plugins/datasource/loki/datasource.test.ts +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -920,6 +920,38 @@ describe('LokiDatasource', () => { expect(contextQuery.expr).not.toContain('uniqueParsedLabel'); }); }); + + describe('logs volume data provider', () => { + it('creates provider for logs query', () => { + const ds = createLokiDSForTests(); + const options = getQueryOptions({ + targets: [{ expr: '{label=value}', refId: 'A' }], + }); + + expect(ds.getLogsVolumeDataProvider(options)).toBeDefined(); + }); + + it('does not create provider for metrics query', () => { + const ds = createLokiDSForTests(); + const options = getQueryOptions({ + targets: [{ expr: 'rate({label=value}[1m])', refId: 'A' }], + }); + + expect(ds.getLogsVolumeDataProvider(options)).not.toBeDefined(); + }); + + it('creates provider if at least one query is a logs query', () => { + const ds = createLokiDSForTests(); + const options = getQueryOptions({ + targets: [ + { expr: 'rate({label=value}[1m])', refId: 'A' }, + { expr: '{label=value}', refId: 'B' }, + ], + }); + + expect(ds.getLogsVolumeDataProvider(options)).toBeDefined(); + }); + }); }); function assertAdHocFilters(query: string, expectedResults: string, ds: LokiDatasource) { diff --git a/public/app/plugins/datasource/loki/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts index 063c51323ba..48511afd580 100644 --- a/public/app/plugins/datasource/loki/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -16,6 +16,7 @@ import { DataQueryResponse, DataSourceApi, DataSourceInstanceSettings, + DataSourceWithLogsVolumeSupport, dateMath, DateTime, FieldCache, @@ -52,6 +53,7 @@ import { serializeParams } from '../../../core/utils/fetch'; import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider'; import syntax from './syntax'; import { DEFAULT_RESOLUTION } from './components/LokiOptionFields'; +import { createLokiLogsVolumeProvider } from './dataProviders/logsVolumeProvider'; export type RangeQueryOptions = DataQueryRequest | AnnotationQueryRequest; export const DEFAULT_MAX_LINES = 1000; @@ -67,7 +69,9 @@ const DEFAULT_QUERY_PARAMS: Partial = { query: '', }; -export class LokiDatasource extends DataSourceApi { +export class LokiDatasource + extends DataSourceApi + implements DataSourceWithLogsVolumeSupport { private streams = new LiveStreams(); languageProvider: LanguageProvider; maxLines: number; @@ -102,6 +106,11 @@ export class LokiDatasource extends DataSourceApi { return getBackendSrv().fetch>(req); } + getLogsVolumeDataProvider(request: DataQueryRequest): Observable | undefined { + const isLogsVolumeAvailable = request.targets.some((target) => target.expr && !isMetricsQuery(target.expr)); + return isLogsVolumeAvailable ? createLokiLogsVolumeProvider(this, request) : undefined; + } + query(options: DataQueryRequest): Observable { const subQueries: Array> = []; const scopedVars = { @@ -703,7 +712,7 @@ export function lokiSpecialRegexEscape(value: any) { * Checks if the query expression uses function and so should return a time series instead of logs. * Sometimes important to know that before we actually do the query. */ -function isMetricsQuery(query: string): boolean { +export function isMetricsQuery(query: string): boolean { const tokens = Prism.tokenize(query, syntax); return tokens.some((t) => { // Not sure in which cases it can be string maybe if nothing matched which means it should not be a function diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 853245966b3..14e462f648b 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -1,4 +1,4 @@ -import { Unsubscribable } from 'rxjs'; +import { Observable, SubscriptionLike, Unsubscribable } from 'rxjs'; import { AbsoluteTimeRange, DataFrame, @@ -12,6 +12,7 @@ import { RawTimeRange, TimeRange, EventBusExtended, + DataQueryResponse, } from '@grafana/data'; export enum ExploreId { @@ -44,6 +45,11 @@ export interface ExploreState { * History of all queries */ richHistory: RichHistoryQuery[]; + + /** + * Auto-loading logs volume after running the query + */ + autoLoadLogsVolume: boolean; } export interface ExploreItemState { @@ -149,6 +155,12 @@ export interface ExploreItemState { * We are currently caching last 5 query responses. */ cache: Array<{ key: string; value: PanelData }>; + + // properties below should be more generic if we add more providers + // see also: DataSourceWithLogsVolumeSupport + logsVolumeDataProvider?: Observable; + logsVolumeDataSubscription?: SubscriptionLike; + logsVolumeData?: DataQueryResponse; } export interface ExploreUpdateState { diff --git a/public/test/mocks/datasource_srv.ts b/public/test/mocks/datasource_srv.ts index 6d5cf459cdb..33cd24e2186 100644 --- a/public/test/mocks/datasource_srv.ts +++ b/public/test/mocks/datasource_srv.ts @@ -5,6 +5,7 @@ import { DataSourceInstanceSettings, DataSourcePluginMeta, } from '@grafana/data'; +import { Observable } from 'rxjs'; export class DatasourceSrvMock { constructor(private defaultDS: DataSourceApi, private datasources: { [name: string]: DataSourceApi }) { @@ -51,3 +52,34 @@ export class MockDataSourceApi extends DataSourceApi { return Promise.resolve(); } } + +export class MockObservableDataSourceApi extends DataSourceApi { + results: DataQueryResponse[] = [{ data: [] }]; + + constructor(name?: string, results?: DataQueryResponse[], meta?: any, private error: string | null = null) { + super({ name: name ? name : 'MockDataSourceApi' } as DataSourceInstanceSettings); + + if (results) { + this.results = results; + } + + this.meta = meta || ({} as DataSourcePluginMeta); + } + + query(request: DataQueryRequest): Observable { + return new Observable((observer) => { + if (this.error) { + observer.error(this.error); + } + + if (this.results) { + this.results.forEach((response) => observer.next(response)); + observer.complete(); + } + }); + } + + testDatasource() { + return Promise.resolve(); + } +}