SparklineCell: Display absolute value (#76125)

This commit is contained in:
Domas
2023-10-13 11:00:42 +03:00
committed by GitHub
parent d72ec22ec2
commit 239bda207e
24 changed files with 253 additions and 111 deletions

View File

@@ -393,6 +393,10 @@
"value": { "value": {
"mode": "continuous-GrYlRd" "mode": "continuous-GrYlRd"
} }
},
{
"id": "unit",
"value": "r/sec"
} }
] ]
}, },
@@ -538,6 +542,10 @@
"fixedColor": "red", "fixedColor": "red",
"mode": "fixed" "mode": "fixed"
} }
},
{
"id": "unit",
"value": "r/sec"
} }
] ]
}, },
@@ -563,6 +571,10 @@
"fixedColor": "purple", "fixedColor": "purple",
"mode": "fixed" "mode": "fixed"
} }
},
{
"id": "unit",
"value": "ms"
} }
] ]
}, },

View File

@@ -170,6 +170,7 @@ It extends [GraphFieldConfig](#graphfieldconfig).
| `fillOpacity` | number | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | | `fillOpacity` | number | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* |
| `gradientMode` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs<br/>Possible values are: `none`, `opacity`, `hue`, `scheme`. | | `gradientMode` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs<br/>Possible values are: `none`, `opacity`, `hue`, `scheme`. |
| `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs | | `hideFrom` | [HideSeriesConfig](#hideseriesconfig) | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs |
| `hideValue` | boolean | No | | |
| `lineColor` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* | | `lineColor` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))* |
| `lineInterpolation` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs<br/>Possible values are: `linear`, `smooth`, `stepBefore`, `stepAfter`. | | `lineInterpolation` | string | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs<br/>Possible values are: `linear`, `smooth`, `stepBefore`, `stepAfter`. |
| `lineStyle` | [LineStyle](#linestyle) | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs | | `lineStyle` | [LineStyle](#linestyle) | No | | *(Inherited from [GraphFieldConfig](#graphfieldconfig))*<br/>TODO docs |

View File

@@ -995,11 +995,10 @@ Here is the result after adding a Limit transformation with a value of '3':
### Time series to table transform ### Time series to table transform
> **Note:** This transformation is available in Grafana 9.5+ as an opt-in beta feature.
> Modify Grafana [configuration file][] to enable the `timeSeriesTable` [feature toggle][] 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][], 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. 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][], 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.
For each generated "Trend" field value calculation function can be selected. Default is "last non null value". This value will be displayed next to the sparkline and used for sorting table rows.
### Format Time ### Format Time
{{% admonition type="note" %}} {{% admonition type="note" %}}

View File

@@ -148,12 +148,9 @@ If you have a field value that is an image URL or a base64 encoded image you can
### Sparkline ### Sparkline
> **Note:** This cell type is available in Grafana 9.5+ as an opt-in beta feature.
> Modify Grafana [configuration file][] to enable the `timeSeriesTable` [feature toggle][] to use it.
Shows value rendered as a sparkline. Requires [time series to table][] data transform. Shows value rendered as a sparkline. Requires [time series to table][] data transform.
{{< figure src="/static/img/docs/tables/sparkline.png" max-width="500px" caption="Sparkline" class="docs-image--no-shadow" >}} {{< figure src="/static/img/docs/tables/sparkline2.png" max-width="500px" caption="Sparkline" class="docs-image--no-shadow" >}}
## Cell value inspect ## Cell value inspect

View File

@@ -104,7 +104,6 @@ Experimental features might be changed or removed without prior notice.
| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | | `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals |
| `lokiQuerySplittingConfig` | Give users the option to configure split durations for Loki queries | | `lokiQuerySplittingConfig` | Give users the option to configure split durations for Loki queries |
| `individualCookiePreferences` | Support overriding cookie preferences per user | | `individualCookiePreferences` | Support overriding cookie preferences per user |
| `timeSeriesTable` | Enable time series table transformer & sparkline cell type |
| `clientTokenRotation` | Replaces the current in-request token rotation so that the client initiates the rotation | | `clientTokenRotation` | Replaces the current in-request token rotation so that the client initiates the rotation |
| `lokiLogsDataplane` | Changes logs responses from Loki to be compliant with the dataplane specification. | | `lokiLogsDataplane` | Changes logs responses from Loki to be compliant with the dataplane specification. |
| `disableSSEDataplane` | Disables dataplane specific processing in server side expressions. | | `disableSSEDataplane` | Disables dataplane specific processing in server side expressions. |

View File

@@ -23,6 +23,7 @@ import {
PanelData, PanelData,
LoadingState, LoadingState,
GraphSeriesValue, GraphSeriesValue,
DataFrameWithValue,
} from '../types/index'; } from '../types/index';
import { arrayToDataFrame } from './ArrayDataFrame'; import { arrayToDataFrame } from './ArrayDataFrame';
@@ -303,6 +304,9 @@ export const isTableData = (data: unknown): data is DataFrame => Boolean(data &&
export const isDataFrame = (data: unknown): data is DataFrame => Boolean(data && data.hasOwnProperty('fields')); export const isDataFrame = (data: unknown): data is DataFrame => Boolean(data && data.hasOwnProperty('fields'));
export const isDataFrameWithValue = (data: unknown): data is DataFrameWithValue =>
Boolean(isDataFrame(data) && data.hasOwnProperty('value'));
/** /**
* Inspect any object and return the results as a DataFrame * Inspect any object and return the results as a DataFrame
*/ */

View File

@@ -30,6 +30,10 @@ export enum ReducerID {
uniqueValues = 'uniqueValues', uniqueValues = 'uniqueValues',
} }
export function isReducerID(id: string): id is ReducerID {
return Object.keys(ReducerID).includes(id);
}
// Internal function // Internal function
type FieldReducer = (field: Field, ignoreNulls: boolean, nullAsZero: boolean) => FieldCalcs; type FieldReducer = (field: Field, ignoreNulls: boolean, nullAsZero: boolean) => FieldCalcs;

View File

@@ -247,6 +247,11 @@ export interface DataFrame extends QueryResultBase {
length: number; length: number;
} }
// Data frame that include aggregate value, for use by timeSeriesTableTransformer / chart cell type
export interface DataFrameWithValue extends DataFrame {
value: number | null;
}
/** /**
* @public * @public
* Like a field, but properties are optional and values may be a simple array * Like a field, but properties are optional and values may be a simple array

View File

@@ -65,7 +65,6 @@ export interface FeatureToggles {
individualCookiePreferences?: boolean; individualCookiePreferences?: boolean;
gcomOnlyExternalOrgRoleSync?: boolean; gcomOnlyExternalOrgRoleSync?: boolean;
prometheusMetricEncyclopedia?: boolean; prometheusMetricEncyclopedia?: boolean;
timeSeriesTable?: boolean;
influxdbBackendMigration?: boolean; influxdbBackendMigration?: boolean;
clientTokenRotation?: boolean; clientTokenRotation?: boolean;
prometheusDataplane?: boolean; prometheusDataplane?: boolean;

View File

@@ -756,6 +756,7 @@ export interface TableBarGaugeCellOptions {
* Sparkline cell options * Sparkline cell options
*/ */
export interface TableSparklineCellOptions extends GraphFieldConfig { export interface TableSparklineCellOptions extends GraphFieldConfig {
hideValue?: boolean;
type: TableCellDisplayMode.Sparkline; type: TableCellDisplayMode.Sparkline;
} }

View File

@@ -59,6 +59,7 @@ TableBarGaugeCellOptions: {
TableSparklineCellOptions: { TableSparklineCellOptions: {
GraphFieldConfig GraphFieldConfig
type: TableCellDisplayMode & "sparkline" type: TableCellDisplayMode & "sparkline"
hideValue?: bool
} @cuetsy(kind="interface") } @cuetsy(kind="interface")
// Colored background cell options // Colored background cell options

View File

@@ -1,7 +1,7 @@
import { difference } from 'lodash'; import { difference } from 'lodash';
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { fieldReducers, SelectableValue } from '@grafana/data'; import { fieldReducers, SelectableValue, FieldReducerInfo } from '@grafana/data';
import { Select } from '../Select/Select'; import { Select } from '../Select/Select';
@@ -15,6 +15,7 @@ export interface Props {
width?: number; width?: number;
menuPlacement?: 'auto' | 'bottom' | 'top'; menuPlacement?: 'auto' | 'bottom' | 'top';
inputId?: string; inputId?: string;
filterOptions?: (ext: FieldReducerInfo) => boolean;
} }
export class StatsPicker extends PureComponent<Props> { export class StatsPicker extends PureComponent<Props> {
@@ -63,9 +64,10 @@ export class StatsPicker extends PureComponent<Props> {
}; };
render() { render() {
const { stats, allowMultiple, defaultStat, placeholder, className, menuPlacement, width, inputId } = this.props; const { stats, allowMultiple, defaultStat, placeholder, className, menuPlacement, width, inputId, filterOptions } =
this.props;
const select = fieldReducers.selectOptions(stats); const select = fieldReducers.selectOptions(stats, filterOptions);
return ( return (
<Select <Select
value={select.current} value={select.current}

View File

@@ -1,22 +1,14 @@
import { isFunction } from 'lodash'; import { isFunction } from 'lodash';
import React from 'react'; import React from 'react';
import { import { ThresholdsConfig, ThresholdsMode, VizOrientation, getFieldConfigWithMinMax } from '@grafana/data';
ThresholdsConfig,
ThresholdsMode,
VizOrientation,
getFieldConfigWithMinMax,
DisplayValueAlignmentFactors,
Field,
DisplayValue,
} from '@grafana/data';
import { BarGaugeDisplayMode, BarGaugeValueMode, TableCellDisplayMode } from '@grafana/schema'; import { BarGaugeDisplayMode, BarGaugeValueMode, TableCellDisplayMode } from '@grafana/schema';
import { BarGauge } from '../BarGauge/BarGauge'; import { BarGauge } from '../BarGauge/BarGauge';
import { DataLinksContextMenu, DataLinksContextMenuApi } from '../DataLinks/DataLinksContextMenu'; import { DataLinksContextMenu, DataLinksContextMenuApi } from '../DataLinks/DataLinksContextMenu';
import { TableCellProps } from './types'; import { TableCellProps } from './types';
import { getCellOptions } from './utils'; import { getAlignmentFactor, getCellOptions } from './utils';
const defaultScale: ThresholdsConfig = { const defaultScale: ThresholdsConfig = {
mode: ThresholdsMode.Absolute, mode: ThresholdsMode.Absolute,
@@ -102,40 +94,3 @@ export const BarGaugeCell = (props: TableCellProps) => {
</div> </div>
); );
}; };
/**
* Getting gauge values to align is very tricky without looking at all values and passing them through display processor. For very large tables that
* could pretty expensive. So this is kind of a compromise. We look at the first 1000 rows and cache the longest value.
* If we have a cached value we just check if the current value is longer and update the alignmentFactor. This can obviously still lead to
* unaligned gauges but it should a lot less common.
**/
function getAlignmentFactor(field: Field, displayValue: DisplayValue, rowIndex: number): DisplayValueAlignmentFactors {
let alignmentFactor = field.state?.alignmentFactors;
if (alignmentFactor) {
// check if current alignmentFactor is still the longest
if (alignmentFactor.text.length < displayValue.text.length) {
alignmentFactor.text = displayValue.text;
}
return alignmentFactor;
} else {
// look at the next 100 rows
alignmentFactor = { ...displayValue };
const maxIndex = Math.min(field.values.length, rowIndex + 1000);
for (let i = rowIndex + 1; i < maxIndex; i++) {
const nextDisplayValue = field.display!(field.values[i]);
if (nextDisplayValue.text.length > alignmentFactor.text.length) {
alignmentFactor.text = displayValue.text;
}
}
if (field.state) {
field.state.alignmentFactors = alignmentFactor;
} else {
field.state = { alignmentFactors: alignmentFactor };
}
return alignmentFactor;
}
}

View File

@@ -1,6 +1,14 @@
import React from 'react'; import React from 'react';
import { FieldType, FieldConfig, getMinMaxAndDelta, FieldSparkline, isDataFrame, Field } from '@grafana/data'; import {
FieldType,
FieldConfig,
getMinMaxAndDelta,
FieldSparkline,
isDataFrame,
Field,
isDataFrameWithValue,
} from '@grafana/data';
import { import {
BarAlignment, BarAlignment,
GraphDrawStyle, GraphDrawStyle,
@@ -12,12 +20,16 @@ import {
VisibilityMode, VisibilityMode,
} from '@grafana/schema'; } from '@grafana/schema';
import { useTheme2 } from '../../themes';
import { measureText } from '../../utils';
import { FormattedValueDisplay } from '../FormattedValueDisplay/FormattedValueDisplay';
import { Sparkline } from '../Sparkline/Sparkline'; import { Sparkline } from '../Sparkline/Sparkline';
import { TableCellProps } from './types'; import { TableCellProps } from './types';
import { getCellOptions } from './utils'; import { getAlignmentFactor, getCellOptions } from './utils';
export const defaultSparklineCellConfig: GraphFieldConfig = { export const defaultSparklineCellConfig: TableSparklineCellOptions = {
type: TableCellDisplayMode.Sparkline,
drawStyle: GraphDrawStyle.Line, drawStyle: GraphDrawStyle.Line,
lineInterpolation: LineInterpolation.Smooth, lineInterpolation: LineInterpolation.Smooth,
lineWidth: 1, lineWidth: 1,
@@ -26,11 +38,13 @@ export const defaultSparklineCellConfig: GraphFieldConfig = {
pointSize: 2, pointSize: 2,
barAlignment: BarAlignment.Center, barAlignment: BarAlignment.Center,
showPoints: VisibilityMode.Never, showPoints: VisibilityMode.Never,
hideValue: false,
}; };
export const SparklineCell = (props: TableCellProps) => { export const SparklineCell = (props: TableCellProps) => {
const { field, innerWidth, tableStyles, cell, cellProps, timeRange } = props; const { field, innerWidth, tableStyles, cell, cellProps, timeRange } = props;
const sparkline = getSparkline(cell.value); const sparkline = getSparkline(cell.value);
const theme = useTheme2();
if (!sparkline) { if (!sparkline) {
return ( return (
@@ -70,15 +84,42 @@ export const SparklineCell = (props: TableCellProps) => {
}, },
}; };
const hideValue = field.config.custom?.cellOptions?.hideValue;
let valueWidth = 0;
let valueElement: React.ReactNode = null;
if (!hideValue) {
const value = isDataFrameWithValue(cell.value) ? cell.value.value : null;
const displayValue = field.display!(value);
const alignmentFactor = getAlignmentFactor(field, displayValue, cell.row.index);
valueWidth =
measureText(`${alignmentFactor.prefix ?? ''}${alignmentFactor.text}${alignmentFactor.suffix ?? ''}`, 16).width +
theme.spacing.gridSize;
valueElement = (
<FormattedValueDisplay
style={{
width: `${valueWidth - theme.spacing.gridSize}px`,
textAlign: 'right',
marginRight: theme.spacing(1),
}}
value={displayValue}
/>
);
}
return ( return (
<div {...cellProps} className={tableStyles.cellContainer}> <div {...cellProps} className={tableStyles.cellContainer}>
<Sparkline {valueElement}
width={innerWidth} <div>
height={tableStyles.cellHeightInner} <Sparkline
sparkline={sparkline} width={innerWidth - valueWidth}
config={config} height={tableStyles.cellHeightInner}
theme={tableStyles.theme} sparkline={sparkline}
/> config={config}
theme={tableStyles.theme}
/>
</div>
</div> </div>
); );
}; };

View File

@@ -15,7 +15,10 @@ import {
reduceField, reduceField,
GrafanaTheme2, GrafanaTheme2,
isDataFrame, isDataFrame,
isDataFrameWithValue,
isTimeSeriesFrame, isTimeSeriesFrame,
DisplayValueAlignmentFactors,
DisplayValue,
} from '@grafana/data'; } from '@grafana/data';
import { import {
BarGaugeDisplayMode, BarGaugeDisplayMode,
@@ -115,6 +118,7 @@ export function getColumns(
const selectSortType = (type: FieldType) => { const selectSortType = (type: FieldType) => {
switch (type) { switch (type) {
case FieldType.number: case FieldType.number:
case FieldType.frame:
return 'number'; return 'number';
case FieldType.time: case FieldType.time:
return 'basic'; return 'basic';
@@ -131,9 +135,7 @@ export function getColumns(
id: fieldIndex.toString(), id: fieldIndex.toString(),
field: field, field: field,
Header: fieldTableOptions.hideHeader ? '' : getFieldDisplayName(field, data), Header: fieldTableOptions.hideHeader ? '' : getFieldDisplayName(field, data),
accessor: (_row, i) => { accessor: (_row, i) => field.values[i],
return field.values[i];
},
sortType: selectSortType(field.type), sortType: selectSortType(field.type),
width: fieldTableOptions.width, width: fieldTableOptions.width,
minWidth: fieldTableOptions.minWidth ?? columnMinWidth, minWidth: fieldTableOptions.minWidth ?? columnMinWidth,
@@ -305,6 +307,10 @@ export function sortNumber(rowA: Row, rowB: Row, id: string) {
} }
function toNumber(value: any): number { function toNumber(value: any): number {
if (isDataFrameWithValue(value)) {
return value.value ?? Number.NEGATIVE_INFINITY;
}
if (value === null || value === undefined || value === '' || isNaN(value)) { if (value === null || value === undefined || value === '' || isNaN(value)) {
return Number.NEGATIVE_INFINITY; return Number.NEGATIVE_INFINITY;
} }
@@ -486,3 +492,44 @@ function addMissingColumnIndex(columns: Array<{ id: string; field?: Field } | un
// Recurse // Recurse
addMissingColumnIndex(columns); addMissingColumnIndex(columns);
} }
/**
* Getting gauge or sparkline values to align is very tricky without looking at all values and passing them through display processor.
* For very large tables that could pretty expensive. So this is kind of a compromise. We look at the first 1000 rows and cache the longest value.
* If we have a cached value we just check if the current value is longer and update the alignmentFactor. This can obviously still lead to
* unaligned gauges but it should a lot less common.
**/
export function getAlignmentFactor(
field: Field,
displayValue: DisplayValue,
rowIndex: number
): DisplayValueAlignmentFactors {
let alignmentFactor = field.state?.alignmentFactors;
if (alignmentFactor) {
// check if current alignmentFactor is still the longest
if (alignmentFactor.text.length < displayValue.text.length) {
alignmentFactor.text = displayValue.text;
}
return alignmentFactor;
} else {
// look at the next 1000 rows
alignmentFactor = { ...displayValue };
const maxIndex = Math.min(field.values.length, rowIndex + 1000);
for (let i = rowIndex + 1; i < maxIndex; i++) {
const nextDisplayValue = field.display!(field.values[i]);
if (nextDisplayValue.text.length > alignmentFactor.text.length) {
alignmentFactor.text = displayValue.text;
}
}
if (field.state) {
field.state.alignmentFactors = alignmentFactor;
} else {
field.state = { alignmentFactors: alignmentFactor };
}
return alignmentFactor;
}
}

View File

@@ -324,13 +324,6 @@ var (
FrontendOnly: true, FrontendOnly: true,
Owner: grafanaObservabilityMetricsSquad, Owner: grafanaObservabilityMetricsSquad,
}, },
{
Name: "timeSeriesTable",
Description: "Enable time series table transformer & sparkline cell type",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: appO11ySquad,
},
{ {
Name: "influxdbBackendMigration", Name: "influxdbBackendMigration",
Description: "Query InfluxDB InfluxQL without the proxy", Description: "Query InfluxDB InfluxQL without the proxy",

View File

@@ -46,7 +46,6 @@ lokiQuerySplittingConfig,experimental,@grafana/observability-logs,false,false,fa
individualCookiePreferences,experimental,@grafana/backend-platform,false,false,false,false individualCookiePreferences,experimental,@grafana/backend-platform,false,false,false,false
gcomOnlyExternalOrgRoleSync,GA,@grafana/grafana-authnz-team,false,false,false,false gcomOnlyExternalOrgRoleSync,GA,@grafana/grafana-authnz-team,false,false,false,false
prometheusMetricEncyclopedia,GA,@grafana/observability-metrics,false,false,false,true prometheusMetricEncyclopedia,GA,@grafana/observability-metrics,false,false,false,true
timeSeriesTable,experimental,@grafana/app-o11y,false,false,false,true
influxdbBackendMigration,preview,@grafana/observability-metrics,false,false,false,true influxdbBackendMigration,preview,@grafana/observability-metrics,false,false,false,true
clientTokenRotation,experimental,@grafana/grafana-authnz-team,false,false,false,false clientTokenRotation,experimental,@grafana/grafana-authnz-team,false,false,false,false
prometheusDataplane,GA,@grafana/observability-metrics,false,false,false,false prometheusDataplane,GA,@grafana/observability-metrics,false,false,false,false
1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
46 individualCookiePreferences experimental @grafana/backend-platform false false false false
47 gcomOnlyExternalOrgRoleSync GA @grafana/grafana-authnz-team false false false false
48 prometheusMetricEncyclopedia GA @grafana/observability-metrics false false false true
timeSeriesTable experimental @grafana/app-o11y false false false true
49 influxdbBackendMigration preview @grafana/observability-metrics false false false true
50 clientTokenRotation experimental @grafana/grafana-authnz-team false false false false
51 prometheusDataplane GA @grafana/observability-metrics false false false false

View File

@@ -195,10 +195,6 @@ const (
// Adds the metrics explorer component to the Prometheus query builder as an option in metric select // Adds the metrics explorer component to the Prometheus query builder as an option in metric select
FlagPrometheusMetricEncyclopedia = "prometheusMetricEncyclopedia" FlagPrometheusMetricEncyclopedia = "prometheusMetricEncyclopedia"
// FlagTimeSeriesTable
// Enable time series table transformer &amp; sparkline cell type
FlagTimeSeriesTable = "timeSeriesTable"
// FlagInfluxdbBackendMigration // FlagInfluxdbBackendMigration
// Query InfluxDB InfluxQL without the proxy // Query InfluxDB InfluxQL without the proxy
FlagInfluxdbBackendMigration = "influxdbBackendMigration" FlagInfluxdbBackendMigration = "influxdbBackendMigration"

View File

@@ -1,5 +1,4 @@
import { TransformerRegistryItem } from '@grafana/data'; import { TransformerRegistryItem } from '@grafana/data';
import { config } from '@grafana/runtime';
import { filterByValueTransformRegistryItem } from './FilterByValueTransformer/FilterByValueTransformerEditor'; import { filterByValueTransformRegistryItem } from './FilterByValueTransformer/FilterByValueTransformerEditor';
import { heatmapTransformRegistryItem } from './calculateHeatmap/HeatmapTransformerEditor'; import { heatmapTransformRegistryItem } from './calculateHeatmap/HeatmapTransformerEditor';
@@ -61,6 +60,6 @@ export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> =
joinByLabelsTransformRegistryItem, joinByLabelsTransformRegistryItem,
partitionByValuesTransformRegistryItem, partitionByValuesTransformRegistryItem,
formatTimeTransformerRegistryItem, formatTimeTransformerRegistryItem,
...(config.featureToggles.timeSeriesTable ? [timeSeriesTableTransformRegistryItem] : []), timeSeriesTableTransformRegistryItem,
]; ];
}; };

View File

@@ -1,17 +1,56 @@
import React from 'react'; import React, { useCallback } from 'react';
import { PluginState, TransformerRegistryItem, TransformerUIProps } from '@grafana/data'; import { PluginState, TransformerRegistryItem, TransformerUIProps, ReducerID, isReducerID } from '@grafana/data';
import { InlineFieldRow, InlineField, StatsPicker } from '@grafana/ui';
import { timeSeriesTableTransformer, TimeSeriesTableTransformerOptions } from './timeSeriesTableTransformer'; import { timeSeriesTableTransformer, TimeSeriesTableTransformerOptions } from './timeSeriesTableTransformer';
export interface Props extends TransformerUIProps<{}> {} export function TimeSeriesTableTransformEditor({
input,
options,
onChange,
}: TransformerUIProps<TimeSeriesTableTransformerOptions>) {
const refIds: string[] = input.reduce<string[]>((acc, frame) => {
if (frame.refId && !acc.includes(frame.refId)) {
return [...acc, frame.refId];
}
return acc;
}, []);
export function TimeSeriesTableTransformEditor({ input, options, onChange }: Props) { const onSelectStat = useCallback(
if (input.length === 0) { (refId: string, stats: string[]) => {
return null; const reducerID = stats[0];
} if (reducerID && isReducerID(reducerID)) {
onChange({
refIdToStat: {
...options.refIdToStat,
[refId]: reducerID,
},
});
}
},
[onChange, options]
);
return <div></div>; return (
<>
{refIds.map((refId) => {
return (
<div key={refId}>
<InlineFieldRow>
<InlineField label={`Trend ${refIds.length > 1 ? ` #${refId}` : ''} value`}>
<StatsPicker
stats={[options.refIdToStat?.[refId] ?? ReducerID.lastNotNull]}
onChange={onSelectStat.bind(null, refId)}
filterOptions={(ext) => ext.id !== ReducerID.allValues && ext.id !== ReducerID.uniqueValues}
/>
</InlineField>
</InlineFieldRow>
</div>
);
})}
</>
);
} }
export const timeSeriesTableTransformRegistryItem: TransformerRegistryItem<TimeSeriesTableTransformerOptions> = { export const timeSeriesTableTransformRegistryItem: TransformerRegistryItem<TimeSeriesTableTransformerOptions> = {

View File

@@ -1,4 +1,4 @@
import { toDataFrame, FieldType, Labels, DataFrame, Field } from '@grafana/data'; import { toDataFrame, FieldType, Labels, DataFrame, Field, ReducerID } from '@grafana/data';
import { timeSeriesToTableTransform } from './timeSeriesTableTransformer'; import { timeSeriesToTableTransform } from './timeSeriesTableTransformer';
@@ -61,6 +61,35 @@ describe('timeSeriesTableTransformer', () => {
expect(results[1].fields[2].values).toEqual(['A', 'B']); expect(results[1].fields[2].values).toEqual(['A', 'B']);
assertDataFrameField(results[1].fields[3], series.slice(3, 5)); assertDataFrameField(results[1].fields[3], series.slice(3, 5));
}); });
it('Will include last value by deault', () => {
const series = [
getTimeSeries('A', { instance: 'A', pod: 'B' }, [4, 2, 3]),
getTimeSeries('A', { instance: 'A', pod: 'C' }, [3, 4, 5]),
];
const results = timeSeriesToTableTransform({}, series);
expect(results[0].fields[2].values[0].value).toEqual(3);
expect(results[0].fields[2].values[1].value).toEqual(5);
});
it('Will calculate average value if configured', () => {
const series = [
getTimeSeries('A', { instance: 'A', pod: 'B' }, [4, 2, 3]),
getTimeSeries('B', { instance: 'A', pod: 'C' }, [3, 4, 5]),
];
const results = timeSeriesToTableTransform(
{
refIdToStat: {
B: ReducerID.mean,
},
},
series
);
expect(results[0].fields[2].values[0].value).toEqual(3);
expect(results[1].fields[2].values[0].value).toEqual(4);
});
}); });
function assertFieldsEqual(field1: Field, field2: Field) { function assertFieldsEqual(field1: Field, field2: Field) {
@@ -80,7 +109,7 @@ function assertDataFrameField(field: Field, matchesFrames: DataFrame[]) {
}); });
} }
function getTimeSeries(refId: string, labels: Labels) { function getTimeSeries(refId: string, labels: Labels, values: number[] = [10]) {
return toDataFrame({ return toDataFrame({
refId, refId,
fields: [ fields: [
@@ -88,7 +117,7 @@ function getTimeSeries(refId: string, labels: Labels) {
{ {
name: 'Value', name: 'Value',
type: FieldType.number, type: FieldType.number,
values: [10], values,
labels, labels,
}, },
], ],

View File

@@ -2,15 +2,20 @@ import { map } from 'rxjs/operators';
import { import {
DataFrame, DataFrame,
DataFrameWithValue,
DataTransformerID, DataTransformerID,
DataTransformerInfo, DataTransformerInfo,
Field, Field,
FieldType, FieldType,
MutableDataFrame, MutableDataFrame,
isTimeSeriesFrame, isTimeSeriesFrame,
ReducerID,
reduceField,
} from '@grafana/data'; } from '@grafana/data';
export interface TimeSeriesTableTransformerOptions {} export interface TimeSeriesTableTransformerOptions {
refIdToStat?: Record<string, ReducerID>;
}
export const timeSeriesTableTransformer: DataTransformerInfo<TimeSeriesTableTransformerOptions> = { export const timeSeriesTableTransformer: DataTransformerInfo<TimeSeriesTableTransformerOptions> = {
id: DataTransformerID.timeSeriesTable, id: DataTransformerID.timeSeriesTable,
@@ -44,7 +49,7 @@ export function timeSeriesToTableTransform(options: TimeSeriesTableTransformerOp
// initialize fields from labels for each refId // initialize fields from labels for each refId
const refId2LabelFields = getLabelFields(data); const refId2LabelFields = getLabelFields(data);
const refId2frameField: Record<string, Field<DataFrame>> = {}; const refId2frameField: Record<string, Field<DataFrameWithValue>> = {};
const result: DataFrame[] = []; const result: DataFrame[] = [];
@@ -83,8 +88,13 @@ export function timeSeriesToTableTransform(options: TimeSeriesTableTransformerOp
const labelValue = labels?.[labelKey] ?? null; const labelValue = labels?.[labelKey] ?? null;
labelFields[labelKey].values.push(labelValue!); labelFields[labelKey].values.push(labelValue!);
} }
const reducerId = options.refIdToStat?.[refId] ?? ReducerID.lastNotNull;
frameField.values.push(frame); const valueField = frame.fields.find((f) => f.type === FieldType.number);
const value = (valueField && reduceField({ field: valueField, reducers: [reducerId] })[reducerId]) || null;
frameField.values.push({
...frame,
value,
});
} }
return result; return result;
} }

View File

@@ -3,7 +3,7 @@ import { merge } from 'lodash';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { TableCellOptions } from '@grafana/schema'; import { TableCellOptions } from '@grafana/schema';
import { Field, Select, TableCellDisplayMode, useStyles2 } from '@grafana/ui'; import { Field, Select, TableCellDisplayMode, useStyles2 } from '@grafana/ui';
@@ -77,14 +77,9 @@ export const TableCellOptionEditor = ({ value, onChange }: Props) => {
); );
}; };
const SparklineDisplayModeOption: SelectableValue<TableCellOptions> = {
value: { type: TableCellDisplayMode.Sparkline },
label: 'Sparkline',
};
const cellDisplayModeOptions: Array<SelectableValue<TableCellOptions>> = [ const cellDisplayModeOptions: Array<SelectableValue<TableCellOptions>> = [
{ value: { type: TableCellDisplayMode.Auto }, label: 'Auto' }, { value: { type: TableCellDisplayMode.Auto }, label: 'Auto' },
...(config.featureToggles.timeSeriesTable ? [SparklineDisplayModeOption] : []), { value: { type: TableCellDisplayMode.Sparkline }, label: 'Sparkline' },
{ value: { type: TableCellDisplayMode.ColorText }, label: 'Colored text' }, { value: { type: TableCellDisplayMode.ColorText }, label: 'Colored text' },
{ value: { type: TableCellDisplayMode.ColorBackground }, label: 'Colored background' }, { value: { type: TableCellDisplayMode.ColorBackground }, label: 'Colored background' },
{ value: { type: TableCellDisplayMode.Gauge }, label: 'Gauge' }, { value: { type: TableCellDisplayMode.Gauge }, label: 'Gauge' },

View File

@@ -1,7 +1,7 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { createFieldConfigRegistry } from '@grafana/data'; import { createFieldConfigRegistry, SetFieldConfigOptionsArgs } from '@grafana/data';
import { GraphFieldConfig, TableSparklineCellOptions } from '@grafana/schema'; import { GraphFieldConfig, TableSparklineCellOptions } from '@grafana/schema';
import { VerticalGroup, Field, useStyles2 } from '@grafana/ui'; import { VerticalGroup, Field, useStyles2 } from '@grafana/ui';
import { defaultSparklineCellConfig } from '@grafana/ui/src/components/Table/SparklineCell'; import { defaultSparklineCellConfig } from '@grafana/ui/src/components/Table/SparklineCell';
@@ -11,7 +11,8 @@ import { TableCellEditorProps } from '../TableCellOptionEditor';
type OptionKey = keyof TableSparklineCellOptions; type OptionKey = keyof TableSparklineCellOptions;
const optionIds: Array<keyof GraphFieldConfig> = [ const optionIds: Array<keyof TableSparklineCellOptions> = [
'hideValue',
'drawStyle', 'drawStyle',
'lineInterpolation', 'lineInterpolation',
'barAlignment', 'barAlignment',
@@ -24,11 +25,25 @@ const optionIds: Array<keyof GraphFieldConfig> = [
'pointSize', 'pointSize',
]; ];
function getChartCellConfig(cfg: GraphFieldConfig): SetFieldConfigOptionsArgs<GraphFieldConfig> {
const graphFieldConfig = getGraphFieldConfig(cfg);
return {
...graphFieldConfig,
useCustomConfig: (builder) => {
graphFieldConfig.useCustomConfig?.(builder);
builder.addBooleanSwitch({
path: 'hideValue',
name: 'Hide value',
});
},
};
}
export const SparklineCellOptionsEditor = (props: TableCellEditorProps<TableSparklineCellOptions>) => { export const SparklineCellOptionsEditor = (props: TableCellEditorProps<TableSparklineCellOptions>) => {
const { cellOptions, onChange } = props; const { cellOptions, onChange } = props;
const registry = useMemo(() => { const registry = useMemo(() => {
const config = getGraphFieldConfig(defaultSparklineCellConfig); const config = getChartCellConfig(defaultSparklineCellConfig);
return createFieldConfigRegistry(config, 'ChartCell'); return createFieldConfigRegistry(config, 'ChartCell');
}, []); }, []);