mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
PublicDashboards: filter by permissions on audit list (#57228)
This commit is contained in:
parent
d81a3e524d
commit
3e6bdf0439
@ -118,7 +118,7 @@ func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response {
|
||||
// Gets list of public dashboards for an org
|
||||
// GET /api/dashboards/public
|
||||
func (api *Api) ListPublicDashboards(c *models.ReqContext) response.Response {
|
||||
resp, err := api.PublicDashboardService.ListPublicDashboards(c.Req.Context(), c.OrgID)
|
||||
resp, err := api.PublicDashboardService.ListPublicDashboards(c.Req.Context(), c.SignedInUser, c.OrgID)
|
||||
if err != nil {
|
||||
return api.handleError(c.Req.Context(), http.StatusInternalServerError, "ListPublicDashboards: failed to list public dashboards", err)
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
dashboardStore "github.com/grafana/grafana/pkg/services/dashboards/database"
|
||||
@ -189,7 +190,7 @@ func TestAPIListPublicDashboard(t *testing.T) {
|
||||
for _, test := range testCases {
|
||||
t.Run(test.Name, func(t *testing.T) {
|
||||
service := publicdashboards.NewFakePublicDashboardService(t)
|
||||
service.On("ListPublicDashboards", mock.Anything, mock.Anything).
|
||||
service.On("ListPublicDashboards", mock.Anything, mock.Anything, mock.Anything).
|
||||
Return(test.Response, test.ResponseErr).Maybe()
|
||||
|
||||
cfg := setting.NewCfg()
|
||||
@ -686,8 +687,9 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T)
|
||||
// create public dashboard
|
||||
store := publicdashboardsStore.ProvideStore(db)
|
||||
cfg := setting.NewCfg()
|
||||
ac := acmock.New()
|
||||
cfg.RBACEnabled = false
|
||||
service := publicdashboardsService.ProvideService(cfg, store, qds, annotationsService)
|
||||
service := publicdashboardsService.ProvideService(cfg, store, qds, annotationsService, ac)
|
||||
pubdash, err := service.SavePublicDashboardConfig(context.Background(), &user.SignedInUser{}, savePubDashboardCmd)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
@ -44,7 +44,7 @@ func (d *PublicDashboardStoreImpl) ListPublicDashboards(ctx context.Context, org
|
||||
Join("LEFT", "dashboard", "dashboard.uid = dashboard_public.dashboard_uid AND dashboard.org_id = dashboard_public.org_id").
|
||||
Cols("dashboard_public.uid", "dashboard_public.access_token", "dashboard_public.dashboard_uid", "dashboard_public.is_enabled", "dashboard.title").
|
||||
Where("dashboard_public.org_id = ?", orgId).
|
||||
OrderBy("is_enabled DESC, dashboard.title ASC")
|
||||
OrderBy(" is_enabled DESC, dashboard.title IS NULL, dashboard.title ASC")
|
||||
|
||||
err := sess.Find(&resp)
|
||||
return err
|
||||
|
@ -1,4 +1,4 @@
|
||||
// Code generated by mockery v2.12.1. DO NOT EDIT.
|
||||
// Code generated by mockery v2.14.0. DO NOT EDIT.
|
||||
|
||||
package publicdashboards
|
||||
|
||||
@ -15,8 +15,6 @@ import (
|
||||
|
||||
publicdashboardsmodels "github.com/grafana/grafana/pkg/services/publicdashboards/models"
|
||||
|
||||
testing "testing"
|
||||
|
||||
user "github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
@ -228,13 +226,13 @@ func (_m *FakePublicDashboardService) GetQueryDataResponse(ctx context.Context,
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// ListPublicDashboards provides a mock function with given fields: ctx, orgId
|
||||
func (_m *FakePublicDashboardService) ListPublicDashboards(ctx context.Context, orgId int64) ([]publicdashboardsmodels.PublicDashboardListResponse, error) {
|
||||
ret := _m.Called(ctx, orgId)
|
||||
// ListPublicDashboards provides a mock function with given fields: ctx, u, orgId
|
||||
func (_m *FakePublicDashboardService) ListPublicDashboards(ctx context.Context, u *user.SignedInUser, orgId int64) ([]publicdashboardsmodels.PublicDashboardListResponse, error) {
|
||||
ret := _m.Called(ctx, u, orgId)
|
||||
|
||||
var r0 []publicdashboardsmodels.PublicDashboardListResponse
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int64) []publicdashboardsmodels.PublicDashboardListResponse); ok {
|
||||
r0 = rf(ctx, orgId)
|
||||
if rf, ok := ret.Get(0).(func(context.Context, *user.SignedInUser, int64) []publicdashboardsmodels.PublicDashboardListResponse); ok {
|
||||
r0 = rf(ctx, u, orgId)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]publicdashboardsmodels.PublicDashboardListResponse)
|
||||
@ -242,8 +240,8 @@ func (_m *FakePublicDashboardService) ListPublicDashboards(ctx context.Context,
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
|
||||
r1 = rf(ctx, orgId)
|
||||
if rf, ok := ret.Get(1).(func(context.Context, *user.SignedInUser, int64) error); ok {
|
||||
r1 = rf(ctx, u, orgId)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
@ -295,8 +293,13 @@ func (_m *FakePublicDashboardService) SavePublicDashboardConfig(ctx context.Cont
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// NewFakePublicDashboardService creates a new instance of FakePublicDashboardService. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
func NewFakePublicDashboardService(t testing.TB) *FakePublicDashboardService {
|
||||
type mockConstructorTestingTNewFakePublicDashboardService interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}
|
||||
|
||||
// NewFakePublicDashboardService creates a new instance of FakePublicDashboardService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
func NewFakePublicDashboardService(t mockConstructorTestingTNewFakePublicDashboardService) *FakePublicDashboardService {
|
||||
mock := &FakePublicDashboardService{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
|
@ -23,7 +23,7 @@ type Service interface {
|
||||
GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error)
|
||||
GetPublicDashboardOrgId(ctx context.Context, accessToken string) (int64, error)
|
||||
GetQueryDataResponse(ctx context.Context, skipCache bool, reqDTO PublicDashboardQueryDTO, panelId int64, accessToken string) (*backend.QueryDataResponse, error)
|
||||
ListPublicDashboards(ctx context.Context, orgId int64) ([]PublicDashboardListResponse, error)
|
||||
ListPublicDashboards(ctx context.Context, u *user.SignedInUser, orgId int64) ([]PublicDashboardListResponse, error)
|
||||
PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error)
|
||||
SavePublicDashboardConfig(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error)
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
@ -33,6 +34,7 @@ type PublicDashboardServiceImpl struct {
|
||||
intervalCalculator intervalv2.Calculator
|
||||
QueryDataService *query.Service
|
||||
AnnotationsRepo annotations.Repository
|
||||
ac accesscontrol.AccessControl
|
||||
}
|
||||
|
||||
var LogPrefix = "publicdashboards.service"
|
||||
@ -48,6 +50,7 @@ func ProvideService(
|
||||
store publicdashboards.Store,
|
||||
qds *query.Service,
|
||||
anno annotations.Repository,
|
||||
ac accesscontrol.AccessControl,
|
||||
) *PublicDashboardServiceImpl {
|
||||
return &PublicDashboardServiceImpl{
|
||||
log: log.New(LogPrefix),
|
||||
@ -56,14 +59,10 @@ func ProvideService(
|
||||
intervalCalculator: intervalv2.NewCalculator(),
|
||||
QueryDataService: qds,
|
||||
AnnotationsRepo: anno,
|
||||
ac: ac,
|
||||
}
|
||||
}
|
||||
|
||||
// Gets a list of public dashboards by orgId
|
||||
func (pd *PublicDashboardServiceImpl) ListPublicDashboards(ctx context.Context, orgId int64) ([]PublicDashboardListResponse, error) {
|
||||
return pd.store.ListPublicDashboards(ctx, orgId)
|
||||
}
|
||||
|
||||
// Gets a dashboard by Uid
|
||||
func (pd *PublicDashboardServiceImpl) GetDashboard(ctx context.Context, dashboardUid string) (*models.Dashboard, error) {
|
||||
dashboard, err := pd.store.GetDashboard(ctx, dashboardUid)
|
||||
@ -392,6 +391,16 @@ func (pd *PublicDashboardServiceImpl) BuildAnonymousUser(ctx context.Context, da
|
||||
return anonymousUser
|
||||
}
|
||||
|
||||
// Gets a list of public dashboards by orgId
|
||||
func (pd *PublicDashboardServiceImpl) ListPublicDashboards(ctx context.Context, u *user.SignedInUser, orgId int64) ([]PublicDashboardListResponse, error) {
|
||||
publicDashboards, err := pd.store.ListPublicDashboards(ctx, orgId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pd.filterDashboardsByPermissions(ctx, u, publicDashboards)
|
||||
}
|
||||
|
||||
func (pd *PublicDashboardServiceImpl) PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) {
|
||||
return pd.store.PublicDashboardEnabled(ctx, dashboardUid)
|
||||
}
|
||||
@ -444,7 +453,26 @@ func (pd *PublicDashboardServiceImpl) logIsEnabledChanged(existingPubdash *Publi
|
||||
}
|
||||
}
|
||||
|
||||
// Checks to see if PublicDashboard.Isenabled is true on create or changed on update
|
||||
// Filter out dashboards that user does not have read access to
|
||||
func (pd *PublicDashboardServiceImpl) filterDashboardsByPermissions(ctx context.Context, u *user.SignedInUser, publicDashboards []PublicDashboardListResponse) ([]PublicDashboardListResponse, error) {
|
||||
result := make([]PublicDashboardListResponse, 0)
|
||||
|
||||
for i := range publicDashboards {
|
||||
hasAccess, err := pd.ac.Evaluate(ctx, u, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(publicDashboards[i].DashboardUid)))
|
||||
// If original dashboard does not exist, the public dashboard is an orphan. We want to list it anyway
|
||||
if err != nil && !errors.Is(err, dashboards.ErrDashboardNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If user has access to the original dashboard or the dashboard does not exist, add the pubdash to the result
|
||||
if hasAccess || errors.Is(err, dashboards.ErrDashboardNotFound) {
|
||||
result = append(result, publicDashboards[i])
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Checks to see if PublicDashboard.IsEnabled is true on create or changed on update
|
||||
func publicDashboardIsEnabledChanged(existingPubdash *PublicDashboard, newPubdash *PublicDashboard) bool {
|
||||
// creating dashboard, enabled true
|
||||
newDashCreated := existingPubdash == nil && newPubdash.IsEnabled
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -17,14 +18,17 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/annotations"
|
||||
"github.com/grafana/grafana/pkg/services/annotations/annotationsimpl"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
dashboardsDB "github.com/grafana/grafana/pkg/services/dashboards/database"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
. "github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards/database"
|
||||
"github.com/grafana/grafana/pkg/services/publicdashboards/internal"
|
||||
. "github.com/grafana/grafana/pkg/services/publicdashboards/models"
|
||||
"github.com/grafana/grafana/pkg/services/serviceaccounts/tests"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/tag/tagimpl"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@ -1190,3 +1194,212 @@ func AddAnnotationsToDashboard(t *testing.T, dash *models.Dashboard, annotations
|
||||
|
||||
return dash
|
||||
}
|
||||
|
||||
func TestPublicDashboardServiceImpl_ListPublicDashboards(t *testing.T) {
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
u *user.SignedInUser
|
||||
orgId int64
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
args args
|
||||
evaluateFunc func(c context.Context, u *user.SignedInUser, e accesscontrol.Evaluator) (bool, error)
|
||||
want []PublicDashboardListResponse
|
||||
wantErr assert.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "should return empty list when user does not have permissions to read any dashboard",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
u: &user.SignedInUser{OrgID: 1},
|
||||
orgId: 1,
|
||||
},
|
||||
want: []PublicDashboardListResponse{},
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "should return all dashboards when has permissions",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
u: &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{
|
||||
1: {"dashboards:read": {
|
||||
"dashboards:uid:0S6TmO67z", "dashboards:uid:1S6TmO67z", "dashboards:uid:2S6TmO67z", "dashboards:uid:9S6TmO67z",
|
||||
}}},
|
||||
},
|
||||
orgId: 1,
|
||||
},
|
||||
want: []PublicDashboardListResponse{
|
||||
{
|
||||
Uid: "0GwW7mgVk",
|
||||
AccessToken: "0b458cb7fe7f42c68712078bcacee6e3",
|
||||
DashboardUid: "0S6TmO67z",
|
||||
Title: "my zero dashboard",
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
Uid: "1GwW7mgVk",
|
||||
AccessToken: "1b458cb7fe7f42c68712078bcacee6e3",
|
||||
DashboardUid: "1S6TmO67z",
|
||||
Title: "my first dashboard",
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
Uid: "2GwW7mgVk",
|
||||
AccessToken: "2b458cb7fe7f42c68712078bcacee6e3",
|
||||
DashboardUid: "2S6TmO67z",
|
||||
Title: "my second dashboard",
|
||||
IsEnabled: false,
|
||||
},
|
||||
{
|
||||
Uid: "9GwW7mgVk",
|
||||
AccessToken: "deletedashboardaccesstoken",
|
||||
DashboardUid: "9S6TmO67z",
|
||||
Title: "",
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "should return only dashboards with permissions",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
u: &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{
|
||||
1: {"dashboards:read": {"dashboards:uid:0S6TmO67z"}}},
|
||||
},
|
||||
orgId: 1,
|
||||
},
|
||||
want: []PublicDashboardListResponse{
|
||||
{
|
||||
Uid: "0GwW7mgVk",
|
||||
AccessToken: "0b458cb7fe7f42c68712078bcacee6e3",
|
||||
DashboardUid: "0S6TmO67z",
|
||||
Title: "my zero dashboard",
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "should return orphaned public dashboards",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
u: &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{
|
||||
1: {"dashboards:read": {"dashboards:uid:0S6TmO67z"}}},
|
||||
},
|
||||
orgId: 1,
|
||||
},
|
||||
evaluateFunc: func(c context.Context, u *user.SignedInUser, e accesscontrol.Evaluator) (bool, error) {
|
||||
return false, dashboards.ErrDashboardNotFound
|
||||
},
|
||||
want: []PublicDashboardListResponse{
|
||||
{
|
||||
Uid: "0GwW7mgVk",
|
||||
AccessToken: "0b458cb7fe7f42c68712078bcacee6e3",
|
||||
DashboardUid: "0S6TmO67z",
|
||||
Title: "my zero dashboard",
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
Uid: "1GwW7mgVk",
|
||||
AccessToken: "1b458cb7fe7f42c68712078bcacee6e3",
|
||||
DashboardUid: "1S6TmO67z",
|
||||
Title: "my first dashboard",
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
Uid: "2GwW7mgVk",
|
||||
AccessToken: "2b458cb7fe7f42c68712078bcacee6e3",
|
||||
DashboardUid: "2S6TmO67z",
|
||||
Title: "my second dashboard",
|
||||
IsEnabled: false,
|
||||
},
|
||||
{
|
||||
Uid: "9GwW7mgVk",
|
||||
AccessToken: "deletedashboardaccesstoken",
|
||||
DashboardUid: "9S6TmO67z",
|
||||
Title: "",
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "errors different than not data found should be returned",
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
u: &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{
|
||||
1: {"dashboards:read": {"dashboards:uid:0S6TmO67z"}}},
|
||||
},
|
||||
orgId: 1,
|
||||
},
|
||||
evaluateFunc: func(c context.Context, u *user.SignedInUser, e accesscontrol.Evaluator) (bool, error) {
|
||||
return false, dashboards.ErrDashboardCorrupt
|
||||
},
|
||||
want: nil,
|
||||
wantErr: assert.Error,
|
||||
},
|
||||
}
|
||||
|
||||
mockedDashboards := []PublicDashboardListResponse{
|
||||
{
|
||||
Uid: "0GwW7mgVk",
|
||||
AccessToken: "0b458cb7fe7f42c68712078bcacee6e3",
|
||||
DashboardUid: "0S6TmO67z",
|
||||
Title: "my zero dashboard",
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
Uid: "1GwW7mgVk",
|
||||
AccessToken: "1b458cb7fe7f42c68712078bcacee6e3",
|
||||
DashboardUid: "1S6TmO67z",
|
||||
Title: "my first dashboard",
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
Uid: "2GwW7mgVk",
|
||||
AccessToken: "2b458cb7fe7f42c68712078bcacee6e3",
|
||||
DashboardUid: "2S6TmO67z",
|
||||
Title: "my second dashboard",
|
||||
IsEnabled: false,
|
||||
},
|
||||
{
|
||||
Uid: "9GwW7mgVk",
|
||||
AccessToken: "deletedashboardaccesstoken",
|
||||
DashboardUid: "9S6TmO67z",
|
||||
Title: "",
|
||||
IsEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
store := NewFakePublicDashboardStore(t)
|
||||
store.On("ListPublicDashboards", mock.Anything, mock.Anything).
|
||||
Return(mockedDashboards, nil)
|
||||
|
||||
ac := tests.SetupMockAccesscontrol(t,
|
||||
func(c context.Context, siu *user.SignedInUser, _ accesscontrol.Options) ([]accesscontrol.Permission, error) {
|
||||
return []accesscontrol.Permission{}, nil
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
||||
pd := &PublicDashboardServiceImpl{
|
||||
log: log.New("test.logger"),
|
||||
store: store,
|
||||
ac: ac,
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac.EvaluateFunc = tt.evaluateFunc
|
||||
|
||||
got, err := pd.ListPublicDashboards(tt.args.ctx, tt.args.u, tt.args.orgId)
|
||||
if !tt.wantErr(t, err, fmt.Sprintf("ListPublicDashboards(%v, %v, %v)", tt.args.ctx, tt.args.u, tt.args.orgId)) {
|
||||
return
|
||||
}
|
||||
assert.Equalf(t, tt.want, got, "ListPublicDashboards(%v, %v, %v)", tt.args.ctx, tt.args.u, tt.args.orgId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -38,8 +38,10 @@ export const ListPublicDashboardTable = () => {
|
||||
<table className="filter-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dashboard</th>
|
||||
<th>Public dashboard enabled</th>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Public URL</th>
|
||||
<th>Configuration</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -65,7 +67,10 @@ export const ListPublicDashboardTable = () => {
|
||||
>
|
||||
<Icon name="external-link-alt" />
|
||||
</LinkButton>
|
||||
|
||||
</ButtonGroup>
|
||||
</td>
|
||||
<td>
|
||||
<ButtonGroup>
|
||||
<LinkButton
|
||||
fill="text"
|
||||
href={`/d/${pd.dashboardUid}?shareView=share`}
|
||||
|
Loading…
Reference in New Issue
Block a user