DataTrails: Auto query, explore and breakdown/drilldown prototype (#77019)

* First try

* Update

* app with drilldowns

* Progres

* Progress

* update

* Update

* update

* Update

* Update

* Progress

* Update

* Progress

* Update

* Progress

* logs url sync

* related metrics

* Progress

* progress

* Progress

* Update

* Update

* Update

* Update

* Update

* fix

* Update

* update

* Update

* update

* Update

* Update

* Update

* Update

* Update

* Update

* Update

* Update

* Update

* Update

* update

* Update

* Update

* Settings

* Update

* Tweaks

* update

* Improve auto queries

* Update

* Update

* Fixes

* Update

* Update

* Update

* fix

* Update

* Removing logs view, cleanup

* Update

* Update

* disabled not implemented buttons

* Update

* Feature toggle on dashboard menu

* remove unused prometheus change

* removed bit

* Fix failing test

* chore: added `/public/app/features/trails/` to CODEOWNERS

* go mod tidy

* go mod tidy

* fix: added missing arg

* Moved panel action

* Moved panel action

---------

Co-authored-by: André Pereira <adrapereira@gmail.com>
Co-authored-by: Darren Janeczek <darren.janeczek@grafana.com>
pull/78202/head
Torkel Ödegaard 2 years ago committed by GitHub
parent c06debe200
commit 1f1d348e17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .betterer.results
  2. 1
      .github/CODEOWNERS
  3. 2
      go.sum
  4. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  5. 3
      packages/grafana-ui/src/components/Drawer/Drawer.tsx
  6. 8
      pkg/services/featuremgmt/registry.go
  7. 1
      pkg/services/featuremgmt/toggles_gen.csv
  8. 4
      pkg/services/featuremgmt/toggles_gen.go
  9. 9
      pkg/services/navtree/navtreeimpl/navtree.go
  10. 9
      public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx
  11. 3
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts
  12. 56
      public/app/features/trails/AddToFiltersGraphAction.tsx
  13. 202
      public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.ts
  14. 95
      public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx
  15. 374
      public/app/features/trails/BreakdownScene.tsx
  16. 55
      public/app/features/trails/ByFrameRepeater.tsx
  17. 205
      public/app/features/trails/DataTrail.tsx
  18. 98
      public/app/features/trails/DataTrailCard.tsx
  19. 67
      public/app/features/trails/DataTrailDrawer.tsx
  20. 93
      public/app/features/trails/DataTrailSettings.tsx
  21. 106
      public/app/features/trails/DataTrailsApp.tsx
  22. 183
      public/app/features/trails/DataTrailsHistory.tsx
  23. 123
      public/app/features/trails/DataTrailsHome.tsx
  24. 11
      public/app/features/trails/DataTrailsPage.tsx
  25. 42
      public/app/features/trails/LayoutSwitcher.tsx
  26. 180
      public/app/features/trails/MetricScene.tsx
  27. 216
      public/app/features/trails/MetricSelectScene.tsx
  28. 25
      public/app/features/trails/SelectMetricAction.tsx
  29. 83
      public/app/features/trails/SelectMetricTrailView.tsx
  30. 38
      public/app/features/trails/dashboardIntegration.ts
  31. 46
      public/app/features/trails/shared.ts
  32. 48
      public/app/features/trails/utils.ts
  33. 8
      public/app/routes/routes.tsx

@ -5016,6 +5016,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "14"],
[0, 0, 0, "Do not use any type assertions.", "15"]
],
"public/app/features/trails/SelectMetricTrailView.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

@ -414,6 +414,7 @@ cypress.config.js @grafana/grafana-frontend-platform
/public/app/features/storage/ @grafana/grafana-app-platform-squad
/public/app/features/teams/ @grafana/identity-access-team
/public/app/features/templating/ @grafana/dashboards-squad
/public/app/features/trails/ @torkelo
/public/app/features/transformers/ @grafana/grafana-bi-squad
/public/app/features/users/ @grafana/identity-access-team
/public/app/features/variables/ @grafana/dashboards-squad

@ -1835,8 +1835,6 @@ github.com/grafana/grafana-google-sdk-go v0.1.0 h1:LKGY8z2DSxKjYfr2flZsWgTRTZ6HG
github.com/grafana/grafana-google-sdk-go v0.1.0/go.mod h1:Vo2TKWfDVmNTELBUM+3lkrZvFtBws0qSZdXhQxRdJrE=
github.com/grafana/grafana-plugin-sdk-go v0.94.0/go.mod h1:3VXz4nCv6wH5SfgB3mlW39s+c+LetqSCjFj7xxPC5+M=
github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk=
github.com/grafana/grafana-plugin-sdk-go v0.191.0 h1:HcpBsrySv7m8TOeeWyeeKfROVUEwSSKvlfiJTF15JZU=
github.com/grafana/grafana-plugin-sdk-go v0.191.0/go.mod h1:Sl9pQlI6djp/340+nY+mpOjQksENLGL40WSqxP/o21Y=
github.com/grafana/grafana-plugin-sdk-go v0.193.0 h1:vRL96urrUfb+XWd4G6/317wpJBWTvoR9+Lrb+yGXZho=
github.com/grafana/grafana-plugin-sdk-go v0.193.0/go.mod h1:6Igwuc+iWyYNWXhJhsWUQpPn2ugNIo6r36vtn7GyIiE=
github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482 h1:1YNoeIhii4UIIQpCPU+EXidnqf449d0C3ZntAEt4KSo=

@ -160,5 +160,6 @@ export interface FeatureToggles {
logsInfiniteScrolling?: boolean;
flameGraphItemCollapsing?: boolean;
alertingDetailsViewV2?: boolean;
datatrails?: boolean;
alertingSimplifiedRouting?: boolean;
}

@ -36,6 +36,7 @@ export interface Props {
* sm = width 25vw & min-width 384px
* md = width 50vw & min-width 568px
* lg = width 75vw & min-width 744px
* xl = width 85vw & min-width 744px
**/
size?: 'sm' | 'md' | 'lg';
/** Tabs */
@ -203,7 +204,7 @@ const getStyles = (theme: GrafanaTheme2) => {
lg: css({
'.rc-drawer-content-wrapper': {
label: 'drawer-lg',
width: '75vw',
width: '85vw',
minWidth: theme.spacing(93),
[theme.breakpoints.down('md')]: {

@ -1047,6 +1047,14 @@ var (
Owner: grafanaAlertingSquad,
HideFromDocs: true,
},
{
Name: "datatrails",
Description: "Enables the new core app datatrails",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDashboardsSquad,
HideFromDocs: true,
},
{
Name: "alertingSimplifiedRouting",
Description: "Enables the simplified routing for alerting",

@ -141,4 +141,5 @@ ssoSettingsApi,experimental,@grafana/identity-access-team,true,false,false,false
logsInfiniteScrolling,experimental,@grafana/observability-logs,false,false,false,true
flameGraphItemCollapsing,experimental,@grafana/observability-traces-and-profiling,false,false,false,true
alertingDetailsViewV2,experimental,@grafana/alerting-squad,false,false,false,true
datatrails,experimental,@grafana/dashboards-squad,false,false,false,true
alertingSimplifiedRouting,experimental,@grafana/alerting-squad,false,false,false,false

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
141 logsInfiniteScrolling experimental @grafana/observability-logs false false false true
142 flameGraphItemCollapsing experimental @grafana/observability-traces-and-profiling false false false true
143 alertingDetailsViewV2 experimental @grafana/alerting-squad false false false true
144 datatrails experimental @grafana/dashboards-squad false false false true
145 alertingSimplifiedRouting experimental @grafana/alerting-squad false false false false

@ -575,6 +575,10 @@ const (
// Enables the preview of the new alert details view
FlagAlertingDetailsViewV2 = "alertingDetailsViewV2"
// FlagDatatrails
// Enables the new core app datatrails
FlagDatatrails = "datatrails"
// FlagAlertingSimplifiedRouting
// Enables the simplified routing for alerting
FlagAlertingSimplifiedRouting = "alertingSimplifiedRouting"

@ -377,6 +377,15 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt
})
}
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagDatatrails) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Data trails",
Id: "data-trails",
Url: s.cfg.AppSubURL + "/data-trails",
Icon: "code-branch",
})
}
if hasAccess(ac.EvalPermission(dashboards.ActionDashboardsCreate)) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "New dashboard", Icon: "plus", Url: s.cfg.AppSubURL + "/dashboard/new", HideFromTabs: true, Id: "dashboards/new", IsCreateAction: true,

@ -1,10 +1,11 @@
import { InterpolateFunction, PanelMenuItem } from '@grafana/data';
import { locationService, reportInteraction } from '@grafana/runtime';
import { sceneGraph, VizPanel, VizPanelMenu } from '@grafana/scenes';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import { VizPanel, VizPanelMenu, sceneGraph } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { PanelModel } from 'app/features/dashboard/state';
import { InspectTab } from 'app/features/inspector/types';
import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { addDataTrailPanelAction } from 'app/features/trails/dashboardIntegration';
import { ShareModal } from '../sharing/ShareModal';
import { getDashboardUrl, getInspectUrl, getViewPanelUrl, tryGetExploreUrlForPanel } from '../utils/urlBuilders';
@ -61,6 +62,10 @@ export function panelMenuBehavior(menu: VizPanelMenu) {
},
shortcut: 'p s',
});
if (config.featureToggles.datatrails) {
addDataTrailPanelAction(dashboard, panel, items);
}
}
const exploreUrl = await tryGetExploreUrlForPanel(panel);

