diff --git a/public/app/features/trails/DataTrail.test.tsx b/public/app/features/trails/DataTrail.test.tsx index 833058cc5b5..b8542fb8d0d 100644 --- a/public/app/features/trails/DataTrail.test.tsx +++ b/public/app/features/trails/DataTrail.test.tsx @@ -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', () => { diff --git a/public/app/features/trails/DataTrailsHistory.test.tsx b/public/app/features/trails/DataTrailsHistory.test.tsx new file mode 100644 index 00000000000..85ed9128279 --- /dev/null +++ b/public/app/features/trails/DataTrailsHistory.test.tsx @@ -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([ + { + 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([ + { + 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); + }); + }); +}); diff --git a/public/app/features/trails/DataTrailsHistory.tsx b/public/app/features/trails/DataTrailsHistory.tsx index ec4203ace5a..5feb4e337a4 100644 --- a/public/app/features/trails/DataTrailsHistory.tsx +++ b/public/app/features/trails/DataTrailsHistory.tsx @@ -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 = { + start: 'Start of history', + metric: 'Metric selected:', + metric_page: 'Metric select page', + filters: 'Filter applied:', + time: 'Time range changed:', +}; + export class DataTrailHistory extends SceneObjectBase { public constructor(state: Partial) { - 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 { // 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 { 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 { 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 { 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 { renderStepTooltip(step: DataTrailHistoryStep) { return ( -
{step.type}
- {step.type === 'metric' &&
{step.trailState.metric || 'Select new metric'}
} +
{step.description}
+ {step.detail !== '' &&
{step.detail}
}
); } @@ -220,6 +300,43 @@ export class DataTrailHistory extends SceneObjectBase { }; } +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), }, }; diff --git a/public/app/features/trails/TrailStore/TrailStore.ts b/public/app/features/trails/TrailStore/TrailStore.ts index e11c9c8abdb..253450043db 100644 --- a/public/app/features/trails/TrailStore/TrailStore.ts +++ b/public/app/features/trails/TrailStore/TrailStore.ts @@ -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; diff --git a/public/app/features/trails/utils.ts b/public/app/features/trails/utils.ts index 86c63f6eebc..a205ae95fec 100644 --- a/public/app/features/trails/utils.ts +++ b/public/app/features/trails/utils.ts @@ -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');