K8s/Dashboards: Extract Dashboard APIs to an app submodule (#102029)

* Move dashboard k8s APIs to a separate app

Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>

* Copy dashboard code in Dockerfile

Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>

* Fix conversion generation

Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>

* Update OpenAPI snapshot for dashboard/v0alpha1

Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>

---------

Signed-off-by: Igor Suleymanov <igor.suleymanov@grafana.com>
This commit is contained in:
Igor Suleymanov
2025-03-13 11:05:01 +02:00
committed by GitHub
parent 87638c0170
commit 5d2ba10113
138 changed files with 1424 additions and 908 deletions
@@ -0,0 +1,25 @@
package schemaversion
import "fmt"
var _ error = &MigrationError{}
// ErrMigrationFailed is an error that is returned when a migration fails.
func NewMigrationError(msg string, currentVersion, targetVersion int) *MigrationError {
return &MigrationError{
msg: msg,
targetVersion: targetVersion,
currentVersion: currentVersion,
}
}
// MigrationError is an error type for migration errors.
type MigrationError struct {
msg string
targetVersion int
currentVersion int
}
func (e *MigrationError) Error() string {
return fmt.Errorf("schema migration from version %d to %d failed: %v", e.currentVersion, e.targetVersion, e.msg).Error()
}
@@ -0,0 +1,32 @@
package schemaversion
import "strconv"
type SchemaVersionMigrationFunc func(map[string]interface{}) error
const LATEST_VERSION = 41
var Migrations = map[int]SchemaVersionMigrationFunc{
37: V37,
38: V38,
39: V39,
40: V40,
41: V41,
}
func GetSchemaVersion(dash map[string]interface{}) int {
if v, ok := dash["schemaVersion"]; ok {
switch v := v.(type) {
case int:
return v
case float64:
return int(v)
case string:
if version, err := strconv.Atoi(v); err == nil {
return version
}
return 0
}
}
return 0
}
@@ -0,0 +1,75 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
"github.com/stretchr/testify/require"
)
func TestGetSchemaVersion(t *testing.T) {
tests := []struct {
name string
dash map[string]interface{}
expected int
}{
{
name: "schemaVersion as int",
dash: map[string]interface{}{
"schemaVersion": 16,
},
expected: 16,
},
{
name: "schemaVersion as float64",
dash: map[string]interface{}{
"schemaVersion": 40.2345,
},
expected: 40,
},
{
name: "schemaVersion is not set",
dash: map[string]interface{}{},
expected: 0,
},
{
name: "schemaVersion as string int",
dash: map[string]interface{}{
"schemaVersion": "5",
},
expected: 5,
},
{
name: "schemaVersion as invalid string",
dash: map[string]interface{}{
"schemaVersion": "foo",
},
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := schemaversion.GetSchemaVersion(tt.dash)
require.Equal(t, tt.expected, result)
})
}
}
type migrationTestCase struct {
name string
input map[string]interface{}
expected map[string]interface{}
}
func runMigrationTests(t *testing.T, testCases []migrationTestCase, migrationFunc schemaversion.SchemaVersionMigrationFunc) {
t.Helper()
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
err := migrationFunc(tt.input)
require.NoError(t, err)
require.Equal(t, tt.expected, tt.input)
})
}
}
@@ -0,0 +1,62 @@
package schemaversion
// V37 normalizes legend configuration in panels to use a consistent format:
// - Converts boolean legend values to object format
// - Standardizes hidden legends to use showLegend: false with displayMode: list
// - Ensures visible legends have showLegend: true
func V37(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = int(37)
panels, ok := dashboard["panels"].([]interface{})
if !ok {
return nil
}
for _, panel := range panels {
p, ok := panel.(map[string]interface{})
if !ok {
continue
}
options, ok := p["options"].(map[string]interface{})
if !ok {
continue
}
// Skip if no legend config exists
legendValue := options["legend"]
if legendValue == nil {
continue
}
// Convert boolean legend to object format
if legendBool, ok := legendValue.(bool); ok {
options["legend"] = map[string]interface{}{
"displayMode": "list",
"showLegend": legendBool,
}
continue
}
// Handle object format legend
legend, ok := legendValue.(map[string]interface{})
if !ok {
continue
}
displayMode, hasDisplayMode := legend["displayMode"].(string)
showLegend, hasShowLegend := legend["showLegend"].(bool)
// Normalize hidden legends
if (hasDisplayMode && displayMode == "hidden") || (hasShowLegend && !showLegend) {
legend["displayMode"] = "list"
legend["showLegend"] = false
continue
}
// Ensure visible legends have showLegend true
legend["showLegend"] = true
}
return nil
}
@@ -0,0 +1,170 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func TestV37(t *testing.T) {
tests := []migrationTestCase{
{
name: "no legend config",
input: map[string]interface{}{
"schemaVersion": 36,
"panels": []interface{}{
map[string]interface{}{
"type": "graph",
"options": map[string]interface{}{},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"type": "graph",
"options": map[string]interface{}{},
},
},
},
},
{
name: "boolean legend true",
input: map[string]interface{}{
"schemaVersion": 36,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": true,
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
"showLegend": true,
},
},
},
},
},
},
{
name: "boolean legend false",
input: map[string]interface{}{
"schemaVersion": 36,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": false,
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
"showLegend": false,
},
},
},
},
},
},
{
name: "hidden displayMode",
input: map[string]interface{}{
"schemaVersion": 36,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "hidden",
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
"showLegend": false,
},
},
},
},
},
},
{
name: "showLegend false",
input: map[string]interface{}{
"schemaVersion": 36,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"showLegend": false,
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "list",
"showLegend": false,
},
},
},
},
},
},
{
name: "visible legend",
input: map[string]interface{}{
"schemaVersion": 36,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "table",
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"options": map[string]interface{}{
"legend": map[string]interface{}{
"displayMode": "table",
"showLegend": true,
},
},
},
},
},
},
}
runMigrationTests(t, tests, schemaversion.V37)
}
@@ -0,0 +1,122 @@
package schemaversion
// V38 updates the configuration of the table panel to use the new cellOptions format
// and updates the overrides to use the new cellOptions format
func V38(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = int(38)
panels, ok := dashboard["panels"].([]interface{})
if !ok {
return nil
}
for _, panel := range panels {
p, ok := panel.(map[string]interface{})
if !ok {
continue
}
// Only process table panels
if p["type"] != "table" {
continue
}
fieldConfig, ok := p["fieldConfig"].(map[string]interface{})
if !ok {
continue
}
defaults, ok := fieldConfig["defaults"].(map[string]interface{})
if !ok {
continue
}
custom, ok := defaults["custom"].(map[string]interface{})
if !ok {
continue
}
// Migrate displayMode to cellOptions
if displayMode, exists := custom["displayMode"]; exists {
if displayModeStr, ok := displayMode.(string); ok {
custom["cellOptions"] = migrateTableDisplayModeToCellOptions(displayModeStr)
}
// Delete the legacy field
delete(custom, "displayMode")
}
// Update any overrides referencing the cell display mode
migrateOverrides(fieldConfig)
}
return nil
}
// migrateOverrides updates the overrides configuration to use the new cellOptions format
func migrateOverrides(fieldConfig map[string]interface{}) {
overrides, ok := fieldConfig["overrides"].([]interface{})
if !ok {
return
}
for _, override := range overrides {
o, ok := override.(map[string]interface{})
if !ok {
continue
}
properties, ok := o["properties"].([]interface{})
if !ok {
continue
}
for _, property := range properties {
prop, ok := property.(map[string]interface{})
if !ok {
continue
}
// Update the id to cellOptions
if prop["id"] == "custom.displayMode" {
prop["id"] = "custom.cellOptions"
if value, ok := prop["value"]; ok {
if valueStr, ok := value.(string); ok {
prop["value"] = migrateTableDisplayModeToCellOptions(valueStr)
}
}
}
}
}
}
// migrateTableDisplayModeToCellOptions converts the old displayMode string to the new cellOptions format
func migrateTableDisplayModeToCellOptions(displayMode string) map[string]interface{} {
switch displayMode {
case "basic", "gradient-gauge", "lcd-gauge":
gaugeMode := "basic"
if displayMode == "gradient-gauge" {
gaugeMode = "gradient"
} else if displayMode == "lcd-gauge" {
gaugeMode = "lcd"
}
return map[string]interface{}{
"type": "gauge",
"mode": gaugeMode,
}
case "color-background", "color-background-solid":
mode := "basic"
if displayMode == "color-background" {
mode = "gradient"
}
return map[string]interface{}{
"type": "color-background",
"mode": mode,
}
default:
return map[string]interface{}{
"type": displayMode,
}
}
}
@@ -0,0 +1,251 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func TestV38(t *testing.T) {
tests := []migrationTestCase{
{
name: "no table panels",
input: map[string]interface{}{
"schemaVersion": 37,
"title": "Test Dashboard",
"panels": []interface{}{
map[string]interface{}{
"type": "graph",
"title": "Panel 1",
},
},
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "graph",
"title": "Panel 1",
},
},
},
},
{
name: "table panel with basic gauge displayMode",
input: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "basic",
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"cellOptions": map[string]interface{}{
"type": "gauge",
"mode": "basic",
},
},
},
},
},
},
},
},
{
name: "table panel with gradient-gauge displayMode",
input: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "gradient-gauge",
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"cellOptions": map[string]interface{}{
"type": "gauge",
"mode": "gradient",
},
},
},
},
},
},
},
},
{
name: "table panel with lcd-gauge displayMode",
input: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "lcd-gauge",
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"cellOptions": map[string]interface{}{
"type": "gauge",
"mode": "lcd",
},
},
},
},
},
},
},
},
{
name: "table panel with color-background displayMode",
input: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "color-background",
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"cellOptions": map[string]interface{}{
"type": "color-background",
"mode": "gradient",
},
},
},
},
},
},
},
},
{
name: "table panel with color-background-solid displayMode",
input: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "color-background-solid",
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"cellOptions": map[string]interface{}{
"type": "color-background",
"mode": "basic",
},
},
},
},
},
},
},
},
{
name: "table panel with default displayMode",
input: map[string]interface{}{
"schemaVersion": 37,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"displayMode": "some-other-mode",
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"type": "table",
"fieldConfig": map[string]interface{}{
"defaults": map[string]interface{}{
"custom": map[string]interface{}{
"cellOptions": map[string]interface{}{
"type": "some-other-mode",
},
},
},
},
},
},
},
},
}
runMigrationTests(t, tests, schemaversion.V38)
}
@@ -0,0 +1,60 @@
package schemaversion
// V39 updates the configuration of the Timeseries to table transformation
// to support multiple options per query
func V39(dashboard map[string]interface{}) error {
dashboard["schemaVersion"] = int(39)
panels, ok := dashboard["panels"].([]interface{})
if !ok {
return nil
}
for _, panel := range panels {
p, ok := panel.(map[string]interface{})
if !ok {
continue
}
transformations, ok := p["transformations"].([]interface{})
if !ok {
continue
}
for _, transformation := range transformations {
t, ok := transformation.(map[string]interface{})
if !ok {
continue
}
// If we run into a timeSeriesTable transformation
// and it doesn't have undefined options then we migrate
if t["id"] != "timeSeriesTable" {
continue
}
options, ok := t["options"].(map[string]interface{})
if !ok {
continue
}
refIdStats, ok := options["refIdToStat"].(map[string]interface{})
if !ok {
continue
}
// For each {refIdtoStat} record which maps refId to a statistic
// we add that to the stat property of the new
// RefIdTransformerOptions interface which includes multiple settings
transformationOptions := make(map[string]interface{})
for refId, stat := range refIdStats {
transformationOptions[refId] = map[string]interface{}{"stat": stat}
}
// Update the options
t["options"] = transformationOptions
}
}
return nil
}
@@ -0,0 +1,111 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func TestV39(t *testing.T) {
tests := []migrationTestCase{
{
name: "no transformations",
input: map[string]interface{}{
"schemaVersion": 38,
"title": "Test Dashboard",
"panels": []interface{}{
map[string]interface{}{
"title": "Panel 1",
},
},
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 39,
"panels": []interface{}{
map[string]interface{}{
"title": "Panel 1",
},
},
},
},
{
name: "timeSeriesTable transformation with refIdToStat",
input: map[string]interface{}{
"schemaVersion": 38,
"panels": []interface{}{
map[string]interface{}{
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"refIdToStat": map[string]interface{}{
"A": "mean",
"B": "max",
},
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 39,
"panels": []interface{}{
map[string]interface{}{
"transformations": []interface{}{
map[string]interface{}{
"id": "timeSeriesTable",
"options": map[string]interface{}{
"A": map[string]interface{}{
"stat": "mean",
},
"B": map[string]interface{}{
"stat": "max",
},
},
},
},
},
},
},
},
{
name: "non-timeSeriesTable transformation is not modified",
input: map[string]interface{}{
"panels": []interface{}{
map[string]interface{}{
"transformations": []interface{}{
map[string]interface{}{
"id": "otherTransform",
"options": map[string]interface{}{
"refIdToStat": map[string]interface{}{
"A": "mean",
},
},
},
},
},
},
},
expected: map[string]interface{}{
"schemaVersion": 39,
"panels": []interface{}{
map[string]interface{}{
"transformations": []interface{}{
map[string]interface{}{
"id": "otherTransform",
"options": map[string]interface{}{
"refIdToStat": map[string]interface{}{
"A": "mean",
},
},
},
},
},
},
},
},
}
runMigrationTests(t, tests, schemaversion.V39)
}
@@ -0,0 +1,9 @@
package schemaversion
func V40(dash map[string]interface{}) error {
dash["schemaVersion"] = int(40)
if _, ok := dash["refresh"].(string); !ok {
dash["refresh"] = ""
}
return nil
}
@@ -0,0 +1,51 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func TestV40(t *testing.T) {
tests := []migrationTestCase{
{
name: "refresh not set",
input: map[string]interface{}{
"title": "Test Dashboard",
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 40,
"refresh": "",
},
},
{
name: "boolean refresh value is converted to an empty string",
input: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 39,
"refresh": true,
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 40,
"refresh": "",
},
},
{
name: "string refresh value is not converted",
input: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 39,
"refresh": "1m",
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 40,
"refresh": "1m",
},
},
}
runMigrationTests(t, tests, schemaversion.V40)
}
@@ -0,0 +1,11 @@
package schemaversion
func V41(dash map[string]interface{}) error {
dash["schemaVersion"] = int(41)
if timepicker, ok := dash["timepicker"].(map[string]interface{}); ok {
// time_options is a legacy property that was not used since grafana version 5
// therefore deprecating this property from the schema
delete(timepicker, "time_options")
}
return nil
}
@@ -0,0 +1,38 @@
package schemaversion_test
import (
"testing"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
)
func TestV41(t *testing.T) {
tests := []migrationTestCase{
{
name: "time_options is removed",
input: map[string]interface{}{
"title": "Test Dashboard",
"timepicker": map[string]interface{}{
"time_options": []string{"1m", "5m", "15m", "1h", "6h", "12h", "24h"},
},
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 41,
"timepicker": map[string]interface{}{},
},
},
{
name: "timepicker is not set",
input: map[string]interface{}{
"title": "Test Dashboard",
},
expected: map[string]interface{}{
"title": "Test Dashboard",
"schemaVersion": 41,
},
},
}
runMigrationTests(t, tests, schemaversion.V41)
}