mirror of https://github.com/grafana/grafana.git
				
				
				
			StateTimeline: Add pagination support (#89586)
Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
		
							parent
							
								
									970a6e71ba
								
							
						
					
					
						commit
						01fc31069f
					
				|  | @ -21,6 +21,10 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui | ||||||
|    * Merge equal consecutive values |    * Merge equal consecutive values | ||||||
|    */ |    */ | ||||||
|   mergeValues?: boolean; |   mergeValues?: boolean; | ||||||
|  |   /** | ||||||
|  |    * Enables pagination when > 0 | ||||||
|  |    */ | ||||||
|  |   perPage?: number; | ||||||
|   /** |   /** | ||||||
|    * Controls the row height |    * Controls the row height | ||||||
|    */ |    */ | ||||||
|  | @ -34,6 +38,7 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui | ||||||
| export const defaultOptions: Partial<Options> = { | export const defaultOptions: Partial<Options> = { | ||||||
|   alignValue: 'left', |   alignValue: 'left', | ||||||
|   mergeValues: true, |   mergeValues: true, | ||||||
|  |   perPage: 20, | ||||||
|   rowHeight: 0.9, |   rowHeight: 0.9, | ||||||
|   showValue: ui.VisibilityMode.Auto, |   showValue: ui.VisibilityMode.Auto, | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -19,15 +19,17 @@ export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsT | ||||||
|   colWidth?: number; |   colWidth?: number; | ||||||
|   legendItems?: VizLegendItem[]; |   legendItems?: VizLegendItem[]; | ||||||
|   tooltip?: VizTooltipOptions; |   tooltip?: VizTooltipOptions; | ||||||
|  |   // Whenever `paginationRev` changes, the graph will be fully re-configured/rendered.
 | ||||||
|  |   paginationRev?: string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const propsToDiff = ['rowHeight', 'colWidth', 'showValue', 'mergeValues', 'alignValue', 'tooltip']; | const propsToDiff = ['rowHeight', 'colWidth', 'showValue', 'mergeValues', 'alignValue', 'tooltip', 'paginationRev']; | ||||||
| 
 | 
 | ||||||
| export class TimelineChart extends Component<TimelineProps> { | export class TimelineChart extends Component<TimelineProps> { | ||||||
|   getValueColor = (frameIdx: number, fieldIdx: number, value: unknown) => { |   getValueColor = (frameIdx: number, fieldIdx: number, value: unknown) => { | ||||||
|     const field = this.props.frames[frameIdx].fields[fieldIdx]; |     const field = this.props.frames[frameIdx]?.fields[fieldIdx]; | ||||||
| 
 | 
 | ||||||
|     if (field.display) { |     if (field?.display) { | ||||||
|       const disp = field.display(value); // will apply color modes
 |       const disp = field.display(value); // will apply color modes
 | ||||||
|       if (disp.color) { |       if (disp.color) { | ||||||
|         return disp.color; |         return disp.color; | ||||||
|  |  | ||||||
|  | @ -17,6 +17,7 @@ import { | ||||||
|   findNextStateIndex, |   findNextStateIndex, | ||||||
|   fmtDuration, |   fmtDuration, | ||||||
|   getThresholdItems, |   getThresholdItems, | ||||||
|  |   makeFramePerSeries, | ||||||
|   prepareTimelineFields, |   prepareTimelineFields, | ||||||
|   prepareTimelineLegendItems, |   prepareTimelineLegendItems, | ||||||
| } from './utils'; | } from './utils'; | ||||||
|  | @ -268,6 +269,61 @@ describe('prepare timeline graph', () => { | ||||||
|   }); |   }); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
|  | describe('prepareFieldsForPagination', () => { | ||||||
|  |   it('ignores frames without any time fields', () => { | ||||||
|  |     const frames = [ | ||||||
|  |       toDataFrame({ | ||||||
|  |         fields: [ | ||||||
|  |           { name: 'a', type: FieldType.number, values: [1, 2, 3] }, | ||||||
|  |           { name: 'b', type: FieldType.string, values: ['a', 'b', 'c'] }, | ||||||
|  |         ], | ||||||
|  |       }), | ||||||
|  |     ]; | ||||||
|  |     const normalizedFrames = makeFramePerSeries(frames); | ||||||
|  |     expect(normalizedFrames.length).toEqual(0); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   it('returns normalized frames, each with one time field and one value field', () => { | ||||||
|  |     const frames = [ | ||||||
|  |       toDataFrame({ | ||||||
|  |         fields: [ | ||||||
|  |           { name: 'a', type: FieldType.time, values: [1, 2, 3] }, | ||||||
|  |           { name: 'b', type: FieldType.number, values: [100, 200, 300] }, | ||||||
|  |           { name: 'c', type: FieldType.string, values: ['h', 'i', 'j'] }, | ||||||
|  |         ], | ||||||
|  |       }), | ||||||
|  |       toDataFrame({ | ||||||
|  |         fields: [ | ||||||
|  |           { name: 'x', type: FieldType.time, values: [10, 20, 30] }, | ||||||
|  |           { name: 'y', type: FieldType.string, values: ['e', 'f', 'g'] }, | ||||||
|  |         ], | ||||||
|  |       }), | ||||||
|  |     ]; | ||||||
|  |     const normalizedFrames = makeFramePerSeries(frames); | ||||||
|  |     expect(normalizedFrames.length).toEqual(3); | ||||||
|  |     expect(normalizedFrames).toMatchObject([ | ||||||
|  |       { | ||||||
|  |         fields: [ | ||||||
|  |           { name: 'a', values: [1, 2, 3] }, | ||||||
|  |           { name: 'b', values: [100, 200, 300] }, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         fields: [ | ||||||
|  |           { name: 'a', values: [1, 2, 3] }, | ||||||
|  |           { name: 'c', values: ['h', 'i', 'j'] }, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         fields: [ | ||||||
|  |           { name: 'x', values: [10, 20, 30] }, | ||||||
|  |           { name: 'y', values: ['e', 'f', 'g'] }, | ||||||
|  |         ], | ||||||
|  |       }, | ||||||
|  |     ]); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  | 
 | ||||||
| describe('findNextStateIndex', () => { | describe('findNextStateIndex', () => { | ||||||
|   it('handles leading datapoint index', () => { |   it('handles leading datapoint index', () => { | ||||||
|     const field = { |     const field = { | ||||||
|  |  | ||||||
|  | @ -435,6 +435,24 @@ export function prepareTimelineFields( | ||||||
|   return { frames }; |   return { frames }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export function makeFramePerSeries(frames: DataFrame[]) { | ||||||
|  |   const outFrames: DataFrame[] = []; | ||||||
|  | 
 | ||||||
|  |   for (let frame of frames) { | ||||||
|  |     const timeFields = frame.fields.filter((field) => field.type === FieldType.time); | ||||||
|  | 
 | ||||||
|  |     if (timeFields.length > 0) { | ||||||
|  |       for (let field of frame.fields) { | ||||||
|  |         if (field.type !== FieldType.time) { | ||||||
|  |           outFrames.push({ fields: [...timeFields, field], length: frame.length }); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return outFrames; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export function getThresholdItems(fieldConfig: FieldConfig, theme: GrafanaTheme2): VizLegendItem[] { | export function getThresholdItems(fieldConfig: FieldConfig, theme: GrafanaTheme2): VizLegendItem[] { | ||||||
|   const items: VizLegendItem[] = []; |   const items: VizLegendItem[] = []; | ||||||
|   const thresholds = fieldConfig.thresholds; |   const thresholds = fieldConfig.thresholds; | ||||||
|  |  | ||||||
|  | @ -1,11 +1,20 @@ | ||||||
|  | import { css } from '@emotion/css'; | ||||||
| import { useMemo, useState } from 'react'; | import { useMemo, useState } from 'react'; | ||||||
|  | import { useMeasure } from 'react-use'; | ||||||
| 
 | 
 | ||||||
| import { DashboardCursorSync, PanelProps } from '@grafana/data'; | import { DashboardCursorSync, DataFrame, PanelProps } from '@grafana/data'; | ||||||
| import { getLastStreamingDataFramePacket } from '@grafana/data/src/dataframe/StreamingDataFrame'; | import { | ||||||
| import { EventBusPlugin, TooltipDisplayMode, TooltipPlugin2, usePanelContext, useTheme2 } from '@grafana/ui'; |   EventBusPlugin, | ||||||
|  |   Pagination, | ||||||
|  |   TooltipDisplayMode, | ||||||
|  |   TooltipPlugin2, | ||||||
|  |   usePanelContext, | ||||||
|  |   useTheme2, | ||||||
|  | } from '@grafana/ui'; | ||||||
| import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; | import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; | ||||||
| import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart'; | import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart'; | ||||||
| import { | import { | ||||||
|  |   makeFramePerSeries, | ||||||
|   prepareTimelineFields, |   prepareTimelineFields, | ||||||
|   prepareTimelineLegendItems, |   prepareTimelineLegendItems, | ||||||
|   TimelineMode, |   TimelineMode, | ||||||
|  | @ -16,10 +25,73 @@ import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; | ||||||
| import { getTimezones } from '../timeseries/utils'; | import { getTimezones } from '../timeseries/utils'; | ||||||
| 
 | 
 | ||||||
| import { StateTimelineTooltip2 } from './StateTimelineTooltip2'; | import { StateTimelineTooltip2 } from './StateTimelineTooltip2'; | ||||||
| import { Options } from './panelcfg.gen'; | import { Options, defaultOptions } from './panelcfg.gen'; | ||||||
| 
 | 
 | ||||||
| interface TimelinePanelProps extends PanelProps<Options> {} | interface TimelinePanelProps extends PanelProps<Options> {} | ||||||
| 
 | 
 | ||||||
|  | const styles = { | ||||||
|  |   container: css({ | ||||||
|  |     display: 'flex', | ||||||
|  |     flexDirection: 'column', | ||||||
|  |   }), | ||||||
|  |   paginationContainer: css({ | ||||||
|  |     display: 'flex', | ||||||
|  |     justifyContent: 'center', | ||||||
|  |     width: '100%', | ||||||
|  |   }), | ||||||
|  |   paginationElement: css({ | ||||||
|  |     marginTop: '8px', | ||||||
|  |   }), | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | function usePagination(frames?: DataFrame[], perPage?: number) { | ||||||
|  |   const [currentPage, setCurrentPage] = useState(1); | ||||||
|  | 
 | ||||||
|  |   const [paginationWrapperRef, { height: paginationHeight, width: paginationWidth }] = useMeasure<HTMLDivElement>(); | ||||||
|  | 
 | ||||||
|  |   const pagedFrames = useMemo( | ||||||
|  |     () => (!perPage || frames == null ? frames : makeFramePerSeries(frames)), | ||||||
|  |     [frames, perPage] | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   if (!perPage || pagedFrames == null) { | ||||||
|  |     return { | ||||||
|  |       paginatedFrames: pagedFrames, | ||||||
|  |       paginationRev: 'disabled', | ||||||
|  |       paginationElement: undefined, | ||||||
|  |       paginationHeight: 0, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   perPage ||= defaultOptions.perPage!; | ||||||
|  | 
 | ||||||
|  |   const numberOfPages = Math.ceil(pagedFrames.length / perPage); | ||||||
|  |   // `perPage` changing might lead to temporarily too large values of `currentPage`.
 | ||||||
|  |   const currentPageCapped = Math.min(currentPage, numberOfPages); | ||||||
|  |   const pageOffset = (currentPageCapped - 1) * perPage; | ||||||
|  |   const currentPageFrames = pagedFrames.slice(pageOffset, pageOffset + perPage); | ||||||
|  | 
 | ||||||
|  |   // `paginationRev` needs to change value whenever any of the pagination settings changes.
 | ||||||
|  |   // It's used in to trigger a reconfiguration of the underlying graphs (which is cached,
 | ||||||
|  |   // hence an explicit nudge is required).
 | ||||||
|  |   const paginationRev = `${currentPageCapped}/${perPage}`; | ||||||
|  | 
 | ||||||
|  |   const showSmallVersion = paginationWidth < 550; | ||||||
|  |   const paginationElement = ( | ||||||
|  |     <div className={styles.paginationContainer} ref={paginationWrapperRef}> | ||||||
|  |       <Pagination | ||||||
|  |         className={styles.paginationElement} | ||||||
|  |         currentPage={currentPageCapped} | ||||||
|  |         numberOfPages={numberOfPages} | ||||||
|  |         showSmallVersion={showSmallVersion} | ||||||
|  |         onNavigate={setCurrentPage} | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   return { paginatedFrames: currentPageFrames, paginationRev, paginationElement, paginationHeight }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| /** | /** | ||||||
|  * @alpha |  * @alpha | ||||||
|  */ |  */ | ||||||
|  | @ -45,14 +117,19 @@ export const StateTimelinePanel = ({ | ||||||
|     [data.series, options.mergeValues, timeRange, theme] |     [data.series, options.mergeValues, timeRange, theme] | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|  |   const { paginatedFrames, paginationRev, paginationElement, paginationHeight } = usePagination( | ||||||
|  |     frames, | ||||||
|  |     options.perPage | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|   const legendItems = useMemo( |   const legendItems = useMemo( | ||||||
|     () => prepareTimelineLegendItems(frames, options.legend, theme), |     () => prepareTimelineLegendItems(paginatedFrames, options.legend, theme), | ||||||
|     [frames, options.legend, theme] |     [paginatedFrames, options.legend, theme] | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]); |   const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]); | ||||||
| 
 | 
 | ||||||
|   if (!frames || warn) { |   if (!paginatedFrames || warn) { | ||||||
|     return ( |     return ( | ||||||
|       <div className="panel-empty"> |       <div className="panel-empty"> | ||||||
|         <p>{warn ?? 'No data found in response'}</p> |         <p>{warn ?? 'No data found in response'}</p> | ||||||
|  | @ -60,23 +137,19 @@ export const StateTimelinePanel = ({ | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   if (frames.length === 1) { |  | ||||||
|     const packet = getLastStreamingDataFramePacket(frames[0]); |  | ||||||
|     if (packet) { |  | ||||||
|       // console.log('STREAM Packet', packet);
 |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|   const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); |   const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|  |     <div className={styles.container}> | ||||||
|       <TimelineChart |       <TimelineChart | ||||||
|         theme={theme} |         theme={theme} | ||||||
|       frames={frames} |         frames={paginatedFrames} | ||||||
|         structureRev={data.structureRev} |         structureRev={data.structureRev} | ||||||
|  |         paginationRev={paginationRev} | ||||||
|         timeRange={timeRange} |         timeRange={timeRange} | ||||||
|         timeZone={timezones} |         timeZone={timezones} | ||||||
|         width={width} |         width={width} | ||||||
|       height={height} |         height={height - paginationHeight} | ||||||
|         legendItems={legendItems} |         legendItems={legendItems} | ||||||
|         {...options} |         {...options} | ||||||
|         mode={TimelineMode.Changes} |         mode={TimelineMode.Changes} | ||||||
|  | @ -145,5 +218,7 @@ export const StateTimelinePanel = ({ | ||||||
|           ); |           ); | ||||||
|         }} |         }} | ||||||
|       </TimelineChart> |       </TimelineChart> | ||||||
|  |       {paginationElement} | ||||||
|  |     </div> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -118,6 +118,15 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StateTimelinePanel) | ||||||
|           step: 0.01, |           step: 0.01, | ||||||
|         }, |         }, | ||||||
|         defaultValue: defaultOptions.rowHeight, |         defaultValue: defaultOptions.rowHeight, | ||||||
|  |       }) | ||||||
|  |       .addNumberInput({ | ||||||
|  |         path: 'perPage', | ||||||
|  |         name: 'Page size (enable pagination)', | ||||||
|  |         settings: { | ||||||
|  |           min: 1, | ||||||
|  |           step: 1, | ||||||
|  |           integer: true, | ||||||
|  |         }, | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|     commonOptionsBuilder.addLegendOptions(builder, false); |     commonOptionsBuilder.addLegendOptions(builder, false); | ||||||
|  |  | ||||||
|  | @ -37,6 +37,8 @@ composableKinds: PanelCfg: { | ||||||
| 					mergeValues?: bool | *true | 					mergeValues?: bool | *true | ||||||
| 					//Controls value alignment on the timelines | 					//Controls value alignment on the timelines | ||||||
| 					alignValue?: ui.TimelineValueAlignment & (*"left" | _) | 					alignValue?: ui.TimelineValueAlignment & (*"left" | _) | ||||||
|  | 					//Enables pagination when > 0 | ||||||
|  | 					perPage?: number & >=1 | *20 | ||||||
| 				} @cuetsy(kind="interface") | 				} @cuetsy(kind="interface") | ||||||
| 				FieldConfig: { | 				FieldConfig: { | ||||||
| 					ui.HideableFieldConfig | 					ui.HideableFieldConfig | ||||||
|  |  | ||||||
|  | @ -19,6 +19,10 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui | ||||||
|    * Merge equal consecutive values |    * Merge equal consecutive values | ||||||
|    */ |    */ | ||||||
|   mergeValues?: boolean; |   mergeValues?: boolean; | ||||||
|  |   /** | ||||||
|  |    * Enables pagination when > 0 | ||||||
|  |    */ | ||||||
|  |   perPage?: number; | ||||||
|   /** |   /** | ||||||
|    * Controls the row height |    * Controls the row height | ||||||
|    */ |    */ | ||||||
|  | @ -32,6 +36,7 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui | ||||||
| export const defaultOptions: Partial<Options> = { | export const defaultOptions: Partial<Options> = { | ||||||
|   alignValue: 'left', |   alignValue: 'left', | ||||||
|   mergeValues: true, |   mergeValues: true, | ||||||
|  |   perPage: 20, | ||||||
|   rowHeight: 0.9, |   rowHeight: 0.9, | ||||||
|   showValue: ui.VisibilityMode.Auto, |   showValue: ui.VisibilityMode.Auto, | ||||||
| }; | }; | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue