mirror of https://github.com/grafana/grafana
add isPublic to dashboard (#48012)
adds toggle to make a dashboard public * config struct for public dashboard config * api endpoints for public dashboard configuration * ui for toggling public dashboard on and off * load public dashboard config on share modal Co-authored-by: Owen Smallwood <owen.smallwood@grafana.com> Co-authored-by: Torkel Ödegaard <torkel@grafana.com>pull/49125/head
parent
156e14e296
commit
c7f8c2cc73
@ -0,0 +1,56 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"net/http" |
||||
|
||||
"github.com/grafana/grafana/pkg/api/response" |
||||
"github.com/grafana/grafana/pkg/models" |
||||
"github.com/grafana/grafana/pkg/services/dashboards" |
||||
"github.com/grafana/grafana/pkg/web" |
||||
) |
||||
|
||||
// Sets sharing configuration for dashboard
|
||||
func (hs *HTTPServer) GetPublicDashboard(c *models.ReqContext) response.Response { |
||||
pdc, err := hs.dashboardService.GetPublicDashboardConfig(c.Req.Context(), c.OrgId, web.Params(c.Req)[":uid"]) |
||||
|
||||
if errors.Is(err, models.ErrDashboardNotFound) { |
||||
return response.Error(http.StatusNotFound, "dashboard not found", err) |
||||
} |
||||
|
||||
if err != nil { |
||||
return response.Error(http.StatusInternalServerError, "error retrieving public dashboard config", err) |
||||
} |
||||
|
||||
return response.JSON(http.StatusOK, pdc) |
||||
} |
||||
|
||||
// Sets sharing configuration for dashboard
|
||||
func (hs *HTTPServer) SavePublicDashboard(c *models.ReqContext) response.Response { |
||||
pdc := &models.PublicDashboardConfig{} |
||||
|
||||
if err := web.Bind(c.Req, pdc); err != nil { |
||||
return response.Error(http.StatusBadRequest, "bad request data", err) |
||||
} |
||||
|
||||
dto := dashboards.SavePublicDashboardConfigDTO{ |
||||
OrgId: c.OrgId, |
||||
Uid: web.Params(c.Req)[":uid"], |
||||
PublicDashboardConfig: *pdc, |
||||
} |
||||
|
||||
pdc, err := hs.dashboardService.SavePublicDashboardConfig(c.Req.Context(), &dto) |
||||
|
||||
fmt.Println("err:", err) |
||||
|
||||
if errors.Is(err, models.ErrDashboardNotFound) { |
||||
return response.Error(http.StatusNotFound, "dashboard not found", err) |
||||
} |
||||
|
||||
if err != nil { |
||||
return response.Error(http.StatusInternalServerError, "error updating public dashboard config", err) |
||||
} |
||||
|
||||
return response.JSON(http.StatusOK, pdc) |
||||
} |
@ -0,0 +1,134 @@ |
||||
package api |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"errors" |
||||
"net/http" |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson" |
||||
"github.com/grafana/grafana/pkg/models" |
||||
"github.com/grafana/grafana/pkg/services/dashboards" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestApiRetrieveConfig(t *testing.T) { |
||||
pdc := &models.PublicDashboardConfig{IsPublic: true} |
||||
|
||||
testCases := []struct { |
||||
name string |
||||
dashboardUid string |
||||
expectedHttpResponse int |
||||
publicDashboardConfigResult *models.PublicDashboardConfig |
||||
publicDashboardConfigError error |
||||
}{ |
||||
{ |
||||
name: "retrieves public dashboard config when dashboard is found", |
||||
dashboardUid: "1", |
||||
expectedHttpResponse: http.StatusOK, |
||||
publicDashboardConfigResult: pdc, |
||||
publicDashboardConfigError: nil, |
||||
}, |
||||
{ |
||||
name: "returns 404 when dashboard not found", |
||||
dashboardUid: "77777", |
||||
expectedHttpResponse: http.StatusNotFound, |
||||
publicDashboardConfigResult: nil, |
||||
publicDashboardConfigError: models.ErrDashboardNotFound, |
||||
}, |
||||
{ |
||||
name: "returns 500 when internal server error", |
||||
dashboardUid: "1", |
||||
expectedHttpResponse: http.StatusInternalServerError, |
||||
publicDashboardConfigResult: nil, |
||||
publicDashboardConfigError: errors.New("database broken"), |
||||
}, |
||||
} |
||||
|
||||
for _, test := range testCases { |
||||
t.Run(test.name, func(t *testing.T) { |
||||
sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards)) |
||||
|
||||
sc.hs.dashboardService = &dashboards.FakeDashboardService{ |
||||
PublicDashboardConfigResult: test.publicDashboardConfigResult, |
||||
PublicDashboardConfigError: test.publicDashboardConfigError, |
||||
} |
||||
|
||||
setInitCtxSignedInViewer(sc.initCtx) |
||||
response := callAPI( |
||||
sc.server, |
||||
http.MethodGet, |
||||
"/api/dashboards/uid/1/public-config", |
||||
nil, |
||||
t, |
||||
) |
||||
|
||||
assert.Equal(t, test.expectedHttpResponse, response.Code) |
||||
|
||||
if test.expectedHttpResponse == http.StatusOK { |
||||
var pdcResp models.PublicDashboardConfig |
||||
err := json.Unmarshal(response.Body.Bytes(), &pdcResp) |
||||
require.NoError(t, err) |
||||
assert.Equal(t, test.publicDashboardConfigResult, &pdcResp) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestApiPersistsValue(t *testing.T) { |
||||
testCases := []struct { |
||||
name string |
||||
dashboardUid string |
||||
expectedHttpResponse int |
||||
saveDashboardError error |
||||
}{ |
||||
{ |
||||
name: "returns 200 when update persists", |
||||
dashboardUid: "1", |
||||
expectedHttpResponse: http.StatusOK, |
||||
saveDashboardError: nil, |
||||
}, |
||||
{ |
||||
name: "returns 500 when not persisted", |
||||
expectedHttpResponse: http.StatusInternalServerError, |
||||
saveDashboardError: errors.New("backend failed to save"), |
||||
}, |
||||
{ |
||||
name: "returns 404 when dashboard not found", |
||||
expectedHttpResponse: http.StatusNotFound, |
||||
saveDashboardError: models.ErrDashboardNotFound, |
||||
}, |
||||
} |
||||
|
||||
for _, test := range testCases { |
||||
t.Run(test.name, func(t *testing.T) { |
||||
sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards)) |
||||
|
||||
sc.hs.dashboardService = &dashboards.FakeDashboardService{ |
||||
PublicDashboardConfigResult: &models.PublicDashboardConfig{IsPublic: true}, |
||||
PublicDashboardConfigError: test.saveDashboardError, |
||||
} |
||||
|
||||
setInitCtxSignedInViewer(sc.initCtx) |
||||
response := callAPI( |
||||
sc.server, |
||||
http.MethodPost, |
||||
"/api/dashboards/uid/1/public-config", |
||||
strings.NewReader(`{ "isPublic": true }`), |
||||
t, |
||||
) |
||||
|
||||
assert.Equal(t, test.expectedHttpResponse, response.Code) |
||||
|
||||
// check the result if it's a 200
|
||||
if response.Code == http.StatusOK { |
||||
respJSON, _ := simplejson.NewJson(response.Body.Bytes()) |
||||
val, _ := respJSON.Get("isPublic").Bool() |
||||
assert.Equal(t, true, val) |
||||
} |
||||
}) |
||||
} |
||||
} |
@ -0,0 +1,76 @@ |
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; |
||||
import React from 'react'; |
||||
|
||||
import config from 'app/core/config'; |
||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state'; |
||||
|
||||
import { ShareModal } from './ShareModal'; |
||||
|
||||
jest.mock('app/core/core', () => { |
||||
return { |
||||
contextSrv: { |
||||
hasPermission: () => true, |
||||
}, |
||||
appEvents: { |
||||
subscribe: () => { |
||||
return { |
||||
unsubscribe: () => {}, |
||||
}; |
||||
}, |
||||
emit: () => {}, |
||||
}, |
||||
}; |
||||
}); |
||||
|
||||
describe('SharePublic', () => { |
||||
let originalBootData: any; |
||||
|
||||
beforeAll(() => { |
||||
originalBootData = config.bootData; |
||||
config.appUrl = 'http://dashboards.grafana.com/'; |
||||
|
||||
config.bootData = { |
||||
user: { |
||||
orgId: 1, |
||||
}, |
||||
} as any; |
||||
}); |
||||
|
||||
afterAll(() => { |
||||
config.bootData = originalBootData; |
||||
}); |
||||
|
||||
it('does not render share panel when public dashboards feature is disabled', () => { |
||||
const mockDashboard = new DashboardModel({ |
||||
uid: 'mockDashboardUid', |
||||
}); |
||||
const mockPanel = new PanelModel({ |
||||
id: 'mockPanelId', |
||||
}); |
||||
|
||||
render(<ShareModal panel={mockPanel} dashboard={mockDashboard} onDismiss={() => {}} />); |
||||
|
||||
expect(screen.getByRole('tablist')).toHaveTextContent('Link'); |
||||
expect(screen.getByRole('tablist')).not.toHaveTextContent('Public Dashboard'); |
||||
}); |
||||
|
||||
it('renders share panel when public dashboards feature is enabled', async () => { |
||||
config.featureToggles.publicDashboards = true; |
||||
const mockDashboard = new DashboardModel({ |
||||
uid: 'mockDashboardUid', |
||||
}); |
||||
const mockPanel = new PanelModel({ |
||||
id: 'mockPanelId', |
||||
}); |
||||
|
||||
render(<ShareModal panel={mockPanel} dashboard={mockDashboard} onDismiss={() => {}} />); |
||||
|
||||
await waitFor(() => screen.getByText('Link')); |
||||
expect(screen.getByRole('tablist')).toHaveTextContent('Link'); |
||||
expect(screen.getByRole('tablist')).toHaveTextContent('Public Dashboard'); |
||||
|
||||
fireEvent.click(screen.getByText('Public Dashboard')); |
||||
|
||||
await waitFor(() => screen.getByText('Enabled')); |
||||
}); |
||||
}); |
@ -0,0 +1,69 @@ |
||||
import React, { useState, useEffect } from 'react'; |
||||
|
||||
import { Button, Field, Switch } from '@grafana/ui'; |
||||
import { notifyApp } from 'app/core/actions'; |
||||
import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; |
||||
import { dispatch } from 'app/store/store'; |
||||
|
||||
import { |
||||
dashboardCanBePublic, |
||||
getPublicDashboardConfig, |
||||
savePublicDashboardConfig, |
||||
PublicDashboardConfig, |
||||
} from './SharePublicDashboardUtils'; |
||||
import { ShareModalTabProps } from './types'; |
||||
|
||||
interface Props extends ShareModalTabProps {} |
||||
|
||||
// 1. write test for dashboardCanBePublic
|
||||
// 2. figure out how to disable the switch
|
||||
|
||||
export const SharePublicDashboard = (props: Props) => { |
||||
const [publicDashboardConfig, setPublicDashboardConfig] = useState<PublicDashboardConfig>({ isPublic: false }); |
||||
const dashboardUid = props.dashboard.uid; |
||||
|
||||
useEffect(() => { |
||||
getPublicDashboardConfig(dashboardUid) |
||||
.then((pdc: PublicDashboardConfig) => { |
||||
setPublicDashboardConfig(pdc); |
||||
}) |
||||
.catch(() => { |
||||
dispatch(notifyApp(createErrorNotification('Failed to retrieve public dashboard config'))); |
||||
}); |
||||
}, [dashboardUid]); |
||||
|
||||
const onSavePublicConfig = () => { |
||||
// verify dashboard can be public
|
||||
if (!dashboardCanBePublic(props.dashboard)) { |
||||
dispatch(notifyApp(createErrorNotification('This dashboard cannot be made public'))); |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
savePublicDashboardConfig(props.dashboard.uid, publicDashboardConfig); |
||||
dispatch(notifyApp(createSuccessNotification('Dashboard sharing configuration saved'))); |
||||
} catch (err) { |
||||
console.error('Error while making dashboard public', err); |
||||
dispatch(notifyApp(createErrorNotification('Error making dashboard public'))); |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<> |
||||
<p className="share-modal-info-text">Public Dashboard Configuration</p> |
||||
<Field label="Enabled" description="Configures whether current dashboard can be available publicly"> |
||||
<Switch |
||||
id="share-current-time-range" |
||||
disabled={!dashboardCanBePublic(props.dashboard)} |
||||
value={publicDashboardConfig?.isPublic} |
||||
onChange={() => |
||||
setPublicDashboardConfig((state) => { |
||||
return { ...state, isPublic: !state.isPublic }; |
||||
}) |
||||
} |
||||
/> |
||||
</Field> |
||||
<Button onClick={onSavePublicConfig}>Save Sharing Configuration</Button> |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1,17 @@ |
||||
import { DashboardModel } from 'app/features/dashboard/state'; |
||||
|
||||
import { dashboardCanBePublic } from './SharePublicDashboardUtils'; |
||||
|
||||
describe('dashboardCanBePublic', () => { |
||||
it('can be public with no template variables', () => { |
||||
//@ts-ignore
|
||||
const dashboard: DashboardModel = { templating: { list: [] } }; |
||||
expect(dashboardCanBePublic(dashboard)).toBe(true); |
||||
}); |
||||
|
||||
it('cannot be public with template variables', () => { |
||||
//@ts-ignore
|
||||
const dashboard: DashboardModel = { templating: { list: [{}] } }; |
||||
expect(dashboardCanBePublic(dashboard)).toBe(false); |
||||
}); |
||||
}); |
@ -0,0 +1,21 @@ |
||||
import { getBackendSrv } from '@grafana/runtime'; |
||||
import { DashboardModel } from 'app/features/dashboard/state'; |
||||
|
||||
export interface PublicDashboardConfig { |
||||
isPublic: boolean; |
||||
} |
||||
|
||||
export const dashboardCanBePublic = (dashboard: DashboardModel): boolean => { |
||||
return dashboard?.templating?.list.length === 0; |
||||
}; |
||||
|
||||
export const getPublicDashboardConfig = async (dashboardUid: string) => { |
||||
const url = `/api/dashboards/uid/${dashboardUid}/public-config`; |
||||
return getBackendSrv().get(url); |
||||
}; |
||||
|
||||
export const savePublicDashboardConfig = async (dashboardUid: string, conf: PublicDashboardConfig) => { |
||||
const payload = { isPublic: conf.isPublic }; |
||||
const url = `/api/dashboards/uid/${dashboardUid}/public-config`; |
||||
return getBackendSrv().post(url, payload); |
||||
}; |
Loading…
Reference in new issue