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 | ||||
|    */ | ||||
|   mergeValues?: boolean; | ||||
|   /** | ||||
|    * Enables pagination when > 0 | ||||
|    */ | ||||
|   perPage?: number; | ||||
|   /** | ||||
|    * Controls the row height | ||||
|    */ | ||||
|  | @ -34,6 +38,7 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui | |||
| export const defaultOptions: Partial<Options> = { | ||||
|   alignValue: 'left', | ||||
|   mergeValues: true, | ||||
|   perPage: 20, | ||||
|   rowHeight: 0.9, | ||||
|   showValue: ui.VisibilityMode.Auto, | ||||
| }; | ||||
|  |  | |||
|  | @ -19,15 +19,17 @@ export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsT | |||
|   colWidth?: number; | ||||
|   legendItems?: VizLegendItem[]; | ||||
|   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> { | ||||
|   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
 | ||||
|       if (disp.color) { | ||||
|         return disp.color; | ||||
|  |  | |||
|  | @ -17,6 +17,7 @@ import { | |||
|   findNextStateIndex, | ||||
|   fmtDuration, | ||||
|   getThresholdItems, | ||||
|   makeFramePerSeries, | ||||
|   prepareTimelineFields, | ||||
|   prepareTimelineLegendItems, | ||||
| } 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', () => { | ||||
|   it('handles leading datapoint index', () => { | ||||
|     const field = { | ||||
|  |  | |||
|  | @ -435,6 +435,24 @@ export function prepareTimelineFields( | |||
|   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[] { | ||||
|   const items: VizLegendItem[] = []; | ||||
|   const thresholds = fieldConfig.thresholds; | ||||
|  |  | |||
|  | @ -1,11 +1,20 @@ | |||
| import { css } from '@emotion/css'; | ||||
| import { useMemo, useState } from 'react'; | ||||
| import { useMeasure } from 'react-use'; | ||||
| 
 | ||||
| import { DashboardCursorSync, PanelProps } from '@grafana/data'; | ||||
| import { getLastStreamingDataFramePacket } from '@grafana/data/src/dataframe/StreamingDataFrame'; | ||||
| import { EventBusPlugin, TooltipDisplayMode, TooltipPlugin2, usePanelContext, useTheme2 } from '@grafana/ui'; | ||||
| import { DashboardCursorSync, DataFrame, PanelProps } from '@grafana/data'; | ||||
| import { | ||||
|   EventBusPlugin, | ||||
|   Pagination, | ||||
|   TooltipDisplayMode, | ||||
|   TooltipPlugin2, | ||||
|   usePanelContext, | ||||
|   useTheme2, | ||||
| } from '@grafana/ui'; | ||||
| import { TimeRange2, TooltipHoverMode } from '@grafana/ui/src/components/uPlot/plugins/TooltipPlugin2'; | ||||
| import { TimelineChart } from 'app/core/components/TimelineChart/TimelineChart'; | ||||
| import { | ||||
|   makeFramePerSeries, | ||||
|   prepareTimelineFields, | ||||
|   prepareTimelineLegendItems, | ||||
|   TimelineMode, | ||||
|  | @ -16,10 +25,73 @@ import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin'; | |||
| import { getTimezones } from '../timeseries/utils'; | ||||
| 
 | ||||
| import { StateTimelineTooltip2 } from './StateTimelineTooltip2'; | ||||
| import { Options } from './panelcfg.gen'; | ||||
| import { Options, defaultOptions } from './panelcfg.gen'; | ||||
| 
 | ||||
| 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 | ||||
|  */ | ||||
|  | @ -45,14 +117,19 @@ export const StateTimelinePanel = ({ | |||
|     [data.series, options.mergeValues, timeRange, theme] | ||||
|   ); | ||||
| 
 | ||||
|   const { paginatedFrames, paginationRev, paginationElement, paginationHeight } = usePagination( | ||||
|     frames, | ||||
|     options.perPage | ||||
|   ); | ||||
| 
 | ||||
|   const legendItems = useMemo( | ||||
|     () => prepareTimelineLegendItems(frames, options.legend, theme), | ||||
|     [frames, options.legend, theme] | ||||
|     () => prepareTimelineLegendItems(paginatedFrames, options.legend, theme), | ||||
|     [paginatedFrames, options.legend, theme] | ||||
|   ); | ||||
| 
 | ||||
|   const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]); | ||||
| 
 | ||||
|   if (!frames || warn) { | ||||
|   if (!paginatedFrames || warn) { | ||||
|     return ( | ||||
|       <div className="panel-empty"> | ||||
|         <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()); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={styles.container}> | ||||
|       <TimelineChart | ||||
|         theme={theme} | ||||
|       frames={frames} | ||||
|         frames={paginatedFrames} | ||||
|         structureRev={data.structureRev} | ||||
|         paginationRev={paginationRev} | ||||
|         timeRange={timeRange} | ||||
|         timeZone={timezones} | ||||
|         width={width} | ||||
|       height={height} | ||||
|         height={height - paginationHeight} | ||||
|         legendItems={legendItems} | ||||
|         {...options} | ||||
|         mode={TimelineMode.Changes} | ||||
|  | @ -145,5 +218,7 @@ export const StateTimelinePanel = ({ | |||
|           ); | ||||
|         }} | ||||
|       </TimelineChart> | ||||
|       {paginationElement} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  |  | |||
|  | @ -118,6 +118,15 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(StateTimelinePanel) | |||
|           step: 0.01, | ||||
|         }, | ||||
|         defaultValue: defaultOptions.rowHeight, | ||||
|       }) | ||||
|       .addNumberInput({ | ||||
|         path: 'perPage', | ||||
|         name: 'Page size (enable pagination)', | ||||
|         settings: { | ||||
|           min: 1, | ||||
|           step: 1, | ||||
|           integer: true, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|     commonOptionsBuilder.addLegendOptions(builder, false); | ||||
|  |  | |||
|  | @ -37,6 +37,8 @@ composableKinds: PanelCfg: { | |||
| 					mergeValues?: bool | *true | ||||
| 					//Controls value alignment on the timelines | ||||
| 					alignValue?: ui.TimelineValueAlignment & (*"left" | _) | ||||
| 					//Enables pagination when > 0 | ||||
| 					perPage?: number & >=1 | *20 | ||||
| 				} @cuetsy(kind="interface") | ||||
| 				FieldConfig: { | ||||
| 					ui.HideableFieldConfig | ||||
|  |  | |||
|  | @ -19,6 +19,10 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui | |||
|    * Merge equal consecutive values | ||||
|    */ | ||||
|   mergeValues?: boolean; | ||||
|   /** | ||||
|    * Enables pagination when > 0 | ||||
|    */ | ||||
|   perPage?: number; | ||||
|   /** | ||||
|    * Controls the row height | ||||
|    */ | ||||
|  | @ -32,6 +36,7 @@ export interface Options extends ui.OptionsWithLegend, ui.OptionsWithTooltip, ui | |||
| export const defaultOptions: Partial<Options> = { | ||||
|   alignValue: 'left', | ||||
|   mergeValues: true, | ||||
|   perPage: 20, | ||||
|   rowHeight: 0.9, | ||||
|   showValue: ui.VisibilityMode.Auto, | ||||
| }; | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue