datatrails: fix stability issues between conflict between browser history, URL sync, and trail history (#85134)

* fix: conflict between browser history and trail history

- ensure the back button or url changes don't generate trail steps
- ensure label breakdown TextLinks which appear on the summary tab work in embedded mode
This commit is contained in:
Darren Janeczek 2024-03-28 09:59:16 -04:00 committed by GitHub
parent b039995a4e
commit 137061d1d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 77 additions and 45 deletions

View File

@ -12,8 +12,9 @@ import { Stack, Text, TextLink } from '@grafana/ui';
import { PromMetricsMetadataItem } from '../../../plugins/datasource/prometheus/types'; import { PromMetricsMetadataItem } from '../../../plugins/datasource/prometheus/types';
import { ALL_VARIABLE_VALUE } from '../../variables/constants'; import { ALL_VARIABLE_VALUE } from '../../variables/constants';
import { MetricScene } from '../MetricScene';
import { StatusWrapper } from '../StatusWrapper'; import { StatusWrapper } from '../StatusWrapper';
import { TRAILS_ROUTE, VAR_DATASOURCE_EXPR, VAR_GROUP_BY } from '../shared'; import { VAR_DATASOURCE_EXPR, VAR_GROUP_BY } from '../shared';
import { getMetricSceneFor, getTrailFor } from '../utils'; import { getMetricSceneFor, getTrailFor } from '../utils';
import { getLabelOptions } from './utils'; import { getLabelOptions } from './utils';
@ -91,26 +92,25 @@ export class MetricOverviewScene extends SceneObjectBase<MetricOverviewSceneStat
<Stack direction="column" gap={0.5}> <Stack direction="column" gap={0.5}>
<Text weight={'medium'}>Labels</Text> <Text weight={'medium'}>Labels</Text>
{labelOptions.length === 0 && 'Unable to fetch labels.'} {labelOptions.length === 0 && 'Unable to fetch labels.'}
{labelOptions.map((l) => {labelOptions.map((l) => (
getTrailFor(model).state.embedded ? ( <TextLink
// Do not render as TextLink when in embedded mode, as any direct URL key={l.label}
// manipulation will take the browser out out of the current page. href={`#View breakdown for ${l.label}`}
<div key={l.label}>{l.label}</div> title={`View breakdown for ${l.label}`}
) : ( onClick={(event) => {
<TextLink event.preventDefault();
key={l.label} event.stopPropagation();
href={sceneGraph.interpolate( sceneGraph.getAncestor(model, MetricScene).setActionView('breakdown');
model, const groupByVar = sceneGraph.lookupVariable(VAR_GROUP_BY, model);
`${TRAILS_ROUTE}$\{__url.params:exclude:actionView,var-groupby}&actionView=breakdown&var-groupby=${encodeURIComponent( if (groupByVar instanceof QueryVariable) {
l.value! groupByVar.setState({ value: l.value });
)}` }
)} return false;
title="View breakdown" }}
> >
{l.label!} {l.label!}
</TextLink> </TextLink>
) ))}
)}
</Stack> </Stack>
</> </>
</Stack> </Stack>

View File

