diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index f5b2cbc31bf..15287d934da 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -112,6 +112,7 @@ Experimental features might be changed or removed without prior notice. | `extraThemes` | Enables extra themes | | `lokiPredefinedOperations` | Adds predefined query operations to Loki query editor | | `pluginsFrontendSandbox` | Enables the plugins frontend sandbox | +| `dashboardEmbed` | Allow embedding dashboard for external use in Code editors | | `frontendSandboxMonitorOnly` | Enables monitor only in the plugin frontend sandbox (if enabled) | | `cloudWatchLogsMonacoEditor` | Enables the Monaco editor for CloudWatch Logs queries | | `exploreScrollableLogsContainer` | Improves the scrolling behavior of logs in Explore | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index b43a0168447..455221adfb8 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -98,6 +98,7 @@ export interface FeatureToggles { extraThemes?: boolean; lokiPredefinedOperations?: boolean; pluginsFrontendSandbox?: boolean; + dashboardEmbed?: boolean; frontendSandboxMonitorOnly?: boolean; sqlDatasourceDatabaseSelection?: boolean; cloudWatchLogsMonacoEditor?: boolean; diff --git a/pkg/api/api.go b/pkg/api/api.go index ae5b4255cf4..3180364b8be 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -148,6 +148,10 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/dashboards/*", reqSignedIn, hs.Index) r.Get("/goto/:uid", reqSignedIn, hs.redirectFromShortURL, hs.Index) + if hs.Features.IsEnabled(featuremgmt.FlagDashboardEmbed) { + r.Get("/d-embed", reqSignedIn, middleware.AddAllowEmbeddingHeader(), hs.Index) + } + if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) { // list public dashboards r.Get("/public-dashboards/list", reqSignedIn, hs.Index) diff --git a/pkg/middleware/middleware.go b/pkg/middleware/middleware.go index 55e49b9b209..cc0bac28bd3 100644 --- a/pkg/middleware/middleware.go +++ b/pkg/middleware/middleware.go @@ -52,7 +52,10 @@ func AddDefaultResponseHeaders(cfg *setting.Cfg) web.Handler { addNoCacheHeaders(c.Resp) } - if !cfg.AllowEmbedding { + // X-Allow-Embedding header is set for specific URLs that need to be embedded in an iframe regardless + // of the configured allow_embedding setting. + embeddingHeader := w.Header().Get("X-Allow-Embedding") + if !cfg.AllowEmbedding && embeddingHeader != "allow" { addXFrameOptionsDenyHeader(w) } addSecurityHeaders(w, cfg) @@ -60,6 +63,14 @@ func AddDefaultResponseHeaders(cfg *setting.Cfg) web.Handler { } } +func AddAllowEmbeddingHeader() web.Handler { + return func(c *web.Context) { + c.Resp.Before(func(w web.ResponseWriter) { + w.Header().Set("X-Allow-Embedding", "allow") + }) + } +} + // addSecurityHeaders adds HTTP(S) response headers that enable various security protections in the client's browser. func addSecurityHeaders(w web.ResponseWriter, cfg *setting.Cfg) { if cfg.StrictTransportSecurity { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 9e5c54eedb6..3a1bcdaf397 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -545,6 +545,13 @@ var ( FrontendOnly: true, Owner: grafanaPluginsPlatformSquad, }, + { + Name: "dashboardEmbed", + Description: "Allow embedding dashboard for external use in Code editors", + FrontendOnly: true, + Stage: FeatureStageExperimental, + Owner: grafanaAsCodeSquad, + }, { Name: "frontendSandboxMonitorOnly", Description: "Enables monitor only in the plugin frontend sandbox (if enabled)", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 98138e32100..c8eefca4903 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -79,6 +79,7 @@ dataSourcePageHeader,preview,@grafana/enterprise-datasources,false,false,false,t extraThemes,experimental,@grafana/grafana-frontend-platform,false,false,false,true lokiPredefinedOperations,experimental,@grafana/observability-logs,false,false,false,true pluginsFrontendSandbox,experimental,@grafana/plugins-platform-backend,false,false,false,true +dashboardEmbed,experimental,@grafana/grafana-as-code,false,false,false,true frontendSandboxMonitorOnly,experimental,@grafana/plugins-platform-backend,false,false,false,true sqlDatasourceDatabaseSelection,preview,@grafana/grafana-bi-squad,false,false,false,true cloudWatchLogsMonacoEditor,experimental,@grafana/aws-plugins,false,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 0c4bb0114d6..7a293b6bb8c 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -327,6 +327,10 @@ const ( // Enables the plugins frontend sandbox FlagPluginsFrontendSandbox = "pluginsFrontendSandbox" + // FlagDashboardEmbed + // Allow embedding dashboard for external use in Code editors + FlagDashboardEmbed = "dashboardEmbed" + // FlagFrontendSandboxMonitorOnly // Enables monitor only in the plugin frontend sandbox (if enabled) FlagFrontendSandboxMonitorOnly = "frontendSandboxMonitorOnly" diff --git a/public/app/features/dashboard/components/EmbeddedDashboard/SaveDashboardDrawer.tsx b/public/app/features/dashboard/components/EmbeddedDashboard/SaveDashboardDrawer.tsx new file mode 100644 index 00000000000..095521a43ba --- /dev/null +++ b/public/app/features/dashboard/components/EmbeddedDashboard/SaveDashboardDrawer.tsx @@ -0,0 +1,73 @@ +import React, { useMemo, useState } from 'react'; + +import { config } from '@grafana/runtime'; +import { Drawer, Tab, TabsBar } from '@grafana/ui'; + +import { DashboardModel } from '../../state'; +import DashboardValidation from '../SaveDashboard/DashboardValidation'; +import { SaveDashboardDiff } from '../SaveDashboard/SaveDashboardDiff'; +import { SaveDashboardData } from '../SaveDashboard/types'; +import { jsonDiff } from '../VersionHistory/utils'; + +import { SaveDashboardForm } from './SaveDashboardForm'; + +type SaveDashboardDrawerProps = { + dashboard: DashboardModel; + onDismiss: () => void; + dashboardJson: string; + onSave: (clone: DashboardModel) => Promise; +}; + +export const SaveDashboardDrawer = ({ dashboard, onDismiss, dashboardJson, onSave }: SaveDashboardDrawerProps) => { + const data = useMemo(() => { + const clone = dashboard.getSaveModelClone(); + const cloneJSON = JSON.stringify(clone, null, 2); + const cloneSafe = JSON.parse(cloneJSON); // avoids undefined issues + + const diff = jsonDiff(JSON.parse(JSON.stringify(dashboardJson, null, 2)), cloneSafe); + let diffCount = 0; + for (const d of Object.values(diff)) { + diffCount += d.length; + } + + return { + clone, + diff, + diffCount, + hasChanges: diffCount > 0, + }; + }, [dashboard, dashboardJson]); + + const [showDiff, setShowDiff] = useState(false); + + return ( + + setShowDiff(false)} /> + {data.hasChanges && ( + setShowDiff(true)} counter={data.diffCount} /> + )} + + } + scrollableContent + > + {showDiff ? ( + + ) : ( + + )} + + {config.featureToggles.showDashboardValidationWarnings && } + + ); +}; diff --git a/public/app/features/dashboard/components/EmbeddedDashboard/SaveDashboardForm.tsx b/public/app/features/dashboard/components/EmbeddedDashboard/SaveDashboardForm.tsx new file mode 100644 index 00000000000..c8e4f6d2e01 --- /dev/null +++ b/public/app/features/dashboard/components/EmbeddedDashboard/SaveDashboardForm.tsx @@ -0,0 +1,57 @@ +import React, { useMemo, useState } from 'react'; + +import { Stack } from '@grafana/experimental'; +import { Button, Form } from '@grafana/ui'; +import { useAppNotification } from 'app/core/copy/appNotification'; + +import { DashboardModel } from '../../state'; +import { SaveDashboardData } from '../SaveDashboard/types'; + +interface SaveDashboardProps { + dashboard: DashboardModel; + onCancel: () => void; + onSubmit?: (clone: DashboardModel) => Promise; + onSuccess: () => void; + saveModel: SaveDashboardData; +} +export const SaveDashboardForm = ({ dashboard, onCancel, onSubmit, onSuccess, saveModel }: SaveDashboardProps) => { + const [saving, setSaving] = useState(false); + const notifyApp = useAppNotification(); + const hasChanges = useMemo(() => dashboard.hasTimeChanged() || saveModel.hasChanges, [dashboard, saveModel]); + + const onFormSubmit = async () => { + if (!onSubmit) { + return; + } + setSaving(true); + onSubmit(saveModel.clone) + .then(() => { + notifyApp.success('Dashboard saved locally'); + onSuccess(); + }) + .catch((error) => { + notifyApp.error(error.message || 'Error saving dashboard'); + }) + .finally(() => setSaving(false)); + }; + + return ( +
+ {() => { + return ( + + + + + {!hasChanges &&
No changes to save
} +
+
+ ); + }} +
+ ); +}; diff --git a/public/app/features/dashboard/containers/EmbeddedDashboardPage.tsx b/public/app/features/dashboard/containers/EmbeddedDashboardPage.tsx new file mode 100644 index 00000000000..0c9674c947e --- /dev/null +++ b/public/app/features/dashboard/containers/EmbeddedDashboardPage.tsx @@ -0,0 +1,148 @@ +import { css } from '@emotion/css'; +import React, { useEffect, useState } from 'react'; + +import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; +import { TimeZone } from '@grafana/schema'; +import { Button, ModalsController, PageToolbar, useStyles2 } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; +import { useGrafana } from 'app/core/context/GrafanaContext'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; +import { useDispatch, useSelector } from 'app/types'; + +import { updateTimeZoneForSession } from '../../profile/state/reducers'; +import { DashNavTimeControls } from '../components/DashNav/DashNavTimeControls'; +import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed'; +import { DashboardLoading } from '../components/DashboardLoading/DashboardLoading'; +import { SaveDashboardDrawer } from '../components/EmbeddedDashboard/SaveDashboardDrawer'; +import { DashboardGrid } from '../dashgrid/DashboardGrid'; +import { DashboardModel } from '../state'; +import { initDashboard } from '../state/initDashboard'; + +interface EmbeddedDashboardPageRouteParams { + uid: string; +} + +interface EmbeddedDashboardPageRouteSearchParams { + callbackUrl?: string; + json?: string; + accessToken?: string; +} + +export type Props = GrafanaRouteComponentProps< + EmbeddedDashboardPageRouteParams, + EmbeddedDashboardPageRouteSearchParams +>; + +export default function EmbeddedDashboardPage({ route, queryParams }: Props) { + const dispatch = useDispatch(); + const context = useGrafana(); + const dashboardState = useSelector((store) => store.dashboard); + const dashboard = dashboardState.getModel(); + const [dashboardJson, setDashboardJson] = useState(''); + + /** + * Create dashboard model and initialize the dashboard from JSON + */ + useEffect(() => { + const callbackUrl = queryParams.callbackUrl; + + if (!callbackUrl) { + throw new Error('No callback URL provided'); + } + getBackendSrv() + .get(`${callbackUrl}/load-dashboard`) + .then((dashboardJson) => { + setDashboardJson(dashboardJson); + // Remove dashboard UID from JSON to prevent errors from external dashboards + delete dashboardJson.uid; + const dashboardModel = new DashboardModel(dashboardJson); + + dispatch( + initDashboard({ + routeName: route.routeName, + fixUrl: false, + keybindingSrv: context.keybindings, + dashboardDto: { dashboard: dashboardModel, meta: { canEdit: true } }, + }) + ); + }) + .catch((err) => { + console.log('Error getting dashboard JSON: ', err); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!dashboard) { + return ; + } + + if (dashboard.meta.dashboardNotFound) { + return

Not available

; + } + + return ( + + + {dashboardState.initError && } +
+ +
+
+ ); +} + +interface ToolbarProps { + dashboard: DashboardModel; + callbackUrl?: string; + dashboardJson: string; +} + +const Toolbar = ({ dashboard, callbackUrl, dashboardJson }: ToolbarProps) => { + const dispatch = useDispatch(); + const styles = useStyles2(getStyles); + + const onChangeTimeZone = (timeZone: TimeZone) => { + dispatch(updateTimeZoneForSession(timeZone)); + }; + + const saveDashboard = async (clone: DashboardModel) => { + if (!clone || !callbackUrl) { + return; + } + + return getBackendSrv().post(`${callbackUrl}/save-dashboard`, { dashboard: clone }); + }; + + return ( + + {!dashboard.timepicker.hidden && ( + + )} + + {({ showModal, hideModal }) => ( + + )} + + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + toolbar: css` + padding: ${theme.spacing(3, 2)}; + `, + }; +}; diff --git a/public/app/features/dashboard/routes.ts b/public/app/features/dashboard/routes.ts index 2d3ad942f44..7e3639a5abb 100644 --- a/public/app/features/dashboard/routes.ts +++ b/public/app/features/dashboard/routes.ts @@ -31,6 +31,25 @@ export const getPublicDashboardRoutes = (): RouteDescriptor[] => { }, ]; } + return []; +}; + +export const getEmbeddedDashboardRoutes = (): RouteDescriptor[] => { + if (config.featureToggles.dashboardEmbed) { + return [ + { + path: '/d-embed', + pageClass: 'dashboard-embed', + routeName: DashboardRoutes.Embedded, + component: SafeDynamicImport( + () => + import( + /* webpackChunkName: "EmbeddedDashboardPage" */ '../../features/dashboard/containers/EmbeddedDashboardPage' + ) + ), + }, + ]; + } return []; }; diff --git a/public/app/features/dashboard/state/initDashboard.ts b/public/app/features/dashboard/state/initDashboard.ts index 710d413ffea..849bfcbc6f3 100644 --- a/public/app/features/dashboard/state/initDashboard.ts +++ b/public/app/features/dashboard/state/initDashboard.ts @@ -43,6 +43,7 @@ export interface InitDashboardArgs { routeName?: string; fixUrl: boolean; keybindingSrv: KeybindingSrv; + dashboardDto?: DashboardDTO; } async function fetchDashboard( @@ -79,6 +80,11 @@ async function fetchDashboard( case DashboardRoutes.Public: { return await dashboardLoaderSrv.loadDashboard('public', args.urlSlug, args.accessToken); } + case DashboardRoutes.Embedded: { + if (args.dashboardDto) { + return args.dashboardDto; + } + } 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 156ebb9e0ff..8aef7aadca0 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -19,7 +19,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'; +import { getEmbeddedDashboardRoutes, getPublicDashboardRoutes } from '../features/dashboard/routes'; export const extraRoutes: RouteDescriptor[] = []; @@ -516,6 +516,7 @@ export function getAppRoutes(): RouteDescriptor[] { ...extraRoutes, ...getPublicDashboardRoutes(), ...getDataConnectionsRoutes(), + ...getEmbeddedDashboardRoutes(), { path: '/*', component: PageNotFound, diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index cd12dfbb7dd..7ac5d3c948f 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -80,6 +80,7 @@ export enum DashboardRoutes { Path = 'path-dashboard', Scripted = 'scripted-dashboard', Public = 'public-dashboard', + Embedded = 'embedded-dashboard', } export enum DashboardInitPhase {