grafana/public/app/features/trails/DataTrail.tsx
Darren Janeczek 34875344ed
datatrails: fix: clear undefined query params on history step change (#85607)
* fix: clear undefined query params on history step change

* Minor tweak

* fix: resolve CodeQL check: Client-side cross-site scripting

---------

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
2024-04-11 09:52:33 -04:00

288 lines
8.5 KiB
TypeScript

import { css } from '@emotion/css';
import React from 'react';
import { AdHocVariableFilter, GrafanaTheme2, VariableHide, urlUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import {
AdHocFiltersVariable,
DataSourceVariable,
getUrlSyncManager,
SceneComponentProps,
SceneControlsSpacer,
sceneGraph,
SceneObject,
SceneObjectBase,
SceneObjectState,
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
SceneRefreshPicker,
SceneTimePicker,
SceneTimeRange,
SceneVariable,
SceneVariableSet,
VariableDependencyConfig,
VariableValueSelectors,
} from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { DataTrailSettings } from './DataTrailSettings';
import { DataTrailHistory, DataTrailHistoryStep } from './DataTrailsHistory';
import { MetricScene } from './MetricScene';
import { MetricSelectScene } from './MetricSelectScene';
import { MetricsHeader } from './MetricsHeader';
import { getTrailStore } from './TrailStore/TrailStore';
import { MetricDatasourceHelper } from './helpers/MetricDatasourceHelper';
import { MetricSelectedEvent, trailDS, VAR_DATASOURCE, VAR_FILTERS } from './shared';
export interface DataTrailState extends SceneObjectState {
topScene?: SceneObject;
embedded?: boolean;
controls: SceneObject[];
history: DataTrailHistory;
settings: DataTrailSettings;
createdAt: number;
// just for for the starting data source
initialDS?: string;
initialFilters?: AdHocVariableFilter[];
// Synced with url
metric?: string;
}
export class DataTrail extends SceneObjectBase<DataTrailState> {
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['metric'] });
public constructor(state: Partial<DataTrailState>) {
super({
$timeRange: state.$timeRange ?? new SceneTimeRange({}),
$variables: state.$variables ?? getVariableSet(state.initialDS, state.metric, state.initialFilters),
controls: state.controls ?? [
new VariableValueSelectors({ layout: 'vertical' }),
new SceneControlsSpacer(),
new SceneTimePicker({}),
new SceneRefreshPicker({}),
],
history: state.history ?? new DataTrailHistory({}),
settings: state.settings ?? new DataTrailSettings({}),
createdAt: state.createdAt ?? new Date().getTime(),
...state,
});
this.addActivationHandler(this._onActivate.bind(this));
}
public _onActivate() {
if (!this.state.topScene) {
this.setState({ topScene: getTopSceneFor(this.state.metric) });
}
// Some scene elements publish this
this.subscribeToEvent(MetricSelectedEvent, this._handleMetricSelectedEvent.bind(this));
// Pay attention to changes in history (i.e., changing the step)
this.state.history.subscribeToState((newState, oldState) => {
const oldNumberOfSteps = oldState.steps.length;
const newNumberOfSteps = newState.steps.length;
const newStepWasAppended = newNumberOfSteps > oldNumberOfSteps;
if (newStepWasAppended) {
// A new step is a significant change. Update the URL to match the new state.
this.syncTrailToUrl();
// In order for the `useBookmarkState` to re-evaluate after a new step was made:
this.forceRender();
// Do nothing else because the step state is already up to date -- it created a new step!
return;
}
if (oldState.currentStep === newState.currentStep) {
// The same step was clicked on -- no need to change anything.
return;
}
// History changed because a different node was selected
const step = newState.steps[newState.currentStep];
if (!step) {
return;
}
this.goBackToStep(step);
});
return () => {
if (!this.state.embedded) {
getTrailStore().setRecentTrail(this);
}
};
}
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [VAR_DATASOURCE],
onReferencedVariableValueChanged: async (variable: SceneVariable) => {
const { name } = variable.state;
if (name === VAR_DATASOURCE) {
this.datasourceHelper.reset();
}
},
});
private datasourceHelper = new MetricDatasourceHelper(this);
public getMetricMetadata(metric?: string) {
return this.datasourceHelper.getMetricMetadata(metric);
}
public getCurrentMetricMetadata() {
return this.getMetricMetadata(this.state.metric);
}
private goBackToStep(step: DataTrailHistoryStep) {
if (!step.trailState.metric) {
step.trailState.metric = undefined;
}
this.setState(step.trailState);
this.syncTrailToUrl();
}
private syncTrailToUrl() {
if (this.state.embedded) {
// Embedded trails should not be altering the URL
return;
}
const urlState = getUrlSyncManager().getUrlState(this);
const fullUrl = urlUtil.renderUrl(locationService.getLocation().pathname, urlState);
locationService.replace(encodeURI(fullUrl));
}
private _handleMetricSelectedEvent(evt: MetricSelectedEvent) {
this.setState(this.getSceneUpdatesForNewMetricValue(evt.payload));
// Add metric to adhoc filters baseFilter
const filterVar = sceneGraph.lookupVariable(VAR_FILTERS, this);
if (filterVar instanceof AdHocFiltersVariable) {
filterVar.setState({
baseFilters: getBaseFiltersForMetric(evt.payload),
});
}
}
private getSceneUpdatesForNewMetricValue(metric: string | undefined) {
const stateUpdate: Partial<DataTrailState> = {};
stateUpdate.metric = metric;
stateUpdate.topScene = getTopSceneFor(metric);
return stateUpdate;
}
getUrlState() {
return { metric: this.state.metric };
}
updateFromUrl(values: SceneObjectUrlValues) {
const stateUpdate: Partial<DataTrailState> = {};
if (typeof values.metric === 'string') {
if (this.state.metric !== values.metric) {
Object.assign(stateUpdate, this.getSceneUpdatesForNewMetricValue(values.metric));
}
} else if (values.metric === null) {
stateUpdate.metric = undefined;
stateUpdate.topScene = new MetricSelectScene({});
}
this.setState(stateUpdate);
}
static Component = ({ model }: SceneComponentProps<DataTrail>) => {
const { controls, topScene, history, settings } = model.useState();
const styles = useStyles2(getStyles);
const showHeaderForFirstTimeUsers = getTrailStore().recent.length < 2;
return (
<div className={styles.container}>
{showHeaderForFirstTimeUsers && <MetricsHeader />}
<history.Component model={history} />
{controls && (
<div className={styles.controls}>
{controls.map((control) => (
<control.Component key={control.state.key} model={control} />
))}
<settings.Component model={settings} />
</div>
)}
<div className={styles.body}>{topScene && <topScene.Component model={topScene} />}</div>
</div>
);
};
}
export function getTopSceneFor(metric?: string) {
if (metric) {
return new MetricScene({ metric: metric });
} else {
return new MetricSelectScene({});
}
}
function getVariableSet(initialDS?: string, metric?: string, initialFilters?: AdHocVariableFilter[]) {
return new SceneVariableSet({
variables: [
new DataSourceVariable({
name: VAR_DATASOURCE,
label: 'Data source',
description: 'Only prometheus data sources are supported',
value: initialDS,
pluginId: 'prometheus',
}),
new AdHocFiltersVariable({
name: VAR_FILTERS,
addFilterButtonText: 'Add label',
datasource: trailDS,
hide: VariableHide.hideLabel,
layout: 'vertical',
filters: initialFilters ?? [],
baseFilters: getBaseFiltersForMetric(metric),
}),
],
});
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css({
flexGrow: 1,
display: 'flex',
gap: theme.spacing(1),
minHeight: '100%',
flexDirection: 'column',
}),
body: css({
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
}),
controls: css({
display: 'flex',
gap: theme.spacing(1),
padding: theme.spacing(1, 0),
alignItems: 'flex-end',
flexWrap: 'wrap',
position: 'sticky',
background: theme.isDark ? theme.colors.background.canvas : theme.colors.background.primary,
zIndex: theme.zIndex.navbarFixed,
top: 0,
}),
};
}
function getBaseFiltersForMetric(metric?: string): AdHocVariableFilter[] {
if (metric) {
return [{ key: '__name__', operator: '=', value: metric }];
}
return [];
}