@ -136,6 +136,9 @@ jest.mock('@grafana/runtime', () => ({
},
config: {
panels: [],
featureToggles: {
dataTrails: false,
},
theme2: {
visualization: {
getColorByName: jest.fn().mockReturnValue('red'),

@ -0,0 +1,56 @@
import React from 'react';
import { DataFrame } from '@grafana/data';
import {
SceneObjectState,
SceneObjectBase,
SceneComponentProps,
sceneGraph,
AdHocFiltersVariable,
} from '@grafana/scenes';
import { Button } from '@grafana/ui';
import { getMetricSceneFor } from './utils';
export interface AddToFiltersGraphActionState extends SceneObjectState {
frame: DataFrame;
}
export class AddToFiltersGraphAction extends SceneObjectBase<AddToFiltersGraphActionState> {
public onClick = () => {
const variable = sceneGraph.lookupVariable('filters', this);
if (!(variable instanceof AdHocFiltersVariable)) {
return;
}
const labels = this.state.frame.fields[1]?.labels ?? {};
if (Object.keys(labels).length !== 1) {
return;
}
// close action view
const metricScene = getMetricSceneFor(this);
metricScene.setActionView(undefined);
const labelName = Object.keys(labels)[0];
variable.state.set.setState({
filters: [
...variable.state.set.state.filters,
{
key: labelName,
operator: '=',
value: labels[labelName],
},
],
});
};
public static Component = ({ model }: SceneComponentProps<AddToFiltersGraphAction>) => {
return (
<Button variant="primary" size="sm" fill="text" onClick={model.onClick}>
Add to filters
</Button>
);
};
}

@ -0,0 +1,202 @@
import { PanelBuilders, SceneQueryRunner, VizPanelBuilder } from '@grafana/scenes';
import { PromQuery } from 'app/plugins/datasource/prometheus/types';
import { HeatmapColorMode } from 'app/plugins/panel/heatmap/types';
import { KEY_SQR_METRIC_VIZ_QUERY, trailDS, VAR_FILTERS_EXPR, VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from '../shared';
export interface AutoQueryDef {
variant: string;
title: string;
unit: string;
queries: PromQuery[];
vizBuilder: (def: AutoQueryDef) => VizPanelBuilder<{}, {}>;
}
export interface AutoQueryInfo {
preview: AutoQueryDef;
main: AutoQueryDef;
variants: AutoQueryDef[];
breakdown: AutoQueryDef;
}
export function getAutoQueriesForMetric(metric: string): AutoQueryInfo {
let unit = 'short';
let agg = 'avg';
let rate = false;
let title = metric;
if (metric.endsWith('seconds_sum')) {
unit = 's';
agg = 'avg';
rate = true;
} else if (metric.endsWith('seconds')) {
unit = 's';
agg = 'avg';
rate = false;
} else if (metric.endsWith('bytes')) {
unit = 'bytes';
agg = 'avg';
rate = false;
} else if (metric.endsWith('seconds_count') || metric.endsWith('seconds_total')) {
agg = 'sum';
rate = true;
} else if (metric.endsWith('bucket')) {
return getQueriesForBucketMetric(metric);
} else if (metric.endsWith('count') || metric.endsWith('total')) {
agg = 'sum';
rate = true;
}
let query = `${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}`;
if (rate) {
query = `rate(${query}[$__rate_interval])`;
}
const main: AutoQueryDef = {
title: `${title}`,
variant: 'graph',
unit,
queries: [{ refId: 'A', expr: `${agg}(${query})` }],
vizBuilder: simpleGraphBuilder,
};
const breakdown: AutoQueryDef = {
title: `${title}`,
variant: 'graph',
unit,
queries: [
{
refId: 'A',
expr: `${agg}(${query}) by(${VAR_GROUP_BY_EXP})`,
legendFormat: `{{${VAR_GROUP_BY_EXP}}}`,
},
],
vizBuilder: simpleGraphBuilder,
};
return { preview: main, main: main, breakdown: breakdown, variants: [] };
}
function getQueriesForBucketMetric(metric: string): AutoQueryInfo {
let unit = 'short';
if (metric.endsWith('seconds_bucket')) {
unit = 's';
}
const p50: AutoQueryDef = {
title: metric,
variant: 'p50',
unit,
queries: [
{
refId: 'A',
expr: `histogram_quantile(0.50, sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`,
},
],
vizBuilder: simpleGraphBuilder,
};
const breakdown: AutoQueryDef = {
title: metric,
variant: 'p50',
unit,
queries: [
{
refId: 'A',
expr: `histogram_quantile(0.50, sum by(le, ${VAR_GROUP_BY_EXP}) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`,
},
],
vizBuilder: simpleGraphBuilder,
};
const percentiles: AutoQueryDef = {
title: metric,
variant: 'percentiles',
unit,
queries: [
{
refId: 'A',
expr: `histogram_quantile(0.99, sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`,
legendFormat: '99th Percentile',
},
{
refId: 'B',
expr: `histogram_quantile(0.90, sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`,
legendFormat: '90th Percentile',
},
{
refId: 'C',
expr: `histogram_quantile(0.50, sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`,
legendFormat: '50th Percentile',
},
],
vizBuilder: percentilesGraphBuilder,
};
const heatmap: AutoQueryDef = {
title: metric,
variant: 'heatmap',
unit,
queries: [
{
refId: 'A',
expr: `sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval]))`,
format: 'heatmap',
},
],
vizBuilder: heatmapGraphBuilder,
};
return { preview: p50, main: percentiles, variants: [percentiles, heatmap], breakdown: breakdown };
}
function simpleGraphBuilder(def: AutoQueryDef) {
return PanelBuilders.timeseries()
.setTitle(def.title)
.setData(
new SceneQueryRunner({
datasource: trailDS,
maxDataPoints: 200,
queries: def.queries,
})
)
.setUnit(def.unit)
.setOption('legend', { showLegend: false })
.setCustomFieldConfig('fillOpacity', 9);
}
function percentilesGraphBuilder(def: AutoQueryDef) {
return PanelBuilders.timeseries()
.setTitle(def.title)
.setData(
new SceneQueryRunner({
datasource: trailDS,
maxDataPoints: 200,
queries: def.queries,
})
)
.setUnit(def.unit)
.setCustomFieldConfig('fillOpacity', 9);
}
function heatmapGraphBuilder(def: AutoQueryDef) {
return PanelBuilders.heatmap()
.setTitle(def.title)
.setUnit(def.unit)
.setOption('calculate', false)
.setOption('color', {
mode: HeatmapColorMode.Scheme,
exponent: 0.5,
scheme: 'Spectral',
steps: 32,
reverse: false,
})
.setData(
new SceneQueryRunner({
key: KEY_SQR_METRIC_VIZ_QUERY,
datasource: trailDS,
queries: def.queries,
})
);
}

@ -0,0 +1,95 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneObjectState, SceneObjectBase, SceneComponentProps, VizPanel } from '@grafana/scenes';
import { Field, RadioButtonGroup, useStyles2, Stack } from '@grafana/ui';
import { getTrailSettings } from '../utils';
import { AutoQueryDef, AutoQueryInfo } from './AutoQueryEngine';
export interface AutoVizPanelState extends SceneObjectState {
panel?: VizPanel;
autoQuery: AutoQueryInfo;
queryDef?: AutoQueryDef;
}
export class AutoVizPanel extends SceneObjectBase<AutoVizPanelState> {
constructor(state: AutoVizPanelState) {
super(state);
if (!state.panel) {
this.setState({
panel: this.getVizPanelFor(state.autoQuery.main),
queryDef: state.autoQuery.main,
});
}
}
private getQuerySelector(def: AutoQueryDef) {
const variants = this.state.autoQuery.variants;
if (variants.length === 0) {
return;
}
const options = variants.map((q) => ({ label: q.variant, value: q.variant }));
return <RadioButtonGroup size="sm" options={options} value={def.variant} onChange={this.onChangeQuery} />;
}
public onChangeQuery = (variant: string) => {
const def = this.state.autoQuery.variants.find((q) => q.variant === variant)!;
this.setState({
panel: this.getVizPanelFor(def),
queryDef: def,
});
};
private getVizPanelFor(def: AutoQueryDef) {
return def.vizBuilder(def).setHeaderActions(this.getQuerySelector(def)).build();
}
public static Component = ({ model }: SceneComponentProps<AutoVizPanel>) => {
const { panel, queryDef } = model.useState();
const { showQuery } = getTrailSettings(model).useState();
const styles = useStyles2(getStyles);
if (!panel) {
return;
}
if (!showQuery) {
return <panel.Component model={panel} />;
}
return (
<div className={styles.wrapper}>
<Stack gap={2}>
<Field label="Query">
<div>{queryDef && queryDef.queries.map((query, index) => <div key={index}>{query.expr}</div>)}</div>
</Field>
</Stack>
<div className={styles.panel}>
<panel.Component model={panel} />
</div>
</div>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
}),
panel: css({
position: 'relative',
flexGrow: 1,
}),
};
}

@ -0,0 +1,374 @@
import { css } from '@emotion/css';
import React from 'react';
import { DataFrame, GrafanaTheme2, SelectableValue } from '@grafana/data';
import {
AdHocFiltersVariable,
PanelBuilders,
QueryVariable,
SceneComponentProps,
SceneCSSGridItem,
SceneCSSGridLayout,
SceneDataNode,
SceneFlexItem,
SceneFlexItemLike,
SceneFlexLayout,
sceneGraph,
SceneObject,
SceneObjectBase,
SceneObjectState,
SceneQueryRunner,
SceneVariableSet,
} from '@grafana/scenes';
import { Button, Field, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
import { AddToFiltersGraphAction } from './AddToFiltersGraphAction';
import { AutoQueryDef, getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine';
import { ByFrameRepeater } from './ByFrameRepeater';
import { LayoutSwitcher } from './LayoutSwitcher';
import { MetricScene } from './MetricScene';
import { trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from './shared';
import { getColorByIndex } from './utils';
export interface BreakdownSceneState extends SceneObjectState {
body?: SceneObject;
labels: Array<SelectableValue<string>>;
value?: string;
loading?: boolean;
}
/**
* Just a proof of concept example of a behavior
*/
export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> {
constructor(state: Partial<BreakdownSceneState>) {
super({
$variables: state.$variables ?? getVariableSet(),
labels: state.labels ?? [],
...state,
});
this.addActivationHandler(this._onActivate.bind(this));
}
private _query?: AutoQueryDef;
private _onActivate() {
const variable = this.getVariable();
variable.subscribeToState((newState, oldState) => {
if (
newState.options !== oldState.options ||
newState.value !== oldState.value ||
newState.loading !== oldState.loading
) {
this.updateBody(variable);
}
});
const metric = sceneGraph.getAncestor(this, MetricScene).state.metric;
this._query = getAutoQueriesForMetric(metric).breakdown;
this.updateBody(variable);
}
private getVariable(): QueryVariable {
const variable = sceneGraph.lookupVariable(VAR_GROUP_BY, this)!;
if (!(variable instanceof QueryVariable)) {
throw new Error('Group by variable not found');
}
return variable;
}
private updateBody(variable: QueryVariable) {
const options = this.getLabelOptions(variable);
const stateUpdate: Partial<BreakdownSceneState> = {
loading: variable.state.loading,
value: String(variable.state.value),
labels: options,
};
if (!this.state.body && !variable.state.loading) {
stateUpdate.body = variable.hasAllValue()
? buildAllLayout(options, this._query!)
: buildNormalLayout(this._query!);
}
this.setState(stateUpdate);
}
private getLabelOptions(variable: QueryVariable) {
const labelFilters = sceneGraph.lookupVariable(VAR_FILTERS, this);
const labelOptions: Array<SelectableValue<string>> = [];
if (!(labelFilters instanceof AdHocFiltersVariable)) {
return [];
}
const filters = labelFilters.state.set.state.filters;
for (const option of variable.getOptionsForSelect()) {
const filterExists = filters.find((f) => f.key === option.value);
if (!filterExists) {
labelOptions.push({ label: option.label, value: String(option.value) });
}
}
return labelOptions;
}
public onChange = (value: string) => {
const variable = this.getVariable();
if (value === ALL_VARIABLE_VALUE) {
this.setState({ body: buildAllLayout(this.getLabelOptions(variable), this._query!) });
} else if (variable.hasAllValue()) {
this.setState({ body: buildNormalLayout(this._query!) });
}
variable.changeValueTo(value);
};
public static Component = ({ model }: SceneComponentProps<BreakdownScene>) => {
const { labels, body, loading, value } = model.useState();
const styles = useStyles2(getStyles);
return (
<div className={styles.container}>
{loading && <div>Loading...</div>}
<div className={styles.controls}>
<Field label="By label">
<RadioButtonGroup options={labels} value={value} onChange={model.onChange} />
</Field>
{body instanceof LayoutSwitcher && (
<div className={styles.controlsRight}>
<body.Selector model={body} />
</div>
)}
</div>
<div className={styles.content}>{body && <body.Component model={body} />}</div>
</div>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css({
flexGrow: 1,
display: 'flex',
minHeight: '100%',
flexDirection: 'column',
}),
content: css({
flexGrow: 1,
display: 'flex',
paddingTop: theme.spacing(0),
}),
tabHeading: css({
paddingRight: theme.spacing(2),
fontWeight: theme.typography.fontWeightMedium,
}),
controls: css({
flexGrow: 0,
display: 'flex',
alignItems: 'top',
gap: theme.spacing(2),
}),
controlsRight: css({
flexGrow: 1,
display: 'flex',
justifyContent: 'flex-end',
}),
};
}
export function buildAllLayout(options: Array<SelectableValue<string>>, queryDef: AutoQueryDef) {
const children: SceneFlexItemLike[] = [];
for (const option of options) {
if (option.value === ALL_VARIABLE_VALUE) {
continue;
}
const expr = queryDef.queries[0].expr.replace(VAR_GROUP_BY_EXP, String(option.value));
children.push(
new SceneCSSGridItem({
body: PanelBuilders.timeseries()
.setTitle(option.label!)
.setUnit(queryDef.unit)
.setData(
new SceneQueryRunner({
maxDataPoints: 300,
datasource: trailDS,
queries: [
{
refId: 'A',
expr: expr,
legendFormat: `{{${option.label}}}`,
},
],
})
)
.setHeaderActions(new SelectLabelAction({ labelName: String(option.value) }))
.build(),
})
);
}
return new LayoutSwitcher({
options: [
{ value: 'grid', label: 'Grid' },
{ value: 'rows', label: 'Rows' },
],
active: 'grid',
layouts: [
new SceneCSSGridLayout({
templateColumns: GRID_TEMPLATE_COLUMNS,
autoRows: '200px',
children: children,
}),
new SceneCSSGridLayout({
templateColumns: '1fr',
autoRows: '200px',
children: children,
}),
],
});
}
const GRID_TEMPLATE_COLUMNS = 'repeat(auto-fit, minmax(400px, 1fr))';
function getVariableSet() {
return new SceneVariableSet({
variables: [
new QueryVariable({
name: VAR_GROUP_BY,
label: 'Group by',
datasource: trailDS,
includeAll: true,
defaultToAll: true,
query: { query: `label_names(${VAR_METRIC_EXPR})`, refId: 'A' },
value: '',
text: '',
}),
],
});
}
function buildNormalLayout(queryDef: AutoQueryDef) {
return new LayoutSwitcher({
$data: new SceneQueryRunner({
datasource: trailDS,
maxDataPoints: 300,
queries: queryDef.queries,
}),
options: [
{ value: 'single', label: 'Single' },
{ value: 'grid', label: 'Grid' },
{ value: 'rows', label: 'Rows' },
],
active: 'grid',
layouts: [
new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
minHeight: 300,
body: PanelBuilders.timeseries().setTitle('$metric').build(),
}),
],
}),
new ByFrameRepeater({
body: new SceneCSSGridLayout({
templateColumns: GRID_TEMPLATE_COLUMNS,
autoRows: '200px',
children: [],
}),
getLayoutChild: (data, frame, frameIndex) => {
return new SceneCSSGridItem({
body: queryDef
.vizBuilder(queryDef)
.setTitle(getLabelValue(frame))
.setData(new SceneDataNode({ data: { ...data, series: [frame] } }))
.setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) })
.setHeaderActions(new AddToFiltersGraphAction({ frame }))
.build(),
});
},
}),
new ByFrameRepeater({
body: new SceneCSSGridLayout({
templateColumns: '1fr',
autoRows: '200px',
children: [],
}),
getLayoutChild: (data, frame, frameIndex) => {
return new SceneCSSGridItem({
body: queryDef
.vizBuilder(queryDef)
.setTitle(getLabelValue(frame))
.setData(new SceneDataNode({ data: { ...data, series: [frame] } }))
.setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) })
.setHeaderActions(new AddToFiltersGraphAction({ frame }))
.build(),
});
},
}),
],
});
}
function getLabelValue(frame: DataFrame) {
const labels = frame.fields[1]?.labels;
if (!labels) {
return 'No labels';
}
const keys = Object.keys(labels);
if (keys.length === 0) {
return 'No labels';
}
return labels[keys[0]];
}
export function buildBreakdownActionScene() {
return new SceneFlexItem({
body: new BreakdownScene({}),
});
}
interface SelectLabelActionState extends SceneObjectState {
labelName: string;
}
export class SelectLabelAction extends SceneObjectBase<SelectLabelActionState> {
public onClick = () => {
getBreakdownSceneFor(this).onChange(this.state.labelName);
};
public static Component = ({ model }: SceneComponentProps<AddToFiltersGraphAction>) => {
return (
<Button variant="primary" size="sm" fill="text" onClick={model.onClick}>
Select
</Button>
);
};
}
function getBreakdownSceneFor(model: SceneObject): BreakdownScene {
if (model instanceof BreakdownScene) {
return model;
}
if (model.parent) {
return getBreakdownSceneFor(model.parent);
}
throw new Error('Unable to find breakdown scene');
}

