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