mirror of
https://github.com/grafana/grafana.git
synced 2026-08-09 04:38:16 -05:00
Dashboard Library: Display datasource plugin dashboards in empty page (#111279)
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -966,7 +966,7 @@ var (
|
||||
Description: "Enable suggested dashboards when creating new dashboards",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSharingSquad,
|
||||
FrontendOnly: true,
|
||||
FrontendOnly: false,
|
||||
},
|
||||
{
|
||||
Name: "logsExploreTableDefaultVisualization",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10068,6 +10068,10 @@
|
||||
"$ref": "#/definitions/publicError"
|
||||
}
|
||||
},
|
||||
"interpolateDashboardResponse": {
|
||||
"description": "",
|
||||
"schema": {}
|
||||
},
|
||||
"jwksResponse": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<DashboardDTO> {
|
||||
// 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: {
|
||||
|
||||
@@ -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<DashboardSceneState>) {
|
||||
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<DashboardSceneState>, 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
} & DashboardLibraryTrackingInfo)
|
||||
) => {
|
||||
reportDashboardInteraction(isNew ? 'created' : 'saved', properties, 'grafana_dashboard');
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: () => <div data-testid="dashboard-library-section">Dashboard Library Section</div>,
|
||||
}));
|
||||
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
{config.featureToggles.dashboardLibrary && dashboardLibraryDatasourceUid && <DashboardLibrarySection />}
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} wrap="wrap" gap={4}>
|
||||
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={3} flex={1}>
|
||||
<Stack direction="column" alignItems="center" gap={1}>
|
||||
|
||||
@@ -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<PluginDashboard[]> => {
|
||||
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 (
|
||||
<Box borderColor="strong" borderStyle="dashed" padding={4} flex={1}>
|
||||
<Stack direction="column" alignItems="center" gap={2}>
|
||||
<Text element="h3" textAlignment="center" weight="medium">
|
||||
<Trans i18nKey="dashboard.empty.start-with-suggested-dashboards">
|
||||
Start with a pre-made dashboard from your data source
|
||||
</Trans>
|
||||
</Text>
|
||||
<Box marginTop={2}>
|
||||
<Grid
|
||||
gap={4}
|
||||
columns={{
|
||||
xs: 1,
|
||||
sm: (dashboardsToShow?.length || 1) >= 2 ? 2 : 1,
|
||||
lg: (dashboardsToShow?.length || 1) >= 3 ? 3 : (dashboardsToShow?.length || 1) >= 2 ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
{dashboardsToShow?.map((dashboard, index) => (
|
||||
<TemplateDashboardBox
|
||||
key={dashboard.uid}
|
||||
index={index}
|
||||
dashboard={dashboard}
|
||||
onImportClick={onImportDashboardClick}
|
||||
/>
|
||||
)) || []}
|
||||
</Grid>
|
||||
</Box>
|
||||
{hasMoreThanThree && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
fill="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAll((prev) => !prev)}
|
||||
className={styles.showMoreButton}
|
||||
>
|
||||
{showAll ? (
|
||||
<Trans i18nKey="dashboard.empty.show-less-dashboards">Show less</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="dashboard.empty.show-more-dashboards">Show more</Trans>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateDashboardBox = ({
|
||||
dashboard,
|
||||
onImportClick,
|
||||
index,
|
||||
}: {
|
||||
dashboard: PluginDashboard;
|
||||
onImportClick: (d: PluginDashboard) => void;
|
||||
index: number;
|
||||
}) => {
|
||||
const dashboardLibraryImages = [
|
||||
dashboardLibrary1,
|
||||
dashboardLibrary2,
|
||||
dashboardLibrary3,
|
||||
dashboardLibrary4,
|
||||
dashboardLibrary5,
|
||||
dashboardLibrary6,
|
||||
];
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<div className={styles.templateDashboardBox}>
|
||||
<img
|
||||
src={index <= 5 ? dashboardLibraryImages[index] : dashboardLibraryImages[index % dashboardLibraryImages.length]}
|
||||
width={285}
|
||||
height={160}
|
||||
alt={dashboard.title}
|
||||
className={styles.templateDashboardImage}
|
||||
/>
|
||||
<div className={styles.templateDashboardTitle}>
|
||||
<Text element="p" textAlignment="center">
|
||||
{dashboard.title}
|
||||
</Text>
|
||||
</div>
|
||||
<Button fill="outline" onClick={() => onImportClick(dashboard)} size="sm">
|
||||
<Trans i18nKey="dashboard.empty.use-template-button">Use this dashboard</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getStyles(theme: GrafanaTheme2, dashboardsLength?: number) {
|
||||
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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>) => {
|
||||
reportInteraction(`grafana_dashboard_library_${name}`, { ...properties, schema_version: SCHEMA_VERSION });
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const DASHBOARD_LIBRARY_ROUTES = {
|
||||
Template: '/dashboard/template',
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -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</2>.",
|
||||
"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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user