Public Dashboards: Add audit table (#54508)

This PR adds an audit table for public dashboards allowing a user to view all public dashboards on an instance of grafana. The public dashboards team is working on a proposal for adding RBAC support to the audit table for 9.3

Co-authored-by: juanicabanas <juan.cabanas@grafana.com>
This commit is contained in:
Jeff Levin
2022-10-12 21:36:05 -08:00
committed by GitHub
parent c7c640d903
commit cc27214dca
20 changed files with 516 additions and 44 deletions

View File

@ -140,6 +140,10 @@ func (hs *HTTPServer) registerRoutes() {
} }
if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) { if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) {
// list public dashboards
r.Get("/public-dashboards/list", reqSignedIn, hs.Index)
// anonymous view public dashboard
r.Get("/public-dashboards/:accessToken", r.Get("/public-dashboards/:accessToken",
publicdashboardsapi.SetPublicDashboardFlag, publicdashboardsapi.SetPublicDashboardFlag,
publicdashboardsapi.SetPublicDashboardOrgIdOnContext(hs.PublicDashboardsApi.PublicDashboardService), publicdashboardsapi.SetPublicDashboardOrgIdOnContext(hs.PublicDashboardsApi.PublicDashboardService),

View File

@ -381,6 +381,15 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
Url: s.cfg.AppSubURL + "/library-panels", Url: s.cfg.AppSubURL + "/library-panels",
Icon: "library-panel", Icon: "library-panel",
}) })
if s.features.IsEnabled(featuremgmt.FlagPublicDashboards) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Public dashboards",
Id: "dashboards/public",
Url: s.cfg.AppSubURL + "/dashboard/public",
Icon: "library-panel",
})
}
} }
if s.features.IsEnabled(featuremgmt.FlagScenes) { if s.features.IsEnabled(featuremgmt.FlagScenes) {

View File

@ -63,9 +63,11 @@ func (api *Api) RegisterAPIEndpoints() {
api.RouteRegister.Get("/api/public/dashboards/:accessToken", routing.Wrap(api.GetPublicDashboard)) api.RouteRegister.Get("/api/public/dashboards/:accessToken", routing.Wrap(api.GetPublicDashboard))
api.RouteRegister.Post("/api/public/dashboards/:accessToken/panels/:panelId/query", routing.Wrap(api.QueryPublicDashboard)) api.RouteRegister.Post("/api/public/dashboards/:accessToken/panels/:panelId/query", routing.Wrap(api.QueryPublicDashboard))
// List Public Dashboards
api.RouteRegister.Get("/api/dashboards/public", middleware.ReqSignedIn, routing.Wrap(api.ListPublicDashboards))
// Create/Update Public Dashboard // Create/Update Public Dashboard
uidScope := dashboards.ScopeDashboardsProvider.GetResourceScopeUID(accesscontrol.Parameter(":uid")) uidScope := dashboards.ScopeDashboardsProvider.GetResourceScopeUID(accesscontrol.Parameter(":uid"))
api.RouteRegister.Get("/api/dashboards/uid/:uid/public-config", api.RouteRegister.Get("/api/dashboards/uid/:uid/public-config",
auth(middleware.ReqSignedIn, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, uidScope)), auth(middleware.ReqSignedIn, accesscontrol.EvalPermission(dashboards.ActionDashboardsRead, uidScope)),
routing.Wrap(api.GetPublicDashboardConfig)) routing.Wrap(api.GetPublicDashboardConfig))
@ -111,6 +113,16 @@ func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response {
return response.JSON(http.StatusOK, dto) return response.JSON(http.StatusOK, dto)
} }
// 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)
if err != nil {
return api.handleError(http.StatusInternalServerError, "failed to list public dashboards", err)
}
return response.JSON(http.StatusOK, resp)
}
// Gets public dashboard configuration for dashboard // Gets public dashboard configuration for dashboard
// GET /api/dashboards/uid/:uid/public-config // GET /api/dashboards/uid/:uid/public-config
func (api *Api) GetPublicDashboardConfig(c *models.ReqContext) response.Response { func (api *Api) GetPublicDashboardConfig(c *models.ReqContext) response.Response {

View File

@ -46,30 +46,129 @@ var userViewer = &user.SignedInUser{UserID: 3, OrgID: 1, OrgRole: org.RoleViewer
var userViewerRBAC = &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleViewer, Login: "testViewerUserRBAC", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}}}} var userViewerRBAC = &user.SignedInUser{UserID: 4, OrgID: 1, OrgRole: org.RoleViewer, Login: "testViewerUserRBAC", Permissions: map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}}}}
var anonymousUser *user.SignedInUser var anonymousUser *user.SignedInUser
func TestAPIGetPublicDashboard(t *testing.T) { type JsonErrResponse struct {
t.Run("It should 404 if featureflag is not enabled", func(t *testing.T) { Error string `json:"error"`
}
func TestAPIFeatureFlag(t *testing.T) {
testCases := []struct {
Name string
Method string
Path string
}{
{
Name: "API: Load Dashboard",
Method: http.MethodGet,
Path: "/api/public/dashboards/acbc123",
},
{
Name: "API: Query Dashboard",
Method: http.MethodGet,
Path: "/api/public/dashboards/abc123/panels/2/query",
},
{
Name: "API: List Dashboards",
Method: http.MethodGet,
Path: "/api/dashboards/public",
},
{
Name: "API: Get Public Dashboard Config",
Method: http.MethodPost,
Path: "/api/dashboards/uid/abc123/public-config",
},
{
Name: "API: Upate Public Dashboard",
Method: http.MethodPost,
Path: "/api/dashboards/uid/abc123/public-config",
},
}
for _, test := range testCases {
t.Run(test.Name, func(t *testing.T) {
cfg := setting.NewCfg() cfg := setting.NewCfg()
cfg.RBACEnabled = false cfg.RBACEnabled = false
service := publicdashboards.NewFakePublicDashboardService(t) service := publicdashboards.NewFakePublicDashboardService(t)
service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")). features := featuremgmt.WithFeatures()
Return(&PublicDashboard{}, &models.Dashboard{}, nil).Maybe() testServer := setupTestServer(t, cfg, features, service, nil, userAdmin)
service.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")). response := callAPI(testServer, test.Method, test.Path, nil, t)
Return(&PublicDashboard{}, nil).Maybe()
testServer := setupTestServer(t, cfg, featuremgmt.WithFeatures(), service, nil, anonymousUser)
response := callAPI(testServer, http.MethodGet, "/api/public/dashboards", nil, t)
assert.Equal(t, http.StatusNotFound, response.Code) assert.Equal(t, http.StatusNotFound, response.Code)
response = callAPI(testServer, http.MethodGet, "/api/public/dashboards/asdf", nil, t)
assert.Equal(t, http.StatusNotFound, response.Code)
// control set. make sure routes are mounted
testServer = setupTestServer(t, cfg, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), service, nil, userAdmin)
response = callAPI(testServer, http.MethodGet, "/api/public/dashboards/asdf", nil, t)
assert.NotEqual(t, http.StatusNotFound, response.Code)
}) })
}
}
func TestAPIListPublicDashboard(t *testing.T) {
successResp := []PublicDashboardListResponse{
{
Uid: "1234asdfasdf",
AccessToken: "asdfasdf",
DashboardUid: "abc1234",
IsEnabled: true,
},
}
testCases := []struct {
Name string
User *user.SignedInUser
Response []PublicDashboardListResponse
ResponseErr error
ExpectedHttpResponse int
}{
{
Name: "Anonymous user cannot list dashboards",
User: anonymousUser,
Response: successResp,
ResponseErr: nil,
ExpectedHttpResponse: http.StatusUnauthorized,
},
{
Name: "User viewer can see public dashboards",
User: userViewer,
Response: successResp,
ResponseErr: nil,
ExpectedHttpResponse: http.StatusOK,
},
{
Name: "Handles Service error",
User: userViewer,
Response: nil,
ResponseErr: errors.New("error, service broken"),
ExpectedHttpResponse: http.StatusInternalServerError,
},
}
for _, test := range testCases {
t.Run(test.Name, func(t *testing.T) {
service := publicdashboards.NewFakePublicDashboardService(t)
service.On("ListPublicDashboards", mock.Anything, mock.Anything).
Return(test.Response, test.ResponseErr).Maybe()
cfg := setting.NewCfg()
cfg.RBACEnabled = false
features := featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards)
testServer := setupTestServer(t, cfg, features, service, nil, test.User)
response := callAPI(testServer, http.MethodGet, "/api/dashboards/public", nil, t)
assert.Equal(t, test.ExpectedHttpResponse, response.Code)
if test.ExpectedHttpResponse == http.StatusOK {
var jsonResp []PublicDashboardListResponse
err := json.Unmarshal(response.Body.Bytes(), &jsonResp)
require.NoError(t, err)
assert.Equal(t, jsonResp[0].Uid, "1234asdfasdf")
}
if test.ResponseErr != nil {
var errResp JsonErrResponse
err := json.Unmarshal(response.Body.Bytes(), &errResp)
require.NoError(t, err)
assert.Equal(t, "error, service broken", errResp.Error)
service.AssertNotCalled(t, "ListPublicDashboards")
}
})
}
}
func TestAPIGetPublicDashboard(t *testing.T) {
DashboardUid := "dashboard-abcd1234" DashboardUid := "dashboard-abcd1234"
token, err := uuid.NewRandom() token, err := uuid.NewRandom()
require.NoError(t, err) require.NoError(t, err)
@ -138,9 +237,7 @@ func TestAPIGetPublicDashboard(t *testing.T) {
assert.Equal(t, false, dashResp.Meta.CanDelete) assert.Equal(t, false, dashResp.Meta.CanDelete)
assert.Equal(t, false, dashResp.Meta.CanSave) assert.Equal(t, false, dashResp.Meta.CanSave)
} else { } else {
var errResp struct { var errResp JsonErrResponse
Error string `json:"error"`
}
err := json.Unmarshal(response.Body.Bytes(), &errResp) err := json.Unmarshal(response.Body.Bytes(), &errResp)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, test.Err.Error(), errResp.Error) assert.Equal(t, test.Err.Error(), errResp.Error)
@ -435,12 +532,6 @@ func TestAPIQueryPublicDashboard(t *testing.T) {
return testServer, service return testServer, service
} }
t.Run("Status code is 404 when feature toggle is disabled", func(t *testing.T) {
server, _ := setup(false)
resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/2/query", strings.NewReader("{}"), t)
require.Equal(t, http.StatusNotFound, resp.Code)
})
t.Run("Status code is 400 when the panel ID is invalid", func(t *testing.T) { t.Run("Status code is 400 when the panel ID is invalid", func(t *testing.T) {
server, _ := setup(true) server, _ := setup(true)
resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/notanumber/query", strings.NewReader("{}"), t) resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/notanumber/query", strings.NewReader("{}"), t)

View File

@ -37,6 +37,28 @@ func ProvideStore(sqlStore *sqlstore.SQLStore) *PublicDashboardStoreImpl {
} }
} }
// Gets list of public dashboards by orgId
func (d *PublicDashboardStoreImpl) ListPublicDashboards(ctx context.Context, orgId int64) ([]PublicDashboardListResponse, error) {
resp := make([]PublicDashboardListResponse, 0)
err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
sess.Table("dashboard_public").
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")
err := sess.Find(&resp)
return err
})
if err != nil {
return nil, err
}
return resp, nil
}
func (d *PublicDashboardStoreImpl) GetDashboard(ctx context.Context, dashboardUid string) (*models.Dashboard, error) { func (d *PublicDashboardStoreImpl) GetDashboard(ctx context.Context, dashboardUid string) (*models.Dashboard, error) {
dashboard := &models.Dashboard{Uid: dashboardUid} dashboard := &models.Dashboard{Uid: dashboardUid}
err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {

View File

@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
dashboardsDB "github.com/grafana/grafana/pkg/services/dashboards/database" dashboardsDB "github.com/grafana/grafana/pkg/services/dashboards/database"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens"
. "github.com/grafana/grafana/pkg/services/publicdashboards/models" . "github.com/grafana/grafana/pkg/services/publicdashboards/models"
"github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/tag/tagimpl"
@ -29,6 +30,39 @@ func TestLogPrefix(t *testing.T) {
assert.Equal(t, LogPrefix, "publicdashboards.store") assert.Equal(t, LogPrefix, "publicdashboards.store")
} }
func TestIntegrationListPublicDashboard(t *testing.T) {
sqlStore := sqlstore.InitTestDB(t, sqlstore.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}})
dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore, sqlStore.Cfg))
publicdashboardStore := ProvideStore(sqlStore)
var orgId int64 = 1
aDash := insertTestDashboard(t, dashboardStore, "a", orgId, 0, true)
bDash := insertTestDashboard(t, dashboardStore, "b", orgId, 0, true)
cDash := insertTestDashboard(t, dashboardStore, "c", orgId, 0, true)
// these are in order of how they should be returned from ListPUblicDashboards
a := insertPublicDashboard(t, publicdashboardStore, bDash.Uid, orgId, true)
b := insertPublicDashboard(t, publicdashboardStore, cDash.Uid, orgId, true)
c := insertPublicDashboard(t, publicdashboardStore, aDash.Uid, orgId, false)
// this is case that can happen as of now, however, postgres and mysql sort
// null in the exact opposite fashion and there is no shared syntax to sort
// nulls in the same way in all 3 db's.
//d := insertPublicDashboard(t, publicdashboardStore, "missing", orgId, false)
// should not be included in response
_ = insertPublicDashboard(t, publicdashboardStore, "wrongOrgId", 777, false)
resp, err := publicdashboardStore.ListPublicDashboards(context.Background(), orgId)
require.NoError(t, err)
assert.Len(t, resp, 3)
assert.Equal(t, resp[0].Uid, a.Uid)
assert.Equal(t, resp[1].Uid, b.Uid)
assert.Equal(t, resp[2].Uid, c.Uid)
}
func TestIntegrationGetDashboard(t *testing.T) { func TestIntegrationGetDashboard(t *testing.T) {
var sqlStore *sqlstore.SQLStore var sqlStore *sqlstore.SQLStore
var dashboardStore *dashboardsDB.DashboardStore var dashboardStore *dashboardsDB.DashboardStore
@ -506,7 +540,7 @@ func TestIntegrationGetPublicDashboardOrgId(t *testing.T) {
}) })
} }
// helper function insertTestDashboard // helper function to insert a dashboard
func insertTestDashboard(t *testing.T, dashboardStore *dashboardsDB.DashboardStore, title string, orgId int64, func insertTestDashboard(t *testing.T, dashboardStore *dashboardsDB.DashboardStore, title string, orgId int64,
folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard { folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard {
t.Helper() t.Helper()
@ -527,3 +561,35 @@ func insertTestDashboard(t *testing.T, dashboardStore *dashboardsDB.DashboardSto
dash.Data.Set("uid", dash.Uid) dash.Data.Set("uid", dash.Uid)
return dash return dash
} }
// helper function to insert a public dashboard
func insertPublicDashboard(t *testing.T, publicdashboardStore *PublicDashboardStoreImpl, dashboardUid string, orgId int64, isEnabled bool) *PublicDashboard {
ctx := context.Background()
uid, err := publicdashboardStore.GenerateNewPublicDashboardUid(ctx)
require.NoError(t, err)
accessToken, err := tokens.GenerateAccessToken()
require.NoError(t, err)
cmd := SavePublicDashboardConfigCommand{
PublicDashboard: PublicDashboard{
Uid: uid,
DashboardUid: dashboardUid,
OrgId: orgId,
IsEnabled: isEnabled,
TimeSettings: &TimeSettings{},
CreatedBy: 1,
CreatedAt: time.Now(),
AccessToken: accessToken,
},
}
err = publicdashboardStore.SavePublicDashboardConfig(ctx, cmd)
require.NoError(t, err)
pubdash, err := publicdashboardStore.GetPublicDashboardByUid(ctx, uid)
require.NoError(t, err)
return pubdash
}

View File

@ -81,6 +81,14 @@ func (pd PublicDashboard) TableName() string {
return "dashboard_public" return "dashboard_public"
} }
type PublicDashboardListResponse struct {
Uid string `json:"uid" xorm:"uid"`
AccessToken string `json:"accessToken" xorm:"access_token"`
Title string `json:"title" xorm:"title"`
DashboardUid string `json:"dashboardUid" xorm:"dashboard_uid"`
IsEnabled bool `json:"isEnabled" xorm:"is_enabled"`
}
type TimeSettings struct { type TimeSettings struct {
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
To string `json:"to,omitempty"` To string `json:"to,omitempty"`

View File

@ -212,6 +212,29 @@ func (_m *FakePublicDashboardService) GetQueryDataResponse(ctx context.Context,
return r0, r1 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)
var r0 []publicdashboardsmodels.PublicDashboardListResponse
if rf, ok := ret.Get(0).(func(context.Context, int64) []publicdashboardsmodels.PublicDashboardListResponse); ok {
r0 = rf(ctx, orgId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]publicdashboardsmodels.PublicDashboardListResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
r1 = rf(ctx, orgId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// PublicDashboardEnabled provides a mock function with given fields: ctx, dashboardUid // PublicDashboardEnabled provides a mock function with given fields: ctx, dashboardUid
func (_m *FakePublicDashboardService) PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) { func (_m *FakePublicDashboardService) PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) {
ret := _m.Called(ctx, dashboardUid) ret := _m.Called(ctx, dashboardUid)

View File

@ -182,6 +182,29 @@ func (_m *FakePublicDashboardStore) GetPublicDashboardOrgId(ctx context.Context,
return r0, r1 return r0, r1
} }
// ListPublicDashboards provides a mock function with given fields: ctx, orgId
func (_m *FakePublicDashboardStore) ListPublicDashboards(ctx context.Context, orgId int64) ([]publicdashboardsmodels.PublicDashboardListResponse, error) {
ret := _m.Called(ctx, orgId)
var r0 []publicdashboardsmodels.PublicDashboardListResponse
if rf, ok := ret.Get(0).(func(context.Context, int64) []publicdashboardsmodels.PublicDashboardListResponse); ok {
r0 = rf(ctx, orgId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]publicdashboardsmodels.PublicDashboardListResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
r1 = rf(ctx, orgId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// PublicDashboardEnabled provides a mock function with given fields: ctx, dashboardUid // PublicDashboardEnabled provides a mock function with given fields: ctx, dashboardUid
func (_m *FakePublicDashboardStore) PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) { func (_m *FakePublicDashboardStore) PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) {
ret := _m.Called(ctx, dashboardUid) ret := _m.Called(ctx, dashboardUid)

View File

@ -22,6 +22,7 @@ type Service interface {
GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error)
GetPublicDashboardOrgId(ctx context.Context, accessToken string) (int64, error) GetPublicDashboardOrgId(ctx context.Context, accessToken string) (int64, error)
GetQueryDataResponse(ctx context.Context, skipCache bool, reqDTO PublicDashboardQueryDTO, panelId int64, accessToken string) (*backend.QueryDataResponse, error) GetQueryDataResponse(ctx context.Context, skipCache bool, reqDTO PublicDashboardQueryDTO, panelId int64, accessToken string) (*backend.QueryDataResponse, error)
ListPublicDashboards(ctx context.Context, orgId int64) ([]PublicDashboardListResponse, error)
PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error)
SavePublicDashboardConfig(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error) SavePublicDashboardConfig(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error)
} }
@ -35,6 +36,7 @@ type Store interface {
GetPublicDashboardByUid(ctx context.Context, uid string) (*PublicDashboard, error) GetPublicDashboardByUid(ctx context.Context, uid string) (*PublicDashboard, error)
GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error)
GetPublicDashboardOrgId(ctx context.Context, accessToken string) (int64, error) GetPublicDashboardOrgId(ctx context.Context, accessToken string) (int64, error)
ListPublicDashboards(ctx context.Context, orgId int64) ([]PublicDashboardListResponse, error)
PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error)
SavePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) error SavePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) error
UpdatePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) error UpdatePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) error

