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