mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Preserve variables and time range when navigating between dashboards (#87966)
* POC preserve filters and group by when navigating between dashboards * Save all variables and time range * minor refactor * Add feature toggle * Update feature toggle usage * Delete local storage item if nothing to preserve * Structural changes * Simplify restore params code * Use session storage * Add tests * Merge fix * Remove unused code * And make it better, errrrrr * Minor deduplication refactor * last minor
This commit is contained in:
parent
0b1aec6767
commit
6e9543e0ad
@ -193,4 +193,5 @@ export interface FeatureToggles {
|
|||||||
dashboardRestore?: boolean;
|
dashboardRestore?: boolean;
|
||||||
datasourceProxyDisableRBAC?: boolean;
|
datasourceProxyDisableRBAC?: boolean;
|
||||||
alertingDisableSendAlertsExternal?: boolean;
|
alertingDisableSendAlertsExternal?: boolean;
|
||||||
|
preserveDashboardStateWhenNavigating?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -1306,6 +1306,14 @@ var (
|
|||||||
HideFromDocs: true,
|
HideFromDocs: true,
|
||||||
HideFromAdminPage: true,
|
HideFromAdminPage: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "preserveDashboardStateWhenNavigating",
|
||||||
|
Description: "Enables possibility to preserve dashboard variables and time range when navigating between dashboards",
|
||||||
|
Stage: FeatureStageExperimental,
|
||||||
|
Owner: grafanaDashboardsSquad,
|
||||||
|
HideFromDocs: true,
|
||||||
|
HideFromAdminPage: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -174,3 +174,4 @@ notificationBanner,experimental,@grafana/grafana-frontend-platform,false,false,f
|
|||||||
dashboardRestore,experimental,@grafana/grafana-frontend-platform,false,false,false
|
dashboardRestore,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||||
datasourceProxyDisableRBAC,GA,@grafana/identity-access-team,false,false,false
|
datasourceProxyDisableRBAC,GA,@grafana/identity-access-team,false,false,false
|
||||||
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
|
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
|
||||||
|
preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false
|
||||||
|
|
@ -706,4 +706,8 @@ const (
|
|||||||
// FlagAlertingDisableSendAlertsExternal
|
// FlagAlertingDisableSendAlertsExternal
|
||||||
// Disables the ability to send alerts to an external Alertmanager datasource.
|
// Disables the ability to send alerts to an external Alertmanager datasource.
|
||||||
FlagAlertingDisableSendAlertsExternal = "alertingDisableSendAlertsExternal"
|
FlagAlertingDisableSendAlertsExternal = "alertingDisableSendAlertsExternal"
|
||||||
|
|
||||||
|
// FlagPreserveDashboardStateWhenNavigating
|
||||||
|
// Enables possibility to preserve dashboard variables and time range when navigating between dashboards
|
||||||
|
FlagPreserveDashboardStateWhenNavigating = "preserveDashboardStateWhenNavigating"
|
||||||
)
|
)
|
||||||
|
@ -2250,6 +2250,20 @@
|
|||||||
"codeowner": "@grafana/alerting-squad",
|
"codeowner": "@grafana/alerting-squad",
|
||||||
"frontend": true
|
"frontend": true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": "preserveDashboardStateWhenNavigating",
|
||||||
|
"resourceVersion": "1716555778767",
|
||||||
|
"creationTimestamp": "2024-05-24T13:02:58Z"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"description": "Enables possibility to preserve dashboard variables and time range when navigating between dashboards",
|
||||||
|
"stage": "experimental",
|
||||||
|
"codeowner": "@grafana/dashboards-squad",
|
||||||
|
"hideFromAdminPage": true,
|
||||||
|
"hideFromDocs": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
@ -16,6 +16,7 @@ import { PanelEditor } from '../panel-edit/PanelEditor';
|
|||||||
import { DashboardScene } from '../scene/DashboardScene';
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel';
|
import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel';
|
||||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||||
|
import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState';
|
||||||
|
|
||||||
import { updateNavModel } from './utils';
|
import { updateNavModel } from './utils';
|
||||||
|
|
||||||
@ -177,6 +178,10 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.featureToggles.preserveDashboardStateWhenNavigating && Boolean(options.uid)) {
|
||||||
|
restoreDashboardStateFromLocalStorage(dashboard);
|
||||||
|
}
|
||||||
|
|
||||||
if (!(config.publicDashboardAccessToken && dashboard.state.controls?.state.hideTimeControls)) {
|
if (!(config.publicDashboardAccessToken && dashboard.state.controls?.state.hideTimeControls)) {
|
||||||
dashboard.startUrlSync();
|
dashboard.startUrlSync();
|
||||||
}
|
}
|
||||||
|
@ -48,6 +48,7 @@ import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
|
|||||||
import { RowActions } from '../scene/row-actions/RowActions';
|
import { RowActions } from '../scene/row-actions/RowActions';
|
||||||
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
|
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
|
||||||
import { createPanelDataProvider } from '../utils/createPanelDataProvider';
|
import { createPanelDataProvider } from '../utils/createPanelDataProvider';
|
||||||
|
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';
|
||||||
import { DashboardInteractions } from '../utils/interactions';
|
import { DashboardInteractions } from '../utils/interactions';
|
||||||
import {
|
import {
|
||||||
getCurrentValueForOldIntervalModel,
|
getCurrentValueForOldIntervalModel,
|
||||||
@ -278,6 +279,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
|
|||||||
registerDashboardMacro,
|
registerDashboardMacro,
|
||||||
registerPanelInteractionsReporter,
|
registerPanelInteractionsReporter,
|
||||||
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
|
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
|
||||||
|
preserveDashboardSceneStateInLocalStorage,
|
||||||
],
|
],
|
||||||
$data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
|
$data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
|
||||||
controls: new DashboardControls({
|
controls: new DashboardControls({
|
||||||
|
@ -0,0 +1,125 @@
|
|||||||
|
import { config, locationService } from '@grafana/runtime';
|
||||||
|
import { CustomVariable } from '@grafana/scenes';
|
||||||
|
import { DashboardDataDTO } from 'app/types';
|
||||||
|
|
||||||
|
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||||
|
|
||||||
|
import { PRESERVED_SCENE_STATE_KEY, restoreDashboardStateFromLocalStorage } from './dashboardSessionState';
|
||||||
|
|
||||||
|
describe('dashboardSessionState', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
config.featureToggles.preserveDashboardStateWhenNavigating = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
config.featureToggles.preserveDashboardStateWhenNavigating = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {});
|
||||||
|
|
||||||
|
describe('behavior', () => {
|
||||||
|
it('should do nothing for default home dashboard', () => {
|
||||||
|
const scene = buildTestScene();
|
||||||
|
scene.setState({ uid: undefined });
|
||||||
|
|
||||||
|
const deactivate = scene.activate();
|
||||||
|
expect(window.sessionStorage.getItem(PRESERVED_SCENE_STATE_KEY)).toBeNull();
|
||||||
|
|
||||||
|
deactivate();
|
||||||
|
expect(window.sessionStorage.getItem(PRESERVED_SCENE_STATE_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should capture dashboard scene state and save it to session storage on deactivation', () => {
|
||||||
|
const scene = buildTestScene();
|
||||||
|
|
||||||
|
const deactivate = scene.activate();
|
||||||
|
expect(window.sessionStorage.getItem(PRESERVED_SCENE_STATE_KEY)).toBeNull();
|
||||||
|
|
||||||
|
deactivate();
|
||||||
|
expect(window.sessionStorage.getItem(PRESERVED_SCENE_STATE_KEY)).toBe(
|
||||||
|
'?from=now-6h&to=now&timezone=browser&var-customVar=a'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoreDashboardStateFromLocalStorage', () => {
|
||||||
|
it('should restore dashboard state from session storage', () => {
|
||||||
|
window.sessionStorage.setItem(PRESERVED_SCENE_STATE_KEY, '?var-customVar=b&from=now-5m&to=now&timezone=browser');
|
||||||
|
const scene = buildTestScene();
|
||||||
|
|
||||||
|
restoreDashboardStateFromLocalStorage(scene);
|
||||||
|
const variable = scene.state.$variables!.getByName('customVar') as CustomVariable;
|
||||||
|
const timeRange = scene.state.$timeRange;
|
||||||
|
scene.startUrlSync();
|
||||||
|
|
||||||
|
expect(variable!.state!.value).toEqual(['b']);
|
||||||
|
expect(variable!.state!.text).toEqual(['b']);
|
||||||
|
expect(timeRange?.state.from).toEqual('now-5m');
|
||||||
|
expect(timeRange?.state.to).toEqual('now');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove query params that are not applicable on a target dashboard', () => {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
PRESERVED_SCENE_STATE_KEY,
|
||||||
|
'?var-customVar=b&var-nonApplicableVar=b&from=now-5m&to=now&timezone=browser'
|
||||||
|
);
|
||||||
|
const scene = buildTestScene();
|
||||||
|
|
||||||
|
restoreDashboardStateFromLocalStorage(scene);
|
||||||
|
|
||||||
|
expect(locationService.getSearch().toString()).toBe('var-customVar=b&from=now-5m&to=now&timezone=browser');
|
||||||
|
});
|
||||||
|
|
||||||
|
// handles case when user navigates back to a dashboard with the same state, i.e. using back button
|
||||||
|
it('should remove duplicate query params', () => {
|
||||||
|
locationService.replace({ search: 'var-customVar=b&from=now-6h&to=now&timezone=browser' });
|
||||||
|
|
||||||
|
window.sessionStorage.setItem(PRESERVED_SCENE_STATE_KEY, '?var-customVar=b&from=now-5m&to=now&timezone=browser');
|
||||||
|
const scene = buildTestScene();
|
||||||
|
|
||||||
|
restoreDashboardStateFromLocalStorage(scene);
|
||||||
|
|
||||||
|
expect(locationService.getSearch().toString()).toBe('var-customVar=b&from=now-6h&to=now&timezone=browser');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildTestScene() {
|
||||||
|
const testDashboard: DashboardDataDTO = {
|
||||||
|
annotations: { list: [] },
|
||||||
|
editable: true,
|
||||||
|
fiscalYearStartMonth: 0,
|
||||||
|
graphTooltip: 0,
|
||||||
|
id: 2483,
|
||||||
|
links: [],
|
||||||
|
panels: [],
|
||||||
|
schemaVersion: 39,
|
||||||
|
tags: [],
|
||||||
|
templating: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
multi: true,
|
||||||
|
name: 'customVar',
|
||||||
|
query: 'a,b,c',
|
||||||
|
type: 'custom',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
from: 'now-6h',
|
||||||
|
to: 'now',
|
||||||
|
},
|
||||||
|
timepicker: {},
|
||||||
|
timezone: 'browser',
|
||||||
|
title: 'Test dashboard',
|
||||||
|
uid: 'edhmd9stpd6o0a',
|
||||||
|
version: 24,
|
||||||
|
weekStart: '',
|
||||||
|
};
|
||||||
|
const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} });
|
||||||
|
|
||||||
|
// Removing data layers to avoid mocking built-in Grafana data source
|
||||||
|
scene.setState({ $data: undefined });
|
||||||
|
|
||||||
|
return scene;
|
||||||
|
}
|
@ -0,0 +1,79 @@
|
|||||||
|
import { UrlQueryMap, urlUtil } from '@grafana/data';
|
||||||
|
import { config, locationService } from '@grafana/runtime';
|
||||||
|
import { UrlSyncManager } from '@grafana/scenes';
|
||||||
|
|
||||||
|
import { DashboardScene } from '../scene/DashboardScene';
|
||||||
|
|
||||||
|
export const PRESERVED_SCENE_STATE_KEY = `grafana.dashboard.preservedUrlFiltersState`;
|
||||||
|
|
||||||
|
export function restoreDashboardStateFromLocalStorage(dashboard: DashboardScene) {
|
||||||
|
const preservedUrlState = window.sessionStorage.getItem(PRESERVED_SCENE_STATE_KEY);
|
||||||
|
|
||||||
|
if (preservedUrlState) {
|
||||||
|
const preservedQueryParams = new URLSearchParams(preservedUrlState);
|
||||||
|
const currentQueryParams = locationService.getSearch();
|
||||||
|
|
||||||
|
// iterate over preserved query params and append them to current query params if they don't already exist
|
||||||
|
preservedQueryParams.forEach((value, key) => {
|
||||||
|
if (!currentQueryParams.has(key)) {
|
||||||
|
currentQueryParams.append(key, value);
|
||||||
|
} else {
|
||||||
|
if (!currentQueryParams.getAll(key).includes(value)) {
|
||||||
|
currentQueryParams.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const key of Array.from(currentQueryParams.keys())) {
|
||||||
|
// preserve non-variable query params, i.e. time range
|
||||||
|
if (!key.startsWith('var-')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove params for variables that are not present on the target dashboard
|
||||||
|
if (!dashboard.state.$variables?.getByName(key.replace('var-', ''))) {
|
||||||
|
currentQueryParams.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalParams = currentQueryParams.toString();
|
||||||
|
if (finalParams) {
|
||||||
|
locationService.replace({
|
||||||
|
search: finalParams,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenes behavior that will capture currently selected variables and time range and save them to local storage, so that they can be applied when the next dashboard is loaded.
|
||||||
|
*/
|
||||||
|
export function preserveDashboardSceneStateInLocalStorage(scene: DashboardScene) {
|
||||||
|
if (!config.featureToggles.preserveDashboardStateWhenNavigating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Skipping saving state for default home dashboard
|
||||||
|
if (!scene.state.uid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlStates: UrlQueryMap = Object.fromEntries(
|
||||||
|
Object.entries(new UrlSyncManager().getUrlState(scene)).filter(
|
||||||
|
([key]) => key.startsWith('var-') || key === 'from' || key === 'to' || key === 'timezone'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const nonEmptyUrlStates = Object.fromEntries(
|
||||||
|
Object.entries(urlStates).filter(([key, value]) => !(Array.isArray(value) && value.length === 0))
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there's anything to preserve, save it to local storage
|
||||||
|
if (Object.keys(nonEmptyUrlStates).length > 0) {
|
||||||
|
window.sessionStorage.setItem(PRESERVED_SCENE_STATE_KEY, urlUtil.renderUrl('', nonEmptyUrlStates));
|
||||||
|
} else {
|
||||||
|
window.sessionStorage.removeItem(PRESERVED_SCENE_STATE_KEY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user