From cfdea1ee30a1ddbb599f1fcd6ad8af4fc9d2b74c Mon Sep 17 00:00:00 2001 From: owensmallwood Date: Thu, 2 Jun 2022 14:57:55 -0600 Subject: [PATCH] PublicDashboards: Frontend routing for public dashboards (#48834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add isPublic to dashboard * refactor routes to use route group and add placeholder method for sharing apii * add sharing pane and utils for public dashboard config to sharing modal * Sharing modal now persists data through the api * moves ShareDashboard endpoint to new file and starts adding tests * generates mocks. Adds tests for public dashboard feature flag * Adds ability to pass in array of features to enable for the test * test to update public flag on dashboard WIP * Adds mock for SaveDashboardSharingConfig * Fixes tests. Had to use FakeDashboardService * Adds React tests for public dashboards toggle * removes semicolons * refactors SharePublic component to use hooks * rename from `share publicly` to `public dashboard config` * checkpoint. debugging tests. need to verify name changes * checkpoint. test bugs fixed. need to finish returning proper response codes * finish renaming. fix test * Update pkg/api/api.go Co-authored-by: Torkel Ödegaard * update backend url * rename internal objects and commands. fix configuration modal labels * add endpoint for retrieving public dashboard configuration and populate the frontend state from it * add test for dashboardCanBePublic * adds backend routes * copy DashboardPage component into component for public dashboards. WIP * adds react routes, and doesnt render main nav bar when viewing a public route * removes extra react route from testing * updates component name * Wrap the original dashboard component so we can pass props relevant to public dashboards, turn kiosk mode on/off, etc * Wraps DashboardPage in PublicDashboardPage component. DashboardPage gets rendered in kiosk mode when public prop is passed. * removes commented out code from exploratory work * Makes public dashboard routes require no auth * extracts helper to own util file to check if were viewing a public page * Hides panel dropdown when its being viewed publicly * formatting * use function from utils file for determining if publicly viewed. If public, hides app notifications, searchwrapper, and commandpalette. * adds unit tests for util function used to see if page is being viewed publicly * cant added annotations to panel when being publicly viewed * removes useless comment * hides backend and frontend pubdash routes behind feature flag * consider feature flag when checking url path to see if on public dashboard * renames function * still render app notifications when in public view * Extract pubdash route logic into own file * fixes failing tests * Determines path using location locationUtils. This covers the case when grafana is being hosted on a subpath. Updates tests. * renames pubdash web route to be more understandable * rename route * fixes failing test * fixes failing test. Needed to update pubdash urls * sets flag on grafana boot config for if viewing public dashboard. Removes hacky check that looks at the url * fixes failing tests. Uses config to determine if viewing public dashboard * renders the blue panel timeInfo on public dashboard panel * Extracts conditional logic for rendering components out into their own functions * removes publicDashboardView check, and uses dashboard meta instead * the timeInfo is always displayed on the panel * After fetch of public dashboard dto, the meta isPublic flag gets set and used to determine if viewing public dashboard for child components. Fixes tests for PanelHeader. * Fixes failing test. Needed to add isPublic flag to dashboard meta. Co-authored-by: Jeff Levin Co-authored-by: Torkel Ödegaard --- packages/grafana-data/src/types/config.ts | 1 + packages/grafana-runtime/src/config.ts | 2 + pkg/api/api.go | 5 +++ pkg/api/index.go | 4 ++ pkg/middleware/dashboard_public.go | 11 +++++ pkg/models/context.go | 3 +- public/app/AppWrapper.tsx | 14 +++++-- .../dashboard/containers/DashboardPage.tsx | 17 +++++--- .../containers/PublicDashboardPage.tsx | 13 ++++++ .../dashboard/dashgrid/PanelChrome.test.tsx | 3 ++ .../dashboard/dashgrid/PanelChrome.tsx | 3 +- .../dashgrid/PanelHeader/PanelHeader.test.tsx | 42 +++++++++++++++++++ .../dashgrid/PanelHeader/PanelHeader.tsx | 13 +++++- public/app/features/dashboard/routes.ts | 24 +++++++++++ .../features/dashboard/state/initDashboard.ts | 9 ++++ public/app/routes/routes.tsx | 2 + public/app/types/dashboard.ts | 1 + 17 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 pkg/middleware/dashboard_public.go create mode 100644 public/app/features/dashboard/containers/PublicDashboardPage.tsx create mode 100644 public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.test.tsx create mode 100644 public/app/features/dashboard/routes.ts diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 80e0a523ac6..0aa90132acb 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -133,6 +133,7 @@ export interface BootData { * @internal */ export interface GrafanaConfig { + isPublicDashboardView: boolean; datasources: { [str: string]: DataSourceInstanceSettings }; panels: { [key: string]: PanelPluginMeta }; minRefreshInterval: string; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 76c1b579079..23c5d048e56 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -24,6 +24,7 @@ export interface AzureSettings { } export class GrafanaBootConfig implements GrafanaConfig { + isPublicDashboardView: boolean; datasources: { [str: string]: DataSourceInstanceSettings } = {}; panels: { [key: string]: PanelPluginMeta } = {}; minRefreshInterval = ''; @@ -124,6 +125,7 @@ export class GrafanaBootConfig implements GrafanaConfig { this.theme2 = createTheme({ colors: { mode } }); this.theme = this.theme2.v1; this.bootData = options.bootData; + this.isPublicDashboardView = options.bootData.settings.isPublicDashboardView; const defaults = { datasources: {}, diff --git a/pkg/api/api.go b/pkg/api/api.go index 47dcf2ece69..1161e5389c6 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -89,6 +89,11 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/a/:id/*", reqSignedIn, hs.Index) // App Root Page r.Get("/a/:id", reqSignedIn, hs.Index) + //pubdash + if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) { + r.Get("/public-dashboards/:uid", middleware.SetPublicDashboardFlag(), hs.Index) + } + r.Get("/d/:uid/:slug", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index) r.Get("/d/:uid", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index) r.Get("/dashboard/script/*", reqSignedIn, hs.Index) diff --git a/pkg/api/index.go b/pkg/api/index.go index ea9c2536c43..c9c0513872b 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -717,6 +717,10 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat return nil, err } + if c.IsPublicDashboardView { + settings["isPublicDashboardView"] = true + } + data := dtos.IndexViewData{ User: &dtos.CurrentUser{ Id: c.UserId, diff --git a/pkg/middleware/dashboard_public.go b/pkg/middleware/dashboard_public.go new file mode 100644 index 00000000000..3c89e8ac566 --- /dev/null +++ b/pkg/middleware/dashboard_public.go @@ -0,0 +1,11 @@ +package middleware + +import ( + "github.com/grafana/grafana/pkg/models" +) + +func SetPublicDashboardFlag() func(c *models.ReqContext) { + return func(c *models.ReqContext) { + c.IsPublicDashboardView = true + } +} diff --git a/pkg/models/context.go b/pkg/models/context.go index f31ea1197d6..3907dcb3942 100644 --- a/pkg/models/context.go +++ b/pkg/models/context.go @@ -21,7 +21,8 @@ type ReqContext struct { SkipCache bool Logger log.Logger // RequestNonce is a cryptographic request identifier for use with Content Security Policy. - RequestNonce string + RequestNonce string + IsPublicDashboardView bool PerfmonTimer prometheus.Summary LookupTokenErr error diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index 812e57c72e0..ea4a1bca653 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -95,6 +95,14 @@ export class AppWrapper extends React.Component !config.isPublicDashboardView && config.featureToggles.commandPalette; + + const renderNavBar = () => { + return !config.isPublicDashboardView && ready && <>{newNavigationEnabled ? : }; + }; + + const searchBarEnabled = () => !config.isPublicDashboardView; + return ( @@ -107,10 +115,10 @@ export class AppWrapper extends React.Component - {config.featureToggles.commandPalette && } + {commandPaletteEnabled() && }
- {ready && <>{newNavigationEnabled ? : }} + {renderNavBar()}
{pageBanners.map((Banner, index) => ( @@ -118,7 +126,7 @@ export class AppWrapper extends React.Component - + {searchBarEnabled() && } {ready && this.renderRoutes()} {bodyRenderHooks.map((Hook, index) => ( diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 32904dd6042..1300edba06b 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -39,7 +39,7 @@ export interface DashboardPageRouteParams { slug?: string; } -type DashboardPageRouteSearchParams = { +export type DashboardPageRouteSearchParams = { tab?: string; folderId?: string; editPanel?: string; @@ -67,7 +67,12 @@ const mapDispatchToProps = { const connector = connect(mapStateToProps, mapDispatchToProps); -export type Props = Themeable2 & +type OwnProps = { + isPublic?: boolean; +}; + +export type Props = OwnProps & + Themeable2 & GrafanaRouteComponentProps & ConnectedProps; @@ -112,7 +117,7 @@ export class UnthemedDashboardPage extends PureComponent { } initDashboard() { - const { dashboard, match, queryParams } = this.props; + const { dashboard, isPublic, match, queryParams } = this.props; if (dashboard) { this.closeDashboard(); @@ -124,7 +129,7 @@ export class UnthemedDashboardPage extends PureComponent { urlType: match.params.type, urlFolderId: queryParams.folderId, routeName: this.props.route.routeName, - fixUrl: true, + fixUrl: !isPublic, }); // small delay to start live updates @@ -312,9 +317,9 @@ export class UnthemedDashboardPage extends PureComponent { } render() { - const { dashboard, initError, queryParams, theme } = this.props; + const { dashboard, initError, queryParams, theme, isPublic } = this.props; const { editPanel, viewPanel, updateScrollTop } = this.state; - const kioskMode = getKioskMode(); + const kioskMode = !isPublic ? getKioskMode() : KioskMode.Full; const styles = getStyles(theme, kioskMode); if (!dashboard) { diff --git a/public/app/features/dashboard/containers/PublicDashboardPage.tsx b/public/app/features/dashboard/containers/PublicDashboardPage.tsx new file mode 100644 index 00000000000..826062fef97 --- /dev/null +++ b/public/app/features/dashboard/containers/PublicDashboardPage.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import { GrafanaRouteComponentProps } from '../../../core/navigation/types'; + +import DashboardPage, { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './DashboardPage'; + +export type Props = GrafanaRouteComponentProps; + +const PublicDashboardPage = (props: Props) => { + return ; +}; + +export default PublicDashboardPage; diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx index a883596bac4..3bfd9624ed5 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.test.tsx @@ -47,6 +47,9 @@ function setupTestContext(options: Partial) { panelInitialized: jest.fn(), getTimezone: () => 'browser', events: new EventBusSrv(), + meta: { + isPublic: false, + }, } as unknown as DashboardModel, plugin: { meta: { skipDataQuery: false }, diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 7ace1ab35fa..df6c86c82bd 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -20,10 +20,9 @@ import { toUtc, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { locationService, RefreshEvent } from '@grafana/runtime'; +import { config, locationService, RefreshEvent } from '@grafana/runtime'; import { VizLegendOptions } from '@grafana/schema'; import { ErrorBoundary, PanelContext, PanelContextProvider, SeriesVisibilityChangeMode } from '@grafana/ui'; -import config from 'app/core/config'; import { PANEL_BORDER } from 'app/core/constants'; import { profiler } from 'app/core/profiler'; import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.test.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.test.tsx new file mode 100644 index 00000000000..d62b7b908c4 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { createEmptyQueryResponse } from '../../../explore/state/utils'; +import { DashboardModel, PanelModel } from '../../state'; + +import { PanelHeader } from './PanelHeader'; + +let panelModel = new PanelModel({ + id: 1, + gridPos: { x: 1, y: 1, w: 1, h: 1 }, + type: 'type', + title: 'title', +}); + +let panelData = createEmptyQueryResponse(); + +describe('Panel Header', () => { + const dashboardModel = new DashboardModel({}, { isPublic: true }); + it('will render header title but not render dropdown icon when dashboard is being viewed publicly', () => { + window.history.pushState({}, 'Test Title', '/public-dashboards/abc123'); + + render( + + ); + + expect(screen.getByText('title')).toBeDefined(); + expect(screen.queryByTestId('panel-dropdown')).toBeNull(); + }); + + it('will render header title and dropdown icon when dashboard is not being viewed publicly', () => { + const dashboardModel = new DashboardModel({}, { isPublic: false }); + window.history.pushState({}, 'Test Title', '/d/abc/123'); + + render( + + ); + + expect(screen.getByText('title')).toBeDefined(); + expect(screen.getByTestId('panel-dropdown')).toBeDefined(); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx index 36c377f0d37..3946d551383 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -59,8 +59,17 @@ export const PanelHeader: FC = ({ panel, error, isViewing, isEditing, dat /> ) : null}

{title}

- - + {!dashboard.meta.isPublic && ( +
+ + +
+ )} {data.request && data.request.timeInfo && ( {data.request.timeInfo} diff --git a/public/app/features/dashboard/routes.ts b/public/app/features/dashboard/routes.ts new file mode 100644 index 00000000000..f1994cb3b4d --- /dev/null +++ b/public/app/features/dashboard/routes.ts @@ -0,0 +1,24 @@ +import { SafeDynamicImport } from '../../core/components/DynamicImports/SafeDynamicImport'; +import { config } from '../../core/config'; +import { RouteDescriptor } from '../../core/navigation/types'; +import { DashboardRoutes } from '../../types'; + +export const getPublicDashboardRoutes = (): RouteDescriptor[] => { + if (config.featureToggles.publicDashboards) { + return [ + { + path: '/public-dashboards/:uid', + pageClass: 'page-dashboard', + routeName: DashboardRoutes.Public, + component: SafeDynamicImport( + () => + import( + /* webpackChunkName: "PublicDashboardPage" */ '../../features/dashboard/containers/PublicDashboardPage' + ) + ), + }, + ]; + } + + return []; +}; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 5a092c25adf..34714b5f1e6 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -60,6 +60,15 @@ async function fetchDashboard( dashDTO.meta.canStar = false; return dashDTO; } + case DashboardRoutes.Public: { + const dashDTO: DashboardDTO = await dashboardLoaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid); + // Make sure new endpoint to fetch dashboard DTO sets these as false + dashDTO.meta.canEdit = false; + dashDTO.meta.canMakeEditable = false; + dashDTO.meta.isPublic = true; + + return dashDTO; + } case DashboardRoutes.Normal: { const dashDTO: DashboardDTO = await dashboardLoaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid); diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 1dc7434d8ec..420d887f8af 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -16,6 +16,7 @@ import { AccessControlAction, DashboardRoutes } from 'app/types'; import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamicImport'; import { RouteDescriptor } from '../core/navigation/types'; +import { getPublicDashboardRoutes } from '../features/dashboard/routes'; export const extraRoutes: RouteDescriptor[] = []; @@ -429,6 +430,7 @@ export function getAppRoutes(): RouteDescriptor[] { ...getAlertingRoutes(), ...getProfileRoutes(), ...extraRoutes, + ...getPublicDashboardRoutes(), { path: '/*', component: ErrorPage, diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index 953fd6e68ff..bb238eeebde 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -66,6 +66,7 @@ export enum DashboardRoutes { New = 'new-dashboard', Normal = 'normal-dashboard', Scripted = 'scripted-dashboard', + Public = 'public-dashboard', } export enum DashboardInitPhase {