Dashboard Library: Integrate community dashboards on Suggested Dashboards Flow (#112808)

* Extend interpolate endpoint to support community dashboard json interpolation
Added unit tests

* Implement Frontend Side
- Show tabs
- Fetch Community dashboads
- Use DashboardCard component
- Search grafana dashboard in the community tab
- Make Tabs and pagination sticky
- Adjust titles to be scoped by datasource name/type
- Add skeleton loading for community tabs and pagination
- Add dashboard details tooltip
- Bring old datasource-provisioned box back and rely on new feature toggle for community dashboards
- update i18n
- update swagger
---------

Co-authored-by: Juan Cabanas <juan.cabanas@grafana.com>
Co-authored-by: nmarrs <nathanielmarrs@gmail.com>
This commit is contained in:
Alexa Vargas
2025-11-11 10:40:39 +01:00
committed by GitHub
co-authored by Juan Cabanas nmarrs
parent 34e85113d2
commit 62eef87208
24 changed files with 2739 additions and 255 deletions
+4 -4
View File
@@ -47,7 +47,7 @@ func (api *ImportDashboardAPI) RegisterAPIEndpoints(routeRegister routing.RouteR
routing.Wrap(api.ImportDashboard),
)
//nolint:staticcheck // not yet migrated to OpenFeature
if api.features.IsEnabledGlobally(featuremgmt.FlagDashboardLibrary) {
if api.features.IsEnabledGlobally(featuremgmt.FlagDashboardLibrary) || api.features.IsEnabledGlobally(featuremgmt.FlagSuggestedDashboards) {
route.Post(
"/interpolate",
authorize(accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate)),
@@ -59,7 +59,7 @@ func (api *ImportDashboardAPI) RegisterAPIEndpoints(routeRegister routing.RouteR
// swagger:route POST /dashboards/interpolate dashboards interpolateDashboard
//
// Interpolate dashboard. This is an experimental endpoint under dashboardLibrary FF and is subject to change.
// Interpolate dashboard. This is an experimental endpoint under dashboardLibrary or suggestedDashboards feature flags and is subject to change.
//
// Responses:
// 200: interpolateDashboardResponse
@@ -73,8 +73,8 @@ func (api *ImportDashboardAPI) InterpolateDashboard(c *contextmodel.ReqContext)
return response.Error(http.StatusBadRequest, "bad request data", err)
}
if req.PluginId == "" {
return response.Error(http.StatusUnprocessableEntity, "pluginId must be set", nil)
if req.PluginId == "" && req.Dashboard == nil {
return response.Error(http.StatusUnprocessableEntity, "pluginId or dashboard must be set", nil)
}
resp, err := api.dashboardImportService.InterpolateDashboard(c.Req.Context(), &req)
+227 -1
View File
@@ -189,7 +189,7 @@ func TestInterpolateDashboardFeatureFlag(t *testing.T) {
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("Feature flag enabled - interpolate endpoint should work", func(t *testing.T) {
t.Run("dashboardLibrary feature flag enabled - interpolate endpoint should work", func(t *testing.T) {
interpolateDashboardServiceCalled := false
service := &serviceMock{
interpolateDashboardFunc: func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) {
@@ -223,6 +223,232 @@ func TestInterpolateDashboardFeatureFlag(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode)
require.True(t, interpolateDashboardServiceCalled)
})
t.Run("suggestedDashboards feature flag enabled - interpolate endpoint should work", func(t *testing.T) {
interpolateDashboardServiceCalled := false
service := &serviceMock{
interpolateDashboardFunc: func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) {
interpolateDashboardServiceCalled = true
return simplejson.New(), nil
},
}
// Create features with suggestedDashboards enabled
features := featuremgmt.WithFeatures(featuremgmt.FlagSuggestedDashboards)
importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, features)
routeRegister := routing.NewRouteRegister()
importDashboardAPI.RegisterAPIEndpoints(routeRegister)
s := webtest.NewServer(t, routeRegister)
cmd := &dashboardimport.ImportDashboardRequest{
PluginId: "test-plugin",
}
jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err)
req := s.NewPostRequest("/api/dashboards/interpolate", bytes.NewReader(jsonBytes))
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
UserID: 1,
Permissions: map[int64]map[string][]string{
1: {dashboards.ActionDashboardsCreate: {}},
},
})
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode)
require.True(t, interpolateDashboardServiceCalled)
})
}
func TestInterpolateDashboardAPI(t *testing.T) {
features := featuremgmt.WithFeatures(featuremgmt.FlagDashboardLibrary)
t.Run("Backward compatibility - plugin-based flow still works", func(t *testing.T) {
var capturedReq *dashboardimport.ImportDashboardRequest
interpolateDashboardServiceCalled := false
service := &serviceMock{
interpolateDashboardFunc: func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) {
interpolateDashboardServiceCalled = true
capturedReq = req
result := simplejson.New()
result.Set("title", "Test Dashboard")
return result, nil
},
}
importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, features)
routeRegister := routing.NewRouteRegister()
importDashboardAPI.RegisterAPIEndpoints(routeRegister)
s := webtest.NewServer(t, routeRegister)
cmd := &dashboardimport.ImportDashboardRequest{
PluginId: "test-plugin",
Path: "dashboards/test.json",
}
jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err)
req := s.NewPostRequest("/api/dashboards/interpolate", bytes.NewReader(jsonBytes))
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
UserID: 1,
Permissions: map[int64]map[string][]string{
1: {dashboards.ActionDashboardsCreate: {}},
},
})
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode)
require.True(t, interpolateDashboardServiceCalled)
require.NotNil(t, capturedReq)
require.Equal(t, "test-plugin", capturedReq.PluginId)
require.Equal(t, "dashboards/test.json", capturedReq.Path)
})
t.Run("New flow with dashboard JSON - should call service with dashboard", func(t *testing.T) {
var capturedReq *dashboardimport.ImportDashboardRequest
interpolateDashboardServiceCalled := false
service := &serviceMock{
interpolateDashboardFunc: func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) {
interpolateDashboardServiceCalled = true
capturedReq = req
result := simplejson.New()
result.Set("title", "Community Dashboard")
result.Set("panels", []interface{}{})
return result, nil
},
}
importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, features)
routeRegister := routing.NewRouteRegister()
importDashboardAPI.RegisterAPIEndpoints(routeRegister)
s := webtest.NewServer(t, routeRegister)
// Create a test dashboard with datasource that needs interpolation
testDashboard := simplejson.New()
testDashboard.Set("title", "Test Community Dashboard")
testDashboard.Set("panels", []interface{}{
map[string]interface{}{
"datasource": "${DS_PROMETHEUS}",
},
})
cmd := &dashboardimport.ImportDashboardRequest{
Dashboard: testDashboard,
Inputs: []dashboardimport.ImportDashboardInput{
{Name: "DS_PROMETHEUS", Type: "datasource", PluginId: "prometheus", Value: "my-prometheus"},
},
}
jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err)
req := s.NewPostRequest("/api/dashboards/interpolate", bytes.NewReader(jsonBytes))
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
UserID: 1,
Permissions: map[int64]map[string][]string{
1: {dashboards.ActionDashboardsCreate: {}},
},
})
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode)
require.True(t, interpolateDashboardServiceCalled)
require.NotNil(t, capturedReq)
require.NotNil(t, capturedReq.Dashboard)
require.Len(t, capturedReq.Inputs, 1)
require.Equal(t, "DS_PROMETHEUS", capturedReq.Inputs[0].Name)
})
t.Run("Validation - both pluginId and dashboard missing should return error", func(t *testing.T) {
service := &serviceMock{}
importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, features)
routeRegister := routing.NewRouteRegister()
importDashboardAPI.RegisterAPIEndpoints(routeRegister)
s := webtest.NewServer(t, routeRegister)
cmd := &dashboardimport.ImportDashboardRequest{
PluginId: "",
Dashboard: nil,
}
jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err)
req := s.NewPostRequest("/api/dashboards/interpolate", bytes.NewReader(jsonBytes))
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
UserID: 1,
Permissions: map[int64]map[string][]string{
1: {dashboards.ActionDashboardsCreate: {}},
},
})
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
})
t.Run("Response should not include internal fields", func(t *testing.T) {
service := &serviceMock{
interpolateDashboardFunc: func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) {
result := simplejson.New()
result.Set("title", "Test Dashboard")
result.Set("__elements", map[string]interface{}{"test": "value"})
result.Set("__inputs", []interface{}{})
result.Set("__requires", []interface{}{})
result.Set("panels", []interface{}{})
return result, nil
},
}
importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, features)
routeRegister := routing.NewRouteRegister()
importDashboardAPI.RegisterAPIEndpoints(routeRegister)
s := webtest.NewServer(t, routeRegister)
cmd := &dashboardimport.ImportDashboardRequest{
PluginId: "test-plugin",
}
jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err)
req := s.NewPostRequest("/api/dashboards/interpolate", bytes.NewReader(jsonBytes))
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
UserID: 1,
Permissions: map[int64]map[string][]string{
1: {dashboards.ActionDashboardsCreate: {}},
},
})
resp, err := s.SendJSON(req)
require.NoError(t, err)
defer func() { require.NoError(t, resp.Body.Close()) }()
require.Equal(t, http.StatusOK, resp.StatusCode)
// Parse response body and verify internal fields are removed
var responseBody map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&responseBody)
require.NoError(t, err)
require.Equal(t, "Test Dashboard", responseBody["title"])
require.NotContains(t, responseBody, "__elements")
require.NotContains(t, responseBody, "__inputs")
require.NotContains(t, responseBody, "__requires")
})
t.Run("Not signed in should return 401", func(t *testing.T) {
service := &serviceMock{}
importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, features)
routeRegister := routing.NewRouteRegister()
importDashboardAPI.RegisterAPIEndpoints(routeRegister)
s := webtest.NewServer(t, routeRegister)
cmd := &dashboardimport.ImportDashboardRequest{
Dashboard: simplejson.New(),
}
jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err)
req := s.NewPostRequest("/api/dashboards/interpolate", bytes.NewReader(jsonBytes))
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
type serviceMock struct {
@@ -2,6 +2,7 @@ package service
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/apimachinery/identity"
@@ -60,8 +61,10 @@ func (s *ImportDashboardService) InterpolateDashboard(ctx context.Context, req *
} else {
draftDashboard = resp.Dashboard
}
} else {
} else if req.Dashboard != nil {
draftDashboard = dashboards.NewDashboardFromJson(req.Dashboard)
} else {
return nil, fmt.Errorf("either PluginId or Dashboard must be provided")
}
evaluator := utils.NewDashTemplateEvaluator(draftDashboard.Data, req.Inputs)
@@ -160,6 +160,159 @@ func TestImportDashboardService(t *testing.T) {
})
}
func TestInterpolateDashboardService(t *testing.T) {
t.Run("InterpolateDashboard with plugin ID should load from plugin", func(t *testing.T) {
pluginDashboardService := &pluginDashboardServiceMock{
loadPluginDashboardFunc: loadTestDashboard,
}
s := &ImportDashboardService{
pluginDashboardService: pluginDashboardService,
features: featuremgmt.WithFeatures(),
}
req := &dashboardimport.ImportDashboardRequest{
PluginId: "prometheus",
Path: "dashboard.json",
Inputs: []dashboardimport.ImportDashboardInput{
{Name: "*", Type: "datasource", Value: "prom"},
},
}
result, err := s.InterpolateDashboard(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, result)
// Verify datasource was interpolated
panel := result.Get("panels").GetIndex(0)
require.Equal(t, "prom", panel.Get("datasource").MustString())
})
t.Run("InterpolateDashboard with dashboard JSON should apply interpolation", func(t *testing.T) {
s := &ImportDashboardService{
features: featuremgmt.WithFeatures(),
}
// Create test dashboard with template variables
testDashboard := simplejson.New()
testDashboard.Set("title", "Test Community Dashboard")
testDashboard.Set("uid", "test-uid")
// Add __inputs section (required by template evaluator)
inputs := []interface{}{
map[string]interface{}{
"name": "DS_PROMETHEUS",
"type": "datasource",
"pluginId": "prometheus",
},
map[string]interface{}{
"name": "DS_LOKI",
"type": "datasource",
"pluginId": "loki",
},
}
testDashboard.Set("__inputs", inputs)
panels := []interface{}{
map[string]interface{}{
"id": 1,
"datasource": map[string]interface{}{
"uid": "${DS_PROMETHEUS}",
},
},
map[string]interface{}{
"id": 2,
"datasource": map[string]interface{}{
"uid": "${DS_LOKI}",
},
},
}
testDashboard.Set("panels", panels)
req := &dashboardimport.ImportDashboardRequest{
Dashboard: testDashboard,
Inputs: []dashboardimport.ImportDashboardInput{
{Name: "DS_PROMETHEUS", Type: "datasource", PluginId: "prometheus", Value: "my-prometheus"},
{Name: "DS_LOKI", Type: "datasource", PluginId: "loki", Value: "my-loki"},
},
}
result, err := s.InterpolateDashboard(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, result)
// Verify datasources were interpolated correctly
panel1 := result.Get("panels").GetIndex(0)
require.Equal(t, "my-prometheus", panel1.Get("datasource").Get("uid").MustString())
panel2 := result.Get("panels").GetIndex(1)
require.Equal(t, "my-loki", panel2.Get("datasource").Get("uid").MustString())
})
t.Run("InterpolateDashboard with dashboard JSON and wildcard datasource", func(t *testing.T) {
s := &ImportDashboardService{
features: featuremgmt.WithFeatures(),
}
// Create test dashboard with simple datasource reference
testDashboard := simplejson.New()
testDashboard.Set("title", "Test Dashboard")
// Add __inputs section for wildcard matching
inputs := []interface{}{
map[string]interface{}{
"name": "DS_TEST",
"type": "datasource",
"pluginId": "testdata",
},
}
testDashboard.Set("__inputs", inputs)
panels := []interface{}{
map[string]interface{}{
"id": 1,
"datasource": "${DS_TEST}",
},
}
testDashboard.Set("panels", panels)
req := &dashboardimport.ImportDashboardRequest{
Dashboard: testDashboard,
Inputs: []dashboardimport.ImportDashboardInput{
{Name: "*", Type: "datasource", Value: "default-datasource"},
},
}
result, err := s.InterpolateDashboard(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, result)
// With wildcard, it should replace any datasource template
panel := result.Get("panels").GetIndex(0)
datasource := panel.Get("datasource").MustString()
// The wildcard matcher should have replaced the template
require.NotEqual(t, "${DS_TEST}", datasource)
})
t.Run("InterpolateDashboard without plugin ID or dashboard should fail", func(t *testing.T) {
s := &ImportDashboardService{
features: featuremgmt.WithFeatures(),
}
req := &dashboardimport.ImportDashboardRequest{
PluginId: "",
Dashboard: nil,
Inputs: []dashboardimport.ImportDashboardInput{},
}
// This should fail with validation error
result, err := s.InterpolateDashboard(context.Background(), req)
require.Error(t, err)
require.Nil(t, result)
require.Contains(t, err.Error(), "either PluginId or Dashboard must be provided")
})
}
func loadTestDashboard(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) {
// It's safe to ignore gosec warning G304 since this is a test and arguments comes from test configuration.
// nolint:gosec