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:
Dominik Prokop 2024-05-27 14:28:06 +02:00 committed by GitHub
parent 0b1aec6767
commit 6e9543e0ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 239 additions and 0 deletions

View File

@ -193,4 +193,5 @@ export interface FeatureToggles {
dashboardRestore?: boolean;
datasourceProxyDisableRBAC?: boolean;
alertingDisableSendAlertsExternal?: boolean;
preserveDashboardStateWhenNavigating?: boolean;
}

View File

@ -1306,6 +1306,14 @@ var (
HideFromDocs: 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,
},
}
)

View File

@ -174,3 +174,4 @@ notificationBanner,experimental,@grafana/grafana-frontend-platform,false,false,f
dashboardRestore,experimental,@grafana/grafana-frontend-platform,false,false,false
datasourceProxyDisableRBAC,GA,@grafana/identity-access-team,false,false,false
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
174 dashboardRestore experimental @grafana/grafana-frontend-platform false false false
175 datasourceProxyDisableRBAC GA @grafana/identity-access-team false false false
176 alertingDisableSendAlertsExternal experimental @grafana/alerting-squad false false false
177 preserveDashboardStateWhenNavigating experimental @grafana/dashboards-squad false false false

View File

@ -706,4 +706,8 @@ const (
// FlagAlertingDisableSendAlertsExternal
// Disables the ability to send alerts to an external Alertmanager datasource.
FlagAlertingDisableSendAlertsExternal = "alertingDisableSendAlertsExternal"
// FlagPreserveDashboardStateWhenNavigating
// Enables possibility to preserve dashboard variables and time range when navigating between dashboards
FlagPreserveDashboardStateWhenNavigating = "preserveDashboardStateWhenNavigating"
)

View File

@ -2250,6 +2250,20 @@
"codeowner": "@grafana/alerting-squad",
"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
}
}
]
}

View File

@ -16,6 +16,7 @@ import { PanelEditor } from '../panel-edit/PanelEditor';
import { DashboardScene } from '../scene/DashboardScene';
import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState';
import { updateNavModel } from './utils';
@ -177,6 +178,10 @@ export class DashboardScenePageStateManager extends StateManagerBase<DashboardSc
return;
}
if (config.featureToggles.preserveDashboardStateWhenNavigating && Boolean(options.uid)) {
restoreDashboardStateFromLocalStorage(dashboard);
}
if (!(config.publicDashboardAccessToken && dashboard.state.controls?.state.hideTimeControls)) {
dashboard.startUrlSync();
}

View File

@ -48,6 +48,7 @@ import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior';
import { RowActions } from '../scene/row-actions/RowActions';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { createPanelDataProvider } from '../utils/createPanelDataProvider';
import { preserveDashboardSceneStateInLocalStorage } from '../utils/dashboardSessionState';
import { DashboardInteractions } from '../utils/interactions';
import {
getCurrentValueForOldIntervalModel,
@ -278,6 +279,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
registerDashboardMacro,
registerPanelInteractionsReporter,
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
preserveDashboardSceneStateInLocalStorage,
],
$data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
controls: new DashboardControls({

View File

@ -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;
}

View File

@ -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);
}
};
}