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
This commit is contained in:
nmarrs
2025-11-19 20:53:21 -08:00
parent 178e44423b
commit bc4747c7f0
5 changed files with 261 additions and 14 deletions
+128 -1
View File
@@ -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');
});
});
+81 -6
View File
@@ -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;
};
@@ -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'
);
});
});
@@ -49,6 +49,24 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> 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<ShareLinkTabState> 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<ShareLinkTabState> 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() {
@@ -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=');
});
});