diff --git a/pkg/services/dashboardimport/api/api.go b/pkg/services/dashboardimport/api/api.go index 8134b190de96..a566047c2b59 100644 --- a/pkg/services/dashboardimport/api/api.go +++ b/pkg/services/dashboardimport/api/api.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboardimport/utils" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/web" @@ -23,15 +24,17 @@ type ImportDashboardAPI struct { quotaService QuotaService pluginStore pluginstore.Store ac accesscontrol.AccessControl + features featuremgmt.FeatureToggles } func New(dashboardImportService dashboardimport.Service, quotaService QuotaService, - pluginStore pluginstore.Store, ac accesscontrol.AccessControl) *ImportDashboardAPI { + pluginStore pluginstore.Store, ac accesscontrol.AccessControl, features featuremgmt.FeatureToggles) *ImportDashboardAPI { return &ImportDashboardAPI{ dashboardImportService: dashboardImportService, quotaService: quotaService, pluginStore: pluginStore, ac: ac, + features: features, } } @@ -43,9 +46,48 @@ func (api *ImportDashboardAPI) RegisterAPIEndpoints(routeRegister routing.RouteR authorize(accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate)), routing.Wrap(api.ImportDashboard), ) + if api.features.IsEnabledGlobally(featuremgmt.FlagDashboardLibrary) { + route.Post( + "/interpolate", + authorize(accesscontrol.EvalPermission(dashboards.ActionDashboardsCreate)), + routing.Wrap(api.InterpolateDashboard), + ) + } }, middleware.ReqSignedIn) } +// swagger:route POST /dashboards/interpolate dashboards interpolateDashboard +// +// Interpolate dashboard. This is an experimental endpoint under dashboardLibrary FF and is subject to change. +// +// Responses: +// 200: interpolateDashboardResponse +// 400: badRequestError +// 401: unauthorisedError +// 422: unprocessableEntityError +// 500: internalServerError +func (api *ImportDashboardAPI) InterpolateDashboard(c *contextmodel.ReqContext) response.Response { + req := dashboardimport.ImportDashboardRequest{} + if err := web.Bind(c.Req, &req); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + + if req.PluginId == "" { + return response.Error(http.StatusUnprocessableEntity, "pluginId must be set", nil) + } + + resp, err := api.dashboardImportService.InterpolateDashboard(c.Req.Context(), &req) + if err != nil { + return response.Error(http.StatusInternalServerError, "failed to interpolate dashboard", err) + } + + resp.Del("__elements") + resp.Del("__inputs") + resp.Del("__requires") + + return response.JSON(http.StatusOK, resp) +} + // swagger:route POST /dashboards/import dashboards importDashboard // // Import dashboard. @@ -110,3 +152,9 @@ type ImportDashboardResponse struct { // in: body Body dashboardimport.ImportDashboardResponse `json:"body"` } + +// swagger:response interpolateDashboardResponse +type InterpolateDashboardResponse struct { + // in: body + Body interface{} `json:"body"` +} diff --git a/pkg/services/dashboardimport/api/api_test.go b/pkg/services/dashboardimport/api/api_test.go index 84a5636575ea..17e0e5d1685a 100644 --- a/pkg/services/dashboardimport/api/api_test.go +++ b/pkg/services/dashboardimport/api/api_test.go @@ -15,6 +15,7 @@ import ( contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/web/webtest" @@ -30,7 +31,7 @@ func TestImportDashboardAPI(t *testing.T) { }, } - importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}) + importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, featuremgmt.WithFeatures()) routeRegister := routing.NewRouteRegister() importDashboardAPI.RegisterAPIEndpoints(routeRegister) s := webtest.NewServer(t, routeRegister) @@ -107,7 +108,7 @@ func TestImportDashboardAPI(t *testing.T) { }, } - importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}) + importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, featuremgmt.WithFeatures()) routeRegister := routing.NewRouteRegister() importDashboardAPI.RegisterAPIEndpoints(routeRegister) s := webtest.NewServer(t, routeRegister) @@ -135,7 +136,7 @@ func TestImportDashboardAPI(t *testing.T) { t.Run("Quota reached", func(t *testing.T) { service := &serviceMock{} - importDashboardAPI := New(service, quotaServiceFunc(quotaReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}) + importDashboardAPI := New(service, quotaServiceFunc(quotaReached), nil, actest.FakeAccessControl{ExpectedEvaluate: true}, featuremgmt.WithFeatures()) routeRegister := routing.NewRouteRegister() importDashboardAPI.RegisterAPIEndpoints(routeRegister) @@ -159,8 +160,74 @@ func TestImportDashboardAPI(t *testing.T) { }) } +func TestInterpolateDashboardFeatureFlag(t *testing.T) { + t.Run("Feature flag disabled - interpolate endpoint should return 404", func(t *testing.T) { + service := &serviceMock{} + // Create features with dashboardLibrary disabled + features := featuremgmt.WithFeatures() + 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)) + 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.StatusNotFound, resp.StatusCode) + }) + + t.Run("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 dashboardLibrary enabled + features := featuremgmt.WithFeatures(featuremgmt.FlagDashboardLibrary) + 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) + }) +} + type serviceMock struct { - importDashboardFunc func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) + importDashboardFunc func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) + interpolateDashboardFunc func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) } func (s *serviceMock) ImportDashboard(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) { @@ -171,6 +238,14 @@ func (s *serviceMock) ImportDashboard(ctx context.Context, req *dashboardimport. return nil, nil } +func (s *serviceMock) InterpolateDashboard(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) { + if s.interpolateDashboardFunc != nil { + return s.interpolateDashboardFunc(ctx, req) + } + + return simplejson.New(), nil +} + func quotaReached(c *contextmodel.ReqContext, target quota.TargetSrv) (bool, error) { return true, nil } diff --git a/pkg/services/dashboardimport/dashboardimport.go b/pkg/services/dashboardimport/dashboardimport.go index d16e0230bf5b..d21d449cc0bb 100644 --- a/pkg/services/dashboardimport/dashboardimport.go +++ b/pkg/services/dashboardimport/dashboardimport.go @@ -52,4 +52,5 @@ type ImportDashboardResponse struct { // Service service interface for importing dashboards. type Service interface { ImportDashboard(ctx context.Context, req *ImportDashboardRequest) (*ImportDashboardResponse, error) + InterpolateDashboard(ctx context.Context, req *ImportDashboardRequest) (*simplejson.Json, error) } diff --git a/pkg/services/dashboardimport/service/service.go b/pkg/services/dashboardimport/service/service.go index fac501f9361d..8e78bdcac3df 100644 --- a/pkg/services/dashboardimport/service/service.go +++ b/pkg/services/dashboardimport/service/service.go @@ -34,7 +34,7 @@ func ProvideService(routeRegister routing.RouteRegister, features: features, } - dashboardImportAPI := api.New(s, quotaService, pluginStore, ac) + dashboardImportAPI := api.New(s, quotaService, pluginStore, ac, features) dashboardImportAPI.RegisterAPIEndpoints(routeRegister) return s @@ -48,7 +48,7 @@ type ImportDashboardService struct { features featuremgmt.FeatureToggles } -func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) { +func (s *ImportDashboardService) InterpolateDashboard(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*simplejson.Json, error) { var draftDashboard *dashboards.Dashboard if req.PluginId != "" { loadReq := &plugindashboards.LoadPluginDashboardRequest{ @@ -70,6 +70,15 @@ func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashb return nil, err } + return generatedDash, nil +} + +func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) { + generatedDash, err := s.InterpolateDashboard(ctx, req) + if err != nil { + return nil, err + } + // Maintain backwards compatibility by transforming array of library elements to map libraryElements := generatedDash.Get("__elements") libElementsArr, err := libraryElements.Array() diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 06581c7e6015..1ed039f3af04 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -966,7 +966,7 @@ var ( Description: "Enable suggested dashboards when creating new dashboards", Stage: FeatureStageExperimental, Owner: grafanaSharingSquad, - FrontendOnly: true, + FrontendOnly: false, }, { Name: "logsExploreTableDefaultVisualization", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index b54bf6df7533..e3dd5c8263af 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -125,7 +125,7 @@ cloudWatchNewLabelParsing,GA,@grafana/aws-datasources,false,false,false disableNumericMetricsSortingInExpressions,experimental,@grafana/oss-big-tent,false,true,false grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,false queryLibrary,preview,@grafana/sharing-squad,false,false,false -dashboardLibrary,experimental,@grafana/sharing-squad,false,false,true +dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index e6dda337118a..ef4b6aac2f8d 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1082,14 +1082,16 @@ { "metadata": { "name": "dashboardLibrary", - "resourceVersion": "1758902532246", - "creationTimestamp": "2025-09-26T16:02:12Z" + "resourceVersion": "1760051989635", + "creationTimestamp": "2025-09-26T16:02:12Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-10-09 23:19:49.635811 +0000 UTC" + } }, "spec": { "description": "Enable suggested dashboards when creating new dashboards", "stage": "experimental", - "codeowner": "@grafana/sharing-squad", - "frontend": true + "codeowner": "@grafana/sharing-squad" } }, { diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index 0f25b53a11a4..46c47a02e829 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -10068,6 +10068,10 @@ "$ref": "#/definitions/publicError" } }, + "interpolateDashboardResponse": { + "description": "", + "schema": {} + }, "jwksResponse": { "description": "", "schema": { diff --git a/public/api-merged.json b/public/api-merged.json index ef550058eec0..83211911e0de 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -3671,6 +3671,32 @@ } } }, + "/dashboards/interpolate": { + "post": { + "tags": [ + "dashboards" + ], + "summary": "Interpolate dashboard. This is an experimental endpoint under dashboardLibrary FF and is subject to change.", + "operationId": "interpolateDashboard", + "responses": { + "200": { + "$ref": "#/responses/interpolateDashboardResponse" + }, + "400": { + "$ref": "#/responses/badRequestError" + }, + "401": { + "$ref": "#/responses/unauthorisedError" + }, + "422": { + "$ref": "#/responses/unprocessableEntityError" + }, + "500": { + "$ref": "#/responses/internalServerError" + } + } + } + }, "/dashboards/public-dashboards": { "get": { "description": "Get list of public dashboards", @@ -25139,6 +25165,10 @@ "$ref": "#/definitions/publicError" } }, + "interpolateDashboardResponse": { + "description": "(empty)", + "schema": {} + }, "jwksResponse": { "description": "(empty)", "schema": { diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 00c02b5d727d..54dd386c619d 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -1,6 +1,6 @@ import { locationUtil, UrlQueryMap } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; +import { config, getBackendSrv, getDataSourceSrv, isFetchError, locationService } from '@grafana/runtime'; 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'; @@ -415,6 +415,13 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag if (rsp?.dashboard) { const scene = transformSaveModelToScene(rsp); + // Special handling for Template route - set up edit mode and dirty state + if (config.featureToggles.dashboardLibrary && options.route === DashboardRoutes.Template) { + scene.setInitialSaveModel(rsp.dashboard, rsp.meta); + scene.onEnterEditMode(); + scene.setState({ isDirty: true }); + } + // Cache scene only if not coming from Explore, we don't want to cache temporary dashboard if (options.uid) { this.setSceneCache(options.uid, scene); @@ -441,6 +448,57 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag throw new Error('Snapshot not found'); } + private async loadTemplateDashboard(): Promise { + // Extract template parameters from URL + const searchParams = new URLSearchParams(window.location.search); + const datasource = searchParams.get('datasource'); + const pluginId = searchParams.get('pluginId'); + const path = searchParams.get('path'); + + if (!datasource || !pluginId || !path) { + throw new Error('Missing required parameters for template dashboard'); + } + + const ds = getDataSourceSrv().getInstanceSettings(datasource); + if (!ds) { + throw new Error(`Datasource "${datasource}" not found. Please check your datasource configuration.`); + } + + const data = { + pluginId, + path, + overwrite: true, + inputs: [ + { + name: '*', + type: 'datasource', + pluginId, + value: datasource, + }, + ], + }; + + const interpolatedDashboard = await getBackendSrv().post('/api/dashboards/interpolate', data); + + return { + dashboard: { + ...interpolatedDashboard, + uid: '', + version: 0, + id: null, + }, + meta: { + canSave: true, + canEdit: true, + canStar: false, + canShare: false, + canDelete: false, + isNew: true, + folderUid: '', + }, + }; + } + public async fetchDashboard({ type, slug, @@ -474,6 +532,9 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag case DashboardRoutes.New: rsp = await buildNewDashboardSaveModel(urlFolderUid); break; + case DashboardRoutes.Template: + rsp = await this.loadTemplateDashboard(); + break; case DashboardRoutes.Provisioning: return this.loadProvisioningDashboard(slug || '', uid); case DashboardRoutes.Public: { diff --git a/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx b/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx index 2b7624b779a2..d86f8309b31b 100644 --- a/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx +++ b/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx @@ -1,12 +1,47 @@ import { SceneQueryRunner, SceneTimeRange, VizPanel, behaviors } from '@grafana/scenes'; +import { Dashboard } from '@grafana/schema'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { ContextSrv, setContextSrv } from 'app/core/services/context_srv'; +import { ObjectMeta } from 'app/features/apiserver/types'; import { DashboardControls } from '../scene/DashboardControls'; import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; -import { ignoreChanges } from './DashboardPrompt'; +import { ignoreChanges, isEmptyDashboard } from './DashboardPrompt'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + ...jest.requireActual('@grafana/runtime').config, + defaultDatasource: 'gdev-testdata', + datasources: { + 'gdev-testdata': { + id: 1, + uid: 'gdev-testdata', + type: 'grafana-testdata-datasource', + name: 'gdev-testdata', + meta: { + id: 'grafana-testdata-datasource', + type: 'datasource', + name: 'TestData', + }, + }, + '-- Grafana --': { + id: -1, + uid: 'grafana', + type: 'datasource', + name: '-- Grafana --', + meta: { + id: 'grafana', + type: 'datasource', + name: '-- Grafana --', + }, + }, + }, + }, +})); function getTestContext() { const contextSrv = { isSignedIn: true, isEditor: true } as ContextSrv; @@ -130,30 +165,288 @@ describe('DashboardPrompt', () => { }); }); }); + + describe('isEmptyDashboard', () => { + describe('Dashboard V1 tests', () => { + describe('empty dashboard cases', () => { + it('should return true for completely empty dashboard', () => { + const emptyDashboard: Dashboard = { + id: null, + uid: '', + title: '', + tags: [], + panels: [], + schemaVersion: 16, + version: 0, + links: [], + time: { from: 'now-6h', to: 'now' }, + timepicker: {}, + templating: { list: [] }, + annotations: { list: [] }, + }; + + expect(isEmptyDashboard(emptyDashboard)).toBe(true); + }); + + it('should return true for dashboard with no panels, links, templates, or uid', () => { + const scene = buildTestScene( + { + uid: '', + body: DefaultGridLayoutManager.fromVizPanels([]), + }, + 'v1' + ); + const dashboard = scene.getSaveModel() as Dashboard; + dashboard.links = []; + dashboard.templating = { list: [] }; + dashboard.uid = ''; + + expect(isEmptyDashboard(dashboard)).toBe(true); + }); + }); + + describe('non-empty dashboard cases', () => { + it('should return false for dashboard with panels', () => { + const scene = buildTestScene(); + const dashboard = scene.getSaveModel(); + + expect(isEmptyDashboard(dashboard)).toBe(false); + }); + + it('should return false for dashboard with links', () => { + const scene = buildTestScene( + { + uid: '', + body: DefaultGridLayoutManager.fromVizPanels([]), + links: [ + { + title: 'Test Link', + url: 'https://example.com', + type: 'link', + icon: 'external link', + tooltip: '', + asDropdown: false, + tags: [], + includeVars: false, + keepTime: false, + targetBlank: false, + }, + ], + }, + 'v1' + ); + const dashboard = scene.getSaveModel() as Dashboard; + dashboard.templating = { list: [] }; + + expect(isEmptyDashboard(dashboard)).toBe(false); + }); + + it('should return false for dashboard with template variables', () => { + const scene = buildTestScene( + { + uid: '', + body: DefaultGridLayoutManager.fromVizPanels([]), + }, + 'v1' + ); + const dashboard = scene.getSaveModel() as Dashboard; + dashboard.links = []; + dashboard.templating = { + list: [ + { + name: 'testVar', + type: 'query', + query: 'test query', + current: { value: 'test', text: 'test' }, + options: [], + }, + ], + }; + + expect(isEmptyDashboard(dashboard)).toBe(false); + }); + + it('should return false for dashboard with uid', () => { + const scene = buildTestScene( + { + uid: 'test-uid-123', + body: DefaultGridLayoutManager.fromVizPanels([]), + }, + 'v1' + ); + const dashboard = scene.getSaveModel() as Dashboard; + dashboard.links = []; + dashboard.templating = { list: [] }; + + expect(isEmptyDashboard(dashboard)).toBe(false); + }); + }); + }); + + describe('Dashboard V2 tests', () => { + describe('empty dashboard cases', () => { + it('should return true for completely empty dashboard v2', () => { + const emptyDashboardV2: DashboardV2Spec = { + title: '', + tags: [], + elements: {}, + layout: { + kind: 'GridLayout', + spec: { + items: [], + }, + }, + links: [], + variables: [], + annotations: [], + timeSettings: { + from: 'now-6h', + to: 'now', + timezone: 'browser', + weekStart: 'monday', + fiscalYearStartMonth: 0, + autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'], + autoRefresh: '5s', + hideTimepicker: false, + }, + cursorSync: 'Off', + liveNow: false, + preload: false, + }; + const emptyMetadata: ObjectMeta = { + name: '', + resourceVersion: '1', + creationTimestamp: '2023-01-01T00:00:00Z', + }; + + expect(isEmptyDashboard(emptyDashboardV2, emptyMetadata)).toBe(true); + }); + + it('should return true for dashboard v2 with no elements, links, variables, or name', () => { + const scene = buildTestScene( + { + body: DefaultGridLayoutManager.fromVizPanels([]), + }, + 'v2' + ); + const dashboard = scene.getSaveModel(); + const metadata: ObjectMeta = { + name: '', + resourceVersion: '1', + creationTimestamp: '2023-01-01T00:00:00Z', + }; + + expect(isEmptyDashboard(dashboard, metadata)).toBe(true); + }); + }); + + describe('non-empty dashboard cases', () => { + it('should return false for dashboard v2 with elements', () => { + const scene = buildTestScene({}, 'v2'); + const dashboard = scene.getSaveModel(); + + expect(isEmptyDashboard(dashboard)).toBe(false); + }); + + it('should return false for dashboard v2 with links', () => { + const scene = buildTestScene( + { + body: DefaultGridLayoutManager.fromVizPanels([]), + links: [ + { + title: 'Test Link V2', + url: 'https://example.com', + type: 'link', + icon: 'external link', + tooltip: '', + asDropdown: false, + tags: [], + includeVars: false, + keepTime: false, + targetBlank: false, + }, + ], + }, + 'v2' + ); + const dashboard = scene.getSaveModel(); + const metadata: ObjectMeta = { + name: '', + resourceVersion: '1', + creationTimestamp: '2023-01-01T00:00:00Z', + }; + + expect(isEmptyDashboard(dashboard, metadata)).toBe(false); + }); + + it('should return false for dashboard v2 with variables', () => { + const scene = buildTestScene( + { + body: DefaultGridLayoutManager.fromVizPanels([]), + }, + 'v2' + ); + const dashboard = scene.getSaveModel() as DashboardV2Spec; + dashboard.variables = [ + { kind: 'ConstantVariable', spec: { name: 'testVar' } } as DashboardV2Spec['variables'][number], + ]; + const metadata: ObjectMeta = { + name: '', + resourceVersion: '1', + creationTimestamp: '2023-01-01T00:00:00Z', + }; + + expect(isEmptyDashboard(dashboard, metadata)).toBe(false); + }); + + it('should return false for dashboard v2 with name in metadata', () => { + const scene = buildTestScene( + { + body: DefaultGridLayoutManager.fromVizPanels([]), + }, + 'v2' + ); + const dashboard = scene.getSaveModel(); + const metadata: ObjectMeta = { + name: 'test-dashboard-with-name', + resourceVersion: '1', + creationTimestamp: '2023-01-01T00:00:00Z', + }; + + expect(isEmptyDashboard(dashboard, metadata)).toBe(false); + }); + }); + }); + }); }); -function buildTestScene(overrides?: Partial) { - const scene = new DashboardScene({ - title: 'hello', - uid: 'dash-1', - description: 'hello description', - tags: ['tag1', 'tag2'], - editable: true, - $timeRange: new SceneTimeRange({ - timeZone: 'browser', +function buildTestScene(overrides?: Partial, serializerVersion: 'v1' | 'v2' = 'v1') { + const defaultPanels = [ + new VizPanel({ + title: 'Panel A', + key: 'panel-1', + pluginId: 'table', + $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), }), - controls: new DashboardControls({}), - $behaviors: [new behaviors.CursorSync({})], - body: DefaultGridLayoutManager.fromVizPanels([ - new VizPanel({ - title: 'Panel A', - key: 'panel-1', - pluginId: 'table', - $data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }), + ]; + + const scene = new DashboardScene( + { + title: 'hello', + uid: 'dash-1', + description: 'hello description', + tags: ['tag1', 'tag2'], + editable: true, + $timeRange: new SceneTimeRange({ + timeZone: 'browser', }), - ]), - ...overrides, - }); + controls: new DashboardControls({}), + $behaviors: [new behaviors.CursorSync({})], + body: DefaultGridLayoutManager.fromVizPanels(defaultPanels), + ...overrides, + }, + serializerVersion + ); return scene; } diff --git a/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx index 203ddc40783b..134e2cedbab9 100644 --- a/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx +++ b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx @@ -4,9 +4,15 @@ import { memo, useContext, useEffect, useMemo } from 'react'; import { Trans, t } from '@grafana/i18n'; import { locationService } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema'; +import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { ModalsContext, Modal, Button, useStyles2 } from '@grafana/ui'; import { Prompt } from 'app/core/components/FormPrompt/Prompt'; import { contextSrv } from 'app/core/services/context_srv'; +import { ObjectMeta } from 'app/features/apiserver/types'; +import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; +import { DASHBOARD_LIBRARY_ROUTES } from 'app/features/dashboard/dashgrid/types'; +import { DashboardMeta } from 'app/types/dashboard'; import { SaveLibraryVizPanelModal } from '../panel-edit/SaveLibraryVizPanelModal'; import { DashboardScene } from '../scene/DashboardScene'; @@ -18,7 +24,8 @@ interface DashboardPromptProps { export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps - const originalPath = useMemo(() => locationService.getLocation().pathname, [dashboard]); + const originalLocation = useMemo(() => locationService.getLocation(), [dashboard]); + const originalPath = useMemo(() => originalLocation.pathname, [originalLocation]); const { showModal, hideModal } = useContext(ModalsContext); useEffect(() => { @@ -94,7 +101,11 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => { onDiscard: () => { dashboard.exitEditMode({ skipConfirm: true }); hideModal(); - moveToBlockedLocationAfterReactStateUpdate(location); + if (originalPath === DASHBOARD_LIBRARY_ROUTES.Template) { + moveToBlockedLocationAfterReactStateUpdate(location, true); + } else { + moveToBlockedLocationAfterReactStateUpdate(location); + } }, onDismiss: hideModal, }); @@ -107,9 +118,9 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => { DashboardPrompt.displayName = 'DashboardPrompt'; -function moveToBlockedLocationAfterReactStateUpdate(location?: H.Location | null) { +function moveToBlockedLocationAfterReactStateUpdate(location?: H.Location | null, replace = false) { if (location) { - setTimeout(() => locationService.push(location), 10); + setTimeout(() => (replace ? locationService.replace(location) : locationService.push(location)), 10); } } @@ -178,6 +189,12 @@ export function ignoreChanges(scene: DashboardScene | null) { return true; } + const dashboard = scene.getSaveModel(); + // Ignore changes if the dashboard is empty (new dashboard) + if (isEmptyDashboard(dashboard, scene?.serializer.metadata)) { + return true; + } + const { canSave, fromScript, fromFile } = scene.state.meta; if (!contextSrv.isEditor && !canSave) { return true; @@ -185,3 +202,24 @@ export function ignoreChanges(scene: DashboardScene | null) { return !canSave || fromScript || fromFile; } + +export function isEmptyDashboard( + dashboard: Dashboard | DashboardV2Spec, + metadata?: DashboardMeta | ObjectMeta +): boolean { + if (isDashboardV2Spec(dashboard)) { + const hasNoPanels = Object.keys(dashboard.elements).length === 0; + const hasNoLinks = !dashboard.links.length; + const hasNoTemplates = !dashboard.variables.length; + const hasNoUid = !metadata || !('name' in metadata) || !metadata.name; + + return hasNoPanels && hasNoLinks && hasNoTemplates && hasNoUid; + } + + const hasNoPanels = !dashboard.panels?.length; + const hasNoLinks = !dashboard.links?.length; + const hasNoTemplates = !dashboard.templating?.list?.length; + const hasNoUid = !dashboard.uid; + + return hasNoPanels && hasNoLinks && hasNoTemplates && hasNoUid; +} diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts index 1d02c7296762..e95040ae2c82 100644 --- a/public/app/features/dashboard-scene/utils/interactions.ts +++ b/public/app/features/dashboard-scene/utils/interactions.ts @@ -4,6 +4,13 @@ import { DashboardTrackingInfo, DynamicDashboardsTrackingInformation } from '../ let isScenesContextSet = false; +type DashboardLibraryTrackingInfo = { + pluginId?: string; + sourceEntryPoint?: string; + libraryItemId?: string; + creationOrigin?: string; +}; + export const DashboardInteractions = { // Dashboard interactions: dashboardInitialized: ( @@ -21,8 +28,8 @@ export const DashboardInteractions = { dashboardCreatedOrSaved: ( isNew: boolean | undefined, properties: - | { name: string; url: string } - | { + | ({ name: string; url: string } & DashboardLibraryTrackingInfo) + | ({ name: string; url: string; numPanels: number; @@ -31,7 +38,7 @@ export const DashboardInteractions = { autoLayoutCount: number; customGridLayoutCount: number; panelsByDatasourceType: Record; - } + } & DashboardLibraryTrackingInfo) ) => { reportDashboardInteraction(isNew ? 'created' : 'saved', properties, 'grafana_dashboard'); }, diff --git a/public/app/features/dashboard-scene/utils/tracking.ts b/public/app/features/dashboard-scene/utils/tracking.ts index 30b385963463..21f770daca08 100644 --- a/public/app/features/dashboard-scene/utils/tracking.ts +++ b/public/app/features/dashboard-scene/utils/tracking.ts @@ -1,4 +1,5 @@ import { store } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { DashboardScene } from '../scene/DashboardScene'; import { EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement'; @@ -52,8 +53,24 @@ export function trackDashboardSceneCreatedOrSaved( dashboard: DashboardScene, initialProperties: { name: string; url: string } ) { + // url values for dashboard library experiment + const urlParams = new URLSearchParams(window.location.search); + const pluginId = urlParams.get('pluginId') || undefined; + const sourceEntryPoint = urlParams.get('sourceEntryPoint') || undefined; + const libraryItemId = urlParams.get('libraryItemId') || undefined; + const creationOrigin = urlParams.get('creationOrigin') || undefined; + const dynamicDashboardsTrackingInformation = dashboard.getDynamicDashboardsTrackingInformation(); + const dashboardLibraryProperties = config.featureToggles.dashboardLibrary + ? { + datasourceTypes: [pluginId], + sourceEntryPoint, + libraryItemId, + creationOrigin, + } + : {}; + DashboardInteractions.dashboardCreatedOrSaved(isNew, { ...initialProperties, ...(dynamicDashboardsTrackingInformation @@ -64,7 +81,10 @@ export function trackDashboardSceneCreatedOrSaved( autoLayoutCount: dynamicDashboardsTrackingInformation.autoLayoutCount, customGridLayoutCount: dynamicDashboardsTrackingInformation.customGridLayoutCount, panelsByDatasourceType: dynamicDashboardsTrackingInformation.panelsByDatasourceType, + ...dashboardLibraryProperties, } - : {}), + : { + ...dashboardLibraryProperties, + }), }); } diff --git a/public/app/features/dashboard/containers/DashboardPageProxy.tsx b/public/app/features/dashboard/containers/DashboardPageProxy.tsx index 2e37c4b37495..5409ff7177b7 100644 --- a/public/app/features/dashboard/containers/DashboardPageProxy.tsx +++ b/public/app/features/dashboard/containers/DashboardPageProxy.tsx @@ -32,7 +32,9 @@ function DashboardPageProxy(props: DashboardPageProxyProps) { } const isScenesSupportedRoute = Boolean( - props.route.routeName === DashboardRoutes.Home || (props.route.routeName === DashboardRoutes.Normal && params.uid) + props.route.routeName === DashboardRoutes.Home || + props.route.routeName === DashboardRoutes.Template || + (props.route.routeName === DashboardRoutes.Normal && params.uid) ); // We pre-fetch dashboard to render dashboard page component depending on dashboard permissions. diff --git a/public/app/features/dashboard/containers/NewDashboardWithDS.tsx b/public/app/features/dashboard/containers/NewDashboardWithDS.tsx index 46064030ca2f..04b2bbcaa41d 100644 --- a/public/app/features/dashboard/containers/NewDashboardWithDS.tsx +++ b/public/app/features/dashboard/containers/NewDashboardWithDS.tsx @@ -22,7 +22,7 @@ export default function NewDashboardWithDS() { dispatch(setInitialDatasource(datasourceUid)); - locationService.replace('/dashboard/new'); + locationService.replace(`/dashboard/new?dashboardLibraryDatasourceUid=${datasourceUid}`); }, [datasourceUid, dispatch]); if (error) { diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx index 4c6ed9d33e32..61cc488109c1 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.test.tsx @@ -1,4 +1,5 @@ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, screen } from '@testing-library/react'; +import { render } from 'test/test-utils'; import { config, locationService, reportInteraction } from '@grafana/runtime'; import { defaultDashboard } from '@grafana/schema'; @@ -18,6 +19,9 @@ jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), locationService: { partial: jest.fn(), + getHistory: jest.fn(() => ({ + listen: jest.fn(), + })), }, reportInteraction: jest.fn(), })); @@ -36,6 +40,10 @@ jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () => })), })); +jest.mock('../DashboardLibrary/DashboardLibrarySection', () => ({ + DashboardLibrarySection: () =>
Dashboard Library Section
, +})); + const mockUseGetResourceRepositoryView = jest.mocked( require('app/features/provisioning/hooks/useGetResourceRepositoryView').useGetResourceRepositoryView ); @@ -154,6 +162,44 @@ it('renders with buttons disabled when repository is read-only', () => { expect(screen.getByRole('button', { name: 'Add library panel' })).toBeDisabled(); }); +describe('DashboardLibrarySection feature toggle', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseGetResourceRepositoryView.mockReturnValue({ + isReadOnlyRepo: false, + isInstanceManaged: false, + isLoading: false, + }); + }); + + it('renders DashboardLibrarySection when feature toggle is enabled and dashboardLibraryDatasourceUid param exists', () => { + config.featureToggles.dashboardLibrary = true; + mockSearchParams.set('dashboardLibraryDatasourceUid', 'test-uid'); + + setup(); + + expect(screen.getByTestId('dashboard-library-section')).toBeInTheDocument(); + }); + + it('does not render DashboardLibrarySection when feature toggle is disabled', () => { + config.featureToggles.dashboardLibrary = false; + mockSearchParams.delete('dashboardLibraryDatasourceUid'); + + setup(); + + expect(screen.queryByTestId('dashboard-library-section')).not.toBeInTheDocument(); + }); + + it('does not render DashboardLibrarySection 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(); + }); +}); + describe('wrapperMaxWidth CSS class', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx index 6369831a12cb..8e4731244340 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty/DashboardEmpty.tsx @@ -10,6 +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 { DashboardEmptyExtensionPoint } from './DashboardEmptyExtensionPoint'; import { useIsReadOnlyRepo, @@ -64,6 +66,7 @@ const InternalDashboardEmpty = ({ onAddVisualization, onAddLibraryPanel, onImpor + {config.featureToggles.dashboardLibrary && dashboardLibraryDatasourceUid && } diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx new file mode 100644 index 000000000000..f0df493ff1ee --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardLibrarySection.tsx @@ -0,0 +1,199 @@ +import { css } from '@emotion/css'; +import { useState } 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 { 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 { DashboardLibraryInteractions } from './interactions'; + +export const DashboardLibrarySection = () => { + const [searchParams] = useSearchParams(); + const datasourceUid = searchParams.get('dashboardLibraryDatasourceUid'); + + const [showAll, setShowAll] = useState(false); + + const { value: templateDashboards } = useAsync(async (): Promise => { + if (!datasourceUid) { + return []; + } + + const ds = getDataSourceSrv().getInstanceSettings(datasourceUid); + if (!ds) { + 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 []; + } + }, [datasourceUid]); + + const hasMoreThanThree = templateDashboards && templateDashboards.length > 3; + const dashboardsToShow = showAll ? templateDashboards : templateDashboards?.slice(0, 3); + + const styles = useStyles2(getStyles, dashboardsToShow?.length); + + const onImportDashboardClick = async (dashboard: PluginDashboard) => { + DashboardLibraryInteractions.itemClicked({ + contentKind: 'datasource_dashboard', + datasourceTypes: [dashboard.pluginId], + libraryItemId: dashboard.uid, + libraryItemTitle: dashboard.title, + sourceEntryPoint: 'datasource_page', + }); + + 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) => ( + + )) || []} + + + {hasMoreThanThree && ( + + )} + + + ); +}; + +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) { + 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', + }), + showMoreButton: css({ + marginTop: theme.spacing(2), + }), + }; +} diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts new file mode 100644 index 000000000000..1222007dd84c --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/interactions.ts @@ -0,0 +1,35 @@ +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 SourceEntryPoint = 'datasource_page'; +// possible future flows onboarding, create-dashboard, empty states +// | 'create_dashboard' | 'empty_state'; + +export const DashboardLibraryInteractions = { + loaded: (properties: { + numberOfItems: number; + contentKinds: ContentKind[]; + datasourceTypes: string[]; + sourceEntryPoint: SourceEntryPoint; + }) => { + reportDashboardLibraryInteraction('loaded', properties); + }, + itemClicked: (properties: { + contentKind: ContentKind; + datasourceTypes: string[]; + libraryItemId: string; + libraryItemTitle: string; + sourceEntryPoint: SourceEntryPoint; + }) => { + reportDashboardLibraryInteraction('item_clicked', properties); + }, +}; + +const reportDashboardLibraryInteraction = (name: string, properties?: Record) => { + reportInteraction(`grafana_dashboard_library_${name}`, { ...properties, schema_version: SCHEMA_VERSION }); +}; diff --git a/public/app/features/dashboard/dashgrid/types.ts b/public/app/features/dashboard/dashgrid/types.ts new file mode 100644 index 000000000000..2549ddbe40df --- /dev/null +++ b/public/app/features/dashboard/dashgrid/types.ts @@ -0,0 +1,3 @@ +export const DASHBOARD_LIBRARY_ROUTES = { + Template: '/dashboard/template', +}; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 47e3fcfa90b9..2448eda99e14 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -12,6 +12,7 @@ import { isAdmin, isLocalDevEnv, isOpenSourceEdition } from 'app/features/alerti import { ConnectionsRedirectNotice } from 'app/features/connections/components/ConnectionsRedirectNotice'; import { ROUTES as CONNECTIONS_ROUTES } from 'app/features/connections/constants'; import { getRoutes as getDataConnectionsRoutes } from 'app/features/connections/routes'; +import { DASHBOARD_LIBRARY_ROUTES } from 'app/features/dashboard/dashgrid/types'; import { DATASOURCES_ROUTES } from 'app/features/datasources/constants'; import { ConfigureIRM } from 'app/features/gops/configuration-tracker/components/ConfigureIRM'; import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/routes'; @@ -65,6 +66,15 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/NewDashboardWithDS') ), }, + { + path: DASHBOARD_LIBRARY_ROUTES.Template, + roles: () => contextSrv.evaluatePermission([AccessControlAction.DashboardsCreate]), + pageClass: 'page-dashboard', + routeName: DashboardRoutes.Template, + component: SafeDynamicImport( + () => import(/* webpackChunkName: "DashboardPage" */ '../features/dashboard/containers/DashboardPageProxy') + ), + }, { path: '/dashboard/:type/:slug', pageClass: 'page-dashboard', diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index a5705803382b..4b926a7a5db0 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -108,6 +108,7 @@ export interface DashboardDataDTO extends Dashboard { export enum DashboardRoutes { Home = 'home-dashboard', New = 'new-dashboard', + Template = 'template-dashboard', Normal = 'normal-dashboard', Provisioning = 'provisioning-dashboard', Scripted = 'scripted-dashboard', diff --git a/public/img/dashboard-library/dashboard_library_1.jpg b/public/img/dashboard-library/dashboard_library_1.jpg new file mode 100644 index 000000000000..d4d4b508cea0 Binary files /dev/null and b/public/img/dashboard-library/dashboard_library_1.jpg differ diff --git a/public/img/dashboard-library/dashboard_library_2.jpg b/public/img/dashboard-library/dashboard_library_2.jpg new file mode 100644 index 000000000000..5f40e9ae2f86 Binary files /dev/null and b/public/img/dashboard-library/dashboard_library_2.jpg differ diff --git a/public/img/dashboard-library/dashboard_library_3.jpg b/public/img/dashboard-library/dashboard_library_3.jpg new file mode 100644 index 000000000000..5b665f352a5e Binary files /dev/null and b/public/img/dashboard-library/dashboard_library_3.jpg differ diff --git a/public/img/dashboard-library/dashboard_library_4.jpg b/public/img/dashboard-library/dashboard_library_4.jpg new file mode 100644 index 000000000000..d04619868bc9 Binary files /dev/null and b/public/img/dashboard-library/dashboard_library_4.jpg differ diff --git a/public/img/dashboard-library/dashboard_library_5.jpg b/public/img/dashboard-library/dashboard_library_5.jpg new file mode 100644 index 000000000000..ed479bd01c89 Binary files /dev/null and b/public/img/dashboard-library/dashboard_library_5.jpg differ diff --git a/public/img/dashboard-library/dashboard_library_6.jpg b/public/img/dashboard-library/dashboard_library_6.jpg new file mode 100644 index 000000000000..4cdfb7914f23 Binary files /dev/null and b/public/img/dashboard-library/dashboard_library_6.jpg differ diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index df3dc82617ee..9c91547b2034 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -4829,7 +4829,11 @@ "add-visualization-header": "Start your new dashboard by adding a visualization", "import-a-dashboard-body": "Import dashboards from files or <2>grafana.com.", "import-a-dashboard-header": "Import a dashboard", - "import-dashboard-button": "Import dashboard" + "import-dashboard-button": "Import dashboard", + "show-less-dashboards": "Show less", + "show-more-dashboards": "Show more", + "start-with-suggested-dashboards": "Start with a pre-made dashboard from your data source", + "use-template-button": "Use this dashboard" }, "errors": { "failed-to-load": "Failed to load dashboard" diff --git a/public/openapi3.json b/public/openapi3.json index 82afc3051fc5..162af40b01ce 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -1405,6 +1405,14 @@ }, "description": "InternalServerPublicError is a general error indicating something went wrong internally." }, + "interpolateDashboardResponse": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "(empty)" + }, "jwksResponse": { "content": { "application/json": { @@ -17742,6 +17750,32 @@ ] } }, + "/dashboards/interpolate": { + "post": { + "operationId": "interpolateDashboard", + "responses": { + "200": { + "$ref": "#/components/responses/interpolateDashboardResponse" + }, + "400": { + "$ref": "#/components/responses/badRequestError" + }, + "401": { + "$ref": "#/components/responses/unauthorisedError" + }, + "422": { + "$ref": "#/components/responses/unprocessableEntityError" + }, + "500": { + "$ref": "#/components/responses/internalServerError" + } + }, + "summary": "Interpolate dashboard. This is an experimental endpoint under dashboardLibrary FF and is subject to change.", + "tags": [ + "dashboards" + ] + } + }, "/dashboards/public-dashboards": { "get": { "description": "Get list of public dashboards",