@ -1,5 +1,4 @@
import { locationService, setDataSourceSrv } from '@grafana/runtime'; import { locationService, setDataSourceSrv } from '@grafana/runtime';
import { getUrlSyncManager } from '@grafana/scenes';
import { MockDataSourceSrv, mockDataSource } from '../alerting/unified/mocks'; import { MockDataSourceSrv, mockDataSource } from '../alerting/unified/mocks';
import { DataSourceType } from '../alerting/unified/utils/datasource'; import { DataSourceType } from '../alerting/unified/utils/datasource';
@ -22,13 +21,13 @@ describe('DataTrail', () => {
); );
}); });
describe('Given starting trail with url sync and no url state', () => { describe('Given starting non-embedded trail with url sync and no url state', () => {
let trail: DataTrail; let trail: DataTrail;
const preTrailUrl = '/';
beforeEach(() => { beforeEach(() => {
trail = new DataTrail({}); trail = new DataTrail({});
locationService.push('/'); locationService.push(preTrailUrl);
getUrlSyncManager().initSync(trail);
activateFullSceneTree(trail); activateFullSceneTree(trail);
}); });
@ -73,6 +72,15 @@ describe('DataTrail', () => {
it('Should set history step 1 parentIndex to 0', () => { it('Should set history step 1 parentIndex to 0', () => {
expect(trail.state.history.state.steps[1].parentIndex).toBe(0); expect(trail.state.history.state.steps[1].parentIndex).toBe(0);
}); });
describe('And browser back button is pressed', () => {
locationService.getHistory().goBack();
it('Should return to original URL', () => {
const { pathname } = locationService.getLocation();
expect(pathname).toEqual(preTrailUrl);
});
});
}); });
describe('When going back to history step 1', () => { describe('When going back to history step 1', () => {
@ -111,6 +119,15 @@ describe('DataTrail', () => {
it('Should set history step 3 parent index to 1', () => { it('Should set history step 3 parent index to 1', () => {
expect(trail.state.history.state.steps[3].parentIndex).toBe(1); expect(trail.state.history.state.steps[3].parentIndex).toBe(1);
}); });
describe('And browser back button is pressed', () => {
locationService.getHistory().goBack();
it('Should return to original URL', () => {
const { pathname } = locationService.getLocation();
expect(pathname).toEqual(preTrailUrl);
});
});
}); });
}); });
}); });

View File

