Schema v2: Move annotation mappings out of legacyOptions (#114721)

* Add mappings property to v2(alpha/beta)1

* Mappings convetions

* OpenAPI spec

* fe annotaations mapping

* Update snapshots

* v2->v1 conversion

* fix tests

---------

Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
This commit is contained in:
Dominik Prokop
2025-12-03 10:19:21 +01:00
committed by GitHub
co-authored by Haris Rozajac
parent 3e50bfced8
commit bb787a2637
26 changed files with 1355 additions and 38 deletions
@@ -81,6 +81,22 @@ AnnotationPanelFilter: {
ids: [...uint32]
}
// Annotation event field source. Defines how to obtain the value for an annotation event field.
// - "field": Find the value with a matching key (default)
// - "text": Write a constant string into the value
// - "skip": Do not include the field
AnnotationEventFieldSource: "field" | "text" | "skip" | *"field"
// Annotation event field mapping. Defines how to map a data frame field to an annotation event field.
AnnotationEventFieldMapping: {
// Source type for the field value
source?: AnnotationEventFieldSource | *"field"
// Constant value to use when source is "text"
value?: string
// Regular expression to apply to the field value
regex?: string
}
// "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip.
@@ -451,7 +467,10 @@ AnnotationQuerySpec: {
name: string
builtIn?: bool | *false
filter?: AnnotationPanelFilter
legacyOptions?: [string]: _ //Catch-all field for datasource-specific properties
// Mappings define how to convert data frame fields to annotation event fields.
mappings?: [string]: AnnotationEventFieldMapping
// Catch-all field for datasource-specific properties
legacyOptions?: [string]: _
}
AnnotationQueryKind: {
@@ -75,10 +75,26 @@ LibraryPanelRef: {
AnnotationPanelFilter: {
// Should the specified panels be included or excluded
exclude?: bool | *false
exclude?: bool | *false
// Panel IDs that should be included or excluded
ids: [...uint32]
}
// Annotation event field source. Defines how to obtain the value for an annotation event field.
// - "field": Find the value with a matching key (default)
// - "text": Write a constant string into the value
// - "skip": Do not include the field
AnnotationEventFieldSource: "field" | "text" | "skip" | *"field"
// Annotation event field mapping. Defines how to map a data frame field to an annotation event field.
AnnotationEventFieldMapping: {
// Source type for the field value
source?: AnnotationEventFieldSource | *"field"
// Constant value to use when source is "text"
value?: string
// Regular expression to apply to the field value
regex?: string
}
// "Off" for no shared crosshair or tooltip (default).
@@ -450,7 +466,10 @@ AnnotationQuerySpec: {
filter?: AnnotationPanelFilter
// Placement can be used to display the annotation query somewhere else on the dashboard other than the default location.
placement?: AnnotationQueryPlacement
legacyOptions?: [string]: _ // Catch-all field for datasource-specific properties. Should not be available in as code tooling.
// Mappings define how to convert data frame fields to annotation event fields.
mappings?: [string]: AnnotationEventFieldMapping
// Catch-all field for datasource-specific properties. Should not be available in as code tooling.
legacyOptions?: [string]: _
}
AnnotationQueryKind: {
@@ -85,6 +85,22 @@ AnnotationPanelFilter: {
ids: [...uint32]
}
// Annotation event field source. Defines how to obtain the value for an annotation event field.
// - "field": Find the value with a matching key (default)
// - "text": Write a constant string into the value
// - "skip": Do not include the field
AnnotationEventFieldSource: "field" | "text" | "skip" | *"field"
// Annotation event field mapping. Defines how to map a data frame field to an annotation event field.
AnnotationEventFieldMapping: {
// Source type for the field value
source?: AnnotationEventFieldSource | *"field"
// Constant value to use when source is "text"
value?: string
// Regular expression to apply to the field value
regex?: string
}
// "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip.
@@ -455,7 +471,10 @@ AnnotationQuerySpec: {
name: string
builtIn?: bool | *false
filter?: AnnotationPanelFilter
legacyOptions?: [string]: _ //Catch-all field for datasource-specific properties
// Mappings define how to convert data frame fields to annotation event fields.
mappings?: [string]: AnnotationEventFieldMapping
// Catch-all field for datasource-specific properties
legacyOptions?: [string]: _
}
AnnotationQueryKind: {
@@ -31,6 +31,8 @@ type DashboardAnnotationQuerySpec struct {
Name string `json:"name"`
BuiltIn *bool `json:"builtIn,omitempty"`
Filter *DashboardAnnotationPanelFilter `json:"filter,omitempty"`
// Mappings define how to convert data frame fields to annotation event fields.
Mappings map[string]DashboardAnnotationEventFieldMapping `json:"mappings,omitempty"`
// Catch-all field for datasource-specific properties
LegacyOptions map[string]interface{} `json:"legacyOptions,omitempty"`
}
@@ -85,6 +87,24 @@ func NewDashboardAnnotationPanelFilter() *DashboardAnnotationPanelFilter {
}
}
// Annotation event field mapping. Defines how to map a data frame field to an annotation event field.
// +k8s:openapi-gen=true
type DashboardAnnotationEventFieldMapping struct {
// Source type for the field value
Source *string `json:"source,omitempty"`
// Constant value to use when source is "text"
Value *string `json:"value,omitempty"`
// Regular expression to apply to the field value
Regex *string `json:"regex,omitempty"`
}
// NewDashboardAnnotationEventFieldMapping creates a new DashboardAnnotationEventFieldMapping object.
func NewDashboardAnnotationEventFieldMapping() *DashboardAnnotationEventFieldMapping {
return &DashboardAnnotationEventFieldMapping{
Source: (func(input string) *string { return &input })("field"),
}
}
// "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip.
@@ -23,6 +23,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdHocFilterWithLabels": schema_pkg_apis_dashboard_v2alpha1_DashboardAdHocFilterWithLabels(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdhocVariableKind": schema_pkg_apis_dashboard_v2alpha1_DashboardAdhocVariableKind(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAdhocVariableSpec": schema_pkg_apis_dashboard_v2alpha1_DashboardAdhocVariableSpec(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationEventFieldMapping": schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationEventFieldMapping(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationPanelFilter": schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationPanelFilter(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationQueryKind": schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationQueryKind(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationQuerySpec": schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationQuerySpec(ref),
@@ -644,6 +645,40 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardAdhocVariableSpec(ref common.Re
}
}
func schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationEventFieldMapping(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "Annotation event field mapping. Defines how to map a data frame field to an annotation event field.",
Type: []string{"object"},
Properties: map[string]spec.Schema{
"source": {
SchemaProps: spec.SchemaProps{
Description: "Source type for the field value",
Type: []string{"string"},
Format: "",
},
},
"value": {
SchemaProps: spec.SchemaProps{
Description: "Constant value to use when source is \"text\"",
Type: []string{"string"},
Format: "",
},
},
"regex": {
SchemaProps: spec.SchemaProps{
Description: "Regular expression to apply to the field value",
Type: []string{"string"},
Format: "",
},
},
},
},
},
}
}
func schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationPanelFilter(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -762,6 +797,21 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationQuerySpec(ref common.
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationPanelFilter"),
},
},
"mappings": {
SchemaProps: spec.SchemaProps{
Description: "Mappings define how to convert data frame fields to annotation event fields.",
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationEventFieldMapping"),
},
},
},
},
},
"legacyOptions": {
SchemaProps: spec.SchemaProps{
Description: "Catch-all field for datasource-specific properties",
@@ -782,7 +832,7 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardAnnotationQuerySpec(ref common.
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationPanelFilter", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDataQueryKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDataSourceRef"},
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationEventFieldMapping", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardAnnotationPanelFilter", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDataQueryKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardDataSourceRef"},
}
}
@@ -79,10 +79,26 @@ LibraryPanelRef: {
AnnotationPanelFilter: {
// Should the specified panels be included or excluded
exclude?: bool | *false
exclude?: bool | *false
// Panel IDs that should be included or excluded
ids: [...uint32]
}
// Annotation event field source. Defines how to obtain the value for an annotation event field.
// - "field": Find the value with a matching key (default)
// - "text": Write a constant string into the value
// - "skip": Do not include the field
AnnotationEventFieldSource: "field" | "text" | "skip" | *"field"
// Annotation event field mapping. Defines how to map a data frame field to an annotation event field.
AnnotationEventFieldMapping: {
// Source type for the field value
source?: AnnotationEventFieldSource | *"field"
// Constant value to use when source is "text"
value?: string
// Regular expression to apply to the field value
regex?: string
}
// "Off" for no shared crosshair or tooltip (default).
@@ -454,7 +470,10 @@ AnnotationQuerySpec: {
filter?: AnnotationPanelFilter
// Placement can be used to display the annotation query somewhere else on the dashboard other than the default location.
placement?: AnnotationQueryPlacement
legacyOptions?: [string]: _ // Catch-all field for datasource-specific properties. Should not be available in as code tooling.
// Mappings define how to convert data frame fields to annotation event fields.
mappings?: [string]: AnnotationEventFieldMapping
// Catch-all field for datasource-specific properties. Should not be available in as code tooling.
legacyOptions?: [string]: _
}
AnnotationQueryKind: {
@@ -32,6 +32,8 @@ type DashboardAnnotationQuerySpec struct {
Filter *DashboardAnnotationPanelFilter `json:"filter,omitempty"`
// Placement can be used to display the annotation query somewhere else on the dashboard other than the default location.
Placement *string `json:"placement,omitempty"`
// Mappings define how to convert data frame fields to annotation event fields.
Mappings map[string]DashboardAnnotationEventFieldMapping `json:"mappings,omitempty"`
// Catch-all field for datasource-specific properties. Should not be available in as code tooling.
LegacyOptions map[string]interface{} `json:"legacyOptions,omitempty"`
}
@@ -86,6 +88,24 @@ func NewDashboardAnnotationPanelFilter() *DashboardAnnotationPanelFilter {
// +k8s:openapi-gen=true
const DashboardAnnotationQueryPlacement = "inControlsMenu"
// Annotation event field mapping. Defines how to map a data frame field to an annotation event field.
// +k8s:openapi-gen=true
type DashboardAnnotationEventFieldMapping struct {
// Source type for the field value
Source *string `json:"source,omitempty"`
// Constant value to use when source is "text"
Value *string `json:"value,omitempty"`
// Regular expression to apply to the field value
Regex *string `json:"regex,omitempty"`
}
// NewDashboardAnnotationEventFieldMapping creates a new DashboardAnnotationEventFieldMapping object.
func NewDashboardAnnotationEventFieldMapping() *DashboardAnnotationEventFieldMapping {
return &DashboardAnnotationEventFieldMapping{
Source: (func(input string) *string { return &input })("field"),
}
}
// "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip.
@@ -23,6 +23,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdHocFilterWithLabels": schema_pkg_apis_dashboard_v2beta1_DashboardAdHocFilterWithLabels(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdhocVariableKind": schema_pkg_apis_dashboard_v2beta1_DashboardAdhocVariableKind(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAdhocVariableSpec": schema_pkg_apis_dashboard_v2beta1_DashboardAdhocVariableSpec(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationEventFieldMapping": schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationEventFieldMapping(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationPanelFilter": schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationPanelFilter(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationQueryKind": schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationQueryKind(ref),
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationQuerySpec": schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationQuerySpec(ref),
@@ -653,6 +654,40 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardAdhocVariableSpec(ref common.Ref
}
}
func schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationEventFieldMapping(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "Annotation event field mapping. Defines how to map a data frame field to an annotation event field.",
Type: []string{"object"},
Properties: map[string]spec.Schema{
"source": {
SchemaProps: spec.SchemaProps{
Description: "Source type for the field value",
Type: []string{"string"},
Format: "",
},
},
"value": {
SchemaProps: spec.SchemaProps{
Description: "Constant value to use when source is \"text\"",
Type: []string{"string"},
Format: "",
},
},
"regex": {
SchemaProps: spec.SchemaProps{
Description: "Regular expression to apply to the field value",
Type: []string{"string"},
Format: "",
},
},
},
},
},
}
}
func schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationPanelFilter(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -774,6 +809,21 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationQuerySpec(ref common.R
Format: "",
},
},
"mappings": {
SchemaProps: spec.SchemaProps{
Description: "Mappings define how to convert data frame fields to annotation event fields.",
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationEventFieldMapping"),
},
},
},
},
},
"legacyOptions": {
SchemaProps: spec.SchemaProps{
Description: "Catch-all field for datasource-specific properties. Should not be available in as code tooling.",
@@ -794,7 +844,7 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardAnnotationQuerySpec(ref common.R
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationPanelFilter", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataQueryKind"},
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationEventFieldMapping", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardAnnotationPanelFilter", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardDataQueryKind"},
}
}
@@ -154,10 +154,22 @@
"exclude": true
},
"mappings": {
"title": "service",
"text": "description",
"time": "timestamp",
"tags": "labels"
"title": {
"source": "field",
"value": "service"
},
"text": {
"source": "field",
"value": "description"
},
"time": {
"source": "field",
"value": "timestamp"
},
"tags": {
"source": "field",
"value": "labels"
}
},
"builtIn": 0,
"type": "influxdb"
@@ -214,4 +226,4 @@
},
"links": []
}
}
}
@@ -151,10 +151,22 @@
"hide": false,
"iconColor": "#FF5722",
"mappings": {
"tags": "labels",
"text": "description",
"time": "timestamp",
"title": "service"
"tags": {
"source": "field",
"value": "labels"
},
"text": {
"source": "field",
"value": "description"
},
"time": {
"source": "field",
"value": "timestamp"
},
"title": {
"source": "field",
"value": "service"
}
},
"name": "Complex Filter Annotation",
"target": {
@@ -227,4 +239,4 @@
"storedVersion": "v1beta1"
}
}
}
}
@@ -215,13 +215,25 @@
3
]
},
"legacyOptions": {
"mappings": {
"tags": "labels",
"text": "description",
"time": "timestamp",
"title": "service"
"mappings": {
"tags": {
"source": "field",
"value": "labels"
},
"text": {
"source": "field",
"value": "description"
},
"time": {
"source": "field",
"value": "timestamp"
},
"title": {
"source": "field",
"value": "service"
}
},
"legacyOptions": {
"type": "influxdb"
}
}
@@ -224,13 +224,25 @@
3
]
},
"legacyOptions": {
"mappings": {
"tags": "labels",
"text": "description",
"time": "timestamp",
"title": "service"
"mappings": {
"tags": {
"source": "field",
"value": "labels"
},
"text": {
"source": "field",
"value": "description"
},
"time": {
"source": "field",
"value": "timestamp"
},
"title": {
"source": "field",
"value": "service"
}
},
"legacyOptions": {
"type": "influxdb"
}
}
@@ -1784,6 +1784,12 @@ func buildAnnotationQuery(annotationMap map[string]interface{}) (dashv2alpha1.Da
filter = buildAnnotationFilter(filterMap)
}
// Transform mappings
var mappings map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping
if mappingsMap, ok := annotationMap["mappings"].(map[string]interface{}); ok && mappingsMap != nil {
mappings = convertAnnotationMappings_V1beta1_to_V2alpha1(mappingsMap)
}
// Transform builtIn from float64 to bool
var builtInPtr *bool
if builtInVal, ok := annotationMap["builtIn"]; ok && builtInVal != nil {
@@ -1809,6 +1815,7 @@ func buildAnnotationQuery(annotationMap map[string]interface{}) (dashv2alpha1.Da
IconColor: schemaversion.GetStringValue(annotationMap, "iconColor", defaultAnnotationQuerySpec.IconColor),
BuiltIn: builtInPtr,
Filter: filter,
Mappings: mappings,
}
// Handle any additional properties in LegacyOptions
@@ -1820,7 +1827,7 @@ func buildAnnotationQuery(annotationMap map[string]interface{}) (dashv2alpha1.Da
// Add other legacy fields if they exist
for key, value := range annotationMap {
switch key {
case "name", "datasource", "enable", "hide", "iconColor", "filter", "target", "builtIn", "type":
case "name", "datasource", "enable", "hide", "iconColor", "filter", "target", "builtIn", "type", "mappings":
// Skip already handled fields
default:
legacyOptions[key] = value
@@ -1866,6 +1873,52 @@ func buildAnnotationFilter(filterMap map[string]interface{}) *dashv2alpha1.Dashb
return filter
}
func convertAnnotationMappings_V1beta1_to_V2alpha1(mappingsMap map[string]interface{}) map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping {
mappings := make(map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping)
for key, value := range mappingsMap {
mapping := dashv2alpha1.DashboardAnnotationEventFieldMapping{}
// Handle simple string format (v1beta1 legacy format: "fieldName": "targetFieldName")
if valueStr, ok := value.(string); ok && valueStr != "" {
// Simple string mapping: treat as field source with the value as the field name
defaultSource := "field"
mapping.Source = &defaultSource
mapping.Value = &valueStr
mappings[key] = mapping
continue
}
// Handle object format (v2alpha1 format: "fieldName": {"source": "field", "value": "...", "regex": "..."})
mappingMap, ok := value.(map[string]interface{})
if !ok {
continue
}
// Extract source (defaults to "field" if not specified)
if source, ok := mappingMap["source"].(string); ok && source != "" {
mapping.Source = &source
} else {
defaultSource := "field"
mapping.Source = &defaultSource
}
// Extract value (optional)
if valueStr, ok := mappingMap["value"].(string); ok && valueStr != "" {
mapping.Value = &valueStr
}
// Extract regex (optional)
if regex, ok := mappingMap["regex"].(string); ok && regex != "" {
mapping.Regex = &regex
}
mappings[key] = mapping
}
return mappings
}
// Panel helper functions
func transformPanelQueries(ctx context.Context, panelMap map[string]interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider) []dashv2alpha1.DashboardPanelQueryKind {
@@ -0,0 +1,324 @@
package conversion
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConvertAnnotationMappings_V1beta1_to_V2alpha1(t *testing.T) {
t.Run("should convert mappings with all fields", func(t *testing.T) {
mappingsMap := map[string]interface{}{
"title": map[string]interface{}{
"source": "field",
"value": "service",
"regex": "",
},
"text": map[string]interface{}{
"source": "text",
"value": "constant text",
},
"time": map[string]interface{}{
"source": "field",
"value": "timestamp",
},
"tags": map[string]interface{}{
"source": "field",
"value": "labels",
"regex": "/(.*)/",
},
}
result := convertAnnotationMappings_V1beta1_to_V2alpha1(mappingsMap)
require.Len(t, result, 4)
// Check title mapping
titleMapping, ok := result["title"]
require.True(t, ok)
assert.Equal(t, "field", *titleMapping.Source)
assert.Equal(t, "service", *titleMapping.Value)
assert.Nil(t, titleMapping.Regex)
// Check text mapping
textMapping, ok := result["text"]
require.True(t, ok)
assert.Equal(t, "text", *textMapping.Source)
assert.Equal(t, "constant text", *textMapping.Value)
assert.Nil(t, textMapping.Regex)
// Check time mapping
timeMapping, ok := result["time"]
require.True(t, ok)
assert.Equal(t, "field", *timeMapping.Source)
assert.Equal(t, "timestamp", *timeMapping.Value)
assert.Nil(t, timeMapping.Regex)
// Check tags mapping
tagsMapping, ok := result["tags"]
require.True(t, ok)
assert.Equal(t, "field", *tagsMapping.Source)
assert.Equal(t, "labels", *tagsMapping.Value)
assert.Equal(t, "/(.*)/", *tagsMapping.Regex)
})
t.Run("should default source to field when not specified", func(t *testing.T) {
mappingsMap := map[string]interface{}{
"title": map[string]interface{}{
"value": "service",
},
}
result := convertAnnotationMappings_V1beta1_to_V2alpha1(mappingsMap)
require.Len(t, result, 1)
titleMapping, ok := result["title"]
require.True(t, ok)
assert.Equal(t, "field", *titleMapping.Source)
assert.Equal(t, "service", *titleMapping.Value)
})
t.Run("should handle empty source string by defaulting to field", func(t *testing.T) {
mappingsMap := map[string]interface{}{
"title": map[string]interface{}{
"source": "",
"value": "service",
},
}
result := convertAnnotationMappings_V1beta1_to_V2alpha1(mappingsMap)
require.Len(t, result, 1)
titleMapping, ok := result["title"]
require.True(t, ok)
assert.Equal(t, "field", *titleMapping.Source)
})
t.Run("should handle skip source", func(t *testing.T) {
mappingsMap := map[string]interface{}{
"title": map[string]interface{}{
"source": "skip",
},
}
result := convertAnnotationMappings_V1beta1_to_V2alpha1(mappingsMap)
require.Len(t, result, 1)
titleMapping, ok := result["title"]
require.True(t, ok)
assert.Equal(t, "skip", *titleMapping.Source)
assert.Nil(t, titleMapping.Value)
assert.Nil(t, titleMapping.Regex)
})
t.Run("should skip invalid mapping entries", func(t *testing.T) {
mappingsMap := map[string]interface{}{
"title": map[string]interface{}{
"source": "field",
"value": "service",
},
"invalid": 123, // Invalid: not a string or map
"text": map[string]interface{}{
"source": "text",
"value": "constant",
},
}
result := convertAnnotationMappings_V1beta1_to_V2alpha1(mappingsMap)
// Should have 2 valid mappings (title and text)
// String values are now treated as valid legacy format mappings
require.Len(t, result, 2)
_, ok := result["title"]
require.True(t, ok)
_, ok = result["text"]
require.True(t, ok)
_, ok = result["invalid"]
assert.False(t, ok, "invalid entry should be skipped")
})
t.Run("should handle empty mappings map", func(t *testing.T) {
mappingsMap := map[string]interface{}{}
result := convertAnnotationMappings_V1beta1_to_V2alpha1(mappingsMap)
assert.Empty(t, result)
})
}
func TestBuildAnnotationQuery_Mappings(t *testing.T) {
t.Run("should extract mappings to top-level property", func(t *testing.T) {
annotationMap := map[string]interface{}{
"name": "Test Annotation",
"enable": true,
"hide": false,
"iconColor": "red",
"datasource": map[string]interface{}{
"type": "prometheus",
"uid": "test-uid",
},
"target": map[string]interface{}{
"expr": "test_query",
},
"mappings": map[string]interface{}{
"title": map[string]interface{}{
"source": "field",
"value": "service",
},
"text": map[string]interface{}{
"source": "text",
"value": "constant text",
},
"time": map[string]interface{}{
"source": "field",
"value": "timestamp",
"regex": "",
},
},
}
result, err := buildAnnotationQuery(annotationMap)
require.NoError(t, err)
// Verify mappings are in the correct location
require.NotNil(t, result.Spec.Mappings)
assert.Len(t, result.Spec.Mappings, 3)
// Verify mappings content
titleMapping, ok := result.Spec.Mappings["title"]
require.True(t, ok)
assert.Equal(t, "field", *titleMapping.Source)
assert.Equal(t, "service", *titleMapping.Value)
textMapping, ok := result.Spec.Mappings["text"]
require.True(t, ok)
assert.Equal(t, "text", *textMapping.Source)
assert.Equal(t, "constant text", *textMapping.Value)
// Verify mappings are NOT in legacyOptions
if result.Spec.LegacyOptions != nil {
_, hasMappingsInLegacy := result.Spec.LegacyOptions["mappings"]
assert.False(t, hasMappingsInLegacy, "mappings should not be in legacyOptions")
}
})
t.Run("should handle annotation without mappings", func(t *testing.T) {
annotationMap := map[string]interface{}{
"name": "Test Annotation",
"enable": true,
"hide": false,
"iconColor": "red",
"datasource": map[string]interface{}{
"type": "prometheus",
"uid": "test-uid",
},
"target": map[string]interface{}{
"expr": "test_query",
},
}
result, err := buildAnnotationQuery(annotationMap)
require.NoError(t, err)
// Mappings should be nil when not present
assert.Nil(t, result.Spec.Mappings)
})
t.Run("should handle empty mappings", func(t *testing.T) {
annotationMap := map[string]interface{}{
"name": "Test Annotation",
"enable": true,
"hide": false,
"iconColor": "red",
"datasource": map[string]interface{}{
"type": "prometheus",
"uid": "test-uid",
},
"target": map[string]interface{}{
"expr": "test_query",
},
"mappings": map[string]interface{}{},
}
result, err := buildAnnotationQuery(annotationMap)
require.NoError(t, err)
// Empty mappings should result in empty map
assert.NotNil(t, result.Spec.Mappings)
assert.Empty(t, result.Spec.Mappings)
})
t.Run("should exclude mappings from legacyOptions", func(t *testing.T) {
annotationMap := map[string]interface{}{
"name": "Test Annotation",
"enable": true,
"hide": false,
"iconColor": "red",
"datasource": map[string]interface{}{
"type": "prometheus",
"uid": "test-uid",
},
"target": map[string]interface{}{
"expr": "test_query",
},
"mappings": map[string]interface{}{
"title": map[string]interface{}{
"source": "field",
"value": "service",
},
},
"type": "prometheus",
"customField": "customValue",
}
result, err := buildAnnotationQuery(annotationMap)
require.NoError(t, err)
// Verify mappings are in the correct location
require.NotNil(t, result.Spec.Mappings)
assert.Len(t, result.Spec.Mappings, 1)
// Verify other fields are in legacyOptions
require.NotNil(t, result.Spec.LegacyOptions)
assert.Equal(t, "prometheus", result.Spec.LegacyOptions["type"])
assert.Equal(t, "customValue", result.Spec.LegacyOptions["customField"])
// Verify mappings are NOT in legacyOptions
_, hasMappingsInLegacy := result.Spec.LegacyOptions["mappings"]
assert.False(t, hasMappingsInLegacy, "mappings should not be in legacyOptions")
})
t.Run("should handle mappings with regex", func(t *testing.T) {
annotationMap := map[string]interface{}{
"name": "Test Annotation",
"enable": true,
"hide": false,
"iconColor": "red",
"datasource": map[string]interface{}{
"type": "prometheus",
"uid": "test-uid",
},
"target": map[string]interface{}{
"expr": "test_query",
},
"mappings": map[string]interface{}{
"tags": map[string]interface{}{
"source": "field",
"value": "labels",
"regex": "/(.*)/",
},
},
}
result, err := buildAnnotationQuery(annotationMap)
require.NoError(t, err)
require.NotNil(t, result.Spec.Mappings)
tagsMapping, ok := result.Spec.Mappings["tags"]
require.True(t, ok)
assert.Equal(t, "field", *tagsMapping.Source)
assert.Equal(t, "labels", *tagsMapping.Value)
assert.Equal(t, "/(.*)/", *tagsMapping.Regex)
})
}
@@ -1721,6 +1721,14 @@ func convertAnnotationsToV1(annotations []dashv2alpha1.DashboardAnnotationQueryK
}
}
// Convert mappings from v2alpha1 format back to v1beta1 format
if len(annotation.Spec.Mappings) > 0 {
mappings := convertAnnotationMappings_V2alpha1_to_V1beta1(annotation.Spec.Mappings)
if len(mappings) > 0 {
annotationMap["mappings"] = mappings
}
}
// Copy legacy options
// This is used to copy any unknown properties from the v1 at the root of the annotations that were not handled by the conversion.
// When they are converted into V2 they are moved to legacyOptions. Now we move them back to the root of the annotation.
@@ -1728,7 +1736,7 @@ func convertAnnotationsToV1(annotations []dashv2alpha1.DashboardAnnotationQueryK
for k, v := range annotation.Spec.LegacyOptions {
// Skip fields already handled
if k != "name" && k != "enable" && k != "hide" && k != "iconColor" &&
k != "datasource" && k != "target" && k != "filter" && k != "builtIn" && k != "placement" {
k != "datasource" && k != "target" && k != "filter" && k != "builtIn" && k != "placement" && k != "mappings" {
annotationMap[k] = v
}
}
@@ -1740,6 +1748,46 @@ func convertAnnotationsToV1(annotations []dashv2alpha1.DashboardAnnotationQueryK
return result
}
// convertAnnotationMappings_V2alpha1_to_V1beta1 converts mappings from v2alpha1 structured format
// back to v1beta1 format. v1beta1 supports both simple string format and structured format with source/value/regex.
// v2alpha1 format: map[string]DashboardAnnotationEventFieldMapping with Source, Value, Regex
// v1beta1 format: map[string]interface{} where values can be either:
// - string (legacy simple format: "fieldName": "targetFieldName")
// - object (structured format: "fieldName": {"source": "field", "value": "...", "regex": "..."})
func convertAnnotationMappings_V2alpha1_to_V1beta1(mappings map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping) map[string]interface{} {
result := make(map[string]interface{})
for key, mapping := range mappings {
// Always convert to structured format with source and value fields
mappingMap := make(map[string]interface{})
// Source defaults to "field" if not specified
source := "field"
if mapping.Source != nil {
source = *mapping.Source
}
mappingMap["source"] = source
// Value is optional (required for "field" and "text" sources, but "skip" doesn't need it)
if mapping.Value != nil && *mapping.Value != "" {
mappingMap["value"] = *mapping.Value
}
// Regex is optional
if mapping.Regex != nil && *mapping.Regex != "" {
mappingMap["regex"] = *mapping.Regex
}
// Include the mapping if it has source (and value for non-skip sources)
// Skip source doesn't require a value
if source == "skip" || (mapping.Value != nil && *mapping.Value != "") {
result[key] = mappingMap
}
}
return result
}
// Enum transformation functions (reverse of v1→v2)
func transformVariableHideFromEnum(hide dashv2alpha1.DashboardVariableHide) interface{} {
switch hide {
@@ -0,0 +1,200 @@
package conversion
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/utils/ptr"
dashv2alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1"
)
func TestConvertAnnotationMappings_V2alpha1_to_V1beta1(t *testing.T) {
t.Run("should convert simple field mappings to structured format with source and value", func(t *testing.T) {
mappings := map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping{
"title": {
Source: ptr.To("field"),
Value: ptr.To("service"),
},
"text": {
Source: ptr.To("field"),
Value: ptr.To("description"),
},
"time": {
Source: ptr.To("field"),
Value: ptr.To("timestamp"),
},
}
result := convertAnnotationMappings_V2alpha1_to_V1beta1(mappings)
require.Len(t, result, 3)
// All mappings should be in structured format with source and value
titleMapping, ok := result["title"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "field", titleMapping["source"])
assert.Equal(t, "service", titleMapping["value"])
textMapping, ok := result["text"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "field", textMapping["source"])
assert.Equal(t, "description", textMapping["value"])
timeMapping, ok := result["time"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "field", timeMapping["source"])
assert.Equal(t, "timestamp", timeMapping["value"])
})
t.Run("should convert mappings with default field source to structured format", func(t *testing.T) {
mappings := map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping{
"title": {
Source: nil, // nil defaults to "field"
Value: ptr.To("service"),
},
}
result := convertAnnotationMappings_V2alpha1_to_V1beta1(mappings)
require.Len(t, result, 1)
titleMapping, ok := result["title"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "field", titleMapping["source"])
assert.Equal(t, "service", titleMapping["value"])
})
t.Run("should preserve complex mappings with regex as structured format", func(t *testing.T) {
mappings := map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping{
"tags": {
Source: ptr.To("field"),
Value: ptr.To("labels"),
Regex: ptr.To("/(.*)/"),
},
}
result := convertAnnotationMappings_V2alpha1_to_V1beta1(mappings)
require.Len(t, result, 1)
tagsMapping, ok := result["tags"].(map[string]interface{})
require.True(t, ok, "tags mapping should be a map")
assert.Equal(t, "field", tagsMapping["source"])
assert.Equal(t, "labels", tagsMapping["value"])
assert.Equal(t, "/(.*)/", tagsMapping["regex"])
})
t.Run("should preserve mappings with non-field source as structured format", func(t *testing.T) {
mappings := map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping{
"text": {
Source: ptr.To("text"),
Value: ptr.To("constant text"),
},
}
result := convertAnnotationMappings_V2alpha1_to_V1beta1(mappings)
require.Len(t, result, 1)
textMapping, ok := result["text"].(map[string]interface{})
require.True(t, ok, "text mapping should be a map")
assert.Equal(t, "text", textMapping["source"])
assert.Equal(t, "constant text", textMapping["value"])
})
t.Run("should preserve mappings with skip source as structured format", func(t *testing.T) {
mappings := map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping{
"title": {
Source: ptr.To("skip"),
},
"text": {
Source: ptr.To("field"),
Value: ptr.To("description"),
},
}
result := convertAnnotationMappings_V2alpha1_to_V1beta1(mappings)
require.Len(t, result, 2)
// Skip mapping should be preserved as structured format
titleMapping, ok := result["title"].(map[string]interface{})
require.True(t, ok, "skip mapping should be preserved as map")
assert.Equal(t, "skip", titleMapping["source"])
// Field mapping should be structured format with source and value
textMapping, ok := result["text"].(map[string]interface{})
require.True(t, ok, "field mapping should be structured format")
assert.Equal(t, "field", textMapping["source"])
assert.Equal(t, "description", textMapping["value"])
})
t.Run("should skip mappings without value", func(t *testing.T) {
mappings := map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping{
"title": {
Source: ptr.To("field"),
Value: nil,
},
"text": {
Source: ptr.To("field"),
Value: ptr.To(""),
},
"time": {
Source: ptr.To("field"),
Value: ptr.To("timestamp"),
},
}
result := convertAnnotationMappings_V2alpha1_to_V1beta1(mappings)
require.Len(t, result, 1)
_, ok := result["title"]
assert.False(t, ok, "mapping without value should be skipped")
_, ok = result["text"]
assert.False(t, ok, "mapping with empty value should be skipped")
_, ok = result["time"]
assert.True(t, ok, "mapping with value should be included")
})
t.Run("should handle empty mappings map", func(t *testing.T) {
mappings := map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping{}
result := convertAnnotationMappings_V2alpha1_to_V1beta1(mappings)
assert.Empty(t, result)
})
t.Run("should handle all mappings in structured format", func(t *testing.T) {
mappings := map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping{
"title": {
Source: ptr.To("field"),
Value: ptr.To("service"),
},
"tags": {
Source: ptr.To("field"),
Value: ptr.To("labels"),
Regex: ptr.To("/(.*)/"),
},
"text": {
Source: ptr.To("text"),
Value: ptr.To("constant"),
},
}
result := convertAnnotationMappings_V2alpha1_to_V1beta1(mappings)
require.Len(t, result, 3)
// All mappings should be in structured format
titleMapping, ok := result["title"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "field", titleMapping["source"])
assert.Equal(t, "service", titleMapping["value"])
tagsMapping, ok := result["tags"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "field", tagsMapping["source"])
assert.Equal(t, "labels", tagsMapping["value"])
assert.Equal(t, "/(.*)/", tagsMapping["regex"])
textMapping, ok := result["text"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "text", textMapping["source"])
assert.Equal(t, "constant", textMapping["value"])
})
}
@@ -113,6 +113,11 @@ func convertAnnotationQuery_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardAnnota
out.Spec.Filter = (*dashv2beta1.DashboardAnnotationPanelFilter)(in.Spec.Filter)
out.Spec.LegacyOptions = in.Spec.LegacyOptions
// Convert mappings
if in.Spec.Mappings != nil {
out.Spec.Mappings = convertAnnotationMappings_V2alpha1_to_V2beta1(in.Spec.Mappings)
}
// Convert query - move datasource from annotation spec to query
if err := convertDataQuery_V2alpha1_to_V2beta1(in.Spec.Query, &out.Spec.Query, in.Spec.Datasource, scope); err != nil {
return err
@@ -982,3 +987,18 @@ func convertRowLayout_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardGridLayoutKi
func convertTabLayout_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, out *dashv2beta1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, scope conversion.Scope) error {
return convertLayout_V2alpha1_to_V2beta1(in, out, scope)
}
func convertAnnotationMappings_V2alpha1_to_V2beta1(in map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping) map[string]dashv2beta1.DashboardAnnotationEventFieldMapping {
if in == nil {
return nil
}
out := make(map[string]dashv2beta1.DashboardAnnotationEventFieldMapping, len(in))
for key, mapping := range in {
out[key] = dashv2beta1.DashboardAnnotationEventFieldMapping{
Source: mapping.Source,
Value: mapping.Value,
Regex: mapping.Regex,
}
}
return out
}
@@ -115,6 +115,83 @@ func TestV2alpha1ToV2beta1(t *testing.T) {
assert.True(t, variable.SwitchVariableKind.Spec.SkipUrlSync)
},
},
{
name: "annotation query with mappings",
createV2alpha1: func() *dashv2alpha1.Dashboard {
sourceField := "field"
sourceText := "text"
valueService := "service"
valueConstant := "constant text"
regexPattern := "/(.*)/"
return &dashv2alpha1.Dashboard{
Spec: dashv2alpha1.DashboardSpec{
Title: "Test Dashboard",
Annotations: []dashv2alpha1.DashboardAnnotationQueryKind{
{
Kind: "AnnotationQuery",
Spec: dashv2alpha1.DashboardAnnotationQuerySpec{
Name: "Test Annotation",
Enable: true,
Hide: false,
IconColor: "red",
Query: &dashv2alpha1.DashboardDataQueryKind{
Kind: "prometheus",
Spec: map[string]interface{}{
"expr": "test_query",
},
},
Mappings: map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping{
"title": {
Source: &sourceField,
Value: &valueService,
},
"text": {
Source: &sourceText,
Value: &valueConstant,
},
"tags": {
Source: &sourceField,
Value: &valueService,
Regex: &regexPattern,
},
},
},
},
},
},
}
},
validateV2beta1: func(t *testing.T, v2beta1 *dashv2beta1.Dashboard) {
require.Len(t, v2beta1.Spec.Annotations, 1)
annotation := v2beta1.Spec.Annotations[0]
assert.Equal(t, "Test Annotation", annotation.Spec.Name)
// Verify mappings are preserved
require.NotNil(t, annotation.Spec.Mappings)
assert.Len(t, annotation.Spec.Mappings, 3)
// Check title mapping
titleMapping, ok := annotation.Spec.Mappings["title"]
require.True(t, ok)
assert.Equal(t, "field", *titleMapping.Source)
assert.Equal(t, "service", *titleMapping.Value)
assert.Nil(t, titleMapping.Regex)
// Check text mapping
textMapping, ok := annotation.Spec.Mappings["text"]
require.True(t, ok)
assert.Equal(t, "text", *textMapping.Source)
assert.Equal(t, "constant text", *textMapping.Value)
assert.Nil(t, textMapping.Regex)
// Check tags mapping
tagsMapping, ok := annotation.Spec.Mappings["tags"]
require.True(t, ok)
assert.Equal(t, "field", *tagsMapping.Source)
assert.Equal(t, "service", *tagsMapping.Value)
assert.Equal(t, "/(.*)/", *tagsMapping.Regex)
},
},
}
for _, tc := range testCases {
@@ -114,6 +114,11 @@ func convertAnnotationQuery_V2beta1_to_V2alpha1(in *dashv2beta1.DashboardAnnotat
out.Spec.Filter = (*dashv2alpha1.DashboardAnnotationPanelFilter)(in.Spec.Filter)
out.Spec.LegacyOptions = in.Spec.LegacyOptions
// Convert mappings
if in.Spec.Mappings != nil {
out.Spec.Mappings = convertAnnotationMappings_V2beta1_to_V2alpha1(in.Spec.Mappings)
}
// Convert query - move datasource from query back to annotation spec
query, datasource, err := convertDataQuery_V2beta1_to_V2alpha1(&in.Spec.Query, scope)
if err != nil {
@@ -1023,3 +1028,18 @@ func convertRowLayout_V2beta1_to_V2alpha1(in *dashv2beta1.DashboardGridLayoutKin
func convertTabLayout_V2beta1_to_V2alpha1(in *dashv2beta1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, out *dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, scope conversion.Scope) error {
return convertLayout_V2beta1_to_V2alpha1(in, out, scope)
}
func convertAnnotationMappings_V2beta1_to_V2alpha1(in map[string]dashv2beta1.DashboardAnnotationEventFieldMapping) map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping {
if in == nil {
return nil
}
out := make(map[string]dashv2alpha1.DashboardAnnotationEventFieldMapping, len(in))
for key, mapping := range in {
out[key] = dashv2alpha1.DashboardAnnotationEventFieldMapping{
Source: mapping.Source,
Value: mapping.Value,
Regex: mapping.Regex,
}
}
return out
}
@@ -586,6 +586,85 @@ func TestV2beta1ToV2alpha1(t *testing.T) {
assert.False(t, *row.Spec.Collapse)
},
},
{
name: "annotation query with mappings",
createV2beta1: func() *dashv2beta1.Dashboard {
sourceField := "field"
sourceText := "text"
valueService := "service"
valueConstant := "constant text"
regexPattern := "/(.*)/"
return &dashv2beta1.Dashboard{
Spec: dashv2beta1.DashboardSpec{
Title: "Test Dashboard",
Annotations: []dashv2beta1.DashboardAnnotationQueryKind{
{
Kind: "AnnotationQuery",
Spec: dashv2beta1.DashboardAnnotationQuerySpec{
Name: "Test Annotation",
Enable: true,
Hide: false,
IconColor: "red",
Query: dashv2beta1.DashboardDataQueryKind{
Kind: "DataQuery",
Group: "prometheus",
Version: "v0",
Spec: map[string]interface{}{
"expr": "test_query",
},
},
Mappings: map[string]dashv2beta1.DashboardAnnotationEventFieldMapping{
"title": {
Source: &sourceField,
Value: &valueService,
},
"text": {
Source: &sourceText,
Value: &valueConstant,
},
"tags": {
Source: &sourceField,
Value: &valueService,
Regex: &regexPattern,
},
},
},
},
},
},
}
},
validateV2alpha1: func(t *testing.T, v2alpha1 *dashv2alpha1.Dashboard) {
require.Len(t, v2alpha1.Spec.Annotations, 1)
annotation := v2alpha1.Spec.Annotations[0]
assert.Equal(t, "Test Annotation", annotation.Spec.Name)
// Verify mappings are preserved
require.NotNil(t, annotation.Spec.Mappings)
assert.Len(t, annotation.Spec.Mappings, 3)
// Check title mapping
titleMapping, ok := annotation.Spec.Mappings["title"]
require.True(t, ok)
assert.Equal(t, "field", *titleMapping.Source)
assert.Equal(t, "service", *titleMapping.Value)
assert.Nil(t, titleMapping.Regex)
// Check text mapping
textMapping, ok := annotation.Spec.Mappings["text"]
require.True(t, ok)
assert.Equal(t, "text", *textMapping.Source)
assert.Equal(t, "constant text", *textMapping.Value)
assert.Nil(t, textMapping.Regex)
// Check tags mapping
tagsMapping, ok := annotation.Spec.Mappings["tags"]
require.True(t, ok)
assert.Equal(t, "field", *tagsMapping.Source)
assert.Equal(t, "service", *tagsMapping.Value)
assert.Equal(t, "/(.*)/", *tagsMapping.Regex)
},
},
}
for _, tc := range testCases {
@@ -19,6 +19,8 @@ export interface AnnotationQuerySpec {
name: string;
builtIn?: boolean;
filter?: AnnotationPanelFilter;
// Mappings define how to convert data frame fields to annotation event fields.
mappings?: Record<string, AnnotationEventFieldMapping>;
// Catch-all field for datasource-specific properties
legacyOptions?: Record<string, any>;
}
@@ -64,6 +66,20 @@ export const defaultAnnotationPanelFilter = (): AnnotationPanelFilter => ({
ids: [],
});
// Annotation event field mapping. Defines how to map a data frame field to an annotation event field.
export interface AnnotationEventFieldMapping {
// Source type for the field value
source?: string;
// Constant value to use when source is "text"
value?: string;
// Regular expression to apply to the field value
regex?: string;
}
export const defaultAnnotationEventFieldMapping = (): AnnotationEventFieldMapping => ({
source: "field",
});
// "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip.
@@ -20,6 +20,8 @@ export interface AnnotationQuerySpec {
filter?: AnnotationPanelFilter;
// Placement can be used to display the annotation query somewhere else on the dashboard other than the default location.
placement?: "inControlsMenu";
// Mappings define how to convert data frame fields to annotation event fields.
mappings?: Record<string, AnnotationEventFieldMapping>;
// Catch-all field for datasource-specific properties. Should not be available in as code tooling.
legacyOptions?: Record<string, any>;
}
@@ -69,6 +71,20 @@ export const defaultAnnotationPanelFilter = (): AnnotationPanelFilter => ({
// - "inControlsMenu" renders the annotation query in the dashboard controls dropdown menu
export const AnnotationQueryPlacement = "inControlsMenu";
// Annotation event field mapping. Defines how to map a data frame field to an annotation event field.
export interface AnnotationEventFieldMapping {
// Source type for the field value
source?: string;
// Constant value to use when source is "text"
value?: string;
// Regular expression to apply to the field value
regex?: string;
}
export const defaultAnnotationEventFieldMapping = (): AnnotationEventFieldMapping => ({
source: "field",
});
// "Off" for no shared crosshair or tooltip (default).
// "Crosshair" for shared crosshair.
// "Tooltip" for shared crosshair AND shared tooltip.
@@ -1275,6 +1275,24 @@
}
}
},
"com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardAnnotationEventFieldMapping": {
"description": "Annotation event field mapping. Defines how to map a data frame field to an annotation event field.",
"type": "object",
"properties": {
"regex": {
"description": "Regular expression to apply to the field value",
"type": "string"
},
"source": {
"description": "Source type for the field value",
"type": "string"
},
"value": {
"description": "Constant value to use when source is \"text\"",
"type": "string"
}
}
},
"com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardAnnotationPanelFilter": {
"type": "object",
"required": [
@@ -1354,6 +1372,18 @@
"type": "object"
}
},
"mappings": {
"description": "Mappings define how to convert data frame fields to annotation event fields.",
"type": "object",
"additionalProperties": {
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardAnnotationEventFieldMapping"
}
]
}
},
"name": {
"type": "string",
"default": ""
@@ -1,4 +1,4 @@
import { AnnotationQuery } from '@grafana/data';
import { AnnotationQuery, AnnotationEventFieldSource } from '@grafana/data';
import { AnnotationQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { transformV1ToV2AnnotationQuery, transformV2ToV1AnnotationQuery } from './annotations';
@@ -220,4 +220,145 @@ describe('V1<->V2 annotation convertions', () => {
const resultV1: AnnotationQuery = transformV2ToV1AnnotationQuery(expectedV2);
expect(resultV1).toEqual(annotationDefinition);
});
test('given annotations with mappings', () => {
const annotationDefinition: AnnotationQuery = {
datasource: {
type: 'prometheus',
uid: 'uid',
},
enable: true,
hide: false,
iconColor: 'red',
name: 'prom-annos',
target: {
// @ts-expect-error
expr: '{action="add_client"}',
refId: 'Anno',
},
mappings: {
title: {
source: AnnotationEventFieldSource.Field,
value: 'service',
},
text: {
source: AnnotationEventFieldSource.Text,
value: 'constant text',
},
tags: {
source: AnnotationEventFieldSource.Field,
value: 'labels',
regex: '/(.*)/',
},
},
};
const expectedV2: AnnotationQueryKind = {
kind: 'AnnotationQuery',
spec: {
enable: true,
hide: false,
iconColor: 'red',
name: 'prom-annos',
query: {
kind: 'DataQuery',
group: 'prometheus',
version: 'v0',
datasource: {
name: 'uid',
},
spec: {
expr: '{action="add_client"}',
refId: 'Anno',
},
},
mappings: {
title: {
source: 'field',
value: 'service',
},
text: {
source: 'text',
value: 'constant text',
},
tags: {
source: 'field',
value: 'labels',
regex: '/(.*)/',
},
},
},
};
const resultV2: AnnotationQueryKind = transformV1ToV2AnnotationQuery(annotationDefinition, 'prometheus', 'uid');
expect(resultV2).toEqual(expectedV2);
const resultV1: AnnotationQuery = transformV2ToV1AnnotationQuery(expectedV2);
expect(resultV1).toEqual(annotationDefinition);
});
test('given annotations with mappings and legacyOptions', () => {
const annotationDefinition: AnnotationQuery = {
datasource: {
type: 'elasticsearch',
uid: 'uid',
},
enable: true,
hide: false,
iconColor: 'red',
name: 'elastic-annos',
target: {
// @ts-expect-error
query: 'test query',
refId: 'Anno',
},
mappings: {
title: {
source: AnnotationEventFieldSource.Field,
value: 'service',
},
},
// These should go to legacyOptions
tagsField: 'asd',
textField: 'asd',
};
const expectedV2: AnnotationQueryKind = {
kind: 'AnnotationQuery',
spec: {
enable: true,
hide: false,
iconColor: 'red',
name: 'elastic-annos',
query: {
kind: 'DataQuery',
group: 'elasticsearch',
version: 'v0',
datasource: {
name: 'uid',
},
spec: {
query: 'test query',
refId: 'Anno',
},
},
mappings: {
title: {
source: 'field',
value: 'service',
},
},
legacyOptions: {
tagsField: 'asd',
textField: 'asd',
},
},
};
const resultV2: AnnotationQueryKind = transformV1ToV2AnnotationQuery(annotationDefinition, 'elasticsearch', 'uid');
expect(resultV2).toEqual(expectedV2);
const resultV1: AnnotationQuery = transformV2ToV1AnnotationQuery(expectedV2);
expect(resultV1).toEqual(annotationDefinition);
});
});
@@ -74,7 +74,10 @@ export function transformV1ToV2AnnotationQuery(
result.spec.filter = annotation.filter;
}
// TODO: add mappings
// Add mappings if they exist
if (annotation.mappings && Object.keys(annotation.mappings).length > 0) {
result.spec.mappings = annotation.mappings;
}
return result;
}
@@ -88,9 +91,13 @@ export function transformV2ToV1AnnotationQuery(annotation: AnnotationQueryKind):
hide: annotation.spec.hide,
iconColor: annotation.spec.iconColor,
name: annotation.spec.name,
// TOOO: mappings
};
// Add mappings if they exist
if (annotation.spec.mappings && Object.keys(annotation.spec.mappings).length > 0) {
annoQuerySpec.mappings = annotation.spec.mappings;
}
// Add placement if it exists
if (annotation.spec.placement) {
annoQuerySpec.placement = annotation.spec.placement;
@@ -1,7 +1,6 @@
import { MetricFindValue, TypedVariableModel } from '@grafana/data';
import { MetricFindValue, TypedVariableModel, AnnotationQuery } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
AnnotationQuery,
DataQuery,
DataSourceRef,
Panel,
@@ -845,7 +844,7 @@ function getVariables(vars: TypedVariableModel[]): DashboardV2Spec['variables']
function getAnnotations(annotations: AnnotationQuery[]): DashboardV2Spec['annotations'] {
return annotations.map((a) => {
// Extract properties that are explicitly handled
const { name, enable, hide, iconColor, builtIn, datasource, target, filter, ...legacyOptions } = a;
const { name, enable, hide, iconColor, builtIn, datasource, target, filter, mappings, ...legacyOptions } = a;
const aq: AnnotationQueryKind = {
kind: 'AnnotationQuery',
@@ -855,6 +854,7 @@ function getAnnotations(annotations: AnnotationQuery[]): DashboardV2Spec['annota
hide: Boolean(hide),
iconColor: iconColor,
builtIn: Boolean(builtIn),
...(mappings && { mappings: transformAnnotationMappingsV1ToV2(mappings) }),
query: {
kind: 'DataQuery',
version: defaultDataQueryKind().version,
@@ -1349,3 +1349,25 @@ export function transformDashboardV2SpecToV1(spec: DashboardV2Spec, metadata: Ob
templating: { list: variables },
};
}
export function transformAnnotationMappingsV1ToV2(
mappings: AnnotationQuery['mappings']
): AnnotationQueryKind['spec']['mappings'] {
if (!mappings) {
return {};
}
return Object.fromEntries(
Object.entries(mappings).map(([key, value]) => {
if (typeof value === 'string') {
return [key, { source: 'field', value }];
}
if (typeof value === 'object') {
return [key, value.source ? value : { source: 'field', ...value }];
}
return [key, value];
})
);
}