Table: Introduce sparkline cell type (#63182)

pull/61477/head
Domas 2 years ago committed by GitHub
parent c955c20670
commit 548a5054ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1744
      devenv/dev-dashboards/panel-table/table_tests_new.json
  2. 1
      docs/sources/developers/kinds/composable/tablepanelcfg/schema-reference.md
  3. 6
      docs/sources/panels-visualizations/query-transform-data/transform-data/index.md
  4. 8
      docs/sources/panels-visualizations/visualizations/table/index.md
  5. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  6. 2
      packages/grafana-data/src/dataframe/index.ts
  7. 6
      packages/grafana-data/src/dataframe/utils.ts
  8. 2
      packages/grafana-data/src/field/index.ts
  9. 1
      packages/grafana-data/src/transformations/transformers/ids.ts
  10. 4
      packages/grafana-data/src/transformations/transformers/seriesToRows.ts
  11. 1
      packages/grafana-data/src/types/dataFrame.ts
  12. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  13. 21
      packages/grafana-schema/src/common/common.gen.ts
  14. 3
      packages/grafana-schema/src/common/mudball.cue
  15. 10
      packages/grafana-schema/src/common/table.cue
  16. 7
      packages/grafana-ui/src/components/Sparkline/Sparkline.tsx
  17. 4
      packages/grafana-ui/src/components/Table/FooterRow.tsx
  18. 114
      packages/grafana-ui/src/components/Table/SparklineCell.tsx
  19. 10
      packages/grafana-ui/src/components/Table/Table.tsx
  20. 37
      packages/grafana-ui/src/components/Table/styles.ts
  21. 2
      packages/grafana-ui/src/components/Table/types.ts
  22. 15
      packages/grafana-ui/src/components/Table/utils.ts
  23. 1
      pkg/services/featuremgmt/codeowners.go
  24. 7
      pkg/services/featuremgmt/registry.go
  25. 4
      pkg/services/featuremgmt/toggles_gen.go
  26. 5
      public/app/features/alerting/unified/components/expressions/Expression.tsx
  27. 5
      public/app/features/alerting/unified/components/rule-editor/util.ts
  28. 7
      public/app/features/search/page/components/SearchResultsTable.tsx
  29. 3
      public/app/features/transformers/standardTransformers.ts
  30. 25
      public/app/features/transformers/timeSeriesTable/TimeSeriesTableTransformEditor.tsx
  31. 104
      public/app/features/transformers/timeSeriesTable/timeSeriesTableTransformer.test.ts
  32. 127
      public/app/features/transformers/timeSeriesTable/timeSeriesTableTransformer.ts
  33. 11
      public/app/plugins/panel/table/TableCellOptionEditor.tsx
  34. 1
      public/app/plugins/panel/table/TablePanel.tsx
  35. 79
      public/app/plugins/panel/table/cells/SparklineCellOptionsEditor.tsx
  36. 20
      public/app/plugins/panel/table/module.tsx
  37. 2
      public/app/plugins/panel/table/panelcfg.cue
  38. 5
      public/app/plugins/panel/table/panelcfg.gen.ts

File diff suppressed because it is too large Load Diff

@ -23,6 +23,7 @@ title: TablePanelCfg kind
|-----------------|---------------------------------------------------|----------|--------------------------------------------------------------------------------------|
| `frameIndex` | number | **Yes** | Represents the index of the selected frame Default: `0`. |
| `showHeader` | boolean | **Yes** | Controls whether the panel should show the header Default: `true`. |
| `cellHeight` | string | No | Height of a table cell<br/>Possible values are: `sm`, `md`, `lg`. |
| `footer` | [object](#footer) | No | Controls footer options Default: `map[countRows:false reducer:[] show:false]`. |
| `showRowNums` | boolean | No | Controls whether the columns should be numbered Default: `false`. |
| `showTypeIcons` | boolean | No | Controls whether the header should show icons for the column types Default: `false`. |

@ -732,3 +732,9 @@ Here is the result after adding a Limit transformation with a value of '3':
| 2020-07-07 11:34:20 | Temperature | 25 |
| 2020-07-07 11:34:20 | Humidity | 22 |
| 2020-07-07 10:32:20 | Humidity | 29 |
### Time series to table transform
> **Note:** This transformation is available in Grafana 9.5+ as an opt-in beta feature. Modify Grafana [configuration file]({{< relref "../../../setup-grafana/configure-grafana/#configuration-file-location" >}}) to enable the `timeSeriesTable` [feature toggle]({{< relref "../../../setup-grafana/configure-grafana/#feature_toggles" >}}) to use it.
Use this transformation to convert time series result into a table, converting time series data frame into a "Trend" field. "Trend" field can then be rendered using [sparkline cell type]({{< relref "../../visualizations/table/#sparkline" >}}), producing an inline sparkline for each table row. If there are multiple time series queries, each will result in a separate table data frame. These can be joined using join or merge transforms to produce a single table with multiple sparklines per row.

@ -122,6 +122,14 @@ If you have a field value that is an image URL or a base64 encoded image you can
{{< figure src="/static/img/docs/v73/table_hover.gif" max-width="900px" caption="Table hover" >}}
### Sparkline
> **Note:** This cell type is available in Grafana 9.5+ as an opt-in beta feature. Modify Grafana [configuration file]({{< relref "../../../setup-grafana/configure-grafana/#configuration-file-location" >}}) to enable the `timeSeriesTable` [feature toggle]({{< relref "../../../setup-grafana/configure-grafana/#feature_toggles" >}}) to use it.
Shows value rendered as a sparkline. Requires [time series to table]({{< relref "../../query-transform-data/transform-data/#time-series-to-table-transform" >}}) data transform.
{{< figure src="/static/img/docs/tables/sparkline.png" max-width="500px" caption="Sparkline" class="docs-image--no-shadow" >}}
## Cell value inspect
Enables value inspection from table cell. The raw value is presented in a modal window.

@ -94,6 +94,7 @@ Alpha features might be changed or removed without prior notice.
| `drawerDataSourcePicker` | Changes the user experience for data source selection to a drawer. |
| `traceqlSearch` | Enables the 'TraceQL Search' tab for the Tempo datasource which provides a UI to generate TraceQL queries |
| `prometheusMetricEncyclopedia` | Replaces the Prometheus query builder metric select option with a paginated and filterable component |
| `timeSeriesTable` | Enable time series table transformer & sparkline cell type |
## Development feature toggles

@ -7,4 +7,4 @@ export * from './dimensions';
export * from './ArrayDataFrame';
export * from './DataFrameJSON';
export * from './frameComparisons';
export { anySeriesWithTimeField } from './utils';
export { anySeriesWithTimeField, isTimeSeriesFrame, isTimeSeriesFrames } from './utils';

@ -2,15 +2,15 @@ import { DataFrame, FieldType } from '../types/dataFrame';
import { getTimeField } from './processDataFrame';
export function isTimeSerie(frame: DataFrame) {
export function isTimeSeriesFrame(frame: DataFrame) {
if (frame.fields.length > 2) {
return false;
}
return Boolean(frame.fields.find((field) => field.type === FieldType.time));
}
export function isTimeSeries(data: DataFrame[]) {
return !data.find((frame) => !isTimeSerie(frame));
export function isTimeSeriesFrames(data: DataFrame[]) {
return !data.find((frame) => !isTimeSeriesFrame(frame));
}
/**

@ -15,4 +15,4 @@ export { sortThresholds, getActiveThreshold } from './thresholds';
export { applyFieldOverrides, validateFieldConfig, applyRawFieldOverrides, useFieldOverrides } from './fieldOverrides';
export { getFieldDisplayValuesProxy } from './getFieldDisplayValuesProxy';
export { getFieldDisplayName, getFrameDisplayName } from './fieldState';
export { getScaleCalculator, getFieldConfigWithMinMax } from './scale';
export { getScaleCalculator, getFieldConfigWithMinMax, getMinMaxAndDelta } from './scale';

@ -36,4 +36,5 @@ export enum DataTransformerID {
groupingToMatrix = 'groupingToMatrix',
limit = 'limit',
partitionByValues = 'partitionByValues',
timeSeriesTable = 'timeSeriesTable',
}

@ -2,7 +2,7 @@ import { omit } from 'lodash';
import { map } from 'rxjs/operators';
import { MutableDataFrame, sortDataFrame } from '../../dataframe';
import { isTimeSeries } from '../../dataframe/utils';
import { isTimeSeriesFrames } from '../../dataframe/utils';
import { getFrameDisplayName } from '../../field/fieldState';
import {
Field,
@ -30,7 +30,7 @@ export const seriesToRowsTransformer: DataTransformerInfo<SeriesToRowsTransforme
return data;
}
if (!isTimeSeries(data)) {
if (!isTimeSeriesFrames(data)) {
return data;
}

@ -18,6 +18,7 @@ export enum FieldType {
geo = 'geo',
enum = 'enum',
other = 'other', // Object, Array, etc
frame = 'frame', // DataFrame
}
/**

@ -82,4 +82,5 @@ export interface FeatureToggles {
drawerDataSourcePicker?: boolean;
traceqlSearch?: boolean;
prometheusMetricEncyclopedia?: boolean;
timeSeriesTable?: boolean;
}

@ -601,6 +601,8 @@ export interface VizTooltipOptions {
sort: SortOrder;
}
export interface Labels {}
/**
* Internally, this is the "type" of cell that's being displayed
* in the table such as colored text, JSON, gauge, etc.
@ -618,6 +620,7 @@ export enum TableCellDisplayMode {
Image = 'image',
JSONView = 'json-view',
LcdGauge = 'lcd-gauge',
Sparkline = 'sparkline',
}
/**
@ -696,6 +699,13 @@ export interface TableBarGaugeCellOptions {
type: TableCellDisplayMode.Gauge;
}
/**
* Sparkline cell options
*/
export interface TableSparklineCellOptions extends GraphFieldConfig {
type: TableCellDisplayMode.Sparkline;
}
/**
* Colored background cell options
*/
@ -708,7 +718,7 @@ export interface TableColoredBackgroundCellOptions {
* Table cell options. Each cell has a display mode
* and other potential options for that display.
*/
export type TableCellOptions = (TableAutoCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableJsonViewCellOptions);
export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableJsonViewCellOptions);
/**
* Use UTC/GMT timezone
@ -769,7 +779,14 @@ export enum LogsDedupStrategy {
signature = 'signature',
}
export interface Labels {}
/**
* Height of a table cell
*/
export enum TableCellHeight {
Lg = 'lg',
Md = 'md',
Sm = 'sm',
}
/**
* Field options for each field within a table (e.g 10, "The String", 64.20, etc.)

@ -251,3 +251,6 @@ VizTooltipOptions: {
Labels: {
[string]: string
} @cuetsy(kind="interface")
// Height of a table cell
TableCellHeight: "sm" | "md" | "lg" @cuetsy(kind="enum")

@ -4,7 +4,7 @@ package common
// in the table such as colored text, JSON, gauge, etc.
// The color-background-solid, gradient-gauge, and lcd-gauge
// modes are deprecated in favor of new cell subOptions
TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge")
TableCellDisplayMode: "auto" | "color-text" | "color-background" | "color-background-solid" | "gradient-gauge" | "lcd-gauge" | "json-view" | "basic" | "image" | "gauge" | "sparkline" @cuetsy(kind="enum",memberNames="Auto|ColorText|ColorBackground|ColorBackgroundSolid|GradientGauge|LcdGauge|JSONView|BasicGauge|Image|Gauge|Sparkline")
// Display mode to the "Colored Background" display
// mode for table cells. Either displays a solid color (basic mode)
@ -54,6 +54,12 @@ TableBarGaugeCellOptions: {
mode?: BarGaugeDisplayMode
} @cuetsy(kind="interface")
// Sparkline cell options
TableSparklineCellOptions: {
GraphFieldConfig
type: TableCellDisplayMode & "sparkline"
} @cuetsy(kind="interface")
// Colored background cell options
TableColoredBackgroundCellOptions: {
type: TableCellDisplayMode & "color-background"
@ -62,7 +68,7 @@ TableColoredBackgroundCellOptions: {
// Table cell options. Each cell has a display mode
// and other potential options for that display.
TableCellOptions: TableAutoCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableJsonViewCellOptions @cuetsy(kind="type")
TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TableJsonViewCellOptions @cuetsy(kind="type")
// Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
// Generally defines alignment, filtering capabilties, display options, etc.

@ -44,6 +44,7 @@ const defaultConfig: GraphFieldConfig = {
drawStyle: GraphDrawStyle.Line,
showPoints: VisibilityMode.Auto,
axisPlacement: AxisPlacement.Hidden,
pointSize: 2,
};
/** @internal */
@ -181,6 +182,8 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
pxAlign: false,
scaleKey,
theme,
colorMode,
thresholds: config.thresholds,
drawStyle: customConfig.drawStyle!,
lineColor: customConfig.lineColor ?? seriesColor,
lineWidth: customConfig.lineWidth,
@ -188,7 +191,9 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
showPoints: pointsMode,
pointSize: customConfig.pointSize,
fillOpacity: customConfig.fillOpacity,
fillColor: customConfig.fillColor ?? seriesColor,
fillColor: customConfig.fillColor,
lineStyle: customConfig.lineStyle,
gradientMode: customConfig.gradientMode,
});
}

@ -16,7 +16,7 @@ export interface FooterRowProps {
tableStyles: TableStyles;
}
export const FooterRow = (props: FooterRowProps) => {
export function FooterRow(props: FooterRowProps) {
const { totalColumnsWidth, footerGroups, isPaginationVisible, tableStyles } = props;
const e2eSelectorsTable = selectors.components.Panels.Visualization.Table;
@ -38,7 +38,7 @@ export const FooterRow = (props: FooterRowProps) => {
})}
</div>
);
};
}
function renderFooterCell(column: ColumnInstance, tableStyles: TableStyles) {
const footerProps = column.getHeaderProps();

@ -0,0 +1,114 @@
import { isArray } from 'lodash';
import React, { FC } from 'react';
import {
ArrayVector,
FieldType,
FieldConfig,
getMinMaxAndDelta,
FieldSparkline,
isDataFrame,
Field,
} from '@grafana/data';
import {
BarAlignment,
GraphDrawStyle,
GraphFieldConfig,
GraphGradientMode,
LineInterpolation,
TableSparklineCellOptions,
TableCellDisplayMode,
VisibilityMode,
} from '@grafana/schema';
import { Sparkline } from '../Sparkline/Sparkline';
import { TableCellProps } from './types';
import { getCellOptions } from './utils';
export const defaultSparklineCellConfig: GraphFieldConfig = {
drawStyle: GraphDrawStyle.Line,
lineInterpolation: LineInterpolation.Smooth,
lineWidth: 1,
fillOpacity: 17,
gradientMode: GraphGradientMode.Hue,
pointSize: 2,
barAlignment: BarAlignment.Center,
showPoints: VisibilityMode.Never,
};
export const SparklineCell: FC<TableCellProps> = (props) => {
const { field, innerWidth, tableStyles, cell, cellProps } = props;
const sparkline = getSparkline(cell.value);
if (!sparkline) {
return (
<div {...cellProps} className={tableStyles.cellContainer}>
no data
</div>
);
}
const range = getMinMaxAndDelta(sparkline.y);
sparkline.y.config.min = range.min;
sparkline.y.config.max = range.max;
sparkline.y.state = { range };
const cellOptions = getTableSparklineCellOptions(field);
const config: FieldConfig<GraphFieldConfig> = {
color: field.config.color,
custom: {
...defaultSparklineCellConfig,
...cellOptions,
},
};
return (
<div {...cellProps} className={tableStyles.cellContainer}>
<Sparkline
width={innerWidth}
height={tableStyles.cellHeightInner}
sparkline={sparkline}
config={config}
theme={tableStyles.theme}
/>
</div>
);
};
function getSparkline(value: unknown): FieldSparkline | undefined {
if (isArray(value)) {
return {
y: {
name: 'test',
type: FieldType.number,
values: new ArrayVector(value),
config: {},
},
};
}
if (isDataFrame(value)) {
const timeField = value.fields.find((x) => x.type === FieldType.time);
const numberField = value.fields.find((x) => x.type === FieldType.number);
if (timeField && numberField) {
return { x: timeField, y: numberField };
}
}
return;
}
function getTableSparklineCellOptions(field: Field): TableSparklineCellOptions {
let options = getCellOptions(field);
if (options.type === TableCellDisplayMode.Auto) {
options = { ...options, type: TableCellDisplayMode.Sparkline };
}
if (options.type === TableCellDisplayMode.Sparkline) {
return options;
}
throw new Error(`Excpected options type ${TableCellDisplayMode.Sparkline} but got ${options.type}`);
}

@ -13,8 +13,9 @@ import {
import { VariableSizeList } from 'react-window';
import { DataFrame, Field, ReducerID } from '@grafana/data';
import { TableCellHeight } from '@grafana/schema';
import { useStyles2, useTheme2 } from '../../themes';
import { useTheme2 } from '../../themes';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { Pagination } from '../Pagination/Pagination';
@ -23,7 +24,7 @@ import { HeaderRow } from './HeaderRow';
import { TableCell } from './TableCell';
import { useFixScrollbarContainer, useResetVariableListSizeCache } from './hooks';
import { getInitialState, useTableStateReducer } from './reducer';
import { getTableStyles } from './styles';
import { useTableStyles } from './styles';
import { FooterItem, GrafanaTableState, Props } from './types';
import {
getColumns,
@ -56,13 +57,14 @@ export const Table = memo((props: Props) => {
showTypeIcons,
footerValues,
enablePagination,
cellHeight = TableCellHeight.Md,
} = props;
const listRef = useRef<VariableSizeList>(null);
const tableDivRef = useRef<HTMLDivElement>(null);
const variableSizeListScrollbarRef = useRef<HTMLDivElement>(null);
const tableStyles = useStyles2(getTableStyles);
const theme = useTheme2();
const tableStyles = useTableStyles(theme, cellHeight);
const headerHeight = noHeader ? 0 : tableStyles.rowHeight;
const [footerItems, setFooterItems] = useState<FooterItem[] | undefined>(footerValues);
@ -385,6 +387,8 @@ export const Table = memo((props: Props) => {
<div ref={variableSizeListScrollbarRef}>
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true}>
<VariableSizeList
// This component needs an unmount/remount when row height changes
key={tableStyles.rowHeight}
height={listHeight}
itemCount={itemCount}
itemSize={getItemSize}

@ -1,15 +1,15 @@
import { css, CSSObject } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { TableCellHeight } from '@grafana/schema';
export const getTableStyles = (theme: GrafanaTheme2) => {
export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCellHeight) {
const borderColor = theme.colors.border.weak;
const resizerColor = theme.colors.primary.border;
const cellPadding = 6;
const lineHeight = theme.typography.body.lineHeight;
const bodyFontSize = 14;
const cellHeight = cellPadding * 2 + bodyFontSize * lineHeight;
const cellHeight = getCellHeight(theme, cellHeightOption, cellPadding);
const rowHeight = cellHeight + 2;
const headerHeight = 28;
const rowHoverBg = theme.colors.emphasize(theme.colors.background.primary, 0.03);
const buildCellContainerStyle = (color?: string, background?: string, overflowOnHover?: boolean) => {
@ -95,7 +95,7 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
cellHeight,
buildCellContainerStyle,
cellPadding,
cellHeightInner: bodyFontSize * lineHeight,
cellHeightInner: cellHeight - cellPadding * 2,
rowHeight,
table: css`
height: 100%;
@ -106,14 +106,14 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
`,
thead: css`
label: thead;
height: ${rowHeight}px;
height: ${headerHeight}px;
overflow-y: auto;
overflow-x: hidden;
position: relative;
`,
tfoot: css`
label: tfoot;
height: ${rowHeight}px;
height: ${headerHeight}px;
border-top: 1px solid ${borderColor};
overflow-y: auto;
overflow-x: hidden;
@ -124,10 +124,12 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
border-bottom: 1px solid ${borderColor};
`,
headerCell: css`
padding: ${cellPadding}px;
height: 100%;
padding: 0 ${cellPadding}px;
overflow: hidden;
white-space: nowrap;
display: flex;
align-items: center;
font-weight: ${theme.typography.fontWeightMedium};
&:last-child {
@ -285,6 +287,21 @@ export const getTableStyles = (theme: GrafanaTheme2) => {
cursor: pointer;
`,
};
};
}
export type TableStyles = ReturnType<typeof useTableStyles>;
function getCellHeight(theme: GrafanaTheme2, cellHeightOption: TableCellHeight, cellPadding: number) {
const bodyFontSize = theme.typography.fontSize;
const lineHeight = theme.typography.body.lineHeight;
export type TableStyles = ReturnType<typeof getTableStyles>;
switch (cellHeightOption) {
case 'md':
return 42;
case 'lg':
return 48;
case 'sm':
default:
return cellPadding * 2 + bodyFontSize * lineHeight;
}
}

@ -3,6 +3,7 @@ import { FC } from 'react';
import { CellProps, Column, Row, TableState, UseExpandedRowProps } from 'react-table';
import { DataFrame, Field, KeyValue, SelectableValue } from '@grafana/data';
import { TableCellHeight } from '@grafana/schema';
import { TableStyles } from './styles';
@ -84,6 +85,7 @@ export interface Props {
footerOptions?: TableFooterCalc;
footerValues?: FooterItem[];
enablePagination?: boolean;
cellHeight?: TableCellHeight;
/** @alpha */
subData?: DataFrame[];
}

@ -15,6 +15,8 @@ import {
reduceField,
GrafanaTheme2,
ArrayVector,
isDataFrame,
isTimeSeriesFrame,
} from '@grafana/data';
import {
BarGaugeDisplayMode,
@ -30,6 +32,7 @@ import { GeoCell } from './GeoCell';
import { ImageCell } from './ImageCell';
import { JSONViewCell } from './JSONViewCell';
import { RowExpander } from './RowExpander';
import { SparklineCell } from './SparklineCell';
import {
CellComponent,
TableCellDisplayMode,
@ -190,6 +193,8 @@ export function getCellComponent(displayMode: TableCellDisplayMode, field: Field
return ImageCell;
case TableCellDisplayMode.Gauge:
return BarGaugeCell;
case TableCellDisplayMode.Sparkline:
return SparklineCell;
case TableCellDisplayMode.JSONView:
return JSONViewCell;
}
@ -198,10 +203,20 @@ export function getCellComponent(displayMode: TableCellDisplayMode, field: Field
return GeoCell;
}
if (field.type === FieldType.frame) {
const firstValue = field.values.get(0);
if (isDataFrame(firstValue) && isTimeSeriesFrame(firstValue)) {
return SparklineCell;
}
return JSONViewCell;
}
// Default or Auto
if (field.type === FieldType.other) {
return JSONViewCell;
}
return DefaultCell;
}

@ -21,4 +21,5 @@ const (
grafanaAlertingSquad codeowner = "@grafana/alerting-squad"
hostedGrafanaTeam codeowner = "@grafana/hosted-grafana-team"
awsPluginsSquad codeowner = "@grafana/aws-plugins"
appO11ySquad codeowner = "@grafana/app-o11y"
)

@ -438,5 +438,12 @@ var (
FrontendOnly: true,
Owner: grafanaObservabilityMetricsSquad,
},
{
Name: "timeSeriesTable",
Description: "Enable time series table transformer & sparkline cell type",
State: FeatureStateAlpha,
FrontendOnly: true,
Owner: appO11ySquad,
},
}
)

@ -270,4 +270,8 @@ const (
// FlagPrometheusMetricEncyclopedia
// Replaces the Prometheus query builder metric select option with a paginated and filterable component
FlagPrometheusMetricEncyclopedia = "prometheusMetricEncyclopedia"
// FlagTimeSeriesTable
// Enable time series table transformer &amp; sparkline cell type
FlagTimeSeriesTable = "timeSeriesTable"
)

@ -2,8 +2,7 @@ import { css, cx } from '@emotion/css';
import { capitalize, uniqueId } from 'lodash';
import React, { FC, useCallback, useState } from 'react';
import { DataFrame, dateTimeFormat, GrafanaTheme2, LoadingState, PanelData } from '@grafana/data';
import { isTimeSeries } from '@grafana/data/src/dataframe/utils';
import { DataFrame, dateTimeFormat, GrafanaTheme2, LoadingState, PanelData, isTimeSeriesFrames } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { AutoSizeInput, clearButtonStyles, Icon, IconButton, Select, useStyles2 } from '@grafana/ui';
import { ClassicConditions } from 'app/features/expressions/components/ClassicConditions';
@ -138,7 +137,7 @@ export const ExpressionResult: FC<ExpressionResultProps> = ({ series, isAlertCon
// sometimes we receive results where every value is just "null" when noData occurs
const emptyResults = isEmptySeries(series);
const isTimeSeriesResults = !emptyResults && isTimeSeries(series);
const isTimeSeriesResults = !emptyResults && isTimeSeriesFrames(series);
return (
<div className={styles.expression.results}>

@ -1,7 +1,6 @@
import { ValidateResult } from 'react-hook-form';
import { DataFrame, ThresholdsConfig, ThresholdsMode } from '@grafana/data';
import { isTimeSeries } from '@grafana/data/src/dataframe/utils';
import { DataFrame, ThresholdsConfig, ThresholdsMode, isTimeSeriesFrames } from '@grafana/data';
import { GraphTresholdsStyleMode } from '@grafana/schema';
import { config } from 'app/core/config';
import { EvalFunction } from 'app/features/alerting/state/alertDef';
@ -98,7 +97,7 @@ export function errorFromSeries(series: DataFrame[]): Error | undefined {
return;
}
const isTimeSeriesResults = isTimeSeries(series);
const isTimeSeriesResults = isTimeSeriesFrames(series);
let error;
if (isTimeSeriesResults) {

@ -7,9 +7,10 @@ import InfiniteLoader from 'react-window-infinite-loader';
import { Observable } from 'rxjs';
import { Field, GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { TableCellHeight } from '@grafana/schema';
import { useStyles2, useTheme2 } from '@grafana/ui';
import { TableCell } from '@grafana/ui/src/components/Table/TableCell';
import { getTableStyles } from '@grafana/ui/src/components/Table/styles';
import { useTableStyles } from '@grafana/ui/src/components/Table/styles';
import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection';
import { QueryResponse } from '../../service';
@ -51,7 +52,7 @@ export const SearchResultsTable = React.memo(
}: SearchResultsProps) => {
const styles = useStyles2(getStyles);
const columnStyles = useStyles2(getColumnStyles);
const tableStyles = useStyles2(getTableStyles);
const tableStyles = useTableStyles(useTheme2(), TableCellHeight.Md);
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const [listEl, setListEl] = useState<FixedSizeList | null>(null);
const highlightIndex = useSearchKeyboardNavigation(keyboardEvents, 0, response);

@ -1,4 +1,5 @@
import { TransformerRegistryItem } from '@grafana/data';
import { config } from '@grafana/runtime';
import { filterByValueTransformRegistryItem } from './FilterByValueTransformer/FilterByValueTransformerEditor';
import { heatmapTransformRegistryItem } from './calculateHeatmap/HeatmapTransformerEditor';
@ -27,6 +28,7 @@ import { partitionByValuesTransformRegistryItem } from './partitionByValues/Part
import { prepareTimeseriesTransformerRegistryItem } from './prepareTimeSeries/PrepareTimeSeriesEditor';
import { rowsToFieldsTransformRegistryItem } from './rowsToFields/RowsToFieldsTransformerEditor';
import { spatialTransformRegistryItem } from './spatial/SpatialTransformerEditor';
import { timeSeriesTableTransformRegistryItem } from './timeSeriesTable/TimeSeriesTableTransformEditor';
export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> => {
return [
@ -57,5 +59,6 @@ export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> =
limitTransformRegistryItem,
joinByLabelsTransformRegistryItem,
partitionByValuesTransformRegistryItem,
...(config.featureToggles.timeSeriesTable ? [timeSeriesTableTransformRegistryItem] : []),
];
};

@ -0,0 +1,25 @@
import React from 'react';
import { PluginState, TransformerRegistryItem, TransformerUIProps } from '@grafana/data';
import { timeSeriesTableTransformer, TimeSeriesTableTransformerOptions } from './timeSeriesTableTransformer';
export interface Props extends TransformerUIProps<{}> {}
export function TimeSeriesTableTransformEditor({ input, options, onChange }: Props) {
if (input.length === 0) {
return null;
}
return <div></div>;
}
export const timeSeriesTableTransformRegistryItem: TransformerRegistryItem<TimeSeriesTableTransformerOptions> = {
id: timeSeriesTableTransformer.id,
editor: TimeSeriesTableTransformEditor,
transformation: timeSeriesTableTransformer,
name: timeSeriesTableTransformer.name,
description: timeSeriesTableTransformer.description,
state: PluginState.beta,
help: ``,
};

@ -0,0 +1,104 @@
import { toDataFrame, FieldType, Labels, DataFrame, Field } from '@grafana/data';
import { timeSeriesToTableTransform } from './timeSeriesTableTransformer';
describe('timeSeriesTableTransformer', () => {
it('Will transform a single query', () => {
const series = [
getTimeSeries('A', { instance: 'A', pod: 'B' }),
getTimeSeries('A', { instance: 'A', pod: 'C' }),
getTimeSeries('A', { instance: 'A', pod: 'D' }),
];
const results = timeSeriesToTableTransform({}, series);
expect(results).toHaveLength(1);
const result = results[0];
expect(result.refId).toBe('A');
expect(result.fields).toHaveLength(3);
expect(result.fields[0].values.toArray()).toEqual(['A', 'A', 'A']);
expect(result.fields[1].values.toArray()).toEqual(['B', 'C', 'D']);
assertDataFrameField(result.fields[2], series);
});
it('Will pass through non time series frames', () => {
const series = [
getTable('B', ['foo', 'bar']),
getTimeSeries('A', { instance: 'A', pod: 'B' }),
getTimeSeries('A', { instance: 'A', pod: 'C' }),
getTable('C', ['bar', 'baz', 'bad']),
];
const results = timeSeriesToTableTransform({}, series);
expect(results).toHaveLength(3);
expect(results[0]).toEqual(series[0]);
expect(results[1].refId).toBe('A');
expect(results[1].fields).toHaveLength(3);
expect(results[1].fields[0].values.toArray()).toEqual(['A', 'A']);
expect(results[1].fields[1].values.toArray()).toEqual(['B', 'C']);
expect(results[2]).toEqual(series[3]);
});
it('Will group by refId', () => {
const series = [
getTimeSeries('A', { instance: 'A', pod: 'B' }),
getTimeSeries('A', { instance: 'A', pod: 'C' }),
getTimeSeries('A', { instance: 'A', pod: 'D' }),
getTimeSeries('B', { instance: 'B', pod: 'F', cluster: 'A' }),
getTimeSeries('B', { instance: 'B', pod: 'G', cluster: 'B' }),
];
const results = timeSeriesToTableTransform({}, series);
expect(results).toHaveLength(2);
expect(results[0].refId).toBe('A');
expect(results[0].fields).toHaveLength(3);
expect(results[0].fields[0].values.toArray()).toEqual(['A', 'A', 'A']);
expect(results[0].fields[1].values.toArray()).toEqual(['B', 'C', 'D']);
assertDataFrameField(results[0].fields[2], series.slice(0, 3));
expect(results[1].refId).toBe('B');
expect(results[1].fields).toHaveLength(4);
expect(results[1].fields[0].values.toArray()).toEqual(['B', 'B']);
expect(results[1].fields[1].values.toArray()).toEqual(['F', 'G']);
expect(results[1].fields[2].values.toArray()).toEqual(['A', 'B']);
assertDataFrameField(results[1].fields[3], series.slice(3, 5));
});
});
function assertFieldsEqual(field1: Field, field2: Field) {
expect(field1.type).toEqual(field2.type);
expect(field1.name).toEqual(field2.name);
expect(field1.values.toArray()).toEqual(field2.values.toArray());
expect(field1.labels ?? {}).toEqual(field2.labels ?? {});
}
function assertDataFrameField(field: Field, matchesFrames: DataFrame[]) {
const frames: DataFrame[] = field.values.toArray();
expect(frames).toHaveLength(matchesFrames.length);
frames.forEach((frame, idx) => {
const matchingFrame = matchesFrames[idx];
expect(frame.fields).toHaveLength(matchingFrame.fields.length);
frame.fields.forEach((field, fidx) => assertFieldsEqual(field, matchingFrame.fields[fidx]));
});
}
function getTimeSeries(refId: string, labels: Labels) {
return toDataFrame({
refId,
fields: [
{ name: 'Time', type: FieldType.time, values: [10] },
{
name: 'Value',
type: FieldType.number,
values: [10],
labels,
},
],
});
}
function getTable(refId: string, fields: string[]) {
return toDataFrame({
refId,
fields: fields.map((f) => ({ name: f, type: FieldType.string, values: ['value'] })),
labels: {},
});
}

@ -0,0 +1,127 @@
import { map } from 'rxjs/operators';
import {
ArrayVector,
DataFrame,
DataTransformerID,
DataTransformerInfo,
Field,
FieldType,
MutableDataFrame,
isTimeSeriesFrame,
} from '@grafana/data';
export interface TimeSeriesTableTransformerOptions {}
export const timeSeriesTableTransformer: DataTransformerInfo<TimeSeriesTableTransformerOptions> = {
id: DataTransformerID.timeSeriesTable,
name: 'Time series to table transform',
description: 'Time series to table rows',
defaultOptions: {},
operator: (options) => (source) =>
source.pipe(
map((data) => {
return timeSeriesToTableTransform(options, data);
})
),
};
/**
* Converts time series frames to table frames for use with sparkline chart type.
*
* @remarks
* For each refId (queryName) convert all time series frames into a single table frame, adding each series
* as values of a "Trend" frame field. This allows "Trend" to be rendered as area chart type.
* Any non time series frames are returned as is.
*
* @param options - Transform options, currently not used
* @param data - Array of data frames to transform
* @returns Array of transformed data frames
*
* @alpha
*/
export function timeSeriesToTableTransform(options: TimeSeriesTableTransformerOptions, data: DataFrame[]): DataFrame[] {
// initialize fields from labels for each refId
const refId2LabelFields = getLabelFields(data);
const refId2frameField: Record<string, Field<DataFrame, ArrayVector>> = {};
const result: DataFrame[] = [];
for (const frame of data) {
if (!isTimeSeriesFrame(frame)) {
result.push(frame);
continue;
}
const refId = frame.refId ?? '';
const labelFields = refId2LabelFields[refId] ?? {};
// initialize a new frame for this refId with fields per label and a Trend frame field, if it doesn't exist yet
let frameField = refId2frameField[refId];
if (!frameField) {
frameField = {
name: 'Trend' + (refId && Object.keys(refId2LabelFields).length > 1 ? ` #${refId}` : ''),
type: FieldType.frame,
config: {},
values: new ArrayVector(),
};
refId2frameField[refId] = frameField;
const table = new MutableDataFrame();
for (const label of Object.values(labelFields)) {
table.addField(label);
}
table.addField(frameField);
table.refId = refId;
result.push(table);
}
// add values to each label based field of this frame
const labels = frame.fields[1].labels;
for (const labelKey of Object.keys(labelFields)) {
const labelValue = labels?.[labelKey] ?? null;
labelFields[labelKey].values.add(labelValue);
}
frameField.values.add(frame);
}
return result;
}
// For each refId, initialize a field for each label name
function getLabelFields(frames: DataFrame[]): Record<string, Record<string, Field<string, ArrayVector>>> {
// refId -> label name -> field
const labelFields: Record<string, Record<string, Field<string, ArrayVector>>> = {};
for (const frame of frames) {
if (!isTimeSeriesFrame(frame)) {
continue;
}
const refId = frame.refId ?? '';
if (!labelFields[refId]) {
labelFields[refId] = {};
}
for (const field of frame.fields) {
if (!field.labels) {
continue;
}
for (const labelName of Object.keys(field.labels)) {
if (!labelFields[refId][labelName]) {
labelFields[refId][labelName] = {
name: labelName,
type: FieldType.string,
config: {},
values: new ArrayVector(),
};
}
}
}
}
return labelFields;
}

@ -2,11 +2,13 @@ import { merge } from 'lodash';
import React, { useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { TableCellOptions } from '@grafana/schema';
import { Field, Select, TableCellDisplayMode } from '@grafana/ui';
import { BarGaugeCellOptionsEditor } from './cells/BarGaugeCellOptionsEditor';
import { ColorBackgroundCellOptionsEditor } from './cells/ColorBackgroundCellOptionsEditor';
import { SparklineCellOptionsEditor } from './cells/SparklineCellOptionsEditor';
// The props that any cell type editor are expected
// to handle. In this case the generic type should
@ -64,12 +66,21 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => {
{cellType === TableCellDisplayMode.ColorBackground && (
<ColorBackgroundCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
{cellType === TableCellDisplayMode.Sparkline && (
<SparklineCellOptionsEditor cellOptions={value} onChange={onCellOptionsChange} />
)}
</>
);
};
const SparklineDisplayModeOption: SelectableValue<TableCellOptions> = {
value: { type: TableCellDisplayMode.Sparkline },
label: 'Sparkline',
};
const cellDisplayModeOptions: Array<SelectableValue<TableCellOptions>> = [
{ value: { type: TableCellDisplayMode.Auto }, label: 'Auto' },
...(config.featureToggles.timeSeriesTable ? [SparklineDisplayModeOption] : []),
{ value: { type: TableCellDisplayMode.ColorText }, label: 'Colored text' },
{ value: { type: TableCellDisplayMode.ColorBackground }, label: 'Colored background' },
{ value: { type: TableCellDisplayMode.Gauge }, label: 'Gauge' },

@ -56,6 +56,7 @@ export function TablePanel(props: Props) {
footerOptions={options.footer}
enablePagination={options.footer?.enablePagination}
subData={subData}
cellHeight={options.cellHeight}
/>
);

@ -0,0 +1,79 @@
import { css } from '@emotion/css';
import React, { useMemo } from 'react';
import { createFieldConfigRegistry } from '@grafana/data';
import { GraphFieldConfig, TableSparklineCellOptions } from '@grafana/schema';
import { VerticalGroup, Field, useStyles2 } from '@grafana/ui';
import { defaultSparklineCellConfig } from '@grafana/ui/src/components/Table/SparklineCell';
import { getGraphFieldConfig } from '../../timeseries/config';
import { TableCellEditorProps } from '../TableCellOptionEditor';
type OptionKey = keyof TableSparklineCellOptions;
const optionIds: Array<keyof GraphFieldConfig> = [
'drawStyle',
'lineInterpolation',
'barAlignment',
'lineWidth',
'fillOpacity',
'gradientMode',
'lineStyle',
'spanNulls',
'showPoints',
'pointSize',
];
export const SparklineCellOptionsEditor = (props: TableCellEditorProps<TableSparklineCellOptions>) => {
const { cellOptions, onChange } = props;
const registry = useMemo(() => {
const config = getGraphFieldConfig(defaultSparklineCellConfig);
return createFieldConfigRegistry(config, 'ChartCell');
}, []);
const style = useStyles2(getStyles);
const values = { ...defaultSparklineCellConfig, ...cellOptions };
return (
<VerticalGroup>
{registry.list(optionIds.map((id) => `custom.${id}`)).map((item) => {
if (item.showIf && !item.showIf(values)) {
return null;
}
const Editor = item.editor;
const path = item.path;
return (
<Field label={item.name} key={item.id} className={style.field}>
<Editor
onChange={(val) => onChange({ ...cellOptions, [path]: val })}
value={(isOptionKey(path, values) ? values[path] : undefined) ?? item.defaultValue}
item={item}
context={{ data: [] }}
/>
</Field>
);
})}
</VerticalGroup>
);
};
// jumping through hoops to avoid using "any"
function isOptionKey(key: string, options: TableSparklineCellOptions): key is OptionKey {
return key in options;
}
const getStyles = () => ({
field: css`
width: 100%;
// @TODO don't show "scheme" option for custom gradient mode.
// it needs thresholds to work, which are not supported
// for area chart cell right now
[title='Use color scheme to define gradient'] {
display: none;
}
`,
});

@ -7,7 +7,13 @@ import {
standardEditorsRegistry,
identityOverrideProcessor,
} from '@grafana/data';
import { TableFieldOptions, TableCellOptions, TableCellDisplayMode, defaultTableFieldOptions } from '@grafana/schema';
import {
TableFieldOptions,
TableCellOptions,
TableCellDisplayMode,
defaultTableFieldOptions,
TableCellHeight,
} from '@grafana/schema';
import { PaginationEditor } from './PaginationEditor';
import { TableCellOptionEditor } from './TableCellOptionEditor';
@ -108,6 +114,18 @@ export const plugin = new PanelPlugin<PanelOptions, TableFieldOptions>(TablePane
name: 'Show table header',
defaultValue: defaultPanelOptions.showHeader,
})
.addRadio({
path: 'cellHeight',
name: 'Cell height',
defaultValue: defaultPanelOptions.cellHeight,
settings: {
options: [
{ value: TableCellHeight.Sm, label: 'Small' },
{ value: TableCellHeight.Md, label: 'Medium' },
{ value: TableCellHeight.Lg, label: 'Large' },
],
},
})
.addBooleanSwitch({
path: 'showRowNums',
name: 'Show row numbers',

@ -45,6 +45,8 @@ composableKinds: PanelCfg: {
// Represents the selected calculations
reducer: []
}
// Controls the height of the rows
cellHeight?: ui.TableCellHeight | *"md"
} @cuetsy(kind="interface")
},
]

@ -13,6 +13,10 @@ import * as ui from '@grafana/schema';
export const PanelCfgModelVersion = Object.freeze([0, 0]);
export interface PanelOptions {
/**
* Controls the height of the rows
*/
cellHeight?: ui.TableCellHeight;
/**
* Controls footer options
*/
@ -40,6 +44,7 @@ export interface PanelOptions {
}
export const defaultPanelOptions: Partial<PanelOptions> = {
cellHeight: ui.TableCellHeight.Md,
footer: {
/**
* Controls whether the footer should be shown

Loading…
Cancel
Save