mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Loki: Full range logs volume (#39327)
* Basic implementation of getLogsVolumeQuery method * Add todos * Add a switcher to automatically load logs volume * De-scope dismissing logs volume panel * De-scope logs volume query cancellation * Remove todo * Aggregate logs volume components in single panel * Show logs volume only when it's available * Aggregate logs volume by level * Simplify aggregation * Handle no logs volume data * Add error handling * Do not show auto-load logs volume switcher when loading logs volume is not available * Remove old logs volume graph * Clean up * Make getting data provider more generic * Provide complete logs volume data (error, isLoading) * Display more specific error message * Add missing props to mocks * Remove setRequest method * Mark getQueryRelatedDataProviders as internal * Add missing dataQueryRequest and add a todo * Remove redundant loading state * Do not mutate existing queries * Apply fix for zooming-in from main * Post-merge fixes * Create collection for data provider results * Use more generic names * Move aggregation logic to Loki logs volume provider * Move LogsVolume to common types * Update tests * Post-merge fixes * Fix mapping related data values * Simplify prop mappings * Add docs * Fix property name * Clean-up * Mark new types as internal * Reduce number of providers to logs volume only * Simplify data structure to DataQueryResponse * Move Logs Volume panel to a separate component * Test logsVolumeProvider.ts * Add observable version of datasource mock * Test getLogsVolumeDataProvider method * Test LogsVolumePanel * Test logs volume reducer * Clean up * Clean up * Fix test * Use sum by to use level field directly * Fix strict type errors * Fix strict type errors * Use "logs" instead of "unknown" if only one level was detected * Add docs about logs volume * Rename histogramRequest to logsVolumeRequest * Use LogsVolumeContentWrapper all content types * Move `autoLoadLogsVolume` local storage handling * Fix strict error * Move getting autoLoadLogsVolume to initial state * Cancel current logs volume subscription * Test cancelling subscriptions * Update docs/sources/datasources/loki.md Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> * Update packages/grafana-data/src/types/explore.ts Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> * Inline container styles * Ensure logs volume is aggregated per each subscription separately * Simplify logs volume provider * Type-guard support for logs volume provider * Simplify event handlers to avoid casting * Clean up and docs * Move auto-load switcher to logs volume panel * Fix test * Move DataSourceWithLogsVolumeSupport to avoid cross referencing * Simplify interface * Bring back old histogram and hide the new one behind a feature flag * Add missing props to logs histogram panel * Clean up the provider when it's not supported * Simplify storing autoLoadLogsVolume * Remove docs * Update packages/grafana-data/src/types/logsVolume.ts Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com> * Skip dataframes without fields (instant queries) * Revert styles changes * Revert styles changes * Add release tag Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>
This commit is contained in:
parent
b7a68a9516
commit
124e9daf26
@ -7,6 +7,7 @@ export * from './dashboard';
|
|||||||
export * from './query';
|
export * from './query';
|
||||||
export * from './annotations';
|
export * from './annotations';
|
||||||
export * from './logs';
|
export * from './logs';
|
||||||
|
export * from './logsVolume';
|
||||||
export * from './navModel';
|
export * from './navModel';
|
||||||
export * from './select';
|
export * from './select';
|
||||||
export * from './time';
|
export * from './time';
|
||||||
|
22
packages/grafana-data/src/types/logsVolume.ts
Normal file
22
packages/grafana-data/src/types/logsVolume.ts
Normal file
@ -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<TQuery extends DataQuery> {
|
||||||
|
getLogsVolumeDataProvider(request: DataQueryRequest<TQuery>): Observable<DataQueryResponse> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export const hasLogsVolumeSupport = <TQuery extends DataQuery>(
|
||||||
|
datasource: any
|
||||||
|
): datasource is DataSourceWithLogsVolumeSupport<TQuery> => {
|
||||||
|
return (datasource as DataSourceWithLogsVolumeSupport<TQuery>).getLogsVolumeDataProvider !== undefined;
|
||||||
|
};
|
@ -67,6 +67,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
|||||||
recordedQueries: false,
|
recordedQueries: false,
|
||||||
prometheusMonaco: false,
|
prometheusMonaco: false,
|
||||||
newNavigation: false,
|
newNavigation: false,
|
||||||
|
fullRangeLogsVolume: false,
|
||||||
};
|
};
|
||||||
licenseInfo: LicenseInfo = {} as LicenseInfo;
|
licenseInfo: LicenseInfo = {} as LicenseInfo;
|
||||||
rendererAvailable = false;
|
rendererAvailable = false;
|
||||||
|
@ -83,6 +83,11 @@ const dummyProps: Props = {
|
|||||||
showTrace: true,
|
showTrace: true,
|
||||||
showNodeGraph: true,
|
showNodeGraph: true,
|
||||||
splitOpen: (() => {}) as any,
|
splitOpen: (() => {}) as any,
|
||||||
|
autoLoadLogsVolume: false,
|
||||||
|
logsVolumeData: undefined,
|
||||||
|
logsVolumeDataProvider: undefined,
|
||||||
|
loadLogsVolumeData: () => {},
|
||||||
|
changeAutoLogsVolume: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Explore', () => {
|
describe('Explore', () => {
|
||||||
|
@ -5,8 +5,8 @@ import { connect, ConnectedProps } from 'react-redux';
|
|||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||||
import memoizeOne from 'memoize-one';
|
import memoizeOne from 'memoize-one';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { ErrorBoundaryAlert, CustomScrollbar, Collapse, withTheme2, Themeable2 } from '@grafana/ui';
|
import { Collapse, CustomScrollbar, ErrorBoundaryAlert, Themeable2, withTheme2 } from '@grafana/ui';
|
||||||
import { AbsoluteTimeRange, DataQuery, LoadingState, RawTimeRange, DataFrame, GrafanaTheme2 } from '@grafana/data';
|
import { AbsoluteTimeRange, DataFrame, DataQuery, GrafanaTheme2, LoadingState, RawTimeRange } from '@grafana/data';
|
||||||
|
|
||||||
import LogsContainer from './LogsContainer';
|
import LogsContainer from './LogsContainer';
|
||||||
import { QueryRows } from './QueryRows';
|
import { QueryRows } from './QueryRows';
|
||||||
@ -16,7 +16,15 @@ import ExploreQueryInspector from './ExploreQueryInspector';
|
|||||||
import { splitOpen } from './state/main';
|
import { splitOpen } from './state/main';
|
||||||
import { changeSize } from './state/explorePane';
|
import { changeSize } from './state/explorePane';
|
||||||
import { updateTimeRange } from './state/time';
|
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 { ExploreId, ExploreItemState } from 'app/types/explore';
|
||||||
import { StoreState } from 'app/types';
|
import { StoreState } from 'app/types';
|
||||||
import { ExploreToolbar } from './ExploreToolbar';
|
import { ExploreToolbar } from './ExploreToolbar';
|
||||||
@ -28,6 +36,7 @@ import { NodeGraphContainer } from './NodeGraphContainer';
|
|||||||
import { ResponseErrorContainer } from './ResponseErrorContainer';
|
import { ResponseErrorContainer } from './ResponseErrorContainer';
|
||||||
import { TraceViewContainer } from './TraceView/TraceViewContainer';
|
import { TraceViewContainer } from './TraceView/TraceViewContainer';
|
||||||
import { ExploreGraph } from './ExploreGraph';
|
import { ExploreGraph } from './ExploreGraph';
|
||||||
|
import { LogsVolumePanel } from './LogsVolumePanel';
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
@ -205,6 +214,36 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderLogsVolume(width: number) {
|
||||||
|
const {
|
||||||
|
logsVolumeData,
|
||||||
|
exploreId,
|
||||||
|
loadLogsVolumeData,
|
||||||
|
autoLoadLogsVolume,
|
||||||
|
changeAutoLogsVolume,
|
||||||
|
absoluteRange,
|
||||||
|
timeZone,
|
||||||
|
splitOpen,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogsVolumePanel
|
||||||
|
exploreId={exploreId}
|
||||||
|
loadLogsVolumeData={loadLogsVolumeData}
|
||||||
|
absoluteRange={absoluteRange}
|
||||||
|
width={width}
|
||||||
|
logsVolumeData={logsVolumeData}
|
||||||
|
onUpdateTimeRange={this.onUpdateTimeRange}
|
||||||
|
timeZone={timeZone}
|
||||||
|
splitOpen={splitOpen}
|
||||||
|
autoLoadLogsVolume={autoLoadLogsVolume}
|
||||||
|
onChangeAutoLogsVolume={(autoLoadLogsVolume) => {
|
||||||
|
changeAutoLogsVolume(exploreId, autoLoadLogsVolume);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
renderTablePanel(width: number) {
|
renderTablePanel(width: number) {
|
||||||
const { exploreId, datasourceInstance } = this.props;
|
const { exploreId, datasourceInstance } = this.props;
|
||||||
return (
|
return (
|
||||||
@ -277,12 +316,14 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
showLogs,
|
showLogs,
|
||||||
showTrace,
|
showTrace,
|
||||||
showNodeGraph,
|
showNodeGraph,
|
||||||
|
logsVolumeDataProvider,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { openDrawer } = this.state;
|
const { openDrawer } = this.state;
|
||||||
const styles = getStyles(theme);
|
const styles = getStyles(theme);
|
||||||
const showPanels = queryResponse && queryResponse.state !== LoadingState.NotStarted;
|
const showPanels = queryResponse && queryResponse.state !== LoadingState.NotStarted;
|
||||||
const showRichHistory = openDrawer === ExploreDrawer.RichHistory;
|
const showRichHistory = openDrawer === ExploreDrawer.RichHistory;
|
||||||
const showQueryInspector = openDrawer === ExploreDrawer.QueryInspector;
|
const showQueryInspector = openDrawer === ExploreDrawer.QueryInspector;
|
||||||
|
const showLogsVolume = !!logsVolumeDataProvider;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomScrollbar autoHeightMin={'100%'}>
|
<CustomScrollbar autoHeightMin={'100%'}>
|
||||||
@ -319,6 +360,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
{showMetrics && graphResult && (
|
{showMetrics && graphResult && (
|
||||||
<ErrorBoundaryAlert>{this.renderGraphPanel(width)}</ErrorBoundaryAlert>
|
<ErrorBoundaryAlert>{this.renderGraphPanel(width)}</ErrorBoundaryAlert>
|
||||||
)}
|
)}
|
||||||
|
{showLogsVolume && <ErrorBoundaryAlert>{this.renderLogsVolume(width)}</ErrorBoundaryAlert>}
|
||||||
{showTable && <ErrorBoundaryAlert>{this.renderTablePanel(width)}</ErrorBoundaryAlert>}
|
{showTable && <ErrorBoundaryAlert>{this.renderTablePanel(width)}</ErrorBoundaryAlert>}
|
||||||
{showLogs && <ErrorBoundaryAlert>{this.renderLogsPanel(width)}</ErrorBoundaryAlert>}
|
{showLogs && <ErrorBoundaryAlert>{this.renderLogsPanel(width)}</ErrorBoundaryAlert>}
|
||||||
{showNodeGraph && <ErrorBoundaryAlert>{this.renderNodeGraphPanel()}</ErrorBoundaryAlert>}
|
{showNodeGraph && <ErrorBoundaryAlert>{this.renderNodeGraphPanel()}</ErrorBoundaryAlert>}
|
||||||
@ -353,7 +395,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
|
|||||||
|
|
||||||
function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||||
const explore = state.explore;
|
const explore = state.explore;
|
||||||
const { syncedTimes } = explore;
|
const { syncedTimes, autoLoadLogsVolume } = explore;
|
||||||
const item: ExploreItemState = explore[exploreId]!;
|
const item: ExploreItemState = explore[exploreId]!;
|
||||||
const timeZone = getTimeZone(state.user);
|
const timeZone = getTimeZone(state.user);
|
||||||
const {
|
const {
|
||||||
@ -362,6 +404,8 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
|||||||
queryKeys,
|
queryKeys,
|
||||||
isLive,
|
isLive,
|
||||||
graphResult,
|
graphResult,
|
||||||
|
logsVolumeDataProvider,
|
||||||
|
logsVolumeData,
|
||||||
logsResult,
|
logsResult,
|
||||||
showLogs,
|
showLogs,
|
||||||
showMetrics,
|
showMetrics,
|
||||||
@ -379,6 +423,9 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
|||||||
queryKeys,
|
queryKeys,
|
||||||
isLive,
|
isLive,
|
||||||
graphResult,
|
graphResult,
|
||||||
|
autoLoadLogsVolume,
|
||||||
|
logsVolumeDataProvider,
|
||||||
|
logsVolumeData,
|
||||||
logsResult: logsResult ?? undefined,
|
logsResult: logsResult ?? undefined,
|
||||||
absoluteRange,
|
absoluteRange,
|
||||||
queryResponse,
|
queryResponse,
|
||||||
@ -400,6 +447,8 @@ const mapDispatchToProps = {
|
|||||||
scanStopAction,
|
scanStopAction,
|
||||||
setQueries,
|
setQueries,
|
||||||
updateTimeRange,
|
updateTimeRange,
|
||||||
|
loadLogsVolumeData,
|
||||||
|
changeAutoLogsVolume,
|
||||||
addQueryRow,
|
addQueryRow,
|
||||||
splitOpen,
|
splitOpen,
|
||||||
};
|
};
|
||||||
|
@ -213,7 +213,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => {
|
const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => {
|
||||||
const syncedTimes = state.explore.syncedTimes;
|
const { syncedTimes, autoLoadLogsVolume } = state.explore;
|
||||||
const exploreItem: ExploreItemState = state.explore[exploreId]!;
|
const exploreItem: ExploreItemState = state.explore[exploreId]!;
|
||||||
const {
|
const {
|
||||||
datasourceInstance,
|
datasourceInstance,
|
||||||
@ -242,6 +242,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => {
|
|||||||
isPaused,
|
isPaused,
|
||||||
syncedTimes,
|
syncedTimes,
|
||||||
containerWidth,
|
containerWidth,
|
||||||
|
autoLoadLogsVolume,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -136,8 +136,8 @@ export class UnthemedLogs extends PureComponent<Props, State> {
|
|||||||
this.setState({ dedupStrategy });
|
this.setState({ dedupStrategy });
|
||||||
};
|
};
|
||||||
|
|
||||||
onChangeLabels = (event?: React.SyntheticEvent) => {
|
onChangeLabels = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const target = event && (event.target as HTMLInputElement);
|
const { target } = event;
|
||||||
if (target) {
|
if (target) {
|
||||||
const showLabels = target.checked;
|
const showLabels = target.checked;
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -147,8 +147,8 @@ export class UnthemedLogs extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onChangeTime = (event?: React.SyntheticEvent) => {
|
onChangeTime = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const target = event && (event.target as HTMLInputElement);
|
const { target } = event;
|
||||||
if (target) {
|
if (target) {
|
||||||
const showTime = target.checked;
|
const showTime = target.checked;
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -158,8 +158,8 @@ export class UnthemedLogs extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onChangewrapLogMessage = (event?: React.SyntheticEvent) => {
|
onChangewrapLogMessage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const target = event && (event.target as HTMLInputElement);
|
const { target } = event;
|
||||||
if (target) {
|
if (target) {
|
||||||
const wrapLogMessage = target.checked;
|
const wrapLogMessage = target.checked;
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -169,8 +169,8 @@ export class UnthemedLogs extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onChangePrettifyLogMessage = (event?: React.SyntheticEvent) => {
|
onChangePrettifyLogMessage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const target = event && (event.target as HTMLInputElement);
|
const { target } = event;
|
||||||
if (target) {
|
if (target) {
|
||||||
const prettifyLogMessage = target.checked;
|
const prettifyLogMessage = target.checked;
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -294,21 +294,24 @@ export class UnthemedLogs extends PureComponent<Props, State> {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.infoText}>
|
|
||||||
This datasource does not support full-range histograms. The graph is based on the logs seen in the response.
|
|
||||||
</div>
|
|
||||||
{logsSeries && logsSeries.length ? (
|
{logsSeries && logsSeries.length ? (
|
||||||
<ExploreGraph
|
<>
|
||||||
data={logsSeries}
|
<div className={styles.infoText}>
|
||||||
height={150}
|
This datasource does not support full-range histograms. The graph is based on the logs seen in the
|
||||||
width={width}
|
response.
|
||||||
tooltipDisplayMode={TooltipDisplayMode.Multi}
|
</div>
|
||||||
absoluteRange={visibleRange || absoluteRange}
|
<ExploreGraph
|
||||||
timeZone={timeZone}
|
data={logsSeries}
|
||||||
loadingState={loadingState}
|
height={150}
|
||||||
onChangeTime={onChangeTime}
|
width={width}
|
||||||
onHiddenSeriesChanged={this.onToggleLogLevel}
|
tooltipDisplayMode={TooltipDisplayMode.Multi}
|
||||||
/>
|
absoluteRange={visibleRange || absoluteRange}
|
||||||
|
timeZone={timeZone}
|
||||||
|
loadingState={loadingState}
|
||||||
|
onChangeTime={onChangeTime}
|
||||||
|
onHiddenSeriesChanged={this.onToggleLogLevel}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<div className={styles.logOptions} ref={this.topLogsRef}>
|
<div className={styles.logOptions} ref={this.topLogsRef}>
|
||||||
<InlineFieldRow>
|
<InlineFieldRow>
|
||||||
|
56
public/app/features/explore/LogsVolumePanel.test.tsx
Normal file
56
public/app/features/explore/LogsVolumePanel.test.tsx
Normal file
@ -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 = () => <span>ExploreGraph</span>;
|
||||||
|
return {
|
||||||
|
ExploreGraph,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPanel(logsVolumeData?: DataQueryResponse) {
|
||||||
|
render(
|
||||||
|
<LogsVolumePanel
|
||||||
|
exploreId={ExploreId.left}
|
||||||
|
loadLogsVolumeData={() => {}}
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
114
public/app/features/explore/LogsVolumePanel.tsx
Normal file
114
public/app/features/explore/LogsVolumePanel.tsx
Normal file
@ -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 = (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
loadLogsVolumeData(exploreId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Load logs volume
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
} else if (logsVolumeData?.error) {
|
||||||
|
LogsVolumePanelContent = (
|
||||||
|
<span>
|
||||||
|
Failed to load volume logs for this query:{' '}
|
||||||
|
{logsVolumeData.error.data?.message || logsVolumeData.error.statusText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (logsVolumeData?.state === LoadingState.Loading) {
|
||||||
|
LogsVolumePanelContent = <span>Logs volume is loading...</span>;
|
||||||
|
} else if (logsVolumeData?.data) {
|
||||||
|
if (logsVolumeData.data.length > 0) {
|
||||||
|
LogsVolumePanelContent = (
|
||||||
|
<ExploreGraph
|
||||||
|
loadingState={LoadingState.Done}
|
||||||
|
data={logsVolumeData.data}
|
||||||
|
height={height}
|
||||||
|
width={width - spacing}
|
||||||
|
absoluteRange={absoluteRange}
|
||||||
|
onChangeTime={onUpdateTimeRange}
|
||||||
|
timeZone={timeZone}
|
||||||
|
splitOpenFn={splitOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
LogsVolumePanelContent = <span>No volume data.</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnChangeAutoLogsVolume = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { target } = event;
|
||||||
|
if (target) {
|
||||||
|
onChangeAutoLogsVolume(target.checked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChangeAutoLogsVolume]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapse label="Logs volume" isOpen={true} loading={logsVolumeData?.state === LoadingState.Loading}>
|
||||||
|
<div
|
||||||
|
style={{ height }}
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{LogsVolumePanelContent}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'end',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<InlineFieldRow>
|
||||||
|
<InlineField label="Auto-load logs volume" transparent>
|
||||||
|
<InlineSwitch value={autoLoadLogsVolume} onChange={handleOnChangeAutoLogsVolume} transparent />
|
||||||
|
</InlineField>
|
||||||
|
</InlineFieldRow>
|
||||||
|
</div>
|
||||||
|
</Collapse>
|
||||||
|
);
|
||||||
|
}
|
@ -48,6 +48,7 @@ function setup(queries: DataQuery[]) {
|
|||||||
syncedTimes: false,
|
syncedTimes: false,
|
||||||
right: undefined,
|
right: undefined,
|
||||||
richHistory: [],
|
richHistory: [],
|
||||||
|
autoLoadLogsVolume: false,
|
||||||
};
|
};
|
||||||
const store = configureStore({ explore: initialState, user: { orgId: 1 } as UserState });
|
const store = configureStore({ explore: initialState, user: { orgId: 1 } as UserState });
|
||||||
|
|
||||||
|
@ -2,7 +2,13 @@ import React, { PureComponent } from 'react';
|
|||||||
import { connect, ConnectedProps } from 'react-redux';
|
import { connect, ConnectedProps } from 'react-redux';
|
||||||
import { ExploreId, ExploreQueryParams } from 'app/types/explore';
|
import { ExploreId, ExploreQueryParams } from 'app/types/explore';
|
||||||
import { ErrorBoundaryAlert } from '@grafana/ui';
|
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 { getRichHistory } from '../../core/utils/richHistory';
|
||||||
import { ExplorePaneContainer } from './ExplorePaneContainer';
|
import { ExplorePaneContainer } from './ExplorePaneContainer';
|
||||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
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 { getNavModel } from '../../core/selectors/navModel';
|
||||||
import { StoreState } from 'app/types';
|
import { StoreState } from 'app/types';
|
||||||
|
import store from '../../core/store';
|
||||||
|
|
||||||
interface RouteProps extends GrafanaRouteComponentProps<{}, ExploreQueryParams> {}
|
interface RouteProps extends GrafanaRouteComponentProps<{}, ExploreQueryParams> {}
|
||||||
interface OwnProps {}
|
interface OwnProps {}
|
||||||
@ -18,12 +25,14 @@ const mapStateToProps = (state: StoreState) => {
|
|||||||
return {
|
return {
|
||||||
navModel: getNavModel(state.navIndex, 'explore'),
|
navModel: getNavModel(state.navIndex, 'explore'),
|
||||||
exploreState: state.explore,
|
exploreState: state.explore,
|
||||||
|
autoLoadLogsVolume: state.explore.autoLoadLogsVolume,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
resetExploreAction,
|
resetExploreAction,
|
||||||
richHistoryUpdatedAction,
|
richHistoryUpdatedAction,
|
||||||
|
storeAutoLoadLogsVolumeAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||||
@ -42,7 +51,8 @@ class WrapperUnconnected extends PureComponent<Props> {
|
|||||||
this.props.richHistoryUpdatedAction({ richHistory });
|
this.props.richHistoryUpdatedAction({ richHistory });
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate() {
|
componentDidUpdate(prevProps: Props) {
|
||||||
|
const { autoLoadLogsVolume } = this.props;
|
||||||
const { left, right } = this.props.queryParams;
|
const { left, right } = this.props.queryParams;
|
||||||
const hasSplit = Boolean(left) && Boolean(right);
|
const hasSplit = Boolean(left) && Boolean(right);
|
||||||
const datasourceTitle = hasSplit
|
const datasourceTitle = hasSplit
|
||||||
@ -50,6 +60,10 @@ class WrapperUnconnected extends PureComponent<Props> {
|
|||||||
: `${this.props.exploreState.left.datasourceInstance?.name}`;
|
: `${this.props.exploreState.left.datasourceInstance?.name}`;
|
||||||
const documentTitle = `${this.props.navModel.main.text} - ${datasourceTitle} - ${Branding.AppTitle}`;
|
const documentTitle = `${this.props.navModel.main.text} - ${datasourceTitle} - ${Branding.AppTitle}`;
|
||||||
document.title = documentTitle;
|
document.title = documentTitle;
|
||||||
|
|
||||||
|
if (prevProps.autoLoadLogsVolume !== autoLoadLogsVolume) {
|
||||||
|
store.set(AUTO_LOAD_LOGS_VOLUME_SETTING_KEY, autoLoadLogsVolume);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
@ -9,6 +9,7 @@ import { getUrlStateFromPaneState, makeExplorePaneState } from './utils';
|
|||||||
import { ThunkResult } from '../../../types';
|
import { ThunkResult } from '../../../types';
|
||||||
import { TimeSrv } from '../../dashboard/services/TimeSrv';
|
import { TimeSrv } from '../../dashboard/services/TimeSrv';
|
||||||
import { PanelModel } from 'app/features/dashboard/state';
|
import { PanelModel } from 'app/features/dashboard/state';
|
||||||
|
import store from '../../../core/store';
|
||||||
|
|
||||||
//
|
//
|
||||||
// Actions and Payloads
|
// Actions and Payloads
|
||||||
@ -21,6 +22,12 @@ export const syncTimesAction = createAction<SyncTimesPayload>('explore/syncTimes
|
|||||||
|
|
||||||
export const richHistoryUpdatedAction = createAction<any>('explore/richHistoryUpdated');
|
export const richHistoryUpdatedAction = createAction<any>('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<boolean>('explore/storeAutoLoadLogsVolumeAction');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets state for explore.
|
* 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
|
* Global Explore state that handles multiple Explore areas and the split state
|
||||||
*/
|
*/
|
||||||
@ -163,6 +172,7 @@ export const initialExploreState: ExploreState = {
|
|||||||
left: initialExploreItemState,
|
left: initialExploreItemState,
|
||||||
right: undefined,
|
right: undefined,
|
||||||
richHistory: [],
|
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)) {
|
if (resetExploreAction.match(action)) {
|
||||||
const payload: ResetExplorePayload = action.payload;
|
const payload: ResetExplorePayload = action.payload;
|
||||||
const leftState = state[ExploreId.left];
|
const leftState = state[ExploreId.left];
|
||||||
|
@ -3,15 +3,17 @@ import {
|
|||||||
addResultsToCache,
|
addResultsToCache,
|
||||||
cancelQueries,
|
cancelQueries,
|
||||||
cancelQueriesAction,
|
cancelQueriesAction,
|
||||||
|
changeAutoLogsVolume,
|
||||||
clearCache,
|
clearCache,
|
||||||
importQueries,
|
importQueries,
|
||||||
|
loadLogsVolumeData,
|
||||||
queryReducer,
|
queryReducer,
|
||||||
runQueries,
|
runQueries,
|
||||||
scanStartAction,
|
scanStartAction,
|
||||||
scanStopAction,
|
scanStopAction,
|
||||||
} from './query';
|
} from './query';
|
||||||
import { ExploreId, ExploreItemState, StoreState, ThunkDispatch } from 'app/types';
|
import { ExploreId, ExploreItemState, StoreState, ThunkDispatch } from 'app/types';
|
||||||
import { interval, of } from 'rxjs';
|
import { interval, Observable, of } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
ArrayVector,
|
ArrayVector,
|
||||||
DataFrame,
|
DataFrame,
|
||||||
@ -33,6 +35,16 @@ import { configureStore } from '../../../store/configureStore';
|
|||||||
import { setTimeSrv } from '../../dashboard/services/TimeSrv';
|
import { setTimeSrv } from '../../dashboard/services/TimeSrv';
|
||||||
import Mock = jest.Mock;
|
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 t = toUtc();
|
||||||
const testRange = {
|
const testRange = {
|
||||||
from: t,
|
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', () => {
|
describe('runQueries', () => {
|
||||||
it('should pass dataFrames to state even if there is error in response', async () => {
|
it('should pass dataFrames to state even if there is error in response', async () => {
|
||||||
setTimeSrv({
|
setTimeSrv({
|
||||||
@ -77,19 +105,7 @@ describe('runQueries', () => {
|
|||||||
const { dispatch, getState }: { dispatch: ThunkDispatch; getState: () => StoreState } = configureStore({
|
const { dispatch, getState }: { dispatch: ThunkDispatch; getState: () => StoreState } = configureStore({
|
||||||
...(defaultInitialState as any),
|
...(defaultInitialState as any),
|
||||||
});
|
});
|
||||||
(getState().explore[ExploreId.left].datasourceInstance?.query as Mock).mockReturnValueOnce(
|
setupQueryResponse(getState());
|
||||||
of({
|
|
||||||
error: { message: 'test error' },
|
|
||||||
data: [
|
|
||||||
new MutableDataFrame({
|
|
||||||
fields: [{ name: 'test', values: new ArrayVector() }],
|
|
||||||
meta: {
|
|
||||||
preferredVisualisationType: 'graph',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
} as DataQueryResponse)
|
|
||||||
);
|
|
||||||
await dispatch(runQueries(ExploreId.left));
|
await dispatch(runQueries(ExploreId.left));
|
||||||
expect(getState().explore[ExploreId.left].showMetrics).toBeTruthy();
|
expect(getState().explore[ExploreId.left].showMetrics).toBeTruthy();
|
||||||
expect(getState().explore[ExploreId.left].graphResult).toBeDefined();
|
expect(getState().explore[ExploreId.left].graphResult).toBeDefined();
|
||||||
@ -303,4 +319,136 @@ describe('reducer', () => {
|
|||||||
expect(getState().explore[ExploreId.left].cache).toEqual([]);
|
expect(getState().explore[ExploreId.left].cache).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('logs volume', () => {
|
||||||
|
let dispatch: ThunkDispatch,
|
||||||
|
getState: () => StoreState,
|
||||||
|
mockLogsVolumeDataProvider: () => Observable<DataQueryResponse>;
|
||||||
|
|
||||||
|
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<DataQueryResponse>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import { mergeMap, throttleTime } from 'rxjs/operators';
|
import { mergeMap, throttleTime } from 'rxjs/operators';
|
||||||
import { identity, Unsubscribable, of } from 'rxjs';
|
import { identity, Observable, of, SubscriptionLike, Unsubscribable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
DataQuery,
|
DataQuery,
|
||||||
DataQueryErrorType,
|
DataQueryErrorType,
|
||||||
|
DataQueryResponse,
|
||||||
DataSourceApi,
|
DataSourceApi,
|
||||||
|
hasLogsVolumeSupport,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
PanelData,
|
PanelData,
|
||||||
PanelEvents,
|
PanelEvents,
|
||||||
@ -30,11 +32,12 @@ import { notifyApp } from '../../../core/actions';
|
|||||||
import { runRequest } from '../../query/state/runRequest';
|
import { runRequest } from '../../query/state/runRequest';
|
||||||
import { decorateData } from '../utils/decorators';
|
import { decorateData } from '../utils/decorators';
|
||||||
import { createErrorNotification } from '../../../core/copy/appNotification';
|
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 { AnyAction, createAction, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { updateTime } from './time';
|
import { updateTime } from './time';
|
||||||
import { historyUpdatedAction } from './history';
|
import { historyUpdatedAction } from './history';
|
||||||
import { createEmptyQueryResponse, createCacheKey, getResultsFromCache } from './utils';
|
import { createCacheKey, createEmptyQueryResponse, getResultsFromCache } from './utils';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
|
||||||
//
|
//
|
||||||
// Actions and Payloads
|
// Actions and Payloads
|
||||||
@ -98,10 +101,43 @@ export interface QueryStoreSubscriptionPayload {
|
|||||||
exploreId: ExploreId;
|
exploreId: ExploreId;
|
||||||
querySubscription: Unsubscribable;
|
querySubscription: Unsubscribable;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const queryStoreSubscriptionAction = createAction<QueryStoreSubscriptionPayload>(
|
export const queryStoreSubscriptionAction = createAction<QueryStoreSubscriptionPayload>(
|
||||||
'explore/queryStoreSubscription'
|
'explore/queryStoreSubscription'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export interface StoreLogsVolumeDataProvider {
|
||||||
|
exploreId: ExploreId;
|
||||||
|
logsVolumeDataProvider?: Observable<DataQueryResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores available logs volume provider after running the query. Used internally by runQueries().
|
||||||
|
*/
|
||||||
|
const storeLogsVolumeDataProviderAction = createAction<StoreLogsVolumeDataProvider>(
|
||||||
|
'explore/storeLogsVolumeDataProviderAction'
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface StoreLogsVolumeDataSubscriptionPayload {
|
||||||
|
exploreId: ExploreId;
|
||||||
|
logsVolumeDataSubscription?: SubscriptionLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores current logs volume subscription for given explore pane.
|
||||||
|
*/
|
||||||
|
const storeLogsVolumeDataSubscriptionAction = createAction<StoreLogsVolumeDataSubscriptionPayload>(
|
||||||
|
'explore/storeLogsVolumeDataSubscriptionAction'
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores data returned by the provider. Used internally by loadLogsVolumeData().
|
||||||
|
*/
|
||||||
|
const updateLogsVolumeDataAction = createAction<{
|
||||||
|
exploreId: ExploreId;
|
||||||
|
logsVolumeData: DataQueryResponse;
|
||||||
|
}>('explore/updateLogsVolumeDataAction');
|
||||||
|
|
||||||
export interface QueryEndedPayload {
|
export interface QueryEndedPayload {
|
||||||
exploreId: ExploreId;
|
exploreId: ExploreId;
|
||||||
response: ExplorePanelData;
|
response: ExplorePanelData;
|
||||||
@ -166,6 +202,7 @@ export interface ClearCachePayload {
|
|||||||
exploreId: ExploreId;
|
exploreId: ExploreId;
|
||||||
}
|
}
|
||||||
export const clearCacheAction = createAction<ClearCachePayload>('explore/clearCache');
|
export const clearCacheAction = createAction<ClearCachePayload>('explore/clearCache');
|
||||||
|
|
||||||
//
|
//
|
||||||
// Action creators
|
// Action creators
|
||||||
//
|
//
|
||||||
@ -281,7 +318,7 @@ export const runQueries = (
|
|||||||
dispatch(clearCache(exploreId));
|
dispatch(clearCache(exploreId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const richHistory = getState().explore.richHistory;
|
const { richHistory, autoLoadLogsVolume } = getState().explore;
|
||||||
const exploreItemState = getState().explore[exploreId]!;
|
const exploreItemState = getState().explore[exploreId]!;
|
||||||
const {
|
const {
|
||||||
datasourceInstance,
|
datasourceInstance,
|
||||||
@ -296,6 +333,7 @@ export const runQueries = (
|
|||||||
refreshInterval,
|
refreshInterval,
|
||||||
absoluteRange,
|
absoluteRange,
|
||||||
cache,
|
cache,
|
||||||
|
logsVolumeDataProvider,
|
||||||
} = exploreItemState;
|
} = exploreItemState;
|
||||||
let newQuerySub;
|
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 we have results saved in cache, we are going to use those results instead of running queries
|
||||||
if (cachedValue) {
|
if (cachedValue) {
|
||||||
newQuerySub = of(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) => {
|
.subscribe((data) => {
|
||||||
if (!data.error) {
|
if (!data.error) {
|
||||||
dispatch(stateSave());
|
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
|
// 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.
|
// actually can see what is happening.
|
||||||
live ? throttleTime(500) : identity,
|
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(
|
.subscribe(
|
||||||
(data) => {
|
(data) => {
|
||||||
@ -402,6 +453,26 @@ export const runQueries = (
|
|||||||
console.error(error);
|
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 }));
|
dispatch(queryStoreSubscriptionAction({ exploreId, querySubscription: newQuerySub }));
|
||||||
@ -458,6 +529,40 @@ export function clearCache(exploreId: ExploreId): ThunkResult<void> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
// 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)) {
|
if (queryStreamUpdatedAction.match(action)) {
|
||||||
return processQueryResponse(state, action);
|
return processQueryResponse(state, action);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AbsoluteTimeRange,
|
||||||
DataSourceApi,
|
DataSourceApi,
|
||||||
EventBusExtended,
|
EventBusExtended,
|
||||||
ExploreUrlState,
|
ExploreUrlState,
|
||||||
@ -6,7 +7,6 @@ import {
|
|||||||
HistoryItem,
|
HistoryItem,
|
||||||
LoadingState,
|
LoadingState,
|
||||||
PanelData,
|
PanelData,
|
||||||
AbsoluteTimeRange,
|
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
|
||||||
import { ExploreItemState } from 'app/types/explore';
|
import { ExploreItemState } from 'app/types/explore';
|
||||||
@ -50,6 +50,8 @@ export const makeExplorePaneState = (): ExploreItemState => ({
|
|||||||
logsResult: null,
|
logsResult: null,
|
||||||
eventBridge: (null as unknown) as EventBusExtended,
|
eventBridge: (null as unknown) as EventBusExtended,
|
||||||
cache: [],
|
cache: [],
|
||||||
|
logsVolumeDataProvider: undefined,
|
||||||
|
logsVolumeData: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createEmptyQueryResponse = (): PanelData => ({
|
export const createEmptyQueryResponse = (): PanelData => ({
|
||||||
|
@ -131,7 +131,12 @@ export const decorateWithTableResult = (data: ExplorePanelData): Observable<Expl
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const decorateWithLogsResult = (
|
export const decorateWithLogsResult = (
|
||||||
options: { absoluteRange?: AbsoluteTimeRange; refreshInterval?: string; queries?: DataQuery[] } = {}
|
options: {
|
||||||
|
absoluteRange?: AbsoluteTimeRange;
|
||||||
|
refreshInterval?: string;
|
||||||
|
queries?: DataQuery[];
|
||||||
|
fullRangeLogsVolumeAvailable?: boolean;
|
||||||
|
} = {}
|
||||||
) => (data: ExplorePanelData): ExplorePanelData => {
|
) => (data: ExplorePanelData): ExplorePanelData => {
|
||||||
if (data.logsFrames.length === 0) {
|
if (data.logsFrames.length === 0) {
|
||||||
return { ...data, logsResult: null };
|
return { ...data, logsResult: null };
|
||||||
@ -142,7 +147,10 @@ export const decorateWithLogsResult = (
|
|||||||
const sortOrder = refreshIntervalToSortOrder(options.refreshInterval);
|
const sortOrder = refreshIntervalToSortOrder(options.refreshInterval);
|
||||||
const sortedNewResults = sortLogsResult(newResults, sortOrder);
|
const sortedNewResults = sortLogsResult(newResults, sortOrder);
|
||||||
const rows = sortedNewResults.rows;
|
const rows = sortedNewResults.rows;
|
||||||
const series = sortedNewResults.series;
|
const series =
|
||||||
|
config.featureToggles.fullRangeLogsVolume && options.fullRangeLogsVolumeAvailable
|
||||||
|
? undefined
|
||||||
|
: sortedNewResults.series;
|
||||||
const logsResult = { ...sortedNewResults, rows, series };
|
const logsResult = { ...sortedNewResults, rows, series };
|
||||||
|
|
||||||
return { ...data, logsResult };
|
return { ...data, logsResult };
|
||||||
@ -154,13 +162,14 @@ export function decorateData(
|
|||||||
queryResponse: PanelData,
|
queryResponse: PanelData,
|
||||||
absoluteRange: AbsoluteTimeRange,
|
absoluteRange: AbsoluteTimeRange,
|
||||||
refreshInterval: string | undefined,
|
refreshInterval: string | undefined,
|
||||||
queries: DataQuery[] | undefined
|
queries: DataQuery[] | undefined,
|
||||||
|
fullRangeLogsVolumeAvailable: boolean
|
||||||
): Observable<ExplorePanelData> {
|
): Observable<ExplorePanelData> {
|
||||||
return of(data).pipe(
|
return of(data).pipe(
|
||||||
map((data: PanelData) => preProcessPanelData(data, queryResponse)),
|
map((data: PanelData) => preProcessPanelData(data, queryResponse)),
|
||||||
map(decorateWithFrameTypeMetadata),
|
map(decorateWithFrameTypeMetadata),
|
||||||
map(decorateWithGraphResult),
|
map(decorateWithGraphResult),
|
||||||
map(decorateWithLogsResult({ absoluteRange, refreshInterval, queries })),
|
map(decorateWithLogsResult({ absoluteRange, refreshInterval, queries, fullRangeLogsVolumeAvailable })),
|
||||||
mergeMap(decorateWithTableResult)
|
mergeMap(decorateWithTableResult)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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<DataQueryResponse>,
|
||||||
|
datasource: MockObservableDataSourceApi,
|
||||||
|
request: DataQueryRequest<LokiQuery>;
|
||||||
|
|
||||||
|
function setup(datasourceSetup: () => void) {
|
||||||
|
datasourceSetup();
|
||||||
|
request = ({
|
||||||
|
targets: [{ expr: '{app="app01"}' }, { expr: '{app="app02"}' }],
|
||||||
|
} as unknown) as DataQueryRequest<LokiQuery>;
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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<LokiQuery>
|
||||||
|
): Observable<DataQueryResponse> {
|
||||||
|
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;
|
||||||
|
}
|
@ -920,6 +920,38 @@ describe('LokiDatasource', () => {
|
|||||||
expect(contextQuery.expr).not.toContain('uniqueParsedLabel');
|
expect(contextQuery.expr).not.toContain('uniqueParsedLabel');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('logs volume data provider', () => {
|
||||||
|
it('creates provider for logs query', () => {
|
||||||
|
const ds = createLokiDSForTests();
|
||||||
|
const options = getQueryOptions<LokiQuery>({
|
||||||
|
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<LokiQuery>({
|
||||||
|
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<LokiQuery>({
|
||||||
|
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) {
|
function assertAdHocFilters(query: string, expectedResults: string, ds: LokiDatasource) {
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
DataQueryResponse,
|
DataQueryResponse,
|
||||||
DataSourceApi,
|
DataSourceApi,
|
||||||
DataSourceInstanceSettings,
|
DataSourceInstanceSettings,
|
||||||
|
DataSourceWithLogsVolumeSupport,
|
||||||
dateMath,
|
dateMath,
|
||||||
DateTime,
|
DateTime,
|
||||||
FieldCache,
|
FieldCache,
|
||||||
@ -52,6 +53,7 @@ import { serializeParams } from '../../../core/utils/fetch';
|
|||||||
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
|
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
|
||||||
import syntax from './syntax';
|
import syntax from './syntax';
|
||||||
import { DEFAULT_RESOLUTION } from './components/LokiOptionFields';
|
import { DEFAULT_RESOLUTION } from './components/LokiOptionFields';
|
||||||
|
import { createLokiLogsVolumeProvider } from './dataProviders/logsVolumeProvider';
|
||||||
|
|
||||||
export type RangeQueryOptions = DataQueryRequest<LokiQuery> | AnnotationQueryRequest<LokiQuery>;
|
export type RangeQueryOptions = DataQueryRequest<LokiQuery> | AnnotationQueryRequest<LokiQuery>;
|
||||||
export const DEFAULT_MAX_LINES = 1000;
|
export const DEFAULT_MAX_LINES = 1000;
|
||||||
@ -67,7 +69,9 @@ const DEFAULT_QUERY_PARAMS: Partial<LokiRangeQueryRequest> = {
|
|||||||
query: '',
|
query: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
|
export class LokiDatasource
|
||||||
|
extends DataSourceApi<LokiQuery, LokiOptions>
|
||||||
|
implements DataSourceWithLogsVolumeSupport<LokiQuery> {
|
||||||
private streams = new LiveStreams();
|
private streams = new LiveStreams();
|
||||||
languageProvider: LanguageProvider;
|
languageProvider: LanguageProvider;
|
||||||
maxLines: number;
|
maxLines: number;
|
||||||
@ -102,6 +106,11 @@ export class LokiDatasource extends DataSourceApi<LokiQuery, LokiOptions> {
|
|||||||
return getBackendSrv().fetch<Record<string, any>>(req);
|
return getBackendSrv().fetch<Record<string, any>>(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLogsVolumeDataProvider(request: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> | undefined {
|
||||||
|
const isLogsVolumeAvailable = request.targets.some((target) => target.expr && !isMetricsQuery(target.expr));
|
||||||
|
return isLogsVolumeAvailable ? createLokiLogsVolumeProvider(this, request) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
query(options: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> {
|
query(options: DataQueryRequest<LokiQuery>): Observable<DataQueryResponse> {
|
||||||
const subQueries: Array<Observable<DataQueryResponse>> = [];
|
const subQueries: Array<Observable<DataQueryResponse>> = [];
|
||||||
const scopedVars = {
|
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.
|
* 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.
|
* 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);
|
const tokens = Prism.tokenize(query, syntax);
|
||||||
return tokens.some((t) => {
|
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
|
// Not sure in which cases it can be string maybe if nothing matched which means it should not be a function
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Unsubscribable } from 'rxjs';
|
import { Observable, SubscriptionLike, Unsubscribable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
AbsoluteTimeRange,
|
AbsoluteTimeRange,
|
||||||
DataFrame,
|
DataFrame,
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
RawTimeRange,
|
RawTimeRange,
|
||||||
TimeRange,
|
TimeRange,
|
||||||
EventBusExtended,
|
EventBusExtended,
|
||||||
|
DataQueryResponse,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
|
||||||
export enum ExploreId {
|
export enum ExploreId {
|
||||||
@ -44,6 +45,11 @@ export interface ExploreState {
|
|||||||
* History of all queries
|
* History of all queries
|
||||||
*/
|
*/
|
||||||
richHistory: RichHistoryQuery[];
|
richHistory: RichHistoryQuery[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-loading logs volume after running the query
|
||||||
|
*/
|
||||||
|
autoLoadLogsVolume: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExploreItemState {
|
export interface ExploreItemState {
|
||||||
@ -149,6 +155,12 @@ export interface ExploreItemState {
|
|||||||
* We are currently caching last 5 query responses.
|
* We are currently caching last 5 query responses.
|
||||||
*/
|
*/
|
||||||
cache: Array<{ key: string; value: PanelData }>;
|
cache: Array<{ key: string; value: PanelData }>;
|
||||||
|
|
||||||
|
// properties below should be more generic if we add more providers
|
||||||
|
// see also: DataSourceWithLogsVolumeSupport
|
||||||
|
logsVolumeDataProvider?: Observable<DataQueryResponse>;
|
||||||
|
logsVolumeDataSubscription?: SubscriptionLike;
|
||||||
|
logsVolumeData?: DataQueryResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExploreUpdateState {
|
export interface ExploreUpdateState {
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
DataSourceInstanceSettings,
|
DataSourceInstanceSettings,
|
||||||
DataSourcePluginMeta,
|
DataSourcePluginMeta,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
export class DatasourceSrvMock {
|
export class DatasourceSrvMock {
|
||||||
constructor(private defaultDS: DataSourceApi, private datasources: { [name: string]: DataSourceApi }) {
|
constructor(private defaultDS: DataSourceApi, private datasources: { [name: string]: DataSourceApi }) {
|
||||||
@ -51,3 +52,34 @@ export class MockDataSourceApi extends DataSourceApi {
|
|||||||
return Promise.resolve();
|
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<DataQueryResponse> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user