diff --git a/.betterer.results b/.betterer.results index ac417c4cb5c..be89734c7d6 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] ], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1858d381855..320982e2fa8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/go.sum b/go.sum index 2c6059fcbe3..a8400f20b12 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5116688fe88..604876fd3de 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -160,5 +160,6 @@ export interface FeatureToggles { logsInfiniteScrolling?: boolean; flameGraphItemCollapsing?: boolean; alertingDetailsViewV2?: boolean; + datatrails?: boolean; alertingSimplifiedRouting?: boolean; } diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index 6359ea814ad..aebf585ccc7 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -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')]: { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d00382bde7e..3199e554ba9 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 174237f5d92..20ad5e1c8e0 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 95b5a503359..db950be0fd4 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -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" diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 5ae0795e2bd..7e6b6729aae 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -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, diff --git a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx index 70699f37b4d..7a25aeca24e 100644 --- a/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx @@ -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); diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts index 4e5fd595032..101b43873a4 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts @@ -136,6 +136,9 @@ jest.mock('@grafana/runtime', () => ({ }, config: { panels: [], + featureToggles: { + dataTrails: false, + }, theme2: { visualization: { getColorByName: jest.fn().mockReturnValue('red'), diff --git a/public/app/features/trails/AddToFiltersGraphAction.tsx b/public/app/features/trails/AddToFiltersGraphAction.tsx new file mode 100644 index 00000000000..d05806c4b94 --- /dev/null +++ b/public/app/features/trails/AddToFiltersGraphAction.tsx @@ -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 { + 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) => { + return ( + + ); + }; +} diff --git a/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.ts b/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.ts new file mode 100644 index 00000000000..e4e95e75ccf --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/AutoQueryEngine.ts @@ -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, + }) + ); +} diff --git a/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx new file mode 100644 index 00000000000..b371ba96216 --- /dev/null +++ b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx @@ -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 { + 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 ; + } + + 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) => { + const { panel, queryDef } = model.useState(); + const { showQuery } = getTrailSettings(model).useState(); + const styles = useStyles2(getStyles); + + if (!panel) { + return; + } + + if (!showQuery) { + return ; + } + + return ( +
+ + +
{queryDef && queryDef.queries.map((query, index) =>
{query.expr}
)}
+
+
+
+ +
+
+ ); + }; +} + +function getStyles(theme: GrafanaTheme2) { + return { + wrapper: css({ + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + }), + panel: css({ + position: 'relative', + flexGrow: 1, + }), + }; +} diff --git a/public/app/features/trails/BreakdownScene.tsx b/public/app/features/trails/BreakdownScene.tsx new file mode 100644 index 00000000000..7e63de042c7 --- /dev/null +++ b/public/app/features/trails/BreakdownScene.tsx @@ -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>; + value?: string; + loading?: boolean; +} + +/** + * Just a proof of concept example of a behavior + */ +export class BreakdownScene extends SceneObjectBase { + constructor(state: Partial) { + 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 = { + 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> = []; + + 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) => { + const { labels, body, loading, value } = model.useState(); + const styles = useStyles2(getStyles); + + return ( +
+ {loading &&
Loading...
} +
+ + + + {body instanceof LayoutSwitcher && ( +
+ +
+ )} +
+
{body && }
+
+ ); + }; +} + +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>, 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 { + public onClick = () => { + getBreakdownSceneFor(this).onChange(this.state.labelName); + }; + + public static Component = ({ model }: SceneComponentProps) => { + return ( + + ); + }; +} + +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'); +} diff --git a/public/app/features/trails/ByFrameRepeater.tsx b/public/app/features/trails/ByFrameRepeater.tsx new file mode 100644 index 00000000000..b11013fded4 --- /dev/null +++ b/public/app/features/trails/ByFrameRepeater.tsx @@ -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 { + 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) => { + const { body } = model.useState(); + return ; + }; +} diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx new file mode 100644 index 00000000000..914f6f9d271 --- /dev/null +++ b/public/app/features/trails/DataTrail.tsx @@ -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 { + protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['metric'] }); + + public constructor(state: Partial) { + 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 = {}; + stateUpdate.metric = metric; + stateUpdate.topScene = getTopSceneFor(metric); + return stateUpdate; + } + + getUrlState() { + return { metric: this.state.metric }; + } + + updateFromUrl(values: SceneObjectUrlValues) { + const stateUpdate: Partial = {}; + + 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) => { + const { controls, topScene, history, settings } = model.useState(); + const styles = useStyles2(getStyles); + + return ( +
+ + {controls && ( +
+ {controls.map((control) => ( + + ))} + +
+ )} +
{topScene && }
+
+ ); + }; +} + +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', + }), + }; +} diff --git a/public/app/features/trails/DataTrailCard.tsx b/public/app/features/trails/DataTrailCard.tsx new file mode 100644 index 00000000000..6809de15735 --- /dev/null +++ b/public/app/features/trails/DataTrailCard.tsx @@ -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 ( + + ); +} + +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), + }), + }; +} diff --git a/public/app/features/trails/DataTrailDrawer.tsx b/public/app/features/trails/DataTrailDrawer.tsx new file mode 100644 index 00000000000..fa2357c4318 --- /dev/null +++ b/public/app/features/trails/DataTrailDrawer.tsx @@ -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 { + 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) { + return ( + +
+ +
+
+ ); +} + +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, + }); +} diff --git a/public/app/features/trails/DataTrailSettings.tsx b/public/app/features/trails/DataTrailSettings.tsx new file mode 100644 index 00000000000..6450a9fed98 --- /dev/null +++ b/public/app/features/trails/DataTrailSettings.tsx @@ -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 { + constructor(state: Partial) { + 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) => { + 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 */ +
evt.stopPropagation()}> +
Settings
+
+
Multi value variables
+ +
Advanced options
+ +
Show query
+ +
+
+ ); + }; + + return ( + + + + ); + }; +} + +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), + }), + }; +} diff --git a/public/app/features/trails/DataTrailsApp.tsx b/public/app/features/trails/DataTrailsApp.tsx new file mode 100644 index 00000000000..83dcb3aee20 --- /dev/null +++ b/public/app/features/trails/DataTrailsApp.tsx @@ -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 { + public constructor(state: DataTrailsAppState) { + super(state); + } + + goToUrlForTrail(trail: DataTrail) { + this.setState({ trail }); + locationService.push(getUrlForTrail(trail)); + } + + static Component = ({ model }: SceneComponentProps) => { + const { trail, home } = model.useState(); + const styles = useStyles2(getStyles); + + return ( + + ( + +
+ +
+
+ )} + /> + ( + +
+ +
+
+ )} + /> +
+ ); + }; +} + +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 ; +} + +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', + }), + }; +} diff --git a/public/app/features/trails/DataTrailsHistory.tsx b/public/app/features/trails/DataTrailsHistory.tsx new file mode 100644 index 00000000000..0ac04947090 --- /dev/null +++ b/public/app/features/trails/DataTrailsHistory.tsx @@ -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 { + public constructor(state: Partial) { + 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 ( + +
{step.type}
+ {step.type === 'metric' &&
{step.trailState.metric}
} +
+ ); + } + + public static Component = ({ model }: SceneComponentProps) => { + const { steps } = model.useState(); + const styles = useStyles2(getStyles); + const trail = getTrailFor(model); + + return ( +
+
Trail
+ {steps.map((step, index) => ( + model.renderStepTooltip(step)} key={index}> + + + ))} +
+ ); + }; +} + +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, + }, + }), + }, + }; +} diff --git a/public/app/features/trails/DataTrailsHome.tsx b/public/app/features/trails/DataTrailsHome.tsx new file mode 100644 index 00000000000..f9739bd255f --- /dev/null +++ b/public/app/features/trails/DataTrailsHome.tsx @@ -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>; + bookmarks: Array>; +} + +export class DataTrailsHome extends SceneObjectBase { + 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) => { + const { recent, bookmarks } = model.useState(); + const app = getAppFor(model); + const styles = useStyles2(getStyles); + + return ( +
+ + Data trails + Automatically query, explore and navigate your observability data + + + + + +
+ Recent trails +
+ {app.state.trail.state.metric && } + {recent.map((trail, index) => ( + + ))} +
+
+
+ Bookmarks +
+ {bookmarks.map((trail, index) => ( + + ))} +
+
+
+
+ ); + }; +} + +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), + }), + }; +} diff --git a/public/app/features/trails/DataTrailsPage.tsx b/public/app/features/trails/DataTrailsPage.tsx new file mode 100644 index 00000000000..f473b90c91c --- /dev/null +++ b/public/app/features/trails/DataTrailsPage.tsx @@ -0,0 +1,11 @@ +// Libraries +import React from 'react'; + +import { getDataTrailsApp } from './DataTrailsApp'; + +export function DataTrailsPage() { + const app = getDataTrailsApp(); + return ; +} + +export default DataTrailsPage; diff --git a/public/app/features/trails/LayoutSwitcher.tsx b/public/app/features/trails/LayoutSwitcher.tsx new file mode 100644 index 00000000000..92b3933728d --- /dev/null +++ b/public/app/features/trails/LayoutSwitcher.tsx @@ -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>; +} + +export type LayoutType = 'single' | 'grid' | 'rows'; + +export class LayoutSwitcher extends SceneObjectBase { + public Selector({ model }: { model: LayoutSwitcher }) { + const { active, options } = model.useState(); + + return ( + + + + ); + } + + public onLayoutChange = (active: LayoutType) => { + this.setState({ active }); + }; + + public static Component = ({ model }: SceneComponentProps) => { + const { layouts, options, active } = model.useState(); + + const index = options.findIndex((o) => o.value === active); + if (index === -1) { + return null; + } + + const layout = layouts[index]; + + return ; + }; +} diff --git a/public/app/features/trails/MetricScene.tsx b/public/app/features/trails/MetricScene.tsx new file mode 100644 index 00000000000..04d83a18ff0 --- /dev/null +++ b/public/app/features/trails/MetricScene.tsx @@ -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 { + protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['actionView'] }); + + public constructor(state: MakeOptional) { + 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) => { + const { body } = model.useState(); + return ; + }; +} + +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 { + public getButtonVariant(actionViewName: string, currentView: string | undefined) { + return currentView === actionViewName ? 'active' : 'canvas'; + } + + public onOpenTrail = () => { + this.publishEvent(new OpenEmbeddedTrailEvent(), true); + }; + + public static Component = ({ model }: SceneComponentProps) => { + const metricScene = sceneGraph.getAncestor(model, MetricScene); + const trail = getTrailFor(model); + const { actionView } = metricScene.useState(); + + return ( + + + {actionViewsDefinitions.map((viewDef) => ( + metricScene.setActionView(viewDef)} + > + {viewDef.displayName} + + ))} + Add to dashboard + + + + {trail.state.embedded && ( + + Open + + )} + + + ); + }; +} + +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({}), + }); +} diff --git a/public/app/features/trails/MetricSelectScene.tsx b/public/app/features/trails/MetricSelectScene.tsx new file mode 100644 index 00000000000..89be7eb87e1 --- /dev/null +++ b/public/app/features/trails/MetricSelectScene.tsx @@ -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 { + constructor(state: Partial) { + 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, 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) => { + this.setState({ searchQuery: evt.currentTarget.value }); + this.buildLayout(); + }; + + public onTogglePreviews = () => { + this.setState({ showPreviews: !this.state.showPreviews }); + this.buildLayout(); + }; + + public static Component = ({ model }: SceneComponentProps) => { + const { showHeading, searchQuery, showPreviews } = model.useState(); + const styles = useStyles2(getStyles); + + return ( +
+ {showHeading && ( +
+ Select a metric +
+ )} +
+ + +
+ +
+ ); + }; +} + +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), + }), + }; +} diff --git a/public/app/features/trails/SelectMetricAction.tsx b/public/app/features/trails/SelectMetricAction.tsx new file mode 100644 index 00000000000..a41e8d0d121 --- /dev/null +++ b/public/app/features/trails/SelectMetricAction.tsx @@ -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 { + public onClick = () => { + this.publishEvent(new MetricSelectedEvent(this.state.metric), true); + }; + + public static Component = ({ model }: SceneComponentProps) => { + return ( + + ); + }; +} diff --git a/public/app/features/trails/SelectMetricTrailView.tsx b/public/app/features/trails/SelectMetricTrailView.tsx new file mode 100644 index 00000000000..bea975cec52 --- /dev/null +++ b/public/app/features/trails/SelectMetricTrailView.tsx @@ -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 { + public constructor(state: Partial) { + 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, 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) => { + const { metricNames } = model.useState(); + + return ( + + + +
+
+ {metricNames.map((option, index) => ( + + {String(option.value)} + + ))} +
+ ); + }; +} diff --git a/public/app/features/trails/dashboardIntegration.ts b/public/app/features/trails/dashboardIntegration.ts new file mode 100644 index 00000000000..98b43feeaf4 --- /dev/null +++ b/public/app/features/trails/dashboardIntegration.ts @@ -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', + }); +} diff --git a/public/app/features/trails/shared.ts b/public/app/features/trails/shared.ts new file mode 100644 index 00000000000..371390b7160 --- /dev/null +++ b/public/app/features/trails/shared.ts @@ -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 = Pick, K> & Omit; + +export function getVariablesWithMetricConstant(metric: string) { + return new SceneVariableSet({ + variables: [ + new ConstantVariable({ + name: VAR_METRIC, + value: metric, + hide: VariableHide.hideVariable, + }), + ], + }); +} + +export class MetricSelectedEvent extends BusEventWithPayload { + public static type = 'metric-selected-event'; +} + +export class OpenEmbeddedTrailEvent extends BusEventBase { + public static type = 'open-embedded-trail-event'; +} diff --git a/public/app/features/trails/utils.ts b/public/app/features/trails/utils.ts new file mode 100644 index 00000000000..66494e8dd79 --- /dev/null +++ b/public/app/features/trails/utils.ts @@ -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]); +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 91910b4a96a..415c0fb9af9 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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(),