@ -0,0 +1,55 @@
import React from 'react';
import { LoadingState, PanelData, DataFrame } from '@grafana/data';
import {
SceneObjectState,
SceneFlexItem,
SceneObjectBase,
sceneGraph,
SceneComponentProps,
SceneByFrameRepeater,
SceneLayout,
} from '@grafana/scenes';
interface ByFrameRepeaterState extends SceneObjectState {
body: SceneLayout;
getLayoutChild(data: PanelData, frame: DataFrame, frameIndex: number): SceneFlexItem;
}
export class ByFrameRepeater extends SceneObjectBase<ByFrameRepeaterState> {
public constructor(state: ByFrameRepeaterState) {
super(state);
this.addActivationHandler(() => {
const data = sceneGraph.getData(this);
this._subs.add(
data.subscribeToState((data) => {
if (data.data?.state === LoadingState.Done) {
this.performRepeat(data.data);
}
})
);
if (data.state.data) {
this.performRepeat(data.state.data);
}
});
}
private performRepeat(data: PanelData) {
const newChildren: SceneFlexItem[] = [];
for (let seriesIndex = 0; seriesIndex < data.series.length; seriesIndex++) {
const layoutChild = this.state.getLayoutChild(data, data.series[seriesIndex], seriesIndex);
newChildren.push(layoutChild);
}
this.state.body.setState({ children: newChildren });
}
public static Component = ({ model }: SceneComponentProps<SceneByFrameRepeater>) => {
const { body } = model.useState();
return <body.Component model={body} />;
};
}

