StateTimeline: Add pagination support (#89586)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Kevin Putera 2024-08-09 10:44:17 +08:00 committed by GitHub
parent 970a6e71ba
commit 01fc31069f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 259 additions and 87 deletions

View File

@ -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,
};

View File

@ -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;

View File

@ -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 = {

View File

@ -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;

View File

@ -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,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 (
<TimelineChart
theme={theme}
frames={frames}
structureRev={data.structureRev}
timeRange={timeRange}
timeZone={timezones}
width={width}
height={height}
legendItems={legendItems}
{...options}
mode={TimelineMode.Changes}
replaceVariables={replaceVariables}
dataLinkPostProcessor={dataLinkPostProcessor}
cursorSync={cursorSync}
>
{(builder, alignedFrame) => {
return (
<>
{cursorSync !== DashboardCursorSync.Off && (
<EventBusPlugin config={builder} eventBus={eventBus} frame={alignedFrame} />
)}
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={builder}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
}
queryZoom={onChangeTimeRange}
syncMode={cursorSync}
syncScope={eventsScope}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
if (enableAnnotationCreation && timeRange2 != null) {
setNewAnnotationRange(timeRange2);
dismiss();
return;
<div className={styles.container}>
<TimelineChart
theme={theme}
frames={paginatedFrames}
structureRev={data.structureRev}
paginationRev={paginationRev}
timeRange={timeRange}
timeZone={timezones}
width={width}
height={height - paginationHeight}
legendItems={legendItems}
{...options}
mode={TimelineMode.Changes}
replaceVariables={replaceVariables}
dataLinkPostProcessor={dataLinkPostProcessor}
cursorSync={cursorSync}
>
{(builder, alignedFrame) => {
return (
<>
{cursorSync !== DashboardCursorSync.Off && (
<EventBusPlugin config={builder} eventBus={eventBus} frame={alignedFrame} />
)}
{options.tooltip.mode !== TooltipDisplayMode.None && (
<TooltipPlugin2
config={builder}
hoverMode={
options.tooltip.mode === TooltipDisplayMode.Multi ? TooltipHoverMode.xAll : TooltipHoverMode.xOne
}
queryZoom={onChangeTimeRange}
syncMode={cursorSync}
syncScope={eventsScope}
render={(u, dataIdxs, seriesIdx, isPinned, dismiss, timeRange2, viaSync) => {
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 (
<StateTimelineTooltip2
series={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
timeRange={timeRange}
annotate={enableAnnotationCreation ? annotate : undefined}
withDuration={true}
maxHeight={options.tooltip.maxHeight}
/>
);
}}
maxWidth={options.tooltip.maxWidth}
return (
<StateTimelineTooltip2
series={alignedFrame}
dataIdxs={dataIdxs}
seriesIdx={seriesIdx}
mode={viaSync ? TooltipDisplayMode.Multi : options.tooltip.mode}
sortOrder={options.tooltip.sort}
isPinned={isPinned}
timeRange={timeRange}
annotate={enableAnnotationCreation ? annotate : undefined}
withDuration={true}
maxHeight={options.tooltip.maxHeight}
/>
);
}}
maxWidth={options.tooltip.maxWidth}
/>
)}
{/* Renders annotations */}
<AnnotationsPlugin2
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}
newRange={newAnnotationRange}
setNewRange={setNewAnnotationRange}
canvasRegionRendering={false}
/>
)}
{/* Renders annotations */}
<AnnotationsPlugin2
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}
newRange={newAnnotationRange}
setNewRange={setNewAnnotationRange}
canvasRegionRendering={false}
/>
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
</>
);
}}
</TimelineChart>
<OutsideRangePlugin config={builder} onChangeTimeRange={onChangeTimeRange} />
</>
);
}}
</TimelineChart>
{paginationElement}
</div>
);
};

View File

@ -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);

View File

@ -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

View File

@ -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,
};