View File

@ -54,6 +54,12 @@ func ProvideService(
} }
} }
// 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) { func (pd *PublicDashboardServiceImpl) GetDashboard(ctx context.Context, dashboardUid string) (*models.Dashboard, error) {
dashboard, err := pd.store.GetDashboard(ctx, dashboardUid) dashboard, err := pd.store.GetDashboard(ctx, dashboardUid)

View File

@ -1,4 +1,4 @@
import React, { FC, ReactNode } from 'react'; import React, { FC, ReactNode, useContext, useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
@ -13,6 +13,7 @@ import {
useForceUpdate, useForceUpdate,
Tag, Tag,
ToolbarButtonRow, ToolbarButtonRow,
ModalsContext,
} from '@grafana/ui'; } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbarSeparator'; import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbarSeparator';
@ -51,6 +52,7 @@ export interface OwnProps {
hideTimePicker: boolean; hideTimePicker: boolean;
folderTitle?: string; folderTitle?: string;
title: string; title: string;
shareModalActiveTab?: string;
onAddPanel: () => void; onAddPanel: () => void;
} }
@ -76,6 +78,7 @@ type Props = OwnProps & ConnectedProps<typeof connector>;
export const DashNav = React.memo<Props>((props) => { export const DashNav = React.memo<Props>((props) => {
const forceUpdate = useForceUpdate(); const forceUpdate = useForceUpdate();
const { chrome } = useGrafana(); const { chrome } = useGrafana();
const { showModal, hideModal } = useContext(ModalsContext);
// We don't really care about the event payload here only that it triggeres a re-render of this component // We don't really care about the event payload here only that it triggeres a re-render of this component
useBusEvent(props.dashboard.events, DashboardMetaChangedEvent); useBusEvent(props.dashboard.events, DashboardMetaChangedEvent);
@ -128,6 +131,25 @@ export const DashNav = React.memo<Props>((props) => {
return playlistSrv.isPlaying; return playlistSrv.isPlaying;
}; };
// Open/Close
useEffect(() => {
const dashboard = props.dashboard;
const shareModalActiveTab = props.shareModalActiveTab;
const { canShare } = dashboard.meta;
if (canShare && shareModalActiveTab) {
// automagically open modal
showModal(ShareModal, {
dashboard,
onDismiss: hideModal,
activeTab: shareModalActiveTab,
});
}
return () => {
hideModal();
};
}, [showModal, hideModal, props.dashboard, props.shareModalActiveTab]);
const renderLeftActions = () => { const renderLeftActions = () => {
const { dashboard, kioskMode } = props; const { dashboard, kioskMode } = props;
const { canStar, canShare, isStarred } = dashboard.meta; const { canStar, canShare, isStarred } = dashboard.meta;

View File

@ -28,15 +28,16 @@ export function addPanelShareTab(tab: ShareModalTabModel) {
} }
function getInitialState(props: Props): State { function getInitialState(props: Props): State {
const tabs = getTabs(props); const { tabs, activeTab } = getTabs(props);
return { return {
tabs, tabs,
activeTab: tabs[0].value, activeTab,
}; };
} }
function getTabs(props: Props) { function getTabs(props: Props) {
const { panel } = props; const { panel, activeTab } = props;
const linkLabel = t('share-modal.tab-title.link', 'Link'); const linkLabel = t('share-modal.tab-title.link', 'Link');
const tabs: ShareModalTabModel[] = [{ label: linkLabel, value: 'link', component: ShareLink }]; const tabs: ShareModalTabModel[] = [{ label: linkLabel, value: 'link', component: ShareLink }];
@ -65,12 +66,18 @@ function getTabs(props: Props) {
tabs.push({ label: 'Public dashboard', value: 'share', component: SharePublicDashboard }); tabs.push({ label: 'Public dashboard', value: 'share', component: SharePublicDashboard });
} }
return tabs; const at = tabs.find((t) => t.value === activeTab);
return {
tabs,
activeTab: at?.value ?? tabs[0].value,
};
} }
interface Props { interface Props {
dashboard: DashboardModel; dashboard: DashboardModel;
panel?: PanelModel; panel?: PanelModel;
activeTab?: string;
onDismiss(): void; onDismiss(): void;
} }
@ -95,7 +102,7 @@ export class ShareModal extends React.Component<Props, State> {
}; };
getTabs() { getTabs() {
return getTabs(this.props); return getTabs(this.props).tabs;
} }
getActiveTab() { getActiveTab() {
@ -107,13 +114,12 @@ export class ShareModal extends React.Component<Props, State> {
const { panel } = this.props; const { panel } = this.props;
const { activeTab } = this.state; const { activeTab } = this.state;
const title = panel ? t('share-modal.panel.title', 'Share Panel') : t('share-modal.dashboard.title', 'Share'); const title = panel ? t('share-modal.panel.title', 'Share Panel') : t('share-modal.dashboard.title', 'Share');
const tabs = this.getTabs();
return ( return (
<ModalTabsHeader <ModalTabsHeader
title={title} title={title}
icon="share-alt" icon="share-alt"
tabs={tabs} tabs={this.getTabs()}
activeTab={activeTab} activeTab={activeTab}
onChangeTab={this.onSelectTab} onChangeTab={this.onSelectTab}
/> />

View File

@ -18,7 +18,7 @@ import { configureStore } from 'app/store/configureStore';
import { ShareModal } from '../ShareModal'; import { ShareModal } from '../ShareModal';
const server = setupServer( const server = setupServer(
rest.get('/api/dashboards/uid/:uId/public-config', (req, res, ctx) => { rest.get('/api/dashboards/uid/:uId/public-config', (_, res, ctx) => {
return res( return res(
ctx.status(200), ctx.status(200),
ctx.json({ ctx.json({

View File

@ -4,8 +4,8 @@ import { VariableModel } from 'app/features/variables/types';
import { import {
PublicDashboard, PublicDashboard,
dashboardHasTemplateVariables, dashboardHasTemplateVariables,
generatePublicDashboardUrl,
publicDashboardPersisted, publicDashboardPersisted,
generatePublicDashboardUrl,
} from './SharePublicDashboardUtils'; } from './SharePublicDashboardUtils';
describe('dashboardHasTemplateVariables', () => { describe('dashboardHasTemplateVariables', () => {

View File

@ -49,6 +49,7 @@ export type DashboardPageRouteSearchParams = {
editPanel?: string; editPanel?: string;
viewPanel?: string; viewPanel?: string;
editview?: string; editview?: string;
shareView?: string;
panelType?: string; panelType?: string;
inspect?: string; inspect?: string;
from?: string; from?: string;
@ -352,6 +353,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
onAddPanel={this.onAddPanel} onAddPanel={this.onAddPanel}
kioskMode={kioskMode} kioskMode={kioskMode}
hideTimePicker={dashboard.timepicker.hidden} hideTimePicker={dashboard.timepicker.hidden}
shareModalActiveTab={this.props.queryParams.shareView}
/> />
</header> </header>
); );

View File

@ -6,6 +6,17 @@ import { DashboardRoutes } from '../../types';
export const getPublicDashboardRoutes = (): RouteDescriptor[] => { export const getPublicDashboardRoutes = (): RouteDescriptor[] => {
if (config.featureToggles.publicDashboards) { if (config.featureToggles.publicDashboards) {
return [ return [
{
path: '/dashboard/public',
pageClass: 'page-dashboard',
routeName: DashboardRoutes.Public,
component: SafeDynamicImport(
() =>
import(
/* webpackChunkName: "ListPublicDashboardPage" */ '../../features/manage-dashboards/PublicDashboardListPage'
)
),
},
{ {
path: '/public-dashboards/:accessToken', path: '/public-dashboards/:accessToken',
pageClass: 'page-dashboard', pageClass: 'page-dashboard',

View File

@ -0,0 +1,17 @@
import React from 'react';
import { Page } from 'app/core/components/Page/Page';
import { ListPublicDashboardTable } from './components/PublicDashboardListTable';
export const ListPublicDashboardPage = ({}) => {
return (
<Page navId="dashboards/public">
<Page.Contents>
<ListPublicDashboardTable />
</Page.Contents>
</Page>
);
};
export default ListPublicDashboardPage;

View File

@ -0,0 +1,54 @@
import {
LIST_PUBLIC_DASHBOARD_URL,
viewPublicDashboardUrl,
//ListPublicDashboardTable,
} from './PublicDashboardListTable';
//import { render, screen, waitFor, act } from '@testing-library/react';
//import React from 'react';
describe('listPublicDashboardsUrl', () => {
it('has the correct url', () => {
expect(LIST_PUBLIC_DASHBOARD_URL).toEqual('/api/dashboards/public');
});
});
describe('viewPublicDashboardUrl', () => {
it('has the correct url', () => {
expect(viewPublicDashboardUrl('abcd')).toEqual('public-dashboards/abcd');
});
});
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as unknown as object),
getBackendSrv: () => ({
get: jest.fn().mockResolvedValue([
{
uid: 'SdZwuCZVz',
accessToken: 'beeaf92f6ab3467f80b2be922c7741ab',
title: 'New dashboardasdf',
dashboardUid: 'iF36Qb6nz',
isEnabled: false,
},
{
uid: 'EuiEbd3nz',
accessToken: '8687b0498ccf4babb2f92810d8563b33',
title: 'New dashboard',
dashboardUid: 'kFlxbd37k',
isEnabled: true,
},
]),
}),
}));
//describe('ListPublicDashboardTable', () => {
// test('renders properly', async() => {
// act(() => {
// render(<ListPublicDashboardTable />)
// });
// //await waitFor(() => screen.getByRole('table'));
// expect(screen.getByText("Dashboard")).toBeInTheDocument();
// //expect(screen.getAllByRole("tr")).toHaveLength(2);
// })
//})

View File

@ -0,0 +1,94 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { GrafanaTheme2 } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { Link, ButtonGroup, LinkButton, Icon, Tag, useStyles2 } from '@grafana/ui';
import { getConfig } from 'app/core/config';
export interface ListPublicDashboardResponse {
uid: string;
accessToken: string;
dashboardUid: string;
title: string;
isEnabled: boolean;
}
export const LIST_PUBLIC_DASHBOARD_URL = `/api/dashboards/public`;
export const getPublicDashboards = async (): Promise<ListPublicDashboardResponse[]> => {
return getBackendSrv().get(LIST_PUBLIC_DASHBOARD_URL);
};
export const viewPublicDashboardUrl = (accessToken: string): string => {
return `${getConfig().appUrl}public-dashboards/${accessToken}`;
};
export const ListPublicDashboardTable = () => {
const styles = useStyles2(getStyles);
const [publicDashboards, setPublicDashboards] = useState<ListPublicDashboardResponse[]>([]);
useAsync(async () => {
const publicDashboards = await getPublicDashboards();
setPublicDashboards(publicDashboards);
}, [setPublicDashboards]);
return (
<div className="page-action-bar">
<table className="filter-table">
<thead>
<tr>
<th>Dashboard</th>
<th>Public dashboard enabled</th>
<th></th>
</tr>
</thead>
<tbody>
{publicDashboards.map((pd) => (
<tr key={pd.uid}>
<td>
<Link className={styles.link} href={`/d/${pd.dashboardUid}`}>
{pd.title}
</Link>
</td>
<td>
<Tag name={pd.isEnabled ? 'enabled' : 'disabled'} colorIndex={pd.isEnabled ? 20 : 15} />
</td>
<td>
<ButtonGroup>
<LinkButton
href={viewPublicDashboardUrl(pd.accessToken)}
fill="text"
title={pd.isEnabled ? 'View public dashboard' : 'Public dashboard is disabled'}
target="_blank"
disabled={!pd.isEnabled}
>
<Icon name="external-link-alt" />
</LinkButton>
<LinkButton
fill="text"
href={`/d/${pd.dashboardUid}?shareView=share`}
title="Configure public dashboard"
>
<Icon name="cog" />
</LinkButton>
</ButtonGroup>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
function getStyles(theme: GrafanaTheme2) {
return {
link: css`
color: ${theme.colors.primary.text};
text-decoration: underline;
margin-right: ${theme.spacing()};
`,
};
}