Dashboard Library: Display datasource plugin dashboards in empty page (#111279)

This commit is contained in:
Juan Cabanas
2025-10-15 09:05:02 -03:00
committed by GitHub
parent 89b3f64479
commit b7a6e1ca53
31 changed files with 972 additions and 47 deletions
+49 -1
View File
@@ -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"`
}
+79 -4
View File
@@ -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()
+1 -1
View File
@@ -966,7 +966,7 @@ var (
Description: "Enable suggested dashboards when creating new dashboards",
Stage: FeatureStageExperimental,
Owner: grafanaSharingSquad,
FrontendOnly: true,
FrontendOnly: false,
},
{
Name: "logsExploreTableDefaultVisualization",
+1 -1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
125 disableNumericMetricsSortingInExpressions experimental @grafana/oss-big-tent false true false
126 grafanaManagedRecordingRules experimental @grafana/alerting-squad false false false
127 queryLibrary preview @grafana/sharing-squad false false false
128 dashboardLibrary experimental @grafana/sharing-squad false false true false
129 logsExploreTableDefaultVisualization experimental @grafana/observability-logs false false true
130 alertingListViewV2 privatePreview @grafana/alerting-squad false false true
131 alertingDisableSendAlertsExternal experimental @grafana/alerting-squad false false false
+6 -4
View File
@@ -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"
}
},
{
+4
View File
@@ -10068,6 +10068,10 @@
"$ref": "#/definitions/publicError"
}
},
"interpolateDashboardResponse": {
"description": "",
"schema": {}
},
"jwksResponse": {
"description": "",
"schema": {
+30
View File
@@ -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',
};
+10
View File
@@ -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',
+1
View File
@@ -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

+5 -1
View File
@@ -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"
+34
View File
@@ -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",