diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index 291f88b23332..57dce2c90e1c 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -47,6 +47,11 @@ export interface FieldConfig { */ displayNameFromDS?: string; + /** + * This is the URL that the field value links to. This supports template variables. + */ + urlFromDS?: string; + /** * Human readable field metadata */ diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index f67c6874dcf1..fdcbd3308d76 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -86,6 +86,7 @@ export const Components = { editorToggle: 'data-testid QueryEditorModeToggle', // wrapper for toggle options: 'data-testid prometheus options', // wrapper for options group legend: 'data-testid prometheus legend wrapper', // wrapper for multiple compomnents + legendUrl: 'data-testid prometheus legendUrl wrapper', // wrapper for multiple compomnents format: 'data-testid prometheus format', step: 'prometheus-step', // id for autosize component type: 'data-testid prometheus type', //wrapper for radio button group diff --git a/packages/grafana-prometheus/src/dataquery.ts b/packages/grafana-prometheus/src/dataquery.ts index 545a32345b74..3ff59251ceec 100644 --- a/packages/grafana-prometheus/src/dataquery.ts +++ b/packages/grafana-prometheus/src/dataquery.ts @@ -38,6 +38,11 @@ export interface Prometheus extends common.DataQuery { * Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname */ legendFormat?: string; + legendUrlFormat?: string; + /** + * Drilldown URL for LegendFormat with template support. Ex. https://example.com/{{instance}} will replace the variable with its value + */ + legendUrl?: string; /** * Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series */ diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx index 5a43727e7286..55893011cb0c 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendListItem.tsx @@ -59,7 +59,9 @@ export const VizLegendListItem = ({ const onClick = useCallback( (event: React.MouseEvent) => { if (onLabelClick) { - onLabelClick(item, event); + if( item.url != null) { + window.location.href = item.url; + } else { onLabelClick(item, event); } } }, [item, onLabelClick] diff --git a/packages/grafana-ui/src/components/VizLegend/types.ts b/packages/grafana-ui/src/components/VizLegend/types.ts index 341008ba4805..d608b00af727 100644 --- a/packages/grafana-ui/src/components/VizLegend/types.ts +++ b/packages/grafana-ui/src/components/VizLegend/types.ts @@ -40,6 +40,7 @@ export interface LegendProps extends VizLegendBaseProps, VizLegendTa export interface VizLegendItem { getItemKey?: () => string; label: string; + url?: string; color?: string; gradient?: string; yAxis: number; diff --git a/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx index 3d5bfe5aaefb..37a8be04f0c5 100644 --- a/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx +++ b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx @@ -71,6 +71,7 @@ export const PlotLegend = React.memo( } const label = getFieldDisplayName(field, data[fieldIndex.frameIndex]!, data); + const url = field.config?.urlFromDS; const scaleColor = getFieldSeriesColor(field, theme); const seriesColor = scaleColor.color; @@ -79,6 +80,7 @@ export const PlotLegend = React.memo( fieldIndex, color: seriesColor, label, + url, yAxis: axisPlacement === AxisPlacement.Left || axisPlacement === AxisPlacement.Bottom ? 1 : 2, getDisplayValues: () => { if (!calcs?.length) { diff --git a/pkg/promlib/models/query.go b/pkg/promlib/models/query.go index bb6c996cbab7..6d3441d180c9 100644 --- a/pkg/promlib/models/query.go +++ b/pkg/promlib/models/query.go @@ -63,6 +63,9 @@ type PrometheusQueryProperties struct { // Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname LegendFormat string `json:"legendFormat,omitempty"` + // Drilldown URL for LegendFormat with template support. Ex. https://example.com/{{instance}} will replace the variable with its value + LegendUrl string `json:"legendUrl,omitempty"` + // ??? Scope *ScopeSpec `json:"scope,omitempty"` } @@ -152,6 +155,7 @@ type Query struct { Expr string Step time.Duration LegendFormat string + LegendUrl string Start time.Time End time.Time RefId string @@ -208,6 +212,7 @@ func Parse(query backend.DataQuery, dsScrapeInterval string, intervalCalculator Expr: expr, Step: calculatedStep, LegendFormat: model.LegendFormat, + LegendUrl: model.LegendUrl, Start: query.TimeRange.From, End: query.TimeRange.To, RefId: query.RefID, diff --git a/pkg/promlib/querydata/response.go b/pkg/promlib/querydata/response.go index 7181400df168..a7d0d262bc9d 100644 --- a/pkg/promlib/querydata/response.go +++ b/pkg/promlib/querydata/response.go @@ -116,10 +116,11 @@ func addMetadataToMultiFrame(q *models.Query, frame *data.Frame, enableDataplane frame.Fields[0].Config = &data.FieldConfig{Interval: float64(q.Step.Milliseconds())} customName := getName(q, frame.Fields[1]) + labelUrl := getUrl(q, frame.Fields[1]) + if customName != "" { - frame.Fields[1].Config = &data.FieldConfig{DisplayNameFromDS: customName} + frame.Fields[1].Config = &data.FieldConfig{DisplayNameFromDS: customName, UrlFromDS: labelUrl} } - if enableDataplane { valueField := frame.Fields[1] if n, ok := valueField.Labels["__name__"]; ok { @@ -169,8 +170,27 @@ func getName(q *models.Query, field *data.Field) string { if len(labels) > 0 { legend = "" } - } else if q.LegendFormat != "" { - result := legendFormatRegexp.ReplaceAllFunc([]byte(q.LegendFormat), func(in []byte) []byte { + } else { + legend = convertLabel(q.LegendFormat, field) + } + + // If legend is empty brackets, use query expression + if legend == "{}" { + return q.Expr + } + + return legend +} + +func getUrl(q *models.Query, field *data.Field) string { + return convertLabel(q.LegendUrl, field) +} + +func convertLabel(label string, field *data.Field) string { + var convertedLabel string + labels := field.Labels + if label != "" { + result := legendFormatRegexp.ReplaceAllFunc([]byte(label), func(in []byte) []byte { labelName := strings.Replace(string(in), "{{", "", 1) labelName = strings.Replace(labelName, "}}", "", 1) labelName = strings.TrimSpace(labelName) @@ -179,15 +199,9 @@ func getName(q *models.Query, field *data.Field) string { } return []byte{} }) - legend = string(result) + convertedLabel = string(result) } - - // If legend is empty brackets, use query expression - if legend == "{}" { - return q.Expr - } - - return legend + return convertedLabel } func isExemplarFrame(frame *data.Frame) bool { diff --git a/pkg/tsdb/loki/frame.go b/pkg/tsdb/loki/frame.go index 8655b746acac..158f85719cd5 100644 --- a/pkg/tsdb/loki/frame.go +++ b/pkg/tsdb/loki/frame.go @@ -50,6 +50,7 @@ func adjustMetricFrame(frame *data.Frame, query *lokiQuery, setFrameName bool) e isMetricRange := query.QueryType == QueryTypeRange name := formatName(labels, query) + url := formatUrl(labels, query) if setFrameName { frame.Name = name } @@ -78,6 +79,7 @@ func adjustMetricFrame(frame *data.Frame, query *lokiQuery, setFrameName bool) e valueField.Config = &data.FieldConfig{} } valueField.Config.DisplayNameFromDS = name + valueField.Config.UrlFromDS = url return nil } @@ -283,17 +285,28 @@ func formatName(labels map[string]string, query *lokiQuery) string { return formatNamePrometheusStyle(labels) } - result := legendFormat.ReplaceAllFunc([]byte(query.LegendFormat), func(in []byte) []byte { - labelName := strings.Replace(string(in), "{{", "", 1) - labelName = strings.Replace(labelName, "}}", "", 1) - labelName = strings.TrimSpace(labelName) - if val, exists := labels[labelName]; exists { - return []byte(val) - } - return []byte{} - }) + return convertLabel(query.LegendFormat, labels) +} + +func formatUrl(labels map[string]string, query *lokiQuery) string { + return convertLabel(query.LegendUrl, labels) +} - return string(result) +func convertLabel(label string, labels map[string]string) string { + var convertedLabel string + if label != "" { + result := legendFormat.ReplaceAllFunc([]byte(label), func(in []byte) []byte { + labelName := strings.Replace(string(in), "{{", "", 1) + labelName = strings.Replace(labelName, "}}", "", 1) + labelName = strings.TrimSpace(labelName) + if val, exists := labels[labelName]; exists { + return []byte(val) + } + return []byte{} + }) + convertedLabel = string(result) + } + return convertedLabel } func getFrameLabels(frame *data.Frame) map[string]string { diff --git a/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go index f9dcd723805a..07cfb132020c 100644 --- a/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/loki/kinds/dataquery/types_dataquery_gen.go @@ -80,6 +80,9 @@ type LokiDataQuery struct { // Used to override the name of the series. LegendFormat *string `json:"legendFormat,omitempty"` + // Used for the drilldown label url + LegendUrl *string `json:"legendUrl,omitempty"` + // Used to limit the number of log rows returned. MaxLines *int64 `json:"maxLines,omitempty"` diff --git a/pkg/tsdb/loki/parse_query.go b/pkg/tsdb/loki/parse_query.go index a4f39679d6c8..5f8c9aaedae6 100644 --- a/pkg/tsdb/loki/parse_query.go +++ b/pkg/tsdb/loki/parse_query.go @@ -168,6 +168,10 @@ func parseQuery(queryContext *backend.QueryDataRequest) ([]*lokiQuery, error) { if model.LegendFormat != nil { legendFormat = *model.LegendFormat } + var legendUrl string + if model.LegendUrl != nil { + legendUrl = *model.LegendUrl + } supportingQueryType := parseSupportingQueryType(model.SupportingQueryType) @@ -178,6 +182,7 @@ func parseQuery(queryContext *backend.QueryDataRequest) ([]*lokiQuery, error) { Step: step, MaxLines: int(maxLines), LegendFormat: legendFormat, + LegendUrl: legendUrl, Start: start, End: end, RefID: query.RefID, diff --git a/pkg/tsdb/loki/types.go b/pkg/tsdb/loki/types.go index 5ac5ee514c34..aeb201fdb8a0 100644 --- a/pkg/tsdb/loki/types.go +++ b/pkg/tsdb/loki/types.go @@ -35,6 +35,7 @@ type lokiQuery struct { Step time.Duration MaxLines int LegendFormat string + LegendUrl string Start time.Time End time.Time RefID string diff --git a/public/app/plugins/datasource/loki/dataquery.gen.ts b/public/app/plugins/datasource/loki/dataquery.gen.ts index 063deb3ee2f4..9eae73ee664e 100644 --- a/public/app/plugins/datasource/loki/dataquery.gen.ts +++ b/public/app/plugins/datasource/loki/dataquery.gen.ts @@ -47,6 +47,10 @@ export interface LokiDataQuery extends common.DataQuery { * Used to override the name of the series. */ legendFormat?: string; + /** + * Used for drilldown/clickable legend labels. + */ + legendUrl?: string; /** * Used to limit the number of log rows returned. */ diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.test.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.test.tsx index cdc80221e9f8..6395efadc657 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.test.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.test.tsx @@ -42,7 +42,7 @@ describe('LokiQueryBuilderOptions', () => { await userEvent.click(screen.getByRole('button', { name: /Options/ })); // Second autosize input is a Line limit - const element = screen.getAllByTestId('autosize-input')[1]; + const element = screen.getAllByTestId('autosize-input')[2]; await userEvent.type(element, '10'); await userEvent.keyboard('{enter}'); @@ -59,7 +59,7 @@ describe('LokiQueryBuilderOptions', () => { await userEvent.click(screen.getByRole('button', { name: /Options/ })); // Second autosize input is a Line limit - const element = screen.getAllByTestId('autosize-input')[1]; + const element = screen.getAllByTestId('autosize-input')[2]; await userEvent.type(element, '-10'); await userEvent.keyboard('{enter}'); @@ -76,7 +76,7 @@ describe('LokiQueryBuilderOptions', () => { await userEvent.click(screen.getByRole('button', { name: /Options/ })); // Second autosize input is a Line limit - const element = screen.getAllByTestId('autosize-input')[1]; + const element = screen.getAllByTestId('autosize-input')[2]; await userEvent.type(element, 'asd'); await userEvent.keyboard('{enter}'); @@ -154,6 +154,32 @@ describe('LokiQueryBuilderOptions', () => { }); }); +describe('getCollapsedInfo', () => { + it('displays a clipped legend URL for long URLs', () => { + const longUrl = 'extremelyLongUrlThatShouldBeCutOffElseItTakesUpTooMuchOnScreenRealestate'; + setup({ legendUrl: longUrl }); + + const urlText = screen.getByText((content, node) => { + const hasText = (node: any) => node.textContent === `URL: extremelyL...`; + const nodeHasText = hasText(node); + const childrenDontHaveText = Array.from(node!.children).every( + (child) => !hasText(child) + ); + return nodeHasText && childrenDontHaveText; + }); + expect(urlText).toBeInTheDocument(); + }); + + it('displays the full legend URL for short URLs', () => { + const shortUrl = 'shortUrl'; + setup({ legendUrl: shortUrl }); + + const urlTextElement = screen.getByText(`URL: ${shortUrl}`); + expect(urlTextElement).toBeInTheDocument(); + }); +}); + + function setup(queryOverrides: Partial = {}) { const props = { query: { diff --git a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx index ad9b7ae80f12..b5aee721d7c9 100644 --- a/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/loki/querybuilder/components/LokiQueryBuilderOptions.tsx @@ -52,6 +52,10 @@ export const LokiQueryBuilderOptions = React.memo( onChange({ ...query, legendFormat: evt.currentTarget.value }); onRunQuery(); }; + const onLegendUrlChanged = (evt: React.FormEvent) => { + onChange({ ...query, legendUrl: evt.currentTarget.value }); + onRunQuery(); + }; function onMaxLinesChange(e: React.SyntheticEvent) { const newMaxLines = preprocessMaxLines(e.currentTarget.value); @@ -95,6 +99,18 @@ export const LokiQueryBuilderOptions = React.memo( onCommitChange={onLegendFormatChanged} /> + + + @@ -185,6 +201,16 @@ function getCollapsedInfo( items.push(`Legend: ${query.legendFormat}`); } + if (query.legendUrl) { + let legendUrl = query.legendUrl; + + if (typeof legendUrl === 'string' && legendUrl.length > 10) { + legendUrl = legendUrl.slice(0, 10) + "..."; + } + + items.push(`URL: ${legendUrl?.length === 0 ? "None" : legendUrl}`); + } + items.push(`Type: ${queryTypeLabel?.label}`); if (isLogQuery) { diff --git a/public/app/plugins/datasource/prometheus/dataquery.ts b/public/app/plugins/datasource/prometheus/dataquery.ts index 54f6cd7bac90..2ff64c2c864d 100644 --- a/public/app/plugins/datasource/prometheus/dataquery.ts +++ b/public/app/plugins/datasource/prometheus/dataquery.ts @@ -38,6 +38,10 @@ export interface Prometheus extends common.DataQuery { * Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname */ legendFormat?: string; + /** + * Drilldown URL for LegendFormat with template support. Ex. https://example.com/{{instance}} will replace the variable with its value + */ + legendUrl?: string; /** * Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series */ diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx index 4f9e1384980a..9ac5539f765a 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.test.tsx @@ -102,6 +102,25 @@ describe('PromQueryBuilderOptions', () => { }); }); +describe('getCollapsedInfo', () => { + it('displays a clipped legend URL for long URLs', async () => { + const longUrl = 'extremelyLongUrlThatShouldBeCutOffElseItTakesUpTooMuchOnScreenRealestate'; + setup({ legendUrl: longUrl }); + + const optionsElement = screen.getByTestId('data-testid prometheus options'); + expect(optionsElement.textContent).toContain("extremelyL..."); + }); + + it('displays the full legend URL for short URLs', async () => { + const shortUrl = 'shortUrl'; + setup({ legendUrl: shortUrl }); + + const optionsElement = screen.getByTestId('data-testid prometheus options'); + expect(optionsElement.textContent).toContain(shortUrl); + }); +}); + + function setup(queryOverrides: Partial = {}, app: CoreApp = CoreApp.PanelEditor) { const props = { app, @@ -112,6 +131,7 @@ function setup(queryOverrides: Partial = {}, app: CoreApp = CoreApp.P expr: '', range: true, instant: false, + legendUrl: '', } as PromQuery, CoreApp.PanelEditor ), @@ -128,7 +148,7 @@ function setup(queryOverrides: Partial = {}, app: CoreApp = CoreApp.P resolution: true, }, }; - + const { container } = render(); return { container, props }; } diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx index aa0f055f2a0d..08f32158fb52 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderOptions.tsx @@ -12,6 +12,8 @@ import { QueryOptionGroup } from '../shared/QueryOptionGroup'; import { FORMAT_OPTIONS, INTERVAL_FACTOR_OPTIONS } from './PromQueryEditorSelector'; import { getLegendModeLabel, PromQueryLegendEditor } from './PromQueryLegendEditor'; +import { PromQueryLegendUrlEditor } from './PromQueryLegendUrlEditor'; + export interface UIOptions { exemplars: boolean; @@ -19,6 +21,7 @@ export interface UIOptions { format: boolean; minStep: boolean; legend: boolean; + legendUrl: boolean; resolution: boolean; } @@ -72,6 +75,11 @@ export const PromQueryBuilderOptions = React.memo(({ query, app, onChange onChange={(legendFormat) => onChange({ ...query, legendFormat })} onRunQuery={onRunQuery} /> + onChange({ ...query, legendUrl })} + onRunQuery={onRunQuery} + /> 10) { + legendUrl = legendUrl.slice(0, 10) + "..."; + } + items.push(`Legend: ${getLegendModeLabel(query.legendFormat)}`); + console.log('"' + legendUrl + '"') + items.push(`URL: ${legendUrl?.length === 0 ? "None" : legendUrl}`); items.push(`Format: ${formatOption}`); items.push(`Step: ${query.interval ?? 'auto'}`); items.push(`Type: ${queryType}`); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendUrlEditor.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendUrlEditor.tsx new file mode 100644 index 000000000000..3347dcb6ce6c --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryLegendUrlEditor.tsx @@ -0,0 +1,38 @@ +import React, { useRef } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { EditorField } from '@grafana/experimental'; +import { AutoSizeInput } from '@grafana/ui'; + +export interface Props { + legendUrl: string | undefined; + onChange: (legendUrl: string) => void; + onRunQuery: () => void; +} + +export const PromQueryLegendUrlEditor = React.memo(({ legendUrl, onChange, onRunQuery }) => { + const inputRef = useRef(null); + const onLegendUrlChanged = (evt: React.FormEvent) => { + onChange(evt.currentTarget.value); + onRunQuery(); + } + + return ( + + + + ); +}); + +PromQueryLegendUrlEditor.displayName = 'PromQueryLegendUrlEditor'; \ No newline at end of file