@ -0,0 +1,205 @@
import { css } from '@emotion/css';
import React from 'react';
import { AdHocVariableFilter, GrafanaTheme2 } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import {
AdHocFiltersVariable,
DataSourceVariable,
getUrlSyncManager,
SceneComponentProps,
SceneControlsSpacer,
SceneObject,
SceneObjectBase,
SceneObjectState,
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
SceneRefreshPicker,
SceneTimePicker,
SceneTimeRange,
SceneVariableSet,
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 { MetricSelectedEvent, trailDS, LOGS_METRIC, VAR_DATASOURCE } from './shared';
import { getUrlForTrail } from './utils';
export interface DataTrailState extends SceneObjectState {
topScene?: SceneObject;
embedded?: boolean;
controls: SceneObject[];
history: DataTrailHistory;
settings: DataTrailSettings;
// 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({}),
...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));
return () => {
if (!this.state.embedded) {
getUrlSyncManager().cleanUp(this);
}
};
}
public goBackToStep(step: DataTrailHistoryStep) {
if (!this.state.embedded) {
getUrlSyncManager().cleanUp(this);
}
if (!step.trailState.metric) {
step.trailState.metric = undefined;
}
this.setState(step.trailState);
if (!this.state.embedded) {
locationService.replace(getUrlForTrail(this));
getUrlSyncManager().initSync(this);
}
}
private _handleMetricSelectedEvent(evt: MetricSelectedEvent) {
if (this.state.embedded) {
this.setState(this.getSceneUpdatesForNewMetricValue(evt.payload));
} else {
locationService.partial({ metric: evt.payload, actionView: null });
}
}
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({ showHeading: true });
}
this.setState(stateUpdate);
}
static Component = ({ model }: SceneComponentProps<DataTrail>) => {
const { controls, topScene, history, settings } = model.useState();
const styles = useStyles2(getStyles);
return (
<div className={styles.container}>
<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>
);
};
}
function getTopSceneFor(metric?: string) {
if (metric) {
return new MetricScene({ metric: metric });
} else {
return new MetricSelectScene({ showHeading: true });
}
}
function getVariableSet(initialDS?: string, metric?: string, initialFilters?: AdHocVariableFilter[]) {
return new SceneVariableSet({
variables: [
new DataSourceVariable({
name: VAR_DATASOURCE,
label: 'Data source',
value: initialDS,
pluginId: metric === LOGS_METRIC ? 'loki' : 'prometheus',
}),
AdHocFiltersVariable.create({
name: 'filters',
datasource: trailDS,
layout: 'vertical',
filters: initialFilters ?? [],
}),
],
});
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css({
flexGrow: 1,
display: 'flex',
gap: theme.spacing(2),
minHeight: '100%',
flexDirection: 'column',
}),
body: css({
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
controls: css({
display: 'flex',
gap: theme.spacing(2),
alignItems: 'flex-end',
flexWrap: 'wrap',
}),
};
}

@ -0,0 +1,98 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes';
import { useStyles2, Stack } from '@grafana/ui';
import { DataTrail } from './DataTrail';
import { LOGS_METRIC, VAR_DATASOURCE_EXPR, VAR_FILTERS } from './shared';
export interface Props {
trail: DataTrail;
onSelect: (trail: DataTrail) => void;
}
export function DataTrailCard({ trail, onSelect }: Props) {
const styles = useStyles2(getStyles);
const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, trail)!;
if (!(filtersVariable instanceof AdHocFiltersVariable)) {
return null;
}
const filters = filtersVariable.state.set.state.filters;
const dsValue = getDataSource(trail);
return (
<button className={styles.container} onClick={() => onSelect(trail)}>
<div className={styles.heading}>{getMetricName(trail.state.metric)}</div>
<Stack gap={1.5}>
{dsValue && (
<Stack direction="column" gap={0.5}>
<div className={styles.label}>Datasource</div>
<div className={styles.value}>{getDataSource(trail)}</div>
</Stack>
)}
{filters.map((filter, index) => (
<Stack key={index} direction="column" gap={0.5}>
<div className={styles.label}>{filter.key}</div>
<div className={styles.value}>{filter.value}</div>
</Stack>
))}
</Stack>
</button>
);
}
function getMetricName(metric?: string) {
if (!metric) {
return 'Select metric';
}
if (metric === LOGS_METRIC) {
return 'Logs';
}
return metric;
}
function getDataSource(trail: DataTrail) {
return sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR);
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css({
padding: theme.spacing(1),
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
cursor: 'pointer',
boxShadow: 'none',
background: 'transparent',
textAlign: 'left',
'&:hover': {
background: theme.colors.emphasize(theme.colors.background.primary, 0.03),
},
}),
label: css({
fontWeight: theme.typography.fontWeightMedium,
fontSize: theme.typography.bodySmall.fontSize,
}),
value: css({
fontSize: theme.typography.bodySmall.fontSize,
}),
heading: css({
padding: theme.spacing(0),
display: 'flex',
fontWeight: theme.typography.fontWeightMedium,
}),
body: css({
padding: theme.spacing(0),
}),
};
}

