mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -100,6 +100,7 @@ const dummyProps: ExploreProps = {
|
||||
showLogs: true,
|
||||
showTable: true,
|
||||
showTrace: true,
|
||||
showNodeGraph: true,
|
||||
splitOpen: (() => {}) as any,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
49
public/app/features/explore/NodeGraphContainer.tsx
Normal file
49
public/app/features/explore/NodeGraphContainer.tsx
Normal 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);
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user