@ -33,7 +33,6 @@ import { MetricsHeader } from './MetricsHeader';
import { getTrailStore } from './TrailStore/TrailStore'; import { getTrailStore } from './TrailStore/TrailStore';
import { MetricDatasourceHelper } from './helpers/MetricDatasourceHelper'; import { MetricDatasourceHelper } from './helpers/MetricDatasourceHelper';
import { MetricSelectedEvent, trailDS, VAR_DATASOURCE, VAR_FILTERS } from './shared'; import { MetricSelectedEvent, trailDS, VAR_DATASOURCE, VAR_FILTERS } from './shared';
import { getUrlForTrail } from './utils';
export interface DataTrailState extends SceneObjectState { export interface DataTrailState extends SceneObjectState {
topScene?: SceneObject; topScene?: SceneObject;
@ -89,9 +88,11 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
const newStepWasAppended = newNumberOfSteps > oldNumberOfSteps; const newStepWasAppended = newNumberOfSteps > oldNumberOfSteps;
if (newStepWasAppended) { if (newStepWasAppended) {
// A new step is a significant change. Update the URL to match the new state.
this.syncTrailToUrl();
// In order for the `useBookmarkState` to re-evaluate after a new step was made: // In order for the `useBookmarkState` to re-evaluate after a new step was made:
this.forceRender(); this.forceRender();
// Do nothing because the state is already up to date -- it created a new step! // Do nothing else because the step state is already up to date -- it created a new step!
return; return;
} }
@ -103,12 +104,15 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
// History changed because a different node was selected // History changed because a different node was selected
const step = newState.steps[newState.currentStep]; const step = newState.steps[newState.currentStep];
if (!step) {
return;
}
this.goBackToStep(step); this.goBackToStep(step);
}); });
return () => { return () => {
if (!this.state.embedded) { if (!this.state.embedded) {
getUrlSyncManager().cleanUp(this);
getTrailStore().setRecentTrail(this); getTrailStore().setRecentTrail(this);
} }
}; };
@ -135,29 +139,25 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
} }
private goBackToStep(step: DataTrailHistoryStep) { private goBackToStep(step: DataTrailHistoryStep) {
if (!this.state.embedded) {
getUrlSyncManager().cleanUp(this);
}
if (!step.trailState.metric) { if (!step.trailState.metric) {
step.trailState.metric = undefined; step.trailState.metric = undefined;
} }
this.setState(step.trailState); this.setState(step.trailState);
this.syncTrailToUrl();
}
if (!this.state.embedded) { private syncTrailToUrl() {
locationService.replace(getUrlForTrail(this)); if (this.state.embedded) {
// Embedded trails should not be altering the URL
getUrlSyncManager().initSync(this); return;
} }
const urlState = getUrlSyncManager().getUrlState(this);
locationService.partial(urlState, true);
} }
private _handleMetricSelectedEvent(evt: MetricSelectedEvent) { private _handleMetricSelectedEvent(evt: MetricSelectedEvent) {
if (this.state.embedded) { this.setState(this.getSceneUpdatesForNewMetricValue(evt.payload));
this.setState(this.getSceneUpdatesForNewMetricValue(evt.payload));
} else {
locationService.partial({ metric: evt.payload, actionView: null });
}
// Add metric to adhoc filters baseFilter // Add metric to adhoc filters baseFilter
const filterVar = sceneGraph.lookupVariable(VAR_FILTERS, this); const filterVar = sceneGraph.lookupVariable(VAR_FILTERS, this);

View File

@ -4,7 +4,7 @@ import { Route, Switch } from 'react-router-dom';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { getUrlSyncManager, SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import { SceneComponentProps, SceneObjectBase, SceneObjectState, getUrlSyncManager } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
@ -75,7 +75,14 @@ function DataTrailView({ trail }: { trail: DataTrail }) {
useEffect(() => { useEffect(() => {
if (!isInitialized) { if (!isInitialized) {
// Set the initial state based on the URL.
getUrlSyncManager().initSync(trail); getUrlSyncManager().initSync(trail);
// Any further changes to the state should occur directly to the state, not through the URL.
// We want to stop automatically syncing the URL state (and vice versa) to the trail after this point.
// Moving forward in the lifecycle of the trail, we will make explicit calls to trail.syncTrailToUrl()
// so we can ensure the URL is kept up to date at key points.
getUrlSyncManager().cleanUp(trail);
getTrailStore().setRecentTrail(trail); getTrailStore().setRecentTrail(trail);
setIsInitialized(true); setIsInitialized(true);
} }

View File

@ -30,7 +30,6 @@ export interface DataTrailHistoryStep {
} }
export type TrailStepType = 'filters' | 'time' | 'metric' | 'start'; export type TrailStepType = 'filters' | 'time' | 'metric' | 'start';
export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> { export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
public constructor(state: Partial<DataTrailsHistoryState>) { public constructor(state: Partial<DataTrailsHistoryState>) {
super({ steps: state.steps ?? [], currentStep: state.currentStep ?? 0 }); super({ steps: state.steps ?? [], currentStep: state.currentStep ?? 0 });
@ -120,9 +119,13 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
} }
public goBackToStep(stepIndex: number) { public goBackToStep(stepIndex: number) {
this.stepTransitionInProgress = true; if (stepIndex === this.state.currentStep) {
return;
}
this.stepTransitionInProgress = true;
this.setState({ currentStep: stepIndex }); this.setState({ currentStep: stepIndex });
// The URL will update
this.stepTransitionInProgress = false; this.stepTransitionInProgress = false;
} }
@ -142,10 +145,15 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
const { ancestry, alternatePredecessorStyle } = useMemo(() => { const { ancestry, alternatePredecessorStyle } = useMemo(() => {
const ancestry = new Set<number>(); const ancestry = new Set<number>();
let cursor = currentStep; let cursor = currentStep;
while (cursor >= 0) { while (cursor >= 0) {
const step = steps[cursor];
if (!step) {
break;
}
ancestry.add(cursor); ancestry.add(cursor);
cursor = steps[cursor].parentIndex; cursor = step.parentIndex;
} }
const alternatePredecessorStyle = new Map<number, string>(); const alternatePredecessorStyle = new Map<number, string>();