mirror of
https://github.com/grafana/grafana.git
synced 2025-02-12 00:25:46 -06:00
333 lines
9.8 KiB
TypeScript
333 lines
9.8 KiB
TypeScript
import { css, cx } from '@emotion/css';
|
|
import React, { useMemo } from 'react';
|
|
|
|
import { GrafanaTheme2 } from '@grafana/data';
|
|
import {
|
|
SceneObjectState,
|
|
SceneObjectBase,
|
|
SceneComponentProps,
|
|
SceneVariableValueChangedEvent,
|
|
SceneObjectStateChangedEvent,
|
|
SceneTimeRange,
|
|
sceneUtils,
|
|
} from '@grafana/scenes';
|
|
import { useStyles2, Tooltip, Stack } from '@grafana/ui';
|
|
|
|
import { DataTrail, DataTrailState, getTopSceneFor } from './DataTrail';
|
|
import { reportExploreMetrics } from './interactions';
|
|
import { VAR_FILTERS } from './shared';
|
|
import { getTrailFor, isSceneTimeRangeState } from './utils';
|
|
|
|
export interface DataTrailsHistoryState extends SceneObjectState {
|
|
currentStep: number;
|
|
steps: DataTrailHistoryStep[];
|
|
}
|
|
|
|
export function isDataTrailsHistoryState(state: SceneObjectState): state is DataTrailsHistoryState {
|
|
return 'currentStep' in state && 'steps' in state;
|
|
}
|
|
|
|
export interface DataTrailHistoryStep {
|
|
description: string;
|
|
type: TrailStepType;
|
|
trailState: DataTrailState;
|
|
parentIndex: number;
|
|
}
|
|
|
|
export type TrailStepType = 'filters' | 'time' | 'metric' | 'start';
|
|
export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
|
|
public constructor(state: Partial<DataTrailsHistoryState>) {
|
|
super({ steps: state.steps ?? [], currentStep: state.currentStep ?? 0 });
|
|
|
|
this.addActivationHandler(this._onActivate.bind(this));
|
|
}
|
|
|
|
private stepTransitionInProgress = false;
|
|
|
|
public _onActivate() {
|
|
const trail = getTrailFor(this);
|
|
|
|
if (this.state.steps.length === 0) {
|
|
// We always want to ensure in initial 'start' step
|
|
this.addTrailStep(trail, 'start');
|
|
|
|
if (trail.state.metric) {
|
|
// But if our current trail has a metric, we want to remove it and the topScene,
|
|
// so that the "start" step always displays a metric select screen.
|
|
|
|
// So we remove the metric and update the topscene for the "start" step
|
|
const { metric, ...startState } = trail.state;
|
|
startState.topScene = getTopSceneFor(undefined);
|
|
this.state.steps[0].trailState = startState;
|
|
|
|
// But must add a secondary step to represent the selection of the metric
|
|
// for this restored trail state
|
|
this.addTrailStep(trail, 'metric');
|
|
}
|
|
}
|
|
|
|
trail.subscribeToState((newState, oldState) => {
|
|
if (newState.metric !== oldState.metric) {
|
|
if (this.state.steps.length === 1) {
|
|
// For the first step we want to update the starting state so that it contains data
|
|
this.state.steps[0].trailState = sceneUtils.cloneSceneObjectState(oldState, { history: this });
|
|
}
|
|
|
|
if (newState.metric || oldState.metric) {
|
|
this.addTrailStep(trail, 'metric');
|
|
}
|
|
}
|
|
});
|
|
|
|
trail.subscribeToEvent(SceneVariableValueChangedEvent, (evt) => {
|
|
if (evt.payload.state.name === VAR_FILTERS) {
|
|
this.addTrailStep(trail, 'filters');
|
|
}
|
|
});
|
|
|
|
trail.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => {
|
|
if (evt.payload.changedObject instanceof SceneTimeRange) {
|
|
const { prevState, newState } = evt.payload;
|
|
|
|
if (isSceneTimeRangeState(prevState) && isSceneTimeRangeState(newState)) {
|
|
if (prevState.from === newState.from && prevState.to === newState.to) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.addTrailStep(trail, 'time');
|
|
}
|
|
});
|
|
}
|
|
|
|
public addTrailStep(trail: DataTrail, type: TrailStepType) {
|
|
if (this.stepTransitionInProgress) {
|
|
// Do not add trail steps when step transition is in progress
|
|
return;
|
|
}
|
|
|
|
const stepIndex = this.state.steps.length;
|
|
const parentIndex = type === 'start' ? -1 : this.state.currentStep;
|
|
|
|
this.setState({
|
|
currentStep: stepIndex,
|
|
steps: [
|
|
...this.state.steps,
|
|
{
|
|
description: 'Test',
|
|
type,
|
|
trailState: sceneUtils.cloneSceneObjectState(trail.state, { history: this }),
|
|
parentIndex,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
public goBackToStep(stepIndex: number) {
|
|
if (stepIndex === this.state.currentStep) {
|
|
return;
|
|
}
|
|
|
|
const step = this.state.steps[stepIndex];
|
|
const type = step.type === 'metric' && step.trailState.metric === undefined ? 'metric-clear' : step.type;
|
|
|
|
reportExploreMetrics('history_step_clicked', { type, step: stepIndex, numberOfSteps: this.state.steps.length });
|
|
|
|
this.stepTransitionInProgress = true;
|
|
this.setState({ currentStep: stepIndex });
|
|
|
|
getTrailFor(this).restoreFromHistoryStep(step.trailState);
|
|
|
|
// The URL will update
|
|
this.stepTransitionInProgress = false;
|
|
}
|
|
|
|
renderStepTooltip(step: DataTrailHistoryStep) {
|
|
return (
|
|
<Stack direction="column">
|
|
<div>{step.type}</div>
|
|
{step.type === 'metric' && <div>{step.trailState.metric || 'Select new metric'}</div>}
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
public static Component = ({ model }: SceneComponentProps<DataTrailHistory>) => {
|
|
const { steps, currentStep } = model.useState();
|
|
const styles = useStyles2(getStyles);
|
|
|
|
const { ancestry, alternatePredecessorStyle } = useMemo(() => {
|
|
const ancestry = new Set<number>();
|
|
|
|
let cursor = currentStep;
|
|
while (cursor >= 0) {
|
|
const step = steps[cursor];
|
|
if (!step) {
|
|
break;
|
|
}
|
|
ancestry.add(cursor);
|
|
cursor = step.parentIndex;
|
|
}
|
|
|
|
const alternatePredecessorStyle = new Map<number, string>();
|
|
|
|
ancestry.forEach((index) => {
|
|
const parent = steps[index].parentIndex;
|
|
if (parent + 1 !== index) {
|
|
alternatePredecessorStyle.set(index, createAlternatePredecessorStyle(index, parent));
|
|
}
|
|
});
|
|
|
|
return { ancestry, alternatePredecessorStyle };
|
|
}, [currentStep, steps]);
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
<div className={styles.heading}>History</div>
|
|
{steps.map((step, index) => {
|
|
let stepType = step.type;
|
|
|
|
if (stepType === 'metric' && step.trailState.metric === undefined) {
|
|
// If we're resetting the metric, we want it to look like a start node
|
|
stepType = 'start';
|
|
}
|
|
|
|
return (
|
|
<Tooltip content={() => model.renderStepTooltip(step)} key={index}>
|
|
<button
|
|
className={cx(
|
|
// Base for all steps
|
|
styles.step,
|
|
// Specifics per step type
|
|
styles.stepTypes[stepType],
|
|
// To highlight selected step
|
|
model.state.currentStep === index ? styles.stepSelected : '',
|
|
// To alter the look of steps with distant non-directly preceding parent
|
|
alternatePredecessorStyle.get(index) ?? '',
|
|
// To remove direct link for steps that don't have a direct parent
|
|
index !== step.parentIndex + 1 ? styles.stepOmitsDirectLeftLink : '',
|
|
// To remove the direct parent link on the start node as well
|
|
index === 0 ? styles.stepOmitsDirectLeftLink : '',
|
|
// To darken steps that aren't the current step's ancesters
|
|
!ancestry.has(index) ? styles.stepIsNotAncestorOfCurrent : ''
|
|
)}
|
|
onClick={() => model.goBackToStep(index)}
|
|
></button>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
}
|
|
|
|
function getStyles(theme: GrafanaTheme2) {
|
|
const visTheme = theme.visualization;
|
|
|
|
return {
|
|
container: css({
|
|
display: 'flex',
|
|
gap: 10,
|
|
alignItems: 'center',
|
|
}),
|
|
heading: css({}),
|
|
step: css({
|
|
flexGrow: 0,
|
|
cursor: 'pointer',
|
|
border: 'none',
|
|
boxShadow: 'none',
|
|
padding: 0,
|
|
margin: 0,
|
|
width: 8,
|
|
height: 8,
|
|
opacity: 0.7,
|
|
borderRadius: theme.shape.radius.circle,
|
|
background: theme.colors.primary.main,
|
|
position: 'relative',
|
|
'&:hover': {
|
|
opacity: 1,
|
|
},
|
|
'&:hover:before': {
|
|
// We only want the node to hover, not its connection to its parent
|
|
opacity: 0.7,
|
|
},
|
|
'&:before': {
|
|
content: '""',
|
|
position: 'absolute',
|
|
width: 10,
|
|
height: 2,
|
|
left: -10,
|
|
top: 3,
|
|
background: theme.colors.primary.border,
|
|
pointerEvents: 'none',
|
|
},
|
|
}),
|
|
stepSelected: css({
|
|
'&:after': {
|
|
content: '""',
|
|
borderStyle: `solid`,
|
|
borderWidth: 2,
|
|
borderRadius: '50%',
|
|
position: 'absolute',
|
|
width: 16,
|
|
height: 16,
|
|
left: -4,
|
|
top: -4,
|
|
boxShadow: `0px 0px 0px 2px inset ${theme.colors.background.canvas}`,
|
|
},
|
|
}),
|
|
stepOmitsDirectLeftLink: css({
|
|
'&:before': {
|
|
background: 'none',
|
|
},
|
|
}),
|
|
stepIsNotAncestorOfCurrent: css({
|
|
opacity: 0.2,
|
|
'&:hover:before': {
|
|
opacity: 0.2,
|
|
},
|
|
}),
|
|
stepTypes: {
|
|
start: generateStepTypeStyle(visTheme.getColorByName('green')),
|
|
filters: generateStepTypeStyle(visTheme.getColorByName('purple')),
|
|
metric: generateStepTypeStyle(visTheme.getColorByName('orange')),
|
|
time: generateStepTypeStyle(theme.colors.primary.main),
|
|
},
|
|
};
|
|
}
|
|
|
|
function generateStepTypeStyle(color: string) {
|
|
return css({
|
|
background: color,
|
|
'&:before': {
|
|
background: color,
|
|
borderColor: color,
|
|
},
|
|
'&:after': {
|
|
borderColor: color,
|
|
},
|
|
});
|
|
}
|
|
|
|
function createAlternatePredecessorStyle(index: number, parent: number) {
|
|
const difference = index - parent;
|
|
|
|
const NODE_DISTANCE = 18;
|
|
const distanceToParent = difference * NODE_DISTANCE;
|
|
|
|
return css({
|
|
'&:before': {
|
|
content: '""',
|
|
width: distanceToParent + 2,
|
|
height: 10,
|
|
borderStyle: 'solid',
|
|
borderWidth: 2,
|
|
borderBottom: 'none',
|
|
borderTopLeftRadius: 8,
|
|
borderTopRightRadius: 8,
|
|
top: -10,
|
|
left: 3 - distanceToParent,
|
|
background: 'none',
|
|
},
|
|
});
|
|
}
|