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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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=');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user