From 5aff3389f4633d6970eb2b3629ac015e564c626c Mon Sep 17 00:00:00 2001 From: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Wed, 13 Dec 2023 11:33:14 +0200 Subject: [PATCH] Table: Highlight row on shared crosshair (#78392) * bidirectional shared crosshair table WIP * add shared crosshair to table panel * lower around point threshold * add feature toggle * add index based verification * add adaptive threshold * switch to debounceTime * lower debounce to 100 * raise debounce back to 200 * revert azure dashboard * re-render only rows list on data hover event * further break down table component * refactor * raise debounce time * fix build --- .../feature-toggles/index.md | 1 + packages/grafana-data/src/dataframe/index.ts | 8 +- packages/grafana-data/src/dataframe/utils.ts | 8 + .../src/types/featureToggles.gen.ts | 1 + .../src/components/Table/RowsList.tsx | 301 ++++++++++++++++++ .../grafana-ui/src/components/Table/Table.tsx | 118 ++----- .../grafana-ui/src/components/Table/types.ts | 1 + .../grafana-ui/src/components/Table/utils.ts | 30 ++ pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + public/app/plugins/panel/table/TablePanel.tsx | 14 +- 12 files changed, 397 insertions(+), 98 deletions(-) create mode 100644 packages/grafana-ui/src/components/Table/RowsList.tsx diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index e0ca5eeec41..ee6ea77c3f9 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -167,6 +167,7 @@ Experimental features might be changed or removed without prior notice. | `flameGraphItemCollapsing` | Allow collapsing of flame graph items | | `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | | `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes | +| `tableSharedCrosshair` | Enables shared crosshair in table panel | | `regressionTransformation` | Enables regression analysis transformation | ## Development feature toggles diff --git a/packages/grafana-data/src/dataframe/index.ts b/packages/grafana-data/src/dataframe/index.ts index ad29c71fc11..5c505a16670 100644 --- a/packages/grafana-data/src/dataframe/index.ts +++ b/packages/grafana-data/src/dataframe/index.ts @@ -7,5 +7,11 @@ export * from './dimensions'; export * from './ArrayDataFrame'; export * from './DataFrameJSON'; export * from './frameComparisons'; -export { anySeriesWithTimeField, isTimeSeriesFrame, isTimeSeriesFrames, isTimeSeriesField } from './utils'; +export { + anySeriesWithTimeField, + hasTimeField, + isTimeSeriesFrame, + isTimeSeriesFrames, + isTimeSeriesField, +} from './utils'; export { StreamingDataFrame, StreamingFrameAction, type StreamingFrameOptions, closestIdx } from './StreamingDataFrame'; diff --git a/packages/grafana-data/src/dataframe/utils.ts b/packages/grafana-data/src/dataframe/utils.ts index 3351793b6f9..ea9dbd34327 100644 --- a/packages/grafana-data/src/dataframe/utils.ts +++ b/packages/grafana-data/src/dataframe/utils.ts @@ -78,3 +78,11 @@ export function anySeriesWithTimeField(data: DataFrame[]) { } return false; } + +/** + * Indicates if there is any time field in the data frame + * @param data + */ +export function hasTimeField(data: DataFrame): boolean { + return data.fields.some((field) => field.type === FieldType.time); +} diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index c353d637fc5..737cc8ca7e0 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -166,6 +166,7 @@ export interface FeatureToggles { alertingSimplifiedRouting?: boolean; logRowsPopoverMenu?: boolean; pluginsSkipHostEnvVars?: boolean; + tableSharedCrosshair?: boolean; regressionTransformation?: boolean; displayAnonymousStats?: boolean; alertStateHistoryAnnotationsFromLoki?: boolean; diff --git a/packages/grafana-ui/src/components/Table/RowsList.tsx b/packages/grafana-ui/src/components/Table/RowsList.tsx new file mode 100644 index 00000000000..ebb2773421e --- /dev/null +++ b/packages/grafana-ui/src/components/Table/RowsList.tsx @@ -0,0 +1,301 @@ +import { css, cx } from '@emotion/css'; +import React, { CSSProperties, UIEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; +import { Cell, Row, TableState } from 'react-table'; +import { VariableSizeList } from 'react-window'; +import { Subscription, debounceTime } from 'rxjs'; + +import { + DataFrame, + DataHoverClearEvent, + DataHoverEvent, + Field, + FieldType, + TimeRange, + hasTimeField, +} from '@grafana/data'; +import { TableCellHeight } from '@grafana/schema'; + +import { useTheme2 } from '../../themes'; +import CustomScrollbar from '../CustomScrollbar/CustomScrollbar'; +import { usePanelContext } from '../PanelChrome'; + +import { ExpandedRow, getExpandedRowHeight } from './ExpandedRow'; +import { TableCell } from './TableCell'; +import { TableStyles } from './styles'; +import { TableFilterActionCallback } from './types'; +import { calculateAroundPointThreshold, isPointTimeValAroundTableTimeVal } from './utils'; + +interface RowsListProps { + data: DataFrame; + rows: Row[]; + enableSharedCrosshair: boolean; + headerHeight: number; + rowHeight: number; + itemCount: number; + pageIndex: number; + listHeight: number; + width: number; + cellHeight?: TableCellHeight; + listRef: React.RefObject; + tableState: TableState; + tableStyles: TableStyles; + nestedDataField?: Field; + prepareRow: (row: Row) => void; + onCellFilterAdded?: TableFilterActionCallback; + timeRange?: TimeRange; + footerPaginationEnabled: boolean; +} + +export const RowsList = (props: RowsListProps) => { + const { + data, + rows, + headerHeight, + footerPaginationEnabled, + rowHeight, + itemCount, + pageIndex, + tableState, + prepareRow, + onCellFilterAdded, + width, + cellHeight = TableCellHeight.Sm, + timeRange, + tableStyles, + nestedDataField, + listHeight, + listRef, + enableSharedCrosshair = false, + } = props; + + const [rowHighlightIndex, setRowHighlightIndex] = useState(undefined); + + const theme = useTheme2(); + const panelContext = usePanelContext(); + + const threshold = useMemo(() => { + const timeField = data.fields.find((f) => f.type === FieldType.time); + + if (!timeField) { + return 0; + } + + return calculateAroundPointThreshold(timeField); + }, [data]); + + const onRowHover = useCallback( + (idx: number, frame: DataFrame) => { + if (!panelContext || !enableSharedCrosshair || !hasTimeField(frame)) { + return; + } + + const timeField: Field = frame!.fields.find((f) => f.type === FieldType.time)!; + + panelContext.eventBus.publish( + new DataHoverEvent({ + point: { + time: timeField.values[idx], + }, + }) + ); + }, + [enableSharedCrosshair, panelContext] + ); + + const onRowLeave = useCallback(() => { + if (!panelContext || !enableSharedCrosshair) { + return; + } + + panelContext.eventBus.publish(new DataHoverClearEvent()); + }, [enableSharedCrosshair, panelContext]); + + const onDataHoverEvent = useCallback( + (evt: DataHoverEvent) => { + if (evt.payload.point?.time && evt.payload.rowIndex !== undefined) { + const timeField = data.fields.find((f) => f.type === FieldType.time); + const time = timeField!.values[evt.payload.rowIndex]; + const pointTime = evt.payload.point.time; + + // If the time value of the hovered point is around the time value of the + // row with same index, highlight the row + if (isPointTimeValAroundTableTimeVal(pointTime, time, threshold)) { + setRowHighlightIndex(evt.payload.rowIndex); + return; + } + + // If the time value of the hovered point is not around the time value of the + // row with same index, try to find a row with same time value + const matchedRowIndex = timeField!.values.findIndex((t) => + isPointTimeValAroundTableTimeVal(pointTime, t, threshold) + ); + + if (matchedRowIndex !== -1) { + setRowHighlightIndex(matchedRowIndex); + return; + } + + setRowHighlightIndex(undefined); + } + }, + [data.fields, threshold] + ); + + useEffect(() => { + if (!panelContext || !enableSharedCrosshair || !hasTimeField(data) || footerPaginationEnabled) { + return; + } + + const subs = new Subscription(); + + subs.add( + panelContext.eventBus + .getStream(DataHoverEvent) + .pipe(debounceTime(250)) + .subscribe({ + next: (evt) => { + if (panelContext.eventBus === evt.origin) { + return; + } + + onDataHoverEvent(evt); + }, + }) + ); + + subs.add( + panelContext.eventBus + .getStream(DataHoverClearEvent) + .pipe(debounceTime(250)) + .subscribe({ + next: (evt) => { + if (panelContext.eventBus === evt.origin) { + return; + } + + setRowHighlightIndex(undefined); + }, + }) + ); + + return () => { + subs.unsubscribe(); + }; + }, [data, enableSharedCrosshair, footerPaginationEnabled, onDataHoverEvent, panelContext]); + + let scrollTop: number | undefined = undefined; + if (rowHighlightIndex !== undefined) { + const firstMatchedRowIndex = rows.findIndex((row) => row.index === rowHighlightIndex); + + if (firstMatchedRowIndex !== -1) { + scrollTop = headerHeight + (firstMatchedRowIndex - 1) * rowHeight; + } + } + + const rowIndexForPagination = useCallback( + (index: number) => { + return tableState.pageIndex * tableState.pageSize + index; + }, + [tableState.pageIndex, tableState.pageSize] + ); + + const RenderRow = useCallback( + ({ index, style, rowHighlightIndex }: { index: number; style: CSSProperties; rowHighlightIndex?: number }) => { + const indexForPagination = rowIndexForPagination(index); + const row = rows[indexForPagination]; + + prepareRow(row); + + const expandedRowStyle = tableState.expanded[row.index] ? css({ '&:hover': { background: 'inherit' } }) : {}; + + if (rowHighlightIndex !== undefined && row.index === rowHighlightIndex) { + style = { ...style, backgroundColor: theme.components.table.rowHoverBackground }; + } + + return ( +
onRowHover(index, data)} + onMouseLeave={onRowLeave} + > + {/*add the nested data to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/} + {nestedDataField && tableState.expanded[row.index] && ( + + )} + {row.cells.map((cell: Cell, index: number) => ( + + ))} +
+ ); + }, + [ + cellHeight, + data, + nestedDataField, + onCellFilterAdded, + onRowHover, + onRowLeave, + prepareRow, + rowIndexForPagination, + rows, + tableState.expanded, + tableStyles, + theme.components.table.rowHoverBackground, + timeRange, + width, + ] + ); + + const getItemSize = (index: number): number => { + const indexForPagination = rowIndexForPagination(index); + const row = rows[indexForPagination]; + if (tableState.expanded[row.index] && nestedDataField) { + return getExpandedRowHeight(nestedDataField, index, tableStyles); + } + + return tableStyles.rowHeight; + }; + + const handleScroll: UIEventHandler = (event) => { + const { scrollTop } = event.currentTarget; + + if (listRef.current !== null) { + listRef.current.scrollTo(scrollTop); + } + }; + + return ( + <> + + + {({ index, style }) => RenderRow({ index, style, rowHighlightIndex })} + + + + ); +}; diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index a80f31d5024..ffa4cab48bc 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -1,7 +1,5 @@ -import { css, cx } from '@emotion/css'; -import React, { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, UIEventHandler } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Cell, useAbsoluteLayout, useExpanded, useFilters, @@ -20,10 +18,9 @@ import { useTheme2 } from '../../themes'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; import { Pagination } from '../Pagination/Pagination'; -import { getExpandedRowHeight, ExpandedRow } from './ExpandedRow'; import { FooterRow } from './FooterRow'; import { HeaderRow } from './HeaderRow'; -import { TableCell } from './TableCell'; +import { RowsList } from './RowsList'; import { useFixScrollbarContainer, useResetVariableListSizeCache } from './hooks'; import { getInitialState, useTableStateReducer } from './reducer'; import { useTableStyles } from './styles'; @@ -50,6 +47,7 @@ export const Table = memo((props: Props) => { enablePagination, cellHeight = TableCellHeight.Sm, timeRange, + enableSharedCrosshair = false, } = props; const listRef = useRef(null); @@ -231,64 +229,6 @@ export const Table = memo((props: Props) => { useResetVariableListSizeCache(extendedState, listRef, data); useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef); - const rowIndexForPagination = useCallback( - (index: number) => { - return state.pageIndex * state.pageSize + index; - }, - [state.pageIndex, state.pageSize] - ); - - const RenderRow = useCallback( - ({ index, style }: { index: number; style: CSSProperties }) => { - const indexForPagination = rowIndexForPagination(index); - const row = rows[indexForPagination]; - - prepareRow(row); - - const expandedRowStyle = state.expanded[row.index] ? css({ '&:hover': { background: 'inherit' } }) : {}; - - return ( -
- {/*add the nested data to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/} - {nestedDataField && state.expanded[row.index] && ( - - )} - {row.cells.map((cell: Cell, index: number) => ( - - ))} -
- ); - }, - [ - rowIndexForPagination, - rows, - prepareRow, - state.expanded, - tableStyles, - nestedDataField, - width, - cellHeight, - onCellFilterAdded, - timeRange, - data, - ] - ); - const onNavigate = useCallback( (toPage: number) => { gotoPage(toPage - 1); @@ -322,24 +262,6 @@ export const Table = memo((props: Props) => { ); } - const getItemSize = (index: number): number => { - const indexForPagination = rowIndexForPagination(index); - const row = rows[indexForPagination]; - if (state.expanded[row.index] && nestedDataField) { - return getExpandedRowHeight(nestedDataField, index, tableStyles); - } - - return tableStyles.rowHeight; - }; - - const handleScroll: UIEventHandler = (event) => { - const { scrollTop } = event.currentTarget; - - if (listRef.current !== null) { - listRef.current.scrollTo(scrollTop); - } - }; - return (
{ )} {itemCount > 0 ? (
- - - {RenderRow} - - +
) : (
diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index 1cb4f5d85b4..517aefb1b90 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -94,6 +94,7 @@ export interface Props { cellHeight?: schema.TableCellHeight; /** @alpha Used by SparklineCell when provided */ timeRange?: TimeRange; + enableSharedCrosshair?: boolean; } /** diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index 91c18ebc029..efece819ba6 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -533,3 +533,33 @@ export function getAlignmentFactor( return alignmentFactor; } } + +// since the conversion from timeseries panel crosshair to time is pixel based, we need +// to set a threshold where the table row highlights when the crosshair is hovered over a certain point +// because multiple pixels (converted to times) may represent the same point/row in table +export function isPointTimeValAroundTableTimeVal(pointTime: number, rowTime: number, threshold: number) { + return Math.abs(Math.floor(pointTime) - rowTime) < threshold; +} + +// calculate the threshold for which we consider a point in a chart +// to match a row in a table based on a time value +export function calculateAroundPointThreshold(timeField: Field): number { + let max = -Number.MAX_VALUE; + let min = Number.MAX_VALUE; + + if (timeField.values.length < 2) { + return 0; + } + + for (let i = 0; i < timeField.values.length; i++) { + const value = timeField.values[i]; + if (value > max) { + max = value; + } + if (value < min) { + min = value; + } + } + + return (max - min) / timeField.values.length; +} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 06caa9270a8..206a92093ac 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1241,6 +1241,14 @@ var ( Owner: grafanaPluginsPlatformSquad, Created: time.Date(2023, time.November, 15, 12, 0, 0, 0, time.UTC), }, + { + Name: "tableSharedCrosshair", + Description: "Enables shared crosshair in table panel", + FrontendOnly: true, + Stage: FeatureStageExperimental, + Owner: grafanaBiSquad, + Created: time.Date(2023, time.December, 12, 12, 0, 0, 0, time.UTC), + }, { Name: "regressionTransformation", Description: "Enables regression analysis transformation", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index dde3c8e2e72..3ef7be8ae4a 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -147,6 +147,7 @@ datatrails,experimental,@grafana/dashboards-squad,2023-11-15,false,false,false,t alertingSimplifiedRouting,experimental,@grafana/alerting-squad,2023-11-10,false,false,false,false logRowsPopoverMenu,experimental,@grafana/observability-logs,2023-11-16,false,false,false,true pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,2023-11-15,false,false,false,false +tableSharedCrosshair,experimental,@grafana/grafana-bi-squad,2023-12-12,false,false,false,true regressionTransformation,experimental,@grafana/grafana-bi-squad,2023-11-24,false,false,false,true displayAnonymousStats,GA,@grafana/identity-access-team,2023-11-29,false,false,false,true alertStateHistoryAnnotationsFromLoki,experimental,@grafana/alerting-squad,2023-11-30,false,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 29e613f880f..e98c75c7bb4 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -599,6 +599,10 @@ const ( // Disables passing host environment variable to plugin processes FlagPluginsSkipHostEnvVars = "pluginsSkipHostEnvVars" + // FlagTableSharedCrosshair + // Enables shared crosshair in table panel + FlagTableSharedCrosshair = "tableSharedCrosshair" + // FlagRegressionTransformation // Enables regression analysis transformation FlagRegressionTransformation = "regressionTransformation" diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx index d9d4e85ec40..15df95afde4 100644 --- a/public/app/plugins/panel/table/TablePanel.tsx +++ b/public/app/plugins/panel/table/TablePanel.tsx @@ -1,8 +1,15 @@ import { css } from '@emotion/css'; import React from 'react'; -import { DataFrame, FieldMatcherID, getFrameDisplayName, PanelProps, SelectableValue } from '@grafana/data'; -import { PanelDataErrorView } from '@grafana/runtime'; +import { + DashboardCursorSync, + DataFrame, + FieldMatcherID, + getFrameDisplayName, + PanelProps, + SelectableValue, +} from '@grafana/data'; +import { config, PanelDataErrorView } from '@grafana/runtime'; import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui'; import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types'; @@ -37,6 +44,8 @@ export function TablePanel(props: Props) { tableHeight = height - inputHeight - padding; } + const enableSharedCrosshair = panelContext.sync && panelContext.sync() !== DashboardCursorSync.Off; + const tableElement = ( );