Commit 4b344032 by Hugo Häggmark Committed by GitHub

Explore: Replaces TimeSeries with GraphSeriesXY (#18475)

* Wip: Compiles and runs

* WIP: Logs Graph partially working

* Refactor: Adds GraphSeriesToggler

* Refactor: Adds tickDecimals to YAxis

* Refactor: Adds TimeZone and PlotSelection to Graph

* Refactor: Makes the graphResult work in Explore

* Refactor: Adds ExploreGraphPanel that is used by Logs and Explore

* Fix: Fixes strange behaviour with ExploreMode not beeing changed

* Fix: Adds onSelectionChanged to GraphWithLegend

* Refactor: Cleans up unused comments

* ExploreGraph: Disable colorpicker
parent 8fd153ed
import { DisplayValue } from './displayValue';
export interface YAxis {
index: number;
min?: number;
tickDecimals?: number;
}
export type GraphSeriesValue = number | null;
/** View model projection of a series */
......@@ -9,7 +15,7 @@ export interface GraphSeriesXY {
data: GraphSeriesValue[][]; // [x,y][]
info?: DisplayValue[]; // Legend info
isVisible: boolean;
yAxis: number;
yAxis: YAxis;
}
export interface CreatePlotOverlay {
......
import { Labels, TimeSeries } from './data';
import { Labels } from './data';
import { GraphSeriesXY } from './graph';
/**
* Mapping of log level abbreviation to canonical log level.
......@@ -54,7 +55,7 @@ export interface LogsModel {
hasUniqueLabels: boolean;
meta?: LogsMetaItem[];
rows: LogRowModel[];
series?: TimeSeries[];
series?: GraphSeriesXY[];
}
export interface LogSearchMatch {
......
......@@ -4,7 +4,7 @@ import isString from 'lodash/isString';
import isBoolean from 'lodash/isBoolean';
// Types
import { DataFrame, Field, TimeSeries, FieldType, TableData, Column } from '../types/index';
import { DataFrame, Field, TimeSeries, FieldType, TableData, Column, GraphSeriesXY } from '../types/index';
import { isDateTime } from './moment_wrapper';
function convertTableToDataFrame(table: TableData): DataFrame {
......@@ -44,6 +44,23 @@ function convertTimeSeriesToDataFrame(timeSeries: TimeSeries): DataFrame {
};
}
function convertGraphSeriesToDataFrame(graphSeries: GraphSeriesXY): DataFrame {
return {
name: graphSeries.label,
fields: [
{
name: graphSeries.label || 'Value',
},
{
name: 'Time',
type: FieldType.time,
unit: 'dateTimeAsIso',
},
],
rows: graphSeries.data,
};
}
// PapaParse Dynamic Typing regex:
// https://github.com/mholt/PapaParse/blob/master/papaparse.js#L998
const NUMBER = /^\s*-?(\d*\.?\d+|\d+\.?\d*)(e[-+]?\d+)?\s*$/i;
......@@ -145,6 +162,9 @@ export const toDataFrame = (data: any): DataFrame => {
if (data.hasOwnProperty('datapoints')) {
return convertTimeSeriesToDataFrame(data);
}
if (data.hasOwnProperty('data')) {
return convertGraphSeriesToDataFrame(data);
}
if (data.hasOwnProperty('columns')) {
return convertTableToDataFrame(data);
}
......
......@@ -4,16 +4,20 @@ import React, { PureComponent } from 'react';
import uniqBy from 'lodash/uniqBy';
// Types
import { TimeRange, GraphSeriesXY } from '@grafana/data';
import { TimeRange, GraphSeriesXY, AbsoluteTimeRange, TimeZone, DefaultTimeZone } from '@grafana/data';
export interface GraphProps {
series: GraphSeriesXY[];
timeRange: TimeRange; // NOTE: we should aim to make `time` a property of the axis, not force it for all graphs
timeZone: TimeZone; // NOTE: we should aim to make `time` a property of the axis, not force it for all graphs
showLines?: boolean;
showPoints?: boolean;
showBars?: boolean;
width: number;
height: number;
isStacked?: boolean;
lineWidth?: number;
onSelectionChanged?: (range: AbsoluteTimeRange) => void;
}
export class Graph extends PureComponent<GraphProps> {
......@@ -21,9 +25,12 @@ export class Graph extends PureComponent<GraphProps> {
showLines: true,
showPoints: false,
showBars: false,
isStacked: false,
lineWidth: 1,
};
element: HTMLElement | null = null;
$element: any;
componentDidUpdate() {
this.draw();
......@@ -31,14 +38,32 @@ export class Graph extends PureComponent<GraphProps> {
componentDidMount() {
this.draw();
if (this.element) {
this.$element = $(this.element);
this.$element.bind('plotselected', this.onPlotSelected);
}
}
componentWillUnmount() {
this.$element.unbind('plotselected', this.onPlotSelected);
}
onPlotSelected = (event: JQueryEventObject, ranges: { xaxis: { from: number; to: number } }) => {
const { onSelectionChanged } = this.props;
if (onSelectionChanged) {
onSelectionChanged({
from: ranges.xaxis.from,
to: ranges.xaxis.to,
});
}
};
draw() {
if (this.element === null) {
return;
}
const { width, series, timeRange, showLines, showBars, showPoints } = this.props;
const { width, series, timeRange, showLines, showBars, showPoints, isStacked, lineWidth, timeZone } = this.props;
if (!width) {
return;
......@@ -49,10 +74,16 @@ export class Graph extends PureComponent<GraphProps> {
const max = timeRange.to.valueOf();
const yaxes = uniqBy(
series.map(s => {
const index = s.yAxis ? s.yAxis.index : 1;
const min = s.yAxis && !isNaN(s.yAxis.min as number) ? s.yAxis.min : null;
const tickDecimals = s.yAxis && !isNaN(s.yAxis.tickDecimals as number) ? s.yAxis.tickDecimals : null;
return {
show: true,
index: s.yAxis,
position: s.yAxis === 1 ? 'left' : 'right',
index,
position: index === 1 ? 'left' : 'right',
min,
tickDecimals,
};
}),
yAxisConfig => yAxisConfig.index
......@@ -62,9 +93,10 @@ export class Graph extends PureComponent<GraphProps> {
show: false,
},
series: {
stack: isStacked,
lines: {
show: showLines,
linewidth: 1,
linewidth: lineWidth,
zero: false,
},
points: {
......@@ -78,7 +110,7 @@ export class Graph extends PureComponent<GraphProps> {
fill: 1,
barWidth: 1,
zero: false,
lineWidth: 0,
lineWidth: lineWidth,
},
shadowSize: 0,
},
......@@ -89,6 +121,7 @@ export class Graph extends PureComponent<GraphProps> {
label: 'Datetime',
ticks: ticks,
timeformat: timeFormat(ticks, min, max),
timezone: timeZone ? timeZone : DefaultTimeZone,
},
yaxes,
grid: {
......@@ -102,6 +135,10 @@ export class Graph extends PureComponent<GraphProps> {
margin: { left: 0, right: 0 },
labelMarginX: 0,
},
selection: {
mode: 'x',
color: '#666',
},
};
try {
......@@ -113,9 +150,10 @@ export class Graph extends PureComponent<GraphProps> {
}
render() {
const { height } = this.props;
return (
<div className="graph-panel">
<div className="graph-panel__chart" ref={e => (this.element = e)} />
<div className="graph-panel__chart" ref={e => (this.element = e)} style={{ height }} />
</div>
);
}
......
......@@ -74,7 +74,7 @@ GraphWithLegendStories.add('default', () => {
.map(s => s.trim())
.indexOf(s.label.split('-')[0]) > -1
) {
s.yAxis = 2;
s.yAxis = { index: 2 };
}
return s;
......
......@@ -2,7 +2,7 @@
import _ from 'lodash';
import React from 'react';
import { css } from 'emotion';
import { GraphSeriesValue } from '@grafana/data';
import { GraphSeriesValue, AbsoluteTimeRange } from '@grafana/data';
import { Graph, GraphProps } from './Graph';
import { LegendRenderOptions, LegendItem, LegendDisplayMode } from '../Legend/Legend';
......@@ -18,10 +18,11 @@ export interface GraphWithLegendProps extends GraphProps, LegendRenderOptions {
displayMode: LegendDisplayMode;
sortLegendBy?: string;
sortLegendDesc?: boolean;
onSeriesColorChange: SeriesColorChangeHandler;
onSeriesColorChange?: SeriesColorChangeHandler;
onSeriesAxisToggle?: SeriesAxisToggleHandler;
onSeriesToggle?: (label: string, event: React.MouseEvent<HTMLElement>) => void;
onToggleSort: (sortBy: string) => void;
onSelectionChanged?: (range: AbsoluteTimeRange) => void;
}
const getGraphWithLegendStyles = ({ placement }: GraphWithLegendProps) => ({
......@@ -67,6 +68,10 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
onToggleSort,
hideEmpty,
hideZero,
isStacked,
lineWidth,
onSelectionChanged,
timeZone,
} = props;
const { graphContainer, wrapper, legendContainer } = getGraphWithLegendStyles(props);
......@@ -78,7 +83,7 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
label: s.label,
color: s.color,
isVisible: s.isVisible,
yAxis: s.yAxis,
yAxis: s.yAxis.index,
displayValues: s.info || [],
},
]);
......@@ -90,12 +95,16 @@ export const GraphWithLegend: React.FunctionComponent<GraphWithLegendProps> = (p
<Graph
series={series.filter(s => !!s.isVisible)}
timeRange={timeRange}
timeZone={timeZone}
showLines={showLines}
showPoints={showPoints}
showBars={showBars}
width={width}
height={height}
key={isLegendVisible ? 'legend-visible' : 'legend-invisible'}
isStacked={isStacked}
lineWidth={lineWidth}
onSelectionChanged={onSelectionChanged}
/>
</div>
......
import { GraphWithLegendProps } from './GraphWithLegend';
import { LegendDisplayMode } from '../Legend/Legend';
import { dateTime } from '@grafana/data';
// import { LegendList } from '../Legend/LegendList';
import { dateTime, DefaultTimeZone } from '@grafana/data';
export const mockGraphWithLegendData = ({
displayMode,
......@@ -1099,7 +1098,9 @@ export const mockGraphWithLegendData = ({
{ title: 'max', text: '18.42', numeric: 18.427101844163694 },
],
isVisible: true,
yAxis: 1,
yAxis: {
index: 1,
},
},
{
label: 'B-series',
......@@ -2191,7 +2192,9 @@ export const mockGraphWithLegendData = ({
{ title: 'max', text: '18.42', numeric: 18.427101844163694 },
],
isVisible: true,
yAxis: 1,
yAxis: {
index: 1,
},
},
{
label: 'C-series',
......@@ -3283,7 +3286,9 @@ export const mockGraphWithLegendData = ({
{ title: 'max', text: '18.42', numeric: 18.427101844163694 },
],
isVisible: true,
yAxis: 1,
yAxis: {
index: 1,
},
},
],
timeRange: {
......@@ -3313,4 +3318,5 @@ export const mockGraphWithLegendData = ({
},
onToggleSort: () => {},
displayMode: displayMode || LegendDisplayMode.List,
timeZone: DefaultTimeZone,
});
import { ComponentClass, ComponentType } from 'react';
import { LoadingState, DataFrame, TimeRange } from '@grafana/data';
import { LoadingState, DataFrame, TimeRange, TimeZone } from '@grafana/data';
import { ScopedVars, DataQueryRequest, DataQueryError, LegacyResponseData } from './datasource';
import { PluginMeta, GrafanaPlugin } from './plugin';
......@@ -27,6 +27,7 @@ export interface PanelProps<T = any> {
// TODO: annotation?: PanelData;
timeRange: TimeRange;
timeZone: TimeZone;
options: T;
onOptionsChange: (options: T) => void;
renderCounter: number;
......
......@@ -169,7 +169,7 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi
timeColumn < 0
? undefined
: getFlotPairs({
series,
rows: series.rows,
xIndex: timeColumn,
yIndex: i,
nullValueMode: NullValueMode.Null,
......
import { getFlotPairs } from './flotPairs';
describe('getFlotPairs', () => {
const series = {
fields: [],
rows: [[1, 100, 'a'], [2, 200, 'b'], [3, 300, 'c']],
};
const rows = [[1, 100, 'a'], [2, 200, 'b'], [3, 300, 'c']];
it('should get X and y', () => {
const pairs = getFlotPairs({ series, xIndex: 0, yIndex: 1 });
const pairs = getFlotPairs({ rows, xIndex: 0, yIndex: 1 });
expect(pairs.length).toEqual(3);
expect(pairs[0].length).toEqual(2);
......@@ -15,7 +13,7 @@ describe('getFlotPairs', () => {
});
it('should work with strings', () => {
const pairs = getFlotPairs({ series, xIndex: 0, yIndex: 2 });
const pairs = getFlotPairs({ rows, xIndex: 0, yIndex: 2 });
expect(pairs.length).toEqual(3);
expect(pairs[0].length).toEqual(2);
......
// Types
import { NullValueMode, DataFrame, GraphSeriesValue } from '@grafana/data';
import { NullValueMode, GraphSeriesValue } from '@grafana/data';
export interface FlotPairsOptions {
series: DataFrame;
rows: any[][];
xIndex: number;
yIndex: number;
nullValueMode?: NullValueMode;
}
export function getFlotPairs({ series, xIndex, yIndex, nullValueMode }: FlotPairsOptions): GraphSeriesValue[][] {
const rows = series.rows;
export function getFlotPairs({ rows, xIndex, yIndex, nullValueMode }: FlotPairsOptions): GraphSeriesValue[][] {
const ignoreNulls = nullValueMode === NullValueMode.Ignore;
const nullAsZero = nullValueMode === NullValueMode.AsZero;
......
import _ from 'lodash';
import ansicolor from 'vendor/ansicolor/ansicolor';
import { colors } from '@grafana/ui';
import { colors, getFlotPairs } from '@grafana/ui';
import {
TimeSeries,
Labels,
LogLevel,
DataFrame,
findCommonLabels,
findUniqueLabels,
getLogLevel,
toLegacyResponseData,
FieldCache,
FieldType,
getLogLevelFromKey,
......@@ -22,10 +20,15 @@ import {
LogsParser,
LogLabelStatsModel,
LogsDedupStrategy,
GraphSeriesXY,
LoadingState,
dateTime,
toUtc,
NullValueMode,
} from '@grafana/data';
import { getThemeColor } from 'app/core/utils/colors';
import { hasAnsiCodes } from 'app/core/utils/text';
import { dateTime, toUtc } from '@grafana/data';
import { getGraphSeriesModel } from 'app/plugins/panel/graph2/getGraphSeriesModel';
export const LogLevelColor = {
[LogLevel.critical]: colors[7],
......@@ -192,7 +195,7 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set<LogLevel>)
};
}
export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): TimeSeries[] {
export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): GraphSeriesXY[] {
// currently interval is rangeMs / resolution, which is too low for showing series as bars.
// need at least 10px per bucket, so we multiply interval by 10. Should be solved higher up the chain
// when executing queries & interval calculated and not here but this is a temporary fix.
......@@ -242,12 +245,26 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Time
return a[1] - b[1];
});
return {
datapoints: series.datapoints,
target: series.alias,
alias: series.alias,
const points = getFlotPairs({
rows: series.datapoints,
xIndex: 1,
yIndex: 0,
nullValueMode: NullValueMode.Null,
});
const graphSeries: GraphSeriesXY = {
color: series.color,
label: series.alias,
data: points,
isVisible: true,
yAxis: {
index: 1,
min: 0,
tickDecimals: 0,
},
};
return graphSeries;
});
}
......@@ -273,10 +290,16 @@ export function dataFrameToLogsModel(dataFrame: DataFrame[], intervalMs: number)
if (metricSeries.length === 0) {
logsModel.series = makeSeriesForLogs(logsModel.rows, intervalMs);
} else {
logsModel.series = [];
for (const series of metricSeries) {
logsModel.series.push(toLegacyResponseData(series) as TimeSeries);
}
logsModel.series = getGraphSeriesModel(
{ series: metricSeries, state: LoadingState.Done },
{},
{ showBars: true, showLines: false, showPoints: false },
{
asTable: false,
isVisible: true,
placement: 'under',
}
);
}
return logsModel;
......
......@@ -178,7 +178,7 @@ export class PanelChrome extends PureComponent<Props, State> {
queries: panel.targets,
panelId: panel.id,
dashboardId: this.props.dashboard.id,
timezone: this.props.dashboard.timezone,
timezone: this.props.dashboard.getTimezone(),
timeRange: timeData.timeRange,
timeInfo: timeData.timeInfo,
widthPixels: width,
......@@ -251,6 +251,7 @@ export class PanelChrome extends PureComponent<Props, State> {
id={panel.id}
data={data}
timeRange={data.request ? data.request.range : this.timeSrv.timeRange()}
timeZone={this.props.dashboard.getTimezone()}
options={panel.getOptions()}
transparent={panel.transparent}
width={width - theme.panelPadding * 2}
......
......@@ -12,7 +12,6 @@ import store from 'app/core/store';
// Components
import { Alert } from './Error';
import ErrorBoundary from './ErrorBoundary';
import GraphContainer from './GraphContainer';
import LogsContainer from './LogsContainer';
import QueryRows from './QueryRows';
import TableContainer from './TableContainer';
......@@ -30,7 +29,7 @@ import {
} from './state/actions';
// Types
import { RawTimeRange } from '@grafana/data';
import { RawTimeRange, GraphSeriesXY } from '@grafana/data';
import { DataQuery, ExploreStartPageProps, DataSourceApi, DataQueryError } from '@grafana/ui';
import {
......@@ -56,6 +55,7 @@ import { FadeIn } from 'app/core/components/Animations/FadeIn';
import { getTimeZone } from '../profile/state/selectors';
import { ErrorContainer } from './ErrorContainer';
import { scanStopAction } from './state/actionTypes';
import ExploreGraphPanel from './ExploreGraphPanel';
interface ExploreProps {
StartPage?: ComponentClass<ExploreStartPageProps>;
......@@ -87,6 +87,7 @@ interface ExploreProps {
queryErrors: DataQueryError[];
isLive: boolean;
updateTimeRange: typeof updateTimeRange;
graphResult?: GraphSeriesXY[];
}
/**
......@@ -192,7 +193,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
refreshExplore = () => {
const { exploreId, update } = this.props;
if (update.queries || update.ui || update.range || update.datasource) {
if (update.queries || update.ui || update.range || update.datasource || update.mode) {
this.props.refreshExplore(exploreId);
}
};
......@@ -225,6 +226,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
queryKeys,
queryErrors,
mode,
graphResult,
} = this.props;
const exploreClass = split ? 'explore explore-split' : 'explore';
......@@ -259,7 +261,9 @@ export class Explore extends React.PureComponent<ExploreProps> {
{showingStartPage && <StartPage onClickExample={this.onClickExample} />}
{!showingStartPage && (
<>
{mode === ExploreMode.Metrics && <GraphContainer width={width} exploreId={exploreId} />}
{mode === ExploreMode.Metrics && (
<ExploreGraphPanel exploreId={exploreId} series={graphResult} width={width} />
)}
{mode === ExploreMode.Metrics && (
<TableContainer exploreId={exploreId} onClickCell={this.onClickLabel} />
)}
......@@ -306,6 +310,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
isLive,
supportedModes,
mode,
graphResult,
} = item;
const { datasource, queries, range: urlRange, mode: urlMode, ui } = (urlState || {}) as ExploreUrlState;
......@@ -318,15 +323,15 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
const urlModeIsValid = supportedModes.includes(urlMode);
const modeStateIsValid = supportedModes.includes(mode);
if (urlModeIsValid) {
newMode = urlMode;
} else if (modeStateIsValid) {
if (modeStateIsValid) {
newMode = mode;
} else if (urlModeIsValid) {
newMode = urlMode;
} else {
newMode = supportedModes[0];
}
} else {
newMode = [ExploreMode.Metrics, ExploreMode.Logs].includes(urlMode) ? urlMode : ExploreMode.Metrics;
newMode = [ExploreMode.Metrics, ExploreMode.Logs].includes(mode) ? mode : ExploreMode.Metrics;
}
const initialUI = ui || DEFAULT_UI_STATE;
......@@ -349,6 +354,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
initialUI,
queryErrors,
isLive,
graphResult,
};
}
......
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { LegendDisplayMode, GraphWithLegend } from '@grafana/ui';
import { TimeZone, AbsoluteTimeRange, GraphSeriesXY, dateTimeForTimeZone, LoadingState } from '@grafana/data';
import { GraphSeriesToggler } from 'app/plugins/panel/graph2/GraphSeriesToggler';
import Panel from './Panel';
import { StoreState, ExploreId, ExploreMode } from 'app/types';
import { getTimeZone } from '../profile/state/selectors';
import { toggleGraph, updateTimeRange } from './state/actions';
const MAX_NUMBER_OF_TIME_SERIES = 20;
interface Props {
exploreId: ExploreId;
series: GraphSeriesXY[];
width: number;
absoluteRange?: AbsoluteTimeRange;
loading?: boolean;
mode?: ExploreMode;
showingGraph?: boolean;
showingTable?: boolean;
timeZone?: TimeZone;
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
toggleGraph: typeof toggleGraph;
updateTimeRange: typeof updateTimeRange;
}
interface State {
hiddenSeries: string[];
showAllTimeSeries: boolean;
}
export class ExploreGraphPanel extends PureComponent<Props, State> {
state: State = {
hiddenSeries: [],
showAllTimeSeries: false,
};
onShowAllTimeSeries = () => {
this.setState({
showAllTimeSeries: true,
});
};
onClickGraphButton = () => {
const { toggleGraph, exploreId, showingGraph } = this.props;
toggleGraph(exploreId, showingGraph);
};
onChangeTime = (absoluteRange: AbsoluteTimeRange) => {
const { exploreId, updateTimeRange } = this.props;
updateTimeRange({ exploreId, absoluteRange });
};
renderGraph = () => {
const {
width,
series,
onHiddenSeriesChanged,
timeZone,
absoluteRange,
mode,
showingGraph,
showingTable,
} = this.props;
const { showAllTimeSeries } = this.state;
if (!series) {
return null;
}
const timeRange = {
from: dateTimeForTimeZone(timeZone, absoluteRange.from),
to: dateTimeForTimeZone(timeZone, absoluteRange.to),
raw: {
from: dateTimeForTimeZone(timeZone, absoluteRange.from),
to: dateTimeForTimeZone(timeZone, absoluteRange.to),
},
};
const height = mode === ExploreMode.Logs ? 100 : showingGraph && showingTable ? 200 : 400;
const showBars = mode === ExploreMode.Logs ? true : false;
const showLines = mode === ExploreMode.Metrics ? true : false;
const isStacked = mode === ExploreMode.Logs ? true : false;
const lineWidth = mode === ExploreMode.Metrics ? 1 : 5;
const seriesToShow = showAllTimeSeries ? series : series.slice(0, MAX_NUMBER_OF_TIME_SERIES);
return (
<GraphSeriesToggler series={seriesToShow} onHiddenSeriesChanged={onHiddenSeriesChanged}>
{({ onSeriesToggle, toggledSeries }) => {
return (
<GraphWithLegend
displayMode={LegendDisplayMode.List}
height={height}
isLegendVisible={true}
placement={'under'}
width={width}
timeRange={timeRange}
timeZone={timeZone}
showBars={showBars}
showLines={showLines}
showPoints={false}
onToggleSort={() => {}}
series={toggledSeries}
isStacked={isStacked}
lineWidth={lineWidth}
onSeriesToggle={onSeriesToggle}
onSelectionChanged={this.onChangeTime}
/>
);
}}
</GraphSeriesToggler>
);
};
render() {
const { series, mode, showingGraph, loading } = this.props;
const { showAllTimeSeries } = this.state;
return (
<>
{series && series.length > MAX_NUMBER_OF_TIME_SERIES && !showAllTimeSeries && (
<div className="time-series-disclaimer">
<i className="fa fa-fw fa-warning disclaimer-icon" />
{`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `}
<span className="show-all-time-series" onClick={this.onShowAllTimeSeries}>{`Show all ${
series.length
}`}</span>
</div>
)}
{mode === ExploreMode.Metrics && (
<Panel label="Graph" collapsible isOpen={showingGraph} loading={loading} onToggle={this.onClickGraphButton}>
{this.renderGraph()}
</Panel>
)}
{mode === ExploreMode.Logs && this.renderGraph()}
</>
);
}
}
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) {
const explore = state.explore;
// @ts-ignore
const item: ExploreItemState = explore[exploreId];
const { loadingState, showingGraph, showingTable, absoluteRange, mode } = item;
const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming;
return {
loading,
showingGraph,
showingTable,
timeZone: getTimeZone(state.user),
absoluteRange,
mode,
};
}
const mapDispatchToProps = {
toggleGraph,
updateTimeRange,
};
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(ExploreGraphPanel)
);
import React from 'react';
import { shallow } from 'enzyme';
import { Graph } from './Graph';
import { mockData } from './__mocks__/mockData';
import { DefaultTimeZone } from '@grafana/data';
const setup = (propOverrides?: object) => {
const props = {
size: { width: 10, height: 20 },
data: mockData().slice(0, 19),
range: { from: 0, to: 1 },
timeZone: DefaultTimeZone,
...propOverrides,
};
// Enzyme.shallow did not work well with jquery.flop. Mocking the draw function.
Graph.prototype.draw = jest.fn();
const wrapper = shallow(<Graph {...props} />);
const instance = wrapper.instance() as Graph;
return {
wrapper,
instance,
};
};
describe('Render', () => {
it('should render component', () => {
const { wrapper } = setup();
expect(wrapper).toMatchSnapshot();
});
it('should render component with disclaimer', () => {
const { wrapper } = setup({
data: mockData(),
});
expect(wrapper).toMatchSnapshot();
});
it('should show query return no time series', () => {
const { wrapper } = setup({
data: [],
});
expect(wrapper).toMatchSnapshot();
});
});
import $ from 'jquery';
import React, { PureComponent } from 'react';
import difference from 'lodash/difference';
import 'vendor/flot/jquery.flot';
import 'vendor/flot/jquery.flot.time';
import 'vendor/flot/jquery.flot.selection';
import 'vendor/flot/jquery.flot.stack';
import { GraphLegend, LegendItem, LegendDisplayMode } from '@grafana/ui';
import { TimeZone, AbsoluteTimeRange } from '@grafana/data';
import TimeSeries from 'app/core/time_series2';
const MAX_NUMBER_OF_TIME_SERIES = 20;
// Copied from graph.ts
function time_format(ticks: number, min: number, max: number) {
if (min && max && ticks) {
const range = max - min;
const secPerTick = range / ticks / 1000;
const oneDay = 86400000;
const oneYear = 31536000000;
if (secPerTick <= 45) {
return '%H:%M:%S';
}
if (secPerTick <= 7200 || range <= oneDay) {
return '%H:%M';
}
if (secPerTick <= 80000) {
return '%m/%d %H:%M';
}
if (secPerTick <= 2419200 || range <= oneYear) {
return '%m/%d';
}
return '%Y-%m';
}
return '%H:%M';
}
const FLOT_OPTIONS: any = {
legend: {
show: false,
},
series: {
lines: {
linewidth: 1,
zero: false,
},
shadowSize: 0,
},
grid: {
minBorderMargin: 0,
markings: [],
backgroundColor: null,
borderWidth: 0,
// hoverable: true,
clickable: true,
color: '#a1a1a1',
margin: { left: 0, right: 0 },
labelMarginX: 0,
},
selection: {
mode: 'x',
color: '#666',
},
// crosshair: {
// mode: 'x',
// },
};
interface GraphProps {
data: any[];
height?: number;
width?: number;
id?: string;
range: AbsoluteTimeRange;
timeZone: TimeZone;
split?: boolean;
userOptions?: any;
onChangeTime?: (range: AbsoluteTimeRange) => void;
onToggleSeries?: (alias: string, hiddenSeries: string[]) => void;
}
interface GraphState {
/**
* Type parameter refers to the `alias` property of a `TimeSeries`.
* Consequently, all series sharing the same alias will share visibility state.
*/
hiddenSeries: string[];
showAllTimeSeries: boolean;
}
export class Graph extends PureComponent<GraphProps, GraphState> {
$el: any;
dynamicOptions: any = null;
state: GraphState = {
hiddenSeries: [],
showAllTimeSeries: false,
};
getGraphData(): TimeSeries[] {
const { data } = this.props;
return this.state.showAllTimeSeries ? data : data.slice(0, MAX_NUMBER_OF_TIME_SERIES);
}
componentDidMount() {
this.draw();
this.$el = $(`#${this.props.id}`);
this.$el.bind('plotselected', this.onPlotSelected);
}
componentDidUpdate(prevProps: GraphProps, prevState: GraphState) {
if (
prevProps.data !== this.props.data ||
prevProps.range !== this.props.range ||
prevProps.split !== this.props.split ||
prevProps.height !== this.props.height ||
prevProps.width !== this.props.width ||
prevState.hiddenSeries !== this.state.hiddenSeries
) {
this.draw();
}
}
componentWillUnmount() {
this.$el.unbind('plotselected', this.onPlotSelected);
}
onPlotSelected = (event: JQueryEventObject, ranges: { xaxis: { from: number; to: number } }) => {
const { onChangeTime } = this.props;
if (onChangeTime) {
this.props.onChangeTime({
from: ranges.xaxis.from,
to: ranges.xaxis.to,
});
}
};
getDynamicOptions() {
const { range, width, timeZone } = this.props;
const ticks = (width || 0) / 100;
const min = range.from;
const max = range.to;
return {
xaxis: {
mode: 'time',
min: min,
max: max,
label: 'Datetime',
ticks: ticks,
timezone: timeZone,
timeformat: time_format(ticks, min, max),
},
};
}
onShowAllTimeSeries = () => {
this.setState(
{
showAllTimeSeries: true,
},
this.draw
);
};
draw() {
const { userOptions = {} } = this.props;
const { hiddenSeries } = this.state;
const data = this.getGraphData();
const $el = $(`#${this.props.id}`);
let series = [{ data: [[0, 0]] }];
if (data && data.length > 0) {
series = data
.filter((ts: TimeSeries) => hiddenSeries.indexOf(ts.alias) === -1)
.map((ts: TimeSeries) => ({
color: ts.color,
label: ts.label,
data: ts.getFlotPairs('null'),
}));
}
this.dynamicOptions = this.getDynamicOptions();
const options = {
...FLOT_OPTIONS,
...this.dynamicOptions,
...userOptions,
};
$.plot($el, series, options);
}
getLegendItems = (): LegendItem[] => {
const { hiddenSeries } = this.state;
const data = this.getGraphData();
return data.map(series => {
return {
label: series.alias,
color: series.color,
isVisible: hiddenSeries.indexOf(series.alias) === -1,
yAxis: 1,
};
});
};
onSeriesToggle(label: string, event: React.MouseEvent<HTMLElement>) {
// This implementation is more or less a copy of GraphPanel's logic.
// TODO: we need to use Graph's panel controller or split it into smaller
// controllers to remove code duplication. Right now we cant easily use that, since Explore
// is not using DataFrame for graph yet
const exclusive = event.ctrlKey || event.metaKey || event.shiftKey;
this.setState((state, props) => {
const { data, onToggleSeries } = props;
let nextHiddenSeries: string[] = [];
if (exclusive) {
// Toggling series with key makes the series itself to toggle
if (state.hiddenSeries.indexOf(label) > -1) {
nextHiddenSeries = state.hiddenSeries.filter(series => series !== label);
} else {
nextHiddenSeries = state.hiddenSeries.concat([label]);
}
} else {
// Toggling series with out key toggles all the series but the clicked one
const allSeriesLabels = data.map(series => series.label);
if (state.hiddenSeries.length + 1 === allSeriesLabels.length) {
nextHiddenSeries = [];
} else {
nextHiddenSeries = difference(allSeriesLabels, [label]);
}
}
if (onToggleSeries) {
onToggleSeries(label, nextHiddenSeries);
}
return {
hiddenSeries: nextHiddenSeries,
};
});
}
render() {
const { height = 100, id = 'graph' } = this.props;
return (
<>
{this.props.data && this.props.data.length > MAX_NUMBER_OF_TIME_SERIES && !this.state.showAllTimeSeries && (
<div className="time-series-disclaimer">
<i className="fa fa-fw fa-warning disclaimer-icon" />
{`Showing only ${MAX_NUMBER_OF_TIME_SERIES} time series. `}
<span className="show-all-time-series" onClick={this.onShowAllTimeSeries}>{`Show all ${
this.props.data.length
}`}</span>
</div>
)}
<div id={id} className="explore-graph" style={{ height }} />
<GraphLegend
items={this.getLegendItems()}
displayMode={LegendDisplayMode.List}
placement="under"
onLabelClick={(item, event) => {
this.onSeriesToggle(item.label, event);
}}
/>
</>
);
}
}
export default Graph;
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { TimeZone, AbsoluteTimeRange, LoadingState } from '@grafana/data';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types';
import { toggleGraph, updateTimeRange } from './state/actions';
import Graph from './Graph';
import Panel from './Panel';
import { getTimeZone } from '../profile/state/selectors';
interface GraphContainerProps {
exploreId: ExploreId;
graphResult?: any[];
loading: boolean;
absoluteRange: AbsoluteTimeRange;
timeZone: TimeZone;
showingGraph: boolean;
showingTable: boolean;
split: boolean;
toggleGraph: typeof toggleGraph;
updateTimeRange: typeof updateTimeRange;
width: number;
}
export class GraphContainer extends PureComponent<GraphContainerProps> {
onClickGraphButton = () => {
this.props.toggleGraph(this.props.exploreId, this.props.showingGraph);
};
onChangeTime = (absoluteRange: AbsoluteTimeRange) => {
const { exploreId, updateTimeRange } = this.props;
updateTimeRange({ exploreId, absoluteRange });
};
render() {
const {
exploreId,
graphResult,
loading,
showingGraph,
showingTable,
absoluteRange,
split,
width,
timeZone,
} = this.props;
const graphHeight = showingGraph && showingTable ? 200 : 400;
return (
<Panel label="Graph" collapsible isOpen={showingGraph} loading={loading} onToggle={this.onClickGraphButton}>
{graphResult && (
<Graph
data={graphResult}
height={graphHeight}
id={`explore-graph-${exploreId}`}
onChangeTime={this.onChangeTime}
range={absoluteRange}
timeZone={timeZone}
split={split}
width={width}
/>
)}
</Panel>
);
}
}
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }) {
const explore = state.explore;
const { split } = explore;
// @ts-ignore
const item: ExploreItemState = explore[exploreId];
const { graphResult, loadingState, showingGraph, showingTable, absoluteRange } = item;
const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming;
return {
graphResult,
loading,
showingGraph,
showingTable,
split,
timeZone: getTimeZone(state.user),
absoluteRange,
};
}
const mapDispatchToProps = {
toggleGraph,
updateTimeRange,
};
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(GraphContainer)
);
......@@ -13,32 +13,17 @@ import {
LogsDedupStrategy,
LogRowModel,
} from '@grafana/data';
import TimeSeries from 'app/core/time_series2';
import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup';
import Graph from './Graph';
import { LogLabels } from './LogLabels';
import { LogRow } from './LogRow';
import { LogsDedupDescription } from 'app/core/logs_model';
import ExploreGraphPanel from './ExploreGraphPanel';
import { ExploreId } from 'app/types';
const PREVIEW_LIMIT = 100;
const graphOptions = {
series: {
stack: true,
bars: {
show: true,
lineWidth: 5,
// barWidth: 10,
},
// stack: true,
},
yaxis: {
tickDecimals: 0,
},
};
function renderMetaItem(value: any, kind: LogsMetaKind) {
if (kind === LogsMetaKind.LabelsMap) {
return (
......@@ -54,7 +39,7 @@ interface Props {
data?: LogsModel;
dedupedData?: LogsModel;
width: number;
exploreId: string;
exploreId: ExploreId;
highlighterExpressions: string[];
loading: boolean;
absoluteRange: AbsoluteTimeRange;
......@@ -135,7 +120,7 @@ export default class Logs extends PureComponent<Props, State> {
});
};
onToggleLogLevel = (rawLevel: string, hiddenRawLevels: string[]) => {
onToggleLogLevel = (hiddenRawLevels: string[]) => {
const hiddenLogLevels: LogLevel[] = hiddenRawLevels.map((level: LogLevel) => LogLevel[level]);
this.props.onToggleLogLevel(hiddenLogLevels);
};
......@@ -157,7 +142,6 @@ export default class Logs extends PureComponent<Props, State> {
highlighterExpressions,
loading = false,
onClickLabel,
absoluteRange,
timeZone,
scanning,
scanRange,
......@@ -193,23 +177,15 @@ export default class Logs extends PureComponent<Props, State> {
// React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
const getRows = () => processedRows;
const timeSeries = data.series
? data.series.map(series => new TimeSeries(series))
: [new TimeSeries({ datapoints: [] })];
return (
<div className="logs-panel">
<div className="logs-panel-graph">
<Graph
data={timeSeries}
height={100}
<ExploreGraphPanel
exploreId={exploreId}
series={data.series}
width={width}
range={absoluteRange}
timeZone={timeZone}
id={`explore-logs-graph-${exploreId}`}
onChangeTime={this.props.onChangeTime}
onToggleSeries={this.onToggleLogLevel}
userOptions={graphOptions}
onHiddenSeriesChanged={this.onToggleLogLevel}
/>
</div>
<div className="logs-panel-options">
......
......@@ -13,7 +13,7 @@ import { changeQuery, modifyQueries, runQueries, addQueryRow } from './state/act
// Types
import { StoreState } from 'app/types';
import { TimeRange, AbsoluteTimeRange } from '@grafana/data';
import { TimeRange, AbsoluteTimeRange, toDataFrame, guessFieldTypes } from '@grafana/data';
import { DataQuery, DataSourceApi, QueryFixAction, DataSourceStatus, PanelData, DataQueryError } from '@grafana/ui';
import { HistoryItem, ExploreItemState, ExploreId, ExploreMode } from 'app/types/explore';
import { Emitter } from 'app/core/utils/emitter';
......@@ -217,7 +217,7 @@ function mapStateToProps(state: StoreState, { exploreId, index }: QueryRowProps)
const query = queries[index];
const datasourceStatus = datasourceError ? DataSourceStatus.Disconnected : DataSourceStatus.Connected;
const error = queryErrors.filter(queryError => queryError.refId === query.refId)[0];
const series = graphResult ? graphResult : []; // TODO: use DataFrame
const series = graphResult ? graphResult.map(serie => guessFieldTypes(toDataFrame(serie))) : []; // TODO: use DataFrame
const queryResponse: PanelData = {
series,
state: loadingState,
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<Fragment>
<div
className="explore-graph"
id="graph"
style={
Object {
"height": 100,
}
}
/>
<GraphLegend
displayMode="list"
items={
Array [
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
]
}
onLabelClick={[Function]}
placement="under"
/>
</Fragment>
`;
exports[`Render should render component with disclaimer 1`] = `
<Fragment>
<div
className="time-series-disclaimer"
>
<i
className="fa fa-fw fa-warning disclaimer-icon"
/>
Showing only 20 time series.
<span
className="show-all-time-series"
onClick={[Function]}
>
Show all 27
</span>
</div>
<div
className="explore-graph"
id="graph"
style={
Object {
"height": 100,
}
}
/>
<GraphLegend
displayMode="list"
items={
Array [
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
Object {
"color": undefined,
"isVisible": true,
"label": undefined,
"yAxis": 1,
},
]
}
onLabelClick={[Function]}
placement="under"
/>
</Fragment>
`;
exports[`Render should show query return no time series 1`] = `
<Fragment>
<div
className="explore-graph"
id="graph"
style={
Object {
"height": 100,
}
}
/>
<GraphLegend
displayMode="list"
items={Array []}
onLabelClick={[Function]}
placement="under"
/>
</Fragment>
`;
......@@ -15,9 +15,9 @@ import {
TimeRange,
DataFrame,
LogsModel,
TimeSeries,
LoadingState,
AbsoluteTimeRange,
GraphSeriesXY,
} from '@grafana/data';
import { ExploreId, ExploreItemState, HistoryItem, ExploreUIState, ExploreMode, QueryOptions } from 'app/types/explore';
import { actionCreatorFactory, noPayloadActionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory';
......@@ -149,7 +149,7 @@ export interface QuerySuccessPayload {
exploreId: ExploreId;
latency: number;
loadingState: LoadingState;
graphResult: TimeSeries[];
graphResult: GraphSeriesXY[];
tableResult: TableModel;
logsResult: LogsModel;
}
......
......@@ -16,8 +16,7 @@ jest.mock('@grafana/data/src/utils/moment_wrapper', () => ({
import { ResultProcessor } from './ResultProcessor';
import { ExploreItemState, ExploreMode } from 'app/types/explore';
import TableModel from 'app/core/table_model';
import { toFixed } from '@grafana/ui';
import { TimeSeries, LogRowModel, LogsMetaItem } from '@grafana/data';
import { TimeSeries, LogRowModel, LogsMetaItem, GraphSeriesXY } from '@grafana/data';
const testContext = (options: any = {}) => {
const response = [
......@@ -129,20 +128,14 @@ describe('ResultProcessor', () => {
expect(theResult).toEqual([
{
alias: 'A-series',
aliasEscaped: 'A-series',
bars: {
fillColor: '#7EB26D',
},
hasMsResolution: true,
id: 'A-series',
label: 'A-series',
legend: true,
stats: {},
color: '#7EB26D',
datapoints: [[39.91264531864214, 1559038518831], [40.35179822906545, 1559038519831]],
unit: undefined,
valueFormater: toFixed,
data: [[1559038518831, 39.91264531864214], [1559038519831, 40.35179822906545]],
info: undefined,
isVisible: true,
yAxis: {
index: 1,
},
},
]);
});
......@@ -205,12 +198,14 @@ describe('ResultProcessor', () => {
],
series: [
{
alias: 'A-series',
datapoints: [[39.91264531864214, 1559038518831], [40.35179822906545, 1559038519831]],
meta: undefined,
refId: 'A',
target: 'A-series',
unit: undefined,
label: 'A-series',
color: '#7EB26D',
data: [[1559038518831, 39.91264531864214], [1559038519831, 40.35179822906545]],
info: undefined,
isVisible: true,
yAxis: {
index: 1,
},
},
],
});
......@@ -234,20 +229,14 @@ describe('ResultProcessor', () => {
replacePreviousResults: false,
graphResult: [
{
alias: 'A-series',
aliasEscaped: 'A-series',
bars: {
fillColor: '#7EB26D',
},
hasMsResolution: true,
id: 'A-series',
label: 'A-series',
legend: true,
stats: {},
color: '#7EB26D',
datapoints: [[19.91264531864214, 1558038518831], [20.35179822906545, 1558038519831]],
unit: undefined,
valueFormater: toFixed,
data: [[1558038518831, 19.91264531864214], [1558038518831, 20.35179822906545]],
info: undefined,
isVisible: true,
yAxis: {
index: 1,
},
},
],
});
......@@ -255,25 +244,19 @@ describe('ResultProcessor', () => {
expect(theResult).toEqual([
{
alias: 'A-series',
aliasEscaped: 'A-series',
bars: {
fillColor: '#7EB26D',
},
hasMsResolution: true,
id: 'A-series',
label: 'A-series',
legend: true,
stats: {},
color: '#7EB26D',
datapoints: [
[19.91264531864214, 1558038518831],
[20.35179822906545, 1558038519831],
[39.91264531864214, 1559038518831],
[40.35179822906545, 1559038519831],
data: [
[1558038518831, 19.91264531864214],
[1558038518831, 20.35179822906545],
[1559038518831, 39.91264531864214],
[1559038519831, 40.35179822906545],
],
unit: undefined,
valueFormater: toFixed,
info: undefined,
isVisible: true,
yAxis: {
index: 1,
},
},
]);
});
......@@ -351,20 +334,14 @@ describe('ResultProcessor', () => {
],
series: [
{
alias: 'A-series',
aliasEscaped: 'A-series',
bars: {
fillColor: '#7EB26D',
},
hasMsResolution: true,
id: 'A-series',
label: 'A-series',
legend: true,
stats: {},
color: '#7EB26D',
datapoints: [[37.91264531864214, 1558038518831], [38.35179822906545, 1558038519831]],
unit: undefined,
valueFormater: toFixed,
data: [[1558038518831, 37.91264531864214], [1558038519831, 38.35179822906545]],
info: undefined,
isVisible: true,
yAxis: {
index: 1,
},
},
],
},
......@@ -437,26 +414,20 @@ describe('ResultProcessor', () => {
],
series: [
{
alias: 'A-series',
aliasEscaped: 'A-series',
bars: {
fillColor: '#7EB26D',
},
hasMsResolution: true,
id: 'A-series',
label: 'A-series',
legend: true,
stats: {},
color: '#7EB26D',
datapoints: [
[37.91264531864214, 1558038518831],
[38.35179822906545, 1558038519831],
[39.91264531864214, 1559038518831],
[40.35179822906545, 1559038519831],
data: [
[1558038518831, 37.91264531864214],
[1558038519831, 38.35179822906545],
[1559038518831, 39.91264531864214],
[1559038519831, 40.35179822906545],
],
unit: undefined as string,
valueFormater: toFixed,
},
info: undefined,
isVisible: true,
yAxis: {
index: 1,
},
} as GraphSeriesXY,
],
};
......
import { DataQueryResponse, DataQueryResponseData } from '@grafana/ui';
import { TableData, isTableData, LogsModel, toDataFrame, guessFieldTypes, TimeSeries } from '@grafana/data';
import {
TableData,
isTableData,
LogsModel,
toDataFrame,
guessFieldTypes,
TimeSeries,
GraphSeriesXY,
LoadingState,
} from '@grafana/data';
import { ExploreItemState, ExploreMode } from 'app/types/explore';
import { getProcessedDataFrames } from 'app/features/dashboard/state/PanelQueryState';
import TableModel, { mergeTablesIntoModel } from 'app/core/table_model';
import { sortLogsResult } from 'app/core/utils/explore';
import { dataFrameToLogsModel } from 'app/core/logs_model';
import { default as TimeSeries2 } from 'app/core/time_series2';
import { DataProcessor } from 'app/plugins/panel/graph/data_processor';
import { getGraphSeriesModel } from 'app/plugins/panel/graph2/getGraphSeriesModel';
export class ResultProcessor {
private rawData: DataQueryResponseData[] = [];
......@@ -45,12 +53,12 @@ export class ResultProcessor {
return this.rawData;
};
getGraphResult = (): TimeSeries[] => {
getGraphResult = (): GraphSeriesXY[] => {
if (this.state.mode !== ExploreMode.Metrics) {
return [];
}
const newResults = this.makeTimeSeriesList(this.metrics);
const newResults = this.createGraphSeries(this.metrics);
return this.mergeGraphResults(newResults, this.state.graphResult);
};
......@@ -100,26 +108,26 @@ export class ResultProcessor {
return { ...sortedNewResults, rows, series };
};
private makeTimeSeriesList = (rawData: any[]) => {
const dataList = getProcessedDataFrames(rawData);
const dataProcessor = new DataProcessor({ xaxis: {}, aliasColors: [] }); // Hack before we use GraphSeriesXY instead
const timeSeries = dataProcessor.getSeriesList({ dataList });
private createGraphSeries = (rawData: any[]) => {
const dataFrames = getProcessedDataFrames(rawData);
const graphSeries = getGraphSeriesModel(
{ series: dataFrames, state: LoadingState.Done },
{},
{ showBars: false, showLines: true, showPoints: false },
{
asTable: false,
isVisible: true,
placement: 'under',
}
);
return (timeSeries as any) as TimeSeries[]; // Hack before we use GraphSeriesXY instead
return graphSeries;
};
private isSameTimeSeries = (a: TimeSeries | TimeSeries2, b: TimeSeries | TimeSeries2) => {
if (a.hasOwnProperty('id') && b.hasOwnProperty('id')) {
const aValue = (a as TimeSeries2).id;
const bValue = (b as TimeSeries2).id;
if (aValue !== undefined && bValue !== undefined && aValue === bValue) {
return true;
}
}
if (a.hasOwnProperty('alias') && b.hasOwnProperty('alias')) {
const aValue = (a as TimeSeries2).alias;
const bValue = (b as TimeSeries2).alias;
private isSameGraphSeries = (a: GraphSeriesXY, b: GraphSeriesXY) => {
if (a.hasOwnProperty('label') && b.hasOwnProperty('label')) {
const aValue = a.label;
const bValue = b.label;
if (aValue !== undefined && bValue !== undefined && aValue === bValue) {
return true;
}
......@@ -128,24 +136,21 @@ export class ResultProcessor {
return false;
};
private mergeGraphResults = (
newResults: TimeSeries[] | TimeSeries2[],
prevResults: TimeSeries[] | TimeSeries2[]
): TimeSeries[] => {
private mergeGraphResults = (newResults: GraphSeriesXY[], prevResults: GraphSeriesXY[]): GraphSeriesXY[] => {
if (!prevResults || prevResults.length === 0 || this.replacePreviousResults) {
return (newResults as any) as TimeSeries[]; // Hack before we use GraphSeriesXY instead
return newResults; // Hack before we use GraphSeriesXY instead
}
const results: TimeSeries[] = prevResults.slice() as TimeSeries[];
const results: GraphSeriesXY[] = prevResults.slice() as GraphSeriesXY[];
// update existing results
for (let index = 0; index < results.length; index++) {
const prevResult = results[index];
for (const newResult of newResults) {
const isSame = this.isSameTimeSeries(prevResult, newResult);
const isSame = this.isSameGraphSeries(prevResult, newResult);
if (isSame) {
prevResult.datapoints = prevResult.datapoints.concat(newResult.datapoints);
prevResult.data = prevResult.data.concat(newResult.data);
break;
}
}
......@@ -155,7 +160,7 @@ export class ResultProcessor {
for (const newResult of newResults) {
let isNew = true;
for (const prevResult of results) {
const isSame = this.isSameTimeSeries(prevResult, newResult);
const isSame = this.isSameGraphSeries(prevResult, newResult);
if (isSame) {
isNew = false;
break;
......@@ -163,10 +168,7 @@ export class ResultProcessor {
}
if (isNew) {
const timeSeries2Result = new TimeSeries2({ ...newResult });
const result = (timeSeries2Result as any) as TimeSeries; // Hack before we use GraphSeriesXY instead
results.push(result);
results.push(newResult);
}
}
return results;
......
......@@ -9,6 +9,7 @@ interface GraphPanelProps extends PanelProps<Options> {}
export const GraphPanel: React.FunctionComponent<GraphPanelProps> = ({
data,
timeRange,
timeZone,
width,
height,
options,
......@@ -39,6 +40,7 @@ export const GraphPanel: React.FunctionComponent<GraphPanelProps> = ({
return (
<GraphWithLegend
timeRange={timeRange}
timeZone={timeZone}
width={width}
height={height}
displayMode={asTable ? LegendDisplayMode.Table : LegendDisplayMode.List}
......
import React from 'react';
import { PanelData } from '@grafana/ui';
import { GraphSeriesXY } from '@grafana/data';
import difference from 'lodash/difference';
import { getGraphSeriesModel } from './getGraphSeriesModel';
import { Options, SeriesOptions } from './types';
import { SeriesColorChangeHandler, SeriesAxisToggleHandler } from '@grafana/ui/src/components/Graph/GraphWithLegend';
import { GraphSeriesToggler } from './GraphSeriesToggler';
interface GraphPanelControllerAPI {
series: GraphSeriesXY[];
......@@ -24,14 +24,12 @@ interface GraphPanelControllerProps {
interface GraphPanelControllerState {
graphSeriesModel: GraphSeriesXY[];
hiddenSeries: string[];
}
export class GraphPanelController extends React.Component<GraphPanelControllerProps, GraphPanelControllerState> {
constructor(props: GraphPanelControllerProps) {
super(props);
this.onSeriesToggle = this.onSeriesToggle.bind(this);
this.onSeriesColorChange = this.onSeriesColorChange.bind(this);
this.onSeriesAxisToggle = this.onSeriesAxisToggle.bind(this);
this.onToggleSort = this.onToggleSort.bind(this);
......@@ -43,7 +41,6 @@ export class GraphPanelController extends React.Component<GraphPanelControllerPr
props.options.graph,
props.options.legend
),
hiddenSeries: [],
};
}
......@@ -76,10 +73,15 @@ export class GraphPanelController extends React.Component<GraphPanelControllerPr
const seriesOptionsUpdate: SeriesOptions = series[label]
? {
...series[label],
yAxis,
yAxis: {
...series[label].yAxis,
index: yAxis,
},
}
: {
yAxis,
yAxis: {
index: yAxis,
},
};
this.onSeriesOptionsUpdate(label, seriesOptionsUpdate);
}
......@@ -112,47 +114,22 @@ export class GraphPanelController extends React.Component<GraphPanelControllerPr
});
}
onSeriesToggle(label: string, event: React.MouseEvent<HTMLElement>) {
const { hiddenSeries, graphSeriesModel } = this.state;
if (event.ctrlKey || event.metaKey || event.shiftKey) {
// Toggling series with key makes the series itself to toggle
if (hiddenSeries.indexOf(label) > -1) {
this.setState({
hiddenSeries: hiddenSeries.filter(series => series !== label),
});
} else {
this.setState({
hiddenSeries: hiddenSeries.concat([label]),
});
}
} else {
// Toggling series with out key toggles all the series but the clicked one
const allSeriesLabels = graphSeriesModel.map(series => series.label);
if (hiddenSeries.length + 1 === allSeriesLabels.length) {
this.setState({ hiddenSeries: [] });
} else {
this.setState({
hiddenSeries: difference(allSeriesLabels, [label]),
});
}
}
}
render() {
const { children } = this.props;
const { graphSeriesModel, hiddenSeries } = this.state;
return children({
series: graphSeriesModel.map(series => ({
...series,
isVisible: hiddenSeries.indexOf(series.label) === -1,
})),
onSeriesToggle: this.onSeriesToggle,
onSeriesColorChange: this.onSeriesColorChange,
onSeriesAxisToggle: this.onSeriesAxisToggle,
onToggleSort: this.onToggleSort,
});
const { graphSeriesModel } = this.state;
return (
<GraphSeriesToggler series={graphSeriesModel}>
{({ onSeriesToggle, toggledSeries }) => {
return children({
series: toggledSeries,
onSeriesColorChange: this.onSeriesColorChange,
onSeriesAxisToggle: this.onSeriesAxisToggle,
onToggleSort: this.onToggleSort,
onSeriesToggle: onSeriesToggle,
});
}}
</GraphSeriesToggler>
);
}
}
import React from 'react';
import { GraphSeriesXY } from '@grafana/data';
import difference from 'lodash/difference';
import isEqual from 'lodash/isEqual';
interface GraphSeriesTogglerAPI {
onSeriesToggle: (label: string, event: React.MouseEvent<HTMLElement>) => void;
toggledSeries: GraphSeriesXY[];
}
interface GraphSeriesTogglerProps {
children: (api: GraphSeriesTogglerAPI) => JSX.Element;
series: GraphSeriesXY[];
onHiddenSeriesChanged?: (hiddenSeries: string[]) => void;
}
interface GraphSeriesTogglerState {
hiddenSeries: string[];
toggledSeries: GraphSeriesXY[];
}
export class GraphSeriesToggler extends React.Component<GraphSeriesTogglerProps, GraphSeriesTogglerState> {
constructor(props: GraphSeriesTogglerProps) {
super(props);
this.onSeriesToggle = this.onSeriesToggle.bind(this);
this.state = {
hiddenSeries: [],
toggledSeries: props.series,
};
}
componentDidUpdate(prevProps: Readonly<GraphSeriesTogglerProps>) {
const { series } = this.props;
if (!isEqual(prevProps.series, series)) {
this.setState({ hiddenSeries: [], toggledSeries: series });
}
}
onSeriesToggle(label: string, event: React.MouseEvent<HTMLElement>) {
const { series, onHiddenSeriesChanged } = this.props;
const { hiddenSeries } = this.state;
if (event.ctrlKey || event.metaKey || event.shiftKey) {
// Toggling series with key makes the series itself to toggle
const newHiddenSeries =
hiddenSeries.indexOf(label) > -1
? hiddenSeries.filter(series => series !== label)
: hiddenSeries.concat([label]);
const toggledSeries = series.map(series => ({
...series,
isVisible: newHiddenSeries.indexOf(series.label) === -1,
}));
this.setState({ hiddenSeries: newHiddenSeries, toggledSeries }, () =>
onHiddenSeriesChanged ? onHiddenSeriesChanged(newHiddenSeries) : undefined
);
return;
}
// Toggling series with out key toggles all the series but the clicked one
const allSeriesLabels = series.map(series => series.label);
const newHiddenSeries =
hiddenSeries.length + 1 === allSeriesLabels.length ? [] : difference(allSeriesLabels, [label]);
const toggledSeries = series.map(series => ({
...series,
isVisible: newHiddenSeries.indexOf(series.label) === -1,
}));
this.setState({ hiddenSeries: newHiddenSeries, toggledSeries }, () =>
onHiddenSeriesChanged ? onHiddenSeriesChanged(newHiddenSeries) : undefined
);
}
render() {
const { children } = this.props;
const { toggledSeries } = this.state;
return children({
onSeriesToggle: this.onSeriesToggle,
toggledSeries,
});
}
}
......@@ -30,7 +30,7 @@ export const getGraphSeriesModel = (
const field = numberFields[i];
// Use external calculator just to make sure it works :)
const points = getFlotPairs({
series,
rows: series.rows,
xIndex: timeColumn.index,
yIndex: field.index,
nullValueMode: NullValueMode.Null,
......@@ -67,7 +67,9 @@ export const getGraphSeriesModel = (
color: seriesColor,
info: statsDisplayValues,
isVisible: true,
yAxis: (seriesOptions[field.name] && seriesOptions[field.name].yAxis) || 1,
yAxis: {
index: (seriesOptions[field.name] && seriesOptions[field.name].yAxis) || 1,
},
});
}
}
......
import { LegendOptions } from '@grafana/ui';
import { YAxis } from '@grafana/data';
import { GraphLegendEditorLegendOptions } from './GraphLegendEditor';
export interface SeriesOptions {
color?: string;
yAxis?: number;
yAxis?: YAxis;
[key: string]: any;
}
export interface GraphOptions {
......
......@@ -16,6 +16,7 @@ import {
LogsDedupStrategy,
LoadingState,
AbsoluteTimeRange,
GraphSeriesXY,
} from '@grafana/data';
import { Emitter } from 'app/core/core';
......@@ -159,7 +160,7 @@ export interface ExploreItemState {
/**
* List of timeseries to be shown in the Explore graph result viewer.
*/
graphResult?: any[];
graphResult?: GraphSeriesXY[];
/**
* History of recent queries. Datasource-specific and initialized via localStorage.
*/
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment