mirror of https://github.com/grafana/grafana
Table: Introduce sparkline cell type (#63182)
parent
c955c20670
commit
548a5054ad
File diff suppressed because it is too large
Load Diff
@ -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}`); |
||||||
|
} |
@ -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; |
||||||
|
} |
@ -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; |
||||||
|
} |
||||||
|
`,
|
||||||
|
}); |
Loading…
Reference in new issue