Logs: Use GraphNG to plot log histograms (#34531)

* Switch to GraphNG for Logs Histogram

* Remove redundant timeZone

It was used just to format timestamp in the tooltip but it's not needed anymore.

* Add tests for creating logs histogram data

* Update decoractors tests

* Adjust bar width to be more like in the old graph

* Fix tooltip pointer color

* Test tooltip pointer color

* Decouple graph config from uPlot internals

* Ensure nested properties are not mutated when overrides are applied

* Add legend toggling for Explore graphs

* Remove unused component

ExploreGraphNGPanel is now used in Explore

* Code formatting

* allow multiple bars pathBuilders to be globally cached with different settings

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Piotr Jamróz 2021-06-01 09:28:25 +02:00 committed by GitHub
parent c6a78a6bd7
commit f4a40a4d85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 272 additions and 402 deletions

View File

@ -143,6 +143,7 @@ describe('applyFieldOverrides', () => {
// Hardcode the max value
f0.fields[1].config.max = 0;
f0.fields[1].config.decimals = 6;
f0.fields[1].config.custom = { value: 1 };
const src: FieldConfigSource = {
defaults: {
@ -315,6 +316,18 @@ describe('applyFieldOverrides', () => {
expect(data.fields[1].config.decimals).toEqual(1);
expect(replaceVariablesCalls[0].__value.value.text).toEqual('100.0');
});
it('creates a deep clone of field config', () => {
const data = applyFieldOverrides({
data: [f0], // the frame
fieldConfig: src as FieldConfigSource, // defaults + overrides
replaceVariables: (undefined as any) as InterpolateFunction,
theme: createTheme(),
})[0];
expect(data.fields[1].config).not.toBe(f0.fields[1].config);
expect(data.fields[1].config.custom).not.toBe(f0.fields[1].config.custom);
});
});
describe('setFieldConfigDefaults', () => {

View File

@ -18,7 +18,7 @@ import {
} from '../types';
import { fieldMatchers, reduceField, ReducerID } from '../transformations';
import { FieldMatcher } from '../types/transformations';
import { isNumber, set, unset, get } from 'lodash';
import { isNumber, set, unset, get, cloneDeep } from 'lodash';
import { getDisplayProcessor, getRawDisplayProcessor } from './displayProcessor';
import { guessFieldTypeForField } from '../dataframe';
import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry';
@ -119,7 +119,7 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
displayName,
};
const config: FieldConfig = { ...field.config };
const config: FieldConfig = { ...cloneDeep(field.config) };
const context = {
field,
data: options.data!,

View File

@ -1,5 +1,4 @@
import { Labels } from './data';
import { GraphSeriesXY } from './graph';
import { DataFrame } from './dataFrame';
import { AbsoluteTimeRange } from './time';
import { DataQuery } from './datasource';
@ -84,7 +83,7 @@ export interface LogsModel {
hasUniqueLabels: boolean;
meta?: LogsMetaItem[];
rows: LogRowModel[];
series?: GraphSeriesXY[];
series?: DataFrame[];
visibleRange?: AbsoluteTimeRange;
queries?: DataQuery[];
}

View File

@ -179,6 +179,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
lineInterpolation: customConfig.lineInterpolation,
lineStyle: customConfig.lineStyle,
barAlignment: customConfig.barAlignment,
barWidthFactor: customConfig.barWidthFactor,
barMaxWidth: customConfig.barMaxWidth,
pointSize: customConfig.pointSize,
pointColor: customConfig.pointColor ?? seriesColor,
spanNulls: customConfig.spanNulls || false,

View File

@ -108,6 +108,8 @@ export interface LineConfig {
*/
export interface BarConfig {
barAlignment?: BarAlignment;
barWidthFactor?: number;
barMaxWidth?: number;
}
/**

View File

@ -41,6 +41,8 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
lineWidth,
lineStyle,
barAlignment,
barWidthFactor,
barMaxWidth,
showPoints,
pointColor,
pointSize,
@ -68,7 +70,13 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
lineConfig.dash = lineStyle.dash ?? [10, 10];
}
lineConfig.paths = (self: uPlot, seriesIdx: number, idx0: number, idx1: number) => {
let pathsBuilder = mapDrawStyleToPathBuilder(drawStyle, lineInterpolation, barAlignment);
let pathsBuilder = mapDrawStyleToPathBuilder(
drawStyle,
lineInterpolation,
barAlignment,
barWidthFactor,
barMaxWidth
);
return pathsBuilder(self, seriesIdx, idx0, idx1);
};
}
@ -153,9 +161,7 @@ interface PathBuilders {
smooth: Series.PathBuilder;
stepBefore: Series.PathBuilder;
stepAfter: Series.PathBuilder;
bars: Series.PathBuilder;
barsAfter: Series.PathBuilder;
barsBefore: Series.PathBuilder;
[key: string]: Series.PathBuilder;
}
let builders: PathBuilders | undefined = undefined;
@ -163,35 +169,35 @@ let builders: PathBuilders | undefined = undefined;
function mapDrawStyleToPathBuilder(
style: DrawStyle,
lineInterpolation?: LineInterpolation,
barAlignment?: BarAlignment
barAlignment = 0,
barWidthFactor = 0.6,
barMaxWidth = Infinity
): Series.PathBuilder {
const pathBuilders = uPlot.paths;
if (!builders) {
// This should be global static, but Jest initalization was failing so we lazy load to avoid the issue
const pathBuilders = uPlot.paths;
const barWidthFactor = 0.6;
const barMaxWidth = Infinity;
builders = {
linear: pathBuilders.linear!(),
smooth: pathBuilders.spline!(),
stepBefore: pathBuilders.stepped!({ align: -1 }),
stepAfter: pathBuilders.stepped!({ align: 1 }),
bars: pathBuilders.bars!({ size: [barWidthFactor, barMaxWidth] }),
barsBefore: pathBuilders.bars!({ size: [barWidthFactor, barMaxWidth], align: -1 }),
barsAfter: pathBuilders.bars!({ size: [barWidthFactor, barMaxWidth], align: 1 }),
};
}
if (style === DrawStyle.Bars) {
if (barAlignment === BarAlignment.After) {
return builders.barsAfter;
// each bars pathBuilder is lazy-initialized and globally cached by a key composed of its options
let barsCfgKey = `bars|${barAlignment}|${barWidthFactor}|${barMaxWidth}`;
if (!builders[barsCfgKey]) {
builders[barsCfgKey] = pathBuilders.bars!({
size: [barWidthFactor, barMaxWidth],
align: barAlignment as BarAlignment,
});
}
if (barAlignment === BarAlignment.Before) {
return builders.barsBefore;
}
return builders.bars;
}
if (style === DrawStyle.Line) {
return builders[barsCfgKey];
} else if (style === DrawStyle.Line) {
if (lineInterpolation === LineInterpolation.StepBefore) {
return builders.stepBefore;
}

View File

@ -1,4 +1,5 @@
import {
ArrayVector,
DataFrame,
FieldType,
LogLevel,
@ -208,7 +209,7 @@ const emptyLogsModel: any = {
describe('dataFrameToLogsModel', () => {
it('given empty series should return empty logs model', () => {
expect(dataFrameToLogsModel([] as DataFrame[], 0, 'utc')).toMatchObject(emptyLogsModel);
expect(dataFrameToLogsModel([] as DataFrame[], 0)).toMatchObject(emptyLogsModel);
});
it('given series without correct series name should return empty logs model', () => {
@ -217,7 +218,7 @@ describe('dataFrameToLogsModel', () => {
fields: [],
}),
];
expect(dataFrameToLogsModel(series, 0, 'utc')).toMatchObject(emptyLogsModel);
expect(dataFrameToLogsModel(series, 0)).toMatchObject(emptyLogsModel);
});
it('given series without a time field should return empty logs model', () => {
@ -232,7 +233,7 @@ describe('dataFrameToLogsModel', () => {
],
}),
];
expect(dataFrameToLogsModel(series, 0, 'utc')).toMatchObject(emptyLogsModel);
expect(dataFrameToLogsModel(series, 0)).toMatchObject(emptyLogsModel);
});
it('given series without a string field should return empty logs model', () => {
@ -247,7 +248,7 @@ describe('dataFrameToLogsModel', () => {
],
}),
];
expect(dataFrameToLogsModel(series, 0, 'utc')).toMatchObject(emptyLogsModel);
expect(dataFrameToLogsModel(series, 0)).toMatchObject(emptyLogsModel);
});
it('given one series should return expected logs model', () => {
@ -282,7 +283,7 @@ describe('dataFrameToLogsModel', () => {
},
}),
];
const logsModel = dataFrameToLogsModel(series, 1, 'utc');
const logsModel = dataFrameToLogsModel(series, 1);
expect(logsModel.hasUniqueLabels).toBeFalsy();
expect(logsModel.rows).toHaveLength(2);
expect(logsModel.rows).toMatchObject([
@ -303,6 +304,22 @@ describe('dataFrameToLogsModel', () => {
]);
expect(logsModel.series).toHaveLength(2);
expect(logsModel.series).toMatchObject([
{
name: 'info',
fields: [
{ type: 'time', values: new ArrayVector([1556270891000, 1556289770000]) },
{ type: 'number', values: new ArrayVector([1, 0]) },
],
},
{
name: 'error',
fields: [
{ type: 'time', values: new ArrayVector([1556289770000]) },
{ type: 'number', values: new ArrayVector([1]) },
],
},
]);
expect(logsModel.meta).toHaveLength(2);
expect(logsModel.meta![0]).toMatchObject({
label: 'Common labels',
@ -352,7 +369,7 @@ describe('dataFrameToLogsModel', () => {
},
}),
];
const logsModel = dataFrameToLogsModel(series, 1, 'utc');
const logsModel = dataFrameToLogsModel(series, 1);
expect(logsModel.hasUniqueLabels).toBeFalsy();
expect(logsModel.rows).toHaveLength(2);
expect(logsModel.rows).toMatchObject([
@ -413,7 +430,7 @@ describe('dataFrameToLogsModel', () => {
],
}),
];
const logsModel = dataFrameToLogsModel(series, 1, 'utc');
const logsModel = dataFrameToLogsModel(series, 1);
expect(logsModel.rows).toHaveLength(1);
expect(logsModel.rows).toMatchObject([
{
@ -477,7 +494,7 @@ describe('dataFrameToLogsModel', () => {
],
}),
];
const logsModel = dataFrameToLogsModel(series, 1, 'utc');
const logsModel = dataFrameToLogsModel(series, 1);
expect(logsModel.hasUniqueLabels).toBeTruthy();
expect(logsModel.rows).toHaveLength(3);
expect(logsModel.rows).toMatchObject([
@ -502,6 +519,22 @@ describe('dataFrameToLogsModel', () => {
]);
expect(logsModel.series).toHaveLength(2);
expect(logsModel.series).toMatchObject([
{
name: 'error',
fields: [
{ type: 'time', values: new ArrayVector([0, 1000, 2000]) },
{ type: 'number', values: new ArrayVector([1, 0, 1]) },
],
},
{
name: 'debug',
fields: [
{ type: 'time', values: new ArrayVector([1000, 2000]) },
{ type: 'number', values: new ArrayVector([1, 0]) },
],
},
]);
expect(logsModel.meta).toHaveLength(1);
expect(logsModel.meta![0]).toMatchObject({
label: 'Common labels',
@ -587,7 +620,7 @@ describe('dataFrameToLogsModel', () => {
],
}),
];
const logsModel = dataFrameToLogsModel(series, 1, 'utc');
const logsModel = dataFrameToLogsModel(series, 1);
expect(logsModel.hasUniqueLabels).toBeTruthy();
expect(logsModel.rows).toHaveLength(4);
expect(logsModel.rows).toMatchObject([
@ -650,7 +683,7 @@ describe('dataFrameToLogsModel', () => {
},
}),
];
const logsModel = dataFrameToLogsModel(series, 1, 'utc', { from: 1556270591353, to: 1556289770991 });
const logsModel = dataFrameToLogsModel(series, 1, { from: 1556270591353, to: 1556289770991 });
expect(logsModel.meta).toHaveLength(2);
expect(logsModel.meta![0]).toMatchObject({
label: 'Common labels',
@ -682,7 +715,7 @@ describe('dataFrameToLogsModel', () => {
],
}),
];
const logsModel = dataFrameToLogsModel(series, 1, 'utc');
const logsModel = dataFrameToLogsModel(series, 1);
expect(logsModel.rows[0].uid).toBe('0');
});
});
@ -763,7 +796,7 @@ describe('logSeriesToLogsModel', () => {
}),
];
const logsModel = dataFrameToLogsModel(logSeries, 0, 'utc');
const logsModel = dataFrameToLogsModel(logSeries, 0);
expect(logsModel.meta).toMatchObject([
{ kind: 2, label: 'Common labels', value: { foo: 'bar', level: 'dbug' } },
{ kind: 0, label: LIMIT_LABEL, value: 2000 },
@ -818,7 +851,7 @@ describe('logSeriesToLogsModel', () => {
}),
];
const logsModel = dataFrameToLogsModel(logSeries, 0, 'utc');
const logsModel = dataFrameToLogsModel(logSeries, 0);
expect(logsModel.rows).toHaveLength(3);
expect(logsModel.rows).toMatchObject([
{

View File

@ -1,40 +1,34 @@
import { size } from 'lodash';
import { colors, ansicolor } from '@grafana/ui';
import { ansicolor, BarAlignment, colors, DrawStyle, StackingMode } from '@grafana/ui';
import {
Labels,
LogLevel,
AbsoluteTimeRange,
DataFrame,
DataQuery,
dateTime,
dateTimeFormat,
dateTimeFormatTimeAgo,
FieldCache,
FieldType,
FieldWithIndex,
findCommonLabels,
findUniqueLabels,
getLogLevel,
FieldType,
getLogLevelFromKey,
Labels,
LogLevel,
LogRowModel,
LogsModel,
LogsDedupStrategy,
LogsMetaItem,
LogsMetaKind,
LogsDedupStrategy,
GraphSeriesXY,
dateTimeFormat,
dateTimeFormatTimeAgo,
NullValueMode,
toDataFrame,
FieldCache,
FieldWithIndex,
getFlotPairs,
TimeZone,
getDisplayProcessor,
textUtil,
dateTime,
AbsoluteTimeRange,
sortInAscendingOrder,
LogsModel,
rangeUtil,
DataQuery,
sortInAscendingOrder,
textUtil,
toDataFrame,
} from '@grafana/data';
import { getThemeColor } from 'app/core/utils/colors';
import { SIPrefix } from '@grafana/data/src/valueFormats/symbolFormatters';
import { config } from '@grafana/runtime';
export const LIMIT_LABEL = 'Line limit';
@ -94,7 +88,7 @@ export function filterLogLevels(logRows: LogRowModel[], hiddenLogLevels: Set<Log
});
}
export function makeSeriesForLogs(sortedRows: LogRowModel[], bucketSize: number, timeZone: TimeZone): GraphSeriesXY[] {
export function makeDataFramesForLogs(sortedRows: LogRowModel[], bucketSize: number): DataFrame[] {
// currently interval is rangeMs / resolution, which is too low for showing series as bars.
// Should be solved higher up the chain when executing queries & interval calculated and not here but this is a temporary fix.
@ -109,7 +103,6 @@ export function makeSeriesForLogs(sortedRows: LogRowModel[], bucketSize: number,
seriesByLevel[row.logLevel] = series = {
lastTs: null,
datapoints: [],
alias: row.logLevel,
target: row.logLevel,
color: LogLevelColor[row.logLevel],
};
@ -141,52 +134,31 @@ export function makeSeriesForLogs(sortedRows: LogRowModel[], bucketSize: number,
return seriesList.map((series, i) => {
series.datapoints.sort((a: number[], b: number[]) => a[1] - b[1]);
// EEEP: converts GraphSeriesXY to DataFrame and back again!
const data = toDataFrame(series);
const fieldCache = new FieldCache(data);
const timeField = fieldCache.getFirstFieldOfType(FieldType.time)!;
timeField.display = getDisplayProcessor({
field: timeField,
timeZone,
theme: config.theme2,
});
const valueField = fieldCache.getFirstFieldOfType(FieldType.number)!;
valueField.config = {
...valueField.config,
color: series.color,
};
valueField.name = series.alias;
const fieldDisplayProcessor = getDisplayProcessor({ field: valueField, timeZone, theme: config.theme2 });
valueField.display = (value: any) => ({ ...fieldDisplayProcessor(value), color: series.color });
data.fields[valueField.index].config.min = 0;
data.fields[valueField.index].config.decimals = 0;
const points = getFlotPairs({
xField: timeField,
yField: valueField,
nullValueMode: NullValueMode.Null,
});
const graphSeries: GraphSeriesXY = {
color: series.color,
label: series.alias,
data: points,
isVisible: true,
yAxis: {
index: 1,
min: 0,
tickDecimals: 0,
data.fields[valueField.index].config.custom = {
drawStyle: DrawStyle.Bars,
barAlignment: BarAlignment.Center,
barWidthFactor: 0.9,
barMaxWidth: 5,
lineColor: series.color,
pointColor: series.color,
fillColor: series.color,
lineWidth: 0,
fillOpacity: 100,
stacking: {
mode: StackingMode.Normal,
group: 'A',
},
seriesIndex: i,
timeField,
valueField,
// for now setting the time step to be 0,
// and handle the bar width by setting lineWidth instead of barWidth in flot options
timeStep: 0,
};
return graphSeries;
return data;
});
}
@ -203,7 +175,6 @@ function isLogsData(series: DataFrame) {
export function dataFrameToLogsModel(
dataFrame: DataFrame[],
intervalMs: number | undefined,
timeZone: TimeZone,
absoluteRange?: AbsoluteTimeRange,
queries?: DataQuery[]
): LogsModel {
@ -220,7 +191,7 @@ export function dataFrameToLogsModel(
absoluteRange
);
logsModel.visibleRange = visibleRange;
logsModel.series = makeSeriesForLogs(sortedRows, bucketSize, timeZone);
logsModel.series = makeDataFramesForLogs(sortedRows, bucketSize);
if (logsModel.meta) {
logsModel.meta = adjustMetaInfo(logsModel, visibleRangeMs, requestedRangeMs);
@ -245,7 +216,7 @@ export function dataFrameToLogsModel(
* Returns a clamped time range and interval based on the visible logs and the given range.
*
* @param sortedRows Log rows from the query response
* @param intervalMs Dynamnic data interval based on available pixel width
* @param intervalMs Dynamic data interval based on available pixel width
* @param absoluteRange Requested time range
* @param pxPerBar Default: 20, buckets will be rendered as bars, assuming 10px per histogram bar plus some free space around it
*/

View File

@ -6,7 +6,14 @@ import { connect } from 'react-redux';
import AutoSizer from 'react-virtualized-auto-sizer';
import memoizeOne from 'memoize-one';
import { selectors } from '@grafana/e2e-selectors';
import { ErrorBoundaryAlert, stylesFactory, withTheme, CustomScrollbar } from '@grafana/ui';
import {
ErrorBoundaryAlert,
stylesFactory,
withTheme,
CustomScrollbar,
Collapse,
TooltipDisplayMode,
} from '@grafana/ui';
import {
AbsoluteTimeRange,
DataQuery,
@ -222,19 +229,21 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
);
}
renderGraphPanel(width: number) {
renderGraphPanel() {
const { graphResult, absoluteRange, timeZone, splitOpen, queryResponse, loading } = this.props;
return (
<ExploreGraphNGPanel
data={graphResult!}
width={width}
absoluteRange={absoluteRange}
timeZone={timeZone}
onUpdateTimeRange={this.onUpdateTimeRange}
annotations={queryResponse.annotations}
splitOpenFn={splitOpen}
isLoading={loading}
/>
<Collapse label="Graph" loading={loading} isOpen>
<ExploreGraphNGPanel
data={graphResult!}
height={400}
tooltipDisplayMode={TooltipDisplayMode.Single}
absoluteRange={absoluteRange}
timeZone={timeZone}
onUpdateTimeRange={this.onUpdateTimeRange}
annotations={queryResponse.annotations}
splitOpenFn={splitOpen}
/>
</Collapse>
);
}
@ -250,11 +259,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
);
}
renderLogsPanel(width: number) {
renderLogsPanel() {
const { exploreId, syncedTimes } = this.props;
return (
<LogsContainer
width={width}
exploreId={exploreId}
syncedTimes={syncedTimes}
onClickFilterLabel={this.onClickFilterLabel}
@ -349,10 +357,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
{showPanels && (
<>
{showMetrics && graphResult && (
<ErrorBoundaryAlert>{this.renderGraphPanel(width)}</ErrorBoundaryAlert>
<ErrorBoundaryAlert>{this.renderGraphPanel()}</ErrorBoundaryAlert>
)}
{showTable && <ErrorBoundaryAlert>{this.renderTablePanel(width)}</ErrorBoundaryAlert>}
{showLogs && <ErrorBoundaryAlert>{this.renderLogsPanel(width)}</ErrorBoundaryAlert>}
{showLogs && <ErrorBoundaryAlert>{this.renderLogsPanel()}</ErrorBoundaryAlert>}
{showNodeGraph && <ErrorBoundaryAlert>{this.renderNodeGraphPanel()}</ErrorBoundaryAlert>}
{showTrace && <ErrorBoundaryAlert>{this.renderTraceViewPanel()}</ErrorBoundaryAlert>}
</>

View File

@ -9,54 +9,61 @@ import {
Field,
FieldColorModeId,
FieldConfigSource,
getFrameDisplayName,
GrafanaTheme,
TimeZone,
} from '@grafana/data';
import {
Collapse,
DrawStyle,
GraphNGLegendEvent,
Icon,
LegendDisplayMode,
PanelContext,
PanelContextProvider,
SeriesVisibilityChangeMode,
TimeSeries,
TooltipDisplayMode,
TooltipPlugin,
useStyles,
useTheme2,
ZoomPlugin,
TooltipDisplayMode,
TimeSeries,
} from '@grafana/ui';
import { defaultGraphConfig, getGraphFieldConfig } from 'app/plugins/panel/timeseries/config';
import { hideSeriesConfigFactory } from 'app/plugins/panel/timeseries/overrides/hideSeriesConfigFactory';
import { ContextMenuPlugin } from 'app/plugins/panel/timeseries/plugins/ContextMenuPlugin';
import { ExemplarsPlugin } from 'app/plugins/panel/timeseries/plugins/ExemplarsPlugin';
import { css, cx } from '@emotion/css';
import React, { useCallback, useMemo, useState, useRef } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { splitOpen } from './state/main';
import { getFieldLinksForExplore } from './utils/links';
import { usePrevious } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import appEvents from 'app/core/app_events';
import { seriesVisibilityConfigFactory } from '../dashboard/dashgrid/SeriesVisibilityConfigFactory';
import { identity } from 'lodash';
const MAX_NUMBER_OF_TIME_SERIES = 20;
interface Props {
data: DataFrame[];
height: number;
annotations?: DataFrame[];
isLoading: boolean;
width: number;
absoluteRange: AbsoluteTimeRange;
timeZone: TimeZone;
onUpdateTimeRange: (absoluteRange: AbsoluteTimeRange) => void;
splitOpenFn: typeof splitOpen;
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
tooltipDisplayMode: TooltipDisplayMode;
splitOpenFn?: typeof splitOpen;
}
export function ExploreGraphNGPanel({
width,
data,
height,
timeZone,
absoluteRange,
onUpdateTimeRange,
isLoading,
annotations,
tooltipDisplayMode,
splitOpenFn,
onHiddenSeriesChanged,
}: Props) {
const theme = useTheme2();
const [showAllTimeSeries, setShowAllTimeSeries] = useState(false);
@ -107,13 +114,18 @@ export function ExploreGraphNGPanel({
});
}, [fieldConfig, data, timeZone, theme]);
const onLegendClick = useCallback(
(event: GraphNGLegendEvent) => {
setBaseStructureRev((r) => r + 1);
setFieldConfig(hideSeriesConfigFactory(event, fieldConfig, data));
},
[fieldConfig, data]
);
useEffect(() => {
if (onHiddenSeriesChanged) {
const hiddenFrames: string[] = [];
dataWithConfig.forEach((frame) => {
const allFieldsHidden = frame.fields.map((field) => field.config?.custom?.hideFrom?.viz).every(identity);
if (allFieldsHidden) {
hiddenFrames.push(getFrameDisplayName(frame));
}
});
onHiddenSeriesChanged(hiddenFrames);
}
}, [dataWithConfig, onHiddenSeriesChanged]);
const seriesToShow = showAllTimeSeries ? dataWithConfig : dataWithConfig.slice(0, MAX_NUMBER_OF_TIME_SERIES);
@ -121,8 +133,16 @@ export function ExploreGraphNGPanel({
return getFieldLinksForExplore({ field, rowIndex, splitOpenFn, range: timeRange });
};
const panelContext: PanelContext = {
eventBus: appEvents,
onToggleSeriesVisibility(label: string, mode: SeriesVisibilityChangeMode) {
setBaseStructureRev((r) => r + 1);
setFieldConfig(seriesVisibilityConfigFactory(label, mode, fieldConfig, data));
},
};
return (
<>
<PanelContextProvider value={panelContext}>
{dataWithConfig.length > MAX_NUMBER_OF_TIME_SERIES && !showAllTimeSeries && (
<div className={cx([style.timeSeriesDisclaimer])}>
<Icon className={style.disclaimerIcon} name="exclamation-triangle" />
@ -136,43 +156,43 @@ export function ExploreGraphNGPanel({
>{`Show all ${dataWithConfig.length}`}</span>
</div>
)}
<Collapse label="Graph" loading={isLoading} isOpen>
<TimeSeries
frames={seriesToShow}
structureRev={structureRev}
width={width}
height={400}
timeRange={timeRange}
onLegendClick={onLegendClick}
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
timeZone={timeZone}
>
{(config, alignedDataFrame) => {
return (
<>
<ZoomPlugin config={config} onZoom={onUpdateTimeRange} />
<TooltipPlugin
config={config}
data={alignedDataFrame}
mode={TooltipDisplayMode.Single}
timeZone={timeZone}
/>
<ContextMenuPlugin config={config} data={alignedDataFrame} timeZone={timeZone} />
{annotations && (
<ExemplarsPlugin
<AutoSizer disableHeight>
{({ width }) => (
<TimeSeries
frames={seriesToShow}
structureRev={structureRev}
width={width}
height={height}
timeRange={timeRange}
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
timeZone={timeZone}
>
{(config, alignedDataFrame) => {
return (
<>
<ZoomPlugin config={config} onZoom={onUpdateTimeRange} />
<TooltipPlugin
config={config}
exemplars={annotations}
data={alignedDataFrame}
mode={tooltipDisplayMode}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
/>
)}
</>
);
}}
</TimeSeries>
</Collapse>
</>
<ContextMenuPlugin config={config} data={alignedDataFrame} timeZone={timeZone} />
{annotations && (
<ExemplarsPlugin
config={config}
exemplars={annotations}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
/>
)}
</>
);
}}
</TimeSeries>
)}
</AutoSizer>
</PanelContextProvider>
);
}

View File

@ -1,172 +0,0 @@
import React, { PureComponent } from 'react';
import { css, cx } from '@emotion/css';
import { GrafanaTheme, TimeZone, AbsoluteTimeRange, GraphSeriesXY, dateTime } from '@grafana/data';
import {
Themeable,
GraphWithLegend,
LegendDisplayMode,
withTheme,
Collapse,
GraphSeriesToggler,
GraphSeriesTogglerAPI,
VizTooltip,
Icon,
TooltipDisplayMode,
} from '@grafana/ui';
const MAX_NUMBER_OF_TIME_SERIES = 20;
const getStyles = (theme: GrafanaTheme) => ({
timeSeriesDisclaimer: css`
label: time-series-disclaimer;
width: 300px;
margin: ${theme.spacing.sm} auto;
padding: 10px 0;
border-radius: ${theme.border.radius.md};
text-align: center;
background-color: ${theme.isLight ? theme.palette.white : theme.palette.dark4};
`,
disclaimerIcon: css`
label: disclaimer-icon;
color: ${theme.palette.yellow};
margin-right: ${theme.spacing.xs};
`,
showAllTimeSeries: css`
label: show-all-time-series;
cursor: pointer;
color: ${theme.colors.linkExternal};
`,
});
interface Props extends Themeable {
ariaLabel?: string;
series?: GraphSeriesXY[] | null;
width: number;
absoluteRange: AbsoluteTimeRange;
loading?: boolean;
showPanel: boolean;
showBars: boolean;
showLines: boolean;
isStacked: boolean;
timeZone?: TimeZone;
onUpdateTimeRange: (absoluteRange: AbsoluteTimeRange) => void;
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
}
interface State {
hiddenSeries: string[];
showAllTimeSeries: boolean;
}
class UnThemedExploreGraphPanel extends PureComponent<Props, State> {
state: State = {
hiddenSeries: [],
showAllTimeSeries: false,
};
onShowAllTimeSeries = () => {
this.setState({
showAllTimeSeries: true,
});
};
onChangeTime = (from: number, to: number) => {
const { onUpdateTimeRange } = this.props;
onUpdateTimeRange({ from, to });
};
renderGraph = () => {
const {
ariaLabel,
width,
series,
onHiddenSeriesChanged,
timeZone,
absoluteRange,
showPanel,
showBars,
showLines,
isStacked,
} = this.props;
const { showAllTimeSeries } = this.state;
if (!series) {
return null;
}
const timeRange = {
from: dateTime(absoluteRange.from),
to: dateTime(absoluteRange.to),
raw: {
from: dateTime(absoluteRange.from),
to: dateTime(absoluteRange.to),
},
};
const height = showPanel ? 200 : 100;
const lineWidth = showLines ? 1 : 5;
const seriesToShow = showAllTimeSeries ? series : series.slice(0, MAX_NUMBER_OF_TIME_SERIES);
return (
<GraphSeriesToggler series={seriesToShow} onHiddenSeriesChanged={onHiddenSeriesChanged}>
{({ onSeriesToggle, toggledSeries }: GraphSeriesTogglerAPI) => {
return (
<GraphWithLegend
ariaLabel={ariaLabel}
legendDisplayMode={LegendDisplayMode.List}
height={height}
placement={'bottom'}
width={width}
timeRange={timeRange}
timeZone={timeZone}
showBars={showBars}
showLines={showLines}
showPoints={false}
onToggleSort={() => {}}
series={toggledSeries}
isStacked={isStacked}
lineWidth={lineWidth}
onSeriesToggle={onSeriesToggle}
onHorizontalRegionSelected={this.onChangeTime}
>
{/* For logs we are using mulit mode until we refactor logs histogram to use barWidth instead of lineWidth to render bars */}
<VizTooltip mode={showBars ? TooltipDisplayMode.Multi : TooltipDisplayMode.Single} />
</GraphWithLegend>
);
}}
</GraphSeriesToggler>
);
};
render() {
const { series, showPanel, loading, theme } = this.props;
const { showAllTimeSeries } = this.state;
const style = getStyles(theme);
return (
<>
{series && series.length > MAX_NUMBER_OF_TIME_SERIES && !showAllTimeSeries && (
<div className={cx([style.timeSeriesDisclaimer])}>
<Icon className={style.disclaimerIcon} name="exclamation-triangle" />
{`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `}
<span
className={cx([style.showAllTimeSeries])}
onClick={this.onShowAllTimeSeries}
>{`Show all ${series.length}`}</span>
</div>
)}
{showPanel && (
<Collapse label="Graph" loading={loading} isOpen>
{this.renderGraph()}
</Collapse>
)}
{!showPanel && this.renderGraph()}
</>
);
}
}
export const ExploreGraphPanel = withTheme(UnThemedExploreGraphPanel);
ExploreGraphPanel.displayName = 'ExploreGraphPanel';

View File

@ -14,11 +14,11 @@ import {
LogsDedupDescription,
LogsMetaItem,
LogsSortOrder,
GraphSeriesXY,
LinkModel,
Field,
GrafanaTheme,
DataQuery,
DataFrame,
} from '@grafana/data';
import {
RadioButtonGroup,
@ -29,13 +29,14 @@ import {
InlineSwitch,
withTheme,
stylesFactory,
TooltipDisplayMode,
} from '@grafana/ui';
import store from 'app/core/store';
import { dedupLogRows, filterLogLevels } from 'app/core/logs_model';
import { ExploreGraphPanel } from './ExploreGraphPanel';
import { LogsMetaRow } from './LogsMetaRow';
import LogsNavigation from './LogsNavigation';
import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
import { ExploreGraphNGPanel } from './ExploreGraphNGPanel';
const SETTINGS_KEYS = {
showLabels: 'grafana.explore.logs.showLabels',
@ -46,10 +47,9 @@ const SETTINGS_KEYS = {
interface Props {
logRows: LogRowModel[];
logsMeta?: LogsMetaItem[];
logsSeries?: GraphSeriesXY[];
logsSeries?: DataFrame[];
logsQueries?: DataQuery[];
visibleRange?: AbsoluteTimeRange;
width: number;
theme: GrafanaTheme;
highlighterExpressions?: string[];
loading: boolean;
@ -240,7 +240,6 @@ export class UnthemedLogs extends PureComponent<Props, State> {
scanning,
scanRange,
showContextToggle,
width,
absoluteRange,
onChangeTime,
getFieldLinks,
@ -276,19 +275,17 @@ export class UnthemedLogs extends PureComponent<Props, State> {
<div className={styles.infoText}>
This datasource does not support full-range histograms. The graph is based on the logs seen in the response.
</div>
<ExploreGraphPanel
series={logsSeries || []}
width={width}
onHiddenSeriesChanged={this.onToggleLogLevel}
loading={loading}
absoluteRange={visibleRange || absoluteRange}
isStacked={true}
showPanel={false}
timeZone={timeZone}
showBars={true}
showLines={false}
onUpdateTimeRange={onChangeTime}
/>
{logsSeries && logsSeries.length ? (
<ExploreGraphNGPanel
data={logsSeries}
height={150}
tooltipDisplayMode={TooltipDisplayMode.Multi}
absoluteRange={visibleRange || absoluteRange}
timeZone={timeZone}
onUpdateTimeRange={onChangeTime}
onHiddenSeriesChanged={this.onToggleLogLevel}
/>
) : undefined}
<div className={styles.logOptions} ref={this.topLogsRef}>
<InlineFieldRow>
<InlineField label="Time" transparent>

View File

@ -19,7 +19,6 @@ import { getFieldLinksForExplore } from './utils/links';
interface LogsContainerProps extends PropsFromRedux {
exploreId: ExploreId;
scanRange?: RawTimeRange;
width: number;
syncedTimes: boolean;
onClickFilterLabel?: (key: string, value: string) => void;
onClickFilterOutLabel?: (key: string, value: string) => void;
@ -75,7 +74,6 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
visibleRange,
scanning,
range,
width,
isLive,
exploreId,
addResultsToCache,
@ -134,7 +132,6 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
scanning={scanning}
scanRange={range.raw}
showContextToggle={this.showContextToggle}
width={width}
getRowContext={this.getLogRowContext}
getFieldLinks={this.getFieldLinks}
addResultsToCache={() => addResultsToCache(exploreId)}

View File

@ -1,3 +1,5 @@
import { DrawStyle, StackingMode } from '@grafana/ui';
jest.mock('@grafana/data/src/datetime/formatter', () => ({
dateTimeFormat: () => 'format() jest mocked',
dateTimeFormatTimeAgo: (ts: any) => 'fromNow() jest mocked',
@ -349,35 +351,34 @@ describe('decorateWithLogsResult', () => {
],
series: [
{
label: 'unknown',
color: '#8e8e8e',
data: [[0, 3]],
isVisible: true,
yAxis: {
index: 1,
min: 0,
tickDecimals: 0,
},
seriesIndex: 0,
timeField: {
name: 'Time',
type: 'time',
config: {},
values: new ArrayVector([0]),
index: 0,
display: expect.anything(),
},
valueField: {
name: 'unknown',
type: 'number',
config: { unit: undefined, color: '#8e8e8e' },
values: new ArrayVector([3]),
labels: undefined,
index: 1,
display: expect.anything(),
state: expect.anything(),
},
timeStep: 0,
name: 'unknown',
length: 1,
fields: [
{ name: 'Time', type: 'time', values: new ArrayVector([0]), config: {} },
{
name: 'Value',
type: 'number',
labels: undefined,
values: new ArrayVector([3]),
config: {
min: 0,
decimals: 0,
unit: undefined,
custom: {
drawStyle: DrawStyle.Bars,
barAlignment: 0,
barMaxWidth: 5,
barWidthFactor: 0.9,
lineColor: '#8e8e8e',
fillColor: '#8e8e8e',
pointColor: '#8e8e8e',
lineWidth: 0,
fillOpacity: 100,
stacking: { mode: StackingMode.Normal, group: 'A' },
},
},
},
],
},
],
visibleRange: undefined,

View File

@ -137,15 +137,8 @@ export const decorateWithLogsResult = (
return { ...data, logsResult: null };
}
const timeZone = data.request?.timezone ?? 'browser';
const intervalMs = data.request?.intervalMs;
const newResults = dataFrameToLogsModel(
data.logsFrames,
intervalMs,
timeZone,
options.absoluteRange,
options.queries
);
const newResults = dataFrameToLogsModel(data.logsFrames, intervalMs, options.absoluteRange, options.queries);
const sortOrder = refreshIntervalToSortOrder(options.refreshInterval);
const sortedNewResults = sortLogsResult(newResults, sortOrder);
const rows = sortedNewResults.rows;

View File

@ -100,7 +100,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
exportLogsAsTxt = () => {
const { data, panel } = this.props;
const logsModel = dataFrameToLogsModel(data || [], undefined, 'utc');
const logsModel = dataFrameToLogsModel(data || [], undefined);
let textToDownload = '';
logsModel.meta?.forEach((metaItem) => {

View File

@ -20,7 +20,7 @@ export const LogsPanel: React.FunctionComponent<LogsPanelProps> = ({
);
}
const newResults = data ? dataFrameToLogsModel(data.series, data.request?.intervalMs, timeZone) : null;
const newResults = data ? dataFrameToLogsModel(data.series, data.request?.intervalMs) : null;
const logRows = newResults?.rows || [];
const deduplicatedRows = dedupLogRows(logRows, dedupStrategy);