Dashboard Library: Integrate community dashboards on Suggested Dashboards Flow (#112808)

* Extend interpolate endpoint to support community dashboard json interpolation
Added unit tests

* Implement Frontend Side
- Show tabs
- Fetch Community dashboads
- Use DashboardCard component
- Search grafana dashboard in the community tab
- Make Tabs and pagination sticky
- Adjust titles to be scoped by datasource name/type
- Add skeleton loading for community tabs and pagination
- Add dashboard details tooltip
- Bring old datasource-provisioned box back and rely on new feature toggle for community dashboards
- update i18n
- update swagger
---------

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