Skip to content

Commit

Permalink
feat(CompareView): Implement new Comparison view with Scenes (#119)
Browse files Browse the repository at this point in the history
  • Loading branch information
grafakus authored Aug 23, 2024
1 parent 10c97dc commit 127d6c3
Show file tree
Hide file tree
Showing 38 changed files with 1,595 additions and 258 deletions.
1 change: 1 addition & 0 deletions e2e/tests/explore-profiles/explore-profiles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -315,8 +315,8 @@ export class SceneByVariableRepeaterGrid extends SceneObjectBase<SceneByVariable
}

setupHideNoData(vizPanel: SceneLabelValuesTimeseries | SceneLabelValuesBarGauge) {
const sub = vizPanel.subscribeToEvent(EventDataReceived, (event) => {
if (event.payload.series.length > 0) {
const sub = vizPanel.subscribeToEvent(EventTimeseriesDataReceived, (event) => {
if (event.payload.series?.length) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<SceneExploreDiffFlameGraphState> {
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<SceneExploreDiffFlameGraph>) {
const styles = useStyles2(getStyles); // eslint-disable-line react-hooks/rules-of-hooks

const { baselinePanel, comparisonPanel, body } = model.useState();

return (
<div className={styles.container}>
<div className={styles.columns}>
<baselinePanel.Component model={baselinePanel} />
<comparisonPanel.Component model={comparisonPanel} />
</div>

<body.Component model={body} />
</div>
);
}
}

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;
}
`,
});
Original file line number Diff line number Diff line change
@@ -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<SceneComparePanelState> {
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<SceneComparePanel>) => {
const styles = useStyles2(getStyles);
const { title, timeseriesPanel: timeseries, timePicker, refreshPicker, filterKey } = model.useState();

const filtersVariable = sceneGraph.findByKey(model, filterKey) as FiltersVariable;

return (
<div className={styles.panel}>
<div className={styles.panelHeader}>
<h6>{title}</h6>

<div className={styles.timePicker}>
<timePicker.Component model={timePicker} />
<refreshPicker.Component model={refreshPicker} />
</div>
</div>

<div className={styles.filter}>
<InlineLabel width="auto">{filtersVariable.state.label}</InlineLabel>
<filtersVariable.Component model={filtersVariable} />
</div>

<div className={styles.timeseries}>{timeseries && <timeseries.Component model={timeseries} />}</div>
</div>
);
};
}

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;
}
`,
});
Loading

1 comment on commit 127d6c3

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unit test coverage

Lines Statements Branches Functions
Coverage: 10%
11.12% (464/4170) 8.6% (134/1558) 8.32% (107/1285)

Please sign in to comment.