NodeGraph: Add node graph visualization (#29706)

* Add GraphView component

* Add service map panel

* Add more metadata visuals

* Add context menu on click

* Add context menu for services

* Fix service map in dashboard

* Add field proxy in explore linkSupplier

* Refactor the link creation

* Remove test file

* Fix scale change when view is panned

* Fix node centering

* Don't show context menu if no links

* Fix service map containers

* Add collapsible around the service map

* Fix stats computation

* Remove debug log

* Fix time stats

* Allow string timestamp

* Make panning bounded

* Add zooming by mouse wheel

* Clean up the colors

* Fix stats for single trace graph

* Don't show debug config

* Add more complex layout

* Update layout with better fixing of the root nodes

* Code cleanup

* Change how we pass in link creation function and some more cleanup

* Refactor the panel section into separate render methods

* Make the edge hover more readable

* Move stats computation to data source

* Put edge labels to front

* Simplify layout for better multi graph layout

* Update for dark theme

* Move function to utils

* Visual improvements

* Improve context menu detail

* Allow custom details

* Rename to NodeGraph

* Remove unused dependencies

* Use named color palette and add some fallbacks for missing data

* Add test data scenario

* Rename plugin

* Switch scroll zoom direction to align with google maps

* Do some perf optimisations and rise the node limit

* Update alert styling

* Rename function

* Add tests

* Add more tests

* Change data frame column mapping to use column names

* Fix test

* Fix type errors

* Don't show context menu without links

* Add beta status to panel

* Fix tests

* Changed function to standard methods

* Fix typing

* Clean up yarn.lock

* Add some UI improvements

- better styling of the zoom buttons
- disable buttons when max reached

* Fix panel references after rename

* Add panel icon
This commit is contained in:
Andrej Ocenas
2021-01-19 16:34:43 +01:00
committed by GitHub
parent 6f0bfa78ec
commit 218a8de220
45 changed files with 2838 additions and 63 deletions

View File

@@ -100,6 +100,7 @@ const dummyProps: ExploreProps = {
showLogs: true,
showTable: true,
showTrace: true,
showNodeGraph: true,
splitOpen: (() => {}) as any,
};

View File

@@ -19,10 +19,10 @@ import {
TimeZone,
ExploreUrlState,
LogsModel,
DataFrame,
EventBusExtended,
EventBusSrv,
TraceViewData,
DataFrame,
} from '@grafana/data';
import store from 'app/core/store';
@@ -54,6 +54,7 @@ import { TraceView } from './TraceView/TraceView';
import { SecondaryActions } from './SecondaryActions';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR, FilterItem } from '@grafana/ui/src/components/Table/types';
import { ExploreGraphNGPanel } from './ExploreGraphNGPanel';
import { NodeGraphContainer } from './NodeGraphContainer';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
@@ -113,6 +114,7 @@ export interface ExploreProps {
showTable: boolean;
showLogs: boolean;
showTrace: boolean;
showNodeGraph: boolean;
splitOpen: typeof splitOpen;
}
@@ -276,13 +278,89 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
}
};
renderEmptyState = () => {
renderEmptyState() {
return (
<div className="explore-container">
<NoDataSourceCallToAction />
</div>
);
};
}
renderGraphPanel(width: number) {
const { graphResult, absoluteRange, timeZone, splitOpen, queryResponse } = this.props;
const isLoading = queryResponse.state === LoadingState.Loading;
return (
<ExploreGraphNGPanel
data={graphResult!}
width={width}
absoluteRange={absoluteRange}
timeZone={timeZone}
onUpdateTimeRange={this.onUpdateTimeRange}
annotations={queryResponse.annotations}
splitOpenFn={splitOpen}
isLoading={isLoading}
/>
);
}
renderTablePanel(width: number) {
const { exploreId, datasourceInstance } = this.props;
return (
<TableContainer
ariaLabel={selectors.pages.Explore.General.table}
width={width}
exploreId={exploreId}
onCellFilterAdded={datasourceInstance?.modifyQuery ? this.onCellFilterAdded : undefined}
/>
);
}
renderLogsPanel(width: number) {
const { exploreId, syncedTimes } = this.props;
return (
<LogsContainer
width={width}
exploreId={exploreId}
syncedTimes={syncedTimes}
onClickFilterLabel={this.onClickFilterLabel}
onClickFilterOutLabel={this.onClickFilterOutLabel}
onStartScanning={this.onStartScanning}
onStopScanning={this.onStopScanning}
/>
);
}
renderNodeGraphPanel() {
const { exploreId, showTrace, queryResponse } = this.props;
return (
<NodeGraphContainer
dataFrames={this.getNodeGraphDataFrames(queryResponse.series)}
exploreId={exploreId}
short={showTrace}
/>
);
}
getNodeGraphDataFrames = memoizeOne((frames: DataFrame[]) => {
// TODO: this not in sync with how other types of responses are handled. Other types have a query response
// processing pipeline which ends up populating redux state with proper data. As we move towards more dataFrame
// oriented API it seems like a better direction to move such processing into to visualisations and do minimal
// and lazy processing here. Needs bigger refactor so keeping nodeGraph and Traces as they are for now.
return frames.filter(frame => frame.meta?.preferredVisualisationType === 'nodeGraph');
});
renderTraceViewPanel() {
const { queryResponse, splitOpen } = this.props;
const dataFrames = queryResponse.series.filter(series => series.meta?.preferredVisualisationType === 'trace');
return (
// We expect only one trace at the moment to be in the dataframe
// If there is no data (like 404) we show a separate error so no need to show anything here
dataFrames[0] && (
<TraceView trace={dataFrames[0].fields[0].values.get(0) as TraceViewData | undefined} splitOpenFn={splitOpen} />
)
);
}
render() {
const {
@@ -292,24 +370,20 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
split,
queryKeys,
graphResult,
absoluteRange,
timeZone,
queryResponse,
syncedTimes,
isLive,
theme,
showMetrics,
showTable,
showLogs,
showTrace,
splitOpen,
showNodeGraph,
} = this.props;
const { openDrawer } = this.state;
const exploreClass = split ? 'explore explore-split' : 'explore';
const styles = getStyles(theme);
const StartPage = datasourceInstance?.components?.ExploreStartPage;
const showStartPage = !queryResponse || queryResponse.state === LoadingState.NotStarted;
const isLoading = queryResponse.state === LoadingState.Loading;
// gets an error without a refID, so non-query-row-related error, like a connection error
const queryErrors =
@@ -360,49 +434,11 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
)}
{!showStartPage && (
<>
{showMetrics && graphResult && (
<ExploreGraphNGPanel
data={graphResult}
width={width}
absoluteRange={absoluteRange}
timeZone={timeZone}
onUpdateTimeRange={this.onUpdateTimeRange}
annotations={queryResponse.annotations}
splitOpenFn={splitOpen}
isLoading={isLoading}
/>
)}
{showTable && (
<TableContainer
ariaLabel={selectors.pages.Explore.General.table}
width={width}
exploreId={exploreId}
onCellFilterAdded={
this.props.datasourceInstance?.modifyQuery ? this.onCellFilterAdded : undefined
}
/>
)}
{showLogs && (
<LogsContainer
width={width}
exploreId={exploreId}
syncedTimes={syncedTimes}
onClickFilterLabel={this.onClickFilterLabel}
onClickFilterOutLabel={this.onClickFilterOutLabel}
onStartScanning={this.onStartScanning}
onStopScanning={this.onStopScanning}
/>
)}
{/* TODO:unification */}
{showTrace &&
// We expect only one trace at the moment to be in the dataframe
// If there is not data (like 404) we show a separate error so no need to show anything here
queryResponse.series[0] && (
<TraceView
trace={queryResponse.series[0].fields[0].values.get(0) as TraceViewData | undefined}
splitOpenFn={splitOpen}
/>
)}
{showMetrics && graphResult && this.renderGraphPanel(width)}
{showTable && this.renderTablePanel(width)}
{showLogs && this.renderLogsPanel(width)}
{showNodeGraph && this.renderNodeGraphPanel()}
{showTrace && this.renderTraceViewPanel()}
</>
)}
{showRichHistory && (
@@ -455,6 +491,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia
showTrace,
absoluteRange,
queryResponse,
showNodeGraph,
} = item;
const { datasource, queries, range: urlRange, originPanelId } = (urlState || {}) as ExploreUrlState;
@@ -486,6 +523,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia
showMetrics,
showTable,
showTrace,
showNodeGraph,
};
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Badge, NodeGraph } from '@grafana/ui';
import { DataFrame, TimeRange } from '@grafana/data';
import { ExploreId, StoreState } from '../../types';
import { splitOpen } from './state/main';
import { connect, ConnectedProps } from 'react-redux';
import { Collapse } from '@grafana/ui';
import { useLinks } from './utils/links';
interface Props {
// Edges and Nodes are separate frames
dataFrames: DataFrame[];
exploreId: ExploreId;
range: TimeRange;
splitOpen: typeof splitOpen;
short?: boolean;
}
export function UnconnectedNodeGraphContainer(props: Props & ConnectedProps<typeof connector>) {
const { dataFrames, range, splitOpen, short } = props;
const getLinks = useLinks(range, splitOpen);
return (
<div style={{ height: short ? 300 : 600 }}>
<Collapse
label={
<span>
Node graph <Badge text={'Beta'} color={'blue'} icon={'rocket'} tooltip={'This visualization is in beta'} />
</span>
}
isOpen
>
<NodeGraph dataFrames={dataFrames} getLinks={getLinks} />
</Collapse>
</div>
);
}
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) {
return {
range: state.explore[exploreId].range,
};
}
const mapDispatchToProps = {
splitOpen,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export const NodeGraphContainer = connector(UnconnectedNodeGraphContainer);

View File

@@ -86,6 +86,11 @@ export function splitOpen<T extends DataQuery = any>(options?: {
rightState.queryKeys = [];
urlState.queries = [];
rightState.urlState = urlState;
rightState.showLogs = false;
rightState.showMetrics = false;
rightState.showNodeGraph = false;
rightState.showTrace = false;
rightState.showTable = false;
if (options.range) {
urlState.range = options.range.raw;
// This is super hacky. In traces to logs we want to create a link but also internally open split window.

View File

@@ -29,7 +29,7 @@ import { getShiftedTimeRange } from 'app/core/utils/timePicker';
import { notifyApp } from '../../../core/actions';
import { preProcessPanelData, runRequest } from '../../query/state/runRequest';
import {
decorateWithGraphLogsTraceAndTable,
decorateWithFrameTypeMetadata,
decorateWithGraphResult,
decorateWithLogsResult,
decorateWithTableResult,
@@ -356,7 +356,7 @@ export const runQueries = (exploreId: ExploreId): ThunkResult<void> => {
// actually can see what is happening.
live ? throttleTime(500) : identity,
map((data: PanelData) => preProcessPanelData(data, queryResponse)),
map(decorateWithGraphLogsTraceAndTable),
map(decorateWithFrameTypeMetadata),
map(decorateWithGraphResult),
map(decorateWithLogsResult({ absoluteRange, refreshInterval })),
mergeMap(decorateWithTableResult)
@@ -639,7 +639,17 @@ export const processQueryResponse = (
action: PayloadAction<QueryEndedPayload>
): ExploreItemState => {
const { response } = action.payload;
const { request, state: loadingState, series, error, graphResult, logsResult, tableResult, traceFrames } = response;
const {
request,
state: loadingState,
series,
error,
graphResult,
logsResult,
tableResult,
traceFrames,
nodeGraphFrames,
} = response;
if (error) {
if (error.type === DataQueryErrorType.Timeout) {
@@ -692,5 +702,6 @@ export const processQueryResponse = (
showMetrics: !!graphResult,
showTable: !!tableResult,
showTrace: !!traceFrames.length,
showNodeGraph: !!nodeGraphFrames.length,
};
};

View File

@@ -15,7 +15,7 @@ import {
} from '@grafana/data';
import {
decorateWithGraphLogsTraceAndTable,
decorateWithFrameTypeMetadata,
decorateWithGraphResult,
decorateWithLogsResult,
decorateWithTableResult,
@@ -78,6 +78,7 @@ const createExplorePanelData = (args: Partial<ExplorePanelData>): ExplorePanelDa
tableFrames: [],
tableResult: (undefined as unknown) as null,
traceFrames: [],
nodeGraphFrames: [],
};
return { ...defaults, ...args };
@@ -93,7 +94,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => {
timeRange: ({} as unknown) as TimeRange,
};
expect(decorateWithGraphLogsTraceAndTable(panelData)).toEqual({
expect(decorateWithFrameTypeMetadata(panelData)).toEqual({
series,
state: LoadingState.Done,
timeRange: {},
@@ -101,6 +102,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => {
tableFrames: [table, emptyTable],
logsFrames: [logs],
traceFrames: [],
nodeGraphFrames: [],
graphResult: null,
tableResult: null,
logsResult: null,
@@ -115,7 +117,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => {
timeRange: ({} as unknown) as TimeRange,
};
expect(decorateWithGraphLogsTraceAndTable(panelData)).toEqual({
expect(decorateWithFrameTypeMetadata(panelData)).toEqual({
series: [],
state: LoadingState.Done,
timeRange: {},
@@ -123,6 +125,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => {
tableFrames: [],
logsFrames: [],
traceFrames: [],
nodeGraphFrames: [],
graphResult: null,
tableResult: null,
logsResult: null,
@@ -139,7 +142,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => {
timeRange: ({} as unknown) as TimeRange,
};
expect(decorateWithGraphLogsTraceAndTable(panelData)).toEqual({
expect(decorateWithFrameTypeMetadata(panelData)).toEqual({
series: [timeSeries, logs, table],
error: {},
state: LoadingState.Error,
@@ -148,6 +151,7 @@ describe('decorateWithGraphLogsTraceAndTable', () => {
tableFrames: [],
logsFrames: [],
traceFrames: [],
nodeGraphFrames: [],
graphResult: null,
tableResult: null,
logsResult: null,

View File

@@ -20,7 +20,7 @@ import { ExplorePanelData } from '../../../types';
* dataFrames with different type of data. This is later used for type specific processing. As we use this in
* Observable pipeline, it decorates the existing panelData to pass the results to later processing stages.
*/
export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePanelData => {
export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData => {
if (data.error) {
return {
...data,
@@ -28,6 +28,7 @@ export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePane
tableFrames: [],
logsFrames: [],
traceFrames: [],
nodeGraphFrames: [],
graphResult: null,
tableResult: null,
logsResult: null,
@@ -38,6 +39,7 @@ export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePane
const tableFrames: DataFrame[] = [];
const logsFrames: DataFrame[] = [];
const traceFrames: DataFrame[] = [];
const nodeGraphFrames: DataFrame[] = [];
for (const frame of data.series) {
switch (frame.meta?.preferredVisualisationType) {
@@ -53,6 +55,9 @@ export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePane
case 'table':
tableFrames.push(frame);
break;
case 'nodeGraph':
nodeGraphFrames.push(frame);
break;
default:
if (isTimeSeries(frame)) {
graphFrames.push(frame);
@@ -70,6 +75,7 @@ export const decorateWithGraphLogsTraceAndTable = (data: PanelData): ExplorePane
tableFrames,
logsFrames,
traceFrames,
nodeGraphFrames,
graphResult: null,
tableResult: null,
logsResult: null,

View File

@@ -1,3 +1,4 @@
import { useCallback } from 'react';
import {
Field,
LinkModel,
@@ -92,3 +93,29 @@ function getTitleFromHref(href: string): string {
}
return title;
}
/**
* Hook that returns a function that can be used to retrieve all the links for a row. This returns all the links from
* all the fields so is useful for visualisation where the whole row is represented as single clickable item like a
* service map.
*/
export function useLinks(range: TimeRange, splitOpenFn?: typeof splitOpen) {
return useCallback(
(dataFrame: DataFrame, rowIndex: number) => {
return dataFrame.fields.flatMap(f => {
if (f.config?.links && f.config?.links.length) {
return getFieldLinksForExplore({
field: f,
rowIndex: rowIndex,
range,
dataFrame,
splitOpenFn,
});
} else {
return [];
}
});
},
[range, splitOpenFn]
);
}