mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 11:11:47 +08:00
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
This commit is contained in:
@ -167,6 +167,7 @@ Experimental features might be changed or removed without prior notice.
|
|||||||
| `flameGraphItemCollapsing` | Allow collapsing of flame graph items |
|
| `flameGraphItemCollapsing` | Allow collapsing of flame graph items |
|
||||||
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected |
|
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected |
|
||||||
| `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes |
|
| `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes |
|
||||||
|
| `tableSharedCrosshair` | Enables shared crosshair in table panel |
|
||||||
| `regressionTransformation` | Enables regression analysis transformation |
|
| `regressionTransformation` | Enables regression analysis transformation |
|
||||||
|
|
||||||
## Development feature toggles
|
## Development feature toggles
|
||||||
|
@ -7,5 +7,11 @@ export * from './dimensions';
|
|||||||
export * from './ArrayDataFrame';
|
export * from './ArrayDataFrame';
|
||||||
export * from './DataFrameJSON';
|
export * from './DataFrameJSON';
|
||||||
export * from './frameComparisons';
|
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';
|
export { StreamingDataFrame, StreamingFrameAction, type StreamingFrameOptions, closestIdx } from './StreamingDataFrame';
|
||||||
|
@ -78,3 +78,11 @@ export function anySeriesWithTimeField(data: DataFrame[]) {
|
|||||||
}
|
}
|
||||||
return false;
|
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);
|
||||||
|
}
|
||||||
|
@ -166,6 +166,7 @@ export interface FeatureToggles {
|
|||||||
alertingSimplifiedRouting?: boolean;
|
alertingSimplifiedRouting?: boolean;
|
||||||
logRowsPopoverMenu?: boolean;
|
logRowsPopoverMenu?: boolean;
|
||||||
pluginsSkipHostEnvVars?: boolean;
|
pluginsSkipHostEnvVars?: boolean;
|
||||||
|
tableSharedCrosshair?: boolean;
|
||||||
regressionTransformation?: boolean;
|
regressionTransformation?: boolean;
|
||||||
displayAnonymousStats?: boolean;
|
displayAnonymousStats?: boolean;
|
||||||
alertStateHistoryAnnotationsFromLoki?: boolean;
|
alertStateHistoryAnnotationsFromLoki?: boolean;
|
||||||
|
301
packages/grafana-ui/src/components/Table/RowsList.tsx
Normal file
301
packages/grafana-ui/src/components/Table/RowsList.tsx
Normal file
@ -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<VariableSizeList>;
|
||||||
|
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<number | undefined>(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 (
|
||||||
|
<div
|
||||||
|
{...row.getRowProps({ style })}
|
||||||
|
className={cx(tableStyles.row, expandedRowStyle)}
|
||||||
|
onMouseEnter={() => 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] && (
|
||||||
|
<ExpandedRow
|
||||||
|
nestedData={nestedDataField}
|
||||||
|
tableStyles={tableStyles}
|
||||||
|
rowIndex={index}
|
||||||
|
width={width}
|
||||||
|
cellHeight={cellHeight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{row.cells.map((cell: Cell, index: number) => (
|
||||||
|
<TableCell
|
||||||
|
key={index}
|
||||||
|
tableStyles={tableStyles}
|
||||||
|
cell={cell}
|
||||||
|
onCellFilterAdded={onCellFilterAdded}
|
||||||
|
columnIndex={index}
|
||||||
|
columnCount={row.cells.length}
|
||||||
|
timeRange={timeRange}
|
||||||
|
frame={data}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true} scrollTop={scrollTop}>
|
||||||
|
<VariableSizeList
|
||||||
|
// This component needs an unmount/remount when row height or page changes
|
||||||
|
key={rowHeight + pageIndex}
|
||||||
|
height={listHeight}
|
||||||
|
itemCount={itemCount}
|
||||||
|
itemSize={getItemSize}
|
||||||
|
width={'100%'}
|
||||||
|
ref={listRef}
|
||||||
|
style={{ overflow: undefined }}
|
||||||
|
>
|
||||||
|
{({ index, style }) => RenderRow({ index, style, rowHighlightIndex })}
|
||||||
|
</VariableSizeList>
|
||||||
|
</CustomScrollbar>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,7 +1,5 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import React, { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, UIEventHandler } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Cell,
|
|
||||||
useAbsoluteLayout,
|
useAbsoluteLayout,
|
||||||
useExpanded,
|
useExpanded,
|
||||||
useFilters,
|
useFilters,
|
||||||
@ -20,10 +18,9 @@ import { useTheme2 } from '../../themes';
|
|||||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||||
import { Pagination } from '../Pagination/Pagination';
|
import { Pagination } from '../Pagination/Pagination';
|
||||||
|
|
||||||
import { getExpandedRowHeight, ExpandedRow } from './ExpandedRow';
|
|
||||||
import { FooterRow } from './FooterRow';
|
import { FooterRow } from './FooterRow';
|
||||||
import { HeaderRow } from './HeaderRow';
|
import { HeaderRow } from './HeaderRow';
|
||||||
import { TableCell } from './TableCell';
|
import { RowsList } from './RowsList';
|
||||||
import { useFixScrollbarContainer, useResetVariableListSizeCache } from './hooks';
|
import { useFixScrollbarContainer, useResetVariableListSizeCache } from './hooks';
|
||||||
import { getInitialState, useTableStateReducer } from './reducer';
|
import { getInitialState, useTableStateReducer } from './reducer';
|
||||||
import { useTableStyles } from './styles';
|
import { useTableStyles } from './styles';
|
||||||
@ -50,6 +47,7 @@ export const Table = memo((props: Props) => {
|
|||||||
enablePagination,
|
enablePagination,
|
||||||
cellHeight = TableCellHeight.Sm,
|
cellHeight = TableCellHeight.Sm,
|
||||||
timeRange,
|
timeRange,
|
||||||
|
enableSharedCrosshair = false,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const listRef = useRef<VariableSizeList>(null);
|
const listRef = useRef<VariableSizeList>(null);
|
||||||
@ -231,64 +229,6 @@ export const Table = memo((props: Props) => {
|
|||||||
useResetVariableListSizeCache(extendedState, listRef, data);
|
useResetVariableListSizeCache(extendedState, listRef, data);
|
||||||
useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef);
|
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 (
|
|
||||||
<div {...row.getRowProps({ style })} className={cx(tableStyles.row, expandedRowStyle)}>
|
|
||||||
{/*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] && (
|
|
||||||
<ExpandedRow
|
|
||||||
nestedData={nestedDataField}
|
|
||||||
tableStyles={tableStyles}
|
|
||||||
rowIndex={index}
|
|
||||||
width={width}
|
|
||||||
cellHeight={cellHeight}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{row.cells.map((cell: Cell, index: number) => (
|
|
||||||
<TableCell
|
|
||||||
key={index}
|
|
||||||
tableStyles={tableStyles}
|
|
||||||
cell={cell}
|
|
||||||
onCellFilterAdded={onCellFilterAdded}
|
|
||||||
columnIndex={index}
|
|
||||||
columnCount={row.cells.length}
|
|
||||||
timeRange={timeRange}
|
|
||||||
frame={data}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
rowIndexForPagination,
|
|
||||||
rows,
|
|
||||||
prepareRow,
|
|
||||||
state.expanded,
|
|
||||||
tableStyles,
|
|
||||||
nestedDataField,
|
|
||||||
width,
|
|
||||||
cellHeight,
|
|
||||||
onCellFilterAdded,
|
|
||||||
timeRange,
|
|
||||||
data,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onNavigate = useCallback(
|
const onNavigate = useCallback(
|
||||||
(toPage: number) => {
|
(toPage: number) => {
|
||||||
gotoPage(toPage - 1);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
{...getTableProps()}
|
{...getTableProps()}
|
||||||
@ -356,20 +278,26 @@ export const Table = memo((props: Props) => {
|
|||||||
)}
|
)}
|
||||||
{itemCount > 0 ? (
|
{itemCount > 0 ? (
|
||||||
<div data-testid={selectors.components.Panels.Visualization.Table.body} ref={variableSizeListScrollbarRef}>
|
<div data-testid={selectors.components.Panels.Visualization.Table.body} ref={variableSizeListScrollbarRef}>
|
||||||
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true}>
|
<RowsList
|
||||||
<VariableSizeList
|
data={data}
|
||||||
// This component needs an unmount/remount when row height or page changes
|
rows={rows}
|
||||||
key={tableStyles.rowHeight + state.pageIndex}
|
width={width}
|
||||||
height={listHeight}
|
cellHeight={cellHeight}
|
||||||
itemCount={itemCount}
|
headerHeight={headerHeight}
|
||||||
itemSize={getItemSize}
|
rowHeight={tableStyles.rowHeight}
|
||||||
width={'100%'}
|
itemCount={itemCount}
|
||||||
ref={listRef}
|
pageIndex={state.pageIndex}
|
||||||
style={{ overflow: undefined }}
|
listHeight={listHeight}
|
||||||
>
|
listRef={listRef}
|
||||||
{RenderRow}
|
tableState={state}
|
||||||
</VariableSizeList>
|
prepareRow={prepareRow}
|
||||||
</CustomScrollbar>
|
timeRange={timeRange}
|
||||||
|
onCellFilterAdded={onCellFilterAdded}
|
||||||
|
nestedDataField={nestedDataField}
|
||||||
|
tableStyles={tableStyles}
|
||||||
|
footerPaginationEnabled={Boolean(enablePagination)}
|
||||||
|
enableSharedCrosshair={enableSharedCrosshair}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ height: height - headerHeight, width }} className={tableStyles.noData}>
|
<div style={{ height: height - headerHeight, width }} className={tableStyles.noData}>
|
||||||
|
@ -94,6 +94,7 @@ export interface Props {
|
|||||||
cellHeight?: schema.TableCellHeight;
|
cellHeight?: schema.TableCellHeight;
|
||||||
/** @alpha Used by SparklineCell when provided */
|
/** @alpha Used by SparklineCell when provided */
|
||||||
timeRange?: TimeRange;
|
timeRange?: TimeRange;
|
||||||
|
enableSharedCrosshair?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -533,3 +533,33 @@ export function getAlignmentFactor(
|
|||||||
return alignmentFactor;
|
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;
|
||||||
|
}
|
||||||
|
@ -1241,6 +1241,14 @@ var (
|
|||||||
Owner: grafanaPluginsPlatformSquad,
|
Owner: grafanaPluginsPlatformSquad,
|
||||||
Created: time.Date(2023, time.November, 15, 12, 0, 0, 0, time.UTC),
|
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",
|
Name: "regressionTransformation",
|
||||||
Description: "Enables regression analysis transformation",
|
Description: "Enables regression analysis transformation",
|
||||||
|
@ -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
|
alertingSimplifiedRouting,experimental,@grafana/alerting-squad,2023-11-10,false,false,false,false
|
||||||
logRowsPopoverMenu,experimental,@grafana/observability-logs,2023-11-16,false,false,false,true
|
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
|
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
|
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
|
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
|
alertStateHistoryAnnotationsFromLoki,experimental,@grafana/alerting-squad,2023-11-30,false,false,true,false
|
||||||
|
|
@ -599,6 +599,10 @@ const (
|
|||||||
// Disables passing host environment variable to plugin processes
|
// Disables passing host environment variable to plugin processes
|
||||||
FlagPluginsSkipHostEnvVars = "pluginsSkipHostEnvVars"
|
FlagPluginsSkipHostEnvVars = "pluginsSkipHostEnvVars"
|
||||||
|
|
||||||
|
// FlagTableSharedCrosshair
|
||||||
|
// Enables shared crosshair in table panel
|
||||||
|
FlagTableSharedCrosshair = "tableSharedCrosshair"
|
||||||
|
|
||||||
// FlagRegressionTransformation
|
// FlagRegressionTransformation
|
||||||
// Enables regression analysis transformation
|
// Enables regression analysis transformation
|
||||||
FlagRegressionTransformation = "regressionTransformation"
|
FlagRegressionTransformation = "regressionTransformation"
|
||||||
|
@ -1,8 +1,15 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { DataFrame, FieldMatcherID, getFrameDisplayName, PanelProps, SelectableValue } from '@grafana/data';
|
import {
|
||||||
import { PanelDataErrorView } from '@grafana/runtime';
|
DashboardCursorSync,
|
||||||
|
DataFrame,
|
||||||
|
FieldMatcherID,
|
||||||
|
getFrameDisplayName,
|
||||||
|
PanelProps,
|
||||||
|
SelectableValue,
|
||||||
|
} from '@grafana/data';
|
||||||
|
import { config, PanelDataErrorView } from '@grafana/runtime';
|
||||||
import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui';
|
import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui';
|
||||||
import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types';
|
import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types';
|
||||||
|
|
||||||
@ -37,6 +44,8 @@ export function TablePanel(props: Props) {
|
|||||||
tableHeight = height - inputHeight - padding;
|
tableHeight = height - inputHeight - padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enableSharedCrosshair = panelContext.sync && panelContext.sync() !== DashboardCursorSync.Off;
|
||||||
|
|
||||||
const tableElement = (
|
const tableElement = (
|
||||||
<Table
|
<Table
|
||||||
height={tableHeight}
|
height={tableHeight}
|
||||||
@ -53,6 +62,7 @@ export function TablePanel(props: Props) {
|
|||||||
enablePagination={options.footer?.enablePagination}
|
enablePagination={options.footer?.enablePagination}
|
||||||
cellHeight={options.cellHeight}
|
cellHeight={options.cellHeight}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
|
enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user