Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6c701cf5b | ||
|
|
c42e00bdac | ||
|
|
f7a938db9a | ||
|
|
b9513983ce |
2
go.mod
2
go.mod
@@ -64,7 +64,7 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/alerting v0.0.0-20250110220613-267368fd1968 // @grafana/alerting-backend
|
||||
github.com/grafana/alerting v0.0.0-20250123190916-7b528a0bc1d5 // @grafana/alerting-backend
|
||||
github.com/grafana/authlib v0.0.0-20250108202437-7a039176d884 // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 // @grafana/identity-access-team
|
||||
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1489,8 +1489,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250110220613-267368fd1968 h1:dSA0aOMzNnpBmYcmwv2OT5Is4kE7rubdSxo9GZSePAY=
|
||||
github.com/grafana/alerting v0.0.0-20250110220613-267368fd1968/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
|
||||
github.com/grafana/alerting v0.0.0-20250123190916-7b528a0bc1d5 h1:mZezO6ccQl6AZv55f9JsPMph3eoHCofIJra2yhKzYMo=
|
||||
github.com/grafana/alerting v0.0.0-20250123190916-7b528a0bc1d5/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
|
||||
github.com/grafana/authlib v0.0.0-20250108202437-7a039176d884 h1:MSRBiQrSJZ+iowjU4Tgtq8+uC5/cs9XdtUdSWCNHrNE=
|
||||
github.com/grafana/authlib v0.0.0-20250108202437-7a039176d884/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI=
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
|
||||
|
||||
@@ -1697,6 +1697,7 @@ github.com/gomarkdown/markdown v0.0.0-20230922112808-5421fefb8386/go.mod h1:JDGc
|
||||
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
||||
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
|
||||
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
|
||||
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/cel-go v0.17.1/go.mod h1:HXZKzB0LXqer5lHHgfWAnlYwJaQBDKMjxjulNQzhwhY=
|
||||
github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-jsonnet v0.18.0 h1:/6pTy6g+Jh1a1I2UMoAODkqELFiVIdOxbNwv0DDzoOg=
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"generate-apis": "rtk-query-codegen-openapi ./scripts/generate-rtk-apis.ts"
|
||||
},
|
||||
"grafana": {
|
||||
"whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v11-4/",
|
||||
"whatsNewUrl": "https://grafana.com/docs/grafana/next/whatsnew/whats-new-in-v11-5/",
|
||||
"releaseNotesUrl": "https://grafana.com/docs/grafana/next/release-notes/"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -42,7 +42,10 @@ describe('ensureColumns transformer', () => {
|
||||
options: {},
|
||||
};
|
||||
|
||||
const data = [seriesA, seriesBC];
|
||||
const data = [
|
||||
{ refId: 'A', ...seriesA },
|
||||
{ refId: 'B', ...seriesBC },
|
||||
];
|
||||
|
||||
await expect(transformDataFrame([cfg], data)).toEmitValuesWith((received) => {
|
||||
const filtered = received[0];
|
||||
@@ -109,6 +112,7 @@ describe('ensureColumns transformer', () => {
|
||||
},
|
||||
],
|
||||
"length": 2,
|
||||
"refId": "joinByField-A-B",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -592,5 +592,6 @@ export function histogramFieldsToFrame(info: HistogramFields, theme?: GrafanaThe
|
||||
type: DataFrameType.Histogram,
|
||||
},
|
||||
fields: [info.xMin, info.xMax, ...info.counts],
|
||||
refId: `${DataTransformerID.histogram}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export const joinByFieldTransformer: SynchronousDataTransformerInfo<JoinByFieldO
|
||||
}
|
||||
const joined = joinDataFrames({ frames: data, joinBy, mode: options.mode });
|
||||
if (joined) {
|
||||
joined.refId = `${DataTransformerID.joinByField}-${data.map((frame) => frame.refId).join('-')}`;
|
||||
return [joined];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ export const mergeTransformer: DataTransformerInfo<MergeTransformerOptions> = {
|
||||
const fieldNames = new Set<string>();
|
||||
const fieldIndexByName: Record<string, Record<number, number>> = {};
|
||||
const fieldNamesForKey: string[] = [];
|
||||
const dataFrame = new MutableDataFrame();
|
||||
const dataFrame = new MutableDataFrame({
|
||||
refId: `${DataTransformerID.merge}-${data.map((frame) => frame.refId).join('-')}`,
|
||||
fields: [],
|
||||
});
|
||||
|
||||
for (let frameIndex = 0; frameIndex < data.length; frameIndex++) {
|
||||
const frame = data[frameIndex];
|
||||
|
||||
@@ -56,7 +56,9 @@ export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> =
|
||||
|
||||
// Add a row for each series
|
||||
const res = reduceSeriesToRows(data, matcher, options.reducers, options.labelsToFields);
|
||||
return res ? [res] : [];
|
||||
return res
|
||||
? [{ ...res, refId: `${DataTransformerID.reduce}-${data.map((frame) => frame.refId).join('-')}` }]
|
||||
: [];
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
@@ -37,7 +37,10 @@ export const seriesToRowsTransformer: DataTransformerInfo<SeriesToRowsTransforme
|
||||
|
||||
const timeFieldByIndex: Record<number, number> = {};
|
||||
const targetFields = new Set<string>();
|
||||
const dataFrame = new MutableDataFrame();
|
||||
const dataFrame = new MutableDataFrame({
|
||||
refId: `${DataTransformerID.seriesToRows}-${data.map((frame) => frame.refId).join('-')}`,
|
||||
fields: [],
|
||||
});
|
||||
const metricField: Field = {
|
||||
name: TIME_SERIES_METRIC_FIELD_NAME,
|
||||
values: [],
|
||||
|
||||
@@ -80,6 +80,7 @@ function transposeDataFrame(options: TransposeTransformerOptions, data: DataFram
|
||||
...frame,
|
||||
fields: newFields,
|
||||
length: Math.max(...newFields.map((field) => field.values.length)),
|
||||
refId: `${DataTransformerID.transpose}-${frame.refId}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { ReactMonacoEditorProps } from './types';
|
||||
monacoEditorLoader.config({ monaco });
|
||||
|
||||
export const ReactMonacoEditor = (props: ReactMonacoEditorProps) => {
|
||||
const { beforeMount, options, ...restProps } = props;
|
||||
const { beforeMount, onMount, options, ...restProps } = props;
|
||||
|
||||
const theme = useTheme2();
|
||||
const onMonacoBeforeMount = useCallback(
|
||||
@@ -31,6 +31,15 @@ export const ReactMonacoEditor = (props: ReactMonacoEditorProps) => {
|
||||
}}
|
||||
theme={theme.isDark ? 'grafana-dark' : 'grafana-light'}
|
||||
beforeMount={onMonacoBeforeMount}
|
||||
onMount={(editor, monaco) => {
|
||||
// we use a custom font in our monaco editor
|
||||
// we need monaco to remeasure the fonts after they are loaded to prevent alignment issues
|
||||
// see https://github.com/microsoft/monaco-editor/issues/648#issuecomment-564978560
|
||||
document.fonts.ready.then(() => {
|
||||
monaco.editor.remeasureFonts();
|
||||
});
|
||||
onMount?.(editor, monaco);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -267,7 +267,7 @@ type ThreemaIntegration struct {
|
||||
type VictoropsIntegration struct {
|
||||
DisableResolveMessage *bool `json:"-" yaml:"-" hcl:"disable_resolve_message"`
|
||||
|
||||
URL string `json:"url" yaml:"url" hcl:"url"`
|
||||
URL Secret `json:"url" yaml:"url" hcl:"url"`
|
||||
|
||||
MessageType *string `json:"messageType,omitempty" yaml:"messageType,omitempty" hcl:"message_type"`
|
||||
Title *string `json:"title,omitempty" yaml:"title,omitempty" hcl:"title"`
|
||||
|
||||
@@ -388,6 +388,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
||||
Placeholder: "VictorOps url",
|
||||
PropertyName: "url",
|
||||
Required: true,
|
||||
Secure: true,
|
||||
},
|
||||
{ // New in 8.0.
|
||||
Label: "Message Type",
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestGetSecretKeysForContactPointType(t *testing.T) {
|
||||
{receiverType: "kafka", expectedSecretFields: []string{"password"}},
|
||||
{receiverType: "email", expectedSecretFields: []string{}},
|
||||
{receiverType: "pagerduty", expectedSecretFields: []string{"integrationKey"}},
|
||||
{receiverType: "victorops", expectedSecretFields: []string{}},
|
||||
{receiverType: "victorops", expectedSecretFields: []string{"url"}},
|
||||
{receiverType: "oncall", expectedSecretFields: []string{"password", "authorization_credentials"}},
|
||||
{receiverType: "pushover", expectedSecretFields: []string{"apiToken", "userKey"}},
|
||||
{receiverType: "slack", expectedSecretFields: []string{"token", "url"}},
|
||||
|
||||
@@ -485,6 +485,14 @@ func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receive
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// We re-encrypt the existing receiver to ensure any unencrypted secure fields that are correctly encrypted, note this should NOT re-encrypt secure fields that are already encrypted.
|
||||
// This is rare, but can happen if a receiver is created with unencrypted secure fields and then the secure option is added later.
|
||||
// Preferably, this would be handled by receiver config versions and migrations but for now this is a good safety net.
|
||||
err = existing.Encrypt(rs.encryptor(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
span.AddEvent("Loaded current receiver", trace.WithAttributes(
|
||||
attribute.String("concurrency_token", revision.ConcurrencyToken),
|
||||
attribute.String("receiver", existing.Name),
|
||||
|
||||
@@ -583,6 +583,44 @@ func TestReceiverService_Update(t *testing.T) {
|
||||
), rm.Encrypted(models.Base64Enrypt)),
|
||||
expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone},
|
||||
},
|
||||
{
|
||||
name: "encrypts previously unencrypted secure fields",
|
||||
user: writer,
|
||||
receiver: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(
|
||||
models.CopyIntegrationWith(slackIntegration, im.AddSetting("token", "unencryptedValue"))),
|
||||
),
|
||||
existing: util.Pointer(models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(
|
||||
models.CopyIntegrationWith(slackIntegration,
|
||||
im.AddSetting("token", "unencryptedValue"), // This will get encrypted.
|
||||
),
|
||||
))),
|
||||
expectedUpdate: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(
|
||||
models.CopyIntegrationWith(slackIntegration,
|
||||
im.AddSecureSetting("token", "dW5lbmNyeXB0ZWRWYWx1ZQ==")),
|
||||
), rm.Encrypted(models.Base64Enrypt)),
|
||||
expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone},
|
||||
},
|
||||
{
|
||||
// This test is important for covering the rare case when an existing field is marked as secure.
|
||||
// The UI will receive the field as secure and, if unchanged, will pass it back on update as a secureField instead of a Setting.
|
||||
name: "encrypts previously unencrypted secure fields when passed in as secureFields",
|
||||
user: writer,
|
||||
receiver: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(
|
||||
models.CopyIntegrationWith(slackIntegration, im.AddSetting("newField", "newValue"))),
|
||||
),
|
||||
secureFields: map[string][]string{slackIntegration.UID: {"token"}},
|
||||
existing: util.Pointer(models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(
|
||||
models.CopyIntegrationWith(slackIntegration,
|
||||
im.AddSetting("token", "unencryptedValue"), // This will get encrypted.
|
||||
),
|
||||
))),
|
||||
expectedUpdate: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(
|
||||
models.CopyIntegrationWith(slackIntegration,
|
||||
im.AddSetting("newField", "newValue"),
|
||||
im.AddSecureSetting("token", "dW5lbmNyeXB0ZWRWYWx1ZQ==")),
|
||||
), rm.Encrypted(models.Base64Enrypt)),
|
||||
expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone},
|
||||
},
|
||||
{
|
||||
name: "doesn't copy existing unsecure fields",
|
||||
user: writer,
|
||||
@@ -684,8 +722,22 @@ func TestReceiverService_Update(t *testing.T) {
|
||||
sut := createReceiverServiceSut(t, &secretsService)
|
||||
|
||||
if tc.existing != nil {
|
||||
created, err := sut.CreateReceiver(context.Background(), tc.existing, tc.user.GetOrgID(), tc.user)
|
||||
// Create route after receivers as they will be referenced.
|
||||
revision, err := sut.cfgStore.Get(context.Background(), tc.user.GetOrgID())
|
||||
require.NoError(t, err)
|
||||
result, err := revision.CreateReceiver(tc.existing)
|
||||
require.NoError(t, err)
|
||||
|
||||
created, err := PostableApiReceiverToReceiver(result, tc.existing.Provenance)
|
||||
require.NoError(t, err)
|
||||
err = sut.cfgStore.Save(context.Background(), revision, tc.user.GetOrgID())
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, integration := range created.Integrations {
|
||||
target := definitions.EmbeddedContactPoint{UID: integration.UID}
|
||||
err = sut.provisioningStore.SetProvenance(context.Background(), &target, tc.user.GetOrgID(), created.Provenance)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if tc.version == "" {
|
||||
tc.version = created.Version
|
||||
|
||||
@@ -169,7 +169,7 @@ require (
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250110220613-267368fd1968 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250123190916-7b528a0bc1d5 // indirect
|
||||
github.com/grafana/authlib v0.0.0-20250108202437-7a039176d884 // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 // indirect
|
||||
|
||||
@@ -543,8 +543,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20250110220613-267368fd1968 h1:dSA0aOMzNnpBmYcmwv2OT5Is4kE7rubdSxo9GZSePAY=
|
||||
github.com/grafana/alerting v0.0.0-20250110220613-267368fd1968/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
|
||||
github.com/grafana/alerting v0.0.0-20250123190916-7b528a0bc1d5 h1:mZezO6ccQl6AZv55f9JsPMph3eoHCofIJra2yhKzYMo=
|
||||
github.com/grafana/alerting v0.0.0-20250123190916-7b528a0bc1d5/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
|
||||
github.com/grafana/authlib v0.0.0-20250108202437-7a039176d884 h1:MSRBiQrSJZ+iowjU4Tgtq8+uC5/cs9XdtUdSWCNHrNE=
|
||||
github.com/grafana/authlib v0.0.0-20250108202437-7a039176d884/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI=
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
|
||||
|
||||
@@ -116,7 +116,7 @@ require (
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250110220613-267368fd1968 // indirect
|
||||
github.com/grafana/alerting v0.0.0-20250123190916-7b528a0bc1d5 // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/grafana-aws-sdk v0.31.5 // indirect
|
||||
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.2 // indirect
|
||||
|
||||
@@ -398,8 +398,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
|
||||
github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/grafana/alerting v0.0.0-20250110220613-267368fd1968 h1:dSA0aOMzNnpBmYcmwv2OT5Is4kE7rubdSxo9GZSePAY=
|
||||
github.com/grafana/alerting v0.0.0-20250110220613-267368fd1968/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
|
||||
github.com/grafana/alerting v0.0.0-20250123190916-7b528a0bc1d5 h1:mZezO6ccQl6AZv55f9JsPMph3eoHCofIJra2yhKzYMo=
|
||||
github.com/grafana/alerting v0.0.0-20250123190916-7b528a0bc1d5/go.mod h1:QsnoKX/iYZxA4Cv+H+wC7uxutBD8qi8ZW5UJvD2TYmU=
|
||||
github.com/grafana/authlib v0.0.0-20250108202437-7a039176d884 h1:MSRBiQrSJZ+iowjU4Tgtq8+uC5/cs9XdtUdSWCNHrNE=
|
||||
github.com/grafana/authlib v0.0.0-20250108202437-7a039176d884/go.mod h1:x7df73G3xuSD35Xv9cjaMLyPJCgM9Z/Wj5ISouoAfiI=
|
||||
github.com/grafana/authlib/claims v0.0.0-20241202085737-df90af04f335 h1:3DHH81RJCi8Bcgn2MdBh7vgWUshmAFjZzBCVuxiQ0uk=
|
||||
|
||||
@@ -1,26 +1,17 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { createElement, useEffect, useMemo, useState } from 'react';
|
||||
import { mergeMap } from 'rxjs/operators';
|
||||
import { createElement, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
DataTransformerConfig,
|
||||
GrafanaTheme2,
|
||||
transformDataFrame,
|
||||
TransformerRegistryItem,
|
||||
getFrameMatchers,
|
||||
DataTransformContext,
|
||||
} from '@grafana/data';
|
||||
import { DataFrame, DataTransformerConfig, GrafanaTheme2, TransformerRegistryItem } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { getTemplateSrv } from '@grafana/runtime';
|
||||
import { Icon, JSONFormatter, useStyles2, Drawer } from '@grafana/ui';
|
||||
|
||||
import { TransformationsEditorTransformation } from './types';
|
||||
|
||||
interface TransformationEditorProps {
|
||||
input: DataFrame[];
|
||||
output: DataFrame[];
|
||||
debugMode?: boolean;
|
||||
index: number;
|
||||
data: DataFrame[];
|
||||
uiConfig: TransformerRegistryItem;
|
||||
configs: TransformationsEditorTransformation[];
|
||||
onChange: (index: number, config: DataTransformerConfig) => void;
|
||||
@@ -28,45 +19,18 @@ interface TransformationEditorProps {
|
||||
}
|
||||
|
||||
export const TransformationEditor = ({
|
||||
input,
|
||||
output,
|
||||
debugMode,
|
||||
index,
|
||||
data,
|
||||
uiConfig,
|
||||
configs,
|
||||
onChange,
|
||||
toggleShowDebug,
|
||||
}: TransformationEditorProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [input, setInput] = useState<DataFrame[]>([]);
|
||||
const [output, setOutput] = useState<DataFrame[]>([]);
|
||||
const config = useMemo(() => configs[index], [configs, index]);
|
||||
|
||||
useEffect(() => {
|
||||
const config = configs[index].transformation;
|
||||
const matcher = config.filter?.options ? getFrameMatchers(config.filter) : undefined;
|
||||
const inputTransforms = configs.slice(0, index).map((t) => t.transformation);
|
||||
const outputTransforms = configs.slice(index, index + 1).map((t) => t.transformation);
|
||||
|
||||
const ctx: DataTransformContext = {
|
||||
interpolate: (v: string) => getTemplateSrv().replace(v),
|
||||
};
|
||||
|
||||
const inputSubscription = transformDataFrame(inputTransforms, data, ctx).subscribe((v) => {
|
||||
if (matcher) {
|
||||
v = data.filter((v) => matcher(v));
|
||||
}
|
||||
setInput(v);
|
||||
});
|
||||
const outputSubscription = transformDataFrame(inputTransforms, data, ctx)
|
||||
.pipe(mergeMap((before) => transformDataFrame(outputTransforms, before, ctx)))
|
||||
.subscribe(setOutput);
|
||||
|
||||
return function unsubscribe() {
|
||||
inputSubscription.unsubscribe();
|
||||
outputSubscription.unsubscribe();
|
||||
};
|
||||
}, [index, data, configs]);
|
||||
|
||||
const editor = useMemo(
|
||||
() =>
|
||||
createElement(uiConfig.editor, {
|
||||
|
||||
@@ -1,40 +1,35 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
DataTransformerConfig,
|
||||
GrafanaTheme2,
|
||||
StandardEditorContext,
|
||||
StandardEditorsRegistryItem,
|
||||
} from '@grafana/data';
|
||||
import { DataFrame, DataTransformerConfig, GrafanaTheme2 } from '@grafana/data';
|
||||
import { DataTopic } from '@grafana/schema';
|
||||
import { Field, Select, useStyles2 } from '@grafana/ui';
|
||||
import { FrameMultiSelectionEditor } from 'app/plugins/panel/geomap/editor/FrameSelectionEditor';
|
||||
|
||||
import { TransformationData } from './TransformationsEditor';
|
||||
|
||||
interface TransformationFilterProps {
|
||||
/** data frames from the output of previous transformation */
|
||||
data: DataFrame[];
|
||||
index: number;
|
||||
config: DataTransformerConfig;
|
||||
data: TransformationData;
|
||||
annotations?: DataFrame[];
|
||||
onChange: (index: number, config: DataTransformerConfig) => void;
|
||||
}
|
||||
|
||||
export const TransformationFilter = ({ index, data, config, onChange }: TransformationFilterProps) => {
|
||||
export const TransformationFilter = ({ index, annotations, config, onChange, data }: TransformationFilterProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const opts = useMemo(() => {
|
||||
return {
|
||||
// eslint-disable-next-line
|
||||
context: { data: data.series } as StandardEditorContext<unknown>,
|
||||
showTopic: true || data.annotations?.length || config.topic?.length,
|
||||
context: { data },
|
||||
showTopic: true || annotations?.length || config.topic?.length,
|
||||
showFilter: config.topic !== DataTopic.Annotations,
|
||||
source: [
|
||||
{ value: DataTopic.Series, label: `Query results` },
|
||||
{ value: DataTopic.Series, label: `Query and Transformation results` },
|
||||
{ value: DataTopic.Annotations, label: `Annotation data` },
|
||||
],
|
||||
};
|
||||
}, [data, config.topic]);
|
||||
}, [data, annotations?.length, config.topic]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
@@ -59,8 +54,6 @@ export const TransformationFilter = ({ index, data, config, onChange }: Transfor
|
||||
<FrameMultiSelectionEditor
|
||||
value={config.filter!}
|
||||
context={opts.context}
|
||||
// eslint-disable-next-line
|
||||
item={{} as StandardEditorsRegistryItem}
|
||||
onChange={(filter) => onChange(index, { ...config, filter })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useCallback } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
import { mergeMap } from 'rxjs';
|
||||
|
||||
import { DataTransformerConfig, TransformerRegistryItem, FrameMatcherID, DataTopic } from '@grafana/data';
|
||||
import {
|
||||
DataTransformerConfig,
|
||||
TransformerRegistryItem,
|
||||
FrameMatcherID,
|
||||
DataTransformContext,
|
||||
getFrameMatchers,
|
||||
transformDataFrame,
|
||||
DataFrame,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { getTemplateSrv, reportInteraction } from '@grafana/runtime';
|
||||
import { ConfirmModal } from '@grafana/ui';
|
||||
import {
|
||||
QueryOperationAction,
|
||||
@@ -46,6 +54,10 @@ export const TransformationOperationRow = ({
|
||||
const topic = configs[index].transformation.topic;
|
||||
const showFilterEditor = configs[index].transformation.filter != null || topic != null;
|
||||
const showFilterToggle = showFilterEditor || data.series.length > 0 || (data.annotations?.length ?? 0) > 0;
|
||||
const [input, setInput] = useState<DataFrame[]>([]);
|
||||
const [output, setOutput] = useState<DataFrame[]>([]);
|
||||
// output of previous transformation
|
||||
const [prevOutput, setPrevOutput] = useState<DataFrame[]>([]);
|
||||
|
||||
const onDisableToggle = useCallback(
|
||||
(index: number) => {
|
||||
@@ -92,6 +104,48 @@ export const TransformationOperationRow = ({
|
||||
[configs, index]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const config = configs[index].transformation;
|
||||
const matcher = config.filter?.options ? getFrameMatchers(config.filter) : undefined;
|
||||
// we need previous transformation index to get its outputs
|
||||
// to be used in this transforms inputs
|
||||
const prevTransformIndex = index - 1;
|
||||
|
||||
let prevInputTransforms: Array<DataTransformerConfig<{}>> = [];
|
||||
let prevOutputTransforms: Array<DataTransformerConfig<{}>> = [];
|
||||
|
||||
if (prevTransformIndex >= 0) {
|
||||
prevInputTransforms = configs.slice(0, prevTransformIndex).map((t) => t.transformation);
|
||||
prevOutputTransforms = configs.slice(prevTransformIndex, index).map((t) => t.transformation);
|
||||
}
|
||||
|
||||
const inputTransforms = configs.slice(0, index).map((t) => t.transformation);
|
||||
const outputTransforms = configs.slice(index, index + 1).map((t) => t.transformation);
|
||||
|
||||
const ctx: DataTransformContext = {
|
||||
interpolate: (v: string) => getTemplateSrv().replace(v),
|
||||
};
|
||||
|
||||
const inputSubscription = transformDataFrame(inputTransforms, data.series, ctx).subscribe((data) => {
|
||||
if (matcher) {
|
||||
data = data.filter((frame) => matcher(frame));
|
||||
}
|
||||
setInput(data);
|
||||
});
|
||||
const outputSubscription = transformDataFrame(inputTransforms, data.series, ctx)
|
||||
.pipe(mergeMap((before) => transformDataFrame(outputTransforms, before, ctx)))
|
||||
.subscribe(setOutput);
|
||||
const prevOutputSubscription = transformDataFrame(prevInputTransforms, data.series, ctx)
|
||||
.pipe(mergeMap((before) => transformDataFrame(prevOutputTransforms, before, ctx)))
|
||||
.subscribe(setPrevOutput);
|
||||
|
||||
return function unsubscribe() {
|
||||
inputSubscription.unsubscribe();
|
||||
outputSubscription.unsubscribe();
|
||||
prevOutputSubscription.unsubscribe();
|
||||
};
|
||||
}, [index, data, configs]);
|
||||
|
||||
const renderActions = () => {
|
||||
return (
|
||||
<>
|
||||
@@ -162,13 +216,20 @@ export const TransformationOperationRow = ({
|
||||
}}
|
||||
>
|
||||
{showFilterEditor && (
|
||||
<TransformationFilter index={index} config={configs[index].transformation} data={data} onChange={onChange} />
|
||||
<TransformationFilter
|
||||
data={prevOutput}
|
||||
index={index}
|
||||
config={configs[index].transformation}
|
||||
annotations={data.annotations}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TransformationEditor
|
||||
input={input}
|
||||
output={output}
|
||||
debugMode={showDebug}
|
||||
index={index}
|
||||
data={topic === DataTopic.Annotations ? (data.annotations ?? []) : data.series}
|
||||
configs={configs}
|
||||
uiConfig={uiConfig}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -35,7 +35,7 @@ interface JoinValues {
|
||||
|
||||
export function joinByLabels(options: JoinByLabelsTransformOptions, data: DataFrame[]): DataFrame {
|
||||
if (!options.value?.length) {
|
||||
return getErrorFrame('No value labele configured');
|
||||
return getErrorFrame('No value label configured');
|
||||
}
|
||||
const distinctLabels = getDistinctLabels(data);
|
||||
if (distinctLabels.size < 1) {
|
||||
@@ -104,7 +104,11 @@ export function joinByLabels(options: JoinByLabelsTransformOptions, data: DataFr
|
||||
}
|
||||
}
|
||||
|
||||
const frame: DataFrame = { fields: [], length: nameValues[0].length };
|
||||
const frame: DataFrame = {
|
||||
fields: [],
|
||||
length: nameValues[0].length,
|
||||
refId: `${DataTransformerID.joinByLabels}-${data.map((frame) => frame.refId).join('-')}`,
|
||||
};
|
||||
for (let i = 0; i < join.length; i++) {
|
||||
frame.fields.push({
|
||||
name: join[i],
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('Rows to fields', () => {
|
||||
{ name: 'Miiin', type: FieldType.number, values: [3, 100] },
|
||||
{ name: 'max', type: FieldType.string, values: [15, 200] },
|
||||
],
|
||||
refId: 'A',
|
||||
});
|
||||
|
||||
const result = rowsToFields(
|
||||
@@ -57,6 +58,7 @@ describe('Rows to fields', () => {
|
||||
},
|
||||
],
|
||||
"length": 1,
|
||||
"refId": "rowsToFields-A",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -64,6 +64,7 @@ export function rowsToFields(options: RowToFieldsTransformOptions, data: DataFra
|
||||
return {
|
||||
fields: outFields,
|
||||
length: 1,
|
||||
refId: `${DataTransformerID.rowsToFields}-${data.refId}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -59,15 +59,17 @@ describe('DataGrid', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('converts dataframe values to cell values properly', () => {
|
||||
it('converts dataframe values to cell values properly', async () => {
|
||||
jest.useFakeTimers();
|
||||
render(<DataGridPanel {...props} />);
|
||||
prep(false);
|
||||
|
||||
expect(screen.getByTestId('glide-cell-1-0')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('glide-cell-2-1')).toHaveTextContent('b');
|
||||
expect(screen.getByTestId('glide-cell-3-2')).toHaveTextContent('c');
|
||||
expect(screen.getByTestId('glide-cell-3-3')).toHaveTextContent('d');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('glide-cell-1-0')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('glide-cell-2-1')).toHaveTextContent('b');
|
||||
expect(screen.getByTestId('glide-cell-3-2')).toHaveTextContent('c');
|
||||
expect(screen.getByTestId('glide-cell-3-3')).toHaveTextContent('d');
|
||||
});
|
||||
});
|
||||
|
||||
it('should open context menu on right click', async () => {
|
||||
|
||||
@@ -29,7 +29,9 @@ export const FrameSelectionEditor = ({ value, context, onChange }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const FrameMultiSelectionEditor = ({ value, context, onChange }: Props) => {
|
||||
type FrameMultiSelectionEditorProps = Omit<StandardEditorProps<MatcherConfig>, 'item'>;
|
||||
|
||||
export const FrameMultiSelectionEditor = ({ value, context, onChange }: FrameMultiSelectionEditorProps) => {
|
||||
const onFilterChange = useCallback(
|
||||
(v: string[]) => {
|
||||
onChange(
|
||||
|
||||
@@ -61,6 +61,9 @@ const mockIntersectionObserver = jest
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
global.IntersectionObserver = mockIntersectionObserver;
|
||||
Object.defineProperty(document, 'fonts', {
|
||||
value: { ready: Promise.resolve({}) },
|
||||
});
|
||||
|
||||
global.TextEncoder = TextEncoder;
|
||||
global.TextDecoder = TextDecoder;
|
||||
|
||||
Reference in New Issue
Block a user