Correlations: Allow correlations to target URLs (#92442)

* Pass one

* Fix linter and add new betterer problem (sorry)

* fix swagger

* Add type to tests and update single correlations sql

* Fix provisioning test and other function that needs a type

* Add errors around query/external typing and add tests

* increment number of correlations tested as we added one for testing v1 type placement

* try merging back the swagger that is in main

* try again?

* Style form a little

* Update public/app/features/logs/components/logParser.ts

Co-authored-by: Matias Chomicki <matyax@gmail.com>

* fix bad commit, simplify logic

* Demonstrating type difficulties

* Fix distributed union changes

* Additional type changes

* Update types in form

* Fix swagger

* Add comment around the assertion and explicit typing

---------

Co-authored-by: Matias Chomicki <matyax@gmail.com>
Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>
This commit is contained in:
Kristina 2024-09-24 09:38:17 -05:00 committed by GitHub
parent 8da1d78c92
commit 002f872ce1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 574 additions and 228 deletions

View File

@ -2631,6 +2631,11 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"]
],
"public/app/features/correlations/components/EmptyCorrelationsCTA.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
@ -2639,6 +2644,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"],
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "1"]
],
"public/app/features/correlations/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],

View File

@ -51,6 +51,10 @@ export interface DataLink<T extends DataQuery = any> {
internal?: InternalDataLink<T>;
origin?: DataLinkConfigOrigin;
meta?: {
correlationData?: ExploreCorrelationHelperData;
transformations?: DataLinkTransformationConfig[];
};
}
/**
@ -78,10 +82,6 @@ export interface InternalDataLink<T extends DataQuery = any> {
datasourceUid: string;
datasourceName: string; // used as a title if `DataLink.title` is empty
panelsState?: ExplorePanelsState;
meta?: {
correlationData?: ExploreCorrelationHelperData;
};
transformations?: DataLinkTransformationConfig[];
range?: TimeRange;
}

View File

@ -40,11 +40,7 @@ export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkMod
const interpolatedQuery = interpolateObject(link.internal?.query, scopedVars, replaceVariables);
const interpolatedPanelsState = interpolateObject(link.internal?.panelsState, scopedVars, replaceVariables);
const interpolatedCorrelationData = interpolateObject(
link.internal?.meta?.correlationData,
scopedVars,
replaceVariables
);
const interpolatedCorrelationData = interpolateObject(link.meta?.correlationData, scopedVars, replaceVariables);
const title = link.title ? link.title : internalLink.datasourceName;
return {

View File

@ -11,6 +11,8 @@ import (
"github.com/grafana/grafana/pkg/util"
)
const VALID_TYPE_FILTER = "(correlation.type = 'external' OR (correlation.type = 'query' AND dst.uid IS NOT NULL))"
// createCorrelation adds a correlation
func (s CorrelationsService) createCorrelation(ctx context.Context, cmd CreateCorrelationCommand) (Correlation, error) {
correlation := Correlation{
@ -25,6 +27,12 @@ func (s CorrelationsService) createCorrelation(ctx context.Context, cmd CreateCo
Type: cmd.Type,
}
if correlation.Config.Type == CorrelationType("query") {
correlation.Type = CorrelationType("query")
} else if correlation.Config.Type != "" {
return correlation, ErrInvalidConfigType
}
err := s.SQLStore.WithTransactionalDbSession(ctx, func(session *db.Session) error {
var err error
@ -184,7 +192,7 @@ func (s CorrelationsService) getCorrelation(ctx context.Context, cmd GetCorrelat
}
// Correlations created before the fix #72498 may have org_id = 0, but it's deprecated and will be removed in #72325
found, err := session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ?", cmd.OrgId).Join("", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId).Where("correlation.uid = ? AND correlation.source_uid = ?", correlation.UID, correlation.SourceUID).Get(&correlation)
found, err := session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ?", cmd.OrgId).Join("LEFT OUTER", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId).Where("correlation.uid = ?", correlation.UID).And("correlation.source_uid = ?", correlation.SourceUID).And(VALID_TYPE_FILTER).Get(&correlation)
if !found {
return ErrCorrelationNotFound
}
@ -235,7 +243,7 @@ func (s CorrelationsService) getCorrelationsBySourceUID(ctx context.Context, cmd
return ErrSourceDataSourceDoesNotExists
}
// Correlations created before the fix #72498 may have org_id = 0, but it's deprecated and will be removed in #72325
return session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ?", cmd.OrgId).Join("", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId).Where("correlation.source_uid = ?", cmd.SourceUID).Find(&correlations)
return session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ?", cmd.OrgId).Join("LEFT OUTER", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId).Where("correlation.source_uid = ?", cmd.SourceUID).And(VALID_TYPE_FILTER).Find(&correlations)
})
if err != nil {
@ -256,12 +264,14 @@ func (s CorrelationsService) getCorrelations(ctx context.Context, cmd GetCorrela
offset := cmd.Limit * (cmd.Page - 1)
// Correlations created before the fix #72498 may have org_id = 0, but it's deprecated and will be removed in #72325
q := session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ? ", cmd.OrgId).Join("", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId)
q := session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ? ", cmd.OrgId).Join("LEFT OUTER", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId)
if len(cmd.SourceUIDs) > 0 {
q.In("dss.uid", cmd.SourceUIDs)
}
q.Where(VALID_TYPE_FILTER)
return q.Limit(int(cmd.Limit), int(offset)).Find(&result.Correlations)
})
if err != nil {
@ -316,6 +326,7 @@ func (s CorrelationsService) createOrUpdateCorrelation(ctx context.Context, cmd
Description: cmd.Description,
Config: cmd.Config,
Provisioned: false,
Type: cmd.Type,
}
found := false

View File

@ -14,12 +14,13 @@ var (
ErrTargetDataSourceDoesNotExists = errors.New("target data source does not exist")
ErrCorrelationNotFound = errors.New("correlation not found")
ErrUpdateCorrelationEmptyParams = errors.New("not enough parameters to edit correlation")
ErrInvalidConfigType = errors.New("invalid correlation config type")
ErrInvalidType = errors.New("invalid correlation type")
ErrInvalidTransformationType = errors.New("invalid transformation type")
ErrTransformationNotNested = errors.New("transformations must be nested under config")
ErrTransformationRegexReqExp = errors.New("regex transformations require expression")
ErrCorrelationsQuotaFailed = errors.New("error getting correlations quota")
ErrCorrelationsQuotaReached = errors.New("correlations quota reached")
ErrInvalidConfigType = errors.New("correlation contains non default value in config.type")
)
const (
@ -27,8 +28,15 @@ const (
QuotaTarget quota.Target = "correlations"
)
// the type of correlation, either query for containing query information, or external for containing an external URL
// +enum
type CorrelationType string
const (
query CorrelationType = "query"
external CorrelationType = "external"
)
type Transformation struct {
//Enum: regex,logfmt
Type string `json:"type"`
@ -37,13 +45,9 @@ type Transformation struct {
MapValue string `json:"mapValue,omitempty"`
}
const (
TypeQuery CorrelationType = "query"
)
func (t CorrelationType) Validate() error {
if t != TypeQuery {
return fmt.Errorf("%s: \"%s\"", ErrInvalidConfigType, t)
if t != query && t != external {
return fmt.Errorf("%s: \"%s\"", ErrInvalidType, t)
}
return nil
}
@ -161,7 +165,7 @@ type CreateCorrelationCommand struct {
Config CorrelationConfig `json:"config" binding:"Required"`
// True if correlation was created with provisioning. This makes it read-only.
Provisioned bool `json:"provisioned"`
// correlation type, currently only valid value is "query"
// correlation type
Type CorrelationType `json:"type" binding:"Required"`
}
@ -169,8 +173,8 @@ func (c CreateCorrelationCommand) Validate() error {
if err := c.Type.Validate(); err != nil {
return err
}
if c.TargetUID == nil && c.Type == TypeQuery {
return fmt.Errorf("correlations of type \"%s\" must have a targetUID", TypeQuery)
if c.TargetUID == nil && c.Type == query {
return fmt.Errorf("correlations of type \"%s\" must have a targetUID", query)
}
if err := c.Config.Transformations.Validate(); err != nil {

View File

@ -20,7 +20,7 @@ func TestCorrelationModels(t *testing.T) {
OrgId: 1,
TargetUID: &targetUid,
Config: *config,
Type: TypeQuery,
Type: query,
}
require.NoError(t, cmd.Validate())
@ -30,7 +30,7 @@ func TestCorrelationModels(t *testing.T) {
config := &CorrelationConfig{
Field: "field",
Target: map[string]any{},
Type: TypeQuery,
Type: query,
}
cmd := &CreateCorrelationCommand{
SourceUID: "some-uid",

View File

@ -196,7 +196,7 @@ func makeCreateCorrelationCommand(correlation map[string]any, SourceUID string,
// we ignore the legacy config.type value - the only valid value at that version was "query"
var corrType = correlation["type"]
if corrType == nil || corrType == "" {
corrType = correlations.TypeQuery
corrType = correlations.CorrelationType("query")
}
var json = jsoniter.ConfigCompatibleWithStandardLibrary
@ -229,6 +229,11 @@ func makeCreateCorrelationCommand(correlation map[string]any, SourceUID string,
return correlations.CreateCorrelationCommand{}, err
}
// config.type is a deprecated place for this value. We will default it to "query" for legacy purposes but non-query correlations should have type outside of config
if config.Type != correlations.CorrelationType("query") {
return correlations.CreateCorrelationCommand{}, correlations.ErrInvalidConfigType
}
createCommand.Config = config
}
if err := createCommand.Validate(); err != nil {

View File

@ -104,7 +104,7 @@ func populateDB(t *testing.T, db db.DB, cfg *setting.Cfg) {
Config: correlations.CorrelationConfig{
Field: "field",
Target: map[string]any{},
Type: correlations.TypeQuery,
Type: correlations.CorrelationType("query"),
},
}
correlation, err := correlationsSvc.CreateCorrelation(context.Background(), cmd)

View File

@ -193,6 +193,11 @@ func (c TestContext) createCorrelation(cmd correlations.CreateCorrelationCommand
return correlation
}
func (c TestContext) createCorrelationPassError(cmd correlations.CreateCorrelationCommand) (correlations.Correlation, error) {
c.t.Helper()
return c.env.Server.HTTPServer.CorrelationsService.CreateCorrelation(context.Background(), cmd)
}
func (c TestContext) createOrUpdateCorrelation(cmd correlations.CreateCorrelationCommand) {
c.t.Helper()
err := c.env.Server.HTTPServer.CorrelationsService.CreateOrUpdateCorrelation(context.Background(), cmd)

View File

@ -230,7 +230,7 @@ func TestIntegrationCreateCorrelation(t *testing.T) {
description := "a description"
label := "a label"
fieldName := "fieldName"
corrType := correlations.TypeQuery
corrType := correlations.CorrelationType("query")
transformation := correlations.Transformation{Type: "logfmt"}
transformation2 := correlations.Transformation{Type: "regex", Expression: "testExpression", MapValue: "testVar"}
res := ctx.Post(PostParams{

View File

@ -135,6 +135,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) {
TargetUID: &writableDs,
OrgId: writableDsOrgId,
Provisioned: true,
Type: correlations.CorrelationType("query"),
})
res := ctx.Delete(DeleteParams{
@ -160,6 +161,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) {
SourceUID: writableDs,
TargetUID: &writableDs,
OrgId: writableDsOrgId,
Type: correlations.CorrelationType("query"),
})
res := ctx.Delete(DeleteParams{
@ -192,6 +194,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) {
SourceUID: writableDs,
TargetUID: &readOnlyDS,
OrgId: writableDsOrgId,
Type: correlations.CorrelationType("query"),
})
res := ctx.Delete(DeleteParams{
@ -225,6 +228,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) {
TargetUID: &readOnlyDS,
OrgId: writableDsOrgId,
Provisioned: false,
Type: correlations.CorrelationType("query"),
})
ctx.createCorrelation(correlations.CreateCorrelationCommand{
@ -232,6 +236,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) {
TargetUID: &readOnlyDS,
OrgId: writableDsOrgId,
Provisioned: true,
Type: correlations.CorrelationType("query"),
})
res := ctx.Delete(DeleteParams{

View File

@ -2,6 +2,7 @@ package correlations
import (
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
@ -38,8 +39,8 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) {
TargetUID: &dataSource.UID,
OrgId: dataSource.OrgID,
Label: "needs migration",
Type: correlations.CorrelationType("query"),
Config: correlations.CorrelationConfig{
Type: correlations.TypeQuery,
Field: "foo",
Target: map[string]any{},
Transformations: []correlations.Transformation{
@ -54,8 +55,8 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) {
TargetUID: &dataSource.UID,
OrgId: dataSource.OrgID,
Label: "existing",
Type: correlations.CorrelationType("query"),
Config: correlations.CorrelationConfig{
Type: correlations.TypeQuery,
Field: "foo",
Target: map[string]any{},
Transformations: []correlations.Transformation{
@ -65,6 +66,23 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) {
Provisioned: false,
})
// v1 correlation where type is in config
v1Correlation := ctx.createCorrelation(correlations.CreateCorrelationCommand{
SourceUID: dataSource.UID,
TargetUID: &dataSource.UID,
OrgId: dataSource.OrgID,
Label: "v1 correlation",
Config: correlations.CorrelationConfig{
Type: correlations.CorrelationType("query"),
Field: "foo",
Target: map[string]any{},
Transformations: []correlations.Transformation{
{Type: "logfmt"},
},
},
Provisioned: true,
})
t.Run("Correctly marks existing correlations as provisioned", func(t *testing.T) {
// should be updated
ctx.createOrUpdateCorrelation(correlations.CreateCorrelationCommand{
@ -75,6 +93,7 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) {
Description: needsMigration.Description,
Config: needsMigration.Config,
Provisioned: true,
Type: needsMigration.Type,
})
// should be added
@ -86,6 +105,7 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) {
Description: needsMigration.Description,
Config: needsMigration.Config,
Provisioned: true,
Type: needsMigration.Type,
})
res := ctx.Get(GetParams{
@ -101,7 +121,7 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) {
err = json.Unmarshal(responseBody, &response)
require.NoError(t, err)
require.Len(t, response.Correlations, 3)
require.Len(t, response.Correlations, 4)
unordered := make(map[string]correlations.Correlation)
for _, v := range response.Correlations {
@ -117,4 +137,44 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) {
require.NoError(t, res.Body.Close())
})
t.Run("If Config.Type is query, provision without error but have value outside of config", func(t *testing.T) {
res := ctx.Get(GetParams{
url: fmt.Sprintf("/api/datasources/uid/%s/correlations/%s", dataSource.UID, v1Correlation.UID),
user: adminUser,
})
require.Equal(t, http.StatusOK, res.StatusCode)
responseBody, err := io.ReadAll(res.Body)
require.NoError(t, err)
var response correlations.Correlation
err = json.Unmarshal(responseBody, &response)
require.NoError(t, err)
require.EqualValues(t, response.Config.Type, "")
require.EqualValues(t, v1Correlation.Config.Type, response.Type)
require.NoError(t, res.Body.Close())
})
t.Run("If Config.type is not query, throw an error", func(t *testing.T) {
_, err := ctx.createCorrelationPassError(correlations.CreateCorrelationCommand{
SourceUID: dataSource.UID,
TargetUID: &dataSource.UID,
OrgId: dataSource.OrgID,
Label: "bad v1 correlation",
Config: correlations.CorrelationConfig{
Type: correlations.CorrelationType("external"),
Field: "foo",
Target: map[string]any{},
Transformations: []correlations.Transformation{
{Type: "logfmt"},
},
},
Provisioned: true,
})
require.Error(t, err)
require.ErrorIs(t, err, correlations.ErrInvalidConfigType)
})
}

View File

@ -77,7 +77,7 @@ func TestIntegrationReadCorrelation(t *testing.T) {
SourceUID: dsWithCorrelations.UID,
TargetUID: &dsWithCorrelations.UID,
OrgId: dsWithCorrelations.OrgID,
Type: correlations.TypeQuery,
Type: correlations.CorrelationType("query"),
Config: correlations.CorrelationConfig{
Field: "foo",
Target: map[string]any{},

View File

@ -128,6 +128,7 @@ func TestIntegrationUpdateCorrelation(t *testing.T) {
TargetUID: &writableDs,
OrgId: writableDsOrgId,
Provisioned: true,
Type: correlations.CorrelationType("query"),
})
res := ctx.Patch(PatchParams{
@ -155,6 +156,7 @@ func TestIntegrationUpdateCorrelation(t *testing.T) {
SourceUID: writableDs,
TargetUID: &writableDs,
OrgId: writableDsOrgId,
Type: correlations.CorrelationType("query"),
})
// no params
@ -220,6 +222,7 @@ func TestIntegrationUpdateCorrelation(t *testing.T) {
TargetUID: &writableDs,
OrgId: writableDsOrgId,
Label: "a label",
Type: correlations.CorrelationType("query"),
})
res := ctx.Patch(PatchParams{
@ -249,9 +252,9 @@ func TestIntegrationUpdateCorrelation(t *testing.T) {
OrgId: writableDsOrgId,
Label: "0",
Description: "0",
Type: correlations.CorrelationType("query"),
Config: correlations.CorrelationConfig{
Field: "fieldName",
Type: "query",
Target: map[string]any{"expr": "foo"},
},
})

View File

@ -13793,6 +13793,7 @@
}
},
"CorrelationType": {
"description": "the type of correlation, either query for containing query information, or external for containing an external URL\n+enum",
"type": "string"
},
"CounterResetHint": {

View File

@ -23,7 +23,7 @@ import {
createRemoveCorrelationResponse,
createUpdateCorrelationResponse,
} from './__mocks__/useCorrelations.mocks';
import { Correlation, CreateCorrelationParams } from './types';
import { Correlation, CreateCorrelationParams, OmitUnion } from './types';
const renderWithContext = async (
datasources: ConstructorParameters<typeof MockDataSourceSrv>[0] = {},
@ -43,7 +43,7 @@ const renderWithContext = async (
throw createFetchCorrelationsError();
},
post: async (url: string, data: Omit<CreateCorrelationParams, 'sourceUID'>) => {
post: async (url: string, data: OmitUnion<CreateCorrelationParams, 'sourceUID'>) => {
const matches = url.match(/^\/api\/datasources\/uid\/(?<sourceUID>[a-zA-Z0-9]+)\/correlations$/);
if (matches?.groups) {
const { sourceUID } = matches.groups;
@ -54,7 +54,7 @@ const renderWithContext = async (
throw createFetchCorrelationsError();
},
patch: async (url: string, data: Omit<CreateCorrelationParams, 'sourceUID'>) => {
patch: async (url: string, data: OmitUnion<CreateCorrelationParams, 'sourceUID'>) => {
const matches = url.match(
/^\/api\/datasources\/uid\/(?<sourceUID>[a-zA-Z0-9]+)\/correlations\/(?<correlationUid>[a-zA-Z0-9]+)$/
);

View File

@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { negate } from 'lodash';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { isFetchError, reportInteraction } from '@grafana/runtime';
import {
Badge,
@ -27,7 +27,7 @@ import { AccessControlAction } from 'app/types';
import { AddCorrelationForm } from './Forms/AddCorrelationForm';
import { EditCorrelationForm } from './Forms/EditCorrelationForm';
import { EmptyCorrelationsCTA } from './components/EmptyCorrelationsCTA';
import type { RemoveCorrelationParams } from './types';
import type { Correlation, RemoveCorrelationParams } from './types';
import { CorrelationData, useCorrelations } from './useCorrelations';
const sortDatasource: SortByFn<CorrelationData> = (a, b, column) =>
@ -238,7 +238,7 @@ interface ExpandedRowProps {
readOnly: boolean;
onUpdated: () => void;
}
function ExpendedRow({ correlation: { source, target, ...correlation }, readOnly, onUpdated }: ExpandedRowProps) {
function ExpendedRow({ correlation: { source, ...correlation }, readOnly, onUpdated }: ExpandedRowProps) {
useEffect(
() => reportInteraction('grafana_correlations_details_expanded'),
// we only want to fire this on first render
@ -246,13 +246,12 @@ function ExpendedRow({ correlation: { source, target, ...correlation }, readOnly
[]
);
return (
<EditCorrelationForm
correlation={{ ...correlation, sourceUID: source.uid, targetUID: target.uid }}
onUpdated={onUpdated}
readOnly={readOnly}
/>
);
let corr: Correlation =
correlation.type === 'query'
? { ...correlation, type: 'query', sourceUID: source.uid, targetUID: correlation.target.uid }
: { ...correlation, type: 'external', sourceUID: source.uid };
return <EditCorrelationForm correlation={corr} onUpdated={onUpdated} readOnly={readOnly} />;
}
const getDatasourceCellStyles = (theme: GrafanaTheme2) => ({
@ -268,20 +267,22 @@ const getDatasourceCellStyles = (theme: GrafanaTheme2) => ({
});
const DataSourceCell = memo(
function DataSourceCell({
cell: { value },
}: CellProps<CorrelationData, CorrelationData['source'] | CorrelationData['target']>) {
function DataSourceCell({ cell: { value } }: CellProps<CorrelationData, DataSourceInstanceSettings>) {
const styles = useStyles2(getDatasourceCellStyles);
return (
<span className={styles.root}>
<img src={value.meta.info.logos.small} alt="" className={styles.dsLogo} />
{value.name}
{value?.name !== undefined && (
<>
<img src={value.meta.info.logos.small} alt="" className={styles.dsLogo} />
{value.name}
</>
)}
</span>
);
},
({ cell: { value } }, { cell: { value: prevValue } }) => {
return value.type === prevValue.type && value.name === prevValue.name;
return value?.type === prevValue?.type && value?.name === prevValue?.name;
}
);

View File

@ -24,6 +24,35 @@ const getStyles = (theme: GrafanaTheme2) => ({
`,
});
const getFormText = (queryType: string, dataSourceName?: string) => {
if (queryType === 'query') {
return {
title: t(
'correlations.source-form.query-title',
'Configure the data source that will link to {{dataSourceName}} (Step 3 of 3)',
{ dataSourceName }
),
descriptionPre: t(
'correlations.source-form.description-query-pre',
'You have used following variables in the target query:'
),
heading: t('correlations.source-form.heading-query', 'Variables used in the target query'),
};
} else {
return {
title: t(
'correlations.source-form.external-title',
'Configure the data source that will use the URL (Step 3 of 3)'
),
descriptionPre: t(
'correlations.source-form.description-external-pre',
'You have used following variables in the target URL:'
),
heading: t('correlations.source-form.heading-external', 'Variables used in the target URL'),
};
}
};
export const ConfigureCorrelationSourceForm = () => {
const { control, formState, register, getValues } = useFormContext<FormDTO>();
const styles = useStyles2(getStyles);
@ -32,9 +61,13 @@ export const ConfigureCorrelationSourceForm = () => {
const { correlation, readOnly } = useCorrelationsFormContext();
const currentTargetQuery = getValues('config.target');
const currentType = getValues('type');
const variables = getVariableUsageInfo(currentTargetQuery, {}).variables.map(
(variable) => variable.variableName + (variable.fieldPath ? `.${variable.fieldPath}` : '')
);
const dataSourceName = getDatasourceSrv().getInstanceSettings(getValues('targetUID'))?.name;
const formText = getFormText(currentType, dataSourceName);
function VariableList() {
return (
@ -49,16 +82,9 @@ export const ConfigureCorrelationSourceForm = () => {
);
}
const dataSourceName = getDatasourceSrv().getInstanceSettings(getValues('targetUID'))?.name;
return (
<>
<FieldSet
label={t(
'correlations.source-form.title',
'Configure the data source that will link to {{dataSourceName}} (Step 3 of 3)',
{ dataSourceName }
)}
>
<FieldSet label={formText.title}>
<Trans i18nKey="correlations.source-form.sub-text">
<p>
Define what data source will display the correlation, and what data will replace previously defined
@ -117,14 +143,14 @@ export const ConfigureCorrelationSourceForm = () => {
</Field>
{variables.length > 0 && (
<Card>
<Card.Heading>
<Trans i18nKey="correlations.source-form.heading">Variables used in the target query</Trans>
</Card.Heading>
<Card.Heading>{formText.heading}</Card.Heading>
<Card.Description>
{formText.descriptionPre}
<VariableList />
<br />
<Trans i18nKey="correlations.source-form.description">
You have used following variables in the target query: <VariableList />
<br />A data point needs to provide values to all variables as fields or as transformations output to
make the correlation button appear in the visualization.
A data point needs to provide values to all variables as fields or as transformations output to make the
correlation button appear in the visualization.
<br />
Note: Not every variable needs to be explicitly defined below. A transformation such as{' '}
<span className={styles.variable}>logfmt</span> will create variables for every key/value pair.

View File

@ -1,64 +1,173 @@
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { css } from '@emotion/css';
import { Controller, FieldError, useFormContext, useWatch } from 'react-hook-form';
import { DataSourceInstanceSettings } from '@grafana/data';
import { Field, FieldSet } from '@grafana/ui';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { Field, FieldSet, Input, Select, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { CorrelationType, ExternalTypeTarget } from '../types';
import { QueryEditorField } from './QueryEditorField';
import { useCorrelationsFormContext } from './correlationsFormContext';
import { FormDTO } from './types';
import { assertIsQueryTypeError, FormDTO } from './types';
type CorrelationTypeOptions = {
value: CorrelationType;
label: string;
description: string;
};
export const CORR_TYPES_SELECT: Record<CorrelationType, CorrelationTypeOptions> = {
query: {
value: 'query',
label: 'Query',
description: 'Open a query',
},
external: {
value: 'external',
label: 'External',
description: 'Open an external URL',
},
};
const getStyles = (theme: GrafanaTheme2) => ({
typeSelect: css`
max-width: ${theme.spacing(40)};
`,
});
export const ConfigureCorrelationTargetForm = () => {
const { control, formState } = useFormContext<FormDTO>();
const {
control,
formState: { errors },
} = useFormContext<FormDTO>();
const withDsUID = (fn: Function) => (ds: DataSourceInstanceSettings) => fn(ds.uid);
const { correlation } = useCorrelationsFormContext();
const targetUID: string | undefined = useWatch({ name: 'targetUID' }) || correlation?.targetUID;
const targetUIDFromCorrelation = correlation && 'targetUID' in correlation ? correlation.targetUID : undefined;
const targetUID: string | undefined = useWatch({ name: 'targetUID' }) || targetUIDFromCorrelation;
const correlationType: CorrelationType | undefined = useWatch({ name: 'type' }) || correlation?.type;
const styles = useStyles2(getStyles);
return (
<>
<FieldSet label={t('correlations.target-form.title', 'Setup the target for the correlation (Step 2 of 3)')}>
<Trans i18nKey="correlations.target-form.sub-text">
<p>
Define what data source the correlation will link to, and what query will run when the correlation is
clicked.
Define what the correlation will link to. With the query type, a query will run when the correlation is
clicked. With the external type, clicking the correlation will open a URL.
</p>
</Trans>
<Controller
control={control}
name="targetUID"
name="type"
rules={{
required: { value: true, message: t('correlations.target-form.control-rules', 'This field is required.') },
}}
render={({ field: { onChange, value } }) => (
render={({ field: { onChange, value, ...field } }) => (
<Field
label={t('correlations.target-form.target-label', 'Target')}
description={t(
'correlations.target-form.target-description',
'Specify which data source is queried when the link is clicked'
)}
htmlFor="target"
invalid={!!formState.errors.targetUID}
error={formState.errors.targetUID?.message}
label={t('correlations.target-form.type-label', 'Type')}
description={t('correlations.target-form.target-type-description', 'Specify the type of correlation')}
htmlFor="corrType"
invalid={!!errors.type}
>
<DataSourcePicker
onChange={withDsUID(onChange)}
noDefault
current={value}
inputId="target"
width={32}
disabled={correlation !== undefined}
<Select
className={styles.typeSelect}
value={correlationType}
onChange={(value) => onChange(value.value)}
options={Object.values(CORR_TYPES_SELECT)}
aria-label="correlation type"
/>
</Field>
)}
/>
<QueryEditorField
name="config.target"
dsUid={targetUID}
invalid={!!formState.errors?.config?.target}
error={formState.errors?.config?.target?.message}
/>
{correlationType === 'query' &&
(() => {
assertIsQueryTypeError(errors);
// the assert above will make sure the form dto, which can be either external or query, is for query
// however, the query type has config.target, an object, which doesn't get converted, so we must explicity type it below
return (
<>
<Controller
control={control}
name="targetUID"
rules={{
required: {
value: true,
message: t('correlations.target-form.control-rules', 'This field is required.'),
},
}}
render={({ field: { onChange, value } }) => (
<Field
label={t('correlations.target-form.target-label', 'Target')}
description={t(
'correlations.target-form.target-description-query',
'Specify which data source is queried when the link is clicked'
)}
htmlFor="target"
invalid={!!errors.targetUID}
error={errors.targetUID?.message}
>
<DataSourcePicker
onChange={withDsUID(onChange)}
noDefault
current={value}
inputId="target"
width={32}
disabled={correlation !== undefined}
/>
</Field>
)}
/>
<QueryEditorField
name="config.target"
dsUid={targetUID}
invalid={!!errors?.config?.target}
error={
errors?.config?.target && 'message' in errors?.config?.target
? (errors?.config?.target as FieldError).message
: 'Error'
}
/>
</>
);
})()}
{correlationType === 'external' && (
<>
<Controller
control={control}
name="config.target"
rules={{
required: {
value: true,
message: t('correlations.target-form.control-rules', 'This field is required.'),
},
}}
render={({ field: { onChange, value } }) => {
const castVal = value as ExternalTypeTarget; // the target under "query" type can contain anything a datasource query contains
return (
<Field
label={t('correlations.target-form.target-label', 'Target')}
description={t(
'correlations.target-form.target-description-external',
'Specify the URL that will open when the link is clicked'
)}
htmlFor="target"
>
<Input
value={castVal.url || ''}
onChange={(e) => {
onChange({ url: e.currentTarget.value });
}}
/>
</Field>
);
}}
/>
</>
)}
</FieldSet>
</>
);

View File

@ -52,12 +52,14 @@ export const QueryEditorField = ({ dsUid, invalid, error, name }: Props) => {
name={name}
rules={{
validate: {
hasQueryEditor: () =>
QueryEditor !== undefined ||
t(
'correlations.query-editor.control-rules',
'The selected target data source must export a query editor.'
),
hasQueryEditor: (_, formVals) => {
return formVals.type === 'query' && QueryEditor === undefined
? t(
'correlations.query-editor.control-rules',
'The selected target data source must export a query editor.'
)
: true;
},
},
}}
render={({ field: { value, onChange } }) => {

View File

@ -1,18 +1,36 @@
import { DeepMap, FieldError, FieldErrors } from 'react-hook-form';
import { SupportedTransformationType } from '@grafana/data';
import { t } from 'app/core/internationalization';
import { CorrelationConfig, CorrelationType } from '../types';
import { CorrelationConfigExternal, CorrelationConfigQuery, OmitUnion } from '../types';
export interface FormDTO {
export interface FormExternalDTO {
sourceUID: string;
label: string;
description: string;
type: 'external';
config: CorrelationConfigExternal;
}
export interface FormQueryDTO {
sourceUID: string;
targetUID: string;
label: string;
description: string;
type: CorrelationType;
config: CorrelationConfig;
type: 'query';
config: CorrelationConfigQuery;
}
export type EditFormDTO = Omit<FormDTO, 'targetUID' | 'sourceUID'>;
export type FormDTO = FormExternalDTO | FormQueryDTO;
export function assertIsQueryTypeError(
errors: FieldErrors<FormDTO>
): asserts errors is DeepMap<FormQueryDTO, FieldError> {
// explicitly assert the type so that TS can narrow down FormDTO to FormQueryDTO
}
export type EditFormDTO = OmitUnion<FormDTO, 'targetUID' | 'sourceUID'>;
export type TransformationDTO = {
type: SupportedTransformationType;

View File

@ -1,6 +1,6 @@
import { Correlation } from '../types';
type CorrelationBaseData = Pick<Correlation, 'uid' | 'sourceUID' | 'targetUID'>;
type CorrelationBaseData = Pick<Correlation, 'uid' | 'sourceUID'>;
export const getInputId = (inputName: string, correlation?: CorrelationBaseData) => {
if (!correlation) {

View File

@ -26,30 +26,50 @@ export interface RemoveCorrelationResponse {
message: string;
}
export type CorrelationType = 'query';
export type CorrelationType = 'query' | 'external';
export interface CorrelationConfig {
export type ExternalTypeTarget = { url: string };
export type CorrelationConfigQuery = {
field: string;
target: object; // this contains anything that would go in the query editor, so any extension off DataQuery a datasource would have, and needs to be generic
target: object; // for queries, this contains anything that would go in the query editor, so any extension off DataQuery a datasource would have, and needs to be generic.
transformations?: DataLinkTransformationConfig[];
}
};
export interface Correlation {
export type CorrelationConfigExternal = {
field: string;
target: ExternalTypeTarget; // For external, this simply contains a URL
transformations?: DataLinkTransformationConfig[];
};
type CorrelationBase = {
uid: string;
sourceUID: string;
targetUID: string;
label?: string;
description?: string;
provisioned: boolean;
orgId?: number;
config: CorrelationConfig;
type: CorrelationType;
}
};
export type CorrelationExternal = CorrelationBase & {
type: 'external';
config: CorrelationConfigExternal;
};
export type CorrelationQuery = CorrelationBase & {
type: 'query';
config: CorrelationConfigQuery;
targetUID: string;
};
export type Correlation = CorrelationExternal | CorrelationQuery;
export type GetCorrelationsParams = {
page: number;
};
export type OmitUnion<T, K extends keyof any> = T extends any ? Omit<T, K> : never;
export type RemoveCorrelationParams = Pick<Correlation, 'sourceUID' | 'uid'>;
export type CreateCorrelationParams = Omit<Correlation, 'uid' | 'provisioned'>;
export type UpdateCorrelationParams = Omit<Correlation, 'targetUID' | 'provisioned'>;
export type CreateCorrelationParams = OmitUnion<Correlation, 'uid' | 'provisioned'>;
export type UpdateCorrelationParams = OmitUnion<Correlation, 'targetUID' | 'provisioned'>;

View File

@ -7,6 +7,8 @@ import { useGrafana } from 'app/core/context/GrafanaContext';
import {
Correlation,
CorrelationExternal,
CorrelationQuery,
CreateCorrelationParams,
CreateCorrelationResponse,
GetCorrelationsParams,
@ -24,10 +26,14 @@ export interface CorrelationsResponse {
totalCount: number;
}
export interface CorrelationData extends Omit<Correlation, 'sourceUID' | 'targetUID'> {
source: DataSourceInstanceSettings;
target: DataSourceInstanceSettings;
}
export type CorrelationData =
| (Omit<CorrelationExternal, 'sourceUID'> & {
source: DataSourceInstanceSettings;
})
| (Omit<CorrelationQuery, 'sourceUID' | 'targetUID'> & {
source: DataSourceInstanceSettings;
target: DataSourceInstanceSettings;
});
export interface CorrelationsData {
correlations: CorrelationData[];
@ -36,13 +42,10 @@ export interface CorrelationsData {
totalCount: number;
}
const toEnrichedCorrelationData = ({
sourceUID,
targetUID,
...correlation
}: Correlation): CorrelationData | undefined => {
const toEnrichedCorrelationData = ({ sourceUID, ...correlation }: Correlation): CorrelationData | undefined => {
const sourceDatasource = getDataSourceSrv().getInstanceSettings(sourceUID);
const targetDatasource = getDataSourceSrv().getInstanceSettings(targetUID);
const targetDatasource =
correlation.type === 'query' ? getDataSourceSrv().getInstanceSettings(correlation.targetUID) : undefined;
// According to #72258 we will remove logic to handle orgId=0/null as global correlations.
// This logging is to check if there are any customers who did not migrate existing correlations.
@ -54,21 +57,33 @@ const toEnrichedCorrelationData = ({
if (
sourceDatasource &&
sourceDatasource?.uid !== undefined &&
targetDatasource &&
targetDatasource.uid !== undefined
targetDatasource?.uid !== undefined &&
correlation.type === 'query'
) {
return {
...correlation,
source: sourceDatasource,
target: targetDatasource,
};
} else {
correlationsLogger.logWarning(`Invalid correlation config: Missing source or target.`, {
source: JSON.stringify(sourceDatasource),
target: JSON.stringify(targetDatasource),
});
return undefined;
}
if (
sourceDatasource &&
sourceDatasource?.uid !== undefined &&
targetDatasource?.uid === undefined &&
correlation.type === 'external'
) {
return {
...correlation,
source: sourceDatasource,
};
}
correlationsLogger.logWarning(`Invalid correlation config: Missing source or target.`, {
source: JSON.stringify(sourceDatasource),
target: JSON.stringify(targetDatasource),
});
return undefined;
};
const validSourceFilter = (correlation: CorrelationData | undefined): correlation is CorrelationData => !!correlation;

View File

@ -53,19 +53,31 @@ const decorateDataFrameWithInternalDataLinks = (dataFrame: DataFrame, correlatio
dataFrame.fields.forEach((field) => {
field.config.links = field.config.links?.filter((link) => link.origin !== DataLinkConfigOrigin.Correlations) || [];
correlations.map((correlation) => {
if (correlation.config?.field === field.name) {
const targetQuery = correlation.config?.target || {};
field.config.links!.push({
internal: {
query: { ...targetQuery, datasource: { uid: correlation.target.uid } },
datasourceUid: correlation.target.uid,
datasourceName: correlation.target.name,
transformations: correlation.config?.transformations,
},
url: '',
title: correlation.label || correlation.target.name,
origin: DataLinkConfigOrigin.Correlations,
});
if (correlation.config.field === field.name) {
if (correlation.type === 'query') {
const targetQuery = correlation.config.target || {};
field.config.links!.push({
internal: {
query: { ...targetQuery, datasource: { uid: correlation.target.uid } },
datasourceUid: correlation.target.uid,
datasourceName: correlation.target.name,
},
url: '',
title: correlation.label || correlation.target.name,
origin: DataLinkConfigOrigin.Correlations,
meta: {
transformations: correlation.config.transformations,
},
});
} else if (correlation.type === 'external') {
const externalTarget = correlation.config.target;
field.config.links!.push({
url: externalTarget.url,
title: correlation.label || 'External URL',
origin: DataLinkConfigOrigin.Correlations,
meta: { transformations: correlation.config?.transformations },
});
}
}
});
});

View File

@ -42,6 +42,7 @@ const renderMenuItems = (
: undefined
}
url={link.href}
target={link.target}
className={styles.menuItem}
/>
));

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import { Field } from '@grafana/data';
import { Field, LinkTarget } from '@grafana/data';
import { TraceSpan } from './trace';
@ -20,6 +20,7 @@ export type SpanLinkDef = {
title?: string;
field: Field;
type: SpanLinkType;
target?: LinkTarget;
};
export type SpanLinkFunc = (span: TraceSpan) => SpanLinkDef[] | undefined;

View File

@ -1654,6 +1654,11 @@ function createMultiLinkDataFrame() {
},
datasourceUid: 'loki1_uid',
datasourceName: 'loki1',
},
url: '',
title: 'Test',
origin: DataLinkConfigOrigin.Correlations,
meta: {
transformations: [
{
type: SupportedTransformationType.Regex,
@ -1662,9 +1667,6 @@ function createMultiLinkDataFrame() {
},
],
},
url: '',
title: 'Test',
origin: DataLinkConfigOrigin.Correlations,
},
{
internal: {
@ -1673,6 +1675,11 @@ function createMultiLinkDataFrame() {
},
datasourceUid: 'loki1_uid',
datasourceName: 'loki1',
},
url: '',
title: 'Test',
origin: DataLinkConfigOrigin.Correlations,
meta: {
transformations: [
{
type: SupportedTransformationType.Regex,
@ -1681,9 +1688,6 @@ function createMultiLinkDataFrame() {
},
],
},
url: '',
title: 'Test2',
origin: DataLinkConfigOrigin.Correlations,
},
],
},

View File

@ -110,6 +110,7 @@ export function createSpanLinkFactory({
content: <Icon name="link" title={link.title || 'Link'} />,
field: link.origin,
type: shouldCreatePyroscopeLink ? SpanLinkType.Profiles : SpanLinkType.Unknown,
target: link.target,
};
});

View File

@ -482,6 +482,7 @@ describe('decorateWithCorrelations', () => {
source: datasourceInstance,
target: datasourceInstance,
provisioned: true,
type: 'query',
config: { field: panelData.series[0].fields[0].name },
},
] as CorrelationData[];

View File

@ -127,9 +127,9 @@ export const decorateWithCorrelations = ({
datasourceUid: defaultTargetDatasource.uid,
datasourceName: defaultTargetDatasource.name,
query: { datasource: { uid: defaultTargetDatasource.uid } },
meta: {
correlationData: { resultField: field.name, vars: availableVars, origVars: availableVars },
},
},
meta: {
correlationData: { resultField: field.name, vars: availableVars, origVars: availableVars },
},
});
}

View File

@ -281,6 +281,8 @@ describe('explore links utils', () => {
query: { query: 'http_requests{app=${application} env=${environment}}' },
datasourceUid: 'uid_1',
datasourceName: 'test_ds',
},
meta: {
transformations: [
{ type: SupportedTransformationType.Logfmt },
{ type: SupportedTransformationType.Regex, expression: 'host=(dev|prod)', mapValue: 'environment' },
@ -330,6 +332,8 @@ describe('explore links utils', () => {
query: { query: 'http_requests{env=${msg}}' },
datasourceUid: 'uid_1',
datasourceName: 'test_ds',
},
meta: {
transformations: [
{ type: SupportedTransformationType.Regex, expression: 'fieldA=(asparagus|broccoli)' },
{ type: SupportedTransformationType.Regex, expression: 'fieldB=(apple|banana)' },
@ -372,6 +376,8 @@ describe('explore links utils', () => {
query: { query: 'http_requests{env=${msg}}' },
datasourceUid: 'uid_1',
datasourceName: 'test_ds',
},
meta: {
transformations: [
{
type: SupportedTransformationType.Regex,
@ -427,8 +433,8 @@ describe('explore links utils', () => {
query: { query: 'http_requests{app=${application} isOnline=${online}}' },
datasourceUid: 'uid_1',
datasourceName: 'test_ds',
transformations: [{ type: SupportedTransformationType.Logfmt }],
},
meta: { transformations: [{ type: SupportedTransformationType.Logfmt }] },
};
const { field, range, dataFrame } = setup(transformationLink, true, {
@ -466,8 +472,8 @@ describe('explore links utils', () => {
query: { query: 'http_requests{app=${application}}' },
datasourceUid: 'uid_1',
datasourceName: 'test_ds',
transformations: [{ type: SupportedTransformationType.Logfmt, field: 'fieldNamedInTransformation' }],
},
meta: { transformations: [{ type: SupportedTransformationType.Logfmt, field: 'fieldNamedInTransformation' }] },
};
// fieldWithLink has the transformation, but the transformation has defined fieldNamedInTransformation as its field to transform
@ -518,6 +524,8 @@ describe('explore links utils', () => {
query: { query: 'http_requests{app=${application} env=${environment}}' },
datasourceUid: 'uid_1',
datasourceName: 'test_ds',
},
meta: {
transformations: [
{
type: SupportedTransformationType.Regex,
@ -598,6 +606,8 @@ describe('explore links utils', () => {
query: { query: 'http_requests{app=${application} env=${diffVar}}' },
datasourceUid: 'uid_1',
datasourceName: 'test_ds',
},
meta: {
transformations: [{ type: SupportedTransformationType.Logfmt }],
},
};
@ -623,6 +633,8 @@ describe('explore links utils', () => {
query: { query: 'http_requests{app=test}' },
datasourceUid: 'uid_1',
datasourceName: 'test_ds',
},
meta: {
transformations: [{ type: SupportedTransformationType.Logfmt }],
},
};

View File

@ -49,7 +49,7 @@ const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasRequiredPermissionsFilte
* for internal links and undefined for non-internal links
*/
export interface ExploreFieldLinkModel extends LinkModel<Field> {
variables?: VariableInterpolation[];
variables: VariableInterpolation[];
}
const DATA_LINK_USAGE_KEY = 'grafana_data_link_clicked';
@ -65,7 +65,7 @@ export const exploreDataLinkPostProcessorFactory = (
const { field, dataLinkScopedVars: vars, frame: dataFrame, link, linkModel } = options;
const { valueRowIndex: rowIndex } = options.config;
if (!link.internal || rowIndex === undefined) {
if (rowIndex === undefined) {
return linkModel;
}
@ -159,57 +159,57 @@ export const getFieldLinksForExplore = (options: {
});
const fieldLinks = links.map((link) => {
if (!link.internal) {
const replace: InterpolateFunction = (value, vars) =>
getTemplateSrv().replace(value, { ...vars, ...scopedVars });
let internalLinkSpecificVars: ScopedVars = {};
if (link.meta?.transformations) {
link.meta?.transformations.forEach((transformation) => {
let fieldValue;
if (transformation.field) {
const transformField = dataFrame?.fields.find((field) => field.name === transformation.field);
fieldValue = transformField?.values[rowIndex];
} else {
fieldValue = field.values[rowIndex];
}
const linkModel = getLinkSrv().getDataLinkUIModel(link, replace, field);
if (!linkModel.title) {
linkModel.title = getTitleFromHref(linkModel.href);
}
return linkModel;
internalLinkSpecificVars = {
...internalLinkSpecificVars,
...getTransformationVars(transformation, fieldValue, field.name),
};
});
}
const allVars = { ...scopedVars, ...internalLinkSpecificVars };
const variableData = getVariableUsageInfo(link, allVars);
let variables: VariableInterpolation[] = [];
// if the link has no variables (static link), add it with the right key but an empty value so we know what field the static link is associated with
if (variableData.variables.length === 0) {
const fieldName = field.name.toString();
variables.push({ variableName: fieldName, value: '', match: '' });
} else {
let internalLinkSpecificVars: ScopedVars = {};
if (link.internal?.transformations) {
link.internal?.transformations.forEach((transformation) => {
let fieldValue;
if (transformation.field) {
const transformField = dataFrame?.fields.find((field) => field.name === transformation.field);
fieldValue = transformField?.values[rowIndex];
} else {
fieldValue = field.values[rowIndex];
}
variables = variableData.variables;
}
if (variableData.allVariablesDefined) {
if (!link.internal) {
const replace: InterpolateFunction = (value, vars) =>
getTemplateSrv().replace(value, { ...vars, ...allVars, ...scopedVars });
internalLinkSpecificVars = {
...internalLinkSpecificVars,
...getTransformationVars(transformation, fieldValue, field.name),
};
});
}
const allVars = { ...scopedVars, ...internalLinkSpecificVars };
const variableData = getVariableUsageInfo(link, allVars);
let variables: VariableInterpolation[] = [];
// if the link has no variables (static link), add it with the right key but an empty value so we know what field the static link is associated with
if (variableData.variables.length === 0) {
const fieldName = field.name.toString();
variables.push({ variableName: fieldName, value: '', match: '' });
const linkModel = getLinkSrv().getDataLinkUIModel(link, replace, field);
if (!linkModel.title) {
linkModel.title = getTitleFromHref(linkModel.href);
}
linkModel.target = '_blank';
return { ...linkModel, variables: variables };
} else {
variables = variableData.variables;
}
const splitFnWithTracking = (options?: SplitOpenOptions<DataQuery>) => {
reportInteraction(DATA_LINK_USAGE_KEY, {
origin: link.origin || DataLinkConfigOrigin.Datasource,
app: CoreApp.Explore,
internal: true,
});
const splitFnWithTracking = (options?: SplitOpenOptions<DataQuery>) => {
reportInteraction(DATA_LINK_USAGE_KEY, {
origin: link.origin || DataLinkConfigOrigin.Datasource,
app: CoreApp.Explore,
internal: true,
});
splitOpenFn?.(options);
};
splitOpenFn?.(options);
};
if (variableData.allVariablesDefined) {
const internalLink = mapInternalLinkToExplore({
link,
internalLink: link.internal,
@ -221,9 +221,9 @@ export const getFieldLinksForExplore = (options: {
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
});
return { ...internalLink, variables: variables };
} else {
return undefined;
}
} else {
return undefined;
}
});
return fieldLinks.filter((link): link is ExploreFieldLinkModel => !!link);

View File

@ -448,6 +448,7 @@ describe('logParser', () => {
},
title: 'test',
target: '_self',
variables: [],
};
const fieldWithVarLink: FieldDef = {

View File

@ -35,8 +35,16 @@ export const getAllFields = (
export const createLogLineLinks = (hiddenFieldsWithLinks: FieldDef[]): FieldDef[] => {
let fieldsWithLinksFromVariableMap: FieldDef[] = [];
hiddenFieldsWithLinks.forEach((linkField) => {
linkField.links?.forEach((link: ExploreFieldLinkModel) => {
if (link.variables) {
linkField.links?.forEach((link: LinkModel | ExploreFieldLinkModel) => {
if ('variables' in link && link.variables.length > 0) {
// convert ExploreFieldLinkModel to LinkModel by omitting variables field
const fieldDefFromLink: LinkModel = {
href: link.href,
title: link.title,
origin: link.origin,
onClick: link.onClick,
target: link.target,
};
const variableKeys = link.variables.map((variable) => {
const varName = variable.variableName;
const fieldPath = variable.fieldPath ? `.${variable.fieldPath}` : '';
@ -46,7 +54,7 @@ export const createLogLineLinks = (hiddenFieldsWithLinks: FieldDef[]): FieldDef[
fieldsWithLinksFromVariableMap.push({
keys: variableKeys,
values: variableValues,
links: [link],
links: [fieldDefFromLink],
fieldIndex: linkField.fieldIndex,
});
}

View File

@ -462,23 +462,30 @@
},
"source-form": {
"control-required": "This field is required.",
"description": "You have used following variables in the target query: <1></1><2></2>A data point needs to provide values to all variables as fields or as transformations output to make the correlation button appear in the visualization.<4></4>Note: Not every variable needs to be explicitly defined below. A transformation such as <7>logfmt</7> will create variables for every key/value pair.",
"heading": "Variables used in the target query",
"description": "A data point needs to provide values to all variables as fields or as transformations output to make the correlation button appear in the visualization.<1></1>Note: Not every variable needs to be explicitly defined below. A transformation such as <4>logfmt</4> will create variables for every key/value pair.",
"description-external-pre": "You have used following variables in the target URL:",
"description-query-pre": "You have used following variables in the target query:",
"external-title": "Configure the data source that will use the URL (Step 3 of 3)",
"heading-external": "Variables used in the target URL",
"heading-query": "Variables used in the target query",
"query-title": "Configure the data source that will link to {{dataSourceName}} (Step 3 of 3)",
"results-description": "The link will be shown next to the value of this field",
"results-label": "Results field",
"results-required": "This field is required.",
"source-description": "Results from selected source data source have links displayed in the panel",
"source-label": "Source",
"sub-text": "<0>Define what data source will display the correlation, and what data will replace previously defined variables.</0>",
"title": "Configure the data source that will link to {{dataSourceName}} (Step 3 of 3)"
"sub-text": "<0>Define what data source will display the correlation, and what data will replace previously defined variables.</0>"
},
"sub-title": "Define how data living in different data sources relates to each other. Read more in the <2>documentation<1></1></2>",
"target-form": {
"control-rules": "This field is required.",
"sub-text": "<0>Define what data source the correlation will link to, and what query will run when the correlation is clicked.</0>",
"target-description": "Specify which data source is queried when the link is clicked",
"sub-text": "<0>Define what the correlation will link to. With the query type, a query will run when the correlation is clicked. With the external type, clicking the correlation will open a URL.</0>",
"target-description-external": "Specify the URL that will open when the link is clicked",
"target-description-query": "Specify which data source is queried when the link is clicked",
"target-label": "Target",
"title": "Setup the target for the correlation (Step 2 of 3)"
"target-type-description": "Specify the type of correlation",
"title": "Setup the target for the correlation (Step 2 of 3)",
"type-label": "Type"
},
"trans-details": {
"logfmt-description": "Parse provided field with logfmt to get variables",

View File

@ -462,23 +462,30 @@
},
"source-form": {
"control-required": "Ŧĥįş ƒįęľđ įş řęqūįřęđ.",
"description": "Ÿőū ĥävę ūşęđ ƒőľľőŵįʼnģ väřįäþľęş įʼn ŧĥę ŧäřģęŧ qūęřy: <1></1><2></2>Å đäŧä pőįʼnŧ ʼnęęđş ŧő přővįđę väľūęş ŧő äľľ väřįäþľęş äş ƒįęľđş őř äş ŧřäʼnşƒőřmäŧįőʼnş őūŧpūŧ ŧő mäĸę ŧĥę čőřřęľäŧįőʼn þūŧŧőʼn äppęäř įʼn ŧĥę vįşūäľįžäŧįőʼn.<4></4>Ńőŧę: Ńőŧ ęvęřy väřįäþľę ʼnęęđş ŧő þę ęχpľįčįŧľy đęƒįʼnęđ þęľőŵ. Å ŧřäʼnşƒőřmäŧįőʼn şūčĥ äş <7>ľőģƒmŧ</7> ŵįľľ čřęäŧę väřįäþľęş ƒőř ęvęřy ĸęy/väľūę päįř.",
"heading": "Väřįäþľęş ūşęđ įʼn ŧĥę ŧäřģęŧ qūęřy",
"description": "Å đäŧä pőįʼnŧ ʼnęęđş ŧő přővįđę väľūęş ŧő äľľ väřįäþľęş äş ƒįęľđş őř äş ŧřäʼnşƒőřmäŧįőʼnş őūŧpūŧ ŧő mäĸę ŧĥę čőřřęľäŧįőʼn þūŧŧőʼn äppęäř įʼn ŧĥę vįşūäľįžäŧįőʼn.<1></1>Ńőŧę: Ńőŧ ęvęřy väřįäþľę ʼnęęđş ŧő þę ęχpľįčįŧľy đęƒįʼnęđ þęľőŵ. Å ŧřäʼnşƒőřmäŧįőʼn şūčĥ äş <4>ľőģƒmŧ</4> ŵįľľ čřęäŧę väřįäþľęş ƒőř ęvęřy ĸęy/väľūę päįř.",
"description-external-pre": "Ÿőū ĥävę ūşęđ ƒőľľőŵįʼnģ väřįäþľęş įʼn ŧĥę ŧäřģęŧ ŮŖĿ:",
"description-query-pre": "Ÿőū ĥävę ūşęđ ƒőľľőŵįʼnģ väřįäþľęş įʼn ŧĥę ŧäřģęŧ qūęřy:",
"external-title": "Cőʼnƒįģūřę ŧĥę đäŧä şőūřčę ŧĥäŧ ŵįľľ ūşę ŧĥę ŮŖĿ (Ŝŧęp 3 őƒ 3)",
"heading-external": "Väřįäþľęş ūşęđ įʼn ŧĥę ŧäřģęŧ ŮŖĿ",
"heading-query": "Väřįäþľęş ūşęđ įʼn ŧĥę ŧäřģęŧ qūęřy",
"query-title": "Cőʼnƒįģūřę ŧĥę đäŧä şőūřčę ŧĥäŧ ŵįľľ ľįʼnĸ ŧő {{dataSourceName}} (Ŝŧęp 3 őƒ 3)",
"results-description": "Ŧĥę ľįʼnĸ ŵįľľ þę şĥőŵʼn ʼnęχŧ ŧő ŧĥę väľūę őƒ ŧĥįş ƒįęľđ",
"results-label": "Ŗęşūľŧş ƒįęľđ",
"results-required": "Ŧĥįş ƒįęľđ įş řęqūįřęđ.",
"source-description": "Ŗęşūľŧş ƒřőm şęľęčŧęđ şőūřčę đäŧä şőūřčę ĥävę ľįʼnĸş đįşpľäyęđ įʼn ŧĥę päʼnęľ",
"source-label": "Ŝőūřčę",
"sub-text": "<0>Đęƒįʼnę ŵĥäŧ đäŧä şőūřčę ŵįľľ đįşpľäy ŧĥę čőřřęľäŧįőʼn, äʼnđ ŵĥäŧ đäŧä ŵįľľ řępľäčę přęvįőūşľy đęƒįʼnęđ väřįäþľęş.</0>",
"title": "Cőʼnƒįģūřę ŧĥę đäŧä şőūřčę ŧĥäŧ ŵįľľ ľįʼnĸ ŧő {{dataSourceName}} (Ŝŧęp 3 őƒ 3)"
"sub-text": "<0>Đęƒįʼnę ŵĥäŧ đäŧä şőūřčę ŵįľľ đįşpľäy ŧĥę čőřřęľäŧįőʼn, äʼnđ ŵĥäŧ đäŧä ŵįľľ řępľäčę přęvįőūşľy đęƒįʼnęđ väřįäþľęş.</0>"
},
"sub-title": "Đęƒįʼnę ĥőŵ đäŧä ľįvįʼnģ įʼn đįƒƒęřęʼnŧ đäŧä şőūřčęş řęľäŧęş ŧő ęäčĥ őŧĥęř. Ŗęäđ mőřę įʼn ŧĥę <2>đőčūmęʼnŧäŧįőʼn<1></1></2>",
"target-form": {
"control-rules": "Ŧĥįş ƒįęľđ įş řęqūįřęđ.",
"sub-text": "<0>Đęƒįʼnę ŵĥäŧ đäŧä şőūřčę ŧĥę čőřřęľäŧįőʼn ŵįľľ ľįʼnĸ ŧő, äʼnđ ŵĥäŧ qūęřy ŵįľľ řūʼn ŵĥęʼn ŧĥę čőřřęľäŧįőʼn įş čľįčĸęđ.</0>",
"target-description": "Ŝpęčįƒy ŵĥįčĥ đäŧä şőūřčę įş qūęřįęđ ŵĥęʼn ŧĥę ľįʼnĸ įş čľįčĸęđ",
"sub-text": "<0>Đęƒįʼnę ŵĥäŧ ŧĥę čőřřęľäŧįőʼn ŵįľľ ľįʼnĸ ŧő. Ŵįŧĥ ŧĥę qūęřy ŧypę, ä qūęřy ŵįľľ řūʼn ŵĥęʼn ŧĥę čőřřęľäŧįőʼn įş čľįčĸęđ. Ŵįŧĥ ŧĥę ęχŧęřʼnäľ ŧypę, čľįčĸįʼnģ ŧĥę čőřřęľäŧįőʼn ŵįľľ őpęʼn ä ŮŖĿ.</0>",
"target-description-external": "Ŝpęčįƒy ŧĥę ŮŖĿ ŧĥäŧ ŵįľľ őpęʼn ŵĥęʼn ŧĥę ľįʼnĸ įş čľįčĸęđ",
"target-description-query": "Ŝpęčįƒy ŵĥįčĥ đäŧä şőūřčę įş qūęřįęđ ŵĥęʼn ŧĥę ľįʼnĸ įş čľįčĸęđ",
"target-label": "Ŧäřģęŧ",
"title": "Ŝęŧūp ŧĥę ŧäřģęŧ ƒőř ŧĥę čőřřęľäŧįőʼn (Ŝŧęp 2 őƒ 3)"
"target-type-description": "Ŝpęčįƒy ŧĥę ŧypę őƒ čőřřęľäŧįőʼn",
"title": "Ŝęŧūp ŧĥę ŧäřģęŧ ƒőř ŧĥę čőřřęľäŧįőʼn (Ŝŧęp 2 őƒ 3)",
"type-label": "Ŧypę"
},
"trans-details": {
"logfmt-description": "Päřşę přővįđęđ ƒįęľđ ŵįŧĥ ľőģƒmŧ ŧő ģęŧ väřįäþľęş",

View File

@ -4026,6 +4026,7 @@
"type": "object"
},
"CorrelationType": {
"description": "the type of correlation, either query for containing query information, or external for containing an external URL\n+enum",
"type": "string"
},
"CounterResetHint": {