mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Public Dashboards: Usage Insights (#52768)
This commit is contained in:
parent
5e4d5eb14b
commit
dc23643bee
@ -11,6 +11,7 @@ export interface DashboardInfo {
|
|||||||
dashboardUid: string;
|
dashboardUid: string;
|
||||||
dashboardName: string;
|
dashboardName: string;
|
||||||
folderName?: string;
|
folderName?: string;
|
||||||
|
publicDashboardUid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -34,6 +34,7 @@ type DashboardMeta struct {
|
|||||||
ProvisionedExternalId string `json:"provisionedExternalId"`
|
ProvisionedExternalId string `json:"provisionedExternalId"`
|
||||||
AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"`
|
AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"`
|
||||||
PublicDashboardAccessToken string `json:"publicDashboardAccessToken"`
|
PublicDashboardAccessToken string `json:"publicDashboardAccessToken"`
|
||||||
|
PublicDashboardUID string `json:"publicDashboardUid"`
|
||||||
PublicDashboardEnabled bool `json:"publicDashboardEnabled"`
|
PublicDashboardEnabled bool `json:"publicDashboardEnabled"`
|
||||||
}
|
}
|
||||||
type AnnotationPermission struct {
|
type AnnotationPermission struct {
|
||||||
|
@ -79,6 +79,11 @@ func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response {
|
|||||||
return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard", err)
|
return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pubDash, err := api.PublicDashboardService.GetPublicDashboardConfig(c.Req.Context(), dash.OrgId, dash.Uid)
|
||||||
|
if err != nil {
|
||||||
|
return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard config", err)
|
||||||
|
}
|
||||||
|
|
||||||
meta := dtos.DashboardMeta{
|
meta := dtos.DashboardMeta{
|
||||||
Slug: dash.Slug,
|
Slug: dash.Slug,
|
||||||
Type: models.DashTypeDB,
|
Type: models.DashTypeDB,
|
||||||
@ -93,6 +98,7 @@ func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response {
|
|||||||
IsFolder: false,
|
IsFolder: false,
|
||||||
FolderId: dash.FolderId,
|
FolderId: dash.FolderId,
|
||||||
PublicDashboardAccessToken: accessToken,
|
PublicDashboardAccessToken: accessToken,
|
||||||
|
PublicDashboardUID: pubDash.Uid,
|
||||||
}
|
}
|
||||||
|
|
||||||
dto := dtos.DashboardFullWithMeta{Meta: meta, Dashboard: dash.Data}
|
dto := dtos.DashboardFullWithMeta{Meta: meta, Dashboard: dash.Data}
|
||||||
|
@ -43,6 +43,8 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
|||||||
service := publicdashboards.NewFakePublicDashboardService(t)
|
service := publicdashboards.NewFakePublicDashboardService(t)
|
||||||
service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
||||||
Return(&models.Dashboard{}, nil).Maybe()
|
Return(&models.Dashboard{}, nil).Maybe()
|
||||||
|
service.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).
|
||||||
|
Return(&PublicDashboard{}, nil).Maybe()
|
||||||
|
|
||||||
testServer := setupTestServer(t, cfg, qs, featuremgmt.WithFeatures(), service, nil)
|
testServer := setupTestServer(t, cfg, qs, featuremgmt.WithFeatures(), service, nil)
|
||||||
|
|
||||||
@ -95,6 +97,8 @@ func TestAPIGetPublicDashboard(t *testing.T) {
|
|||||||
service := publicdashboards.NewFakePublicDashboardService(t)
|
service := publicdashboards.NewFakePublicDashboardService(t)
|
||||||
service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")).
|
||||||
Return(test.PublicDashboardResult, test.PublicDashboardErr).Maybe()
|
Return(test.PublicDashboardResult, test.PublicDashboardErr).Maybe()
|
||||||
|
service.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")).
|
||||||
|
Return(&PublicDashboard{}, nil).Maybe()
|
||||||
|
|
||||||
testServer := setupTestServer(
|
testServer := setupTestServer(
|
||||||
t,
|
t,
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||||
|
"github.com/grafana/grafana/pkg/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetPublicDashboardFlag() func(c *models.ReqContext) {
|
func SetPublicDashboardFlag() func(c *models.ReqContext) {
|
||||||
@ -11,6 +15,31 @@ func SetPublicDashboardFlag() func(c *models.ReqContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RequiresValidAccessToken(publicDashboardService publicdashboards.Service) func(c *models.ReqContext) {
|
||||||
|
return func(c *models.ReqContext) {
|
||||||
|
accessToken, ok := web.Params(c.Req)[":accessToken"]
|
||||||
|
|
||||||
|
// Check access token is present on the request
|
||||||
|
if !ok || accessToken == "" {
|
||||||
|
c.JsonApiErr(http.StatusBadRequest, "Invalid access token", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the access token references an enabled public dashboard
|
||||||
|
exists, err := publicDashboardService.AccessTokenExists(c.Req.Context(), accessToken)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.JsonApiErr(http.StatusInternalServerError, "Error validating access token", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
c.JsonApiErr(http.StatusBadRequest, "Invalid access token", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func CountPublicDashboardRequest() func(c *models.ReqContext) {
|
func CountPublicDashboardRequest() func(c *models.ReqContext) {
|
||||||
return func(c *models.ReqContext) {
|
return func(c *models.ReqContext) {
|
||||||
metrics.MPublicDashboardRequestCount.Inc()
|
metrics.MPublicDashboardRequestCount.Inc()
|
||||||
|
75
pkg/services/publicdashboards/api/middleware_test.go
Normal file
75
pkg/services/publicdashboards/api/middleware_test.go
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
|
||||||
|
"github.com/grafana/grafana/pkg/services/publicdashboards"
|
||||||
|
publicdashboardsService "github.com/grafana/grafana/pkg/services/publicdashboards/service"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/grafana/grafana/pkg/web"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequiresValidAccessToken(t *testing.T) {
|
||||||
|
t.Run("Returns 404 when access token is empty", func(t *testing.T) {
|
||||||
|
request, err := http.NewRequest("GET", "/api/public/ma/events/", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp := runMiddleware(request, mockAccessTokenExistsResponse(false, nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusNotFound, resp.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns 200 when public dashboard with access token exists", func(t *testing.T) {
|
||||||
|
request, err := http.NewRequest("GET", "/api/public/ma/events/myAccessToken", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp := runMiddleware(request, mockAccessTokenExistsResponse(true, nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, resp.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns 400 when public dashboard with access token does not exist", func(t *testing.T) {
|
||||||
|
request, err := http.NewRequest("GET", "/api/public/ma/events/myAccessToken", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp := runMiddleware(request, mockAccessTokenExistsResponse(false, nil))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Returns 500 when public dashboard service gives an error", func(t *testing.T) {
|
||||||
|
request, err := http.NewRequest("GET", "/api/public/ma/events/myAccessToken", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp := runMiddleware(request, mockAccessTokenExistsResponse(false, fmt.Errorf("error not found")))
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusInternalServerError, resp.Code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockAccessTokenExistsResponse(returnArguments ...interface{}) *publicdashboardsService.PublicDashboardServiceImpl {
|
||||||
|
fakeStore := &publicdashboards.FakePublicDashboardStore{}
|
||||||
|
fakeStore.On("AccessTokenExists", mock.Anything, mock.Anything).Return(returnArguments[0], returnArguments[1])
|
||||||
|
return publicdashboardsService.ProvideService(setting.NewCfg(), fakeStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMiddleware(request *http.Request, pubdashService *publicdashboardsService.PublicDashboardServiceImpl) *httptest.ResponseRecorder {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
m := web.New()
|
||||||
|
initCtx := &models.ReqContext{}
|
||||||
|
m.Use(func(c *web.Context) {
|
||||||
|
initCtx.Context = c
|
||||||
|
c.Req = c.Req.WithContext(ctxkey.Set(c.Req.Context(), initCtx))
|
||||||
|
})
|
||||||
|
m.Get("/api/public/ma/events/:accessToken", RequiresValidAccessToken(pubdashService))
|
||||||
|
m.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
return recorder
|
||||||
|
}
|
@ -197,3 +197,21 @@ func (d *PublicDashboardStoreImpl) PublicDashboardEnabled(ctx context.Context, d
|
|||||||
|
|
||||||
return hasPublicDashboard, err
|
return hasPublicDashboard, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *PublicDashboardStoreImpl) AccessTokenExists(ctx context.Context, accessToken string) (bool, error) {
|
||||||
|
hasPublicDashboard := false
|
||||||
|
err := d.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
|
||||||
|
sql := "SELECT COUNT(*) FROM dashboard_public WHERE access_token=? AND is_enabled=true"
|
||||||
|
|
||||||
|
result, err := dbSession.SQL(sql, accessToken).Count()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPublicDashboard = result > 0
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
return hasPublicDashboard, err
|
||||||
|
}
|
||||||
|
@ -64,6 +64,38 @@ func TestIntegrationGetPublicDashboard(t *testing.T) {
|
|||||||
publicdashboardStore = ProvideStore(sqlStore)
|
publicdashboardStore = ProvideStore(sqlStore)
|
||||||
savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true)
|
savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true)
|
||||||
}
|
}
|
||||||
|
t.Run("AccessTokenExists will return true when at least one public dashboard has a matching access token", func(t *testing.T) {
|
||||||
|
setup()
|
||||||
|
|
||||||
|
_, err := publicdashboardStore.SavePublicDashboardConfig(context.Background(), SavePublicDashboardConfigCommand{
|
||||||
|
DashboardUid: savedDashboard.Uid,
|
||||||
|
OrgId: savedDashboard.OrgId,
|
||||||
|
PublicDashboard: PublicDashboard{
|
||||||
|
IsEnabled: true,
|
||||||
|
Uid: "abc123",
|
||||||
|
DashboardUid: savedDashboard.Uid,
|
||||||
|
OrgId: savedDashboard.OrgId,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
CreatedBy: 7,
|
||||||
|
AccessToken: "accessToken",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
res, err := publicdashboardStore.AccessTokenExists(context.Background(), "accessToken")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.True(t, res)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AccessTokenExists will return false when no public dashboard has matching access token", func(t *testing.T) {
|
||||||
|
setup()
|
||||||
|
|
||||||
|
res, err := publicdashboardStore.AccessTokenExists(context.Background(), "accessToken")
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, res)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("PublicDashboardEnabled Will return true when dashboard has at least one enabled public dashboard", func(t *testing.T) {
|
t.Run("PublicDashboardEnabled Will return true when dashboard has at least one enabled public dashboard", func(t *testing.T) {
|
||||||
setup()
|
setup()
|
||||||
|
@ -21,6 +21,27 @@ type FakePublicDashboardService struct {
|
|||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccessTokenExists provides a mock function with given fields: ctx, accessToken
|
||||||
|
func (_m *FakePublicDashboardService) AccessTokenExists(ctx context.Context, accessToken string) (bool, error) {
|
||||||
|
ret := _m.Called(ctx, accessToken)
|
||||||
|
|
||||||
|
var r0 bool
|
||||||
|
if rf, ok := ret.Get(0).(func(context.Context, string) bool); ok {
|
||||||
|
r0 = rf(ctx, accessToken)
|
||||||
|
} else {
|
||||||
|
r0 = ret.Get(0).(bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
var r1 error
|
||||||
|
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||||
|
r1 = rf(ctx, accessToken)
|
||||||
|
} else {
|
||||||
|
r1 = ret.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0, r1
|
||||||
|
}
|
||||||
|
|
||||||
// BuildAnonymousUser provides a mock function with given fields: ctx, dashboard
|
// BuildAnonymousUser provides a mock function with given fields: ctx, dashboard
|
||||||
func (_m *FakePublicDashboardService) BuildAnonymousUser(ctx context.Context, dashboard *models.Dashboard) (*user.SignedInUser, error) {
|
func (_m *FakePublicDashboardService) BuildAnonymousUser(ctx context.Context, dashboard *models.Dashboard) (*user.SignedInUser, error) {
|
||||||
ret := _m.Called(ctx, dashboard)
|
ret := _m.Called(ctx, dashboard)
|
||||||
|
@ -18,6 +18,27 @@ type FakePublicDashboardStore struct {
|
|||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AccessTokenExists provides a mock function with given fields: ctx, accessToken
|
||||||
|
func (_m *FakePublicDashboardStore) AccessTokenExists(ctx context.Context, accessToken string) (bool, error) {
|
||||||
|
ret := _m.Called(ctx, accessToken)
|
||||||
|
|
||||||
|
var r0 bool
|
||||||
|
if rf, ok := ret.Get(0).(func(context.Context, string) bool); ok {
|
||||||
|
r0 = rf(ctx, accessToken)
|
||||||
|
} else {
|
||||||
|
r0 = ret.Get(0).(bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
var r1 error
|
||||||
|
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||||
|
r1 = rf(ctx, accessToken)
|
||||||
|
} else {
|
||||||
|
r1 = ret.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r0, r1
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateNewPublicDashboardUid provides a mock function with given fields: ctx
|
// GenerateNewPublicDashboardUid provides a mock function with given fields: ctx
|
||||||
func (_m *FakePublicDashboardStore) GenerateNewPublicDashboardUid(ctx context.Context) (string, error) {
|
func (_m *FakePublicDashboardStore) GenerateNewPublicDashboardUid(ctx context.Context) (string, error) {
|
||||||
ret := _m.Called(ctx)
|
ret := _m.Called(ctx)
|
||||||
|
@ -20,6 +20,7 @@ type Service interface {
|
|||||||
SavePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error)
|
SavePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error)
|
||||||
BuildPublicDashboardMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *PublicDashboard, panelId int64) (dtos.MetricRequest, error)
|
BuildPublicDashboardMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *PublicDashboard, panelId int64) (dtos.MetricRequest, error)
|
||||||
PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error)
|
PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error)
|
||||||
|
AccessTokenExists(ctx context.Context, accessToken string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:generate mockery --name Store --structname FakePublicDashboardStore --inpackage --filename public_dashboard_store_mock.go
|
//go:generate mockery --name Store --structname FakePublicDashboardStore --inpackage --filename public_dashboard_store_mock.go
|
||||||
@ -31,4 +32,5 @@ type Store interface {
|
|||||||
SavePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) (*PublicDashboard, error)
|
SavePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) (*PublicDashboard, error)
|
||||||
UpdatePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) error
|
UpdatePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) error
|
||||||
PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error)
|
PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error)
|
||||||
|
AccessTokenExists(ctx context.Context, accessToken string) (bool, error)
|
||||||
}
|
}
|
||||||
|
@ -211,6 +211,10 @@ func (pd *PublicDashboardServiceImpl) PublicDashboardEnabled(ctx context.Context
|
|||||||
return pd.store.PublicDashboardEnabled(ctx, dashboardUid)
|
return pd.store.PublicDashboardEnabled(ctx, dashboardUid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pd *PublicDashboardServiceImpl) AccessTokenExists(ctx context.Context, accessToken string) (bool, error) {
|
||||||
|
return pd.store.AccessTokenExists(ctx, accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
// generates a uuid formatted without dashes to use as access token
|
// generates a uuid formatted without dashes to use as access token
|
||||||
func GenerateAccessToken() (string, error) {
|
func GenerateAccessToken() (string, error) {
|
||||||
token, err := uuid.NewRandom()
|
token, err := uuid.NewRandom()
|
||||||
|
@ -9,6 +9,7 @@ export function emitDashboardViewEvent(dashboard: DashboardModel) {
|
|||||||
dashboardUid: dashboard.uid,
|
dashboardUid: dashboard.uid,
|
||||||
folderName: dashboard.meta.folderTitle,
|
folderName: dashboard.meta.folderTitle,
|
||||||
eventName: MetaAnalyticsEventName.DashboardView,
|
eventName: MetaAnalyticsEventName.DashboardView,
|
||||||
|
publicDashboardUid: dashboard.meta.publicDashboardUid,
|
||||||
};
|
};
|
||||||
|
|
||||||
reportMetaAnalytics(eventData);
|
reportMetaAnalytics(eventData);
|
||||||
|
@ -50,6 +50,7 @@ export function emitDataRequestEvent(datasource: DataSourceApi) {
|
|||||||
eventData.dashboardName = dashboard.title;
|
eventData.dashboardName = dashboard.title;
|
||||||
eventData.dashboardUid = dashboard.uid;
|
eventData.dashboardUid = dashboard.uid;
|
||||||
eventData.folderName = dashboard.meta.folderTitle;
|
eventData.folderName = dashboard.meta.folderTitle;
|
||||||
|
eventData.publicDashboardUid = dashboard.meta.publicDashboardUid;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.series && data.series.length > 0) {
|
if (data.series && data.series.length > 0) {
|
||||||
|
@ -42,6 +42,7 @@ export interface DashboardMeta {
|
|||||||
hasUnsavedFolderChange?: boolean;
|
hasUnsavedFolderChange?: boolean;
|
||||||
annotationsPermissions?: AnnotationsPermissions;
|
annotationsPermissions?: AnnotationsPermissions;
|
||||||
publicDashboardAccessToken?: string;
|
publicDashboardAccessToken?: string;
|
||||||
|
publicDashboardUid?: string;
|
||||||
publicDashboardEnabled?: boolean;
|
publicDashboardEnabled?: boolean;
|
||||||
dashboardNotFound?: boolean;
|
dashboardNotFound?: boolean;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user