diff --git a/pkg/services/dashboardimport/api/api.go b/pkg/services/dashboardimport/api/api.go index 27697602383..b0dabcaf881 100644 --- a/pkg/services/dashboardimport/api/api.go +++ b/pkg/services/dashboardimport/api/api.go @@ -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) diff --git a/pkg/services/dashboardimport/api/api_test.go b/pkg/services/dashboardimport/api/api_test.go index 17e0e5d1685..461e370352e 100644 --- a/pkg/services/dashboardimport/api/api_test.go +++ b/pkg/services/dashboardimport/api/api_test.go @@ -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 { diff --git a/pkg/services/dashboardimport/service/service.go b/pkg/services/dashboardimport/service/service.go index 8e78bdcac3d..ca006d0b98b 100644 --- a/pkg/services/dashboardimport/service/service.go +++ b/pkg/services/dashboardimport/service/service.go @@ -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) diff --git a/pkg/services/dashboardimport/service/service_test.go b/pkg/services/dashboardimport/service/service_test.go index 16ee483fc1b..76c02f424bc 100644 --- a/pkg/services/dashboardimport/service/service_test.go +++ b/pkg/services/dashboardimport/service/service_test.go @@ -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 diff --git a/public/api-merged.json b/public/api-merged.json index 87a8bcb9d35..a168fed65c0 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -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": { diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index e17f7e36cff..595a51d80e9 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -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 { + 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 { // 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 { + // 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); diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx index 61cc488109c..58d69d5ac92 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx @@ -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: () =>
Dashboard Library Section
, +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( ); + // 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'); diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx index 8e473124434..bef4318e339 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx @@ -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 ( - -
- - - - - - Start your new dashboard by adding a visualization - - - - - - Select a data source and then query and visualize your data with charts, stats and tables or create - lists, markdowns and other widgets. + <> + +
+ + + + + + Start your new dashboard by adding a visualization - - - - - {config.featureToggles.dashboardLibrary && dashboardLibraryDatasourceUid && } - - - - - Import panel - - + - - Add visualizations that are shared with other dashboards. + + Select a data source and then query and visualize your data with charts, stats and tables or + create lists, markdowns and other widgets. - - - - Import a dashboard - - - - - Import dashboards from files or{' '} - - grafana.com - - . - + + {/* Suggested Dashboards Section */} + {config.featureToggles.suggestedDashboards && + config.featureToggles.dashboardLibrary && + dashboardLibraryDatasourceUid && } + + {/* Basic Provisioned Dashboards Section that don't include community dashboards */} + {config.featureToggles.dashboardLibrary && + !config.featureToggles.suggestedDashboards && + dashboardLibraryDatasourceUid && ( + + )} + + + + + + Import panel - - - - + + + + Add visualizations that are shared with other dashboards. + + + + + + + + + + Import a dashboard + + + + + Import dashboards from files or{' '} + + grafana.com + + . + + + + + + + - -
-
+
+
+ ); }; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx new file mode 100644 index 00000000000..de2145c2750 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/BasicProvisionedDashboardsEmptyPage.tsx @@ -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 => { + 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 ( + + + + + Start with a pre-made dashboard from your data source + + + + = 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 ( + + ); + }) || []} + + + {hasMoreThanThree && ( + + )} + + + ); +}; + +const TemplateDashboardBox = ({ + dashboard, + onImportClick, + index, + imageUrl, +}: { + dashboard: PluginDashboard; + onImportClick: (d: PluginDashboard) => void; + index: number; + imageUrl: string; +}) => { + const styles = useStyles2(getStyles); + return ( +
+ {dashboard.title} +
+ + {dashboard.title} + +
+ +
+ ); +}; + +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), + }), + }; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.tsx new file mode 100644 index 00000000000..27b0a352ec1 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.tsx @@ -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>( + () => { + // Initialize with existing unmapped inputs + return unmappedInputs.reduce>((acc, input) => { + const unmappedInput = { + name: input.name, + pluginId: input.pluginId, + datasource: undefined, + }; + acc[input.name] = unmappedInput; + return acc; + }, {}); + } + ); + + const [constantValues, setConstantValues] = useState>(() => { + // Initialize with default values from constantInputs + return constantInputs.reduce>((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 ( + + + + + This dashboard requires datasource configuration. Select datasources for each input below. + + + + {existingMappings.length > 0 && ( + + + + + {{ count: existingMappings.length }} datasources were automatically configured: + + + + {existingMappings + .map((mapping) => { + const ds = getDataSourceSrv().getInstanceSettings(mapping.value); + return `${mapping.pluginId} → ${ds?.name || mapping.value}`; + }) + .join(' | ')} + + + + )} + + {unmappedInputs.length > 0 && ( + + + + Datasource Configuration + + + {unmappedInputs.map((input) => { + const selectedDatasource = userSelectedDsMappings[input.name]?.datasource; + + return ( + + 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} + /> + + ); + })} + + )} + + {constantInputs.length > 0 && ( + + + Dashboard Variables + + {constantInputs.map((input) => ( + + onConstantChange(input.name, e.currentTarget.value)} + placeholder={input.value} + /> + + ))} + + )} + + + + + + + + + + ); +}; diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx new file mode 100644 index 00000000000..791add79d32 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardSection.tsx @@ -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 ( + + + +
+ {loading ? ( + + {Array.from({ length: COMMUNITY_PAGE_SIZE }).map((_, i) => ( + + ))} + + ) : showError ? ( + + + + Failed to load community dashboards. Please try again. + + + + + ) : showEmptyState ? ( + window.open('https://grafana.com/grafana/dashboards/', '_blank')} + > + Browse Grafana.com + + } + > + {searchQuery && !datasourceType ? ( + + Try a different search term or browse more dashboards on Grafana.com. + + ) : ( + + Try a different search term or browse dashboards for different datasource types on Grafana.com. + + )} + + ) : ( + = 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 ( + onPreviewCommunityDashboard(dashboard)} + isLogo={isLogo} + details={details} + buttonText={Use dashboard} + /> + ); + })} + + )} +
+ {totalPages > 1 && ( +
+ +
+ )} +
+ ); +}; + +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), + }), + }; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx new file mode 100644 index 00000000000..8d038e0e5c3 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx @@ -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 ( + + {title} +
+ {imageUrl ? ( + {title} { + console.error('Failed to load image for:', title, 'URL:', imageUrl); + e.currentTarget.style.display = 'none'; + }} + /> + ) : ( +
+ No preview available +
+ )} + {showDatasourceProvidedBadge && ( +
+ +
+ )} +
+
+ {dashboard.description && ( + {dashboard.description} + )} +
+ + + {details && ( + } placement="right"> + + + )} + +
+ ); +} + +function DetailsTooltipContent({ details }: { details: Details }) { + const Section = ({ label, value }: { label: string; value: string }) => { + return ( + + {label} + + {value} + + + ); + }; + + return ( + + +
+
+
+
+
+ {details.grafanaComUrl && ( + + + {t('dashboard-library.dashboard-card.details.view-on-grafana-com', 'View on Grafana.com')} + + + )} + + + ); +} + +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 ; +}; + +const getSkeletonStyles = () => ({ + container: css({ + lineHeight: 1, + }), +}); + +export const DashboardCard = attachSkeleton(DashboardCardComponent, DashboardCardSkeleton); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx index 266e671de03..6c31bb7bc83 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx @@ -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 => { + // 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 => { 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 ( - - - - - Start with a pre-made dashboard from your data source + + {showEmptyState ? ( + window.open('https://grafana.com/grafana/plugins/', '_blank')}> + Browse plugins + + } + > + + Provisioned dashboards are provided by data source plugins. You can find more plugins on Grafana.com. - - - = 2 ? 2 : 1, - lg: (dashboardsToShow?.length || 1) >= 3 ? 3 : (dashboardsToShow?.length || 1) >= 2 ? 2 : 1, - }} - > - {dashboardsToShow?.map((dashboard, index) => ( - - )) || []} - - - {hasMoreThanThree && ( - - )} - - + + ) : ( + = 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) => ) + : dashboardsToShow?.map((dashboard, index) => { + // Use global index for consistent image assignment across pages + const globalIndex = startIndex + index; + const imageUrl = getProvisionedDashboardImageUrl(globalIndex); + + return ( + onUseProvisionedDashboard(dashboard)} + buttonText={Use dashboard} + /> + ); + }) || []} + + )} + {!showEmptyState && totalPages > 1 && ( + setCurrentPage(page)} + className={styles.pagination} + /> + )} + ); }; -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 ( -
- {dashboard.title} -
- - {dashboard.title} - -
- -
- ); -}; - -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, }), }; } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx new file mode 100644 index 00000000000..ecd5e258cf7 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboards.tsx @@ -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(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 => { + 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 ( + <> +
+
+
+

+ {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' + )} +

+

+ + Browse and select from data-source provided or community dashboards + +

+
+ {result?.hasMoreDashboards && ( + + )} +
+ + + {loading + ? Array.from({ length: 3 }).map((_, i) => ) + : result?.dashboards.map((item, idx) => { + if (item.type === 'provisioned') { + return ( + onUseProvisionedDashboard(item.dashboard)} + showDatasourceProvidedBadge={true} + dimThumbnail={true} + buttonText={Use dashboard} + /> + ); + } else { + const thumbnailUrl = getThumbnailUrl(item.dashboard); + const imageUrl = thumbnailUrl || getLogoUrl(item.dashboard); + const isLogo = !thumbnailUrl; + const details = buildDashboardDetails(item.dashboard); + + return ( + onPreviewCommunityDashboard(item.dashboard)} + isLogo={isLogo} + details={details} + buttonText={Use dashboard} + /> + ); + } + }) || []} + +
+ + + ); +}; + +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, + }), + }; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.tsx new file mode 100644 index 00000000000..e6b78d35e5f --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/SuggestedDashboardsModal.tsx @@ -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(initialMappingContext ? 'mapping' : defaultTab); + const [mappingContext, setMappingContext] = useState(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 ( + + {activeView !== 'mapping' && ( +
+ + + Browse and select from data-source provided or community dashboards + + + + + onTabChange('datasource')} + /> + onTabChange('community')} + /> + +
+ )} + + + {activeView === 'datasource' && } + {activeView === 'community' && ( + + )} + {activeView === 'mapping' && mappingContext && ( + { + mappingContext.onInterpolateAndNavigate(allMappings); + }} + /> + )} + +
+ ); +}; + +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), + }), + }; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts new file mode 100644 index 00000000000..d856fd996f0 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts @@ -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 { + 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 { + return getBackendSrv().get(`/api/gnet/dashboards/${gnetId}`); +} + +/** + * Fetch provisioned dashboards for a datasource type + */ +export async function fetchProvisionedDashboards(datasourceType: string): Promise { + 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 []; + } +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts index 1222007dd84..6b1a07c9e43 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts @@ -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); }, diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts new file mode 100644 index 00000000000..38267a4870f --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/types.ts @@ -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[]; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.ts new file mode 100644 index 00000000000..856c99daefc --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.ts @@ -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 +): 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 +): InputMapping[] { + return unmappedInputs.map((input) => ({ + name: input.name, + type: 'datasource', + pluginId: input.pluginId, + value: userSelectedDsMappings[input.name]?.datasource?.uid || '', + })); +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts new file mode 100644 index 00000000000..83b7dd47d78 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.ts @@ -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 { + // 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 + } +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/provisionedDashboardHelpers.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/provisionedDashboardHelpers.ts new file mode 100644 index 00000000000..30a7c5c0059 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/provisionedDashboardHelpers.ts @@ -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]; +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index ae6452a1d99..3a688ea53dc 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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', diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 6c9cb73d689..63c36ebc16d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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", diff --git a/public/openapi3.json b/public/openapi3.json index f28b394ffb1..1a3473953d5 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -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" ]