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