From bc4747c7f0fc8134383b1770fe9b5687eba38c62 Mon Sep 17 00:00:00 2001 From: nmarrs Date: Wed, 19 Nov 2025 20:53:21 -0800 Subject: [PATCH] feat: add lockTimeRange parameter to distinguish locked vs unlocked time ranges in share URLs - Add lockTimeRange query parameter (true/false) to share URLs to ensure different short URLs are generated for the same time range with different lock settings - Update getShareUrlParams to include lockTimeRange parameter - Add convertToRelativeTime helper to convert absolute timestamps to relative format when lock time range is disabled - Update createDashboardShareUrl to always use current time range from scene graph and remove stale time params from location.search - Update ShareLinkTab to subscribe to time range changes and rebuild URL dynamically - Add comprehensive unit tests for lockTimeRange functionality - Fix ShareLinkTab to use overrideUseLockedTime parameter for immediate URL updates when toggling lock time range setting --- public/app/core/utils/shortLinks.test.ts | 129 +++++++++++++++++- public/app/core/utils/shortLinks.ts | 87 +++++++++++- .../sharing/ShareLinkTab.test.tsx | 8 +- .../dashboard-scene/sharing/ShareLinkTab.tsx | 27 +++- .../utils/getDashboardUrl.test.ts | 24 ++++ 5 files changed, 261 insertions(+), 14 deletions(-) diff --git a/public/app/core/utils/shortLinks.test.ts b/public/app/core/utils/shortLinks.test.ts index 39a43af6629..33f05b2005e 100644 --- a/public/app/core/utils/shortLinks.test.ts +++ b/public/app/core/utils/shortLinks.test.ts @@ -1,12 +1,19 @@ import { LogRowModel } from '@grafana/data'; import { config } from '@grafana/runtime'; +import { SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; import { createLogRow } from 'app/features/logs/components/mocks/logRow'; import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen'; import { defaultSpec } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/types.spec.gen'; import { defaultStatus } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/types.status.gen'; -import { createShortLink, createAndCopyShortLink, getLogsPermalinkRange, buildShortUrl } from './shortLinks'; +import { + createShortLink, + createAndCopyShortLink, + getLogsPermalinkRange, + buildShortUrl, + getShareUrlParams, +} from './shortLinks'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), @@ -214,3 +221,123 @@ describe('getLogsPermalinkRange', () => { }); }); }); + +describe('getShareUrlParams', () => { + const mockTimeRange = { + state: { + value: { + from: new Date('2024-01-01T00:00:00Z'), + to: new Date('2024-01-01T06:00:00Z'), + }, + }, + } as unknown as SceneTimeRangeLike; + + it('should include from and to when useAbsoluteTimeRange is true', () => { + const params = getShareUrlParams({ useAbsoluteTimeRange: true, theme: 'current' }, mockTimeRange); + + expect(params.from).toBe('2024-01-01T00:00:00.000Z'); + expect(params.to).toBe('2024-01-01T06:00:00.000Z'); + expect(params.lockTimeRange).toBe('true'); + }); + + it('should use relative time format when useAbsoluteTimeRange is false', () => { + const mockTimeRangeWithRelative = { + state: { + value: { + from: new Date('2024-01-01T00:00:00Z'), + to: new Date('2024-01-01T06:00:00Z'), + raw: { + from: 'now-6h', + to: 'now', + }, + }, + }, + } as unknown as SceneTimeRangeLike; + + const params = getShareUrlParams({ useAbsoluteTimeRange: false, theme: 'current' }, mockTimeRangeWithRelative); + + expect(params.from).toBe('now-6h'); + expect(params.to).toBe('now'); + expect(params.lockTimeRange).toBe('false'); + }); + + it('should include theme when theme is not current', () => { + const mockTimeRangeWithRelative = { + state: { + value: { + from: new Date('2024-01-01T00:00:00Z'), + to: new Date('2024-01-01T06:00:00Z'), + raw: { + from: 'now-6h', + to: 'now', + }, + }, + }, + } as unknown as SceneTimeRangeLike; + + const params = getShareUrlParams({ useAbsoluteTimeRange: false, theme: 'dark' }, mockTimeRangeWithRelative); + + expect(params.theme).toBe('dark'); + expect(params.from).toBe('now-6h'); + expect(params.to).toBe('now'); + expect(params.lockTimeRange).toBe('false'); + }); + + it('should include viewPanel when panel is provided', () => { + const mockPanel = { + getPathId: () => 'panel-123', + } as unknown as VizPanel; + + const mockTimeRangeWithRelative = { + state: { + value: { + from: new Date('2024-01-01T00:00:00Z'), + to: new Date('2024-01-01T06:00:00Z'), + raw: { + from: 'now-6h', + to: 'now', + }, + }, + }, + } as unknown as SceneTimeRangeLike; + + const params = getShareUrlParams( + { useAbsoluteTimeRange: false, theme: 'current' }, + mockTimeRangeWithRelative, + mockPanel + ); + + expect(params.viewPanel).toBe('panel-123'); + expect(params.lockTimeRange).toBe('false'); + }); + + it('should include lockTimeRange parameter to distinguish locked vs unlocked time ranges', () => { + const mockTimeRangeWithRelative = { + state: { + value: { + from: new Date('2024-01-01T00:00:00Z'), + to: new Date('2024-01-01T06:00:00Z'), + raw: { + from: 'now-6h', + to: 'now', + }, + }, + }, + } as unknown as SceneTimeRangeLike; + + // Locked time range should have lockTimeRange=true and absolute timestamps + const lockedParams = getShareUrlParams({ useAbsoluteTimeRange: true, theme: 'current' }, mockTimeRangeWithRelative); + expect(lockedParams.lockTimeRange).toBe('true'); + expect(lockedParams.from).toBe('2024-01-01T00:00:00.000Z'); + expect(lockedParams.to).toBe('2024-01-01T06:00:00.000Z'); + + // Unlocked time range should have lockTimeRange=false and relative timestamps + const unlockedParams = getShareUrlParams( + { useAbsoluteTimeRange: false, theme: 'current' }, + mockTimeRangeWithRelative + ); + expect(unlockedParams.lockTimeRange).toBe('false'); + expect(unlockedParams.from).toBe('now-6h'); + expect(unlockedParams.to).toBe('now'); + }); +}); diff --git a/public/app/core/utils/shortLinks.ts b/public/app/core/utils/shortLinks.ts index 706857c122c..87452d6d96b 100644 --- a/public/app/core/utils/shortLinks.ts +++ b/public/app/core/utils/shortLinks.ts @@ -1,6 +1,6 @@ import memoizeOne from 'memoize-one'; -import { AbsoluteTimeRange, LogRowModel, UrlQueryMap } from '@grafana/data'; +import { AbsoluteTimeRange, dateTime, DateTime, isDateTime, LogRowModel, UrlQueryMap } from '@grafana/data'; import { t } from '@grafana/i18n'; import { getBackendSrv, config, locationService } from '@grafana/runtime'; import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; @@ -123,19 +123,75 @@ export const createAndCopyShareDashboardLink = async ( export const createDashboardShareUrl = (dashboard: DashboardScene, opts: ShareLinkConfiguration, panel?: VizPanel) => { const location = locationService.getLocation(); - const timeRange = sceneGraph.getTimeRange(panel ?? dashboard); + // Get the current time range from the scene graph - this is always up-to-date + // and reflects any changes the user has made to the dashboard time range + // We access the state directly each time to ensure we get the latest values + const timeRangeObj = sceneGraph.getTimeRange(panel ?? dashboard); - const urlParamsUpdate = getShareUrlParams(opts, timeRange, panel); + const urlParamsUpdate = getShareUrlParams(opts, timeRangeObj, panel); + + // Remove time params from currentQueryParams to avoid conflicts with the time range from scene graph + // We always use the time range from the scene graph (or remove it if useAbsoluteTimeRange is false) + // Never use stale time params from location.search + let currentQueryParams = location.search; + // Always remove time params from currentQueryParams - we'll add them back if needed via updateQuery + const params = new URLSearchParams(currentQueryParams); + params.delete('from'); + params.delete('to'); + currentQueryParams = params.toString() ? `?${params.toString()}` : ''; return getDashboardUrl({ uid: dashboard.state.uid, slug: dashboard.state.meta.slug, - currentQueryParams: location.search, + currentQueryParams: currentQueryParams, updateQuery: urlParamsUpdate, absolute: !opts.useShortUrl, }); }; +/** + * Converts a time value to relative format (e.g., now-24h, now) if it's an absolute timestamp. + * If it's already a string (relative format), returns it as-is. + * Only converts recent timestamps (within last 24 hours) to relative format. + */ +function convertToRelativeTime(timeValue: string | DateTime | number): string { + // If it's already a string, assume it's relative format + if (typeof timeValue === 'string') { + return timeValue; + } + + // Convert to DateTime if it's a number (epoch milliseconds) + const dateTimeValue = typeof timeValue === 'number' ? dateTime(timeValue) : timeValue; + + if (!isDateTime(dateTimeValue)) { + return String(timeValue); + } + + const now = dateTime(); + const diff = now.diff(dateTimeValue); + + // Only convert recent timestamps (within last 24 hours) to relative format + // Older timestamps should stay as absolute + if (Math.abs(diff) > 24 * 60 * 60 * 1000) { + return dateTimeValue.toISOString(); + } + + // Calculate offset in minutes + const offsetMinutes = Math.round(diff / (60 * 1000)); + + if (offsetMinutes === 0) { + return 'now'; + } + + // Convert to relative format + if (Math.abs(offsetMinutes) < 60) { + return `now${offsetMinutes > 0 ? '-' : '+'}${Math.abs(offsetMinutes)}m`; + } else { + const hours = Math.round(offsetMinutes / 60); + return `now${hours > 0 ? '-' : '+'}${Math.abs(hours)}h`; + } +} + export const getShareUrlParams = ( opts: { useAbsoluteTimeRange: boolean; theme: string }, timeRange: SceneTimeRangeLike, @@ -147,15 +203,34 @@ export const getShareUrlParams = ( urlParamsUpdate.viewPanel = panel.getPathId(); } + // Access state.value directly to ensure we get the latest time range values + // Access the state synchronously at this exact moment to get the current time range + // Note: timeRange.state is reactive, so accessing .value here gets the current state + const currentTimeRange = timeRange.state.value; + if (opts.useAbsoluteTimeRange) { - urlParamsUpdate.from = timeRange.state.value.from.toISOString(); - urlParamsUpdate.to = timeRange.state.value.to.toISOString(); + // Lock time range: use absolute ISO timestamps + // This converts relative time ranges (e.g., now-24h) to absolute timestamps + urlParamsUpdate.from = currentTimeRange.from.toISOString(); + urlParamsUpdate.to = currentTimeRange.to.toISOString(); + } else { + // Don't lock time range: use relative time format (e.g., now-24h, now) + // This preserves the current time range but as relative, so it updates when the dashboard is opened + const raw = currentTimeRange.raw; + + // Convert to relative format if needed + urlParamsUpdate.from = convertToRelativeTime(raw.from); + urlParamsUpdate.to = convertToRelativeTime(raw.to); } if (opts.theme !== 'current') { urlParamsUpdate.theme = opts.theme; } + // Include lock time range state in URL to ensure different short URLs for locked vs unlocked + // This allows de-duplication within the same lock state, but different URLs for different states + urlParamsUpdate.lockTimeRange = opts.useAbsoluteTimeRange ? 'true' : 'false'; + return urlParamsUpdate; }; diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx index d50de744de1..ce431d40f08 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.test.tsx @@ -47,7 +47,7 @@ describe('ShareLinkTab', () => { buildAndRenderScenario({}); expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( - 'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=A$panel-12' + 'http://dashboards.grafana.com/grafana/d/dash-1?viewPanel=A$panel-12&from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&lockTimeRange=true' ); }); }); @@ -58,7 +58,7 @@ describe('ShareLinkTab', () => { await act(() => tab.onToggleLockedTime()); expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( - 'http://dashboards.grafana.com/grafana/d/dash-1?from=now-6h&to=now&viewPanel=A$panel-12' + 'http://dashboards.grafana.com/grafana/d/dash-1?viewPanel=A$panel-12&from=now-6h&to=now&lockTimeRange=false' ); }); }); @@ -68,7 +68,7 @@ describe('ShareLinkTab', () => { await act(() => tab.onThemeChange('light')); expect(await screen.findByRole('textbox', { name: 'Link URL' })).toHaveValue( - 'http://dashboards.grafana.com/grafana/d/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&viewPanel=A$panel-12&theme=light' + 'http://dashboards.grafana.com/grafana/d/dash-1?viewPanel=A$panel-12&from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&theme=light&lockTimeRange=true' ); }); @@ -89,7 +89,7 @@ describe('ShareLinkTab', () => { await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage }) ).toHaveAttribute( 'href', - 'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=A$panel-12&__feature.dashboardSceneSolo=true&width=1000&height=500&tz=Pacific%2FEaster' + 'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&lockTimeRange=true&panelId=A$panel-12&__feature.dashboardSceneSolo=true&width=1000&height=500&tz=Pacific%2FEaster' ); }); }); diff --git a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx index 9d2daa5812b..bde33b603d8 100644 --- a/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareLinkTab.tsx @@ -49,6 +49,24 @@ export class ShareLinkTab extends SceneObjectBase implements this.addActivationHandler(() => { this.buildUrl(); + + // Subscribe to time range changes to rebuild URL when dashboard time range changes + // Only rebuild if lock time range is disabled - when enabled, the URL should stay locked + const dashboard = getDashboardSceneFor(this); + const panel = state.panelRef?.resolve(); + const timeRange = sceneGraph.getTimeRange(panel ?? dashboard); + + const subscription = timeRange.subscribeToState(() => { + // Rebuild URL when time range changes + // If lock time range is enabled, this updates the absolute timestamps in the URL + // If disabled, this updates the relative time range in the URL + this.buildUrl(); + }); + + // Return cleanup function to unsubscribe when component is deactivated + return () => { + subscription.unsubscribe(); + }; }); this.onToggleLockedTime = this.onToggleLockedTime.bind(this); @@ -56,9 +74,11 @@ export class ShareLinkTab extends SceneObjectBase implements this.onThemeChange = this.onThemeChange.bind(this); } - buildUrl = async (queryOptions?: UrlQueryMap) => { + buildUrl = async (queryOptions?: UrlQueryMap, overrideUseLockedTime?: boolean) => { this.setState({ isBuildUrlLoading: true }); - const { panelRef, useLockedTime: useAbsoluteTimeRange, useShortUrl, selectedTheme } = this.state; + const { panelRef, useLockedTime, useShortUrl, selectedTheme } = this.state; + // Use override value if provided (for immediate updates), otherwise use state + const useAbsoluteTimeRange = overrideUseLockedTime !== undefined ? overrideUseLockedTime : useLockedTime; const dashboard = getDashboardSceneFor(this); const panel = panelRef?.resolve(); @@ -101,7 +121,8 @@ export class ShareLinkTab extends SceneObjectBase implements async onToggleLockedTime() { const useLockedTime = !this.state.useLockedTime; this.setState({ useLockedTime }); - await this.buildUrl(); + // Pass the new value directly to buildUrl to ensure it uses the updated setting immediately + await this.buildUrl(undefined, useLockedTime); } async onUrlShorten() { diff --git a/public/app/features/dashboard-scene/utils/getDashboardUrl.test.ts b/public/app/features/dashboard-scene/utils/getDashboardUrl.test.ts index 9e69c10bdaa..561fba2e2f6 100644 --- a/public/app/features/dashboard-scene/utils/getDashboardUrl.test.ts +++ b/public/app/features/dashboard-scene/utils/getDashboardUrl.test.ts @@ -65,4 +65,28 @@ describe('dashboard utils', () => { expect(url).toBe('/dashboard/new?orgId=1&filter=A'); }); + + it('should remove time params (from/to) when set to null in updateQuery', () => { + const url = getDashboardUrl({ + uid: 'dash-1', + currentQueryParams: '?orgId=1&from=2024-01-01T00:00:00Z&to=2024-01-01T06:00:00Z&theme=dark', + updateQuery: { from: null, to: null }, + }); + + expect(url).toBe('/d/dash-1?orgId=1&theme=dark'); + expect(url).not.toContain('from='); + expect(url).not.toContain('to='); + }); + + it('should remove time params even when other params are present', () => { + const url = getDashboardUrl({ + uid: 'dash-1', + currentQueryParams: '?orgId=1&from=now-6h&to=now&var-datasource=prometheus', + updateQuery: { from: null, to: null }, + }); + + expect(url).toBe('/d/dash-1?orgId=1&var-datasource=prometheus'); + expect(url).not.toContain('from='); + expect(url).not.toContain('to='); + }); });