Explore Metrics: Update history breadcrumb tooltips (#90825)

* add history handler

* move them into functions

* handle adding new history steps

* handle time history by respecting the timezone

* remove commented code

* no type casting

* add unit tests

* add colons and a new type metric_page

* remove console

* fix unit tests
This commit is contained in:
ismail simsek 2024-07-25 23:27:41 +02:00 committed by GitHub
parent 2fe506d502
commit 8dd6bfef3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 267 additions and 80 deletions

View File

@ -52,8 +52,8 @@ describe('DataTrail', () => {
expect(trail.state.topScene).toBeInstanceOf(MetricSelectScene);
});
it('Should set history current step to 0', () => {
expect(trail.state.history.state.currentStep).toBe(0);
it('Should set history current step to 1', () => {
expect(trail.state.history.state.currentStep).toBe(1);
});
it('Should set history step 0 parentIndex to -1', () => {
@ -75,11 +75,11 @@ describe('DataTrail', () => {
});
it('should add history step', () => {
expect(trail.state.history.state.steps[1].type).toBe('metric');
expect(trail.state.history.state.steps[1].type).toBe('metric_page');
});
it('Should set history currentStep to 1', () => {
expect(trail.state.history.state.currentStep).toBe(1);
it('Should set history currentStep to 2', () => {
expect(trail.state.history.state.currentStep).toBe(2);
});
it('Should set history step 1 parentIndex to 0', () => {
@ -105,11 +105,11 @@ describe('DataTrail', () => {
});
it('should add history step', () => {
expect(trail.state.history.state.steps[2].type).toBe('time');
expect(trail.state.history.state.steps[3].type).toBe('time');
});
it('Should set history currentStep to 2', () => {
expect(trail.state.history.state.currentStep).toBe(2);
it('Should set history currentStep to 3', () => {
expect(trail.state.history.state.currentStep).toBe(3);
});
it('Should set history step 2 parentIndex to 1', () => {
@ -125,7 +125,7 @@ describe('DataTrail', () => {
});
it('Current history step should have new `from` of "now-1h"', () => {
expect(trail.state.history.state.steps[2].trailState.$timeRange?.state.from).toBe('now-1h');
expect(trail.state.history.state.steps[3].trailState.$timeRange?.state.from).toBe('now-1h');
});
describe('And when traversing back to step 1', () => {
@ -154,12 +154,12 @@ describe('DataTrail', () => {
expect(trail.state.history.state.steps[3].type).toBe('time');
});
it('Should set history currentStep to 3', () => {
expect(trail.state.history.state.currentStep).toBe(3);
it('Should set history currentStep to 4', () => {
expect(trail.state.history.state.currentStep).toBe(4);
});
it('Should set history step 3 parentIndex to 1', () => {
expect(trail.state.history.state.steps[3].parentIndex).toBe(1);
it('Should set history step 4 parentIndex to 1', () => {
expect(trail.state.history.state.steps[4].parentIndex).toBe(1);
});
it('Should have time range `from` be updated "now-15m"', () => {
@ -171,7 +171,7 @@ describe('DataTrail', () => {
});
it('History step 2 should still have `from` of "now-1h"', () => {
expect(trail.state.history.state.steps[2].trailState.$timeRange?.state.from).toBe('now-1h');
expect(trail.state.history.state.steps[3].trailState.$timeRange?.state.from).toBe('now-1h');
});
describe('And then when returning again to step 1', () => {
@ -191,12 +191,12 @@ describe('DataTrail', () => {
expect(trail.state.history.state.steps[1].trailState.$timeRange?.state.from).toBe('now-6h');
});
it('History step 2 should still have `from` of "now-1h"', () => {
expect(trail.state.history.state.steps[2].trailState.$timeRange?.state.from).toBe('now-1h');
it('History step 3 should still have `from` of "now-1h"', () => {
expect(trail.state.history.state.steps[3].trailState.$timeRange?.state.from).toBe('now-1h');
});
it('History step 3 should still have `from` of "now-15m"', () => {
expect(trail.state.history.state.steps[3].trailState.$timeRange?.state.from).toBe('now-15m');
it('History step 4 should still have `from` of "now-15m"', () => {
expect(trail.state.history.state.steps[4].trailState.$timeRange?.state.from).toBe('now-15m');
});
it('Should have time range `from` be set back to "now-6h"', () => {
@ -217,11 +217,11 @@ describe('DataTrail', () => {
});
it('should add history step', () => {
expect(trail.state.history.state.steps[2].type).toBe('filters');
expect(trail.state.history.state.steps[3].type).toBe('filters');
});
it('Should set history currentStep to 2', () => {
expect(trail.state.history.state.currentStep).toBe(2);
it('Should set history currentStep to 3', () => {
expect(trail.state.history.state.currentStep).toBe(3);
});
it('Should set history step 2 parentIndex to 1', () => {
@ -238,8 +238,8 @@ describe('DataTrail', () => {
});
it('Current history step should have new filter zone=a', () => {
expect(getStepFilterVar(2).state.filters[0].key).toBe('zone');
expect(getStepFilterVar(2).state.filters[0].value).toBe('a');
expect(getStepFilterVar(3).state.filters[0].key).toBe('zone');
expect(getStepFilterVar(3).state.filters[0].value).toBe('a');
});
describe('And when traversing back to step 1', () => {
@ -268,12 +268,12 @@ describe('DataTrail', () => {
expect(trail.state.history.state.steps[3].type).toBe('filters');
});
it('Should set history currentStep to 3', () => {
expect(trail.state.history.state.currentStep).toBe(3);
it('Should set history currentStep to 4', () => {
expect(trail.state.history.state.currentStep).toBe(4);
});
it('Should set history step 3 parentIndex to 1', () => {
expect(trail.state.history.state.steps[3].parentIndex).toBe(1);
it('Should set history step 4 parentIndex to 1', () => {
expect(trail.state.history.state.steps[4].parentIndex).toBe(1);
});
it('Should have filter be updated to "zone=b"', () => {
@ -285,14 +285,14 @@ describe('DataTrail', () => {
expect(getStepFilterVar(1).state.filters.length).toBe(0);
});
it('History step 2 should still have old filter zone=a', () => {
expect(getStepFilterVar(2).state.filters[0].key).toBe('zone');
expect(getStepFilterVar(2).state.filters[0].value).toBe('a');
it('History step 3 should still have old filter zone=a', () => {
expect(getStepFilterVar(3).state.filters[0].key).toBe('zone');
expect(getStepFilterVar(3).state.filters[0].value).toBe('a');
});
it('Current history step 3 should have new filter zone=b', () => {
expect(getStepFilterVar(3).state.filters[0].key).toBe('zone');
expect(getStepFilterVar(3).state.filters[0].value).toBe('b');
it('Current history step 4 should have new filter zone=b', () => {
expect(getStepFilterVar(4).state.filters[0].key).toBe('zone');
expect(getStepFilterVar(4).state.filters[0].value).toBe('b');
});
describe('And then when returning again to step 1', () => {
@ -316,14 +316,14 @@ describe('DataTrail', () => {
expect(getStepFilterVar(1).state.filters.length).toBe(0);
});
it('History step 2 should still have old filter zone=a', () => {
expect(getStepFilterVar(2).state.filters[0].key).toBe('zone');
expect(getStepFilterVar(2).state.filters[0].value).toBe('a');
it('History step 3 should still have old filter zone=a', () => {
expect(getStepFilterVar(3).state.filters[0].key).toBe('zone');
expect(getStepFilterVar(3).state.filters[0].value).toBe('a');
});
it('History step 3 should have new filter zone=b', () => {
expect(getStepFilterVar(3).state.filters[0].key).toBe('zone');
expect(getStepFilterVar(3).state.filters[0].value).toBe('b');
it('History step 4 should have new filter zone=b', () => {
expect(getStepFilterVar(4).state.filters[0].key).toBe('zone');
expect(getStepFilterVar(4).state.filters[0].value).toBe('b');
});
});
});
@ -331,11 +331,11 @@ describe('DataTrail', () => {
});
});
describe('When going back to history step 1', () => {
describe('When going back to history step 2', () => {
beforeEach(() => {
trail.publishEvent(new MetricSelectedEvent('first_metric'));
trail.publishEvent(new MetricSelectedEvent('second_metric'));
trail.state.history.goBackToStep(1);
trail.state.history.goBackToStep(2);
});
it('Should restore state and url', () => {
@ -343,12 +343,12 @@ describe('DataTrail', () => {
expect(locationService.getSearchObject().metric).toBe('first_metric');
});
it('Should set history currentStep to 1', () => {
expect(trail.state.history.state.currentStep).toBe(1);
it('Should set history currentStep to 2', () => {
expect(trail.state.history.state.currentStep).toBe(2);
});
it('Should not create another history step', () => {
expect(trail.state.history.state.steps.length).toBe(3);
expect(trail.state.history.state.steps.length).toBe(4);
});
describe('But then selecting a new metric', () => {
@ -357,15 +357,15 @@ describe('DataTrail', () => {
});
it('Should create another history step', () => {
expect(trail.state.history.state.steps.length).toBe(4);
expect(trail.state.history.state.steps.length).toBe(5);
});
it('Should set history current step to 3', () => {
expect(trail.state.history.state.currentStep).toBe(3);
it('Should set history current step to 4', () => {
expect(trail.state.history.state.currentStep).toBe(4);
});
it('Should set history step 3 parent index to 1', () => {
expect(trail.state.history.state.steps[3].parentIndex).toBe(1);
it('Should set history step 4 parent index to 2', () => {
expect(trail.state.history.state.steps[4].parentIndex).toBe(2);
});
describe('And browser back button is pressed', () => {
@ -407,9 +407,9 @@ describe('DataTrail', () => {
expect(getFilterVar().state.filters[0].value).toBe('a');
});
it('Filter of step 1 should be zone=a', () => {
expect(getStepFilterVar(1).state.filters[0].key).toBe('zone');
expect(getStepFilterVar(1).state.filters[0].value).toBe('a');
it('Filter of step 2 should be zone=a', () => {
expect(getStepFilterVar(2).state.filters[0].key).toBe('zone');
expect(getStepFilterVar(2).state.filters[0].value).toBe('a');
});
it('Filter of step 0 should empty', () => {
@ -440,12 +440,12 @@ describe('DataTrail', () => {
expect(trail.state.$timeRange?.state.from).toBe('now-15m');
});
it('Time range `from` of step 1 should be now-15m', () => {
expect(trail.state.history.state.steps[1].trailState.$timeRange?.state.from).toBe('now-15m');
it('Time range `from` of step 2 should be now-15m', () => {
expect(trail.state.history.state.steps[2].trailState.$timeRange?.state.from).toBe('now-15m');
});
it('Time range `from` of step 0 should be now-6h', () => {
expect(trail.state.history.state.steps[0].trailState.$timeRange?.state.from).toBe('now-6h');
it('Time range `from` of step 1 should be now-6h', () => {
expect(trail.state.history.state.steps[1].trailState.$timeRange?.state.from).toBe('now-6h');
});
describe('When returning to step 0', () => {

View File

@ -0,0 +1,65 @@
import { SceneObjectUrlValues } from '@grafana/scenes';
import { parseFilterTooltip, parseTimeTooltip } from './DataTrailsHistory';
type ParseTimeTestCase = {
name: string;
input: SceneObjectUrlValues;
expected: string;
};
type ParseFilterTestCase = {
name: string;
input: { urlValues: SceneObjectUrlValues; filtersApplied: string[] };
expected: string;
expectedFiltersApplied: string[];
};
describe('DataTrailsHistory', () => {
describe('parseTimeTooltip', () => {
// global timezone is set to Pacific/Easter, see jest-config.js file
test.each<ParseTimeTestCase>([
{
name: 'from history',
input: { from: '2024-07-22T18:30:00.000Z', to: '2024-07-22T19:30:00.000Z' },
expected: '2024-07-22 12:30:00 - 2024-07-22 13:30:00',
},
{
name: 'time change event with timezone',
input: { from: '2024-07-22T18:30:00.000Z', to: '2024-07-22T19:30:00.000Z', timeZone: 'Europe/Berlin' },
expected: '2024-07-22 20:30:00 - 2024-07-22 21:30:00',
},
])('$name', ({ input, expected }) => {
const result = parseTimeTooltip(input);
expect(result).toBe(expected);
});
});
describe('parseFilterTooltip', () => {
test.each<ParseFilterTestCase>([
{
name: 'from history initial load',
input: {
urlValues: { 'var-filters': ['job|=|grafana'] },
filtersApplied: [],
},
expected: 'job = grafana',
expectedFiltersApplied: ['job|=|grafana'],
},
{
name: 'from history initial load',
input: {
urlValues: { 'var-filters': ['job|=|grafana', 'instance|=|host.docker.internal:3000'] },
filtersApplied: ['job|=|grafana'],
},
expected: 'instance = host.docker.internal:3000',
expectedFiltersApplied: ['job|=|grafana', 'instance|=|host.docker.internal:3000'],
},
])('$name', ({ input, expected, expectedFiltersApplied }) => {
const filtersApplied = input.filtersApplied;
const result = parseFilterTooltip(input.urlValues, filtersApplied);
expect(result).toBe(expected);
expect(filtersApplied).toEqual(expectedFiltersApplied);
});
});
});

View File

@ -1,19 +1,24 @@
import { css, cx } from '@emotion/css';
import { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { getTimeZoneInfo, GrafanaTheme2, InternalTimeZones, TIME_FORMAT } from '@grafana/data';
import { convertRawToRange } from '@grafana/data/src/datetime/rangeutil';
import {
SceneObjectState,
SceneObjectBase,
getUrlSyncManager,
SceneComponentProps,
SceneVariableValueChangedEvent,
SceneObjectBase,
SceneObjectState,
SceneObjectStateChangedEvent,
SceneObjectUrlValue,
SceneObjectUrlValues,
SceneTimeRange,
sceneUtils,
SceneVariableValueChangedEvent,
} from '@grafana/scenes';
import { useStyles2, Tooltip, Stack } from '@grafana/ui';
import { Stack, Tooltip, useStyles2 } from '@grafana/ui';
import { DataTrail, DataTrailState, getTopSceneFor } from './DataTrail';
import { SerializedTrailHistory } from './TrailStore/TrailStore';
import { reportExploreMetrics } from './interactions';
import { VAR_FILTERS } from './shared';
import { getTrailFor, isSceneTimeRangeState } from './utils';
@ -21,23 +26,42 @@ import { getTrailFor, isSceneTimeRangeState } from './utils';
export interface DataTrailsHistoryState extends SceneObjectState {
currentStep: number;
steps: DataTrailHistoryStep[];
filtersApplied: string[];
}
export function isDataTrailsHistoryState(state: SceneObjectState): state is DataTrailsHistoryState {
return 'currentStep' in state && 'steps' in state;
}
export function isDataTrailHistoryFilter(filter?: SceneObjectUrlValue): filter is string[] {
return !!filter;
}
const isString = (value: unknown): value is string => typeof value === 'string';
export interface DataTrailHistoryStep {
description: string;
detail: string;
type: TrailStepType;
trailState: DataTrailState;
parentIndex: number;
}
export type TrailStepType = 'filters' | 'time' | 'metric' | 'start';
export type TrailStepType = 'filters' | 'time' | 'metric' | 'start' | 'metric_page';
const filterSubst = ` $2 `;
const filterPipeRegex = /(\|)(=|=~|!=|>|<|!~)(\|)/g;
const stepDescriptionMap: Record<TrailStepType, string> = {
start: 'Start of history',
metric: 'Metric selected:',
metric_page: 'Metric select page',
filters: 'Filter applied:',
time: 'Time range changed:',
};
export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
public constructor(state: Partial<DataTrailsHistoryState>) {
super({ steps: state.steps ?? [], currentStep: state.currentStep ?? 0 });
super({ steps: state.steps ?? [], currentStep: state.currentStep ?? 0, filtersApplied: [] });
this.addActivationHandler(this._onActivate.bind(this));
}
@ -62,7 +86,9 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
// But must add a secondary step to represent the selection of the metric
// for this restored trail state
this.addTrailStep(trail, 'metric');
this.addTrailStep(trail, 'metric', trail.state.metric);
} else {
this.addTrailStep(trail, 'metric_page');
}
}
@ -73,15 +99,20 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
this.state.steps[0].trailState = sceneUtils.cloneSceneObjectState(oldState, { history: this });
}
if (newState.metric || oldState.metric) {
this.addTrailStep(trail, 'metric');
if (!newState.metric) {
this.addTrailStep(trail, 'metric_page');
} else {
this.addTrailStep(trail, 'metric', newState.metric);
}
}
});
trail.subscribeToEvent(SceneVariableValueChangedEvent, (evt) => {
if (evt.payload.state.name === VAR_FILTERS) {
this.addTrailStep(trail, 'filters');
const filtersApplied = this.state.filtersApplied;
const urlState = getUrlSyncManager().getUrlState(trail);
this.addTrailStep(trail, 'filters', parseFilterTooltip(urlState, filtersApplied));
this.setState({ filtersApplied });
}
});
@ -93,14 +124,22 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
if (prevState.from === newState.from && prevState.to === newState.to) {
return;
}
}
this.addTrailStep(trail, 'time');
this.addTrailStep(
trail,
'time',
parseTimeTooltip({
from: newState.from,
to: newState.to,
timeZone: newState.timeZone,
})
);
}
}
});
}
public addTrailStep(trail: DataTrail, type: TrailStepType) {
public addTrailStep(trail: DataTrail, type: TrailStepType, detail = '') {
if (this.stepTransitionInProgress) {
// Do not add trail steps when step transition is in progress
return;
@ -114,8 +153,49 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
steps: [
...this.state.steps,
{
description: 'Test',
type,
detail,
description: stepDescriptionMap[type],
trailState: sceneUtils.cloneSceneObjectState(trail.state, { history: this }),
parentIndex,
},
],
});
}
public addTrailStepFromStorage(trail: DataTrail, step: SerializedTrailHistory) {
if (this.stepTransitionInProgress) {
// Do not add trail steps when step transition is in progress
return;
}
const type = step.type;
const stepIndex = this.state.steps.length;
const parentIndex = type === 'start' ? -1 : this.state.currentStep;
const filtersApplied = this.state.filtersApplied;
let detail = '';
switch (step.type) {
case 'metric':
detail = step.urlValues.metric?.toString() ?? '';
break;
case 'filters':
detail = parseFilterTooltip(step.urlValues, filtersApplied);
break;
case 'time':
detail = parseTimeTooltip(step.urlValues);
break;
}
this.setState({
filtersApplied,
currentStep: stepIndex,
steps: [
...this.state.steps,
{
type,
detail,
description: stepDescriptionMap[type],
trailState: sceneUtils.cloneSceneObjectState(trail.state, { history: this }),
parentIndex,
},
@ -145,8 +225,8 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
renderStepTooltip(step: DataTrailHistoryStep) {
return (
<Stack direction="column">
<div>{step.type}</div>
{step.type === 'metric' && <div>{step.trailState.metric || 'Select new metric'}</div>}
<div>{step.description}</div>
{step.detail !== '' && <div>{step.detail}</div>}
</Stack>
);
}
@ -220,6 +300,43 @@ export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
};
}
export function parseTimeTooltip(urlValues: SceneObjectUrlValues): string {
if (!isSceneTimeRangeState(urlValues)) {
return '';
}
const range = convertRawToRange({
from: urlValues.from,
to: urlValues.to,
});
const zone = isString(urlValues.timeZone) ? urlValues.timeZone : InternalTimeZones.localBrowserTime;
const tzInfo = getTimeZoneInfo(zone, Date.now());
const from = range.from.subtract(tzInfo?.offsetInMins ?? 0, 'minute').format(TIME_FORMAT);
const to = range.to.subtract(tzInfo?.offsetInMins ?? 0, 'minute').format(TIME_FORMAT);
return `${from} - ${to}`;
}
export function parseFilterTooltip(urlValues: SceneObjectUrlValues, filtersApplied: string[]): string {
let detail = '';
const varFilters = urlValues['var-filters'];
if (isDataTrailHistoryFilter(varFilters)) {
detail =
varFilters.filter((f) => {
if (f !== '' && !filtersApplied.includes(f)) {
filtersApplied.push(f);
return true;
}
return false;
})[0] ?? '';
}
// filters saved as key|operator|value
// we need to remove pipes (|)
return detail.replace(filterPipeRegex, filterSubst);
}
function getStyles(theme: GrafanaTheme2) {
const visTheme = theme.visualization;
@ -290,6 +407,7 @@ function getStyles(theme: GrafanaTheme2) {
start: generateStepTypeStyle(visTheme.getColorByName('green')),
filters: generateStepTypeStyle(visTheme.getColorByName('purple')),
metric: generateStepTypeStyle(visTheme.getColorByName('orange')),
metric_page: generateStepTypeStyle(visTheme.getColorByName('orange')),
time: generateStepTypeStyle(theme.colors.primary.main),
},
};

View File

@ -14,13 +14,15 @@ import { createBookmarkSavedNotification } from './utils';
const MAX_RECENT_TRAILS = 20;
export interface SerializedTrailHistory {
urlValues: SceneObjectUrlValues;
type: TrailStepType;
description: string;
parentIndex: number;
}
export interface SerializedTrail {
history: Array<{
urlValues: SceneObjectUrlValues;
type: TrailStepType;
description: string;
parentIndex: number;
}>;
history: SerializedTrailHistory[];
currentStep?: number; // Assume last step in history if not specified
createdAt?: number;
}
@ -98,7 +100,7 @@ export class TrailStore {
const parentIndex = step.parentIndex ?? trail.state.history.state.steps.length - 1;
// Set the parent of the next trail step by setting the current step in history.
trail.state.history.setState({ currentStep: parentIndex });
trail.state.history.addTrailStep(trail, step.type);
trail.state.history.addTrailStepFromStorage(trail, step);
});
const currentStep = t.currentStep ?? trail.state.history.state.steps.length - 1;

View File

@ -100,7 +100,9 @@ export function getColorByIndex(index: number) {
export type SceneTimeRangeState = SceneObjectState & {
from: string;
to: string;
timeZone?: string;
};
export function isSceneTimeRangeState(state: SceneObjectState): state is SceneTimeRangeState {
const keys = Object.keys(state);
return keys.includes('from') && keys.includes('to');