diff --git a/e2e/tests/explore-profiles/explore-profiles.spec.ts b/e2e/tests/explore-profiles/explore-profiles.spec.ts index af719221..01f25c8e 100644 --- a/e2e/tests/explore-profiles/explore-profiles.spec.ts +++ b/e2e/tests/explore-profiles/explore-profiles.spec.ts @@ -8,6 +8,7 @@ test.describe('Explore Profiles', () => { { type: 'profiles', label: 'Profile types' }, { type: 'labels', label: 'Labels' }, { type: 'flame-graph', label: 'Flame graph' }, + { type: 'diff-flame-graph', label: 'Diff flame graph' }, { type: 'favorites', label: 'Favorites' }, ]) { test(label, async ({ exploreProfilesPage }) => { diff --git a/src/pages/ProfilesExplorerView/components/SceneByVariableRepeaterGrid/SceneByVariableRepeaterGrid.tsx b/src/pages/ProfilesExplorerView/components/SceneByVariableRepeaterGrid/SceneByVariableRepeaterGrid.tsx index e23580ea..2091703a 100644 --- a/src/pages/ProfilesExplorerView/components/SceneByVariableRepeaterGrid/SceneByVariableRepeaterGrid.tsx +++ b/src/pages/ProfilesExplorerView/components/SceneByVariableRepeaterGrid/SceneByVariableRepeaterGrid.tsx @@ -16,7 +16,7 @@ import { noOp } from '@shared/domain/noOp'; import { debounce, isEqual } from 'lodash'; import React from 'react'; -import { EventDataReceived } from '../../domain/events/EventDataReceived'; +import { EventTimeseriesDataReceived } from '../../domain/events/EventTimeseriesDataReceived'; import { FiltersVariable } from '../../domain/variables/FiltersVariable/FiltersVariable'; import { getSceneVariableValue } from '../../helpers/getSceneVariableValue'; import { SceneLabelValuesBarGauge } from '../SceneLabelValuesBarGauge'; @@ -315,8 +315,8 @@ export class SceneByVariableRepeaterGrid extends SceneObjectBase { - if (event.payload.series.length > 0) { + const sub = vizPanel.subscribeToEvent(EventTimeseriesDataReceived, (event) => { + if (event.payload.series?.length) { return; } diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/SceneExploreDiffFlameGraph.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/SceneExploreDiffFlameGraph.tsx new file mode 100644 index 00000000..e302d374 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/SceneExploreDiffFlameGraph.tsx @@ -0,0 +1,114 @@ +import { css } from '@emotion/css'; +import { DashboardCursorSync, GrafanaTheme2 } from '@grafana/data'; +import { behaviors, SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { useStyles2 } from '@grafana/ui'; +import React from 'react'; + +import { ProfileMetricVariable } from '../../domain/variables/ProfileMetricVariable'; +import { ServiceNameVariable } from '../../domain/variables/ServiceNameVariable'; +import { CompareTarget } from '../SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/domain/types'; +import { SceneComparePanel } from './components/SceneComparePanel/SceneComparePanel'; +import { SceneDiffFlameGraph } from './components/SceneDiffFlameGraph/SceneDiffFlameGraph'; +import { syncYAxis } from './domain/behaviours/syncYAxis'; + +interface SceneExploreDiffFlameGraphState extends SceneObjectState { + baselinePanel: SceneComparePanel; + comparisonPanel: SceneComparePanel; + body: SceneDiffFlameGraph; +} + +export class SceneExploreDiffFlameGraph extends SceneObjectBase { + constructor({ useAncestorTimeRange }: { useAncestorTimeRange: boolean }) { + super({ + key: 'explore-diff-flame-graph', + baselinePanel: new SceneComparePanel({ + target: CompareTarget.BASELINE, + useAncestorTimeRange, + }), + comparisonPanel: new SceneComparePanel({ + target: CompareTarget.COMPARISON, + useAncestorTimeRange, + }), + $behaviors: [ + new behaviors.CursorSync({ + key: 'metricCrosshairSync', + sync: DashboardCursorSync.Crosshair, + }), + syncYAxis(), + ], + body: new SceneDiffFlameGraph(), + }); + + this.addActivationHandler(this.onActivate.bind(this)); + } + + onActivate() { + const profileMetricVariable = sceneGraph.findByKeyAndType(this, 'profileMetricId', ProfileMetricVariable); + + profileMetricVariable.setState({ query: ProfileMetricVariable.QUERY_SERVICE_NAME_DEPENDENT }); + profileMetricVariable.update(true); + + return () => { + profileMetricVariable.setState({ query: ProfileMetricVariable.QUERY_DEFAULT }); + profileMetricVariable.update(true); + }; + } + + // see SceneProfilesExplorer + getVariablesAndGridControls() { + return { + variables: [ + sceneGraph.findByKeyAndType(this, 'serviceName', ServiceNameVariable), + sceneGraph.findByKeyAndType(this, 'profileMetricId', ProfileMetricVariable), + ], + gridControls: [], + }; + } + + useDiffTimeRanges = () => { + const { baselinePanel, comparisonPanel } = this.state; + + const { annotationTimeRange: baselineTimeRange } = baselinePanel.useDiffTimeRange(); + const { annotationTimeRange: comparisonTimeRange } = comparisonPanel.useDiffTimeRange(); + + return { + baselineTimeRange, + comparisonTimeRange, + }; + }; + + static Component({ model }: SceneComponentProps) { + const styles = useStyles2(getStyles); // eslint-disable-line react-hooks/rules-of-hooks + + const { baselinePanel, comparisonPanel, body } = model.useState(); + + return ( +
+
+ + +
+ + +
+ ); + } +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css` + width: 100%; + display: flex; + flex-direction: column; + `, + columns: css` + display: flex; + flex-direction: row; + gap: ${theme.spacing(1)}; + margin-bottom: ${theme.spacing(1)}; + + & > div { + flex: 1 1 0; + } + `, +}); diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/SceneComparePanel.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/SceneComparePanel.tsx new file mode 100644 index 00000000..c7f21da7 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/SceneComparePanel.tsx @@ -0,0 +1,243 @@ +import { css } from '@emotion/css'; +import { FieldMatcherID, getValueFormat, GrafanaTheme2 } from '@grafana/data'; +import { + SceneComponentProps, + SceneDataTransformer, + sceneGraph, + SceneObjectBase, + SceneObjectState, + SceneRefreshPicker, + SceneTimePicker, + SceneTimeRange, + SceneTimeRangeLike, + VariableDependencyConfig, +} from '@grafana/scenes'; +import { InlineLabel, useStyles2 } from '@grafana/ui'; +import { getProfileMetric, ProfileMetricId } from '@shared/infrastructure/profile-metrics/getProfileMetric'; +import { omit } from 'lodash'; +import React from 'react'; + +import { BASELINE_COLORS, COMPARISON_COLORS } from '../../../../../ComparisonView/ui/colors'; +import { getDefaultTimeRange } from '../../../../domain/getDefaultTimeRange'; +import { FiltersVariable } from '../../../../domain/variables/FiltersVariable/FiltersVariable'; +import { getSceneVariableValue } from '../../../../helpers/getSceneVariableValue'; +import { getSeriesStatsValue } from '../../../../infrastructure/helpers/getSeriesStatsValue'; +import { getProfileMetricLabel } from '../../../../infrastructure/series/helpers/getProfileMetricLabel'; +import { addRefId, addStats } from '../../../SceneByVariableRepeaterGrid/infrastructure/data-transformations'; +import { CompareTarget } from '../../../SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/domain/types'; +import { SceneLabelValuesTimeseries } from '../../../SceneLabelValuesTimeseries'; +import { + SceneTimeRangeWithAnnotations, + TimeRangeWithAnnotationsMode, +} from './components/SceneTimeRangeWithAnnotations'; +import { + SwitchTimeRangeSelectionModeAction, + TimerangeSelectionMode, +} from './domain/actions/SwitchTimeRangeSelectionModeAction'; +import { EventSwitchTimerangeSelectionMode } from './domain/events/EventSwitchTimerangeSelectionMode'; +import { buildCompareTimeSeriesQueryRunner } from './infrastructure/buildCompareTimeSeriesQueryRunner'; + +export interface SceneComparePanelState extends SceneObjectState { + target: CompareTarget; + filterKey: 'filtersBaseline' | 'filtersComparison'; + title: string; + color: string; + timePicker: SceneTimePicker; + refreshPicker: SceneRefreshPicker; + $timeRange: SceneTimeRange; + timeseriesPanel: SceneLabelValuesTimeseries; +} + +export class SceneComparePanel extends SceneObjectBase { + protected _variableDependency = new VariableDependencyConfig(this, { + variableNames: ['profileMetricId'], + onReferencedVariableValueChanged: () => { + this.state.timeseriesPanel.updateTitle(this.buildTimeseriesTitle()); + }, + }); + + constructor({ + target, + useAncestorTimeRange, + }: { + target: SceneComparePanelState['target']; + useAncestorTimeRange: boolean; + }) { + const filterKey = target === CompareTarget.BASELINE ? 'filtersBaseline' : 'filtersComparison'; + const title = target === CompareTarget.BASELINE ? 'Baseline' : 'Comparison'; + const color = + target === CompareTarget.BASELINE ? BASELINE_COLORS.COLOR.toString() : COMPARISON_COLORS.COLOR.toString(); + + super({ + key: `${target}-panel`, + target, + filterKey, + title, + color, + $timeRange: new SceneTimeRange({ key: `${target}-panel-timerange`, ...getDefaultTimeRange() }), + timePicker: new SceneTimePicker({ isOnCanvas: true }), + refreshPicker: new SceneRefreshPicker({ isOnCanvas: true }), + timeseriesPanel: SceneComparePanel.buildTimeSeriesPanel({ target, filterKey, title, color }), + }); + + this.addActivationHandler(this.onActivate.bind(this, useAncestorTimeRange)); + } + + onActivate(useAncestorTimeRange: boolean) { + const { $timeRange, timeseriesPanel } = this.state; + + if (useAncestorTimeRange) { + $timeRange.setState(omit(this.getAncestorTimeRange().state, 'key')); + } + + timeseriesPanel.updateTitle(this.buildTimeseriesTitle()); + + const eventSub = this.subscribeToEvents(); + + return () => { + eventSub.unsubscribe(); + }; + } + + static buildTimeSeriesPanel({ target, filterKey, title, color }: any) { + const timeseriesPanel = new SceneLabelValuesTimeseries({ + item: { + index: 0, + value: target, + label: '', + queryRunnerParams: {}, + }, + data: new SceneDataTransformer({ + $data: buildCompareTimeSeriesQueryRunner({ filterKey }), + transformations: [addRefId, addStats], + }), + overrides: (series) => + series.map((s) => { + const metricField = s.fields[1]; + const allValuesSum = getSeriesStatsValue(s, 'allValuesSum') || 0; + const formattedValue = getValueFormat(metricField.config.unit)(allValuesSum); + const displayName = `${title} total = ${formattedValue.text}${formattedValue.suffix}`; + + return { + matcher: { id: FieldMatcherID.byFrameRefID, options: s.refId }, + properties: [ + { + id: 'displayName', + value: displayName, + }, + { + id: 'color', + value: { mode: 'fixed', fixedColor: color }, + }, + ], + }; + }), + headerActions: () => [new SwitchTimeRangeSelectionModeAction()], + }); + + timeseriesPanel.state.body.setState({ + $timeRange: new SceneTimeRangeWithAnnotations({ + key: `${target}-annotation-timerange`, + mode: TimeRangeWithAnnotationsMode.ANNOTATIONS, + annotationColor: + target === CompareTarget.BASELINE ? BASELINE_COLORS.OVERLAY.toString() : COMPARISON_COLORS.OVERLAY.toString(), + annotationTitle: `${title} time range`, + }), + }); + + return timeseriesPanel; + } + + protected getAncestorTimeRange(): SceneTimeRangeLike { + if (!this.parent || !this.parent.parent) { + throw new Error(typeof this + ' must be used within $timeRange scope'); + } + + return sceneGraph.getTimeRange(this.parent.parent); + } + + subscribeToEvents() { + return this.subscribeToEvent(EventSwitchTimerangeSelectionMode, (event) => { + // this triggers a timeseries request to the API + // TODO: caching? + (this.state.timeseriesPanel.state.body.state.$timeRange as SceneTimeRangeWithAnnotations).setState({ + mode: + event.payload.mode === TimerangeSelectionMode.FLAMEGRAPH + ? TimeRangeWithAnnotationsMode.ANNOTATIONS + : TimeRangeWithAnnotationsMode.DEFAULT, + }); + }); + } + + buildTimeseriesTitle() { + const profileMetricId = getSceneVariableValue(this, 'profileMetricId'); + const { description } = getProfileMetric(profileMetricId as ProfileMetricId); + return description || getProfileMetricLabel(profileMetricId); + } + + useDiffTimeRange() { + return (this.state.timeseriesPanel.state.body.state.$timeRange as SceneTimeRangeWithAnnotations).useState(); + } + + public static Component = ({ model }: SceneComponentProps) => { + const styles = useStyles2(getStyles); + const { title, timeseriesPanel: timeseries, timePicker, refreshPicker, filterKey } = model.useState(); + + const filtersVariable = sceneGraph.findByKey(model, filterKey) as FiltersVariable; + + return ( +
+
+
{title}
+ +
+ + +
+
+ +
+ {filtersVariable.state.label} + +
+ +
{timeseries && }
+
+ ); + }; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + panel: css` + background-color: ${theme.colors.background.primary}; + padding: ${theme.spacing(1)} ${theme.spacing(1)} 0 ${theme.spacing(1)}; + border: 1px solid ${theme.colors.border.weak}; + border-radius: 2px; + `, + panelHeader: css` + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: ${theme.spacing(2)}; + + & > h6 { + margin-top: -2px; + } + `, + timePicker: css` + display: flex; + justify-content: flex-end; + gap: ${theme.spacing(1)}; + `, + filter: css` + display: flex; + margin-bottom: ${theme.spacing(3)}; + `, + timeseries: css` + height: 200px; + + & [data-viz-panel-key] > * { + border: 0 none; + } + `, +}); diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/components/SceneTimeRangeWithAnnotations.ts b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/components/SceneTimeRangeWithAnnotations.ts new file mode 100644 index 00000000..a8164512 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/components/SceneTimeRangeWithAnnotations.ts @@ -0,0 +1,217 @@ +import { dateTime, TimeRange } from '@grafana/data'; +import { + sceneGraph, + SceneObjectBase, + SceneObjectUrlSyncConfig, + SceneObjectUrlValues, + SceneTimeRangeLike, + SceneTimeRangeState, + VariableDependencyConfig, + VizPanel, +} from '@grafana/scenes'; +import { omit } from 'lodash'; + +import { evaluateTimeRange } from '../domain/evaluateTimeRange'; +import { parseUrlParam } from '../domain/parseUrlParam'; +import { RangeAnnotation } from '../domain/RangeAnnotation'; + +export enum TimeRangeWithAnnotationsMode { + ANNOTATIONS = 'annotations', + DEFAULT = 'default', +} + +interface SceneTimeRangeWithAnnotationsState extends SceneTimeRangeState { + annotationTimeRange: TimeRange; + mode: TimeRangeWithAnnotationsMode; + annotationColor: string; + annotationTitle: string; +} + +const TIMERANGE_NIL = { + from: dateTime(0), + to: dateTime(0), + raw: { from: '', to: '' }, +}; + +/** + * This custom SceneTimeRange class provides the ability to draw annotations on timeseries vizualisations. + * Indeed, timeseries visualizations don't support drawing annotations by dragging (it's only supported when holding ctrl/command key) so we need to hijack the zooming event to emulate drawing. + * At the same time, the only way to hijack it is by passing custom $timeRange because TimeSeries vizualization handles zooming internally by looking for the nearest time range object. + * @see https://github.com/grafana/scenes/pull/744 + */ +export class SceneTimeRangeWithAnnotations + extends SceneObjectBase + implements SceneTimeRangeLike +{ + protected _variableDependency = new VariableDependencyConfig(this, { + variableNames: ['dataSource', 'serviceName'], + onReferencedVariableValueChanged: () => { + this.nullifyAnnotationTimeRange(); + this.updateTimeseriesAnnotation(); + }, + }); + + protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['diffFrom', 'diffTo'] }); + + constructor(options: { + key: string; + mode: TimeRangeWithAnnotationsMode; + annotationColor: string; + annotationTitle: string; + }) { + super({ + from: TIMERANGE_NIL.raw.from, + to: TIMERANGE_NIL.raw.to, + value: TIMERANGE_NIL, + annotationTimeRange: TIMERANGE_NIL, + ...options, + }); + + this.addActivationHandler(this.onActivate.bind(this)); + } + + onActivate() { + this.setState(omit(this.getAncestorTimeRange().state, 'key')); + + this._subs.add( + this.getAncestorTimeRange().subscribeToState((newState) => { + this.setState(omit(newState, 'key')); + }) + ); + + this._subs.add( + this.getTimeseries().state.$data?.subscribeToState((newState, prevState) => { + if (!newState.data) { + return; + } + + // add annotation for the first time + if (!newState.data.annotations?.length && !prevState.data?.annotations?.length) { + this.updateTimeseriesAnnotation(); + return; + } + + // ensure we retain the previous annotations, if they exist + if (!newState.data.annotations?.length && prevState.data?.annotations?.length) { + newState.data.annotations = prevState.data.annotations; + } + }) + ); + } + + protected getAncestorTimeRange(): SceneTimeRangeLike { + if (!this.parent || !this.parent.parent) { + throw new Error(typeof this + ' must be used within $timeRange scope'); + } + + return sceneGraph.getTimeRange(this.parent.parent); + } + + protected getTimeseries(): VizPanel { + try { + const vizPanel = sceneGraph.getAncestor(this, VizPanel); + + if (vizPanel.state.pluginId !== 'timeseries') { + throw new TypeError('Incorrect VizPanel type!'); + } + + return vizPanel; + } catch (error) { + throw new Error('Ancestor timeseries panel not found!'); + } + } + + updateTimeseriesAnnotation() { + const { annotationTimeRange, annotationColor, annotationTitle } = this.state; + + const { $data } = this.getTimeseries().state; + + const data = $data?.state.data; + if (!data) { + return; + } + + const annotation = new RangeAnnotation(); + + annotation.addRange({ + color: annotationColor, + text: annotationTitle, + time: annotationTimeRange.from.unix() * 1000, + timeEnd: annotationTimeRange.to.unix() * 1000, + }); + + // tradeoff: this will trigger any $data subscribers even though the data itself hasn't changed + $data?.setState({ + data: { + ...data, + annotations: [annotation], + }, + }); + } + + nullifyAnnotationTimeRange() { + this.setState({ annotationTimeRange: TIMERANGE_NIL }); + } + + getUrlState() { + const { annotationTimeRange } = this.state; + + return { + diffFrom: + typeof annotationTimeRange.raw.from === 'string' + ? annotationTimeRange.raw.from + : annotationTimeRange.raw.from.toISOString(), + diffTo: + typeof annotationTimeRange.raw.to === 'string' + ? annotationTimeRange.raw.to + : annotationTimeRange.raw.to.toISOString(), + }; + } + + updateFromUrl(values: SceneObjectUrlValues) { + const { diffFrom, diffTo } = values; + + if (!diffTo && !diffFrom) { + return; + } + + const { annotationTimeRange } = this.state; + + this.setState({ + annotationTimeRange: evaluateTimeRange( + parseUrlParam(diffFrom) ?? annotationTimeRange.from, + parseUrlParam(diffTo) ?? annotationTimeRange.to, + this.getTimeZone(), + this.state.fiscalYearStartMonth, + this.state.UNSAFE_nowDelay + ), + }); + } + + onTimeRangeChange(timeRange: TimeRange): void { + const { mode } = this.state; + + if (mode === TimeRangeWithAnnotationsMode.DEFAULT) { + this.getAncestorTimeRange().onTimeRangeChange(timeRange); + return; + } + + // this triggers a timeseries request to the API + // TODO: caching? + this.setState({ annotationTimeRange: timeRange }); + + this.updateTimeseriesAnnotation(); + } + + onTimeZoneChange(timeZone: string): void { + this.getAncestorTimeRange().onTimeZoneChange(timeZone); + } + + getTimeZone(): string { + return this.getAncestorTimeRange().getTimeZone(); + } + + onRefresh(): void { + this.getAncestorTimeRange().onRefresh(); + } +} diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/RangeAnnotation.ts b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/RangeAnnotation.ts new file mode 100644 index 00000000..4cc71e3b --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/RangeAnnotation.ts @@ -0,0 +1,33 @@ +import { FieldType, MutableDataFrame } from '@grafana/data'; + +export class RangeAnnotation extends MutableDataFrame { + constructor() { + super(); + [ + { + name: 'time', + type: FieldType.time, + }, + { + name: 'timeEnd', + type: FieldType.time, + }, + { + name: 'isRegion', + type: FieldType.boolean, + }, + { + name: 'color', + type: FieldType.other, + }, + { + name: 'text', + type: FieldType.string, + }, + ].forEach((field) => this.addField(field)); + } + + addRange(entry: { time: number; timeEnd: number; color?: string; text: string }) { + this.add({ ...entry, isRegion: true }); + } +} diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/actions/SwitchTimeRangeSelectionModeAction.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/actions/SwitchTimeRangeSelectionModeAction.tsx new file mode 100644 index 00000000..490f3ebd --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/actions/SwitchTimeRangeSelectionModeAction.tsx @@ -0,0 +1,102 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { Icon, RadioButtonGroup, Tooltip, useStyles2 } from '@grafana/ui'; +import React from 'react'; + +import { EventSwitchTimerangeSelectionMode } from '../events/EventSwitchTimerangeSelectionMode'; + +export enum TimerangeSelectionMode { + TIMEPICKER = 'timepicker', + FLAMEGRAPH = 'flame-graph', +} + +export interface SwitchTimeRangeSelectionTypeActionState extends SceneObjectState { + mode: TimerangeSelectionMode; +} + +export class SwitchTimeRangeSelectionModeAction extends SceneObjectBase { + static OPTIONS = [ + { label: 'Time picker', value: TimerangeSelectionMode.TIMEPICKER }, + { label: 'Flame graph', value: TimerangeSelectionMode.FLAMEGRAPH }, + ]; + + constructor() { + super({ + mode: TimerangeSelectionMode.FLAMEGRAPH, + }); + } + + public onChange = (newMode: TimerangeSelectionMode) => { + this.setState({ mode: newMode }); + + this.publishEvent(new EventSwitchTimerangeSelectionMode({ mode: newMode }), true); + }; + + public static Component = ({ model }: SceneComponentProps) => { + const styles = useStyles2(getStyles); + const { mode } = model.useState(); + + return ( +
+
+ } + placement="top" + > + + + + + + ); + }; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css` + display: flex; + flex-direction: column; + `, + tooltip: css` + padding: ${theme.spacing(1)}; + & dl { + margin-top: ${theme.spacing(2)}; + display: grid; + grid-gap: ${theme.spacing(1)} ${theme.spacing(2)}; + grid-template-columns: max-content; + } + & dt { + font-weight: bold; + } + & dd { + margin: 0; + grid-column-start: 2; + } + `, + label: css` + font-size: 12px; + text-align: right; + margin-bottom: 2px; + color: ${theme.colors.text.secondary}; + `, +}); diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/evaluateTimeRange.ts b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/evaluateTimeRange.ts new file mode 100644 index 00000000..c4d747fc --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/evaluateTimeRange.ts @@ -0,0 +1,23 @@ +import { dateMath, DateTime, TimeRange } from '@grafana/data'; +import { TimeZone } from '@grafana/schema'; + +/* Copied from https://github.com/grafana/scenes/blob/main/packages/scenes/src/utils/evaluateTimeRange.ts */ + +export function evaluateTimeRange( + from: string | DateTime, + to: string | DateTime, + timeZone: TimeZone, + fiscalYearStartMonth?: number, + delay?: string +): TimeRange { + const hasDelay = delay && to === 'now'; + + return { + from: dateMath.parse(from, false, timeZone, fiscalYearStartMonth)!, + to: dateMath.parse(hasDelay ? 'now-' + delay : to, true, timeZone, fiscalYearStartMonth)!, + raw: { + from: from, + to: to, + }, + }; +} diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/events/EventSwitchTimerangeSelectionMode.ts b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/events/EventSwitchTimerangeSelectionMode.ts new file mode 100644 index 00000000..f1780be3 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/events/EventSwitchTimerangeSelectionMode.ts @@ -0,0 +1,11 @@ +import { BusEventWithPayload } from '@grafana/data'; + +import { TimerangeSelectionMode } from '../actions/SwitchTimeRangeSelectionModeAction'; + +export interface EventSwitchTimerangeSelectionModePayload { + mode: TimerangeSelectionMode; +} + +export class EventSwitchTimerangeSelectionMode extends BusEventWithPayload { + public static type = 'switch-timerange-selection-mode'; +} diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/parseUrlParam.ts b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/parseUrlParam.ts new file mode 100644 index 00000000..fbf83664 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/domain/parseUrlParam.ts @@ -0,0 +1,43 @@ +import { toUtc } from '@grafana/data'; +import { SceneObjectUrlValue } from '@grafana/scenes'; + +const INTERVAL_STRING_REGEX = /^\d+[yYmMsSwWhHdD]$/; + +/* Copied from https://github.com/grafana/scenes/blob/main/packages/scenes/src/utils/parseUrlParam.ts */ + +// eslint-disable-next-line sonarjs/cognitive-complexity +export function parseUrlParam(value: SceneObjectUrlValue): string | null { + if (typeof value !== 'string') { + return null; + } + + if (value.indexOf('now') !== -1) { + return value; + } + + if (INTERVAL_STRING_REGEX.test(value)) { + return value; + } + + if (value.length === 8) { + const utcValue = toUtc(value, 'YYYYMMDD'); + if (utcValue.isValid()) { + return utcValue.toISOString(); + } + } else if (value.length === 15) { + const utcValue = toUtc(value, 'YYYYMMDDTHHmmss'); + if (utcValue.isValid()) { + return utcValue.toISOString(); + } + } else if (value.length === 24) { + const utcValue = toUtc(value); + return utcValue.toISOString(); + } + + const epoch = parseInt(value, 10); + if (!isNaN(epoch)) { + return toUtc(epoch).toISOString(); + } + + return null; +} diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/infrastructure/buildCompareTimeSeriesQueryRunner.ts b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/infrastructure/buildCompareTimeSeriesQueryRunner.ts new file mode 100644 index 00000000..049bae8b --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneComparePanel/infrastructure/buildCompareTimeSeriesQueryRunner.ts @@ -0,0 +1,20 @@ +import { SceneQueryRunner } from '@grafana/scenes'; +import { PYROSCOPE_DATA_SOURCE } from 'src/pages/ProfilesExplorerView/infrastructure/pyroscope-data-sources'; + +export function buildCompareTimeSeriesQueryRunner({ + filterKey, +}: { + filterKey: 'filtersBaseline' | 'filtersComparison'; +}) { + return new SceneQueryRunner({ + datasource: PYROSCOPE_DATA_SOURCE, + queries: [ + { + refId: `$profileMetricId-$serviceName-${filterKey}}`, + queryType: 'metrics', + profileTypeId: '$profileMetricId', + labelSelector: `{service_name="$serviceName",$${filterKey}}`, + }, + ], + }); +} diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneDiffFlameGraph/SceneDiffFlameGraph.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneDiffFlameGraph/SceneDiffFlameGraph.tsx new file mode 100644 index 00000000..7c178ae4 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneDiffFlameGraph/SceneDiffFlameGraph.tsx @@ -0,0 +1,207 @@ +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; +import { Spinner, useStyles2 } from '@grafana/ui'; +import { AiPanel } from '@shared/components/AiPanel/AiPanel'; +import { AIButton } from '@shared/components/AiPanel/components/AIButton'; +import { FlameGraph } from '@shared/components/FlameGraph/FlameGraph'; +import { displayWarning } from '@shared/domain/displayStatus'; +import { useToggleSidePanel } from '@shared/domain/useToggleSidePanel'; +import { getProfileMetric, ProfileMetricId } from '@shared/infrastructure/profile-metrics/getProfileMetric'; +import { useFetchPluginSettings } from '@shared/infrastructure/settings/useFetchPluginSettings'; +import { DomainHookReturnValue } from '@shared/types/DomainHookReturnValue'; +import { FlamebearerProfile } from '@shared/types/FlamebearerProfile'; +import { InlineBanner } from '@shared/ui/InlineBanner'; +import { Panel } from '@shared/ui/Panel/Panel'; +import React, { useEffect, useMemo } from 'react'; + +import { useBuildPyroscopeQuery } from '../../../../domain/useBuildPyroscopeQuery'; +import { ProfileMetricVariable } from '../../../../domain/variables/ProfileMetricVariable'; +import { ProfilesDataSourceVariable } from '../../../../domain/variables/ProfilesDataSourceVariable'; +import { ServiceNameVariable } from '../../../../domain/variables/ServiceNameVariable'; +import { SceneExploreDiffFlameGraph } from '../../SceneExploreDiffFlameGraph'; +import { useFetchDiffProfile } from './infrastructure/useFetchDiffProfile'; + +interface SceneDiffFlameGraphState extends SceneObjectState {} + +export class SceneDiffFlameGraph extends SceneObjectBase { + constructor() { + super({ key: 'diff-flame-graph' }); + } + + useSceneDiffFlameGraph = (): DomainHookReturnValue => { + const { baselineTimeRange, comparisonTimeRange } = (this.parent as SceneExploreDiffFlameGraph).useDiffTimeRanges(); + + const baselineQuery = useBuildPyroscopeQuery(this, 'filtersBaseline'); + const comparisonQuery = useBuildPyroscopeQuery(this, 'filtersComparison'); + + const { settings, error: fetchSettingsError } = useFetchPluginSettings(); + + const dataSourceUid = sceneGraph.findByKeyAndType(this, 'dataSource', ProfilesDataSourceVariable).useState() + .value as string; + const serviceName = sceneGraph.findByKeyAndType(this, 'serviceName', ServiceNameVariable).useState() + .value as string; + const profileMetricId = sceneGraph.findByKeyAndType(this, 'profileMetricId', ProfileMetricVariable).useState() + .value as string; + const profileMetricType = getProfileMetric(profileMetricId as ProfileMetricId).type; + + const isDiffQueryEnabled = Boolean( + baselineQuery && + comparisonQuery && + // warning: sending zero parameters values to the API would make the pods crash + // so we enable only when we have non-zero parameters values + baselineTimeRange.from.unix() && + baselineTimeRange.to.unix() && + comparisonTimeRange.from.unix() && + comparisonTimeRange.to.unix() + ); + + const { + isFetching, + error: fetchProfileError, + profile, + } = useFetchDiffProfile({ + enabled: isDiffQueryEnabled, + dataSourceUid, + baselineTimeRange, + baselineQuery, + comparisonTimeRange, + comparisonQuery, + }); + + const noProfileDataAvailable = + isDiffQueryEnabled && !isFetching && !fetchProfileError && profile?.flamebearer.numTicks === 0; + + const shouldDisplayFlamegraph = Boolean( + isDiffQueryEnabled && !fetchProfileError && !noProfileDataAvailable && profile + ); + const shouldDisplayInfo = !isDiffQueryEnabled; + + return { + data: { + title: `${serviceName} diff flame graph (${profileMetricType})`, + isLoading: isFetching, + fetchProfileError, + noProfileDataAvailable, + shouldDisplayFlamegraph, + shouldDisplayInfo, + profile: profile as FlamebearerProfile, + settings, + fetchSettingsError, + }, + actions: {}, + }; + }; + + static Component = ({ model }: SceneComponentProps) => { + const styles = useStyles2(getStyles); + + const { data } = model.useSceneDiffFlameGraph(); + const { + isLoading, + fetchProfileError, + shouldDisplayInfo, + shouldDisplayFlamegraph, + noProfileDataAvailable, + profile, + fetchSettingsError, + settings, + } = data; + + const sidePanel = useToggleSidePanel(); + + useEffect(() => { + if (data.isLoading) { + sidePanel.close(); + } + }, [data.isLoading, sidePanel]); + + if (fetchSettingsError) { + displayWarning([ + 'Error while retrieving the plugin settings!', + 'Some features might not work as expected (e.g. flamegraph export options). Please try to reload the page, sorry for the inconvenience.', + ]); + } + + const panelTitle = useMemo( + () => ( + <> + {data.title} + {data.isLoading && } + + ), + [data.isLoading, data.title, styles.spinner] + ); + + return ( +
+ sidePanel.open('ai')} + disabled={isLoading || noProfileDataAvailable || sidePanel.isOpen('ai')} + interactionName="g_pyroscope_app_explain_flamegraph_clicked" + > + Explain Flame Graph + + } + > + {shouldDisplayInfo && ( + + )} + + {fetchProfileError && ( + + )} + + {noProfileDataAvailable && ( + + )} + + {shouldDisplayFlamegraph && ( + + )} + + + {sidePanel.isOpen('ai') && } +
+ ); + }; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + flex: css` + display: flex; + `, + flamegraphPanel: css` + min-width: 0; + flex-grow: 1; + `, + sidePanel: css` + flex: 1 0 50%; + margin-left: 8px; + max-width: calc(50% - 4px); + `, + spinner: css` + margin-left: ${theme.spacing(1)}; + `, + aiButton: css` + margin-top: ${theme.spacing(1)}; + `, +}); diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneDiffFlameGraph/infrastructure/DiffProfileApiClient.ts b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneDiffFlameGraph/infrastructure/DiffProfileApiClient.ts new file mode 100644 index 00000000..27022023 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneDiffFlameGraph/infrastructure/DiffProfileApiClient.ts @@ -0,0 +1,41 @@ +import { TimeRange } from '@grafana/data'; +import { FlamebearerProfile } from '@shared/types/FlamebearerProfile'; + +import { DataSourceProxyClient } from '../../../../../infrastructure/series/http/DataSourceProxyClient'; + +type DiffProfileResponse = FlamebearerProfile; + +type GetParams = { + leftQuery: string; + leftTimeRange: TimeRange; + rightQuery: string; + rightTimeRange: TimeRange; + maxNodes: number | null; +}; + +export class DiffProfileApiClient extends DataSourceProxyClient { + constructor(options: { dataSourceUid: string }) { + super(options); + } + + async get(params: GetParams): Promise { + const searchParams = new URLSearchParams({ + leftQuery: params.leftQuery, + leftFrom: String(params.leftTimeRange.from.unix() * 1000), + leftUntil: String(params.leftTimeRange.to.unix() * 1000), + rightQuery: params.rightQuery, + rightFrom: String(params.rightTimeRange.from.unix() * 1000), + rightUntil: String(params.rightTimeRange.to.unix() * 1000), + }); + + if (params.maxNodes) { + searchParams.set('max-nodes', String(params.maxNodes)); + } + + const response = await this.fetch(`/pyroscope/render-diff?${searchParams.toString()}`); + + const json = await response.json(); + + return json; + } +} diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneDiffFlameGraph/infrastructure/useFetchDiffProfile.ts b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneDiffFlameGraph/infrastructure/useFetchDiffProfile.ts new file mode 100644 index 00000000..8bbf7e97 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/components/SceneDiffFlameGraph/infrastructure/useFetchDiffProfile.ts @@ -0,0 +1,75 @@ +import { TimeRange } from '@grafana/data'; +import { useMaxNodesFromUrl } from '@shared/domain/url-params/useMaxNodesFromUrl'; +import { useQuery } from '@tanstack/react-query'; + +import { DataSourceProxyClientBuilder } from '../../../../../infrastructure/series/http/DataSourceProxyClientBuilder'; +import { DiffProfileApiClient } from './DiffProfileApiClient'; + +type FetchParams = { + enabled: boolean; + dataSourceUid: string; + baselineTimeRange: TimeRange; + baselineQuery: string; + comparisonTimeRange: TimeRange; + comparisonQuery: string; +}; + +export function useFetchDiffProfile({ + enabled, + dataSourceUid, + baselineTimeRange, + baselineQuery, + comparisonTimeRange, + comparisonQuery, +}: FetchParams) { + const [maxNodes] = useMaxNodesFromUrl(); + + const diffProfileApiClient = DataSourceProxyClientBuilder.build( + dataSourceUid, + DiffProfileApiClient + ) as DiffProfileApiClient; + + const { isFetching, error, data, refetch } = useQuery({ + // for UX: keep previous data while fetching -> profile does not re-render with empty panels when refreshing + placeholderData: (previousData) => previousData, + enabled, + // we use "raw" to cache relative time ranges between renders, so that only refetch() will trigger a new query + // eslint-disable-next-line @tanstack/query/exhaustive-deps + queryKey: [ + 'diff-profile', + baselineQuery, + baselineTimeRange.from.unix(), + baselineTimeRange.to.unix(), + comparisonQuery, + comparisonTimeRange.from.unix(), + comparisonTimeRange.to.unix(), + maxNodes, + ], + queryFn: () => { + diffProfileApiClient.abort(); + + const params = { + leftQuery: baselineQuery, + leftTimeRange: baselineTimeRange, + rightQuery: comparisonQuery, + rightTimeRange: comparisonTimeRange, + maxNodes, + }; + + return diffProfileApiClient.get(params).then((json) => ({ + profile: { + version: json.version, + flamebearer: json.flamebearer, + metadata: json.metadata, + }, + })); + }, + }); + + return { + isFetching, + error: diffProfileApiClient.isAbortError(error) ? null : error, + ...data, + refetch, + }; +} diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/domain/behaviours/syncYAxis.ts b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/domain/behaviours/syncYAxis.ts new file mode 100644 index 00000000..5e19c19a --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreDiffFlameGraph/domain/behaviours/syncYAxis.ts @@ -0,0 +1,44 @@ +import { sceneGraph, SceneObject, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { cloneDeep, merge } from 'lodash'; + +import { EventTimeseriesDataReceived } from '../../../../domain/events/EventTimeseriesDataReceived'; + +export function syncYAxis() { + return (vizPanel: SceneObject) => { + const maxima = new Map(); + + const eventSub = vizPanel.subscribeToEvent(EventTimeseriesDataReceived, (event) => { + const s = event.payload.series?.[0]; + const refId = s?.refId; + + if (!refId) { + console.warn('Missing refId! Cannot sync y-axis on the timeseries.', event.payload.series); + return; + } + + maxima.set(s.refId as string, Math.max(...s.fields[1].values)); + + updateTimeseriesAxis(vizPanel, Math.max(...maxima.values())); + }); + + return () => { + eventSub.unsubscribe(); + }; + }; +} + +function updateTimeseriesAxis(vizPanel: SceneObject, max: number) { + // findAllObjects searches down the full scene graph + const timeseries = sceneGraph.findAllObjects( + vizPanel, + (o) => o instanceof VizPanel && o.state.pluginId === 'timeseries' + ) as VizPanel[]; + + for (const t of timeseries) { + t.clearFieldConfigCache(); // required + + t.setState({ + fieldConfig: merge(cloneDeep(t.state.fieldConfig), { defaults: { max } }), + }); + } +} diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreServiceFlameGraph/SceneFlameGraph.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreServiceFlameGraph/SceneFlameGraph.tsx index 8f2b7719..83c8c1d4 100644 --- a/src/pages/ProfilesExplorerView/components/SceneExploreServiceFlameGraph/SceneFlameGraph.tsx +++ b/src/pages/ProfilesExplorerView/components/SceneExploreServiceFlameGraph/SceneFlameGraph.tsx @@ -116,8 +116,9 @@ export class SceneFlameGraph extends SceneObjectBase { static Component = ({ model }: SceneComponentProps) => { const styles = useStyles2(getStyles); - const sidePanel = useToggleSidePanel(); const { data, actions } = model.useSceneFlameGraph(); + + const sidePanel = useToggleSidePanel(); const gitHubIntegration = useGitHubIntegration(sidePanel); useEffect(() => { diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/SceneGroupByLabels.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/SceneGroupByLabels.tsx index 11bea591..28fb1389 100644 --- a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/SceneGroupByLabels.tsx +++ b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/SceneGroupByLabels.tsx @@ -1,6 +1,5 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { config } from '@grafana/runtime'; import { MultiValueVariableState, SceneComponentProps, @@ -11,9 +10,9 @@ import { } from '@grafana/scenes'; import { Stack, useStyles2 } from '@grafana/ui'; import { reportInteraction } from '@shared/domain/reportInteraction'; -import { buildQuery } from '@shared/domain/url-params/parseQuery'; import React, { useMemo } from 'react'; import { Unsubscribable } from 'rxjs'; +import { EventViewDiffFlameGraph } from 'src/pages/ProfilesExplorerView/domain/events/EventViewDiffFlameGraph'; import { FavAction } from '../../../../domain/actions/FavAction'; import { SelectAction } from '../../../../domain/actions/SelectAction'; @@ -24,7 +23,6 @@ import { EventViewServiceFlameGraph } from '../../../../domain/events/EventViewS import { addFilter } from '../../../../domain/variables/FiltersVariable/filters-ops'; import { FiltersVariable } from '../../../../domain/variables/FiltersVariable/FiltersVariable'; import { GroupByVariable } from '../../../../domain/variables/GroupByVariable/GroupByVariable'; -import { computeRoundedTimeRange } from '../../../../helpers/computeRoundedTimeRange'; import { getSceneVariableValue } from '../../../../helpers/getSceneVariableValue'; import { interpolateQueryRunnerVariables } from '../../../../infrastructure/helpers/interpolateQueryRunnerVariables'; import { getProfileMetricLabel } from '../../../../infrastructure/series/helpers/getProfileMetricLabel'; @@ -296,14 +294,12 @@ export class SceneGroupByLabels extends SceneObjectBase selectForCompare(compareTarget: CompareTarget, item: GridItemData) { const compare = new Map(this.state.compare); - const otherTarget = compareTarget === CompareTarget.BASELINE ? CompareTarget.COMPARISON : CompareTarget.BASELINE; - - if (compare.get(otherTarget)?.value === item.value) { - compare.delete(otherTarget); + if (compare.get(compareTarget)?.value === item.value) { + compare.delete(compareTarget); + } else { + compare.set(compareTarget, item); } - compare.set(compareTarget, item); - this.setState({ compare }); this.updateStatsPanels(); @@ -319,7 +315,7 @@ export class SceneGroupByLabels extends SceneObjectBase // TODO: optimize if needed // we can remove the loop if we clear the current selection in the UI before updating the compare map (see selectForCompare() and onClickClearCompareButton()) for (const panel of statsPanels) { - panel.setCompareTargetValue(baselineItem, comparisonItem); + panel.updateCompareActions(baselineItem, comparisonItem); } } @@ -331,58 +327,29 @@ export class SceneGroupByLabels extends SceneObjectBase this.setState({ compare: new Map() }); } - buildDiffUrl(): string { + updateCompareFilters() { const { compare } = this.state; const baselineItem = compare.get(CompareTarget.BASELINE); const comparisonItem = compare.get(CompareTarget.COMPARISON); - let { appUrl } = config; - if (appUrl.at(-1) !== '/') { - // ensures that the API pathname is appended correctly (appUrl seems to always have it but better to be extra careful) - appUrl += '/'; - } - - const diffUrl = new URL('a/grafana-pyroscope-app/comparison-diff', appUrl); - - // data source - diffUrl.searchParams.set('var-dataSource', getSceneVariableValue(this, 'dataSource')); - - // time range - const { from, to } = computeRoundedTimeRange(sceneGraph.getTimeRange(this).state.value); - diffUrl.searchParams.set('from', from.toString()); - diffUrl.searchParams.set('to', to.toString()); - const baselineQueryRunnerParams = interpolateQueryRunnerVariables(this, baselineItem as GridItemData); const comparisonQueryRunnerParams = interpolateQueryRunnerVariables(this, comparisonItem as GridItemData); - // // query - just in case - const query = buildQuery({ - serviceId: baselineQueryRunnerParams.serviceName, - profileMetricId: baselineQueryRunnerParams.profileMetricId, - labels: baselineQueryRunnerParams.filters.map(({ key, operator, value }) => `${key}${operator}"${value}"`), + sceneGraph.findByKeyAndType(this, 'filtersBaseline', FiltersVariable).setState({ + filters: baselineQueryRunnerParams.filters, }); - diffUrl.searchParams.set('query', query); - - // left & right queries - const [leftQuery, rightQuery] = [baselineQueryRunnerParams, comparisonQueryRunnerParams].map( - ({ serviceName: serviceId, profileMetricId, filters }) => - buildQuery({ - serviceId, - profileMetricId, - labels: filters.map(({ key, operator, value }) => `${key}${operator}"${value}"`), - }) - ); - diffUrl.searchParams.set('leftQuery', leftQuery); - diffUrl.searchParams.set('rightQuery', rightQuery); - - return diffUrl.toString(); + sceneGraph.findByKeyAndType(this, 'filtersComparison', FiltersVariable).setState({ + filters: comparisonQueryRunnerParams.filters, + }); } onClickCompareButton = () => { - window.open(this.buildDiffUrl(), '_blank'); - reportInteraction('g_pyroscope_app_compare_link_clicked'); + + this.updateCompareFilters(); + + this.publishEvent(new EventViewDiffFlameGraph({}), true); }; onClickClearCompareButton = () => { diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/SceneLabelValuesGrid.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/SceneLabelValuesGrid.tsx index 8d2f7104..29401065 100644 --- a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/SceneLabelValuesGrid.tsx +++ b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/SceneLabelValuesGrid.tsx @@ -15,7 +15,7 @@ import { Spinner } from '@grafana/ui'; import { debounce, isEqual } from 'lodash'; import React from 'react'; -import { EventDataReceived } from '../../../../../../domain/events/EventDataReceived'; +import { EventTimeseriesDataReceived } from '../../../../../../domain/events/EventTimeseriesDataReceived'; import { FiltersVariable } from '../../../../../../domain/variables/FiltersVariable/FiltersVariable'; import { GroupByVariable } from '../../../../../../domain/variables/GroupByVariable/GroupByVariable'; import { getSceneVariableValue } from '../../../../../../helpers/getSceneVariableValue'; @@ -338,8 +338,8 @@ export class SceneLabelValuesGrid extends SceneObjectBase { - if (!this.state.hideNoData || event.payload.series.length) { + const sub = vizPanel.subscribeToEvent(EventTimeseriesDataReceived, (event) => { + if (!this.state.hideNoData || event.payload.series?.length) { return; } @@ -418,6 +418,12 @@ export class SceneLabelValuesGrid extends SceneObjectBase) { const { body, isLoading } = model.useState(); - return isLoading ? : ; + return isLoading ? ( + + ) : ( +
+ +
+ ); } } diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneLabelValuePanel.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneLabelValuePanel.tsx index a3e80d45..a2344f01 100644 --- a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneLabelValuePanel.tsx +++ b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneLabelValuePanel.tsx @@ -4,7 +4,7 @@ import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanelState } import { useStyles2 } from '@grafana/ui'; import React from 'react'; -import { EventDataReceived } from '../../../../../../../domain/events/EventDataReceived'; +import { EventTimeseriesDataReceived } from '../../../../../../../domain/events/EventTimeseriesDataReceived'; import { getSeriesStatsValue } from '../../../../../../../infrastructure/helpers/getSeriesStatsValue'; import { GridItemData } from '../../../../../../SceneByVariableRepeaterGrid/types/GridItemData'; import { SceneLabelValuesTimeseries } from '../../../../../../SceneLabelValuesTimeseries'; @@ -40,8 +40,8 @@ export class SceneLabelValuePanel extends SceneObjectBase { - const [s] = event.payload.series; + const timeseriesSub = timeseriesPanel.subscribeToEvent(EventTimeseriesDataReceived, (event) => { + const s = event.payload.series?.[0]; if (!s) { statsPanel.updateStats({ allValuesSum: 0, unit: 'short' }); @@ -66,10 +66,11 @@ export class SceneLabelValuePanel extends SceneObjectBase) { const styles = useStyles2(getStyles); // eslint-disable-line react-hooks/rules-of-hooks const { statsPanel, timeseriesPanel } = model.useState(); - const { compareTargetValue } = statsPanel.useState(); + const { compareActionChecks } = statsPanel.useState(); + const isSelected = compareActionChecks[0] || compareActionChecks[1]; return ( -
+
diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneStatsPanel/SceneStatsPanel.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneStatsPanel/SceneStatsPanel.tsx index 3929a02a..26215ed3 100644 --- a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneStatsPanel/SceneStatsPanel.tsx +++ b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneStatsPanel/SceneStatsPanel.tsx @@ -15,17 +15,17 @@ export type ItemStats = { interface SceneStatsPanelState extends SceneObjectState { item: GridItemData; itemStats?: ItemStats; - compareTargetValue?: CompareTarget; + compareActionChecks: boolean[]; } export class SceneStatsPanel extends SceneObjectBase { - static WIDTH_IN_PIXELS = 180; + static WIDTH_IN_PIXELS = 186; constructor({ item }: { item: GridItemData }) { super({ item, itemStats: undefined, - compareTargetValue: undefined, + compareActionChecks: [false, false], }); this.addActivationHandler(this.onActivate.bind(this)); @@ -34,20 +34,15 @@ export class SceneStatsPanel extends SceneObjectBase { onActivate() { const compare = sceneGraph.findByKeyAndType(this, 'group-by-labels', SceneGroupByLabels).getCompare(); - this.setCompareTargetValue(compare.get(CompareTarget.BASELINE), compare.get(CompareTarget.COMPARISON)); + this.updateCompareActions(compare.get(CompareTarget.BASELINE), compare.get(CompareTarget.COMPARISON)); } - setCompareTargetValue(baselineItem?: GridItemData, comparisonItem?: GridItemData) { + updateCompareActions(baselineItem?: GridItemData, comparisonItem?: GridItemData) { const { item } = this.state; - let compareTargetValue; - if (baselineItem?.value === item.value) { - compareTargetValue = CompareTarget.BASELINE; - } else if (comparisonItem?.value === item.value) { - compareTargetValue = CompareTarget.COMPARISON; - } - - this.setState({ compareTargetValue }); + this.setState({ + compareActionChecks: [baselineItem?.value === item.value, comparisonItem?.value === item.value], + }); } onChangeCompareTarget = (compareTarget: CompareTarget) => { @@ -69,13 +64,13 @@ export class SceneStatsPanel extends SceneObjectBase { } static Component({ model }: SceneComponentProps) { - const { item, itemStats, compareTargetValue } = model.useState(); + const { item, itemStats, compareActionChecks } = model.useState(); return ( ); diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneStatsPanel/ui/CompareAction.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneStatsPanel/ui/CompareAction.tsx new file mode 100644 index 00000000..98b5ccc9 --- /dev/null +++ b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneStatsPanel/ui/CompareAction.tsx @@ -0,0 +1,93 @@ +import { css, cx } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Checkbox, Tooltip, useStyles2 } from '@grafana/ui'; +import React, { useEffect, useRef, useState } from 'react'; + +import { CompareTarget } from '../../../domain/types'; + +type CompareActionProps = { + option: { + label: string; + value: CompareTarget; + description: string; + }; + checked: boolean; + onChange: (compareTarget: CompareTarget) => void; +}; + +export function CompareAction({ option, checked, onChange }: CompareActionProps) { + const styles = useStyles2(getStyles); + + const [showTooltip, setShowTooltip] = useState(false); + const checkboxRef = useRef(null); + const label = (checkboxRef.current as HTMLInputElement)?.closest('label'); + + // we write custom code to provide the tooltips because wrapping our checkbox into the component does not work + useEffect(() => { + if (!label || checked) { + setShowTooltip(false); + return; + } + + const onMouseEnter = () => { + setShowTooltip(true); + }; + + const onMouseLeave = () => { + setShowTooltip(false); + }; + + label.addEventListener('mouseenter', onMouseEnter); + label.addEventListener('mouseleave', onMouseLeave); + + return () => { + label.removeEventListener('mouseleave', onMouseLeave); + label.removeEventListener('mouseenter', onMouseEnter); + }; + }, [checked, label]); + + return ( + <> + + + + onChange(option.value)} + /> + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + tooltipAnchor: css` + position: relative; + left: 42px; + `, + checkbox: css` + column-gap: 4px; + + &:last-child { + & :nth-child(1) { + grid-column-start: 2; + } + & :nth-child(2) { + grid-column-start: 1; + } + } + + span { + color: ${theme.colors.text.secondary}; + } + span:hover { + color: ${theme.colors.text.primary}; + } + + &.checked span { + color: ${theme.colors.text.primary}; + } + `, +}); diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneStatsPanel/ui/StatsPanel.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneStatsPanel/ui/StatsPanel.tsx index 3ce74ff2..f0393ad6 100644 --- a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneStatsPanel/ui/StatsPanel.tsx +++ b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/components/SceneLabelValuesGrid/components/SceneStatsPanel/ui/StatsPanel.tsx @@ -1,21 +1,22 @@ import { css } from '@emotion/css'; import { getValueFormat, GrafanaTheme2 } from '@grafana/data'; -import { RadioButtonGroup, Spinner, useStyles2 } from '@grafana/ui'; +import { Spinner, useStyles2 } from '@grafana/ui'; import React, { useMemo } from 'react'; import { GridItemData } from '../../../../../../../../../components/SceneByVariableRepeaterGrid/types/GridItemData'; import { getColorByIndex } from '../../../../../../../../../helpers/getColorByIndex'; import { CompareTarget } from '../../../domain/types'; import { ItemStats } from '../SceneStatsPanel'; +import { CompareAction } from './CompareAction'; export type StatsPanelProps = { item: GridItemData; itemStats?: ItemStats; - compareTargetValue?: CompareTarget; + compareActionChecks: boolean[]; onChangeCompareTarget: (compareTarget: CompareTarget) => void; }; -export function StatsPanel({ item, itemStats, compareTargetValue, onChangeCompareTarget }: StatsPanelProps) { +export function StatsPanel({ item, itemStats, compareActionChecks, onChangeCompareTarget }: StatsPanelProps) { const styles = useStyles2(getStyles); const { index, value } = item; @@ -38,17 +39,15 @@ export function StatsPanel({ item, itemStats, compareTargetValue, onChangeCompar { label: 'Baseline', value: CompareTarget.BASELINE, - description: - compareTargetValue !== CompareTarget.BASELINE ? `Click to select "${value}" as baseline for comparison` : '', + description: !compareActionChecks[0] ? `Click to select "${value}" as baseline for comparison` : '', }, { label: 'Comparison', value: CompareTarget.COMPARISON, - description: - compareTargetValue !== CompareTarget.COMPARISON ? `Click to select "${value}" as target for comparison` : '', + description: !compareActionChecks[1] ? `Click to select "${value}" as target for comparison` : '', }, ], - [compareTargetValue, value] + [compareActionChecks, value] ); return ( @@ -57,14 +56,15 @@ export function StatsPanel({ item, itemStats, compareTargetValue, onChangeCompar {total} -
- +
+ {options.map((option, i) => ( + + ))}
); @@ -88,21 +88,9 @@ const getStyles = (theme: GrafanaTheme2) => ({ text-align: center; margin-top: ${theme.spacing(5)}; `, - radioButtonsGroup: css` - width: 100%; - - & > * { - flex-grow: 1 !important; - } - - & :nth-child(1):checked + label { - color: #fff; - background-color: ${theme.colors.primary.main}; // TODO - } - - & :nth-child(2):checked + label { - color: #fff; - background-color: ${theme.colors.primary.main}; // TODO - } + compareActions: css` + display: flex; + justify-content: space-between; + font-size: 11px; `, }); diff --git a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/ui/CompareActions.tsx b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/ui/CompareActions.tsx index 3c6fda75..38608399 100644 --- a/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/ui/CompareActions.tsx +++ b/src/pages/ProfilesExplorerView/components/SceneExploreServiceLabels/components/SceneGroupByLabels/ui/CompareActions.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Button, useStyles2 } from '@grafana/ui'; import { noOp } from '@shared/domain/noOp'; -import React from 'react'; +import React, { useMemo } from 'react'; import { SceneStatsPanel } from '../components/SceneLabelValuesGrid/components/SceneStatsPanel/SceneStatsPanel'; import { CompareTarget } from '../components/SceneLabelValuesGrid/domain/types'; @@ -19,6 +19,21 @@ export function CompareActions({ compare, onClickCompare, onClickClear }: Compar const compareIsDisabled = compare.size < 2; const hasSelection = compare.size > 0; + const tooltip = useMemo(() => { + if (compare.size === 2) { + return `Compare "${compare.get(CompareTarget.BASELINE)?.label}" vs "${ + compare.get(CompareTarget.COMPARISON)?.label + }"`; + } + if (compare.size === 0) { + return 'Select both a baseline and a comparison panel to compare their flame graphs'; + } + + return compare.has(CompareTarget.BASELINE) + ? `Select another panel to compare against "${compare.get(CompareTarget.BASELINE)?.label}"` + : `Select another panel to compare against "${compare.get(CompareTarget.COMPARISON)?.label}"`; + }, [compare]); + return (
diff --git a/src/pages/ProfilesExplorerView/components/SceneLabelValuesBarGauge.tsx b/src/pages/ProfilesExplorerView/components/SceneLabelValuesBarGauge.tsx index 957d9e2c..2069bde5 100644 --- a/src/pages/ProfilesExplorerView/components/SceneLabelValuesBarGauge.tsx +++ b/src/pages/ProfilesExplorerView/components/SceneLabelValuesBarGauge.tsx @@ -11,7 +11,7 @@ import { import { BarGaugeDisplayMode, BarGaugeNamePlacement, BarGaugeSizing, BarGaugeValueMode } from '@grafana/schema'; import React from 'react'; -import { EventDataReceived } from '../domain/events/EventDataReceived'; +import { EventTimeseriesDataReceived } from '../domain/events/EventTimeseriesDataReceived'; import { getColorByIndex } from '../helpers/getColorByIndex'; import { getSeriesLabelFieldName } from '../infrastructure/helpers/getSeriesLabelFieldName'; import { getSeriesStatsValue } from '../infrastructure/helpers/getSeriesStatsValue'; @@ -51,16 +51,19 @@ export class SceneLabelValuesBarGauge extends SceneObjectBase { - if (state.data?.state !== LoadingState.Done) { + const sub = (body.state.$data as SceneDataTransformer)!.subscribeToState((newState) => { + if (newState.data?.state !== LoadingState.Done) { return; } - const { series } = state.data; + const { series } = newState.data; - this.publishEvent(new EventDataReceived({ series }), true); + if (series?.length) { + body.setState(this.getConfig(item, series)); + } - body.setState(this.getConfig(item, series)); + // we publish the event only after setting the new config so that the subscribers can modify it + this.publishEvent(new EventTimeseriesDataReceived({ series }), true); }); return () => { diff --git a/src/pages/ProfilesExplorerView/components/SceneLabelValuesTimeseries.tsx b/src/pages/ProfilesExplorerView/components/SceneLabelValuesTimeseries.tsx index 4543b7c0..b185d3ab 100644 --- a/src/pages/ProfilesExplorerView/components/SceneLabelValuesTimeseries.tsx +++ b/src/pages/ProfilesExplorerView/components/SceneLabelValuesTimeseries.tsx @@ -2,6 +2,7 @@ import { DataFrame, FieldMatcherID, getValueFormat, LoadingState } from '@grafan import { PanelBuilders, SceneComponentProps, + SceneDataProvider, SceneDataTransformer, SceneObjectBase, SceneObjectState, @@ -11,7 +12,7 @@ import { import { GraphGradientMode } from '@grafana/schema'; import React from 'react'; -import { EventDataReceived } from '../domain/events/EventDataReceived'; +import { EventTimeseriesDataReceived } from '../domain/events/EventTimeseriesDataReceived'; import { getColorByIndex } from '../helpers/getColorByIndex'; import { getSeriesLabelFieldName } from '../infrastructure/helpers/getSeriesLabelFieldName'; import { getSeriesStatsValue } from '../infrastructure/helpers/getSeriesStatsValue'; @@ -30,6 +31,7 @@ interface SceneLabelValuesTimeseriesState extends SceneObjectState { headerActions: (item: GridItemData) => VizPanelState['headerActions']; displayAllValues: boolean; body: VizPanel; + overrides?: (series: DataFrame[]) => VizPanelState['fieldConfig']['overrides']; } export class SceneLabelValuesTimeseries extends SceneObjectBase { @@ -37,25 +39,31 @@ export class SceneLabelValuesTimeseries extends SceneObjectBase { - if (state.data?.state !== LoadingState.Done) { + const sub = (body.state.$data as SceneDataProvider).subscribeToState((newState) => { + if (newState.data?.state !== LoadingState.Done) { return; } - const { series } = state.data; + const { series } = newState.data; - this.publishEvent(new EventDataReceived({ series }), true); - - if (!series.length) { - return; + if (series?.length) { + const config = this.state.displayAllValues ? this.getAllValuesConfig(series) : this.getConfig(series); + body.setState(config); } - const config = this.state.displayAllValues ? this.getAllValuesConfig(series) : this.getConfig(series); - - body.setState(config); + // we publish the event only after setting the new config so that the subscribers can modify it + // (e.g. sync y-axis in SceneExploreDiffFlameGraphs.tsx) + this.publishEvent(new EventTimeseriesDataReceived({ series }), true); }); return () => { @@ -137,6 +144,10 @@ export class SceneLabelValuesTimeseries extends SceneObjectBase { + onReferencedVariableValueChanged: () => { this.state.body?.updateTitle(this.buildTitle()); }, }); diff --git a/src/pages/ProfilesExplorerView/components/SceneProfilesExplorer/SceneProfilesExplorer.tsx b/src/pages/ProfilesExplorerView/components/SceneProfilesExplorer/SceneProfilesExplorer.tsx index c3e8723c..6cbbf57c 100644 --- a/src/pages/ProfilesExplorerView/components/SceneProfilesExplorer/SceneProfilesExplorer.tsx +++ b/src/pages/ProfilesExplorerView/components/SceneProfilesExplorer/SceneProfilesExplorer.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { dateTimeParse, GrafanaTheme2 } from '@grafana/data'; +import { dateMath, GrafanaTheme2 } from '@grafana/data'; import { EmbeddedSceneState, getUrlSyncManager, @@ -27,9 +27,11 @@ import { SceneExploreAllServices } from '../../components/SceneExploreAllService import { SceneExploreFavorites } from '../../components/SceneExploreFavorites/SceneExploreFavorites'; import { SceneExploreServiceLabels } from '../../components/SceneExploreServiceLabels/SceneExploreServiceLabels'; import { SceneExploreServiceProfileTypes } from '../../components/SceneExploreServiceProfileTypes/SceneExploreServiceProfileTypes'; +import { EventViewDiffFlameGraph } from '../../domain/events/EventViewDiffFlameGraph'; import { EventViewServiceFlameGraph } from '../../domain/events/EventViewServiceFlameGraph'; import { EventViewServiceLabels } from '../../domain/events/EventViewServiceLabels'; import { EventViewServiceProfiles } from '../../domain/events/EventViewServiceProfiles'; +import { getDefaultTimeRange } from '../../domain/getDefaultTimeRange'; import { FiltersVariable } from '../../domain/variables/FiltersVariable/FiltersVariable'; import { GroupByVariable } from '../../domain/variables/GroupByVariable/GroupByVariable'; import { ProfileMetricVariable } from '../../domain/variables/ProfileMetricVariable'; @@ -43,10 +45,13 @@ import { SceneNoDataSwitcher } from '../SceneByVariableRepeaterGrid/components/S import { ScenePanelTypeSwitcher } from '../SceneByVariableRepeaterGrid/components/ScenePanelTypeSwitcher'; import { SceneQuickFilter } from '../SceneByVariableRepeaterGrid/components/SceneQuickFilter'; import { GridItemData } from '../SceneByVariableRepeaterGrid/types/GridItemData'; +import { SceneTimeRangeWithAnnotations } from '../SceneExploreDiffFlameGraph/components/SceneComparePanel/components/SceneTimeRangeWithAnnotations'; +import { SceneExploreDiffFlameGraph } from '../SceneExploreDiffFlameGraph/SceneExploreDiffFlameGraph'; import { SceneExploreServiceFlameGraph } from '../SceneExploreServiceFlameGraph/SceneExploreServiceFlameGraph'; import { ExplorationTypeSelector } from './ui/ExplorationTypeSelector'; export interface SceneProfilesExplorerState extends Partial { + $timeRange: SceneTimeRange; $variables: SceneVariableSet; gridControls: Array; explorationType?: ExplorationType; @@ -58,6 +63,7 @@ export enum ExplorationType { PROFILE_TYPES = 'profiles', LABELS = 'labels', FLAME_GRAPH = 'flame-graph', + DIFF_FLAME_GRAPH = 'diff-flame-graph', FAVORITES = 'favorites', } @@ -83,6 +89,11 @@ export class SceneProfilesExplorer extends SceneObjectBase { + this.setExplorationType({ + type: ExplorationType.DIFF_FLAME_GRAPH, + comesFromUserAction: true, + }); + }); + return { unsubscribe() { + diffFlameGraphSub.unsubscribe(); flameGraphSub.unsubscribe(); labelsSub.unsubscribe(); profilesSub.unsubscribe(); @@ -230,11 +251,46 @@ export class SceneProfilesExplorer extends SceneObjectBase { + sceneGraph.findByKeyAndType(this, filterKey, FiltersVariable).setState({ + filters: FiltersVariable.DEFAULT_VALUE, + }); + }); + + const { from, to, value } = this.state.$timeRange.state; + + ['baseline-panel-timerange', 'comparison-panel-timerange'].forEach((timeRangeKey) => { + sceneGraph.findByKeyAndType(this, timeRangeKey, SceneTimeRange).setState({ from, to, value }); + }); + + ['baseline-annotation-timerange', 'comparison-annotation-timerange'].forEach((timeRangeKey) => { + sceneGraph.findByKeyAndType(this, timeRangeKey, SceneTimeRangeWithAnnotations).nullifyAnnotationTimeRange(); + }); + } + } + + buildBodyScene(explorationType: ExplorationType, item?: GridItemData, comesFromUserAction?: boolean) { let primary; switch (explorationType) { @@ -250,6 +306,10 @@ export class SceneProfilesExplorer extends SceneObjectBase { try { const shareableUrl = new URL(window.location.toString()); + const { searchParams } = shareableUrl; - ['from', 'to'].forEach((name) => { - shareableUrl.searchParams.set(name, String(dateTimeParse(shareableUrl.searchParams.get(name)).valueOf())); - }); + searchParams.delete('query'); // TODO: temp while removing the comparison pages + + ['from', 'to', 'from-2', 'to-2', 'from-3', 'to-3', 'diffFrom', 'diffTo', 'diffFrom-2', 'diffTo-2'].forEach( + (name) => { + const value = searchParams.get(name); + if (value) { + searchParams.set(name, String(dateMath.parse(value)!.valueOf())); + } + } + ); await navigator.clipboard.writeText(shareableUrl.toString()); displaySuccess(['Link copied to clipboard!']); - } catch {} + } catch (error) { + console.error('Error while creating the shareable link!'); + console.error(error); + } }; useProfilesExplorer = () => { const { explorationType, controls, body, $variables } = this.useState(); - const [timePickerControl, refreshPickerControl] = controls as [SceneObject, SceneObject]; + const [timePickerControl, refreshPickerControl] = + explorationType === ExplorationType.DIFF_FLAME_GRAPH ? [] : (controls as [SceneObject, SceneObject]); + const dataSourceVariable = $variables.state.variables[0] as ProfilesDataSourceVariable; - const { variables: sceneVariables, gridControls } = (body?.state.primary as any).getVariablesAndGridControls() as { + const bodySceneObject = body?.state.primary as any; + + if (typeof bodySceneObject.getVariablesAndGridControls !== 'function') { + throw new Error( + `Error while rendering "${bodySceneObject.constructor.name}": the "getVariablesAndGridControls" method is missing! Please implement it.` + ); + } + + const { variables: sceneVariables, gridControls } = bodySceneObject.getVariablesAndGridControls() as { variables: SceneVariable[]; gridControls: SceneObject[]; }; @@ -361,8 +428,13 @@ export class SceneProfilesExplorer extends SceneObjectBase
- - + {timePickerControl && ( + + )} + {refreshPickerControl && ( + + )} + - {i < options.length - 2 && } + {i < options.length - 3 && } ); })} @@ -54,14 +54,18 @@ const getStyles = (theme: GrafanaTheme2) => ({ line-height: 32px; display: flex; align-items: center; - - & > button:last-child { - margin-left: ${theme.spacing(2)}; - } `, button: css` height: 30px; line-height: 30px; + + &:nth-last-child(2) { + margin-left: ${theme.spacing(1)}; + } + + &:nth-last-child(1) { + margin-left: ${theme.spacing(2)}; + } `, active: css` &:hover { diff --git a/src/pages/ProfilesExplorerView/domain/events/EventDataReceived.ts b/src/pages/ProfilesExplorerView/domain/events/EventDataReceived.ts deleted file mode 100644 index 782ab841..00000000 --- a/src/pages/ProfilesExplorerView/domain/events/EventDataReceived.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BusEventWithPayload, DataFrame } from '@grafana/data'; - -export interface EventDataReceivedPayload { - series: DataFrame[]; -} - -export class EventDataReceived extends BusEventWithPayload { - public static type = 'data-received'; -} diff --git a/src/pages/ProfilesExplorerView/domain/events/EventTimeseriesDataReceived.ts b/src/pages/ProfilesExplorerView/domain/events/EventTimeseriesDataReceived.ts new file mode 100644 index 00000000..fa3e0528 --- /dev/null +++ b/src/pages/ProfilesExplorerView/domain/events/EventTimeseriesDataReceived.ts @@ -0,0 +1,9 @@ +import { BusEventWithPayload, DataFrame } from '@grafana/data'; + +export interface EventTimeseriesDataReceivedPayload { + series?: DataFrame[]; +} + +export class EventTimeseriesDataReceived extends BusEventWithPayload { + public static type = 'timeseries-data-received'; +} diff --git a/src/pages/ProfilesExplorerView/domain/events/EventViewDiffFlameGraph.tsx b/src/pages/ProfilesExplorerView/domain/events/EventViewDiffFlameGraph.tsx new file mode 100644 index 00000000..93b8d370 --- /dev/null +++ b/src/pages/ProfilesExplorerView/domain/events/EventViewDiffFlameGraph.tsx @@ -0,0 +1,7 @@ +import { BusEventWithPayload } from '@grafana/data'; + +export interface EventViewDiffFlameGraphPayload {} + +export class EventViewDiffFlameGraph extends BusEventWithPayload { + public static type = 'view-diff-flame-graph'; +} diff --git a/src/pages/ProfilesExplorerView/domain/getDefaultTimeRange.ts b/src/pages/ProfilesExplorerView/domain/getDefaultTimeRange.ts new file mode 100644 index 00000000..7367dc61 --- /dev/null +++ b/src/pages/ProfilesExplorerView/domain/getDefaultTimeRange.ts @@ -0,0 +1,16 @@ +import { dateTime } from '@grafana/data'; +import { SceneTimeRangeState } from '@grafana/scenes'; + +export function getDefaultTimeRange(): SceneTimeRangeState { + const now = dateTime(); + + return { + from: 'now-30m', + to: 'now', + value: { + from: dateTime(now).subtract(30, 'minutes'), + to: now, + raw: { from: 'now-30m', to: 'now' }, + }, + }; +} diff --git a/src/pages/ProfilesExplorerView/domain/useBuildPyroscopeQuery.ts b/src/pages/ProfilesExplorerView/domain/useBuildPyroscopeQuery.ts new file mode 100644 index 00000000..3683409f --- /dev/null +++ b/src/pages/ProfilesExplorerView/domain/useBuildPyroscopeQuery.ts @@ -0,0 +1,23 @@ +import { sceneGraph, SceneObject } from '@grafana/scenes'; +import { useMemo } from 'react'; + +import { FiltersVariable } from './variables/FiltersVariable/FiltersVariable'; +import { ProfileMetricVariable } from './variables/ProfileMetricVariable'; +import { ServiceNameVariable } from './variables/ServiceNameVariable'; + +export function useBuildPyroscopeQuery(sceneObject: SceneObject, filterKey: string) { + const { value: serviceName } = sceneGraph + .findByKeyAndType(sceneObject, 'serviceName', ServiceNameVariable) + .useState(); + + const { value: profileMetricId } = sceneGraph + .findByKeyAndType(sceneObject, 'profileMetricId', ProfileMetricVariable) + .useState(); + + const { filterExpression } = sceneGraph.findByKeyAndType(sceneObject, filterKey, FiltersVariable).useState(); + + return useMemo( + () => `${profileMetricId}{service_name="${serviceName}",${filterExpression}}`, + [filterExpression, profileMetricId, serviceName] + ); +} diff --git a/src/pages/ProfilesExplorerView/domain/variables/FiltersVariable/FiltersVariable.tsx b/src/pages/ProfilesExplorerView/domain/variables/FiltersVariable/FiltersVariable.tsx index 17de35f7..3d79e8de 100644 --- a/src/pages/ProfilesExplorerView/domain/variables/FiltersVariable/FiltersVariable.tsx +++ b/src/pages/ProfilesExplorerView/domain/variables/FiltersVariable/FiltersVariable.tsx @@ -4,83 +4,77 @@ import { useStyles2 } from '@grafana/ui'; import { CompleteFilters } from '@shared/components/QueryBuilder/domain/types'; import { QueryBuilder } from '@shared/components/QueryBuilder/QueryBuilder'; import { useQueryFromUrl } from '@shared/domain/url-params/useQueryFromUrl'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; -import { ProfileMetricVariable } from '../ProfileMetricVariable'; +import { useBuildPyroscopeQuery } from '../../useBuildPyroscopeQuery'; import { ProfilesDataSourceVariable } from '../ProfilesDataSourceVariable'; -import { ServiceNameVariable } from '../ServiceNameVariable'; -import { convertPyroscopeToVariableFilter, expressionBuilder } from './filters-ops'; +import { convertPyroscopeToVariableFilter } from './filters-ops'; export class FiltersVariable extends AdHocFiltersVariable { static DEFAULT_VALUE = []; - constructor() { + constructor({ key }: { key: string }) { super({ - key: 'filters', - name: 'filters', + key, + name: key, label: 'Filters', filters: FiltersVariable.DEFAULT_VALUE, + expressionBuilder: (filters) => + filters.map(({ key, operator, value }) => `${key}${operator}"${value}"`).join(','), }); - this.addActivationHandler(() => { - // VariableDependencyConfig does not work :man_shrug: (never called) - const dataSourceSub = sceneGraph - .findByKeyAndType(this, 'dataSource', ProfilesDataSourceVariable) - .subscribeToState(() => { - this.setState({ filters: [] }); - }); + this.addActivationHandler(this.onActivate.bind(this)); + } - return () => { - dataSourceSub.unsubscribe(); - }; - }); + onActivate() { + // VariableDependencyConfig does not work :man_shrug: (never called) + const dataSourceSub = sceneGraph + .findByKeyAndType(this, 'dataSource', ProfilesDataSourceVariable) + .subscribeToState(() => { + this.setState({ filters: [] }); + }); + + return () => { + dataSourceSub.unsubscribe(); + }; } - updateQuery = (query: string, filters: CompleteFilters) => { + onChangeQuery = (query: string, filters: CompleteFilters) => { this.setState({ filters: filters.map(convertPyroscopeToVariableFilter), }); }; - static Component = ({ model }: SceneComponentProps) => { + static Component = ({ model }: SceneComponentProps) => { const styles = useStyles2(getStyles); - const { filters } = model.useState(); + const { key } = model.useState(); const [, setQuery] = useQueryFromUrl(); - const { value: dataSourceUid } = sceneGraph - .findByKeyAndType(model, 'dataSource', ProfilesDataSourceVariable) - .useState(); + const query = useBuildPyroscopeQuery(model, key as string); - const { value: serviceName } = sceneGraph.findByKeyAndType(model, 'serviceName', ServiceNameVariable).useState(); + useEffect(() => { + if (typeof query === 'string') { + // Explain Flame Graph (AI button) depends on the query value so we have to sync it here + setQuery(query); + } + }, [query, setQuery]); - const { value: profileMetricId } = sceneGraph - .findByKeyAndType(model, 'profileMetricId', ProfileMetricVariable) + const { value: dataSourceUid } = sceneGraph + .findByKeyAndType(model, 'dataSource', ProfilesDataSourceVariable) .useState(); - const filterExpression = useMemo( - () => expressionBuilder(serviceName as string, profileMetricId as string, filters), - [filters, profileMetricId, serviceName] - ); - const { from, to } = sceneGraph.getTimeRange(model).state.value; - useEffect(() => { - if (typeof filterExpression === 'string') { - // Explain Flame Graph (AI button) depends on the query value so we have to sync it here - setQuery(filterExpression); - } - }, [filterExpression, setQuery]); - return ( ); }; diff --git a/src/pages/ProfilesExplorerView/domain/variables/FiltersVariable/filters-ops.tsx b/src/pages/ProfilesExplorerView/domain/variables/FiltersVariable/filters-ops.tsx index 2cba4c86..d07d5a87 100644 --- a/src/pages/ProfilesExplorerView/domain/variables/FiltersVariable/filters-ops.tsx +++ b/src/pages/ProfilesExplorerView/domain/variables/FiltersVariable/filters-ops.tsx @@ -22,17 +22,6 @@ export const addFilter = (model: FiltersVariable, filter: AdHocVariableFilter) = model.setState({ filters: [...model.state.filters, filter] }); }; -export const expressionBuilder = (serviceName: string, profileMetricId: string, filters: AdHocVariableFilter[]) => { - if (!serviceName || !profileMetricId) { - return ''; - } - - const completeFilters = [{ key: 'service_name', operator: '=', value: serviceName }, ...filters]; - const selector = completeFilters.map(({ key, operator, value }) => `${key}${operator}"${value}"`).join(','); - - return `${profileMetricId}{${selector}}`; -}; - export const parseVariableValue = (variableValue = '') => !variableValue ? [] diff --git a/src/pages/ProfilesExplorerView/domain/variables/ServiceNameVariable.tsx b/src/pages/ProfilesExplorerView/domain/variables/ServiceNameVariable.tsx index e9db73fd..f24c1216 100644 --- a/src/pages/ProfilesExplorerView/domain/variables/ServiceNameVariable.tsx +++ b/src/pages/ProfilesExplorerView/domain/variables/ServiceNameVariable.tsx @@ -73,10 +73,12 @@ export class ServiceNameVariable extends QueryVariable { this.changeValueTo(newValue); - // manually reset filters - the "Scenes way" would be to listen to the variable changes but it leads to errors - // see comments in src/pages/ProfilesExplorerView/variables/FiltersVariable/FiltersVariable.tsx - const filtersVariable = sceneGraph.findByKeyAndType(this, 'filters', FiltersVariable); - filtersVariable.setState({ filters: [] }); + // manually reset filters - we should listen to the variables changes but it leads to unwanted behaviour + // (filters set in the URL search parameters are resetted when the user lands on the page) + ['filters', 'filtersBaseline', 'filtersComparison'].forEach((filterKey) => { + const filtersVariable = sceneGraph.findByKeyAndType(this, filterKey, FiltersVariable); + filtersVariable.setState({ filters: [] }); + }); }; static Component = ({ model }: SceneComponentProps) => { diff --git a/src/plugin.json b/src/plugin.json index a5ea43e7..8da77aee 100644 --- a/src/plugin.json +++ b/src/plugin.json @@ -47,22 +47,6 @@ "addToNav": true, "defaultNav": false }, - { - "type": "page", - "name": "Comparison view", - "path": "/a/%PLUGIN_ID%/comparison", - "role": "Viewer", - "addToNav": true, - "defaultNav": false - }, - { - "type": "page", - "name": "Comparison diff view", - "path": "/a/%PLUGIN_ID%/comparison-diff", - "role": "Viewer", - "addToNav": true, - "defaultNav": false - }, { "type": "page", "name": "Ad hoc view", diff --git a/src/shared/ui/PageTitle.tsx b/src/shared/ui/PageTitle.tsx index e6f146a4..25af1628 100644 --- a/src/shared/ui/PageTitle.tsx +++ b/src/shared/ui/PageTitle.tsx @@ -3,7 +3,6 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Stack, useStyles2 } from '@grafana/ui'; import { QueryAnalysisResult } from '@shared/components/QueryAnalysisTooltip/domain/QueryAnalysis'; import { QueryAnalysisTooltip } from '@shared/components/QueryAnalysisTooltip/QueryAnalysisTooltip'; -import { useQueryFromUrl } from '@shared/domain/url-params/useQueryFromUrl'; import React, { memo, ReactNode } from 'react'; import { Helmet } from 'react-helmet'; @@ -16,8 +15,7 @@ type PageTitleProps = { function PageTitleComponent({ title, queryAnalysis }: PageTitleProps) { const styles = useStyles2(getStyles); - const [query] = useQueryFromUrl(); - const fullTitle = typeof title === 'string' ? `${title} | ${query} | Pyroscope` : '...'; + const fullTitle = typeof title === 'string' ? `${title} | Pyroscope` : 'Pyroscope'; return ( <>