mirror of https://github.com/grafana/grafana
DataTrails: Auto query, explore and breakdown/drilldown prototype (#77019)
* First try * Update * app with drilldowns * Progres * Progress * update * Update * update * Update * Update * Progress * Update * Progress * Update * Progress * logs url sync * related metrics * Progress * progress * Progress * Update * Update * Update * Update * Update * fix * Update * update * Update * update * Update * Update * Update * Update * Update * Update * Update * Update * Update * Update * update * Update * Update * Settings * Update * Tweaks * update * Improve auto queries * Update * Update * Fixes * Update * Update * Update * fix * Update * Removing logs view, cleanup * Update * Update * disabled not implemented buttons * Update * Feature toggle on dashboard menu * remove unused prometheus change * removed bit * Fix failing test * chore: added `/public/app/features/trails/` to CODEOWNERS * go mod tidy * go mod tidy * fix: added missing arg * Moved panel action * Moved panel action --------- Co-authored-by: André Pereira <adrapereira@gmail.com> Co-authored-by: Darren Janeczek <darren.janeczek@grafana.com>pull/78202/head
parent
c06debe200
commit
1f1d348e17
|
@ -0,0 +1,56 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { DataFrame } from '@grafana/data'; |
||||||
|
import { |
||||||
|
SceneObjectState, |
||||||
|
SceneObjectBase, |
||||||
|
SceneComponentProps, |
||||||
|
sceneGraph, |
||||||
|
AdHocFiltersVariable, |
||||||
|
} from '@grafana/scenes'; |
||||||
|
import { Button } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { getMetricSceneFor } from './utils'; |
||||||
|
|
||||||
|
export interface AddToFiltersGraphActionState extends SceneObjectState { |
||||||
|
frame: DataFrame; |
||||||
|
} |
||||||
|
|
||||||
|
export class AddToFiltersGraphAction extends SceneObjectBase<AddToFiltersGraphActionState> { |
||||||
|
public onClick = () => { |
||||||
|
const variable = sceneGraph.lookupVariable('filters', this); |
||||||
|
if (!(variable instanceof AdHocFiltersVariable)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const labels = this.state.frame.fields[1]?.labels ?? {}; |
||||||
|
if (Object.keys(labels).length !== 1) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// close action view
|
||||||
|
const metricScene = getMetricSceneFor(this); |
||||||
|
metricScene.setActionView(undefined); |
||||||
|
|
||||||
|
const labelName = Object.keys(labels)[0]; |
||||||
|
|
||||||
|
variable.state.set.setState({ |
||||||
|
filters: [ |
||||||
|
...variable.state.set.state.filters, |
||||||
|
{ |
||||||
|
key: labelName, |
||||||
|
operator: '=', |
||||||
|
value: labels[labelName], |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
public static Component = ({ model }: SceneComponentProps<AddToFiltersGraphAction>) => { |
||||||
|
return ( |
||||||
|
<Button variant="primary" size="sm" fill="text" onClick={model.onClick}> |
||||||
|
Add to filters |
||||||
|
</Button> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,202 @@ |
|||||||
|
import { PanelBuilders, SceneQueryRunner, VizPanelBuilder } from '@grafana/scenes'; |
||||||
|
import { PromQuery } from 'app/plugins/datasource/prometheus/types'; |
||||||
|
import { HeatmapColorMode } from 'app/plugins/panel/heatmap/types'; |
||||||
|
|
||||||
|
import { KEY_SQR_METRIC_VIZ_QUERY, trailDS, VAR_FILTERS_EXPR, VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from '../shared'; |
||||||
|
|
||||||
|
export interface AutoQueryDef { |
||||||
|
variant: string; |
||||||
|
title: string; |
||||||
|
unit: string; |
||||||
|
queries: PromQuery[]; |
||||||
|
vizBuilder: (def: AutoQueryDef) => VizPanelBuilder<{}, {}>; |
||||||
|
} |
||||||
|
|
||||||
|
export interface AutoQueryInfo { |
||||||
|
preview: AutoQueryDef; |
||||||
|
main: AutoQueryDef; |
||||||
|
variants: AutoQueryDef[]; |
||||||
|
breakdown: AutoQueryDef; |
||||||
|
} |
||||||
|
|
||||||
|
export function getAutoQueriesForMetric(metric: string): AutoQueryInfo { |
||||||
|
let unit = 'short'; |
||||||
|
let agg = 'avg'; |
||||||
|
let rate = false; |
||||||
|
let title = metric; |
||||||
|
|
||||||
|
if (metric.endsWith('seconds_sum')) { |
||||||
|
unit = 's'; |
||||||
|
agg = 'avg'; |
||||||
|
rate = true; |
||||||
|
} else if (metric.endsWith('seconds')) { |
||||||
|
unit = 's'; |
||||||
|
agg = 'avg'; |
||||||
|
rate = false; |
||||||
|
} else if (metric.endsWith('bytes')) { |
||||||
|
unit = 'bytes'; |
||||||
|
agg = 'avg'; |
||||||
|
rate = false; |
||||||
|
} else if (metric.endsWith('seconds_count') || metric.endsWith('seconds_total')) { |
||||||
|
agg = 'sum'; |
||||||
|
rate = true; |
||||||
|
} else if (metric.endsWith('bucket')) { |
||||||
|
return getQueriesForBucketMetric(metric); |
||||||
|
} else if (metric.endsWith('count') || metric.endsWith('total')) { |
||||||
|
agg = 'sum'; |
||||||
|
rate = true; |
||||||
|
} |
||||||
|
|
||||||
|
let query = `${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}`; |
||||||
|
if (rate) { |
||||||
|
query = `rate(${query}[$__rate_interval])`; |
||||||
|
} |
||||||
|
|
||||||
|
const main: AutoQueryDef = { |
||||||
|
title: `${title}`, |
||||||
|
variant: 'graph', |
||||||
|
unit, |
||||||
|
queries: [{ refId: 'A', expr: `${agg}(${query})` }], |
||||||
|
vizBuilder: simpleGraphBuilder, |
||||||
|
}; |
||||||
|
|
||||||
|
const breakdown: AutoQueryDef = { |
||||||
|
title: `${title}`, |
||||||
|
variant: 'graph', |
||||||
|
unit, |
||||||
|
queries: [ |
||||||
|
{ |
||||||
|
refId: 'A', |
||||||
|
expr: `${agg}(${query}) by(${VAR_GROUP_BY_EXP})`, |
||||||
|
legendFormat: `{{${VAR_GROUP_BY_EXP}}}`, |
||||||
|
}, |
||||||
|
], |
||||||
|
vizBuilder: simpleGraphBuilder, |
||||||
|
}; |
||||||
|
|
||||||
|
return { preview: main, main: main, breakdown: breakdown, variants: [] }; |
||||||
|
} |
||||||
|
|
||||||
|
function getQueriesForBucketMetric(metric: string): AutoQueryInfo { |
||||||
|
let unit = 'short'; |
||||||
|
|
||||||
|
if (metric.endsWith('seconds_bucket')) { |
||||||
|
unit = 's'; |
||||||
|
} |
||||||
|
|
||||||
|
const p50: AutoQueryDef = { |
||||||
|
title: metric, |
||||||
|
variant: 'p50', |
||||||
|
unit, |
||||||
|
queries: [ |
||||||
|
{ |
||||||
|
refId: 'A', |
||||||
|
expr: `histogram_quantile(0.50, sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`, |
||||||
|
}, |
||||||
|
], |
||||||
|
vizBuilder: simpleGraphBuilder, |
||||||
|
}; |
||||||
|
|
||||||
|
const breakdown: AutoQueryDef = { |
||||||
|
title: metric, |
||||||
|
variant: 'p50', |
||||||
|
unit, |
||||||
|
queries: [ |
||||||
|
{ |
||||||
|
refId: 'A', |
||||||
|
expr: `histogram_quantile(0.50, sum by(le, ${VAR_GROUP_BY_EXP}) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`, |
||||||
|
}, |
||||||
|
], |
||||||
|
vizBuilder: simpleGraphBuilder, |
||||||
|
}; |
||||||
|
|
||||||
|
const percentiles: AutoQueryDef = { |
||||||
|
title: metric, |
||||||
|
variant: 'percentiles', |
||||||
|
unit, |
||||||
|
queries: [ |
||||||
|
{ |
||||||
|
refId: 'A', |
||||||
|
expr: `histogram_quantile(0.99, sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`, |
||||||
|
legendFormat: '99th Percentile', |
||||||
|
}, |
||||||
|
{ |
||||||
|
refId: 'B', |
||||||
|
expr: `histogram_quantile(0.90, sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`, |
||||||
|
legendFormat: '90th Percentile', |
||||||
|
}, |
||||||
|
{ |
||||||
|
refId: 'C', |
||||||
|
expr: `histogram_quantile(0.50, sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval])))`, |
||||||
|
legendFormat: '50th Percentile', |
||||||
|
}, |
||||||
|
], |
||||||
|
vizBuilder: percentilesGraphBuilder, |
||||||
|
}; |
||||||
|
|
||||||
|
const heatmap: AutoQueryDef = { |
||||||
|
title: metric, |
||||||
|
variant: 'heatmap', |
||||||
|
unit, |
||||||
|
queries: [ |
||||||
|
{ |
||||||
|
refId: 'A', |
||||||
|
expr: `sum by(le) (rate(${VAR_METRIC_EXPR}${VAR_FILTERS_EXPR}[$__rate_interval]))`, |
||||||
|
format: 'heatmap', |
||||||
|
}, |
||||||
|
], |
||||||
|
vizBuilder: heatmapGraphBuilder, |
||||||
|
}; |
||||||
|
|
||||||
|
return { preview: p50, main: percentiles, variants: [percentiles, heatmap], breakdown: breakdown }; |
||||||
|
} |
||||||
|
|
||||||
|
function simpleGraphBuilder(def: AutoQueryDef) { |
||||||
|
return PanelBuilders.timeseries() |
||||||
|
.setTitle(def.title) |
||||||
|
.setData( |
||||||
|
new SceneQueryRunner({ |
||||||
|
datasource: trailDS, |
||||||
|
maxDataPoints: 200, |
||||||
|
queries: def.queries, |
||||||
|
}) |
||||||
|
) |
||||||
|
.setUnit(def.unit) |
||||||
|
.setOption('legend', { showLegend: false }) |
||||||
|
.setCustomFieldConfig('fillOpacity', 9); |
||||||
|
} |
||||||
|
|
||||||
|
function percentilesGraphBuilder(def: AutoQueryDef) { |
||||||
|
return PanelBuilders.timeseries() |
||||||
|
.setTitle(def.title) |
||||||
|
.setData( |
||||||
|
new SceneQueryRunner({ |
||||||
|
datasource: trailDS, |
||||||
|
maxDataPoints: 200, |
||||||
|
queries: def.queries, |
||||||
|
}) |
||||||
|
) |
||||||
|
.setUnit(def.unit) |
||||||
|
.setCustomFieldConfig('fillOpacity', 9); |
||||||
|
} |
||||||
|
|
||||||
|
function heatmapGraphBuilder(def: AutoQueryDef) { |
||||||
|
return PanelBuilders.heatmap() |
||||||
|
.setTitle(def.title) |
||||||
|
.setUnit(def.unit) |
||||||
|
.setOption('calculate', false) |
||||||
|
.setOption('color', { |
||||||
|
mode: HeatmapColorMode.Scheme, |
||||||
|
exponent: 0.5, |
||||||
|
scheme: 'Spectral', |
||||||
|
steps: 32, |
||||||
|
reverse: false, |
||||||
|
}) |
||||||
|
.setData( |
||||||
|
new SceneQueryRunner({ |
||||||
|
key: KEY_SQR_METRIC_VIZ_QUERY, |
||||||
|
datasource: trailDS, |
||||||
|
queries: def.queries, |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,95 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { SceneObjectState, SceneObjectBase, SceneComponentProps, VizPanel } from '@grafana/scenes'; |
||||||
|
import { Field, RadioButtonGroup, useStyles2, Stack } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { getTrailSettings } from '../utils'; |
||||||
|
|
||||||
|
import { AutoQueryDef, AutoQueryInfo } from './AutoQueryEngine'; |
||||||
|
|
||||||
|
export interface AutoVizPanelState extends SceneObjectState { |
||||||
|
panel?: VizPanel; |
||||||
|
autoQuery: AutoQueryInfo; |
||||||
|
queryDef?: AutoQueryDef; |
||||||
|
} |
||||||
|
|
||||||
|
export class AutoVizPanel extends SceneObjectBase<AutoVizPanelState> { |
||||||
|
constructor(state: AutoVizPanelState) { |
||||||
|
super(state); |
||||||
|
|
||||||
|
if (!state.panel) { |
||||||
|
this.setState({ |
||||||
|
panel: this.getVizPanelFor(state.autoQuery.main), |
||||||
|
queryDef: state.autoQuery.main, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private getQuerySelector(def: AutoQueryDef) { |
||||||
|
const variants = this.state.autoQuery.variants; |
||||||
|
|
||||||
|
if (variants.length === 0) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const options = variants.map((q) => ({ label: q.variant, value: q.variant })); |
||||||
|
|
||||||
|
return <RadioButtonGroup size="sm" options={options} value={def.variant} onChange={this.onChangeQuery} />; |
||||||
|
} |
||||||
|
|
||||||
|
public onChangeQuery = (variant: string) => { |
||||||
|
const def = this.state.autoQuery.variants.find((q) => q.variant === variant)!; |
||||||
|
|
||||||
|
this.setState({ |
||||||
|
panel: this.getVizPanelFor(def), |
||||||
|
queryDef: def, |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
private getVizPanelFor(def: AutoQueryDef) { |
||||||
|
return def.vizBuilder(def).setHeaderActions(this.getQuerySelector(def)).build(); |
||||||
|
} |
||||||
|
|
||||||
|
public static Component = ({ model }: SceneComponentProps<AutoVizPanel>) => { |
||||||
|
const { panel, queryDef } = model.useState(); |
||||||
|
const { showQuery } = getTrailSettings(model).useState(); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
if (!panel) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!showQuery) { |
||||||
|
return <panel.Component model={panel} />; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.wrapper}> |
||||||
|
<Stack gap={2}> |
||||||
|
<Field label="Query"> |
||||||
|
<div>{queryDef && queryDef.queries.map((query, index) => <div key={index}>{query.expr}</div>)}</div> |
||||||
|
</Field> |
||||||
|
</Stack> |
||||||
|
<div className={styles.panel}> |
||||||
|
<panel.Component model={panel} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
wrapper: css({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
flexGrow: 1, |
||||||
|
}), |
||||||
|
panel: css({ |
||||||
|
position: 'relative', |
||||||
|
flexGrow: 1, |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,374 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { DataFrame, GrafanaTheme2, SelectableValue } from '@grafana/data'; |
||||||
|
import { |
||||||
|
AdHocFiltersVariable, |
||||||
|
PanelBuilders, |
||||||
|
QueryVariable, |
||||||
|
SceneComponentProps, |
||||||
|
SceneCSSGridItem, |
||||||
|
SceneCSSGridLayout, |
||||||
|
SceneDataNode, |
||||||
|
SceneFlexItem, |
||||||
|
SceneFlexItemLike, |
||||||
|
SceneFlexLayout, |
||||||
|
sceneGraph, |
||||||
|
SceneObject, |
||||||
|
SceneObjectBase, |
||||||
|
SceneObjectState, |
||||||
|
SceneQueryRunner, |
||||||
|
SceneVariableSet, |
||||||
|
} from '@grafana/scenes'; |
||||||
|
import { Button, Field, RadioButtonGroup, useStyles2 } from '@grafana/ui'; |
||||||
|
import { ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; |
||||||
|
|
||||||
|
import { AddToFiltersGraphAction } from './AddToFiltersGraphAction'; |
||||||
|
import { AutoQueryDef, getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine'; |
||||||
|
import { ByFrameRepeater } from './ByFrameRepeater'; |
||||||
|
import { LayoutSwitcher } from './LayoutSwitcher'; |
||||||
|
import { MetricScene } from './MetricScene'; |
||||||
|
import { trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP, VAR_METRIC_EXPR } from './shared'; |
||||||
|
import { getColorByIndex } from './utils'; |
||||||
|
|
||||||
|
export interface BreakdownSceneState extends SceneObjectState { |
||||||
|
body?: SceneObject; |
||||||
|
labels: Array<SelectableValue<string>>; |
||||||
|
value?: string; |
||||||
|
loading?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Just a proof of concept example of a behavior |
||||||
|
*/ |
||||||
|
export class BreakdownScene extends SceneObjectBase<BreakdownSceneState> { |
||||||
|
constructor(state: Partial<BreakdownSceneState>) { |
||||||
|
super({ |
||||||
|
$variables: state.$variables ?? getVariableSet(), |
||||||
|
labels: state.labels ?? [], |
||||||
|
...state, |
||||||
|
}); |
||||||
|
|
||||||
|
this.addActivationHandler(this._onActivate.bind(this)); |
||||||
|
} |
||||||
|
|
||||||
|
private _query?: AutoQueryDef; |
||||||
|
|
||||||
|
private _onActivate() { |
||||||
|
const variable = this.getVariable(); |
||||||
|
|
||||||
|
variable.subscribeToState((newState, oldState) => { |
||||||
|
if ( |
||||||
|
newState.options !== oldState.options || |
||||||
|
newState.value !== oldState.value || |
||||||
|
newState.loading !== oldState.loading |
||||||
|
) { |
||||||
|
this.updateBody(variable); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
const metric = sceneGraph.getAncestor(this, MetricScene).state.metric; |
||||||
|
this._query = getAutoQueriesForMetric(metric).breakdown; |
||||||
|
|
||||||
|
this.updateBody(variable); |
||||||
|
} |
||||||
|
|
||||||
|
private getVariable(): QueryVariable { |
||||||
|
const variable = sceneGraph.lookupVariable(VAR_GROUP_BY, this)!; |
||||||
|
if (!(variable instanceof QueryVariable)) { |
||||||
|
throw new Error('Group by variable not found'); |
||||||
|
} |
||||||
|
|
||||||
|
return variable; |
||||||
|
} |
||||||
|
|
||||||
|
private updateBody(variable: QueryVariable) { |
||||||
|
const options = this.getLabelOptions(variable); |
||||||
|
|
||||||
|
const stateUpdate: Partial<BreakdownSceneState> = { |
||||||
|
loading: variable.state.loading, |
||||||
|
value: String(variable.state.value), |
||||||
|
labels: options, |
||||||
|
}; |
||||||
|
|
||||||
|
if (!this.state.body && !variable.state.loading) { |
||||||
|
stateUpdate.body = variable.hasAllValue() |
||||||
|
? buildAllLayout(options, this._query!) |
||||||
|
: buildNormalLayout(this._query!); |
||||||
|
} |
||||||
|
|
||||||
|
this.setState(stateUpdate); |
||||||
|
} |
||||||
|
|
||||||
|
private getLabelOptions(variable: QueryVariable) { |
||||||
|
const labelFilters = sceneGraph.lookupVariable(VAR_FILTERS, this); |
||||||
|
const labelOptions: Array<SelectableValue<string>> = []; |
||||||
|
|
||||||
|
if (!(labelFilters instanceof AdHocFiltersVariable)) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
const filters = labelFilters.state.set.state.filters; |
||||||
|
|
||||||
|
for (const option of variable.getOptionsForSelect()) { |
||||||
|
const filterExists = filters.find((f) => f.key === option.value); |
||||||
|
if (!filterExists) { |
||||||
|
labelOptions.push({ label: option.label, value: String(option.value) }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return labelOptions; |
||||||
|
} |
||||||
|
|
||||||
|
public onChange = (value: string) => { |
||||||
|
const variable = this.getVariable(); |
||||||
|
|
||||||
|
if (value === ALL_VARIABLE_VALUE) { |
||||||
|
this.setState({ body: buildAllLayout(this.getLabelOptions(variable), this._query!) }); |
||||||
|
} else if (variable.hasAllValue()) { |
||||||
|
this.setState({ body: buildNormalLayout(this._query!) }); |
||||||
|
} |
||||||
|
|
||||||
|
variable.changeValueTo(value); |
||||||
|
}; |
||||||
|
|
||||||
|
public static Component = ({ model }: SceneComponentProps<BreakdownScene>) => { |
||||||
|
const { labels, body, loading, value } = model.useState(); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.container}> |
||||||
|
{loading && <div>Loading...</div>} |
||||||
|
<div className={styles.controls}> |
||||||
|
<Field label="By label"> |
||||||
|
<RadioButtonGroup options={labels} value={value} onChange={model.onChange} /> |
||||||
|
</Field> |
||||||
|
{body instanceof LayoutSwitcher && ( |
||||||
|
<div className={styles.controlsRight}> |
||||||
|
<body.Selector model={body} /> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<div className={styles.content}>{body && <body.Component model={body} />}</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
container: css({ |
||||||
|
flexGrow: 1, |
||||||
|
display: 'flex', |
||||||
|
minHeight: '100%', |
||||||
|
flexDirection: 'column', |
||||||
|
}), |
||||||
|
content: css({ |
||||||
|
flexGrow: 1, |
||||||
|
display: 'flex', |
||||||
|
paddingTop: theme.spacing(0), |
||||||
|
}), |
||||||
|
tabHeading: css({ |
||||||
|
paddingRight: theme.spacing(2), |
||||||
|
fontWeight: theme.typography.fontWeightMedium, |
||||||
|
}), |
||||||
|
controls: css({ |
||||||
|
flexGrow: 0, |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'top', |
||||||
|
gap: theme.spacing(2), |
||||||
|
}), |
||||||
|
controlsRight: css({ |
||||||
|
flexGrow: 1, |
||||||
|
display: 'flex', |
||||||
|
justifyContent: 'flex-end', |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export function buildAllLayout(options: Array<SelectableValue<string>>, queryDef: AutoQueryDef) { |
||||||
|
const children: SceneFlexItemLike[] = []; |
||||||
|
|
||||||
|
for (const option of options) { |
||||||
|
if (option.value === ALL_VARIABLE_VALUE) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
const expr = queryDef.queries[0].expr.replace(VAR_GROUP_BY_EXP, String(option.value)); |
||||||
|
|
||||||
|
children.push( |
||||||
|
new SceneCSSGridItem({ |
||||||
|
body: PanelBuilders.timeseries() |
||||||
|
.setTitle(option.label!) |
||||||
|
.setUnit(queryDef.unit) |
||||||
|
.setData( |
||||||
|
new SceneQueryRunner({ |
||||||
|
maxDataPoints: 300, |
||||||
|
datasource: trailDS, |
||||||
|
queries: [ |
||||||
|
{ |
||||||
|
refId: 'A', |
||||||
|
expr: expr, |
||||||
|
legendFormat: `{{${option.label}}}`, |
||||||
|
}, |
||||||
|
], |
||||||
|
}) |
||||||
|
) |
||||||
|
.setHeaderActions(new SelectLabelAction({ labelName: String(option.value) })) |
||||||
|
.build(), |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return new LayoutSwitcher({ |
||||||
|
options: [ |
||||||
|
{ value: 'grid', label: 'Grid' }, |
||||||
|
{ value: 'rows', label: 'Rows' }, |
||||||
|
], |
||||||
|
active: 'grid', |
||||||
|
layouts: [ |
||||||
|
new SceneCSSGridLayout({ |
||||||
|
templateColumns: GRID_TEMPLATE_COLUMNS, |
||||||
|
autoRows: '200px', |
||||||
|
children: children, |
||||||
|
}), |
||||||
|
new SceneCSSGridLayout({ |
||||||
|
templateColumns: '1fr', |
||||||
|
autoRows: '200px', |
||||||
|
children: children, |
||||||
|
}), |
||||||
|
], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
const GRID_TEMPLATE_COLUMNS = 'repeat(auto-fit, minmax(400px, 1fr))'; |
||||||
|
|
||||||
|
function getVariableSet() { |
||||||
|
return new SceneVariableSet({ |
||||||
|
variables: [ |
||||||
|
new QueryVariable({ |
||||||
|
name: VAR_GROUP_BY, |
||||||
|
label: 'Group by', |
||||||
|
datasource: trailDS, |
||||||
|
includeAll: true, |
||||||
|
defaultToAll: true, |
||||||
|
query: { query: `label_names(${VAR_METRIC_EXPR})`, refId: 'A' }, |
||||||
|
value: '', |
||||||
|
text: '', |
||||||
|
}), |
||||||
|
], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function buildNormalLayout(queryDef: AutoQueryDef) { |
||||||
|
return new LayoutSwitcher({ |
||||||
|
$data: new SceneQueryRunner({ |
||||||
|
datasource: trailDS, |
||||||
|
maxDataPoints: 300, |
||||||
|
queries: queryDef.queries, |
||||||
|
}), |
||||||
|
options: [ |
||||||
|
{ value: 'single', label: 'Single' }, |
||||||
|
{ value: 'grid', label: 'Grid' }, |
||||||
|
{ value: 'rows', label: 'Rows' }, |
||||||
|
], |
||||||
|
active: 'grid', |
||||||
|
layouts: [ |
||||||
|
new SceneFlexLayout({ |
||||||
|
direction: 'column', |
||||||
|
children: [ |
||||||
|
new SceneFlexItem({ |
||||||
|
minHeight: 300, |
||||||
|
body: PanelBuilders.timeseries().setTitle('$metric').build(), |
||||||
|
}), |
||||||
|
], |
||||||
|
}), |
||||||
|
new ByFrameRepeater({ |
||||||
|
body: new SceneCSSGridLayout({ |
||||||
|
templateColumns: GRID_TEMPLATE_COLUMNS, |
||||||
|
autoRows: '200px', |
||||||
|
children: [], |
||||||
|
}), |
||||||
|
getLayoutChild: (data, frame, frameIndex) => { |
||||||
|
return new SceneCSSGridItem({ |
||||||
|
body: queryDef |
||||||
|
.vizBuilder(queryDef) |
||||||
|
.setTitle(getLabelValue(frame)) |
||||||
|
.setData(new SceneDataNode({ data: { ...data, series: [frame] } })) |
||||||
|
.setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) }) |
||||||
|
.setHeaderActions(new AddToFiltersGraphAction({ frame })) |
||||||
|
.build(), |
||||||
|
}); |
||||||
|
}, |
||||||
|
}), |
||||||
|
new ByFrameRepeater({ |
||||||
|
body: new SceneCSSGridLayout({ |
||||||
|
templateColumns: '1fr', |
||||||
|
autoRows: '200px', |
||||||
|
children: [], |
||||||
|
}), |
||||||
|
getLayoutChild: (data, frame, frameIndex) => { |
||||||
|
return new SceneCSSGridItem({ |
||||||
|
body: queryDef |
||||||
|
.vizBuilder(queryDef) |
||||||
|
.setTitle(getLabelValue(frame)) |
||||||
|
.setData(new SceneDataNode({ data: { ...data, series: [frame] } })) |
||||||
|
.setColor({ mode: 'fixed', fixedColor: getColorByIndex(frameIndex) }) |
||||||
|
.setHeaderActions(new AddToFiltersGraphAction({ frame })) |
||||||
|
.build(), |
||||||
|
}); |
||||||
|
}, |
||||||
|
}), |
||||||
|
], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function getLabelValue(frame: DataFrame) { |
||||||
|
const labels = frame.fields[1]?.labels; |
||||||
|
|
||||||
|
if (!labels) { |
||||||
|
return 'No labels'; |
||||||
|
} |
||||||
|
|
||||||
|
const keys = Object.keys(labels); |
||||||
|
if (keys.length === 0) { |
||||||
|
return 'No labels'; |
||||||
|
} |
||||||
|
|
||||||
|
return labels[keys[0]]; |
||||||
|
} |
||||||
|
|
||||||
|
export function buildBreakdownActionScene() { |
||||||
|
return new SceneFlexItem({ |
||||||
|
body: new BreakdownScene({}), |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
interface SelectLabelActionState extends SceneObjectState { |
||||||
|
labelName: string; |
||||||
|
} |
||||||
|
export class SelectLabelAction extends SceneObjectBase<SelectLabelActionState> { |
||||||
|
public onClick = () => { |
||||||
|
getBreakdownSceneFor(this).onChange(this.state.labelName); |
||||||
|
}; |
||||||
|
|
||||||
|
public static Component = ({ model }: SceneComponentProps<AddToFiltersGraphAction>) => { |
||||||
|
return ( |
||||||
|
<Button variant="primary" size="sm" fill="text" onClick={model.onClick}> |
||||||
|
Select |
||||||
|
</Button> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function getBreakdownSceneFor(model: SceneObject): BreakdownScene { |
||||||
|
if (model instanceof BreakdownScene) { |
||||||
|
return model; |
||||||
|
} |
||||||
|
|
||||||
|
if (model.parent) { |
||||||
|
return getBreakdownSceneFor(model.parent); |
||||||
|
} |
||||||
|
|
||||||
|
throw new Error('Unable to find breakdown scene'); |
||||||
|
} |
||||||
@ -0,0 +1,55 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { LoadingState, PanelData, DataFrame } from '@grafana/data'; |
||||||
|
import { |
||||||
|
SceneObjectState, |
||||||
|
SceneFlexItem, |
||||||
|
SceneObjectBase, |
||||||
|
sceneGraph, |
||||||
|
SceneComponentProps, |
||||||
|
SceneByFrameRepeater, |
||||||
|
SceneLayout, |
||||||
|
} from '@grafana/scenes'; |
||||||
|
|
||||||
|
interface ByFrameRepeaterState extends SceneObjectState { |
||||||
|
body: SceneLayout; |
||||||
|
getLayoutChild(data: PanelData, frame: DataFrame, frameIndex: number): SceneFlexItem; |
||||||
|
} |
||||||
|
|
||||||
|
export class ByFrameRepeater extends SceneObjectBase<ByFrameRepeaterState> { |
||||||
|
public constructor(state: ByFrameRepeaterState) { |
||||||
|
super(state); |
||||||
|
|
||||||
|
this.addActivationHandler(() => { |
||||||
|
const data = sceneGraph.getData(this); |
||||||
|
|
||||||
|
this._subs.add( |
||||||
|
data.subscribeToState((data) => { |
||||||
|
if (data.data?.state === LoadingState.Done) { |
||||||
|
this.performRepeat(data.data); |
||||||
|
} |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
if (data.state.data) { |
||||||
|
this.performRepeat(data.state.data); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
private performRepeat(data: PanelData) { |
||||||
|
const newChildren: SceneFlexItem[] = []; |
||||||
|
|
||||||
|
for (let seriesIndex = 0; seriesIndex < data.series.length; seriesIndex++) { |
||||||
|
const layoutChild = this.state.getLayoutChild(data, data.series[seriesIndex], seriesIndex); |
||||||
|
newChildren.push(layoutChild); |
||||||
|
} |
||||||
|
|
||||||
|
this.state.body.setState({ children: newChildren }); |
||||||
|
} |
||||||
|
|
||||||
|
public static Component = ({ model }: SceneComponentProps<SceneByFrameRepeater>) => { |
||||||
|
const { body } = model.useState(); |
||||||
|
return <body.Component model={body} />; |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,205 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { AdHocVariableFilter, GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { locationService } from '@grafana/runtime'; |
||||||
|
import { |
||||||
|
AdHocFiltersVariable, |
||||||
|
DataSourceVariable, |
||||||
|
getUrlSyncManager, |
||||||
|
SceneComponentProps, |
||||||
|
SceneControlsSpacer, |
||||||
|
SceneObject, |
||||||
|
SceneObjectBase, |
||||||
|
SceneObjectState, |
||||||
|
SceneObjectUrlSyncConfig, |
||||||
|
SceneObjectUrlValues, |
||||||
|
SceneRefreshPicker, |
||||||
|
SceneTimePicker, |
||||||
|
SceneTimeRange, |
||||||
|
SceneVariableSet, |
||||||
|
VariableValueSelectors, |
||||||
|
} from '@grafana/scenes'; |
||||||
|
import { useStyles2 } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { DataTrailSettings } from './DataTrailSettings'; |
||||||
|
import { DataTrailHistory, DataTrailHistoryStep } from './DataTrailsHistory'; |
||||||
|
import { MetricScene } from './MetricScene'; |
||||||
|
import { MetricSelectScene } from './MetricSelectScene'; |
||||||
|
import { MetricSelectedEvent, trailDS, LOGS_METRIC, VAR_DATASOURCE } from './shared'; |
||||||
|
import { getUrlForTrail } from './utils'; |
||||||
|
|
||||||
|
export interface DataTrailState extends SceneObjectState { |
||||||
|
topScene?: SceneObject; |
||||||
|
embedded?: boolean; |
||||||
|
controls: SceneObject[]; |
||||||
|
history: DataTrailHistory; |
||||||
|
settings: DataTrailSettings; |
||||||
|
|
||||||
|
// just for for the starting data source
|
||||||
|
initialDS?: string; |
||||||
|
initialFilters?: AdHocVariableFilter[]; |
||||||
|
|
||||||
|
// Synced with url
|
||||||
|
metric?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export class DataTrail extends SceneObjectBase<DataTrailState> { |
||||||
|
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['metric'] }); |
||||||
|
|
||||||
|
public constructor(state: Partial<DataTrailState>) { |
||||||
|
super({ |
||||||
|
$timeRange: state.$timeRange ?? new SceneTimeRange({}), |
||||||
|
$variables: state.$variables ?? getVariableSet(state.initialDS, state.metric, state.initialFilters), |
||||||
|
controls: state.controls ?? [ |
||||||
|
new VariableValueSelectors({ layout: 'vertical' }), |
||||||
|
new SceneControlsSpacer(), |
||||||
|
new SceneTimePicker({}), |
||||||
|
new SceneRefreshPicker({}), |
||||||
|
], |
||||||
|
history: state.history ?? new DataTrailHistory({}), |
||||||
|
settings: state.settings ?? new DataTrailSettings({}), |
||||||
|
...state, |
||||||
|
}); |
||||||
|
|
||||||
|
this.addActivationHandler(this._onActivate.bind(this)); |
||||||
|
} |
||||||
|
|
||||||
|
public _onActivate() { |
||||||
|
if (!this.state.topScene) { |
||||||
|
this.setState({ topScene: getTopSceneFor(this.state.metric) }); |
||||||
|
} |
||||||
|
|
||||||
|
// Some scene elements publish this
|
||||||
|
this.subscribeToEvent(MetricSelectedEvent, this._handleMetricSelectedEvent.bind(this)); |
||||||
|
|
||||||
|
return () => { |
||||||
|
if (!this.state.embedded) { |
||||||
|
getUrlSyncManager().cleanUp(this); |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
public goBackToStep(step: DataTrailHistoryStep) { |
||||||
|
if (!this.state.embedded) { |
||||||
|
getUrlSyncManager().cleanUp(this); |
||||||
|
} |
||||||
|
|
||||||
|
if (!step.trailState.metric) { |
||||||
|
step.trailState.metric = undefined; |
||||||
|
} |
||||||
|
|
||||||
|
this.setState(step.trailState); |
||||||
|
|
||||||
|
if (!this.state.embedded) { |
||||||
|
locationService.replace(getUrlForTrail(this)); |
||||||
|
|
||||||
|
getUrlSyncManager().initSync(this); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private _handleMetricSelectedEvent(evt: MetricSelectedEvent) { |
||||||
|
if (this.state.embedded) { |
||||||
|
this.setState(this.getSceneUpdatesForNewMetricValue(evt.payload)); |
||||||
|
} else { |
||||||
|
locationService.partial({ metric: evt.payload, actionView: null }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private getSceneUpdatesForNewMetricValue(metric: string | undefined) { |
||||||
|
const stateUpdate: Partial<DataTrailState> = {}; |
||||||
|
stateUpdate.metric = metric; |
||||||
|
stateUpdate.topScene = getTopSceneFor(metric); |
||||||
|
return stateUpdate; |
||||||
|
} |
||||||
|
|
||||||
|
getUrlState() { |
||||||
|
return { metric: this.state.metric }; |
||||||
|
} |
||||||
|
|
||||||
|
updateFromUrl(values: SceneObjectUrlValues) { |
||||||
|
const stateUpdate: Partial<DataTrailState> = {}; |
||||||
|
|
||||||
|
if (typeof values.metric === 'string') { |
||||||
|
if (this.state.metric !== values.metric) { |
||||||
|
Object.assign(stateUpdate, this.getSceneUpdatesForNewMetricValue(values.metric)); |
||||||
|
} |
||||||
|
} else if (values.metric === null) { |
||||||
|
stateUpdate.metric = undefined; |
||||||
|
stateUpdate.topScene = new MetricSelectScene({ showHeading: true }); |
||||||
|
} |
||||||
|
|
||||||
|
this.setState(stateUpdate); |
||||||
|
} |
||||||
|
|
||||||
|
static Component = ({ model }: SceneComponentProps<DataTrail>) => { |
||||||
|
const { controls, topScene, history, settings } = model.useState(); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.container}> |
||||||
|
<history.Component model={history} /> |
||||||
|
{controls && ( |
||||||
|
<div className={styles.controls}> |
||||||
|
{controls.map((control) => ( |
||||||
|
<control.Component key={control.state.key} model={control} /> |
||||||
|
))} |
||||||
|
<settings.Component model={settings} /> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<div className={styles.body}>{topScene && <topScene.Component model={topScene} />}</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function getTopSceneFor(metric?: string) { |
||||||
|
if (metric) { |
||||||
|
return new MetricScene({ metric: metric }); |
||||||
|
} else { |
||||||
|
return new MetricSelectScene({ showHeading: true }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getVariableSet(initialDS?: string, metric?: string, initialFilters?: AdHocVariableFilter[]) { |
||||||
|
return new SceneVariableSet({ |
||||||
|
variables: [ |
||||||
|
new DataSourceVariable({ |
||||||
|
name: VAR_DATASOURCE, |
||||||
|
label: 'Data source', |
||||||
|
value: initialDS, |
||||||
|
pluginId: metric === LOGS_METRIC ? 'loki' : 'prometheus', |
||||||
|
}), |
||||||
|
AdHocFiltersVariable.create({ |
||||||
|
name: 'filters', |
||||||
|
datasource: trailDS, |
||||||
|
layout: 'vertical', |
||||||
|
filters: initialFilters ?? [], |
||||||
|
}), |
||||||
|
], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
container: css({ |
||||||
|
flexGrow: 1, |
||||||
|
display: 'flex', |
||||||
|
gap: theme.spacing(2), |
||||||
|
minHeight: '100%', |
||||||
|
flexDirection: 'column', |
||||||
|
}), |
||||||
|
body: css({ |
||||||
|
flexGrow: 1, |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: theme.spacing(1), |
||||||
|
}), |
||||||
|
controls: css({ |
||||||
|
display: 'flex', |
||||||
|
gap: theme.spacing(2), |
||||||
|
alignItems: 'flex-end', |
||||||
|
flexWrap: 'wrap', |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,98 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes'; |
||||||
|
import { useStyles2, Stack } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { DataTrail } from './DataTrail'; |
||||||
|
import { LOGS_METRIC, VAR_DATASOURCE_EXPR, VAR_FILTERS } from './shared'; |
||||||
|
|
||||||
|
export interface Props { |
||||||
|
trail: DataTrail; |
||||||
|
onSelect: (trail: DataTrail) => void; |
||||||
|
} |
||||||
|
|
||||||
|
export function DataTrailCard({ trail, onSelect }: Props) { |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, trail)!; |
||||||
|
if (!(filtersVariable instanceof AdHocFiltersVariable)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const filters = filtersVariable.state.set.state.filters; |
||||||
|
const dsValue = getDataSource(trail); |
||||||
|
|
||||||
|
return ( |
||||||
|
<button className={styles.container} onClick={() => onSelect(trail)}> |
||||||
|
<div className={styles.heading}>{getMetricName(trail.state.metric)}</div> |
||||||
|
<Stack gap={1.5}> |
||||||
|
{dsValue && ( |
||||||
|
<Stack direction="column" gap={0.5}> |
||||||
|
<div className={styles.label}>Datasource</div> |
||||||
|
<div className={styles.value}>{getDataSource(trail)}</div> |
||||||
|
</Stack> |
||||||
|
)} |
||||||
|
{filters.map((filter, index) => ( |
||||||
|
<Stack key={index} direction="column" gap={0.5}> |
||||||
|
<div className={styles.label}>{filter.key}</div> |
||||||
|
<div className={styles.value}>{filter.value}</div> |
||||||
|
</Stack> |
||||||
|
))} |
||||||
|
</Stack> |
||||||
|
</button> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function getMetricName(metric?: string) { |
||||||
|
if (!metric) { |
||||||
|
return 'Select metric'; |
||||||
|
} |
||||||
|
|
||||||
|
if (metric === LOGS_METRIC) { |
||||||
|
return 'Logs'; |
||||||
|
} |
||||||
|
|
||||||
|
return metric; |
||||||
|
} |
||||||
|
|
||||||
|
function getDataSource(trail: DataTrail) { |
||||||
|
return sceneGraph.interpolate(trail, VAR_DATASOURCE_EXPR); |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
container: css({ |
||||||
|
padding: theme.spacing(1), |
||||||
|
flexGrow: 1, |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: theme.spacing(2), |
||||||
|
border: `1px solid ${theme.colors.border.weak}`, |
||||||
|
borderRadius: theme.shape.radius.default, |
||||||
|
cursor: 'pointer', |
||||||
|
boxShadow: 'none', |
||||||
|
background: 'transparent', |
||||||
|
textAlign: 'left', |
||||||
|
'&:hover': { |
||||||
|
background: theme.colors.emphasize(theme.colors.background.primary, 0.03), |
||||||
|
}, |
||||||
|
}), |
||||||
|
label: css({ |
||||||
|
fontWeight: theme.typography.fontWeightMedium, |
||||||
|
fontSize: theme.typography.bodySmall.fontSize, |
||||||
|
}), |
||||||
|
value: css({ |
||||||
|
fontSize: theme.typography.bodySmall.fontSize, |
||||||
|
}), |
||||||
|
heading: css({ |
||||||
|
padding: theme.spacing(0), |
||||||
|
display: 'flex', |
||||||
|
fontWeight: theme.typography.fontWeightMedium, |
||||||
|
}), |
||||||
|
body: css({ |
||||||
|
padding: theme.spacing(0), |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,67 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { getDataSourceSrv } from '@grafana/runtime'; |
||||||
|
import { SceneComponentProps, SceneObjectBase, SceneObjectState, SceneTimeRangeLike } from '@grafana/scenes'; |
||||||
|
import { DataSourceRef } from '@grafana/schema'; |
||||||
|
import { Drawer } from '@grafana/ui'; |
||||||
|
import { PromVisualQuery } from 'app/plugins/datasource/prometheus/querybuilder/types'; |
||||||
|
|
||||||
|
import { getDashboardSceneFor } from '../dashboard-scene/utils/utils'; |
||||||
|
|
||||||
|
import { DataTrail } from './DataTrail'; |
||||||
|
import { getDataTrailsApp } from './DataTrailsApp'; |
||||||
|
import { OpenEmbeddedTrailEvent } from './shared'; |
||||||
|
|
||||||
|
interface DataTrailDrawerState extends SceneObjectState { |
||||||
|
timeRange: SceneTimeRangeLike; |
||||||
|
query: PromVisualQuery; |
||||||
|
dsRef: DataSourceRef; |
||||||
|
} |
||||||
|
|
||||||
|
export class DataTrailDrawer extends SceneObjectBase<DataTrailDrawerState> { |
||||||
|
static Component = DataTrailDrawerRenderer; |
||||||
|
|
||||||
|
public trail: DataTrail; |
||||||
|
|
||||||
|
constructor(state: DataTrailDrawerState) { |
||||||
|
super(state); |
||||||
|
|
||||||
|
this.trail = buildDataTrailFromQuery(state); |
||||||
|
this.trail.addActivationHandler(() => { |
||||||
|
this.trail.subscribeToEvent(OpenEmbeddedTrailEvent, this.onOpenTrail); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
onOpenTrail = () => { |
||||||
|
getDataTrailsApp().goToUrlForTrail(this.trail.clone({ embedded: false })); |
||||||
|
}; |
||||||
|
|
||||||
|
onClose = () => { |
||||||
|
const dashboard = getDashboardSceneFor(this); |
||||||
|
dashboard.closeModal(); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function DataTrailDrawerRenderer({ model }: SceneComponentProps<DataTrailDrawer>) { |
||||||
|
return ( |
||||||
|
<Drawer title={'Data trail'} onClose={model.onClose} size="lg"> |
||||||
|
<div style={{ display: 'flex', height: '100%' }}> |
||||||
|
<model.trail.Component model={model.trail} /> |
||||||
|
</div> |
||||||
|
</Drawer> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function buildDataTrailFromQuery({ query, dsRef, timeRange }: DataTrailDrawerState) { |
||||||
|
const filters = query.labels.map((label) => ({ key: label.label, value: label.value, operator: label.op })); |
||||||
|
|
||||||
|
const ds = getDataSourceSrv().getInstanceSettings(dsRef); |
||||||
|
|
||||||
|
return new DataTrail({ |
||||||
|
$timeRange: timeRange, |
||||||
|
metric: query.metric, |
||||||
|
initialDS: ds?.name, |
||||||
|
initialFilters: filters, |
||||||
|
embedded: true, |
||||||
|
}); |
||||||
|
} |
||||||
@ -0,0 +1,93 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; |
||||||
|
import { Dropdown, Switch, ToolbarButton, useStyles2 } from '@grafana/ui'; |
||||||
|
|
||||||
|
export interface DataTrailSettingsState extends SceneObjectState { |
||||||
|
showQuery?: boolean; |
||||||
|
showAdvanced?: boolean; |
||||||
|
multiValueVars?: boolean; |
||||||
|
isOpen?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export class DataTrailSettings extends SceneObjectBase<DataTrailSettingsState> { |
||||||
|
constructor(state: Partial<DataTrailSettingsState>) { |
||||||
|
super({ |
||||||
|
showQuery: state.showQuery ?? false, |
||||||
|
showAdvanced: state.showAdvanced ?? false, |
||||||
|
isOpen: state.isOpen ?? false, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public onToggleShowQuery = () => { |
||||||
|
this.setState({ showQuery: !this.state.showQuery }); |
||||||
|
}; |
||||||
|
|
||||||
|
public onToggleAdvanced = () => { |
||||||
|
this.setState({ showAdvanced: !this.state.showAdvanced }); |
||||||
|
}; |
||||||
|
|
||||||
|
public onToggleMultiValue = () => { |
||||||
|
this.setState({ multiValueVars: !this.state.multiValueVars }); |
||||||
|
}; |
||||||
|
|
||||||
|
public onToggleOpen = (isOpen: boolean) => { |
||||||
|
this.setState({ isOpen }); |
||||||
|
}; |
||||||
|
|
||||||
|
static Component = ({ model }: SceneComponentProps<DataTrailSettings>) => { |
||||||
|
const { showQuery, showAdvanced, multiValueVars, isOpen } = model.useState(); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
const renderPopover = () => { |
||||||
|
return ( |
||||||
|
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ |
||||||
|
<div className={styles.popover} onClick={(evt) => evt.stopPropagation()}> |
||||||
|
<div className={styles.heading}>Settings</div> |
||||||
|
<div className={styles.options}> |
||||||
|
<div>Multi value variables</div> |
||||||
|
<Switch value={multiValueVars} onChange={model.onToggleMultiValue} /> |
||||||
|
<div>Advanced options</div> |
||||||
|
<Switch value={showAdvanced} onChange={model.onToggleAdvanced} /> |
||||||
|
<div>Show query</div> |
||||||
|
<Switch value={showQuery} onChange={model.onToggleShowQuery} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<Dropdown overlay={renderPopover} placement="bottom" onVisibleChange={model.onToggleOpen}> |
||||||
|
<ToolbarButton icon="cog" variant="canvas" isOpen={isOpen} /> |
||||||
|
</Dropdown> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
popover: css({ |
||||||
|
display: 'flex', |
||||||
|
padding: theme.spacing(2), |
||||||
|
flexDirection: 'column', |
||||||
|
background: theme.colors.background.primary, |
||||||
|
boxShadow: theme.shadows.z3, |
||||||
|
borderRadius: theme.shape.borderRadius(), |
||||||
|
border: `1px solid ${theme.colors.border.weak}`, |
||||||
|
zIndex: 1, |
||||||
|
marginRight: theme.spacing(2), |
||||||
|
}), |
||||||
|
heading: css({ |
||||||
|
fontWeight: theme.typography.fontWeightMedium, |
||||||
|
paddingBottom: theme.spacing(2), |
||||||
|
}), |
||||||
|
options: css({ |
||||||
|
display: 'grid', |
||||||
|
gridTemplateColumns: '1fr 50px', |
||||||
|
rowGap: theme.spacing(1), |
||||||
|
columnGap: theme.spacing(2), |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,106 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React, { useEffect } from 'react'; |
||||||
|
import { Route, Switch } from 'react-router-dom'; |
||||||
|
|
||||||
|
import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; |
||||||
|
import { locationService } from '@grafana/runtime'; |
||||||
|
import { getUrlSyncManager, SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; |
||||||
|
import { useStyles2 } from '@grafana/ui'; |
||||||
|
import { Page } from 'app/core/components/Page/Page'; |
||||||
|
|
||||||
|
import { DataTrail } from './DataTrail'; |
||||||
|
import { DataTrailsHome } from './DataTrailsHome'; |
||||||
|
import { getUrlForTrail, newMetricsTrail } from './utils'; |
||||||
|
|
||||||
|
export interface DataTrailsAppState extends SceneObjectState { |
||||||
|
trail: DataTrail; |
||||||
|
home: DataTrailsHome; |
||||||
|
} |
||||||
|
|
||||||
|
export class DataTrailsApp extends SceneObjectBase<DataTrailsAppState> { |
||||||
|
public constructor(state: DataTrailsAppState) { |
||||||
|
super(state); |
||||||
|
} |
||||||
|
|
||||||
|
goToUrlForTrail(trail: DataTrail) { |
||||||
|
this.setState({ trail }); |
||||||
|
locationService.push(getUrlForTrail(trail)); |
||||||
|
} |
||||||
|
|
||||||
|
static Component = ({ model }: SceneComponentProps<DataTrailsApp>) => { |
||||||
|
const { trail, home } = model.useState(); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Switch> |
||||||
|
<Route |
||||||
|
exact={true} |
||||||
|
path="/data-trails" |
||||||
|
render={() => ( |
||||||
|
<Page navId="data-trails" layout={PageLayoutType.Custom}> |
||||||
|
<div className={styles.customPage}> |
||||||
|
<home.Component model={home} /> |
||||||
|
</div> |
||||||
|
</Page> |
||||||
|
)} |
||||||
|
/> |
||||||
|
<Route |
||||||
|
exact={true} |
||||||
|
path="/data-trails/trail" |
||||||
|
render={() => ( |
||||||
|
<Page navId="data-trails" pageNav={{ text: 'Trail' }} layout={PageLayoutType.Custom}> |
||||||
|
<div className={styles.customPage}> |
||||||
|
<DataTrailView trail={trail} /> |
||||||
|
</div> |
||||||
|
</Page> |
||||||
|
)} |
||||||
|
/> |
||||||
|
</Switch> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function DataTrailView({ trail }: { trail: DataTrail }) { |
||||||
|
const [isInitialized, setIsInitialized] = React.useState(false); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!isInitialized) { |
||||||
|
getUrlSyncManager().initSync(trail); |
||||||
|
setIsInitialized(true); |
||||||
|
} |
||||||
|
}, [trail, isInitialized]); |
||||||
|
|
||||||
|
if (!isInitialized) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return <trail.Component model={trail} />; |
||||||
|
} |
||||||
|
|
||||||
|
let dataTrailsApp: DataTrailsApp; |
||||||
|
|
||||||
|
export function getDataTrailsApp() { |
||||||
|
if (!dataTrailsApp) { |
||||||
|
dataTrailsApp = new DataTrailsApp({ |
||||||
|
trail: newMetricsTrail(), |
||||||
|
home: new DataTrailsHome({ |
||||||
|
recent: [], |
||||||
|
bookmarks: [], |
||||||
|
}), |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return dataTrailsApp; |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
customPage: css({ |
||||||
|
padding: theme.spacing(2, 3, 2, 3), |
||||||
|
background: theme.isLight ? theme.colors.background.primary : theme.colors.background.canvas, |
||||||
|
flexGrow: 1, |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,183 @@ |
|||||||
|
import { css, cx } from '@emotion/css'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { |
||||||
|
SceneObjectState, |
||||||
|
SceneObjectBase, |
||||||
|
SceneComponentProps, |
||||||
|
sceneUtils, |
||||||
|
SceneVariableValueChangedEvent, |
||||||
|
SceneObjectStateChangedEvent, |
||||||
|
SceneTimeRange, |
||||||
|
} from '@grafana/scenes'; |
||||||
|
import { useStyles2, Tooltip, Stack } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { DataTrail, DataTrailState } from './DataTrail'; |
||||||
|
import { VAR_FILTERS } from './shared'; |
||||||
|
import { getTrailFor } from './utils'; |
||||||
|
|
||||||
|
export interface DataTrailsHistoryState extends SceneObjectState { |
||||||
|
steps: DataTrailHistoryStep[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface DataTrailHistoryStep { |
||||||
|
description: string; |
||||||
|
type: TrailStepType; |
||||||
|
trailState: DataTrailState; |
||||||
|
} |
||||||
|
|
||||||
|
export type TrailStepType = 'filters' | 'time' | 'metric' | 'start'; |
||||||
|
|
||||||
|
export class DataTrailHistory extends SceneObjectBase<DataTrailsHistoryState> { |
||||||
|
public constructor(state: Partial<DataTrailsHistoryState>) { |
||||||
|
super({ steps: state.steps ?? [] }); |
||||||
|
|
||||||
|
this.addActivationHandler(this._onActivate.bind(this)); |
||||||
|
} |
||||||
|
|
||||||
|
public _onActivate() { |
||||||
|
const trail = getTrailFor(this); |
||||||
|
|
||||||
|
if (this.state.steps.length === 0) { |
||||||
|
this.addTrailStep(trail, 'start'); |
||||||
|
} |
||||||
|
|
||||||
|
trail.subscribeToState((newState, oldState) => { |
||||||
|
if (newState.metric !== oldState.metric) { |
||||||
|
if (this.state.steps.length === 1) { |
||||||
|
// For the first step we want to update the starting state so that it contains data
|
||||||
|
this.state.steps[0].trailState = sceneUtils.cloneSceneObjectState(oldState, { history: this }); |
||||||
|
} |
||||||
|
|
||||||
|
if (newState.metric) { |
||||||
|
this.addTrailStep(trail, 'metric'); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
trail.subscribeToEvent(SceneVariableValueChangedEvent, (evt) => { |
||||||
|
if (evt.payload.state.name === VAR_FILTERS) { |
||||||
|
this.addTrailStep(trail, 'filters'); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
trail.subscribeToEvent(SceneObjectStateChangedEvent, (evt) => { |
||||||
|
if (evt.payload.changedObject instanceof SceneTimeRange) { |
||||||
|
this.addTrailStep(trail, 'time'); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
public addTrailStep(trail: DataTrail, type: TrailStepType) { |
||||||
|
this.setState({ |
||||||
|
steps: [ |
||||||
|
...this.state.steps, |
||||||
|
{ |
||||||
|
description: 'Test', |
||||||
|
type, |
||||||
|
trailState: sceneUtils.cloneSceneObjectState(trail.state, { history: this }), |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
renderStepTooltip(step: DataTrailHistoryStep) { |
||||||
|
return ( |
||||||
|
<Stack direction="column"> |
||||||
|
<div>{step.type}</div> |
||||||
|
{step.type === 'metric' && <div>{step.trailState.metric}</div>} |
||||||
|
</Stack> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
public static Component = ({ model }: SceneComponentProps<DataTrailHistory>) => { |
||||||
|
const { steps } = model.useState(); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
const trail = getTrailFor(model); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.container}> |
||||||
|
<div className={styles.heading}>Trail</div> |
||||||
|
{steps.map((step, index) => ( |
||||||
|
<Tooltip content={() => model.renderStepTooltip(step)} key={index}> |
||||||
|
<button |
||||||
|
className={cx(styles.step, styles.stepTypes[step.type])} |
||||||
|
onClick={() => trail.goBackToStep(step)} |
||||||
|
></button> |
||||||
|
</Tooltip> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
const visTheme = theme.visualization; |
||||||
|
|
||||||
|
return { |
||||||
|
container: css({ |
||||||
|
display: 'flex', |
||||||
|
gap: 10, |
||||||
|
alignItems: 'center', |
||||||
|
}), |
||||||
|
heading: css({}), |
||||||
|
step: css({ |
||||||
|
flexGrow: 0, |
||||||
|
cursor: 'pointer', |
||||||
|
border: 'none', |
||||||
|
boxShadow: 'none', |
||||||
|
padding: 0, |
||||||
|
margin: 0, |
||||||
|
width: 8, |
||||||
|
height: 8, |
||||||
|
opacity: 0.7, |
||||||
|
borderRadius: theme.shape.radius.circle, |
||||||
|
background: theme.colors.primary.main, |
||||||
|
position: 'relative', |
||||||
|
'&:hover': { |
||||||
|
transform: 'scale(1.1)', |
||||||
|
}, |
||||||
|
'&:after': { |
||||||
|
content: '""', |
||||||
|
position: 'absolute', |
||||||
|
width: 10, |
||||||
|
height: 2, |
||||||
|
left: 8, |
||||||
|
top: 3, |
||||||
|
background: theme.colors.primary.border, |
||||||
|
}, |
||||||
|
'&:last-child': { |
||||||
|
'&:after': { |
||||||
|
display: 'none', |
||||||
|
}, |
||||||
|
}, |
||||||
|
}), |
||||||
|
stepTypes: { |
||||||
|
start: css({ |
||||||
|
background: visTheme.getColorByName('green'), |
||||||
|
'&:after': { |
||||||
|
background: visTheme.getColorByName('green'), |
||||||
|
}, |
||||||
|
}), |
||||||
|
filters: css({ |
||||||
|
background: visTheme.getColorByName('purple'), |
||||||
|
'&:after': { |
||||||
|
background: visTheme.getColorByName('purple'), |
||||||
|
}, |
||||||
|
}), |
||||||
|
metric: css({ |
||||||
|
background: visTheme.getColorByName('orange'), |
||||||
|
'&:after': { |
||||||
|
background: visTheme.getColorByName('orange'), |
||||||
|
}, |
||||||
|
}), |
||||||
|
time: css({ |
||||||
|
background: theme.colors.primary.main, |
||||||
|
'&:after': { |
||||||
|
background: theme.colors.primary.main, |
||||||
|
}, |
||||||
|
}), |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,123 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { |
||||||
|
SceneComponentProps, |
||||||
|
sceneGraph, |
||||||
|
SceneObject, |
||||||
|
SceneObjectBase, |
||||||
|
SceneObjectRef, |
||||||
|
SceneObjectState, |
||||||
|
} from '@grafana/scenes'; |
||||||
|
import { Button, useStyles2, Stack } from '@grafana/ui'; |
||||||
|
import { Text } from '@grafana/ui/src/components/Text/Text'; |
||||||
|
|
||||||
|
import { DataTrail } from './DataTrail'; |
||||||
|
import { DataTrailCard } from './DataTrailCard'; |
||||||
|
import { DataTrailsApp } from './DataTrailsApp'; |
||||||
|
import { newMetricsTrail } from './utils'; |
||||||
|
|
||||||
|
export interface DataTrailsHomeState extends SceneObjectState { |
||||||
|
recent: Array<SceneObjectRef<DataTrail>>; |
||||||
|
bookmarks: Array<SceneObjectRef<DataTrail>>; |
||||||
|
} |
||||||
|
|
||||||
|
export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> { |
||||||
|
public constructor(state: DataTrailsHomeState) { |
||||||
|
super(state); |
||||||
|
} |
||||||
|
|
||||||
|
public onNewMetricsTrail = () => { |
||||||
|
const app = getAppFor(this); |
||||||
|
const trail = newMetricsTrail(); |
||||||
|
|
||||||
|
this.setState({ recent: [app.state.trail.getRef(), ...this.state.recent] }); |
||||||
|
app.goToUrlForTrail(trail); |
||||||
|
}; |
||||||
|
|
||||||
|
public onSelectTrail = (trail: DataTrail) => { |
||||||
|
const app = getAppFor(this); |
||||||
|
|
||||||
|
const currentTrail = app.state.trail; |
||||||
|
const existsInRecent = this.state.recent.find((t) => t.resolve() === currentTrail); |
||||||
|
|
||||||
|
if (!existsInRecent) { |
||||||
|
this.setState({ recent: [currentTrail.getRef(), ...this.state.recent] }); |
||||||
|
} |
||||||
|
|
||||||
|
app.goToUrlForTrail(trail); |
||||||
|
}; |
||||||
|
|
||||||
|
static Component = ({ model }: SceneComponentProps<DataTrailsHome>) => { |
||||||
|
const { recent, bookmarks } = model.useState(); |
||||||
|
const app = getAppFor(model); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.container}> |
||||||
|
<Stack direction="column" gap={1}> |
||||||
|
<Text variant="h2">Data trails</Text> |
||||||
|
<Text color="secondary">Automatically query, explore and navigate your observability data</Text> |
||||||
|
</Stack> |
||||||
|
<Stack gap={2}> |
||||||
|
<Button icon="plus" size="lg" variant="secondary" onClick={model.onNewMetricsTrail}> |
||||||
|
New metric trail |
||||||
|
</Button> |
||||||
|
</Stack> |
||||||
|
<Stack gap={4}> |
||||||
|
<div className={styles.column}> |
||||||
|
<Text variant="h4">Recent trails</Text> |
||||||
|
<div className={styles.trailList}> |
||||||
|
{app.state.trail.state.metric && <DataTrailCard trail={app.state.trail} onSelect={model.onSelectTrail} />} |
||||||
|
{recent.map((trail, index) => ( |
||||||
|
<DataTrailCard key={index} trail={trail.resolve()} onSelect={model.onSelectTrail} /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className={styles.column}> |
||||||
|
<Text variant="h4">Bookmarks</Text> |
||||||
|
<div className={styles.trailList}> |
||||||
|
{bookmarks.map((trail, index) => ( |
||||||
|
<DataTrailCard key={index} trail={trail.resolve()} onSelect={model.onSelectTrail} /> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Stack> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function getAppFor(model: SceneObject) { |
||||||
|
return sceneGraph.getAncestor(model, DataTrailsApp); |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
container: css({ |
||||||
|
padding: theme.spacing(2), |
||||||
|
flexGrow: 1, |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: theme.spacing(3), |
||||||
|
}), |
||||||
|
column: css({ |
||||||
|
width: 500, |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: theme.spacing(2), |
||||||
|
}), |
||||||
|
newTrail: css({ |
||||||
|
height: 'auto', |
||||||
|
justifyContent: 'center', |
||||||
|
fontSize: theme.typography.h5.fontSize, |
||||||
|
}), |
||||||
|
trailCard: css({}), |
||||||
|
trailList: css({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
gap: theme.spacing(2), |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
// Libraries
|
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { getDataTrailsApp } from './DataTrailsApp'; |
||||||
|
|
||||||
|
export function DataTrailsPage() { |
||||||
|
const app = getDataTrailsApp(); |
||||||
|
return <app.Component model={app} />; |
||||||
|
} |
||||||
|
|
||||||
|
export default DataTrailsPage; |
||||||
@ -0,0 +1,42 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { SelectableValue } from '@grafana/data'; |
||||||
|
import { SceneComponentProps, SceneObject, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; |
||||||
|
import { Field, RadioButtonGroup } from '@grafana/ui'; |
||||||
|
|
||||||
|
export interface LayoutSwitcherState extends SceneObjectState { |
||||||
|
active: LayoutType; |
||||||
|
layouts: SceneObject[]; |
||||||
|
options: Array<SelectableValue<LayoutType>>; |
||||||
|
} |
||||||
|
|
||||||
|
export type LayoutType = 'single' | 'grid' | 'rows'; |
||||||
|
|
||||||
|
export class LayoutSwitcher extends SceneObjectBase<LayoutSwitcherState> { |
||||||
|
public Selector({ model }: { model: LayoutSwitcher }) { |
||||||
|
const { active, options } = model.useState(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Field label="View"> |
||||||
|
<RadioButtonGroup options={options} value={active} onChange={model.onLayoutChange} /> |
||||||
|
</Field> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
public onLayoutChange = (active: LayoutType) => { |
||||||
|
this.setState({ active }); |
||||||
|
}; |
||||||
|
|
||||||
|
public static Component = ({ model }: SceneComponentProps<LayoutSwitcher>) => { |
||||||
|
const { layouts, options, active } = model.useState(); |
||||||
|
|
||||||
|
const index = options.findIndex((o) => o.value === active); |
||||||
|
if (index === -1) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const layout = layouts[index]; |
||||||
|
|
||||||
|
return <layout.Component model={layout} />; |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,180 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { |
||||||
|
SceneObjectState, |
||||||
|
SceneObjectBase, |
||||||
|
SceneComponentProps, |
||||||
|
SceneFlexLayout, |
||||||
|
SceneFlexItem, |
||||||
|
SceneQueryRunner, |
||||||
|
SceneObjectUrlSyncConfig, |
||||||
|
SceneObjectUrlValues, |
||||||
|
PanelBuilders, |
||||||
|
sceneGraph, |
||||||
|
} from '@grafana/scenes'; |
||||||
|
import { ToolbarButton, Box, Stack } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine'; |
||||||
|
import { AutoVizPanel } from './AutomaticMetricQueries/AutoVizPanel'; |
||||||
|
import { buildBreakdownActionScene } from './BreakdownScene'; |
||||||
|
import { MetricSelectScene } from './MetricSelectScene'; |
||||||
|
import { SelectMetricAction } from './SelectMetricAction'; |
||||||
|
import { |
||||||
|
ActionViewDefinition, |
||||||
|
getVariablesWithMetricConstant, |
||||||
|
LOGS_METRIC, |
||||||
|
MakeOptional, |
||||||
|
OpenEmbeddedTrailEvent, |
||||||
|
} from './shared'; |
||||||
|
import { getTrailFor } from './utils'; |
||||||
|
|
||||||
|
export interface MetricSceneState extends SceneObjectState { |
||||||
|
body: SceneFlexLayout; |
||||||
|
metric: string; |
||||||
|
actionView?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export class MetricScene extends SceneObjectBase<MetricSceneState> { |
||||||
|
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['actionView'] }); |
||||||
|
|
||||||
|
public constructor(state: MakeOptional<MetricSceneState, 'body'>) { |
||||||
|
super({ |
||||||
|
$variables: state.$variables ?? getVariablesWithMetricConstant(state.metric), |
||||||
|
body: state.body ?? buildGraphScene(state.metric), |
||||||
|
...state, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
getUrlState() { |
||||||
|
return { actionView: this.state.actionView }; |
||||||
|
} |
||||||
|
|
||||||
|
updateFromUrl(values: SceneObjectUrlValues) { |
||||||
|
if (typeof values.actionView === 'string') { |
||||||
|
if (this.state.actionView !== values.actionView) { |
||||||
|
const actionViewDef = actionViewsDefinitions.find((v) => v.value === values.actionView); |
||||||
|
if (actionViewDef) { |
||||||
|
this.setActionView(actionViewDef); |
||||||
|
} |
||||||
|
} |
||||||
|
} else if (values.actionView === null) { |
||||||
|
this.setActionView(undefined); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public setActionView(actionViewDef?: ActionViewDefinition) { |
||||||
|
const { body } = this.state; |
||||||
|
|
||||||
|
if (actionViewDef && actionViewDef.value !== this.state.actionView) { |
||||||
|
// reduce max height for main panel to reduce height flicker
|
||||||
|
body.state.children[0].setState({ maxHeight: MAIN_PANEL_MIN_HEIGHT }); |
||||||
|
body.setState({ children: [...body.state.children.slice(0, 2), actionViewDef.getScene()] }); |
||||||
|
this.setState({ actionView: actionViewDef.value }); |
||||||
|
} else { |
||||||
|
// restore max height
|
||||||
|
body.state.children[0].setState({ maxHeight: MAIN_PANEL_MAX_HEIGHT }); |
||||||
|
body.setState({ children: body.state.children.slice(0, 2) }); |
||||||
|
this.setState({ actionView: undefined }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
static Component = ({ model }: SceneComponentProps<MetricScene>) => { |
||||||
|
const { body } = model.useState(); |
||||||
|
return <body.Component model={body} />; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
const actionViewsDefinitions: ActionViewDefinition[] = [ |
||||||
|
{ displayName: 'Breakdown', value: 'breakdown', getScene: buildBreakdownActionScene }, |
||||||
|
{ displayName: 'Logs', value: 'logs', getScene: buildLogsScene }, |
||||||
|
{ displayName: 'Related metrics', value: 'related', getScene: buildRelatedMetricsScene }, |
||||||
|
]; |
||||||
|
|
||||||
|
export interface MetricActionBarState extends SceneObjectState {} |
||||||
|
|
||||||
|
export class MetricActionBar extends SceneObjectBase<MetricActionBarState> { |
||||||
|
public getButtonVariant(actionViewName: string, currentView: string | undefined) { |
||||||
|
return currentView === actionViewName ? 'active' : 'canvas'; |
||||||
|
} |
||||||
|
|
||||||
|
public onOpenTrail = () => { |
||||||
|
this.publishEvent(new OpenEmbeddedTrailEvent(), true); |
||||||
|
}; |
||||||
|
|
||||||
|
public static Component = ({ model }: SceneComponentProps<MetricActionBar>) => { |
||||||
|
const metricScene = sceneGraph.getAncestor(model, MetricScene); |
||||||
|
const trail = getTrailFor(model); |
||||||
|
const { actionView } = metricScene.useState(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Box paddingY={1}> |
||||||
|
<Stack gap={2}> |
||||||
|
{actionViewsDefinitions.map((viewDef) => ( |
||||||
|
<ToolbarButton |
||||||
|
key={viewDef.value} |
||||||
|
variant={viewDef.value === actionView ? 'active' : 'canvas'} |
||||||
|
onClick={() => metricScene.setActionView(viewDef)} |
||||||
|
> |
||||||
|
{viewDef.displayName} |
||||||
|
</ToolbarButton> |
||||||
|
))} |
||||||
|
<ToolbarButton variant={'canvas'}>Add to dashboard</ToolbarButton> |
||||||
|
<ToolbarButton variant={'canvas'} icon="compass" tooltip="Open in explore (todo)" disabled /> |
||||||
|
<ToolbarButton variant={'canvas'} icon="star" tooltip="Bookmark (todo)" disabled /> |
||||||
|
<ToolbarButton variant={'canvas'} icon="share-alt" tooltip="Copy url (todo)" disabled /> |
||||||
|
{trail.state.embedded && ( |
||||||
|
<ToolbarButton variant={'canvas'} onClick={model.onOpenTrail}> |
||||||
|
Open |
||||||
|
</ToolbarButton> |
||||||
|
)} |
||||||
|
</Stack> |
||||||
|
</Box> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
const MAIN_PANEL_MIN_HEIGHT = 280; |
||||||
|
const MAIN_PANEL_MAX_HEIGHT = '40%'; |
||||||
|
|
||||||
|
function buildGraphScene(metric: string) { |
||||||
|
const autoQuery = getAutoQueriesForMetric(metric); |
||||||
|
|
||||||
|
return new SceneFlexLayout({ |
||||||
|
direction: 'column', |
||||||
|
children: [ |
||||||
|
new SceneFlexItem({ |
||||||
|
minHeight: MAIN_PANEL_MIN_HEIGHT, |
||||||
|
maxHeight: MAIN_PANEL_MAX_HEIGHT, |
||||||
|
body: new AutoVizPanel({ autoQuery }), |
||||||
|
}), |
||||||
|
new SceneFlexItem({ |
||||||
|
ySizing: 'content', |
||||||
|
body: new MetricActionBar({}), |
||||||
|
}), |
||||||
|
], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function buildLogsScene() { |
||||||
|
return new SceneFlexItem({ |
||||||
|
$data: new SceneQueryRunner({ |
||||||
|
queries: [ |
||||||
|
{ |
||||||
|
refId: 'A', |
||||||
|
datasource: { uid: 'gdev-loki' }, |
||||||
|
expr: '{${filters}} | logfmt', |
||||||
|
}, |
||||||
|
], |
||||||
|
}), |
||||||
|
body: PanelBuilders.logs() |
||||||
|
.setTitle('Logs') |
||||||
|
.setHeaderActions(new SelectMetricAction({ metric: LOGS_METRIC, title: 'Open' })) |
||||||
|
.build(), |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function buildRelatedMetricsScene() { |
||||||
|
return new SceneFlexItem({ |
||||||
|
body: new MetricSelectScene({}), |
||||||
|
}); |
||||||
|
} |
||||||
@ -0,0 +1,216 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { |
||||||
|
SceneObjectState, |
||||||
|
SceneObjectBase, |
||||||
|
SceneComponentProps, |
||||||
|
PanelBuilders, |
||||||
|
SceneFlexItem, |
||||||
|
SceneVariableSet, |
||||||
|
QueryVariable, |
||||||
|
sceneGraph, |
||||||
|
VariableDependencyConfig, |
||||||
|
SceneVariable, |
||||||
|
SceneCSSGridLayout, |
||||||
|
SceneCSSGridItem, |
||||||
|
} from '@grafana/scenes'; |
||||||
|
import { VariableHide } from '@grafana/schema'; |
||||||
|
import { Input, Text, useStyles2, InlineSwitch } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine'; |
||||||
|
import { SelectMetricAction } from './SelectMetricAction'; |
||||||
|
import { getVariablesWithMetricConstant, trailDS, VAR_FILTERS_EXPR, VAR_METRIC_NAMES } from './shared'; |
||||||
|
import { getColorByIndex } from './utils'; |
||||||
|
|
||||||
|
export interface MetricSelectSceneState extends SceneObjectState { |
||||||
|
body: SceneCSSGridLayout; |
||||||
|
showHeading?: boolean; |
||||||
|
searchQuery?: string; |
||||||
|
showPreviews?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
const ROW_PREVIEW_HEIGHT = '175px'; |
||||||
|
const ROW_CARD_HEIGHT = '64px'; |
||||||
|
|
||||||
|
export class MetricSelectScene extends SceneObjectBase<MetricSelectSceneState> { |
||||||
|
constructor(state: Partial<MetricSelectSceneState>) { |
||||||
|
super({ |
||||||
|
$variables: state.$variables ?? getMetricNamesVariableSet(), |
||||||
|
body: |
||||||
|
state.body ?? |
||||||
|
new SceneCSSGridLayout({ |
||||||
|
children: [], |
||||||
|
templateColumns: 'repeat(auto-fill, minmax(450px, 1fr))', |
||||||
|
autoRows: ROW_PREVIEW_HEIGHT, |
||||||
|
}), |
||||||
|
showPreviews: true, |
||||||
|
...state, |
||||||
|
}); |
||||||
|
|
||||||
|
this.addActivationHandler(this._onActivate.bind(this)); |
||||||
|
} |
||||||
|
|
||||||
|
protected _variableDependency = new VariableDependencyConfig(this, { |
||||||
|
variableNames: [VAR_METRIC_NAMES], |
||||||
|
onVariableUpdatesCompleted: this._onVariableChanged.bind(this), |
||||||
|
}); |
||||||
|
|
||||||
|
private _onVariableChanged(changedVariables: Set<SceneVariable>, dependencyChanged: boolean): void { |
||||||
|
if (dependencyChanged) { |
||||||
|
this.buildLayout(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private ignoreNextUpdate = false; |
||||||
|
private _onActivate() { |
||||||
|
if (this.state.body.state.children.length === 0) { |
||||||
|
this.buildLayout(); |
||||||
|
} else { |
||||||
|
// Temp hack when going back to select metric scene and variable updates
|
||||||
|
this.ignoreNextUpdate = true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private buildLayout() { |
||||||
|
// Temp hack when going back to select metric scene and variable updates
|
||||||
|
if (this.ignoreNextUpdate) { |
||||||
|
this.ignoreNextUpdate = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const variable = sceneGraph.lookupVariable(VAR_METRIC_NAMES, this); |
||||||
|
|
||||||
|
if (!(variable instanceof QueryVariable)) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (variable.state.loading) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const searchRegex = new RegExp(this.state.searchQuery ?? '.*'); |
||||||
|
const metricNames = variable.state.options; |
||||||
|
const children: SceneFlexItem[] = []; |
||||||
|
const showPreviews = this.state.showPreviews; |
||||||
|
const previewLimit = 20; |
||||||
|
const cardLimit = 50; |
||||||
|
|
||||||
|
for (let index = 0; index < metricNames.length; index++) { |
||||||
|
const metric = metricNames[index]; |
||||||
|
|
||||||
|
const metricName = String(metric.value); |
||||||
|
if (!metricName.match(searchRegex)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (children.length > cardLimit) { |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
if (showPreviews && children.length < previewLimit) { |
||||||
|
children.push( |
||||||
|
new SceneCSSGridItem({ |
||||||
|
$variables: getVariablesWithMetricConstant(metricName), |
||||||
|
body: getPreviewPanelFor(metricName, index), |
||||||
|
}) |
||||||
|
); |
||||||
|
} else { |
||||||
|
children.push( |
||||||
|
new SceneCSSGridItem({ |
||||||
|
$variables: getVariablesWithMetricConstant(metricName), |
||||||
|
body: getCardPanelFor(metricName), |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const rowTemplate = this.state.showPreviews ? ROW_PREVIEW_HEIGHT : ROW_CARD_HEIGHT; |
||||||
|
|
||||||
|
this.state.body.setState({ children, autoRows: rowTemplate }); |
||||||
|
} |
||||||
|
|
||||||
|
public onSearchChange = (evt: React.SyntheticEvent<HTMLInputElement>) => { |
||||||
|
this.setState({ searchQuery: evt.currentTarget.value }); |
||||||
|
this.buildLayout(); |
||||||
|
}; |
||||||
|
|
||||||
|
public onTogglePreviews = () => { |
||||||
|
this.setState({ showPreviews: !this.state.showPreviews }); |
||||||
|
this.buildLayout(); |
||||||
|
}; |
||||||
|
|
||||||
|
public static Component = ({ model }: SceneComponentProps<MetricSelectScene>) => { |
||||||
|
const { showHeading, searchQuery, showPreviews } = model.useState(); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.container}> |
||||||
|
{showHeading && ( |
||||||
|
<div className={styles.headingWrapper}> |
||||||
|
<Text variant="h4">Select a metric</Text> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
<div className={styles.header}> |
||||||
|
<Input placeholder="Search metrics" value={searchQuery} onChange={model.onSearchChange} /> |
||||||
|
<InlineSwitch showLabel={true} label="Show previews" value={showPreviews} onChange={model.onTogglePreviews} /> |
||||||
|
</div> |
||||||
|
<model.state.body.Component model={model.state.body} /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function getMetricNamesVariableSet() { |
||||||
|
return new SceneVariableSet({ |
||||||
|
variables: [ |
||||||
|
new QueryVariable({ |
||||||
|
name: VAR_METRIC_NAMES, |
||||||
|
datasource: trailDS, |
||||||
|
hide: VariableHide.hideVariable, |
||||||
|
includeAll: true, |
||||||
|
defaultToAll: true, |
||||||
|
skipUrlSync: true, |
||||||
|
query: { query: `label_values(${VAR_FILTERS_EXPR},__name__)`, refId: 'A' }, |
||||||
|
}), |
||||||
|
], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function getPreviewPanelFor(metric: string, index: number) { |
||||||
|
const autoQuery = getAutoQueriesForMetric(metric); |
||||||
|
|
||||||
|
return autoQuery.preview |
||||||
|
.vizBuilder(autoQuery.preview) |
||||||
|
.setColor({ mode: 'fixed', fixedColor: getColorByIndex(index) }) |
||||||
|
.setHeaderActions(new SelectMetricAction({ metric, title: 'Select' })) |
||||||
|
.build(); |
||||||
|
} |
||||||
|
|
||||||
|
function getCardPanelFor(metric: string) { |
||||||
|
return PanelBuilders.text() |
||||||
|
.setTitle(metric) |
||||||
|
.setHeaderActions(new SelectMetricAction({ metric, title: 'Select' })) |
||||||
|
.setOption('content', '') |
||||||
|
.build(); |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
container: css({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
flexGrow: 1, |
||||||
|
}), |
||||||
|
headingWrapper: css({ |
||||||
|
marginTop: theme.spacing(1), |
||||||
|
}), |
||||||
|
header: css({ |
||||||
|
flexGrow: 0, |
||||||
|
display: 'flex', |
||||||
|
gap: theme.spacing(2), |
||||||
|
marginBottom: theme.spacing(1), |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { SceneObjectState, SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; |
||||||
|
import { Button } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { MetricSelectedEvent } from './shared'; |
||||||
|
|
||||||
|
export interface SelectMetricActionState extends SceneObjectState { |
||||||
|
title: string; |
||||||
|
metric: string; |
||||||
|
} |
||||||
|
|
||||||
|
export class SelectMetricAction extends SceneObjectBase<SelectMetricActionState> { |
||||||
|
public onClick = () => { |
||||||
|
this.publishEvent(new MetricSelectedEvent(this.state.metric), true); |
||||||
|
}; |
||||||
|
|
||||||
|
public static Component = ({ model }: SceneComponentProps<SelectMetricAction>) => { |
||||||
|
return ( |
||||||
|
<Button variant="primary" size="sm" fill="text" onClick={model.onClick}> |
||||||
|
{model.state.title} |
||||||
|
</Button> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,83 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { |
||||||
|
SceneObjectState, |
||||||
|
SceneObjectBase, |
||||||
|
VariableDependencyConfig, |
||||||
|
sceneGraph, |
||||||
|
SceneComponentProps, |
||||||
|
SceneVariableSet, |
||||||
|
SceneVariable, |
||||||
|
QueryVariable, |
||||||
|
VariableValueOption, |
||||||
|
} from '@grafana/scenes'; |
||||||
|
import { VariableHide } from '@grafana/schema'; |
||||||
|
import { Input, Card, Stack } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { trailDS } from './shared'; |
||||||
|
|
||||||
|
export interface SelectMetricTrailViewState extends SceneObjectState { |
||||||
|
metricNames: VariableValueOption[]; |
||||||
|
} |
||||||
|
|
||||||
|
export class SelectMetricTrailView extends SceneObjectBase<SelectMetricTrailViewState> { |
||||||
|
public constructor(state: Partial<SelectMetricTrailViewState>) { |
||||||
|
super({ |
||||||
|
$variables: new SceneVariableSet({ |
||||||
|
variables: [ |
||||||
|
new QueryVariable({ |
||||||
|
name: 'metricNames', |
||||||
|
datasource: trailDS, |
||||||
|
hide: VariableHide.hideVariable, |
||||||
|
includeAll: true, |
||||||
|
defaultToAll: true, |
||||||
|
skipUrlSync: true, |
||||||
|
query: { query: 'label_values({$filters},__name__)', refId: 'A' }, |
||||||
|
}), |
||||||
|
], |
||||||
|
}), |
||||||
|
metricNames: [], |
||||||
|
...state, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
protected _variableDependency = new VariableDependencyConfig(this, { |
||||||
|
variableNames: ['filters', 'metricNames'], |
||||||
|
onVariableUpdatesCompleted: this._onVariableChanged.bind(this), |
||||||
|
}); |
||||||
|
|
||||||
|
private _onVariableChanged(changedVariables: Set<SceneVariable>, dependencyChanged: boolean): void { |
||||||
|
for (const variable of changedVariables) { |
||||||
|
if (variable.state.name === 'filters') { |
||||||
|
const variable = sceneGraph.lookupVariable('filters', this)!; |
||||||
|
// Temp hack
|
||||||
|
(this.state.$variables as any)._handleVariableValueChanged(variable); |
||||||
|
} |
||||||
|
|
||||||
|
if (variable.state.name === 'metricNames' && variable instanceof QueryVariable) { |
||||||
|
this.setState({ metricNames: variable.state.options }); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
static Component = ({ model }: SceneComponentProps<SelectMetricTrailView>) => { |
||||||
|
const { metricNames } = model.useState(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Stack direction="column" gap={0}> |
||||||
|
<Stack direction="column" gap={2}> |
||||||
|
<Input placeholder="Search metrics" /> |
||||||
|
<div></div> |
||||||
|
</Stack> |
||||||
|
{metricNames.map((option, index) => ( |
||||||
|
<Card |
||||||
|
key={index} |
||||||
|
href={sceneGraph.interpolate(model, `\${__url.path}\${__url.params}&metric=${option.value}`)} |
||||||
|
> |
||||||
|
<Card.Heading>{String(option.value)}</Card.Heading> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
</Stack> |
||||||
|
); |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,38 @@ |
|||||||
|
import { PanelMenuItem } from '@grafana/data'; |
||||||
|
import { getDataSourceSrv } from '@grafana/runtime'; |
||||||
|
import { VizPanel } from '@grafana/scenes'; |
||||||
|
import { buildVisualQueryFromString } from 'app/plugins/datasource/prometheus/querybuilder/parsing'; |
||||||
|
|
||||||
|
import { DashboardScene } from '../dashboard-scene/scene/DashboardScene'; |
||||||
|
import { getQueryRunnerFor } from '../dashboard-scene/utils/utils'; |
||||||
|
|
||||||
|
import { DataTrailDrawer } from './DataTrailDrawer'; |
||||||
|
|
||||||
|
export function addDataTrailPanelAction(dashboard: DashboardScene, vizPanel: VizPanel, items: PanelMenuItem[]) { |
||||||
|
const queryRunner = getQueryRunnerFor(vizPanel); |
||||||
|
if (!queryRunner) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const ds = getDataSourceSrv().getInstanceSettings(queryRunner.state.datasource); |
||||||
|
if (!ds || ds.meta.id !== 'prometheus' || queryRunner.state.queries.length > 1) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const query = queryRunner.state.queries[0]; |
||||||
|
const parsedResult = buildVisualQueryFromString(query.expr); |
||||||
|
if (parsedResult.errors.length > 0) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
items.push({ |
||||||
|
text: 'Data trail', |
||||||
|
iconClassName: 'code-branch', |
||||||
|
onClick: () => { |
||||||
|
dashboard.showModal( |
||||||
|
new DataTrailDrawer({ query: parsedResult.query, dsRef: ds, timeRange: dashboard.state.$timeRange!.clone() }) |
||||||
|
); |
||||||
|
}, |
||||||
|
shortcut: 'p s', |
||||||
|
}); |
||||||
|
} |
||||||
@ -0,0 +1,46 @@ |
|||||||
|
import { BusEventBase, BusEventWithPayload } from '@grafana/data'; |
||||||
|
import { ConstantVariable, SceneObject, SceneVariableSet } from '@grafana/scenes'; |
||||||
|
import { VariableHide } from '@grafana/schema'; |
||||||
|
|
||||||
|
export interface ActionViewDefinition { |
||||||
|
displayName: string; |
||||||
|
value: string; |
||||||
|
getScene: () => SceneObject; |
||||||
|
} |
||||||
|
|
||||||
|
export const VAR_METRIC_NAMES = 'metricNames'; |
||||||
|
export const VAR_FILTERS = 'filters'; |
||||||
|
export const VAR_FILTERS_EXPR = '{${filters}}'; |
||||||
|
export const VAR_METRIC = 'metric'; |
||||||
|
export const VAR_METRIC_EXPR = '${metric}'; |
||||||
|
export const VAR_GROUP_BY = 'groupby'; |
||||||
|
export const VAR_GROUP_BY_EXP = '${groupby}'; |
||||||
|
export const VAR_DATASOURCE = 'ds'; |
||||||
|
export const VAR_DATASOURCE_EXPR = '${ds}'; |
||||||
|
|
||||||
|
export const LOGS_METRIC = '$__logs__'; |
||||||
|
export const KEY_SQR_METRIC_VIZ_QUERY = 'sqr-metric-viz-query'; |
||||||
|
|
||||||
|
export const trailDS = { uid: VAR_DATASOURCE_EXPR }; |
||||||
|
|
||||||
|
export type MakeOptional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>; |
||||||
|
|
||||||
|
export function getVariablesWithMetricConstant(metric: string) { |
||||||
|
return new SceneVariableSet({ |
||||||
|
variables: [ |
||||||
|
new ConstantVariable({ |
||||||
|
name: VAR_METRIC, |
||||||
|
value: metric, |
||||||
|
hide: VariableHide.hideVariable, |
||||||
|
}), |
||||||
|
], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
export class MetricSelectedEvent extends BusEventWithPayload<string> { |
||||||
|
public static type = 'metric-selected-event'; |
||||||
|
} |
||||||
|
|
||||||
|
export class OpenEmbeddedTrailEvent extends BusEventBase { |
||||||
|
public static type = 'open-embedded-trail-event'; |
||||||
|
} |
||||||
@ -0,0 +1,48 @@ |
|||||||
|
import { urlUtil } from '@grafana/data'; |
||||||
|
import { config } from '@grafana/runtime'; |
||||||
|
import { getUrlSyncManager, sceneGraph, SceneObject, SceneTimeRange } from '@grafana/scenes'; |
||||||
|
|
||||||
|
import { DataTrail } from './DataTrail'; |
||||||
|
import { DataTrailSettings } from './DataTrailSettings'; |
||||||
|
import { MetricScene } from './MetricScene'; |
||||||
|
|
||||||
|
export function getTrailFor(model: SceneObject): DataTrail { |
||||||
|
return sceneGraph.getAncestor(model, DataTrail); |
||||||
|
} |
||||||
|
|
||||||
|
export function getTrailSettings(model: SceneObject): DataTrailSettings { |
||||||
|
return sceneGraph.getAncestor(model, DataTrail).state.settings; |
||||||
|
} |
||||||
|
|
||||||
|
export function newMetricsTrail(): DataTrail { |
||||||
|
return new DataTrail({ |
||||||
|
//initialDS: 'gdev-prometheus',
|
||||||
|
$timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }), |
||||||
|
//initialFilters: [{ key: 'job', operator: '=', value: 'grafana' }],
|
||||||
|
embedded: false, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
export function getUrlForTrail(trail: DataTrail) { |
||||||
|
const params = getUrlSyncManager().getUrlState(trail); |
||||||
|
return urlUtil.renderUrl('/data-trails/trail', params); |
||||||
|
} |
||||||
|
|
||||||
|
export function getMetricSceneFor(model: SceneObject): MetricScene { |
||||||
|
if (model instanceof MetricScene) { |
||||||
|
return model; |
||||||
|
} |
||||||
|
|
||||||
|
if (model.parent) { |
||||||
|
return getMetricSceneFor(model.parent); |
||||||
|
} |
||||||
|
|
||||||
|
console.error('Unable to find graph view for', model); |
||||||
|
|
||||||
|
throw new Error('Unable to find trail'); |
||||||
|
} |
||||||
|
|
||||||
|
export function getColorByIndex(index: number) { |
||||||
|
const visTheme = config.theme2.visualization; |
||||||
|
return visTheme.getColorByName(visTheme.palette[index % 8]); |
||||||
|
} |
||||||
Loading…
Reference in new issue