mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
@@ -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"]
|
||||
],
|
||||
|
||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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'),
|
||||
|
||||
56
public/app/features/trails/AddToFiltersGraphAction.tsx
Normal file
56
public/app/features/trails/AddToFiltersGraphAction.tsx
Normal file
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
374
public/app/features/trails/BreakdownScene.tsx
Normal file
374
public/app/features/trails/BreakdownScene.tsx
Normal file
@@ -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');
|
||||
}
|
||||
55
public/app/features/trails/ByFrameRepeater.tsx
Normal file
55
public/app/features/trails/ByFrameRepeater.tsx
Normal file
@@ -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} />;
|
||||
};
|
||||
}
|
||||
205
public/app/features/trails/DataTrail.tsx
Normal file
205
public/app/features/trails/DataTrail.tsx
Normal file
@@ -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',
|
||||
}),
|
||||
};
|
||||
}
|
||||
98
public/app/features/trails/DataTrailCard.tsx
Normal file
98
public/app/features/trails/DataTrailCard.tsx
Normal file
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
67
public/app/features/trails/DataTrailDrawer.tsx
Normal file
67
public/app/features/trails/DataTrailDrawer.tsx
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
93
public/app/features/trails/DataTrailSettings.tsx
Normal file
93
public/app/features/trails/DataTrailSettings.tsx
Normal file
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
106
public/app/features/trails/DataTrailsApp.tsx
Normal file
106
public/app/features/trails/DataTrailsApp.tsx
Normal file
@@ -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',
|
||||
}),
|
||||
};
|
||||
}
|
||||
183
public/app/features/trails/DataTrailsHistory.tsx
Normal file
183
public/app/features/trails/DataTrailsHistory.tsx
Normal file
@@ -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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
123
public/app/features/trails/DataTrailsHome.tsx
Normal file
123
public/app/features/trails/DataTrailsHome.tsx
Normal file
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
11
public/app/features/trails/DataTrailsPage.tsx
Normal file
11
public/app/features/trails/DataTrailsPage.tsx
Normal file
@@ -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;
|
||||
42
public/app/features/trails/LayoutSwitcher.tsx
Normal file
42
public/app/features/trails/LayoutSwitcher.tsx
Normal file
@@ -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} />;
|
||||
};
|
||||
}
|
||||
180
public/app/features/trails/MetricScene.tsx
Normal file
180
public/app/features/trails/MetricScene.tsx
Normal file
@@ -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({}),
|
||||
});
|
||||
}
|
||||
216
public/app/features/trails/MetricSelectScene.tsx
Normal file
216
public/app/features/trails/MetricSelectScene.tsx
Normal file
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
25
public/app/features/trails/SelectMetricAction.tsx
Normal file
25
public/app/features/trails/SelectMetricAction.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
83
public/app/features/trails/SelectMetricTrailView.tsx
Normal file
83
public/app/features/trails/SelectMetricTrailView.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
38
public/app/features/trails/dashboardIntegration.ts
Normal file
38
public/app/features/trails/dashboardIntegration.ts
Normal file
@@ -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',
|
||||
});
|
||||
}
|
||||
46
public/app/features/trails/shared.ts
Normal file
46
public/app/features/trails/shared.ts
Normal file
@@ -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';
|
||||
}
|
||||
48
public/app/features/trails/utils.ts
Normal file
48
public/app/features/trails/utils.ts
Normal file
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user