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:
co-authored by
Juan Cabanas
nmarrs
parent
34e85113d2
commit
62eef87208
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -3684,7 +3684,7 @@
|
||||
"tags": [
|
||||
"dashboards"
|
||||
],
|
||||
"summary": "Interpolate dashboard. This is an experimental endpoint under dashboardLibrary FF and is subject to change.",
|
||||
"summary": "Interpolate dashboard. This is an experimental endpoint under dashboardLibrary or suggestedDashboards feature flags and is subject to change.",
|
||||
"operationId": "interpolateDashboard",
|
||||
"responses": {
|
||||
"200": {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { sceneGraph } from '@grafana/scenes';
|
||||
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { GetRepositoryFilesWithPathApiResponse, provisioningAPIv0alpha1 } from 'app/api/clients/provisioning/v0alpha1';
|
||||
import { StateManagerBase } from 'app/core/services/StateManagerBase';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { getMessageFromError, getMessageIdFromError, getStatusFromError } from 'app/core/utils/errors';
|
||||
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
|
||||
import {
|
||||
@@ -474,13 +475,40 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
|
||||
throw new Error('Snapshot not found');
|
||||
}
|
||||
|
||||
private async loadTemplateDashboard(): Promise<DashboardDTO> {
|
||||
private buildDashboardDTOFromInterpolated(interpolatedDashboard: DashboardDataDTO): DashboardDTO {
|
||||
return {
|
||||
dashboard: {
|
||||
...interpolatedDashboard,
|
||||
uid: '',
|
||||
version: 0,
|
||||
id: null,
|
||||
},
|
||||
meta: {
|
||||
canSave: contextSrv.hasEditPermissionInFolders,
|
||||
canEdit: contextSrv.hasEditPermissionInFolders,
|
||||
canStar: false,
|
||||
canShare: false,
|
||||
canDelete: false,
|
||||
isNew: true,
|
||||
folderUid: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async loadSuggestedDashboard(): Promise<DashboardDTO> {
|
||||
// Extract template parameters from URL
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const datasource = searchParams.get('datasource');
|
||||
const gnetId = searchParams.get('gnetId');
|
||||
const pluginId = searchParams.get('pluginId');
|
||||
const path = searchParams.get('path');
|
||||
|
||||
// Check if this is a community dashboard (has gnetId) or plugin dashboard
|
||||
if (gnetId) {
|
||||
return this.loadCommunityTemplateDashboard(gnetId);
|
||||
}
|
||||
|
||||
// Original plugin dashboard flow
|
||||
if (!datasource || !pluginId || !path) {
|
||||
throw new Error('Missing required parameters for template dashboard');
|
||||
}
|
||||
@@ -505,24 +533,41 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
|
||||
};
|
||||
|
||||
const interpolatedDashboard = await getBackendSrv().post('/api/dashboards/interpolate', data);
|
||||
return this.buildDashboardDTOFromInterpolated(interpolatedDashboard);
|
||||
}
|
||||
|
||||
return {
|
||||
dashboard: {
|
||||
...interpolatedDashboard,
|
||||
uid: '',
|
||||
version: 0,
|
||||
id: null,
|
||||
},
|
||||
meta: {
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canStar: false,
|
||||
canShare: false,
|
||||
canDelete: false,
|
||||
isNew: true,
|
||||
folderUid: '',
|
||||
},
|
||||
private async loadCommunityTemplateDashboard(gnetId: string): Promise<DashboardDTO> {
|
||||
// Extract mappings from URL params
|
||||
const location = locationService.getLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const mappingsJson = searchParams.get('mappings');
|
||||
|
||||
if (!mappingsJson) {
|
||||
throw new Error('Missing mappings parameter for community dashboard');
|
||||
}
|
||||
|
||||
let mappings;
|
||||
try {
|
||||
mappings = JSON.parse(mappingsJson);
|
||||
} catch (err) {
|
||||
throw new Error('Invalid mappings parameter: ' + err);
|
||||
}
|
||||
|
||||
// Fetch the community dashboard from grafana.com
|
||||
const gnetDashboard = await getBackendSrv().get(`/api/gnet/dashboards/${gnetId}`);
|
||||
|
||||
// The dashboard JSON is in the 'json' property
|
||||
const dashboardJson = gnetDashboard.json;
|
||||
|
||||
// Call interpolate endpoint with the dashboard JSON and mappings
|
||||
const data = {
|
||||
dashboard: dashboardJson,
|
||||
overwrite: true,
|
||||
inputs: mappings,
|
||||
};
|
||||
|
||||
const interpolatedDashboard = await getBackendSrv().post('/api/dashboards/interpolate', data);
|
||||
return this.buildDashboardDTOFromInterpolated(interpolatedDashboard);
|
||||
}
|
||||
|
||||
public async fetchDashboard({
|
||||
@@ -559,7 +604,7 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
|
||||
rsp = await buildNewDashboardSaveModel(urlFolderUid);
|
||||
break;
|
||||
case DashboardRoutes.Template:
|
||||
rsp = await this.loadTemplateDashboard();
|
||||
rsp = await this.loadSuggestedDashboard();
|
||||
break;
|
||||
case DashboardRoutes.Provisioning:
|
||||
return this.loadProvisioningDashboard(slug || '', uid);
|
||||
|
||||
@@ -24,6 +24,13 @@ jest.mock('@grafana/runtime', () => ({
|
||||
})),
|
||||
},
|
||||
reportInteraction: jest.fn(),
|
||||
getDataSourceSrv: jest.fn(() => ({
|
||||
getInstanceSettings: jest.fn((uid: string) => ({
|
||||
uid,
|
||||
name: 'Test Datasource',
|
||||
type: 'prometheus',
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('app/features/dashboard/utils/dashboard', () => ({
|
||||
@@ -40,10 +47,16 @@ jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () =>
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../DashboardLibrary/DashboardLibrarySection', () => ({
|
||||
DashboardLibrarySection: () => <div data-testid="dashboard-library-section">Dashboard Library Section</div>,
|
||||
jest.mock('../DashboardLibrary/api/dashboardLibraryApi', () => ({
|
||||
fetchProvisionedDashboards: jest.fn(() => Promise.resolve([])),
|
||||
fetchCommunityDashboards: jest.fn(() => Promise.resolve({ page: 1, pages: 1, dashboards: [] })),
|
||||
fetchCommunityDashboard: jest.fn(() => Promise.resolve({ json: {} })),
|
||||
}));
|
||||
|
||||
const mockFetchProvisionedDashboards = jest.mocked(
|
||||
require('../DashboardLibrary/api/dashboardLibraryApi').fetchProvisionedDashboards
|
||||
);
|
||||
|
||||
const mockUseGetResourceRepositoryView = jest.mocked(
|
||||
require('app/features/provisioning/hooks/useGetResourceRepositoryView').useGetResourceRepositoryView
|
||||
);
|
||||
@@ -162,7 +175,7 @@ it('renders with buttons disabled when repository is read-only', () => {
|
||||
expect(screen.getByRole('button', { name: 'Add library panel' })).toBeDisabled();
|
||||
});
|
||||
|
||||
describe('DashboardLibrarySection feature toggle', () => {
|
||||
describe('ProvisionedDashboardsEmptyPage feature toggle', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseGetResourceRepositoryView.mockReturnValue({
|
||||
@@ -172,31 +185,41 @@ describe('DashboardLibrarySection feature toggle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders DashboardLibrarySection when feature toggle is enabled and dashboardLibraryDatasourceUid param exists', () => {
|
||||
it('renders ProvisionedDashboardsEmptyPage when feature toggle is enabled and dashboardLibraryDatasourceUid param exists', async () => {
|
||||
config.featureToggles.dashboardLibrary = true;
|
||||
mockSearchParams.set('dashboardLibraryDatasourceUid', 'test-uid');
|
||||
|
||||
// Mock provisioned dashboards to return at least one dashboard so component renders
|
||||
mockFetchProvisionedDashboards.mockResolvedValueOnce([
|
||||
{
|
||||
uid: 'test-dashboard-1',
|
||||
title: 'Test Dashboard',
|
||||
pluginId: 'prometheus',
|
||||
path: '/test/path',
|
||||
},
|
||||
]);
|
||||
|
||||
setup();
|
||||
|
||||
expect(screen.getByTestId('dashboard-library-section')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('provisioned-dashboards-empty-page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render DashboardLibrarySection when feature toggle is disabled', () => {
|
||||
it('does not render ProvisionedDashboardsEmptyPage when feature toggle is disabled', () => {
|
||||
config.featureToggles.dashboardLibrary = false;
|
||||
mockSearchParams.delete('dashboardLibraryDatasourceUid');
|
||||
|
||||
setup();
|
||||
|
||||
expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('provisioned-dashboards-empty-page')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render DashboardLibrarySection when feature toggle is enabled but no dashboardLibraryDatasourceUid param', () => {
|
||||
it('does not render ProvisionedDashboardsEmptyPage when feature toggle is enabled but no dashboardLibraryDatasourceUid param', () => {
|
||||
config.featureToggles.dashboardLibrary = true;
|
||||
mockSearchParams.delete('dashboardLibraryDatasourceUid');
|
||||
|
||||
setup();
|
||||
|
||||
expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('provisioned-dashboards-empty-page')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,15 +256,28 @@ describe('wrapperMaxWidth CSS class', () => {
|
||||
expect(wrapperElement).toHaveStyle('max-width: 890px');
|
||||
});
|
||||
|
||||
it('does not apply wrapperMaxWidth class when dashboardLibrary feature is enabled and dashboardLibraryDatasourceUid param exists', () => {
|
||||
it('does not apply wrapperMaxWidth class when dashboardLibrary feature is enabled and dashboardLibraryDatasourceUid param exists', async () => {
|
||||
config.featureToggles.dashboardLibrary = true;
|
||||
|
||||
mockSearchParams.set('dashboardLibraryDatasourceUid', 'test-uid');
|
||||
|
||||
// Mock provisioned dashboards to return at least one dashboard so component renders
|
||||
mockFetchProvisionedDashboards.mockResolvedValueOnce([
|
||||
{
|
||||
uid: 'test-dashboard-1',
|
||||
title: 'Test Dashboard',
|
||||
pluginId: 'prometheus',
|
||||
path: '/test/path',
|
||||
},
|
||||
]);
|
||||
|
||||
const { container } = render(
|
||||
<DashboardEmpty dashboard={createDashboardModelFixture(defaultDashboard)} canCreate={true} />
|
||||
);
|
||||
|
||||
// Wait for ProvisionedDashboardsEmptyPage to render and complete async operations
|
||||
await screen.findByTestId('provisioned-dashboards-empty-page');
|
||||
|
||||
const wrapperElement = container.querySelector('[class*="dashboard-empty-wrapper"]');
|
||||
expect(wrapperElement).toBeInTheDocument();
|
||||
expect(wrapperElement).not.toHaveStyle('max-width: 890px');
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Button, useStyles2, Text, Box, Stack, TextLink } from '@grafana/ui';
|
||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
|
||||
|
||||
import { DashboardLibrarySection } from '../DashboardLibrary/DashboardLibrarySection';
|
||||
import { BasicProvisionedDashboardsEmptyPage } from '../DashboardLibrary/BasicProvisionedDashboardsEmptyPage';
|
||||
import { SuggestedDashboards } from '../DashboardLibrary/SuggestedDashboards';
|
||||
|
||||
import { DashboardEmptyExtensionPoint } from './DashboardEmptyExtensionPoint';
|
||||
import {
|
||||
@@ -28,100 +29,115 @@ interface InternalProps {
|
||||
|
||||
const InternalDashboardEmpty = ({ onAddVisualization, onAddLibraryPanel, onImportDashboard }: InternalProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const dashboardLibraryDatasourceUid = searchParams.get('dashboardLibraryDatasourceUid');
|
||||
|
||||
return (
|
||||
<Stack alignItems="center" justifyContent="center">
|
||||
<div
|
||||
className={cx(styles.wrapper, {
|
||||
[styles.wrapperMaxWidth]: !config.featureToggles.dashboardLibrary || !dashboardLibraryDatasourceUid,
|
||||
})}
|
||||
>
|
||||
<Stack alignItems="stretch" justifyContent="center" gap={4} direction="column">
|
||||
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={4}>
|
||||
<Stack direction="column" alignItems="center" gap={2}>
|
||||
<Text element="h1" textAlignment="center" weight="medium">
|
||||
<Trans i18nKey="dashboard.empty.add-visualization-header">
|
||||
Start your new dashboard by adding a visualization
|
||||
</Trans>
|
||||
</Text>
|
||||
<Box marginBottom={2} paddingX={4}>
|
||||
<Text element="p" textAlignment="center" color="secondary">
|
||||
<Trans i18nKey="dashboard.empty.add-visualization-body">
|
||||
Select a data source and then query and visualize your data with charts, stats and tables or create
|
||||
lists, markdowns and other widgets.
|
||||
<>
|
||||
<Stack alignItems="center" justifyContent="center">
|
||||
<div
|
||||
className={cx(styles.wrapper, {
|
||||
[styles.wrapperMaxWidth]:
|
||||
!(config.featureToggles.dashboardLibrary || config.featureToggles.suggestedDashboards) ||
|
||||
!dashboardLibraryDatasourceUid,
|
||||
})}
|
||||
>
|
||||
<Stack alignItems="stretch" justifyContent="center" gap={4} direction="column">
|
||||
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={4}>
|
||||
<Stack direction="column" alignItems="center" gap={2}>
|
||||
<Text element="h1" textAlignment="center" weight="medium">
|
||||
<Trans i18nKey="dashboard.empty.add-visualization-header">
|
||||
Start your new dashboard by adding a visualization
|
||||
</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
size="lg"
|
||||
icon="plus"
|
||||
data-testid={selectors.pages.AddDashboard.itemButton('Create new panel button')}
|
||||
onClick={onAddVisualization}
|
||||
disabled={!onAddVisualization}
|
||||
>
|
||||
<Trans i18nKey="dashboard.empty.add-visualization-button">Add visualization</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
{config.featureToggles.dashboardLibrary && dashboardLibraryDatasourceUid && <DashboardLibrarySection />}
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} wrap="wrap" gap={4}>
|
||||
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={3} flex={1}>
|
||||
<Stack direction="column" alignItems="center" gap={1}>
|
||||
<Text element="h3" textAlignment="center" weight="medium">
|
||||
<Trans i18nKey="dashboard.empty.add-library-panel-header">Import panel</Trans>
|
||||
</Text>
|
||||
<Box marginBottom={2}>
|
||||
<Box marginBottom={2} paddingX={4}>
|
||||
<Text element="p" textAlignment="center" color="secondary">
|
||||
<Trans i18nKey="dashboard.empty.add-library-panel-body">
|
||||
Add visualizations that are shared with other dashboards.
|
||||
<Trans i18nKey="dashboard.empty.add-visualization-body">
|
||||
Select a data source and then query and visualize your data with charts, stats and tables or
|
||||
create lists, markdowns and other widgets.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
size="lg"
|
||||
icon="plus"
|
||||
fill="outline"
|
||||
data-testid={selectors.pages.AddDashboard.itemButton('Add a panel from the panel library button')}
|
||||
onClick={onAddLibraryPanel}
|
||||
disabled={!onAddLibraryPanel}
|
||||
data-testid={selectors.pages.AddDashboard.itemButton('Create new panel button')}
|
||||
onClick={onAddVisualization}
|
||||
disabled={!onAddVisualization}
|
||||
>
|
||||
<Trans i18nKey="dashboard.empty.add-library-panel-button">Add library panel</Trans>
|
||||
<Trans i18nKey="dashboard.empty.add-visualization-button">Add visualization</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={3} flex={1}>
|
||||
<Stack direction="column" alignItems="center" gap={1}>
|
||||
<Text element="h3" textAlignment="center" weight="medium">
|
||||
<Trans i18nKey="dashboard.empty.import-a-dashboard-header">Import a dashboard</Trans>
|
||||
</Text>
|
||||
<Box marginBottom={2}>
|
||||
<Text element="p" textAlignment="center" color="secondary">
|
||||
<Trans i18nKey="dashboard.empty.import-a-dashboard-body">
|
||||
Import dashboards from files or{' '}
|
||||
<TextLink external href="https://grafana.com/grafana/dashboards/">
|
||||
grafana.com
|
||||
</TextLink>
|
||||
.
|
||||
</Trans>
|
||||
|
||||
{/* Suggested Dashboards Section */}
|
||||
{config.featureToggles.suggestedDashboards &&
|
||||
config.featureToggles.dashboardLibrary &&
|
||||
dashboardLibraryDatasourceUid && <SuggestedDashboards datasourceUid={dashboardLibraryDatasourceUid} />}
|
||||
|
||||
{/* Basic Provisioned Dashboards Section that don't include community dashboards */}
|
||||
{config.featureToggles.dashboardLibrary &&
|
||||
!config.featureToggles.suggestedDashboards &&
|
||||
dashboardLibraryDatasourceUid && (
|
||||
<BasicProvisionedDashboardsEmptyPage datasourceUid={dashboardLibraryDatasourceUid} />
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} wrap="wrap" gap={4}>
|
||||
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={3} flex={1}>
|
||||
<Stack direction="column" alignItems="center" gap={1}>
|
||||
<Text element="h3" textAlignment="center" weight="medium">
|
||||
<Trans i18nKey="dashboard.empty.add-library-panel-header">Import panel</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
icon="upload"
|
||||
fill="outline"
|
||||
data-testid={selectors.pages.AddDashboard.itemButton('Import dashboard button')}
|
||||
onClick={onImportDashboard}
|
||||
disabled={!onImportDashboard}
|
||||
>
|
||||
<Trans i18nKey="dashboard.empty.import-dashboard-button">Import dashboard</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box marginBottom={2}>
|
||||
<Text element="p" textAlignment="center" color="secondary">
|
||||
<Trans i18nKey="dashboard.empty.add-library-panel-body">
|
||||
Add visualizations that are shared with other dashboards.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
icon="plus"
|
||||
fill="outline"
|
||||
data-testid={selectors.pages.AddDashboard.itemButton('Add a panel from the panel library button')}
|
||||
onClick={onAddLibraryPanel}
|
||||
disabled={!onAddLibraryPanel}
|
||||
>
|
||||
<Trans i18nKey="dashboard.empty.add-library-panel-button">Add library panel</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={3} flex={1}>
|
||||
<Stack direction="column" alignItems="center" gap={1}>
|
||||
<Text element="h3" textAlignment="center" weight="medium">
|
||||
<Trans i18nKey="dashboard.empty.import-a-dashboard-header">Import a dashboard</Trans>
|
||||
</Text>
|
||||
<Box marginBottom={2}>
|
||||
<Text element="p" textAlignment="center" color="secondary">
|
||||
<Trans i18nKey="dashboard.empty.import-a-dashboard-body">
|
||||
Import dashboards from files or{' '}
|
||||
<TextLink external href="https://grafana.com/grafana/dashboards/">
|
||||
grafana.com
|
||||
</TextLink>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
icon="upload"
|
||||
fill="outline"
|
||||
data-testid={selectors.pages.AddDashboard.itemButton('Import dashboard button')}
|
||||
onClick={onImportDashboard}
|
||||
disabled={!onImportDashboard}
|
||||
>
|
||||
<Trans i18nKey="dashboard.empty.import-dashboard-button">Import dashboard</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { getDataSourceSrv, locationService } from '@grafana/runtime';
|
||||
import { Button, useStyles2, Text, Box, Stack, Grid } from '@grafana/ui';
|
||||
import { PluginDashboard } from 'app/types/plugins';
|
||||
|
||||
import { DASHBOARD_LIBRARY_ROUTES } from '../types';
|
||||
|
||||
import { fetchProvisionedDashboards } from './api/dashboardLibraryApi';
|
||||
import { DashboardLibraryInteractions } from './interactions';
|
||||
import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers';
|
||||
|
||||
interface Props {
|
||||
datasourceUid?: string;
|
||||
}
|
||||
|
||||
export const BasicProvisionedDashboardsEmptyPage = ({ datasourceUid }: Props) => {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const { value: templateDashboards } = useAsync(async (): Promise<PluginDashboard[]> => {
|
||||
if (!datasourceUid) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ds = getDataSourceSrv().getInstanceSettings(datasourceUid);
|
||||
if (!ds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dashboards = await fetchProvisionedDashboards(ds.type);
|
||||
return dashboards;
|
||||
}, [datasourceUid]);
|
||||
|
||||
const hasMoreThanThree = templateDashboards && templateDashboards.length > 3;
|
||||
const dashboardsToShow = showAll ? templateDashboards : templateDashboards?.slice(0, 3);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const onImportDashboardClick = async (dashboard: PluginDashboard) => {
|
||||
DashboardLibraryInteractions.itemClicked({
|
||||
contentKind: 'datasource_dashboard',
|
||||
datasourceTypes: [dashboard.pluginId],
|
||||
libraryItemId: dashboard.uid,
|
||||
libraryItemTitle: dashboard.title,
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
eventLocation: 'empty_dashboard',
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
datasource: datasourceUid || '',
|
||||
title: dashboard.title || 'Template',
|
||||
pluginId: dashboard.pluginId,
|
||||
path: dashboard.path,
|
||||
// tracking event purpose values
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
libraryItemId: dashboard.uid,
|
||||
creationOrigin: 'dashboard_library_datasource_dashboard',
|
||||
});
|
||||
|
||||
const templateUrl = `${DASHBOARD_LIBRARY_ROUTES.Template}?${params.toString()}`;
|
||||
locationService.push(templateUrl);
|
||||
};
|
||||
|
||||
if (!templateDashboards?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderColor="strong" borderStyle="dashed" padding={4} flex={1} data-testid="provisioned-dashboards-empty-page">
|
||||
<Stack direction="column" alignItems="center" gap={2}>
|
||||
<Text element="h3" textAlignment="center" weight="medium">
|
||||
<Trans i18nKey="dashboard.empty.start-with-suggested-dashboards">
|
||||
Start with a pre-made dashboard from your data source
|
||||
</Trans>
|
||||
</Text>
|
||||
<Box marginTop={2}>
|
||||
<Grid
|
||||
gap={4}
|
||||
columns={{
|
||||
xs: 1,
|
||||
sm: (dashboardsToShow?.length || 1) >= 2 ? 2 : 1,
|
||||
lg: (dashboardsToShow?.length || 1) >= 3 ? 3 : (dashboardsToShow?.length || 1) >= 2 ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
{dashboardsToShow?.map((dashboard, index) => {
|
||||
// Use global index for consistent image assignment across pages
|
||||
const imageUrl = getProvisionedDashboardImageUrl(index);
|
||||
|
||||
return (
|
||||
<TemplateDashboardBox
|
||||
key={dashboard.uid}
|
||||
index={index}
|
||||
dashboard={dashboard}
|
||||
imageUrl={imageUrl}
|
||||
onImportClick={onImportDashboardClick}
|
||||
/>
|
||||
);
|
||||
}) || []}
|
||||
</Grid>
|
||||
</Box>
|
||||
{hasMoreThanThree && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAll((prev) => !prev)}
|
||||
className={styles.showMoreButton}
|
||||
>
|
||||
{showAll ? (
|
||||
<Trans i18nKey="dashboard.empty.show-less-dashboards">Show less</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="dashboard.empty.show-more-dashboards">Show more</Trans>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateDashboardBox = ({
|
||||
dashboard,
|
||||
onImportClick,
|
||||
index,
|
||||
imageUrl,
|
||||
}: {
|
||||
dashboard: PluginDashboard;
|
||||
onImportClick: (d: PluginDashboard) => void;
|
||||
index: number;
|
||||
imageUrl: string;
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<div className={styles.templateDashboardBox}>
|
||||
<img src={imageUrl} width={285} height={160} alt={dashboard.title} className={styles.templateDashboardImage} />
|
||||
<div className={styles.templateDashboardTitle}>
|
||||
<Text element="p" textAlignment="center">
|
||||
{dashboard.title}
|
||||
</Text>
|
||||
</div>
|
||||
<Button fill="outline" onClick={() => onImportClick(dashboard)} size="sm">
|
||||
<Trans i18nKey="dashboard.empty.use-template-button">Use this dashboard</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
templateDashboardBox: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
alignItems: 'center',
|
||||
}),
|
||||
templateDashboardTitle: css({
|
||||
flex: 1,
|
||||
}),
|
||||
templateDashboardImage: css({
|
||||
borderRadius: theme.shape.radius.default,
|
||||
borderColor: theme.colors.text.primary,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
objectFit: 'cover',
|
||||
}),
|
||||
showMoreButton: css({
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
};
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { Stack, Text, Button, Alert, Field, Input, Box } from '@grafana/ui';
|
||||
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
|
||||
import { DashboardInput, DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
|
||||
|
||||
import { InputMapping, mapConstantInputs, mapUserSelectedDatasources } from './utils/autoMapDatasources';
|
||||
|
||||
interface Props {
|
||||
unmappedInputs: DataSourceInput[];
|
||||
constantInputs: DashboardInput[];
|
||||
existingMappings: InputMapping[];
|
||||
onBack: () => void;
|
||||
onPreview: (allMappings: InputMapping[]) => void;
|
||||
}
|
||||
|
||||
interface UserSelectedDatasourceMappings {
|
||||
name: string;
|
||||
pluginId: string;
|
||||
datasource: DataSourceInstanceSettings | undefined;
|
||||
}
|
||||
|
||||
export const CommunityDashboardMappingForm = ({
|
||||
unmappedInputs,
|
||||
constantInputs,
|
||||
existingMappings,
|
||||
onBack,
|
||||
onPreview,
|
||||
}: Props) => {
|
||||
const [userSelectedDsMappings, setUserSelectedDsMappings] = useState<Record<string, UserSelectedDatasourceMappings>>(
|
||||
() => {
|
||||
// Initialize with existing unmapped inputs
|
||||
return unmappedInputs.reduce<Record<string, UserSelectedDatasourceMappings>>((acc, input) => {
|
||||
const unmappedInput = {
|
||||
name: input.name,
|
||||
pluginId: input.pluginId,
|
||||
datasource: undefined,
|
||||
};
|
||||
acc[input.name] = unmappedInput;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
);
|
||||
|
||||
const [constantValues, setConstantValues] = useState<Record<string, string>>(() => {
|
||||
// Initialize with default values from constantInputs
|
||||
return constantInputs.reduce<Record<string, string>>((acc, input) => {
|
||||
acc[input.name] = input.value;
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const onDatasourceSelect = (inputName: string, datasource: DataSourceInstanceSettings) => {
|
||||
setUserSelectedDsMappings((prev) => ({
|
||||
...prev,
|
||||
[inputName]: {
|
||||
...prev[inputName],
|
||||
datasource,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const onConstantChange = (inputName: string, value: string) => {
|
||||
setConstantValues((prev) => ({
|
||||
...prev,
|
||||
[inputName]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const onPreviewClick = () => {
|
||||
// Combine all mappings:
|
||||
// 1. Existing auto-mapped datasources
|
||||
// 2. User-selected datasources
|
||||
// 3. Constant values (user-edited or defaults)
|
||||
|
||||
const userSelectedDatasources = mapUserSelectedDatasources(unmappedInputs, userSelectedDsMappings);
|
||||
const constantMappings = mapConstantInputs(constantInputs, constantValues);
|
||||
|
||||
const allMappings = [...existingMappings, ...userSelectedDatasources, ...constantMappings];
|
||||
onPreview(allMappings);
|
||||
};
|
||||
|
||||
// Check if all unmapped datasource inputs have been mapped by user
|
||||
// Constants are optional (have default values)
|
||||
const allDatasourcesMapped = unmappedInputs.every((input) => userSelectedDsMappings[input.name]?.datasource);
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={3} height="100%" justifyContent="space-between">
|
||||
<Stack direction="column" gap={3}>
|
||||
<Text element="p" color="secondary">
|
||||
<Trans i18nKey="dashboard-library.community-mapping-form.description">
|
||||
This dashboard requires datasource configuration. Select datasources for each input below.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{existingMappings.length > 0 && (
|
||||
<Alert title="" severity="info">
|
||||
<Stack direction="column" gap={1}>
|
||||
<Text>
|
||||
<Trans i18nKey="dashboard-library.community-mapping-form.auto-mapped" count={existingMappings.length}>
|
||||
{{ count: existingMappings.length }} datasources were automatically configured:
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text color="secondary">
|
||||
{existingMappings
|
||||
.map((mapping) => {
|
||||
const ds = getDataSourceSrv().getInstanceSettings(mapping.value);
|
||||
return `${mapping.pluginId} → ${ds?.name || mapping.value}`;
|
||||
})
|
||||
.join(' | ')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{unmappedInputs.length > 0 && (
|
||||
<Stack direction="column" gap={2}>
|
||||
<Text element="h4" weight="medium">
|
||||
<Trans i18nKey="dashboard-library.community-mapping-form.datasources-title">
|
||||
Datasource Configuration
|
||||
</Trans>
|
||||
</Text>
|
||||
{unmappedInputs.map((input) => {
|
||||
const selectedDatasource = userSelectedDsMappings[input.name]?.datasource;
|
||||
|
||||
return (
|
||||
<Field
|
||||
key={input.name}
|
||||
label={input.label || input.name}
|
||||
description={input.description}
|
||||
invalid={false}
|
||||
noMargin
|
||||
>
|
||||
<DataSourcePicker
|
||||
onChange={(ds) => onDatasourceSelect(input.name, ds)}
|
||||
current={selectedDatasource?.uid}
|
||||
noDefault={true}
|
||||
placeholder={
|
||||
input.info || t('dashboard-library.community-mapping-select-datasource', 'Select a datasource')
|
||||
}
|
||||
pluginId={input.pluginId}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{constantInputs.length > 0 && (
|
||||
<Stack direction="column" gap={2}>
|
||||
<Text element="h4" weight="medium">
|
||||
<Trans i18nKey="dashboard-library.community-mapping-form.constants-title">Dashboard Variables</Trans>
|
||||
</Text>
|
||||
{constantInputs.map((input) => (
|
||||
<Field
|
||||
key={input.name}
|
||||
label={input.label || input.name}
|
||||
description={input.description || input.info}
|
||||
noMargin
|
||||
>
|
||||
<Input
|
||||
value={constantValues[input.name] || ''}
|
||||
onChange={(e) => onConstantChange(input.name, e.currentTarget.value)}
|
||||
placeholder={input.value}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box paddingBottom={3}>
|
||||
<Stack direction="row" justifyContent="space-between" gap={2}>
|
||||
<Button variant="secondary" icon="arrow-left" onClick={onBack}>
|
||||
<Trans i18nKey="dashboard-library.community-mapping-form.back">Back to dashboards</Trans>
|
||||
</Button>
|
||||
<Button onClick={onPreviewClick} disabled={!allDatasourcesMapped}>
|
||||
<Trans i18nKey="dashboard-library.community-mapping-form.preview">Preview dashboard</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { useAsync, useDebounce } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { Button, useStyles2, Stack, Grid, EmptyState, Alert, Pagination, FilterInput } from '@grafana/ui';
|
||||
|
||||
import { DashboardCard } from './DashboardCard';
|
||||
import { MappingContext } from './SuggestedDashboardsModal';
|
||||
import { fetchCommunityDashboards } from './api/dashboardLibraryApi';
|
||||
import { DashboardLibraryInteractions } from './interactions';
|
||||
import { GnetDashboard } from './types';
|
||||
import {
|
||||
getThumbnailUrl,
|
||||
getLogoUrl,
|
||||
buildDashboardDetails,
|
||||
onUseCommunityDashboard,
|
||||
} from './utils/communityDashboardHelpers';
|
||||
|
||||
interface Props {
|
||||
onShowMapping: (context: MappingContext) => void;
|
||||
datasourceType?: string;
|
||||
}
|
||||
|
||||
// Constants for community dashboard pagination and API params
|
||||
const COMMUNITY_PAGE_SIZE = 9;
|
||||
const SEARCH_DEBOUNCE_MS = 500;
|
||||
const DEFAULT_SORT_ORDER = 'downloads';
|
||||
const DEFAULT_SORT_DIRECTION = 'desc';
|
||||
const INCLUDE_LOGO = true;
|
||||
const INCLUDE_SCREENSHOTS = true;
|
||||
|
||||
export const CommunityDashboardSection = ({ onShowMapping, datasourceType }: Props) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
|
||||
useDebounce(
|
||||
() => {
|
||||
setDebouncedSearchQuery(searchQuery);
|
||||
},
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
[searchQuery]
|
||||
);
|
||||
|
||||
// Reset to page 1 when debounced search query changes
|
||||
useEffect(() => {
|
||||
if (debouncedSearchQuery) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}, [debouncedSearchQuery]);
|
||||
|
||||
const {
|
||||
value: response,
|
||||
loading,
|
||||
error,
|
||||
} = useAsync(async () => {
|
||||
if (!datasourceUid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ds = getDataSourceSrv().getInstanceSettings(datasourceUid);
|
||||
if (!ds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiResponse = await fetchCommunityDashboards({
|
||||
orderBy: DEFAULT_SORT_ORDER,
|
||||
direction: DEFAULT_SORT_DIRECTION,
|
||||
page: currentPage,
|
||||
pageSize: COMMUNITY_PAGE_SIZE,
|
||||
includeLogo: INCLUDE_LOGO,
|
||||
includeScreenshots: INCLUDE_SCREENSHOTS,
|
||||
dataSourceSlugIn: ds.type,
|
||||
filter: debouncedSearchQuery.trim() || undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
dashboards: apiResponse.dashboards,
|
||||
pages: apiResponse.pages,
|
||||
datasourceType: ds.type,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error loading community dashboards', err);
|
||||
throw err;
|
||||
}
|
||||
}, [datasourceUid, currentPage, debouncedSearchQuery]);
|
||||
|
||||
// Track analytics only once on first successful load
|
||||
const hasTrackedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!loading &&
|
||||
!hasTrackedRef.current &&
|
||||
currentPage === 1 &&
|
||||
response?.dashboards &&
|
||||
response.dashboards.length > 0
|
||||
) {
|
||||
DashboardLibraryInteractions.loaded({
|
||||
numberOfItems: response.dashboards.length,
|
||||
contentKinds: ['community_dashboard'],
|
||||
datasourceTypes: [response.datasourceType],
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
eventLocation: 'suggested_dashboards_modal_community_tab',
|
||||
});
|
||||
hasTrackedRef.current = true;
|
||||
}
|
||||
}, [loading, currentPage, response]);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// Determine what to show in results area
|
||||
const dashboards = Array.isArray(response?.dashboards) ? response.dashboards : [];
|
||||
const totalPages = response?.pages || 1;
|
||||
const showEmptyState = !loading && (!response?.dashboards || response.dashboards.length === 0);
|
||||
const showError = !loading && error;
|
||||
|
||||
const onPreviewCommunityDashboard = (dashboard: GnetDashboard) => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUseCommunityDashboard({
|
||||
dashboard,
|
||||
datasourceUid: datasourceUid || '',
|
||||
datasourceType: response.datasourceType,
|
||||
eventLocation: 'suggested_dashboards_modal_community_tab',
|
||||
onShowMapping,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2} height="100%">
|
||||
<FilterInput
|
||||
className={styles.searchInput}
|
||||
placeholder={
|
||||
datasourceType
|
||||
? t(
|
||||
'dashboard-library.community-search-placeholder-with-datasource',
|
||||
'Search {{datasourceType}} community dashboards...',
|
||||
{ datasourceType }
|
||||
)
|
||||
: t('dashboard-library.community-search-placeholder', 'Search community dashboards...')
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
/>
|
||||
|
||||
<div className={styles.resultsContainer}>
|
||||
{loading ? (
|
||||
<Grid
|
||||
gap={4}
|
||||
columns={{
|
||||
xs: 1,
|
||||
sm: 2,
|
||||
lg: 3,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: COMMUNITY_PAGE_SIZE }).map((_, i) => (
|
||||
<DashboardCard.Skeleton key={`skeleton-${i}`} />
|
||||
))}
|
||||
</Grid>
|
||||
) : showError ? (
|
||||
<Stack direction="column" alignItems="center" gap={2}>
|
||||
<Alert
|
||||
title={t('dashboard-library.community-error-title', 'Error loading community dashboards')}
|
||||
severity="error"
|
||||
>
|
||||
<Trans i18nKey="dashboard-library.community-error">
|
||||
Failed to load community dashboards. Please try again.
|
||||
</Trans>
|
||||
</Alert>
|
||||
<Button variant="secondary" onClick={() => setCurrentPage(1)}>
|
||||
<Trans i18nKey="dashboard-library.retry">Retry</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
) : showEmptyState ? (
|
||||
<EmptyState
|
||||
variant="call-to-action"
|
||||
message={
|
||||
datasourceType
|
||||
? t(
|
||||
'dashboard-library.community-empty-title-with-datasource',
|
||||
'No {{datasourceType}} community dashboards found',
|
||||
{ datasourceType }
|
||||
)
|
||||
: t('dashboard-library.community-empty-title', 'No community dashboards found')
|
||||
}
|
||||
button={
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => window.open('https://grafana.com/grafana/dashboards/', '_blank')}
|
||||
>
|
||||
<Trans i18nKey="dashboard-library.browse-grafana-com">Browse Grafana.com</Trans>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{searchQuery && !datasourceType ? (
|
||||
<Trans i18nKey="dashboard-library.no-community-dashboards-search">
|
||||
Try a different search term or browse more dashboards on Grafana.com.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="dashboard-library.no-community-dashboards-datasource">
|
||||
Try a different search term or browse dashboards for different datasource types on Grafana.com.
|
||||
</Trans>
|
||||
)}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<Grid
|
||||
gap={4}
|
||||
columns={{
|
||||
xs: 1,
|
||||
sm: dashboards.length >= 2 ? 2 : 1,
|
||||
lg: dashboards.length >= 3 ? 3 : dashboards.length >= 2 ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
{dashboards.map((dashboard) => {
|
||||
const thumbnailUrl = getThumbnailUrl(dashboard);
|
||||
const logoUrl = getLogoUrl(dashboard);
|
||||
const imageUrl = thumbnailUrl || logoUrl;
|
||||
const isLogo = !thumbnailUrl;
|
||||
const details = buildDashboardDetails(dashboard);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
key={dashboard.id}
|
||||
title={dashboard.name}
|
||||
imageUrl={imageUrl}
|
||||
dashboard={dashboard}
|
||||
onClick={() => onPreviewCommunityDashboard(dashboard)}
|
||||
isLogo={isLogo}
|
||||
details={details}
|
||||
buttonText={<Trans i18nKey="dashboard-library.card.use-dashboard-button">Use dashboard</Trans>}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className={styles.paginationWrapper}>
|
||||
<Pagination currentPage={currentPage} numberOfPages={totalPages} onNavigate={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
resultsContainer: css({
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
}),
|
||||
paginationWrapper: css({
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
padding: theme.spacing(2),
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
zIndex: 2,
|
||||
}),
|
||||
searchInput: css({
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(2),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { Badge, Box, Button, Card, IconButton, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { attachSkeleton, SkeletonComponent } from '@grafana/ui/unstable';
|
||||
import { PluginDashboard } from 'app/types/plugins';
|
||||
|
||||
import { GnetDashboard } from './types';
|
||||
|
||||
interface Details {
|
||||
id: string;
|
||||
datasource: string;
|
||||
dependencies: string[];
|
||||
publishedBy: string;
|
||||
lastUpdate: string;
|
||||
grafanaComUrl?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
imageUrl?: string;
|
||||
dashboard: PluginDashboard | GnetDashboard;
|
||||
details?: Details;
|
||||
onClick: () => void;
|
||||
isLogo?: boolean; // Indicates if imageUrl is a small logo vs full screenshot
|
||||
showDatasourceProvidedBadge?: boolean;
|
||||
dimThumbnail?: boolean; // Apply 50% opacity to thumbnail when badge is shown
|
||||
buttonText?: React.ReactNode; // Optional custom button text, defaults to "Use template"
|
||||
}
|
||||
|
||||
function DashboardCardComponent({
|
||||
title,
|
||||
imageUrl,
|
||||
onClick,
|
||||
dashboard,
|
||||
details,
|
||||
isLogo,
|
||||
showDatasourceProvidedBadge,
|
||||
dimThumbnail,
|
||||
buttonText,
|
||||
}: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Card className={styles.card} noMargin>
|
||||
<Card.Heading className={styles.title}>{title}</Card.Heading>
|
||||
<div className={isLogo ? styles.logoContainer : styles.thumbnailContainer}>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
className={cx(
|
||||
isLogo ? styles.logo : styles.thumbnail,
|
||||
dimThumbnail && showDatasourceProvidedBadge && styles.dimmedImage
|
||||
)}
|
||||
onError={(e) => {
|
||||
console.error('Failed to load image for:', title, 'URL:', imageUrl);
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.noImage}>
|
||||
<Trans i18nKey="dashboard-library.card.no-preview">No preview available </Trans>
|
||||
</div>
|
||||
)}
|
||||
{showDatasourceProvidedBadge && (
|
||||
<div className={styles.badgeContainer}>
|
||||
<Badge
|
||||
text={t('dashboard-library.card.datasource-provided-badge', 'Data source provided')}
|
||||
color="orange"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div title={dashboard.description || ''} className={styles.descriptionWrapper}>
|
||||
{dashboard.description && (
|
||||
<Card.Description className={styles.description}>{dashboard.description}</Card.Description>
|
||||
)}
|
||||
</div>
|
||||
<Card.Actions className={styles.actionsContainer}>
|
||||
<Button variant="secondary" onClick={onClick}>
|
||||
{buttonText || <Trans i18nKey="dashboard-library.card.use-template-button">Use template</Trans>}
|
||||
</Button>
|
||||
{details && (
|
||||
<Tooltip interactive={true} content={<DetailsTooltipContent details={details} />} placement="right">
|
||||
<IconButton
|
||||
name="info-circle"
|
||||
size="xl"
|
||||
aria-label={t('dashboard-library.card.details-tooltip', 'Details')}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Card.Actions>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsTooltipContent({ details }: { details: Details }) {
|
||||
const Section = ({ label, value }: { label: string; value: string }) => {
|
||||
return (
|
||||
<Box display="flex" direction="column" gap={1}>
|
||||
<Text element="p">{label}</Text>
|
||||
<Text element="p" color="secondary">
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box display="flex">
|
||||
<Box display="flex" direction="column" gap={1} width={{ xs: 'auto', md: 340 }}>
|
||||
<Section label={t('dashboard-library.dashboard-card.details.id', 'ID')} value={details.id} />
|
||||
<Section
|
||||
label={t('dashboard-library.dashboard-card.details.datasource', 'Datasource')}
|
||||
value={details.datasource}
|
||||
/>
|
||||
<Section
|
||||
label={t('dashboard-library.dashboard-card.details.dependencies', 'Dependencies')}
|
||||
value={details.dependencies.join(' | ')}
|
||||
/>
|
||||
<Section
|
||||
label={t('dashboard-library.dashboard-card.details.published-by', 'Published By')}
|
||||
value={details.publishedBy}
|
||||
/>
|
||||
<Section
|
||||
label={t('dashboard-library.dashboard-card.details.last-update', 'Last Update')}
|
||||
value={details.lastUpdate}
|
||||
/>
|
||||
{details.grafanaComUrl && (
|
||||
<Box display="flex" direction="column" gap={1}>
|
||||
<TextLink href={details.grafanaComUrl} external>
|
||||
{t('dashboard-library.dashboard-card.details.view-on-grafana-com', 'View on Grafana.com')}
|
||||
</TextLink>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
card: css({
|
||||
gridTemplateAreas: `
|
||||
"Heading Heading"
|
||||
"Thumbnail Thumbnail"
|
||||
"Description Description"
|
||||
"Actions Secondary"`,
|
||||
gridTemplateRows: 'auto auto auto auto',
|
||||
gridTemplateColumns: '1fr auto',
|
||||
height: 'auto',
|
||||
width: '350px',
|
||||
background: 'transparent',
|
||||
gridGap: theme.spacing(1),
|
||||
}),
|
||||
thumbnailContainer: css({
|
||||
gridArea: 'Thumbnail',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
borderColor: theme.colors.border.strong,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
width: '100%',
|
||||
maxWidth: '350px',
|
||||
height: '180px',
|
||||
backgroundColor: theme.colors.background.canvas,
|
||||
position: 'relative',
|
||||
}),
|
||||
thumbnail: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}),
|
||||
logoContainer: css({
|
||||
gridArea: 'Thumbnail',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
width: '100%',
|
||||
height: '180px',
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
position: 'relative',
|
||||
}),
|
||||
logo: css({
|
||||
objectFit: 'fill',
|
||||
}),
|
||||
noImage: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: theme.colors.text.secondary,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}),
|
||||
descriptionWrapper: css({
|
||||
gridArea: 'Description',
|
||||
wordBreak: 'break-word',
|
||||
minHeight: `calc(${theme.typography.body.lineHeight} * 1em)`, // Preserve space even when empty
|
||||
}),
|
||||
title: css({
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 1,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}),
|
||||
description: css({
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 1,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
margin: 0,
|
||||
height: `calc(${theme.typography.body.lineHeight} * 1em)`, // Fixed height for 1 line
|
||||
}),
|
||||
actionsContainer: css({
|
||||
marginTop: 0,
|
||||
alignItems: 'stretch',
|
||||
}),
|
||||
detailsContainer: css({
|
||||
width: '340px',
|
||||
}),
|
||||
detailValue: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
color: theme.colors.text.secondary,
|
||||
}),
|
||||
badgeContainer: css({
|
||||
position: 'absolute',
|
||||
top: theme.spacing(1),
|
||||
right: theme.spacing(1),
|
||||
zIndex: 1,
|
||||
}),
|
||||
dimmedImage: css({
|
||||
opacity: 0.3,
|
||||
}),
|
||||
placeholderText: css({
|
||||
color: theme.colors.text.disabled,
|
||||
fontStyle: 'italic',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const DashboardCardSkeleton: SkeletonComponent = ({ rootProps }) => {
|
||||
const styles = useStyles2(getSkeletonStyles);
|
||||
return <Skeleton width={350} height={300} containerClassName={styles.container} {...rootProps} />;
|
||||
};
|
||||
|
||||
const getSkeletonStyles = () => ({
|
||||
container: css({
|
||||
lineHeight: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
export const DashboardCard = attachSkeleton(DashboardCardComponent, DashboardCardSkeleton);
|
||||
+117
-138
@@ -1,31 +1,40 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { getBackendSrv, getDataSourceSrv, locationService } from '@grafana/runtime';
|
||||
import { Button, useStyles2, Text, Box, Stack, Grid } from '@grafana/ui';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { getDataSourceSrv, locationService } from '@grafana/runtime';
|
||||
import { useStyles2, Stack, Grid, Pagination, EmptyState, Button } from '@grafana/ui';
|
||||
import { PluginDashboard } from 'app/types/plugins';
|
||||
import dashboardLibrary1 from 'img/dashboard-library/dashboard_library_1.jpg';
|
||||
import dashboardLibrary2 from 'img/dashboard-library/dashboard_library_2.jpg';
|
||||
import dashboardLibrary3 from 'img/dashboard-library/dashboard_library_3.jpg';
|
||||
import dashboardLibrary4 from 'img/dashboard-library/dashboard_library_4.jpg';
|
||||
import dashboardLibrary5 from 'img/dashboard-library/dashboard_library_5.jpg';
|
||||
import dashboardLibrary6 from 'img/dashboard-library/dashboard_library_6.jpg';
|
||||
|
||||
import { DASHBOARD_LIBRARY_ROUTES } from '../types';
|
||||
|
||||
import { DashboardCard } from './DashboardCard';
|
||||
import { fetchProvisionedDashboards } from './api/dashboardLibraryApi';
|
||||
import { DashboardLibraryInteractions } from './interactions';
|
||||
import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers';
|
||||
|
||||
// Constants for datasource-provided dashboards pagination
|
||||
const PAGE_SIZE = 9;
|
||||
|
||||
export const DashboardLibrarySection = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid');
|
||||
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const { value: templateDashboards } = useAsync(async (): Promise<PluginDashboard[]> => {
|
||||
// Get datasource info for empty state
|
||||
const datasourceType = useMemo(() => {
|
||||
if (!datasourceUid) {
|
||||
return '';
|
||||
}
|
||||
const ds = getDataSourceSrv().getInstanceSettings(datasourceUid);
|
||||
return ds?.type || '';
|
||||
}, [datasourceUid]);
|
||||
|
||||
const { value: templateDashboards, loading } = useAsync(async (): Promise<PluginDashboard[]> => {
|
||||
if (!datasourceUid) {
|
||||
return [];
|
||||
}
|
||||
@@ -35,38 +44,45 @@ export const DashboardLibrarySection = () => {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const dashboards = await getBackendSrv().get(`api/plugins/${ds.type}/dashboards`, undefined, undefined, {
|
||||
showErrorAlert: false,
|
||||
});
|
||||
|
||||
if (dashboards.length > 0) {
|
||||
DashboardLibraryInteractions.loaded({
|
||||
numberOfItems: dashboards.length,
|
||||
contentKinds: ['datasource_dashboard'],
|
||||
datasourceTypes: [ds.type],
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
});
|
||||
}
|
||||
return dashboards;
|
||||
} catch (error) {
|
||||
console.error('Error loading template dashboards', error);
|
||||
return [];
|
||||
}
|
||||
const dashboards = await fetchProvisionedDashboards(ds.type);
|
||||
return dashboards;
|
||||
}, [datasourceUid]);
|
||||
|
||||
const hasMoreThanThree = templateDashboards && templateDashboards.length > 3;
|
||||
const dashboardsToShow = showAll ? templateDashboards : templateDashboards?.slice(0, 3);
|
||||
// Track analytics only once on first successful load
|
||||
const hasTrackedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!loading && !hasTrackedRef.current && templateDashboards && templateDashboards.length > 0) {
|
||||
DashboardLibraryInteractions.loaded({
|
||||
numberOfItems: templateDashboards.length,
|
||||
contentKinds: ['datasource_dashboard'],
|
||||
datasourceTypes: [datasourceType],
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
eventLocation: 'suggested_dashboards_modal_provisioned_tab',
|
||||
});
|
||||
hasTrackedRef.current = true;
|
||||
}
|
||||
}, [loading, templateDashboards, datasourceType]);
|
||||
|
||||
const styles = useStyles2(getStyles, dashboardsToShow?.length);
|
||||
// Calculate pagination
|
||||
const totalDashboards = templateDashboards?.length || 0;
|
||||
const totalPages = Math.ceil(totalDashboards / PAGE_SIZE);
|
||||
const startIndex = (currentPage - 1) * PAGE_SIZE;
|
||||
const endIndex = startIndex + PAGE_SIZE;
|
||||
const dashboardsToShow = templateDashboards?.slice(startIndex, endIndex);
|
||||
|
||||
const onImportDashboardClick = async (dashboard: PluginDashboard) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// Determine what to show
|
||||
const showEmptyState = !loading && (!templateDashboards || templateDashboards.length === 0);
|
||||
|
||||
const onUseProvisionedDashboard = async (dashboard: PluginDashboard) => {
|
||||
DashboardLibraryInteractions.itemClicked({
|
||||
contentKind: 'datasource_dashboard',
|
||||
datasourceTypes: [dashboard.pluginId],
|
||||
libraryItemId: dashboard.uid,
|
||||
libraryItemTitle: dashboard.title,
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
eventLocation: 'suggested_dashboards_modal_provisioned_tab',
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
@@ -84,117 +100,80 @@ export const DashboardLibrarySection = () => {
|
||||
locationService.push(templateUrl);
|
||||
};
|
||||
|
||||
if (!templateDashboards?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={4} flex={1}>
|
||||
<Stack direction="column" alignItems="center" gap={2}>
|
||||
<Text element="h3" textAlignment="center" weight="medium">
|
||||
<Trans i18nKey="dashboard.empty.start-with-suggested-dashboards">
|
||||
Start with a pre-made dashboard from your data source
|
||||
<Stack direction="column" gap={2} justifyContent="space-between" height="100%">
|
||||
{showEmptyState ? (
|
||||
<EmptyState
|
||||
variant="call-to-action"
|
||||
message={
|
||||
datasourceType
|
||||
? t(
|
||||
'dashboard-library.provisioned-empty-title-with-datasource',
|
||||
'No {{datasourceType}} provisioned dashboards found',
|
||||
{ datasourceType }
|
||||
)
|
||||
: t('dashboard-library.provisioned-empty-title', 'No provisioned dashboards found')
|
||||
}
|
||||
button={
|
||||
<Button variant="secondary" onClick={() => window.open('https://grafana.com/grafana/plugins/', '_blank')}>
|
||||
<Trans i18nKey="dashboard-library.browse-plugins">Browse plugins</Trans>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Trans i18nKey="dashboard-library.no-provisioned-dashboards">
|
||||
Provisioned dashboards are provided by data source plugins. You can find more plugins on Grafana.com.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Box marginTop={2}>
|
||||
<Grid
|
||||
gap={4}
|
||||
columns={{
|
||||
xs: 1,
|
||||
sm: (dashboardsToShow?.length || 1) >= 2 ? 2 : 1,
|
||||
lg: (dashboardsToShow?.length || 1) >= 3 ? 3 : (dashboardsToShow?.length || 1) >= 2 ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
{dashboardsToShow?.map((dashboard, index) => (
|
||||
<TemplateDashboardBox
|
||||
key={dashboard.uid}
|
||||
index={index}
|
||||
dashboard={dashboard}
|
||||
onImportClick={onImportDashboardClick}
|
||||
/>
|
||||
)) || []}
|
||||
</Grid>
|
||||
</Box>
|
||||
{hasMoreThanThree && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAll((prev) => !prev)}
|
||||
className={styles.showMoreButton}
|
||||
>
|
||||
{showAll ? (
|
||||
<Trans i18nKey="dashboard.empty.show-less-dashboards">Show less</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="dashboard.empty.show-more-dashboards">Show more</Trans>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<Grid
|
||||
gap={4}
|
||||
columns={{
|
||||
xs: 1,
|
||||
sm: loading ? 2 : (dashboardsToShow?.length || 1) >= 2 ? 2 : 1,
|
||||
lg: loading ? 3 : (dashboardsToShow?.length || 1) >= 3 ? 3 : (dashboardsToShow?.length || 1) >= 2 ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
{loading && !templateDashboards
|
||||
? Array.from({ length: 9 }).map((_, i) => <DashboardCard.Skeleton key={`skeleton-${i}`} />)
|
||||
: dashboardsToShow?.map((dashboard, index) => {
|
||||
// Use global index for consistent image assignment across pages
|
||||
const globalIndex = startIndex + index;
|
||||
const imageUrl = getProvisionedDashboardImageUrl(globalIndex);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
key={dashboard.uid}
|
||||
title={dashboard.title}
|
||||
imageUrl={imageUrl}
|
||||
dashboard={dashboard}
|
||||
onClick={() => onUseProvisionedDashboard(dashboard)}
|
||||
buttonText={<Trans i18nKey="dashboard-library.card.use-dashboard-button">Use dashboard</Trans>}
|
||||
/>
|
||||
);
|
||||
}) || []}
|
||||
</Grid>
|
||||
)}
|
||||
{!showEmptyState && totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
numberOfPages={totalPages}
|
||||
onNavigate={(page) => setCurrentPage(page)}
|
||||
className={styles.pagination}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateDashboardBox = ({
|
||||
dashboard,
|
||||
onImportClick,
|
||||
index,
|
||||
}: {
|
||||
dashboard: PluginDashboard;
|
||||
onImportClick: (d: PluginDashboard) => void;
|
||||
index: number;
|
||||
}) => {
|
||||
const dashboardLibraryImages = [
|
||||
dashboardLibrary1,
|
||||
dashboardLibrary2,
|
||||
dashboardLibrary3,
|
||||
dashboardLibrary4,
|
||||
dashboardLibrary5,
|
||||
dashboardLibrary6,
|
||||
];
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<div className={styles.templateDashboardBox}>
|
||||
<img
|
||||
src={index <= 5 ? dashboardLibraryImages[index] : dashboardLibraryImages[index % dashboardLibraryImages.length]}
|
||||
width={285}
|
||||
height={160}
|
||||
alt={dashboard.title}
|
||||
className={styles.templateDashboardImage}
|
||||
/>
|
||||
<div className={styles.templateDashboardTitle}>
|
||||
<Text element="p" textAlignment="center">
|
||||
{dashboard.title}
|
||||
</Text>
|
||||
</div>
|
||||
<Button fill="outline" onClick={() => onImportClick(dashboard)} size="sm">
|
||||
<Trans i18nKey="dashboard.empty.use-template-button">Use this dashboard</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2, dashboardsLength?: number) {
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
templateDashboardBox: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
pagination: css({
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
padding: theme.spacing(2),
|
||||
alignItems: 'center',
|
||||
}),
|
||||
templateDashboardTitle: css({
|
||||
flex: 1,
|
||||
}),
|
||||
templateDashboardImage: css({
|
||||
borderRadius: theme.shape.radius.default,
|
||||
borderColor: theme.colors.text.primary,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
objectFit: 'cover',
|
||||
}),
|
||||
showMoreButton: css({
|
||||
marginTop: theme.spacing(2),
|
||||
zIndex: 2,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { getDataSourceSrv, locationService } from '@grafana/runtime';
|
||||
import { Button, useStyles2, Grid } from '@grafana/ui';
|
||||
import { PluginDashboard } from 'app/types/plugins';
|
||||
|
||||
import { DashboardCard } from './DashboardCard';
|
||||
import { MappingContext, SuggestedDashboardsModal } from './SuggestedDashboardsModal';
|
||||
import { fetchCommunityDashboards, fetchProvisionedDashboards } from './api/dashboardLibraryApi';
|
||||
import { DashboardLibraryInteractions } from './interactions';
|
||||
import { GnetDashboard } from './types';
|
||||
import {
|
||||
getThumbnailUrl,
|
||||
getLogoUrl,
|
||||
buildDashboardDetails,
|
||||
onUseCommunityDashboard,
|
||||
} from './utils/communityDashboardHelpers';
|
||||
import { getProvisionedDashboardImageUrl } from './utils/provisionedDashboardHelpers';
|
||||
|
||||
interface Props {
|
||||
datasourceUid?: string;
|
||||
}
|
||||
|
||||
type MixedDashboard =
|
||||
| { type: 'provisioned'; dashboard: PluginDashboard; index: number }
|
||||
| { type: 'community'; dashboard: GnetDashboard };
|
||||
|
||||
type SuggestedDashboardsResult = {
|
||||
dashboards: MixedDashboard[];
|
||||
hasMoreDashboards: boolean;
|
||||
};
|
||||
|
||||
// Constants for suggested dashboards API params
|
||||
const SUGGESTED_COMMUNITY_PAGE_SIZE = 2;
|
||||
const DEFAULT_SORT_ORDER = 'downloads';
|
||||
const DEFAULT_SORT_DIRECTION = 'desc';
|
||||
const INCLUDE_SCREENSHOTS = true;
|
||||
const INCLUDE_LOGO = true;
|
||||
|
||||
export const SuggestedDashboards = ({ datasourceUid }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const showLibraryModal = searchParams.get('dashboardLibraryModal') === 'open';
|
||||
|
||||
// Validate and get default tab from URL params
|
||||
const tabParam = searchParams.get('dashboardLibraryTab');
|
||||
const defaultTab: 'datasource' | 'community' = tabParam === 'community' ? 'community' : 'datasource';
|
||||
|
||||
const [mappingContext, setMappingContext] = useState<MappingContext | null>(null);
|
||||
|
||||
// Get datasource type for dynamic title
|
||||
const datasourceType = useMemo(() => {
|
||||
if (!datasourceUid) {
|
||||
return '';
|
||||
}
|
||||
const ds = getDataSourceSrv().getInstanceSettings(datasourceUid);
|
||||
return ds?.type || '';
|
||||
}, [datasourceUid]);
|
||||
|
||||
const { value: result, loading } = useAsync(async (): Promise<SuggestedDashboardsResult> => {
|
||||
if (!datasourceUid) {
|
||||
return { dashboards: [], hasMoreDashboards: false };
|
||||
}
|
||||
|
||||
const ds = getDataSourceSrv().getInstanceSettings(datasourceUid);
|
||||
if (!ds) {
|
||||
return { dashboards: [], hasMoreDashboards: false };
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch both provisioned and community dashboards in parallel
|
||||
const [provisioned, communityResponse] = await Promise.all([
|
||||
// Fetch provisioned dashboards
|
||||
fetchProvisionedDashboards(ds.type),
|
||||
|
||||
// Fetch community dashboards
|
||||
fetchCommunityDashboards({
|
||||
orderBy: DEFAULT_SORT_ORDER,
|
||||
direction: DEFAULT_SORT_DIRECTION,
|
||||
page: 1,
|
||||
pageSize: SUGGESTED_COMMUNITY_PAGE_SIZE,
|
||||
includeScreenshots: INCLUDE_SCREENSHOTS,
|
||||
dataSourceSlugIn: ds.type,
|
||||
includeLogo: INCLUDE_LOGO,
|
||||
}),
|
||||
]);
|
||||
|
||||
const community = communityResponse.dashboards;
|
||||
|
||||
// Mix: 1 provisioned + 2 community
|
||||
const mixed: MixedDashboard[] = [];
|
||||
|
||||
// Take 1 provisioned if available
|
||||
if (provisioned.length > 0) {
|
||||
mixed.push({ type: 'provisioned', dashboard: provisioned[0], index: 0 });
|
||||
}
|
||||
|
||||
// Take up to 2 community dashboards
|
||||
const communityCount = Math.min(2, community.length);
|
||||
for (let i = 0; i < communityCount; i++) {
|
||||
mixed.push({ type: 'community', dashboard: community[i] });
|
||||
}
|
||||
|
||||
// Fill remaining slots if we have less than 3
|
||||
while (mixed.length < 3) {
|
||||
const provisionedUsed = mixed.filter((m) => m.type === 'provisioned').length;
|
||||
const communityUsed = mixed.filter((m) => m.type === 'community').length;
|
||||
|
||||
if (provisionedUsed < provisioned.length) {
|
||||
mixed.push({ type: 'provisioned', dashboard: provisioned[provisionedUsed], index: provisionedUsed });
|
||||
} else if (communityUsed < community.length) {
|
||||
mixed.push({ type: 'community', dashboard: community[communityUsed] });
|
||||
} else {
|
||||
break; // Not enough dashboards
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if there are more dashboards available beyond what we're showing
|
||||
// Show "View all" if: more than 1 provisioned exists OR we got the full page size of community dashboards
|
||||
const hasMoreDashboards = provisioned.length > 1 || community.length >= SUGGESTED_COMMUNITY_PAGE_SIZE;
|
||||
|
||||
return { dashboards: mixed, hasMoreDashboards };
|
||||
} catch (error) {
|
||||
console.error('Error loading suggested dashboards', error);
|
||||
return { dashboards: [], hasMoreDashboards: false };
|
||||
}
|
||||
}, [datasourceUid]);
|
||||
|
||||
// Determine which tab should be default based on available data
|
||||
const computedDefaultTab = useMemo((): 'datasource' | 'community' => {
|
||||
if (!result || loading) {
|
||||
return 'datasource'; // Default while loading
|
||||
}
|
||||
|
||||
const hasProvisioned = result.dashboards.some((d) => d.type === 'provisioned');
|
||||
|
||||
// Prefer datasource tab if it has data, otherwise community
|
||||
return hasProvisioned ? 'datasource' : 'community';
|
||||
}, [result, loading]);
|
||||
|
||||
// Track analytics only once on first successful load
|
||||
const hasTrackedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!loading && !hasTrackedRef.current && result && result.dashboards.length > 0) {
|
||||
const contentKinds: Array<'datasource_dashboard' | 'community_dashboard'> = [
|
||||
...new Set(
|
||||
result.dashboards.map((m) => (m.type === 'provisioned' ? 'datasource_dashboard' : 'community_dashboard'))
|
||||
),
|
||||
];
|
||||
DashboardLibraryInteractions.loaded({
|
||||
numberOfItems: result.dashboards.length,
|
||||
contentKinds,
|
||||
datasourceTypes: [datasourceType],
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
eventLocation: 'empty_dashboard',
|
||||
});
|
||||
hasTrackedRef.current = true;
|
||||
}
|
||||
}, [loading, result, datasourceType]);
|
||||
|
||||
const onModalDismiss = () => {
|
||||
// Remove modal-related query params while keeping datasourceUid
|
||||
setSearchParams((params) => {
|
||||
params.delete('dashboardLibraryModal');
|
||||
params.delete('dashboardLibraryTab');
|
||||
return params;
|
||||
});
|
||||
setMappingContext(null);
|
||||
};
|
||||
|
||||
const onOpenModal = (tab: 'datasource' | 'community') => {
|
||||
setSearchParams((params) => {
|
||||
const newParams = new URLSearchParams(params);
|
||||
newParams.set('dashboardLibraryModal', 'open');
|
||||
newParams.set('dashboardLibraryTab', tab);
|
||||
return newParams;
|
||||
});
|
||||
};
|
||||
|
||||
const onShowMapping = (context: MappingContext) => {
|
||||
setMappingContext(context);
|
||||
onOpenModal(computedDefaultTab);
|
||||
};
|
||||
|
||||
const onUseProvisionedDashboard = (dashboard: PluginDashboard) => {
|
||||
if (!datasourceUid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ds = getDataSourceSrv().getInstanceSettings(datasourceUid);
|
||||
if (!ds) {
|
||||
return;
|
||||
}
|
||||
|
||||
DashboardLibraryInteractions.itemClicked({
|
||||
contentKind: 'datasource_dashboard',
|
||||
datasourceTypes: [ds.type],
|
||||
libraryItemId: dashboard.uid,
|
||||
libraryItemTitle: dashboard.title,
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
eventLocation: 'empty_dashboard',
|
||||
});
|
||||
|
||||
// Navigate to template route (existing flow)
|
||||
const params = new URLSearchParams({
|
||||
datasource: datasourceUid,
|
||||
title: dashboard.title || 'Template',
|
||||
pluginId: dashboard.pluginId,
|
||||
path: dashboard.path,
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
libraryItemId: dashboard.uid,
|
||||
creationOrigin: 'dashboard_library_datasource_dashboard',
|
||||
});
|
||||
|
||||
locationService.push(`/dashboard/template?${params.toString()}`);
|
||||
};
|
||||
|
||||
const onPreviewCommunityDashboard = (dashboard: GnetDashboard) => {
|
||||
if (!datasourceUid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ds = getDataSourceSrv().getInstanceSettings(datasourceUid);
|
||||
if (!ds) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUseCommunityDashboard({
|
||||
dashboard,
|
||||
datasourceUid,
|
||||
datasourceType: ds.type,
|
||||
eventLocation: 'empty_dashboard',
|
||||
onShowMapping: onShowMapping,
|
||||
});
|
||||
};
|
||||
|
||||
// Don't render if no dashboards or still loading
|
||||
if (!loading && (!result || result.dashboards.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container} data-testid="suggested-dashboards">
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerText}>
|
||||
<h1 className={styles.title}>
|
||||
{datasourceType
|
||||
? t(
|
||||
'dashboard-library.suggested-dashboards-title-with-datasource',
|
||||
'Build a dashboard using suggested options for your {{datasourceType}} data source',
|
||||
{ datasourceType }
|
||||
)
|
||||
: t(
|
||||
'dashboard-library.suggested-dashboards-title',
|
||||
'Build a dashboard using suggested options for your selected data source'
|
||||
)}
|
||||
</h1>
|
||||
<p className={styles.subtitle}>
|
||||
<Trans i18nKey="dashboard-library.suggested-dashboards-subtitle">
|
||||
Browse and select from data-source provided or community dashboards
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
{result?.hasMoreDashboards && (
|
||||
<Button variant="secondary" fill="outline" onClick={() => onOpenModal(computedDefaultTab)} size="sm">
|
||||
<Trans i18nKey="dashboard-library.view-all">View all</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Grid
|
||||
gap={4}
|
||||
columns={{
|
||||
xs: 1,
|
||||
sm: 2,
|
||||
lg: 3,
|
||||
}}
|
||||
>
|
||||
{loading
|
||||
? Array.from({ length: 3 }).map((_, i) => <DashboardCard.Skeleton key={`skeleton-${i}`} />)
|
||||
: result?.dashboards.map((item, idx) => {
|
||||
if (item.type === 'provisioned') {
|
||||
return (
|
||||
<DashboardCard
|
||||
key={`provisioned-${item.dashboard.uid}-${idx}`}
|
||||
title={item.dashboard.title}
|
||||
imageUrl={getProvisionedDashboardImageUrl(item.index)}
|
||||
dashboard={item.dashboard}
|
||||
onClick={() => onUseProvisionedDashboard(item.dashboard)}
|
||||
showDatasourceProvidedBadge={true}
|
||||
dimThumbnail={true}
|
||||
buttonText={<Trans i18nKey="dashboard-library.card.use-dashboard-button">Use dashboard</Trans>}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const thumbnailUrl = getThumbnailUrl(item.dashboard);
|
||||
const imageUrl = thumbnailUrl || getLogoUrl(item.dashboard);
|
||||
const isLogo = !thumbnailUrl;
|
||||
const details = buildDashboardDetails(item.dashboard);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
key={`community-${item.dashboard.id}-${idx}`}
|
||||
title={item.dashboard.name}
|
||||
imageUrl={imageUrl}
|
||||
dashboard={item.dashboard}
|
||||
onClick={() => onPreviewCommunityDashboard(item.dashboard)}
|
||||
isLogo={isLogo}
|
||||
details={details}
|
||||
buttonText={<Trans i18nKey="dashboard-library.card.use-dashboard-button">Use dashboard</Trans>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}) || []}
|
||||
</Grid>
|
||||
</div>
|
||||
<SuggestedDashboardsModal
|
||||
isOpen={showLibraryModal}
|
||||
onDismiss={onModalDismiss}
|
||||
initialMappingContext={mappingContext}
|
||||
defaultTab={defaultTab}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
container: css({
|
||||
borderRadius: theme.shape.radius.default,
|
||||
borderColor: theme.colors.border.strong,
|
||||
borderStyle: 'dashed',
|
||||
borderWidth: 1,
|
||||
padding: theme.spacing(4),
|
||||
}),
|
||||
header: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: theme.spacing(1),
|
||||
gap: theme.spacing(2),
|
||||
paddingRight: theme.spacing(2),
|
||||
paddingLeft: theme.spacing(2),
|
||||
}),
|
||||
headerText: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(0.5),
|
||||
flex: 1,
|
||||
}),
|
||||
title: css({
|
||||
margin: 0,
|
||||
fontSize: theme.typography.h2.fontSize,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
lineHeight: theme.typography.h2.lineHeight,
|
||||
}),
|
||||
subtitle: css({
|
||||
margin: 0,
|
||||
fontSize: theme.typography.body.fontSize,
|
||||
color: theme.colors.text.secondary,
|
||||
lineHeight: theme.typography.body.lineHeight,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { Modal, TabsBar, Tab, TabContent, useStyles2, Text } from '@grafana/ui';
|
||||
import { DashboardInput, DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
|
||||
import { DashboardJson } from 'app/features/manage-dashboards/types';
|
||||
|
||||
import { CommunityDashboardMappingForm } from './CommunityDashboardMappingForm';
|
||||
import { CommunityDashboardSection } from './CommunityDashboardSection';
|
||||
import { DashboardLibrarySection } from './DashboardLibrarySection';
|
||||
import { InputMapping } from './utils/autoMapDatasources';
|
||||
|
||||
interface SuggestedDashboardsModalProps {
|
||||
isOpen: boolean;
|
||||
onDismiss: () => void;
|
||||
initialMappingContext?: MappingContext | null;
|
||||
defaultTab?: 'datasource' | 'community';
|
||||
}
|
||||
|
||||
type ModalView = 'datasource' | 'community' | 'mapping';
|
||||
|
||||
export interface MappingContext {
|
||||
dashboardName: string;
|
||||
dashboardJson: DashboardJson;
|
||||
unmappedInputs: DataSourceInput[];
|
||||
constantInputs: DashboardInput[];
|
||||
existingMappings: InputMapping[];
|
||||
onInterpolateAndNavigate: (mappings: InputMapping[]) => void;
|
||||
}
|
||||
|
||||
export const SuggestedDashboardsModal = ({
|
||||
isOpen,
|
||||
onDismiss,
|
||||
initialMappingContext,
|
||||
defaultTab = 'datasource',
|
||||
}: SuggestedDashboardsModalProps) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid');
|
||||
|
||||
const [activeView, setActiveView] = useState<ModalView>(initialMappingContext ? 'mapping' : defaultTab);
|
||||
const [mappingContext, setMappingContext] = useState<MappingContext | null>(initialMappingContext || null);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// Get datasource info for modal title and search
|
||||
const datasourceInfo = useMemo(() => {
|
||||
if (!datasourceUid) {
|
||||
return { type: '' };
|
||||
}
|
||||
const ds = getDataSourceSrv().getInstanceSettings(datasourceUid);
|
||||
return {
|
||||
type: ds?.type || '',
|
||||
};
|
||||
}, [datasourceUid]);
|
||||
|
||||
// Update state when initialMappingContext changes or modal opens/closes
|
||||
useEffect(() => {
|
||||
if (initialMappingContext) {
|
||||
setMappingContext(initialMappingContext);
|
||||
setActiveView('mapping');
|
||||
} else if (isOpen) {
|
||||
// When modal opens, set to defaultTab
|
||||
setActiveView(defaultTab);
|
||||
} else {
|
||||
// Reset when modal closes
|
||||
setMappingContext(null);
|
||||
}
|
||||
}, [initialMappingContext, isOpen, defaultTab]);
|
||||
|
||||
const onTabChange = (tab: 'datasource' | 'community') => {
|
||||
setActiveView(tab);
|
||||
// Update URL to reflect current tab
|
||||
setSearchParams((params) => {
|
||||
const newParams = new URLSearchParams(params);
|
||||
newParams.set('dashboardLibraryTab', tab);
|
||||
return newParams;
|
||||
});
|
||||
};
|
||||
|
||||
const handleShowMapping = (context: MappingContext) => {
|
||||
setMappingContext(context);
|
||||
setActiveView('mapping');
|
||||
};
|
||||
|
||||
const handleBackToDashboards = () => {
|
||||
setMappingContext(null);
|
||||
setActiveView('community');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
activeView === 'mapping' && mappingContext
|
||||
? t('dashboard-library.modal.title-mapping-with-name', 'Configure datasources for {{dashboardName}}', {
|
||||
dashboardName: mappingContext.dashboardName,
|
||||
})
|
||||
: datasourceInfo.type
|
||||
? t(
|
||||
'dashboard-library.modal.title-with-datasource',
|
||||
'Suggested dashboards for your {{datasourceType}} datasource',
|
||||
{ datasourceType: datasourceInfo.type }
|
||||
)
|
||||
: t('dashboard-library.modal.title', 'Suggested dashboards')
|
||||
}
|
||||
isOpen={isOpen}
|
||||
onDismiss={onDismiss}
|
||||
className={styles.modal}
|
||||
contentClassName={styles.modalContent}
|
||||
>
|
||||
{activeView !== 'mapping' && (
|
||||
<div className={styles.stickyHeader}>
|
||||
<Text element="p">
|
||||
<Trans i18nKey="dashboard-library.modal.description">
|
||||
Browse and select from data-source provided or community dashboards
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<TabsBar>
|
||||
<Tab
|
||||
label={t('dashboard-library.modal.tab-datasource', 'Data-source provided')}
|
||||
icon="apps"
|
||||
active={activeView === 'datasource'}
|
||||
onChangeTab={() => onTabChange('datasource')}
|
||||
/>
|
||||
<Tab
|
||||
label={t('dashboard-library.modal.tab-community', 'Community')}
|
||||
icon="users-alt"
|
||||
active={activeView === 'community'}
|
||||
onChangeTab={() => onTabChange('community')}
|
||||
/>
|
||||
</TabsBar>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TabContent className={styles.tabContent}>
|
||||
{activeView === 'datasource' && <DashboardLibrarySection />}
|
||||
{activeView === 'community' && (
|
||||
<CommunityDashboardSection onShowMapping={handleShowMapping} datasourceType={datasourceInfo.type} />
|
||||
)}
|
||||
{activeView === 'mapping' && mappingContext && (
|
||||
<CommunityDashboardMappingForm
|
||||
unmappedInputs={mappingContext.unmappedInputs}
|
||||
constantInputs={mappingContext.constantInputs}
|
||||
existingMappings={mappingContext.existingMappings}
|
||||
onBack={handleBackToDashboards}
|
||||
onPreview={(allMappings) => {
|
||||
mappingContext.onInterpolateAndNavigate(allMappings);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TabContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
modal: css({
|
||||
width: '90%',
|
||||
maxWidth: '1200px',
|
||||
height: '80vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}),
|
||||
modalContent: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
marginBottom: 0,
|
||||
height: '100%',
|
||||
}),
|
||||
stickyHeader: css({
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
paddingTop: theme.spacing(3),
|
||||
paddingLeft: theme.spacing(3),
|
||||
paddingRight: theme.spacing(3),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
}),
|
||||
tabContent: css({
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
paddingTop: theme.spacing(3),
|
||||
paddingLeft: theme.spacing(3),
|
||||
paddingRight: theme.spacing(3),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { DashboardJson } from 'app/features/manage-dashboards/types';
|
||||
import { PluginDashboard } from 'app/types/plugins';
|
||||
|
||||
import { GnetDashboardsResponse } from '../types';
|
||||
|
||||
/**
|
||||
* Parameters for fetching community dashboards from Grafana.com
|
||||
*/
|
||||
export interface FetchCommunityDashboardsParams {
|
||||
orderBy: string;
|
||||
direction: 'asc' | 'desc';
|
||||
page: number;
|
||||
pageSize: number;
|
||||
includeLogo: boolean;
|
||||
includeScreenshots: boolean;
|
||||
dataSourceSlugIn?: string;
|
||||
filter?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from the Gnet API when fetching a single dashboard
|
||||
*/
|
||||
export interface GnetDashboardResponse {
|
||||
json: DashboardJson;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch community dashboards from Grafana.com
|
||||
*/
|
||||
export async function fetchCommunityDashboards(
|
||||
params: FetchCommunityDashboardsParams
|
||||
): Promise<GnetDashboardsResponse> {
|
||||
const searchParams = new URLSearchParams({
|
||||
orderBy: params.orderBy,
|
||||
direction: params.direction,
|
||||
page: params.page.toString(),
|
||||
pageSize: params.pageSize.toString(),
|
||||
includeLogo: params.includeLogo ? '1' : '0',
|
||||
includeScreenshots: params.includeScreenshots ? 'true' : 'false',
|
||||
});
|
||||
|
||||
if (params.dataSourceSlugIn) {
|
||||
searchParams.append('dataSourceSlugIn', params.dataSourceSlugIn);
|
||||
}
|
||||
if (params.filter) {
|
||||
searchParams.append('filter', params.filter);
|
||||
}
|
||||
|
||||
const result = await getBackendSrv().get(`/api/gnet/dashboards?${searchParams}`, undefined, undefined, {
|
||||
showErrorAlert: false,
|
||||
});
|
||||
|
||||
// Grafana.com API returns format: { page: number, pages: number, items: GnetDashboard[] }
|
||||
// We normalize it to use "dashboards" instead of "items" for consistency
|
||||
if (result && Array.isArray(result.items)) {
|
||||
return {
|
||||
page: result.page || params.page,
|
||||
pages: result.pages || 1,
|
||||
dashboards: result.items,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unexpected response format
|
||||
console.warn('Unexpected API response format from Grafana.com:', result);
|
||||
return {
|
||||
page: params.page,
|
||||
pages: 1,
|
||||
dashboards: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single community dashboard's full JSON from Grafana.com
|
||||
*/
|
||||
export async function fetchCommunityDashboard(gnetId: number): Promise<GnetDashboardResponse> {
|
||||
return getBackendSrv().get(`/api/gnet/dashboards/${gnetId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch provisioned dashboards for a datasource type
|
||||
*/
|
||||
export async function fetchProvisionedDashboards(datasourceType: string): Promise<PluginDashboard[]> {
|
||||
try {
|
||||
const dashboards = await getBackendSrv().get(`api/plugins/${datasourceType}/dashboards`, undefined, undefined, {
|
||||
showErrorAlert: false,
|
||||
});
|
||||
return Array.isArray(dashboards) ? dashboards : [];
|
||||
} catch (error) {
|
||||
console.error('Error loading provisioned dashboards', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,26 @@ import { reportInteraction } from '@grafana/runtime';
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
type ContentKind = 'datasource_dashboard';
|
||||
// in future this could be "template_dashboard" if/when items become templates or "community_dashboard"
|
||||
// | 'template_dashboard' | 'community_dashboard';
|
||||
type ContentKind = 'datasource_dashboard' | 'community_dashboard';
|
||||
// in future this could also include "template_dashboard" if/when items become templates
|
||||
// | 'template_dashboard';
|
||||
|
||||
type SourceEntryPoint = 'datasource_page';
|
||||
// possible future flows onboarding, create-dashboard, empty states
|
||||
// | 'create_dashboard' | 'empty_state';
|
||||
|
||||
type EventLocation =
|
||||
| 'empty_dashboard'
|
||||
| 'suggested_dashboards_modal_provisioned_tab'
|
||||
| 'suggested_dashboards_modal_community_tab';
|
||||
|
||||
export const DashboardLibraryInteractions = {
|
||||
loaded: (properties: {
|
||||
numberOfItems: number;
|
||||
contentKinds: ContentKind[];
|
||||
datasourceTypes: string[];
|
||||
sourceEntryPoint: SourceEntryPoint;
|
||||
eventLocation: EventLocation;
|
||||
}) => {
|
||||
reportDashboardLibraryInteraction('loaded', properties);
|
||||
},
|
||||
@@ -25,6 +31,7 @@ export const DashboardLibraryInteractions = {
|
||||
libraryItemId: string;
|
||||
libraryItemTitle: string;
|
||||
sourceEntryPoint: SourceEntryPoint;
|
||||
eventLocation: EventLocation;
|
||||
}) => {
|
||||
reportDashboardLibraryInteraction('item_clicked', properties);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { DashboardJson } from 'app/features/manage-dashboards/types';
|
||||
|
||||
export interface Link {
|
||||
rel: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface Screenshot {
|
||||
links: Link[];
|
||||
}
|
||||
|
||||
export interface LogoImage {
|
||||
content: string;
|
||||
filename: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface Logo {
|
||||
small?: LogoImage;
|
||||
large?: LogoImage;
|
||||
}
|
||||
|
||||
export interface GnetDashboard {
|
||||
id: number;
|
||||
uid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
downloads: number;
|
||||
datasource: string;
|
||||
screenshots?: Screenshot[];
|
||||
logos?: Logo;
|
||||
json?: DashboardJson; // Full dashboard JSON from detail API
|
||||
createdAt?: string; // ISO date string if available
|
||||
updatedAt?: string; // ISO date string if available
|
||||
publishedAt?: string; // ISO date string if available
|
||||
// Author/organization information
|
||||
orgId?: number;
|
||||
orgName?: string;
|
||||
orgSlug?: string;
|
||||
userId?: number;
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export interface GnetDashboardsResponse {
|
||||
page: number;
|
||||
pages: number;
|
||||
dashboards: GnetDashboard[];
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { Input } from 'app/features/dashboard/components/DashExportModal/DashboardExporter';
|
||||
import { DashboardInput, DataSourceInput, InputType } from 'app/features/manage-dashboards/state/reducers';
|
||||
|
||||
export interface InputMapping {
|
||||
name: string;
|
||||
type: 'datasource' | 'constant';
|
||||
pluginId?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an Input is a DataSourceInput.
|
||||
* DataSourceInput requires both type='datasource' and a pluginId property.
|
||||
*/
|
||||
export function isDataSourceInput(input: Input): input is Input & DataSourceInput {
|
||||
return input.type === 'datasource' && 'pluginId' in input;
|
||||
}
|
||||
|
||||
export interface AutoMapResult {
|
||||
allMapped: boolean;
|
||||
mappings: InputMapping[];
|
||||
unmappedInputs: DataSourceInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to automatically map datasource inputs to available datasources.
|
||||
* Uses two ways of mapping:
|
||||
* 1. Prefer the current datasource if it matches the required type
|
||||
* 2. Auto-select if only one compatible datasource exists
|
||||
*
|
||||
* @param inputs - Array of datasource inputs from dashboard __inputs
|
||||
* @param currentDatasourceUid - UID of the datasource selected in "build dashboard" flow
|
||||
* @returns Result containing mappings, unmapped inputs, and whether all inputs were mapped
|
||||
*/
|
||||
export function tryAutoMapDatasources(inputs: DataSourceInput[], currentDatasourceUid: string): AutoMapResult {
|
||||
const mappings: InputMapping[] = [];
|
||||
const unmappedInputs: DataSourceInput[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
// Get all datasources compatible with this input's plugin type
|
||||
const compatibleDs = getDataSourceSrv()
|
||||
.getList({ type: input.pluginId })
|
||||
.filter((ds) => ds.uid);
|
||||
|
||||
let selectedDs: string | undefined;
|
||||
|
||||
// Option 1: Use current datasource if compatible
|
||||
if (compatibleDs.some((ds) => ds.uid === currentDatasourceUid)) {
|
||||
selectedDs = currentDatasourceUid;
|
||||
}
|
||||
// Option 2: Auto-select if only one option exists AND it's not the current datasource's type
|
||||
// (example: only auto-select if we are confident it's the right choice)
|
||||
else if (compatibleDs.length === 1) {
|
||||
const currentDs = getDataSourceSrv().getInstanceSettings(currentDatasourceUid);
|
||||
|
||||
// Only auto-select if:
|
||||
// - The single option matches the input's plugin type exactly
|
||||
// - OR we're coming from a datasource of the same type (e.g., Prometheus -> Prometheus)
|
||||
if (currentDs && currentDs.type === input.pluginId) {
|
||||
selectedDs = compatibleDs[0].uid;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedDs) {
|
||||
mappings.push({
|
||||
name: input.name,
|
||||
type: 'datasource',
|
||||
pluginId: input.pluginId,
|
||||
value: selectedDs,
|
||||
});
|
||||
} else {
|
||||
unmappedInputs.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allMapped: unmappedInputs.length === 0,
|
||||
mappings,
|
||||
unmappedInputs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses constant inputs from dashboard __inputs array.
|
||||
* Constants need to be shown to the user for filling that information (the same as the import flow).
|
||||
*
|
||||
* @param allInputs - All inputs from dashboard.__inputs
|
||||
* @returns Array of constant inputs with their default values
|
||||
*/
|
||||
export function parseConstantInputs(allInputs: Input[]): DashboardInput[] {
|
||||
if (!allInputs || !Array.isArray(allInputs)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return allInputs
|
||||
.filter((input) => input.type === 'constant')
|
||||
.map((input) => ({
|
||||
name: input.name,
|
||||
label: input.label || input.name,
|
||||
description: input.description,
|
||||
info: input.description || 'Specify a string constant',
|
||||
value: input.value || '',
|
||||
type: InputType.Constant,
|
||||
pluginId: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts constant inputs to InputMapping format for the interpolate API.
|
||||
* Uses user-provided values or defaults from the dashboard.
|
||||
*
|
||||
* @param constantInputs - Array of constant inputs
|
||||
* @param userValues - User-entered values (key: input name, value: user input)
|
||||
* @returns Array of InputMapping for constants
|
||||
*/
|
||||
export function mapConstantInputs(
|
||||
constantInputs: DashboardInput[],
|
||||
userValues: Record<string, string>
|
||||
): InputMapping[] {
|
||||
return constantInputs.map((input) => ({
|
||||
name: input.name,
|
||||
type: 'constant',
|
||||
value: userValues[input.name] !== undefined ? userValues[input.name] : input.value,
|
||||
}));
|
||||
}
|
||||
|
||||
interface UserSelectedDatasourceMappings {
|
||||
name: string;
|
||||
pluginId: string;
|
||||
datasource: { uid: string } | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps user-selected datasources to InputMapping format.
|
||||
* Used in the mapping form to convert user selections into the format required for dashboard interpolation.
|
||||
*
|
||||
* @param unmappedInputs - The datasource inputs that need mapping
|
||||
* @param userSelectedDsMappings - Record of user selections keyed by input name
|
||||
* @returns Array of InputMapping objects for user-selected datasources
|
||||
*/
|
||||
export function mapUserSelectedDatasources(
|
||||
unmappedInputs: DataSourceInput[],
|
||||
userSelectedDsMappings: Record<string, UserSelectedDatasourceMappings>
|
||||
): InputMapping[] {
|
||||
return unmappedInputs.map((input) => ({
|
||||
name: input.name,
|
||||
type: 'datasource',
|
||||
pluginId: input.pluginId,
|
||||
value: userSelectedDsMappings[input.name]?.datasource?.uid || '',
|
||||
}));
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { DataSourceInput } from 'app/features/manage-dashboards/state/reducers';
|
||||
|
||||
import { DASHBOARD_LIBRARY_ROUTES } from '../../types';
|
||||
import { MappingContext } from '../SuggestedDashboardsModal';
|
||||
import { fetchCommunityDashboard } from '../api/dashboardLibraryApi';
|
||||
import { DashboardLibraryInteractions } from '../interactions';
|
||||
import { GnetDashboard, Link } from '../types';
|
||||
|
||||
import { InputMapping, tryAutoMapDatasources, parseConstantInputs, isDataSourceInput } from './autoMapDatasources';
|
||||
|
||||
/**
|
||||
* Extract thumbnail URL from dashboard screenshots
|
||||
*/
|
||||
export function getThumbnailUrl(dashboard: GnetDashboard): string {
|
||||
const thumbnail = dashboard.screenshots?.[0]?.links.find((l: Link) => l.rel === 'image')?.href ?? '';
|
||||
return thumbnail ? `/api/gnet${thumbnail}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract logo URL from dashboard logos
|
||||
*/
|
||||
export function getLogoUrl(dashboard: GnetDashboard): string {
|
||||
const logo = dashboard.logos?.large || dashboard.logos?.small;
|
||||
if (logo?.content && logo?.type) {
|
||||
return `data:${logo.type};base64,${logo.content}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date string for display
|
||||
*/
|
||||
export function formatDate(dateString?: string): string {
|
||||
if (!dateString) {
|
||||
return 'N/A';
|
||||
}
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create URL-friendly slug from dashboard name
|
||||
*/
|
||||
export function createSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Grafana.com URL for a dashboard
|
||||
*/
|
||||
export function buildGrafanaComUrl(dashboard: GnetDashboard): string {
|
||||
return `https://grafana.com/grafana/dashboards/${dashboard.id}-${createSlug(dashboard.name)}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dashboard details object for display in card
|
||||
*/
|
||||
export interface DashboardDetails {
|
||||
id: string;
|
||||
datasource: string;
|
||||
dependencies: string[];
|
||||
publishedBy: string;
|
||||
lastUpdate: string;
|
||||
grafanaComUrl: string;
|
||||
}
|
||||
|
||||
export function buildDashboardDetails(dashboard: GnetDashboard): DashboardDetails {
|
||||
return {
|
||||
id: String(dashboard.id),
|
||||
datasource: dashboard.datasource || 'N/A',
|
||||
dependencies: dashboard.datasource ? [dashboard.datasource] : [],
|
||||
publishedBy: dashboard.orgName || dashboard.userName || 'Grafana Community',
|
||||
lastUpdate: formatDate(dashboard.updatedAt || dashboard.publishedAt),
|
||||
grafanaComUrl: buildGrafanaComUrl(dashboard),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to dashboard template route with mappings
|
||||
*/
|
||||
export function navigateToTemplate(
|
||||
dashboardTitle: string,
|
||||
gnetId: number,
|
||||
datasourceUid: string,
|
||||
mappings: InputMapping[]
|
||||
): void {
|
||||
const searchParams = new URLSearchParams({
|
||||
datasource: datasourceUid,
|
||||
title: dashboardTitle,
|
||||
gnetId: String(gnetId),
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
creationOrigin: 'dashboard_library_community_dashboard',
|
||||
mappings: JSON.stringify(mappings),
|
||||
});
|
||||
|
||||
locationService.push({
|
||||
pathname: DASHBOARD_LIBRARY_ROUTES.Template,
|
||||
search: searchParams.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
interface UseCommunityDashboardParams {
|
||||
dashboard: GnetDashboard;
|
||||
datasourceUid: string;
|
||||
datasourceType: string;
|
||||
eventLocation: 'empty_dashboard' | 'suggested_dashboards_modal_community_tab';
|
||||
onShowMapping?: (context: MappingContext) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the flow when a user selects a community dashboard:
|
||||
* 1. Tracks analytics
|
||||
* 2. Fetches full dashboard JSON with __inputs
|
||||
* 3. Attempts auto-mapping of datasources
|
||||
* 4. Either navigates directly or shows mapping form
|
||||
*/
|
||||
export async function onUseCommunityDashboard({
|
||||
dashboard,
|
||||
datasourceUid,
|
||||
datasourceType,
|
||||
eventLocation,
|
||||
onShowMapping,
|
||||
}: UseCommunityDashboardParams): Promise<void> {
|
||||
// Track analytics
|
||||
DashboardLibraryInteractions.itemClicked({
|
||||
contentKind: 'community_dashboard',
|
||||
datasourceTypes: [datasourceType],
|
||||
libraryItemId: String(dashboard.id),
|
||||
libraryItemTitle: dashboard.name,
|
||||
sourceEntryPoint: 'datasource_page',
|
||||
eventLocation,
|
||||
});
|
||||
|
||||
try {
|
||||
// Fetch full dashboard from Gcom, this is the JSON with __inputs
|
||||
const fullDashboard = await fetchCommunityDashboard(dashboard.id);
|
||||
const dashboardJson = fullDashboard.json;
|
||||
|
||||
// Parse datasource requirements from __inputs
|
||||
const dsInputs: DataSourceInput[] = dashboardJson.__inputs?.filter(isDataSourceInput) || [];
|
||||
|
||||
// Parse constant inputs - these always need user review
|
||||
const constantInputs = parseConstantInputs(dashboardJson.__inputs || []);
|
||||
|
||||
// Try auto-mapping datasources
|
||||
const mappingResult = tryAutoMapDatasources(dsInputs, datasourceUid);
|
||||
|
||||
// Decide whether to show mapping form or navigate directly
|
||||
// Show mapping form if: (a) there are unmapped datasources OR (b) there are constants
|
||||
const needsMapping = mappingResult.unmappedInputs.length > 0 || constantInputs.length > 0;
|
||||
|
||||
if (!needsMapping) {
|
||||
// No mapping needed - all datasources auto-mapped, no constants
|
||||
navigateToTemplate(dashboard.name, dashboard.id, datasourceUid, mappingResult.mappings);
|
||||
} else {
|
||||
// Show mapping form for unmapped datasources and/or constants
|
||||
if (onShowMapping) {
|
||||
onShowMapping({
|
||||
dashboardName: dashboard.name,
|
||||
dashboardJson,
|
||||
unmappedInputs: mappingResult.unmappedInputs,
|
||||
constantInputs,
|
||||
existingMappings: mappingResult.mappings,
|
||||
onInterpolateAndNavigate: (mappings) =>
|
||||
navigateToTemplate(dashboard.name, dashboard.id, datasourceUid, mappings),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading community dashboard:', err);
|
||||
// TODO: Show error notification
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import dashboardLibrary1 from 'img/dashboard-library/dashboard_library_1.jpg';
|
||||
import dashboardLibrary2 from 'img/dashboard-library/dashboard_library_2.jpg';
|
||||
import dashboardLibrary3 from 'img/dashboard-library/dashboard_library_3.jpg';
|
||||
import dashboardLibrary4 from 'img/dashboard-library/dashboard_library_4.jpg';
|
||||
import dashboardLibrary5 from 'img/dashboard-library/dashboard_library_5.jpg';
|
||||
import dashboardLibrary6 from 'img/dashboard-library/dashboard_library_6.jpg';
|
||||
|
||||
/**
|
||||
* Collection of placeholder images for provisioned/plugin dashboards
|
||||
*/
|
||||
export const DASHBOARD_PLACEHOLDER_IMAGES = [
|
||||
dashboardLibrary1,
|
||||
dashboardLibrary2,
|
||||
dashboardLibrary3,
|
||||
dashboardLibrary4,
|
||||
dashboardLibrary5,
|
||||
dashboardLibrary6,
|
||||
];
|
||||
|
||||
/**
|
||||
* Get a placeholder image URL for a provisioned dashboard by index.
|
||||
* Cycles through available images if index exceeds collection size.
|
||||
*/
|
||||
export function getProvisionedDashboardImageUrl(index: number): string {
|
||||
return DASHBOARD_PLACEHOLDER_IMAGES[index % DASHBOARD_PLACEHOLDER_IMAGES.length];
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
() => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/NewDashboardWithDS')
|
||||
),
|
||||
},
|
||||
{
|
||||
(config.featureToggles.suggestedDashboards || config.featureToggles.dashboardLibrary) && {
|
||||
path: DASHBOARD_LIBRARY_ROUTES.Template,
|
||||
roles: () => contextSrv.evaluatePermission([AccessControlAction.DashboardsCreate]),
|
||||
pageClass: 'page-dashboard',
|
||||
|
||||
@@ -5661,6 +5661,61 @@
|
||||
"validation-required": "Need a dashboard JSON model"
|
||||
}
|
||||
},
|
||||
"dashboard-library": {
|
||||
"browse-grafana-com": "Browse Grafana.com",
|
||||
"browse-plugins": "Browse plugins",
|
||||
"card": {
|
||||
"datasource-provided-badge": "Data source provided",
|
||||
"details-tooltip": "Details",
|
||||
"no-preview": "No preview available",
|
||||
"use-dashboard-button": "Use dashboard",
|
||||
"use-template-button": "Use template"
|
||||
},
|
||||
"community-empty-title": "No community dashboards found",
|
||||
"community-empty-title-with-datasource": "No {{datasourceType}} community dashboards found",
|
||||
"community-error": "Failed to load community dashboards. Please try again.",
|
||||
"community-error-title": "Error loading community dashboards",
|
||||
"community-mapping-form": {
|
||||
"auto-mapped_one": "{{count}} datasources were automatically configured:",
|
||||
"auto-mapped_other": "{{count}} datasources were automatically configured:",
|
||||
"back": "Back to dashboards",
|
||||
"constants-title": "Dashboard Variables",
|
||||
"datasources-title": "Datasource Configuration",
|
||||
"description": "This dashboard requires datasource configuration. Select datasources for each input below.",
|
||||
"preview": "Preview dashboard"
|
||||
},
|
||||
"community-mapping-select-datasource": "Select a datasource",
|
||||
"community-search-placeholder": "Search community dashboards...",
|
||||
"community-search-placeholder-with-datasource": "Search {{datasourceType}} community dashboards...",
|
||||
"dashboard-card": {
|
||||
"details": {
|
||||
"datasource": "Datasource",
|
||||
"dependencies": "Dependencies",
|
||||
"id": "ID",
|
||||
"last-update": "Last Update",
|
||||
"published-by": "Published By",
|
||||
"view-on-grafana-com": "View on Grafana.com"
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"description": "Browse and select from data-source provided or community dashboards",
|
||||
"tab-community": "Community",
|
||||
"tab-datasource": "Data-source provided",
|
||||
"title": "Suggested dashboards",
|
||||
"title-mapping-with-name": "Configure datasources for {{dashboardName}}",
|
||||
"title-with-datasource": "Suggested dashboards for your {{datasourceType}} datasource"
|
||||
},
|
||||
"no-community-dashboards-datasource": "Try a different search term or browse dashboards for different datasource types on Grafana.com.",
|
||||
"no-community-dashboards-search": "Try a different search term or browse more dashboards on Grafana.com.",
|
||||
"no-provisioned-dashboards": "Provisioned dashboards are provided by data source plugins. You can find more plugins on Grafana.com.",
|
||||
"provisioned-empty-title": "No provisioned dashboards found",
|
||||
"provisioned-empty-title-with-datasource": "No {{datasourceType}} provisioned dashboards found",
|
||||
"retry": "Retry",
|
||||
"suggested-dashboards-subtitle": "Browse and select from data-source provided or community dashboards",
|
||||
"suggested-dashboards-title": "Build a dashboard using suggested options for your selected data source",
|
||||
"suggested-dashboards-title-with-datasource": "Build a dashboard using suggested options for your {{datasourceType}} data source",
|
||||
"view-all": "View all"
|
||||
},
|
||||
"dashboard-links": {
|
||||
"empty-state": {
|
||||
"button-title": "Add dashboard link",
|
||||
|
||||
Generated
+1
-1
@@ -17838,7 +17838,7 @@
|
||||
"$ref": "#/components/responses/internalServerError"
|
||||
}
|
||||
},
|
||||
"summary": "Interpolate dashboard. This is an experimental endpoint under dashboardLibrary FF and is subject to change.",
|
||||
"summary": "Interpolate dashboard. This is an experimental endpoint under dashboardLibrary or suggestedDashboards feature flags and is subject to change.",
|
||||
"tags": [
|
||||
"dashboards"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user