@ -0,0 +1,67 @@
import React from 'react';
import { getDataSourceSrv } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneTimeRangeLike } from '@grafana/scenes';
import { DataSourceRef } from '@grafana/schema';
import { Drawer } from '@grafana/ui';
import { PromVisualQuery } from 'app/plugins/datasource/prometheus/querybuilder/types';
import { getDashboardSceneFor } from '../dashboard-scene/utils/utils';
import { DataTrail } from './DataTrail';
import { getDataTrailsApp } from './DataTrailsApp';
import { OpenEmbeddedTrailEvent } from './shared';
interface DataTrailDrawerState extends SceneObjectState {
timeRange: SceneTimeRangeLike;
query: PromVisualQuery;
dsRef: DataSourceRef;
}
export class DataTrailDrawer extends SceneObjectBase<DataTrailDrawerState> {
static Component = DataTrailDrawerRenderer;
public trail: DataTrail;
constructor(state: DataTrailDrawerState) {
super(state);
this.trail = buildDataTrailFromQuery(state);
this.trail.addActivationHandler(() => {
this.trail.subscribeToEvent(OpenEmbeddedTrailEvent, this.onOpenTrail);
});
}
onOpenTrail = () => {
getDataTrailsApp().goToUrlForTrail(this.trail.clone({ embedded: false }));
};
onClose = () => {
const dashboard = getDashboardSceneFor(this);
dashboard.closeModal();
};
}
function DataTrailDrawerRenderer({ model }: SceneComponentProps<DataTrailDrawer>) {
return (
<Drawer title={'Data trail'} onClose={model.onClose} size="lg">
<div style={{ display: 'flex', height: '100%' }}>
<model.trail.Component model={model.trail} />
</div>
</Drawer>
);
}
export function buildDataTrailFromQuery({ query, dsRef, timeRange }: DataTrailDrawerState) {
const filters = query.labels.map((label) => ({ key: label.label, value: label.value, operator: label.op }));
const ds = getDataSourceSrv().getInstanceSettings(dsRef);
return new DataTrail({
$timeRange: timeRange,
metric: query.metric,
initialDS: ds?.name,
initialFilters: filters,
embedded: true,
});
}

@ -0,0 +1,93 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { Dropdown, Switch, ToolbarButton, useStyles2 } from '@grafana/ui';
export interface DataTrailSettingsState extends SceneObjectState {
showQuery?: boolean;
showAdvanced?: boolean;
multiValueVars?: boolean;
isOpen?: boolean;
}
export class DataTrailSettings extends SceneObjectBase<DataTrailSettingsState> {
constructor(state: Partial<DataTrailSettingsState>) {
super({
showQuery: state.showQuery ?? false,
showAdvanced: state.showAdvanced ?? false,
isOpen: state.isOpen ?? false,
});
}
public onToggleShowQuery = () => {
this.setState({ showQuery: !this.state.showQuery });
};
public onToggleAdvanced = () => {
this.setState({ showAdvanced: !this.state.showAdvanced });
};
public onToggleMultiValue = () => {
this.setState({ multiValueVars: !this.state.multiValueVars });
};
public onToggleOpen = (isOpen: boolean) => {
this.setState({ isOpen });
};
static Component = ({ model }: SceneComponentProps<DataTrailSettings>) => {
const { showQuery, showAdvanced, multiValueVars, isOpen } = model.useState();
const styles = useStyles2(getStyles);
const renderPopover = () => {
return (
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div className={styles.popover} onClick={(evt) => evt.stopPropagation()}>
<div className={styles.heading}>Settings</div>
<div className={styles.options}>
<div>Multi value variables</div>
<Switch value={multiValueVars} onChange={model.onToggleMultiValue} />
<div>Advanced options</div>
<Switch value={showAdvanced} onChange={model.onToggleAdvanced} />
<div>Show query</div>
<Switch value={showQuery} onChange={model.onToggleShowQuery} />
</div>
</div>
);
};
return (
<Dropdown overlay={renderPopover} placement="bottom" onVisibleChange={model.onToggleOpen}>
<ToolbarButton icon="cog" variant="canvas" isOpen={isOpen} />
</Dropdown>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
popover: css({
display: 'flex',
padding: theme.spacing(2),
flexDirection: 'column',
background: theme.colors.background.primary,
boxShadow: theme.shadows.z3,
borderRadius: theme.shape.borderRadius(),
border: `1px solid ${theme.colors.border.weak}`,
zIndex: 1,
marginRight: theme.spacing(2),
}),
heading: css({
fontWeight: theme.typography.fontWeightMedium,
paddingBottom: theme.spacing(2),
}),
options: css({
display: 'grid',
gridTemplateColumns: '1fr 50px',
rowGap: theme.spacing(1),
columnGap: theme.spacing(2),
}),
};
}

@ -0,0 +1,106 @@
import { css } from '@emotion/css';
import React, { useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { getUrlSyncManager, SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { DataTrail } from './DataTrail';
import { DataTrailsHome } from './DataTrailsHome';
import { getUrlForTrail, newMetricsTrail } from './utils';
export interface DataTrailsAppState extends SceneObjectState {
trail: DataTrail;
home: DataTrailsHome;
}
export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> {
public constructor(state: DataTrailsAppState) {
super(state);
}
goToUrlForTrail(trail: DataTrail) {
this.setState({ trail });
locationService.push(getUrlForTrail(trail));
}
static Component = ({ model }: SceneComponentProps<DataTrailsApp>) => {
const { trail, home } = model.useState();
const styles = useStyles2(getStyles);
return (
<Switch>
<Route
exact={true}
path="/data-trails"
render={() => (
<Page navId="data-trails" layout={PageLayoutType.Custom}>
<div className={styles.customPage}>
<home.Component model={home} />
</div>
</Page>
)}
/>
<Route
exact={true}
path="/data-trails/trail"
render={() => (
<Page navId="data-trails" pageNav={{ text: 'Trail' }} layout={PageLayoutType.Custom}>
<div className={styles.customPage}>
<DataTrailView trail={trail} />
</div>
</Page>
)}
/>
</Switch>
);
};
}
function DataTrailView({ trail }: { trail: DataTrail }) {
const [isInitialized, setIsInitialized] = React.useState(false);
useEffect(() => {
if (!isInitialized) {
getUrlSyncManager().initSync(trail);
setIsInitialized(true);
}
}, [trail, isInitialized]);
if (!isInitialized) {
return null;
}
return <trail.Component model={trail} />;
}
let dataTrailsApp: DataTrailsApp;
export function getDataTrailsApp() {
if (!dataTrailsApp) {
dataTrailsApp = new DataTrailsApp({
trail: newMetricsTrail(),
home: new DataTrailsHome({
recent: [],
bookmarks: [],
}),
});
}
return dataTrailsApp;
}
function getStyles(theme: GrafanaTheme2) {
return {
customPage: css({
padding: theme.spacing(2, 3, 2, 3),
background: theme.isLight ? theme.colors.background.primary : theme.colors.background.canvas,
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
}),
};
}

@ -0,0 +1,183 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import {
SceneObjectState,
SceneObjectBase,
SceneComponentProps,
sceneUtils,
SceneVariableValueChangedEvent,
SceneObjectStateChangedEvent,
SceneTimeRange,
} from '@grafana/scenes';
import { useStyles2, Tooltip, Stack } from '@grafana/ui';
import { DataTrail, DataTrailState } from './DataTrail';
import { VAR_FILTERS } from './shared';
import { getTrailFor } from './utils';
export interface DataTrailsHistoryState extends SceneObjectState {
steps: DataTrailHistoryStep[];
}
export interface DataTrailHistoryStep {
description: string;
type: TrailStepType;
trailState: DataTrailState;
}
export type TrailStepType = 'filters' | 'time' | 'metric' | 'start';
export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> {
public constructor(state: Partial<DataTrailsHistoryState>) {
super({ steps: state.steps ?? [] });
this.addActivationHandler(this._onActivate.bind(this));
}
public _onActivate() {
const trail = getTrailFor(this);
if (this.state.steps.length === 0) {
this.addTrailStep(trail, 'start');
}
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) {
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) {
this.addTrailStep(trail, 'time');
}
});
}
public addTrailStep(trail: DataTrail, type: TrailStepType) {
this.setState({
steps: [
...this.state.steps,
{
description: 'Test',
type,
trailState: sceneUtils.cloneSceneObjectState(trail.state, { history: this }),
},
],
});
}
renderStepTooltip(step: DataTrailHistoryStep) {
return (
<Stack direction="column">
<div>{step.type}</div>
{step.type === 'metric' && <div>{step.trailState.metric}</div>}
</Stack>
);
}
public static Component = ({ model }: SceneComponentProps<DataTrailHistory>) => {
const { steps } = model.useState();
const styles = useStyles2(getStyles);
const trail = getTrailFor(model);
return (
<div className={styles.container}>
<div className={styles.heading}>Trail</div>
{steps.map((step, index) => (
<Tooltip content={() => model.renderStepTooltip(step)} key={index}>
<button
className={cx(styles.step, styles.stepTypes[step.type])}
onClick={() => trail.goBackToStep(step)}
></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': {
transform: 'scale(1.1)',
},
'&:after': {
content: '""',
position: 'absolute',
width: 10,
height: 2,
left: 8,
top: 3,
background: theme.colors.primary.border,
},
'&:last-child': {
'&:after': {
display: 'none',
},
},
}),
stepTypes: {
start: css({
background: visTheme.getColorByName('green'),
'&:after': {
background: visTheme.getColorByName('green'),
},
}),
filters: css({
background: visTheme.getColorByName('purple'),
'&:after': {
background: visTheme.getColorByName('purple'),
},
}),
metric: css({
background: visTheme.getColorByName('orange'),
'&:after': {
background: visTheme.getColorByName('orange'),
},
}),
time: css({
background: theme.colors.primary.main,
'&:after': {
background: theme.colors.primary.main,
},
}),
},
};
}

@ -0,0 +1,123 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import {
SceneComponentProps,
sceneGraph,
SceneObject,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
} from '@grafana/scenes';
import { Button, useStyles2, Stack } from '@grafana/ui';
import { Text } from '@grafana/ui/src/components/Text/Text';
import { DataTrail } from './DataTrail';
import { DataTrailCard } from './DataTrailCard';
import { DataTrailsApp } from './DataTrailsApp';
import { newMetricsTrail } from './utils';
export interface DataTrailsHomeState extends SceneObjectState {
recent: Array<SceneObjectRef<DataTrail>>;
bookmarks: Array<SceneObjectRef<DataTrail>>;
}
export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> {
public constructor(state: DataTrailsHomeState) {
super(state);
}
public onNewMetricsTrail = () => {
const app = getAppFor(this);
const trail = newMetricsTrail();
this.setState({ recent: [app.state.trail.getRef(), ...this.state.recent] });
app.goToUrlForTrail(trail);
};
public onSelectTrail = (trail: DataTrail) => {
const app = getAppFor(this);
const currentTrail = app.state.trail;
const existsInRecent = this.state.recent.find((t) => t.resolve() === currentTrail);
if (!existsInRecent) {
this.setState({ recent: [currentTrail.getRef(), ...this.state.recent] });
}
app.goToUrlForTrail(trail);
};
static Component = ({ model }: SceneComponentProps<DataTrailsHome>) => {
const { recent, bookmarks } = model.useState();
const app = getAppFor(model);
const styles = useStyles2(getStyles);
return (
<div className={styles.container}>
<Stack direction="column" gap={1}>
<Text variant="h2">Data trails</Text>
<Text color="secondary">Automatically query, explore and navigate your observability data</Text>
</Stack>
<Stack gap={2}>
<Button icon="plus" size="lg" variant="secondary" onClick={model.onNewMetricsTrail}>
New metric trail
</Button>
</Stack>
<Stack gap={4}>
<div className={styles.column}>
<Text variant="h4">Recent trails</Text>
<div className={styles.trailList}>
{app.state.trail.state.metric && <DataTrailCard trail={app.state.trail} onSelect={model.onSelectTrail} />}
{recent.map((trail, index) => (
<DataTrailCard key={index} trail={trail.resolve()} onSelect={model.onSelectTrail} />
))}
</div>
</div>
<div className={styles.column}>
<Text variant="h4">Bookmarks</Text>
<div className={styles.trailList}>
{bookmarks.map((trail, index) => (
<DataTrailCard key={index} trail={trail.resolve()} onSelect={model.onSelectTrail} />
))}
</div>
</div>
</Stack>
</div>
);
};
}
function getAppFor(model: SceneObject) {
return sceneGraph.getAncestor(model, DataTrailsApp);
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css({
padding: theme.spacing(2),
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
}),
column: css({
width: 500,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}),
newTrail: css({
height: 'auto',
justifyContent: 'center',
fontSize: theme.typography.h5.fontSize,
}),
trailCard: css({}),
trailList: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}),
};
}

