diff --git a/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts index 5ca2b9b988e..e5fa116eeb7 100644 --- a/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/statetimeline/panelcfg/x/StateTimelinePanelCfg_types.gen.ts @@ -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 = { alignValue: 'left', mergeValues: true, + perPage: 20, rowHeight: 0.9, showValue: ui.VisibilityMode.Auto, }; diff --git a/public/app/core/components/TimelineChart/TimelineChart.tsx b/public/app/core/components/TimelineChart/TimelineChart.tsx index b6249657cca..1c1b4e8dad7 100644 --- a/public/app/core/components/TimelineChart/TimelineChart.tsx +++ b/public/app/core/components/TimelineChart/TimelineChart.tsx @@ -19,15 +19,17 @@ export interface TimelineProps extends Omit { 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; diff --git a/public/app/core/components/TimelineChart/utils.test.ts b/public/app/core/components/TimelineChart/utils.test.ts index baa114f5da1..a3c181ef42b 100644 --- a/public/app/core/components/TimelineChart/utils.test.ts +++ b/public/app/core/components/TimelineChart/utils.test.ts @@ -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 = { diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index 835d5e74d2c..98954d8308b 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -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; diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index 8739b3790bb..202de0f1035 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -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 {} +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(); + + 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 = ( +
+ +
+ ); + + 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 (

{warn ?? 'No data found in response'}

@@ -60,90 +137,88 @@ 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 ( - - {(builder, alignedFrame) => { - return ( - <> - {cursorSync !== DashboardCursorSync.Off && ( - - )} - {options.tooltip.mode !== TooltipDisplayMode.None && ( - { - if (enableAnnotationCreation && timeRange2 != null) { - setNewAnnotationRange(timeRange2); - dismiss(); - return; +
+ + {(builder, alignedFrame) => { + return ( + <> + {cursorSync !== DashboardCursorSync.Off && ( + + )} + {options.tooltip.mode !== TooltipDisplayMode.None && ( + { + if (enableAnnotationCreation && timeRange2 != null) { + setNewAnnotationRange(timeRange2); + dismiss(); + return; + } - const annotate = () => { - let xVal = u.posToVal(u.cursor.left!, 'x'); + const annotate = () => { + let xVal = u.posToVal(u.cursor.left!, 'x'); - setNewAnnotationRange({ from: xVal, to: xVal }); - dismiss(); - }; + setNewAnnotationRange({ from: xVal, to: xVal }); + dismiss(); + }; - return ( - - ); - }} - maxWidth={options.tooltip.maxWidth} + return ( + + ); + }} + maxWidth={options.tooltip.maxWidth} + /> + )} + {/* Renders annotations */} + - )} - {/* Renders annotations */} - - - - ); - }} - + + + ); + }} + + {paginationElement} +
); }; diff --git a/public/app/plugins/panel/state-timeline/module.tsx b/public/app/plugins/panel/state-timeline/module.tsx index 8b77bd5d04c..972194fc52d 100644 --- a/public/app/plugins/panel/state-timeline/module.tsx +++ b/public/app/plugins/panel/state-timeline/module.tsx @@ -118,6 +118,15 @@ export const plugin = new PanelPlugin(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); diff --git a/public/app/plugins/panel/state-timeline/panelcfg.cue b/public/app/plugins/panel/state-timeline/panelcfg.cue index 0a48dc98306..a186f5e12e8 100644 --- a/public/app/plugins/panel/state-timeline/panelcfg.cue +++ b/public/app/plugins/panel/state-timeline/panelcfg.cue @@ -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 diff --git a/public/app/plugins/panel/state-timeline/panelcfg.gen.ts b/public/app/plugins/panel/state-timeline/panelcfg.gen.ts index fb03ba0795f..0fc25fe65b8 100644 --- a/public/app/plugins/panel/state-timeline/panelcfg.gen.ts +++ b/public/app/plugins/panel/state-timeline/panelcfg.gen.ts @@ -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 = { alignValue: 'left', mergeValues: true, + perPage: 20, rowHeight: 0.9, showValue: ui.VisibilityMode.Auto, };