From 749cc735bb79e2059d8ffb6f5206d269ae3e82fd Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Mon, 17 Nov 2025 07:42:54 -0800 Subject: [PATCH] Timeline: Add timeRangePan (#113890) * Timeline: Add timeRangePan * Add tests for state timeline * Add tests for status history * Fix state timeline test and use same dashboard * Consolidate tests * Optimize time range pan tests --- .../panels-suite/state-timeline.spec.ts | 109 ++++++++++++++++++ .../panels-suite/status-history.spec.ts | 109 ++++++++++++++++++ .../core/components/TimelineChart/utils.ts | 8 +- .../state-timeline/StateTimelinePanel.tsx | 2 + .../status-history/StatusHistoryPanel.tsx | 2 + 5 files changed, 229 insertions(+), 1 deletion(-) diff --git a/e2e-playwright/panels-suite/state-timeline.spec.ts b/e2e-playwright/panels-suite/state-timeline.spec.ts index d57498ab95e..ebdeb8d5398 100644 --- a/e2e-playwright/panels-suite/state-timeline.spec.ts +++ b/e2e-playwright/panels-suite/state-timeline.spec.ts @@ -87,3 +87,112 @@ test.describe('Panels test: StateTimeine', { tag: ['@panels', '@state-timeline'] await expect(tooltip, 'tooltip is not shown when disabled').toBeHidden(); }); }); + +test.use({ + featureToggles: { + timeRangePan: true, + }, +}); + +test.describe('Panels test: State Timeline X-axis panning', { tag: ['@panels', '@state-timeline'] }, () => { + test('x-axis panning functionality', async ({ gotoDashboardPage, page, selectors }) => { + let centerX: number; + let centerY: number; + let initialFromTime: number; + let initialToTime: number; + + const dashboardPage = await test.step('Load dashboard and verify cursor changes to grab', async () => { + const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + + const stateTimelinePanel = page.locator('.uplot').first(); + await expect(stateTimelinePanel, 'panel rendered').toBeVisible(); + + const xAxis = stateTimelinePanel.locator('.u-axis').first(); + await expect(xAxis, 'x-axis rendered').toBeVisible(); + + await xAxis.hover(); + + const cursorStyle = await xAxis.evaluate((el: HTMLElement) => window.getComputedStyle(el).cursor); + expect(cursorStyle, 'cursor is grab').toBe('grab'); + + return dashboardPage; + }); + + await test.step('Capture initial time range', async () => { + const stateTimelinePanel = page.locator('.uplot').first(); + const xAxis = stateTimelinePanel.locator('.u-axis').first(); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const initialFrom = await fromField.inputValue(); + const initialTo = await toField.inputValue(); + initialFromTime = new Date(initialFrom).getTime(); + initialToTime = new Date(initialTo).getTime(); + + await page.keyboard.press('Escape'); + + const axisBox = await xAxis.boundingBox(); + if (!axisBox) { + throw new Error('X-axis bounding box not found'); + } + + centerX = axisBox.x + axisBox.width / 2; + centerY = axisBox.y + axisBox.height / 2; + }); + + await test.step('Drag right pans backward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX + 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterRightFrom = await fromField.inputValue(); + const afterRightTo = await toField.inputValue(); + const afterRightFromTime = new Date(afterRightFrom).getTime(); + const afterRightToTime = new Date(afterRightTo).getTime(); + + expect(afterRightFromTime, 'panned backward').toBeLessThan(initialFromTime); + expect(afterRightToTime, 'panned backward').toBeLessThan(initialToTime); + + await page.keyboard.press('Escape'); + + initialFromTime = afterRightFromTime; + initialToTime = afterRightToTime; + }); + + await test.step('Drag left pans forward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX - 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterLeftFrom = await fromField.inputValue(); + const afterLeftTo = await toField.inputValue(); + const afterLeftFromTime = new Date(afterLeftFrom).getTime(); + const afterLeftToTime = new Date(afterLeftTo).getTime(); + + expect(afterLeftFromTime, 'panned forward').toBeGreaterThan(initialFromTime); + expect(afterLeftToTime, 'panned forward').toBeGreaterThan(initialToTime); + }); + }); +}); diff --git a/e2e-playwright/panels-suite/status-history.spec.ts b/e2e-playwright/panels-suite/status-history.spec.ts index e6c7cccac67..bece3aa47c7 100644 --- a/e2e-playwright/panels-suite/status-history.spec.ts +++ b/e2e-playwright/panels-suite/status-history.spec.ts @@ -85,3 +85,112 @@ test.describe('Panels test: StatusHistory', { tag: ['@panels', '@status-history' await expect(tooltip, 'tooltip is not shown when disabled').toBeHidden(); }); }); + +test.use({ + featureToggles: { + timeRangePan: true, + }, +}); + +test.describe('Panels test: Status History X-axis panning', { tag: ['@panels', '@status-history'] }, () => { + test('x-axis panning functionality', async ({ gotoDashboardPage, page, selectors }) => { + let centerX: number; + let centerY: number; + let initialFromTime: number; + let initialToTime: number; + + const dashboardPage = await test.step('Load dashboard and verify cursor changes to grab', async () => { + const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + + const statusHistoryPanel = page.locator('.uplot').first(); + await expect(statusHistoryPanel, 'panel rendered').toBeVisible(); + + const xAxis = statusHistoryPanel.locator('.u-axis').first(); + await expect(xAxis, 'x-axis rendered').toBeVisible(); + + await xAxis.hover(); + + const cursorStyle = await xAxis.evaluate((el: HTMLElement) => window.getComputedStyle(el).cursor); + expect(cursorStyle, 'cursor is grab').toBe('grab'); + + return dashboardPage; + }); + + await test.step('Capture initial time range', async () => { + const statusHistoryPanel = page.locator('.uplot').first(); + const xAxis = statusHistoryPanel.locator('.u-axis').first(); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const initialFrom = await fromField.inputValue(); + const initialTo = await toField.inputValue(); + initialFromTime = new Date(initialFrom).getTime(); + initialToTime = new Date(initialTo).getTime(); + + await page.keyboard.press('Escape'); + + const axisBox = await xAxis.boundingBox(); + if (!axisBox) { + throw new Error('X-axis bounding box not found'); + } + + centerX = axisBox.x + axisBox.width / 2; + centerY = axisBox.y + axisBox.height / 2; + }); + + await test.step('Drag right pans backward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX + 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterRightFrom = await fromField.inputValue(); + const afterRightTo = await toField.inputValue(); + const afterRightFromTime = new Date(afterRightFrom).getTime(); + const afterRightToTime = new Date(afterRightTo).getTime(); + + expect(afterRightFromTime, 'panned backward').toBeLessThan(initialFromTime); + expect(afterRightToTime, 'panned backward').toBeLessThan(initialToTime); + + await page.keyboard.press('Escape'); + + initialFromTime = afterRightFromTime; + initialToTime = afterRightToTime; + }); + + await test.step('Drag left pans forward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX - 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterLeftFrom = await fromField.inputValue(); + const afterLeftTo = await toField.inputValue(); + const afterLeftFromTime = new Date(afterLeftFrom).getTime(); + const afterLeftToTime = new Date(afterLeftTo).getTime(); + + expect(afterLeftFromTime, 'panned forward').toBeGreaterThan(initialFromTime); + expect(afterLeftToTime, 'panned forward').toBeGreaterThan(initialToTime); + }); + }); +}); diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index a2e9ad366ff..955f1ea11b2 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -156,7 +156,13 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ( isTime: true, orientation: ScaleOrientation.Horizontal, direction: ScaleDirection.Right, - range: coreConfig.xRange, + range: (u) => { + const state = builder.getState(); + if (state.isPanning) { + return [state.min, state.max]; + } + return coreConfig.xRange(u); + }, }); builder.addScale({ diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index a68e81fb6d6..4342b64639b 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -7,6 +7,7 @@ import { EventBusPlugin, TooltipDisplayMode, TooltipPlugin2, + XAxisInteractionAreaPlugin, usePanelContext, useTheme2, } from '@grafana/ui'; @@ -102,6 +103,7 @@ export const StateTimelinePanel = ({ {cursorSync !== DashboardCursorSync.Off && ( )} + {options.tooltip.mode !== TooltipDisplayMode.None && ( )} + {options.tooltip.mode !== TooltipDisplayMode.None && (