@ -0,0 +1,11 @@
// Libraries
import React from 'react';
import { getDataTrailsApp } from './DataTrailsApp';
export function DataTrailsPage() {
const app = getDataTrailsApp();
return <app.Component model={app} />;
}
export default DataTrailsPage;

@ -0,0 +1,42 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { SceneComponentProps, SceneObject, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { Field, RadioButtonGroup } from '@grafana/ui';
export interface LayoutSwitcherState extends SceneObjectState {
active: LayoutType;
layouts: SceneObject[];
options: Array<SelectableValue<LayoutType>>;
}
export type LayoutType = 'single' | 'grid' | 'rows';
export class LayoutSwitcher extends SceneObjectBase<LayoutSwitcherState> {
public Selector({ model }: { model: LayoutSwitcher }) {
const { active, options } = model.useState();
return (
<Field label="View">
<RadioButtonGroup options={options} value={active} onChange={model.onLayoutChange} />
</Field>
);
}
public onLayoutChange = (active: LayoutType) => {
this.setState({ active });
};
public static Component = ({ model }: SceneComponentProps<LayoutSwitcher>) => {
const { layouts, options, active } = model.useState();
const index = options.findIndex((o) => o.value === active);
if (index === -1) {
return null;
}
const layout = layouts[index];
return <layout.Component model={layout} />;
};
}

@ -0,0 +1,180 @@
import React from 'react';
import {
SceneObjectState,
SceneObjectBase,
SceneComponentProps,
SceneFlexLayout,
SceneFlexItem,
SceneQueryRunner,
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
PanelBuilders,
sceneGraph,
} from '@grafana/scenes';
import { ToolbarButton, Box, Stack } from '@grafana/ui';
import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine';
import { AutoVizPanel } from './AutomaticMetricQueries/AutoVizPanel';
import { buildBreakdownActionScene } from './BreakdownScene';
import { MetricSelectScene } from './MetricSelectScene';
import { SelectMetricAction } from './SelectMetricAction';
import {
ActionViewDefinition,
getVariablesWithMetricConstant,
LOGS_METRIC,
MakeOptional,
OpenEmbeddedTrailEvent,
} from './shared';
import { getTrailFor } from './utils';
export interface MetricSceneState extends SceneObjectState {
body: SceneFlexLayout;
metric: string;
actionView?: string;
}
export class MetricScene extends SceneObjectBase<MetricSceneState> {
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['actionView'] });
public constructor(state: MakeOptional<MetricSceneState, 'body'>) {
super({
$variables: state.$variables ?? getVariablesWithMetricConstant(state.metric),
body: state.body ?? buildGraphScene(state.metric),
...state,
});
}
getUrlState() {
return { actionView: this.state.actionView };
}
updateFromUrl(values: SceneObjectUrlValues) {
if (typeof values.actionView === 'string') {
if (this.state.actionView !== values.actionView) {
const actionViewDef = actionViewsDefinitions.find((v) => v.value === values.actionView);
if (actionViewDef) {
this.setActionView(actionViewDef);
}
}
} else if (values.actionView === null) {
this.setActionView(undefined);
}
}
public setActionView(actionViewDef?: ActionViewDefinition) {
const { body } = this.state;
if (actionViewDef && actionViewDef.value !== this.state.actionView) {
// reduce max height for main panel to reduce height flicker
body.state.children[0].setState({ maxHeight: MAIN_PANEL_MIN_HEIGHT });
body.setState({ children: [...body.state.children.slice(0, 2), actionViewDef.getScene()] });
this.setState({ actionView: actionViewDef.value });
} else {
// restore max height
body.state.children[0].setState({ maxHeight: MAIN_PANEL_MAX_HEIGHT });
body.setState({ children: body.state.children.slice(0, 2) });
this.setState({ actionView: undefined });
}
}
static Component = ({ model }: SceneComponentProps<MetricScene>) => {
const { body } = model.useState();
return <body.Component model={body} />;
};
}
const actionViewsDefinitions: ActionViewDefinition[] = [
{ displayName: 'Breakdown', value: 'breakdown', getScene: buildBreakdownActionScene },
{ displayName: 'Logs', value: 'logs', getScene: buildLogsScene },
{ displayName: 'Related metrics', value: 'related', getScene: buildRelatedMetricsScene },
];
export interface MetricActionBarState extends SceneObjectState {}
export class MetricActionBar extends SceneObjectBase<MetricActionBarState> {
public getButtonVariant(actionViewName: string, currentView: string | undefined) {
return currentView === actionViewName ? 'active' : 'canvas';
}
public onOpenTrail = () => {
this.publishEvent(new OpenEmbeddedTrailEvent(), true);
};
public static Component = ({ model }: SceneComponentProps<MetricActionBar>) => {
const metricScene = sceneGraph.getAncestor(model, MetricScene);
const trail = getTrailFor(model);
const { actionView } = metricScene.useState();
return (
<Box paddingY={1}>
<Stack gap={2}>
{actionViewsDefinitions.map((viewDef) => (
<ToolbarButton
key={viewDef.value}
variant={viewDef.value === actionView ? 'active' : 'canvas'}
onClick={() => metricScene.setActionView(viewDef)}
>
{viewDef.displayName}
</ToolbarButton>
))}
<ToolbarButton variant={'canvas'}>Add to dashboard</ToolbarButton>
<ToolbarButton variant={'canvas'} icon="compass" tooltip="Open in explore (todo)" disabled />
<ToolbarButton variant={'canvas'} icon="star" tooltip="Bookmark (todo)" disabled />
<ToolbarButton variant={'canvas'} icon="share-alt" tooltip="Copy url (todo)" disabled />
{trail.state.embedded && (
<ToolbarButton variant={'canvas'} onClick={model.onOpenTrail}>
Open
</ToolbarButton>
)}
</Stack>
</Box>
);
};
}
const MAIN_PANEL_MIN_HEIGHT = 280;
const MAIN_PANEL_MAX_HEIGHT = '40%';
function buildGraphScene(metric: string) {
const autoQuery = getAutoQueriesForMetric(metric);
return new SceneFlexLayout({
direction: 'column',
children: [
new SceneFlexItem({
minHeight: MAIN_PANEL_MIN_HEIGHT,
maxHeight: MAIN_PANEL_MAX_HEIGHT,
body: new AutoVizPanel({ autoQuery }),
}),
new SceneFlexItem({
ySizing: 'content',
body: new MetricActionBar({}),
}),
],
});
}
function buildLogsScene() {
return new SceneFlexItem({
$data: new SceneQueryRunner({
queries: [
{
refId: 'A',
datasource: { uid: 'gdev-loki' },
expr: '{${filters}} | logfmt',
},
],
}),
body: PanelBuilders.logs()
.setTitle('Logs')
.setHeaderActions(new SelectMetricAction({ metric: LOGS_METRIC, title: 'Open' }))
.build(),
});
}
function buildRelatedMetricsScene() {
return new SceneFlexItem({
body: new MetricSelectScene({}),
});
}

@ -0,0 +1,216 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import {
SceneObjectState,
SceneObjectBase,
SceneComponentProps,
PanelBuilders,
SceneFlexItem,
SceneVariableSet,
QueryVariable,
sceneGraph,
VariableDependencyConfig,
SceneVariable,
SceneCSSGridLayout,
SceneCSSGridItem,
} from '@grafana/scenes';
import { VariableHide } from '@grafana/schema';
import { Input, Text, useStyles2, InlineSwitch } from '@grafana/ui';
import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine';
import { SelectMetricAction } from './SelectMetricAction';
import { getVariablesWithMetricConstant, trailDS, VAR_FILTERS_EXPR, VAR_METRIC_NAMES } from './shared';
import { getColorByIndex } from './utils';
export interface MetricSelectSceneState extends SceneObjectState {
body: SceneCSSGridLayout;
showHeading?: boolean;
searchQuery?: string;
showPreviews?: boolean;
}
const ROW_PREVIEW_HEIGHT = '175px';
const ROW_CARD_HEIGHT = '64px';
export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> {
constructor(state: Partial<MetricSelectSceneState>) {
super({
$variables: state.$variables ?? getMetricNamesVariableSet(),
body:
state.body ??
new SceneCSSGridLayout({
children: [],
templateColumns: 'repeat(auto-fill, minmax(450px, 1fr))',
autoRows: ROW_PREVIEW_HEIGHT,
}),
showPreviews: true,
...state,
});
this.addActivationHandler(this._onActivate.bind(this));
}
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: [VAR_METRIC_NAMES],
onVariableUpdatesCompleted: this._onVariableChanged.bind(this),
});
private _onVariableChanged(changedVariables: Set<SceneVariable>, dependencyChanged: boolean): void {
if (dependencyChanged) {
this.buildLayout();
}
}
private ignoreNextUpdate = false;
private _onActivate() {
if (this.state.body.state.children.length === 0) {
this.buildLayout();
} else {
// Temp hack when going back to select metric scene and variable updates
this.ignoreNextUpdate = true;
}
}
private buildLayout() {
// Temp hack when going back to select metric scene and variable updates
if (this.ignoreNextUpdate) {
this.ignoreNextUpdate = false;
return;
}
const variable = sceneGraph.lookupVariable(VAR_METRIC_NAMES, this);
if (!(variable instanceof QueryVariable)) {
return;
}
if (variable.state.loading) {
return;
}
const searchRegex = new RegExp(this.state.searchQuery ?? '.*');
const metricNames = variable.state.options;
const children: SceneFlexItem[] = [];
const showPreviews = this.state.showPreviews;
const previewLimit = 20;
const cardLimit = 50;
for (let index = 0; index < metricNames.length; index++) {
const metric = metricNames[index];
const metricName = String(metric.value);
if (!metricName.match(searchRegex)) {
continue;
}
if (children.length > cardLimit) {
break;
}
if (showPreviews && children.length < previewLimit) {
children.push(
new SceneCSSGridItem({
$variables: getVariablesWithMetricConstant(metricName),
body: getPreviewPanelFor(metricName, index),
})
);
} else {
children.push(
new SceneCSSGridItem({
$variables: getVariablesWithMetricConstant(metricName),
body: getCardPanelFor(metricName),
})
);
}
}
const rowTemplate = this.state.showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT;
this.state.body.setState({ children, autoRows: rowTemplate });
}
public onSearchChange = (evt: React.SyntheticEvent<HTMLInputElement>) => {
this.setState({ searchQuery: evt.currentTarget.value });
this.buildLayout();
};
public onTogglePreviews = () => {
this.setState({ showPreviews: !this.state.showPreviews });
this.buildLayout();
};
public static Component = ({ model }: SceneComponentProps<MetricSelectScene>) => {
const { showHeading, searchQuery, showPreviews } = model.useState();
const styles = useStyles2(getStyles);
return (
<div className={styles.container}>
{showHeading && (
<div className={styles.headingWrapper}>
<Text variant="h4">Select a metric</Text>
</div>
)}
<div className={styles.header}>
<Input placeholder="Search metrics" value={searchQuery} onChange={model.onSearchChange} />
<InlineSwitch showLabel={true} label="Show previews" value={showPreviews} onChange={model.onTogglePreviews} />
</div>
<model.state.body.Component model={model.state.body} />
</div>
);
};
}
function getMetricNamesVariableSet() {
return new SceneVariableSet({
variables: [
new QueryVariable({
name: VAR_METRIC_NAMES,
datasource: trailDS,
hide: VariableHide.hideVariable,
includeAll: true,
defaultToAll: true,
skipUrlSync: true,
query: { query: `label_values(${VAR_FILTERS_EXPR},__name__)`, refId: 'A' },
}),
],
});
}
function getPreviewPanelFor(metric: string, index: number) {
const autoQuery = getAutoQueriesForMetric(metric);
return autoQuery.preview
.vizBuilder(autoQuery.preview)
.setColor({ mode: 'fixed', fixedColor: getColorByIndex(index) })
.setHeaderActions(new SelectMetricAction({ metric, title: 'Select' }))
.build();
}
function getCardPanelFor(metric: string) {
return PanelBuilders.text()
.setTitle(metric)
.setHeaderActions(new SelectMetricAction({ metric, title: 'Select' }))
.setOption('content', '')
.build();
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
}),
headingWrapper: css({
marginTop: theme.spacing(1),
}),
header: css({
flexGrow: 0,
display: 'flex',
gap: theme.spacing(2),
marginBottom: theme.spacing(1),
}),
};
}

@ -0,0 +1,25 @@
import React from 'react';
import { SceneObjectState, SceneObjectBase, SceneComponentProps } from '@grafana/scenes';
import { Button } from '@grafana/ui';
import { MetricSelectedEvent } from './shared';
export interface SelectMetricActionState extends SceneObjectState {
title: string;
metric: string;
}
export class SelectMetricAction extends SceneObjectBase<SelectMetricActionState> {
public onClick = () => {
this.publishEvent(new MetricSelectedEvent(this.state.metric), true);
};
public static Component = ({ model }: SceneComponentProps<SelectMetricAction>) => {
return (
<Button variant="primary" size="sm" fill="text" onClick={model.onClick}>
{model.state.title}
</Button>
);
};
}

@ -0,0 +1,83 @@
import React from 'react';
import {
SceneObjectState,
SceneObjectBase,
VariableDependencyConfig,
sceneGraph,
SceneComponentProps,
SceneVariableSet,
SceneVariable,
QueryVariable,
VariableValueOption,
} from '@grafana/scenes';
import { VariableHide } from '@grafana/schema';
import { Input, Card, Stack } from '@grafana/ui';
import { trailDS } from './shared';
export interface SelectMetricTrailViewState extends SceneObjectState {
metricNames: VariableValueOption[];
}
export class SelectMetricTrailView extends SceneObjectBase<SelectMetricTrailViewState> {
public constructor(state: Partial<SelectMetricTrailViewState>) {
super({
$variables: new SceneVariableSet({
variables: [
new QueryVariable({
name: 'metricNames',
datasource: trailDS,
hide: VariableHide.hideVariable,
includeAll: true,
defaultToAll: true,
skipUrlSync: true,
query: { query: 'label_values({$filters},__name__)', refId: 'A' },
}),
],
}),
metricNames: [],
...state,
});
}
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: ['filters', 'metricNames'],
onVariableUpdatesCompleted: this._onVariableChanged.bind(this),
});
private _onVariableChanged(changedVariables: Set<SceneVariable>, dependencyChanged: boolean): void {
for (const variable of changedVariables) {
if (variable.state.name === 'filters') {
const variable = sceneGraph.lookupVariable('filters', this)!;
// Temp hack
(this.state.$variables as any)._handleVariableValueChanged(variable);
}
if (variable.state.name === 'metricNames' && variable instanceof QueryVariable) {
this.setState({ metricNames: variable.state.options });
}
}
}
static Component = ({ model }: SceneComponentProps<SelectMetricTrailView>) => {
const { metricNames } = model.useState();
return (
<Stack direction="column" gap={0}>
<Stack direction="column" gap={2}>
<Input placeholder="Search metrics" />
<div></div>
</Stack>
{metricNames.map((option, index) => (
<Card
key={index}
href={sceneGraph.interpolate(model, `\${__url.path}\${__url.params}&metric=${option.value}`)}
>
<Card.Heading>{String(option.value)}</Card.Heading>
</Card>
))}
</Stack>
);
};
}

@ -0,0 +1,38 @@
import { PanelMenuItem } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { buildVisualQueryFromString } from 'app/plugins/datasource/prometheus/querybuilder/parsing';
import { DashboardScene } from '../dashboard-scene/scene/DashboardScene';
import { getQueryRunnerFor } from '../dashboard-scene/utils/utils';
import { DataTrailDrawer } from './DataTrailDrawer';
export function addDataTrailPanelAction(dashboard: DashboardScene, vizPanel: VizPanel, items: PanelMenuItem[]) {
const queryRunner = getQueryRunnerFor(vizPanel);
if (!queryRunner) {
return;
}
const ds = getDataSourceSrv().getInstanceSettings(queryRunner.state.datasource);
if (!ds || ds.meta.id !== 'prometheus' || queryRunner.state.queries.length > 1) {
return;
}
const query = queryRunner.state.queries[0];
const parsedResult = buildVisualQueryFromString(query.expr);
if (parsedResult.errors.length > 0) {
return;
}
items.push({
text: 'Data trail',
iconClassName: 'code-branch',
onClick: () => {
dashboard.showModal(
new DataTrailDrawer({ query: parsedResult.query, dsRef: ds, timeRange: dashboard.state.$timeRange!.clone() })
);
},
shortcut: 'p s',
});
}

@ -0,0 +1,46 @@
import { BusEventBase, BusEventWithPayload } from '@grafana/data';
import { ConstantVariable, SceneObject, SceneVariableSet } from '@grafana/scenes';
import { VariableHide } from '@grafana/schema';
export interface ActionViewDefinition {
displayName: string;
value: string;
getScene: () => SceneObject;
}
export const VAR_METRIC_NAMES = 'metricNames';
export const VAR_FILTERS = 'filters';
export const VAR_FILTERS_EXPR = '{${filters}}';
export const VAR_METRIC = 'metric';
export const VAR_METRIC_EXPR = '${metric}';
export const VAR_GROUP_BY = 'groupby';
export const VAR_GROUP_BY_EXP = '${groupby}';
export const VAR_DATASOURCE = 'ds';
export const VAR_DATASOURCE_EXPR = '${ds}';
export const LOGS_METRIC = '$__logs__';
export const KEY_SQR_METRIC_VIZ_QUERY = 'sqr-metric-viz-query';
export const trailDS = { uid: VAR_DATASOURCE_EXPR };
export type MakeOptional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
export function getVariablesWithMetricConstant(metric: string) {
return new SceneVariableSet({
variables: [
new ConstantVariable({
name: VAR_METRIC,
value: metric,
hide: VariableHide.hideVariable,
}),
],
});
}
export class MetricSelectedEvent extends BusEventWithPayload<string> {
public static type = 'metric-selected-event';
}
export class OpenEmbeddedTrailEvent extends BusEventBase {
public static type = 'open-embedded-trail-event';
}

@ -0,0 +1,48 @@
import { urlUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
import { getUrlSyncManager, sceneGraph, SceneObject, SceneTimeRange } from '@grafana/scenes';
import { DataTrail } from './DataTrail';
import { DataTrailSettings } from './DataTrailSettings';
import { MetricScene } from './MetricScene';
export function getTrailFor(model: SceneObject): DataTrail {
return sceneGraph.getAncestor(model, DataTrail);
}
export function getTrailSettings(model: SceneObject): DataTrailSettings {
return sceneGraph.getAncestor(model, DataTrail).state.settings;
}
export function newMetricsTrail(): DataTrail {
return new DataTrail({
//initialDS: 'gdev-prometheus',
$timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }),
//initialFilters: [{ key: 'job', operator: '=', value: 'grafana' }],
embedded: false,
});
}
export function getUrlForTrail(trail: DataTrail) {
const params = getUrlSyncManager().getUrlState(trail);
return urlUtil.renderUrl('/data-trails/trail', params);
}
export function getMetricSceneFor(model: SceneObject): MetricScene {
if (model instanceof MetricScene) {
return model;
}
if (model.parent) {
return getMetricSceneFor(model.parent);
}
console.error('Unable to find graph view for', model);
throw new Error('Unable to find trail');
}
export function getColorByIndex(index: number) {
const visTheme = config.theme2.visualization;
return visTheme.getColorByName(visTheme.palette[index % 8]);
}

@ -485,6 +485,14 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "NotificationsPage"*/ 'app/features/notifications/NotificationsPage')
),
},
{
path: '/data-trails',
chromeless: false,
exact: false,
component: SafeDynamicImport(
() => import(/* webpackChunkName: "DataTrailsPage"*/ 'app/features/trails/DataTrailsPage')
),
},
...getDynamicDashboardRoutes(),
...getPluginCatalogRoutes(),
...getSupportBundleRoutes(),

Loading…
Cancel
Save