Dashboards: Add dashboard embed route (#69596)

* Dashboard embed: Set up route

* Dashboard embed: Cleanup

* Dashboard embed: Separate routes

* Dashboard embed: Render dashboard page

* Dashboard embed: Add toolbar

* Dashboard embed: Send JSON on save

* Dashboard embed: Add JSON param

* Dashboard embed: Make the dashboard editable

* Fix sending dashboard to remote server

* Add notifications

* Add "dashboardEmbed" feature toggle

* Use the toggle

* Update toggles

* Add toggle on backend

* Add get JSON endpoint

* Add drawer

* Close drawer on success

* Update toggles

* Cleanup

* Update toggle

* Allow embedding for the d-embed url

* Allow embedding via custom X-Allow-Embedding header

* Use callbackUrl

* Cleanup

* Update public/app/features/dashboard/containers/EmbeddedDashboardPage.tsx

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>

* Use theme for spacing

* Update toggles

* Update public/app/features/dashboard/components/EmbeddedDashboard/SaveDashboardForm.tsx

Co-authored-by: Polina Boneva <13227501+polibb@users.noreply.github.com>

* Add select data source modal

---------

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>
Co-authored-by: Polina Boneva <13227501+polibb@users.noreply.github.com>
This commit is contained in:
Alex Khomenko
2023-07-06 17:43:20 +03:00
committed by GitHub
parent a8d2a9ae2b
commit 420b19e0e4
14 changed files with 336 additions and 2 deletions

View File

@@ -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 |

View File

@@ -98,6 +98,7 @@ export interface FeatureToggles {
extraThemes?: boolean;
lokiPredefinedOperations?: boolean;
pluginsFrontendSandbox?: boolean;
dashboardEmbed?: boolean;
frontendSandboxMonitorOnly?: boolean;
sqlDatasourceDatabaseSelection?: boolean;
cloudWatchLogsMonacoEditor?: boolean;

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)",

View File

@@ -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
1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
79 extraThemes experimental @grafana/grafana-frontend-platform false false false true
80 lokiPredefinedOperations experimental @grafana/observability-logs false false false true
81 pluginsFrontendSandbox experimental @grafana/plugins-platform-backend false false false true
82 dashboardEmbed experimental @grafana/grafana-as-code false false false true
83 frontendSandboxMonitorOnly experimental @grafana/plugins-platform-backend false false false true
84 sqlDatasourceDatabaseSelection preview @grafana/grafana-bi-squad false false false true
85 cloudWatchLogsMonacoEditor experimental @grafana/aws-plugins false false false true

View File

@@ -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"

View File

@@ -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<unknown>;
};
export const SaveDashboardDrawer = ({ dashboard, onDismiss, dashboardJson, onSave }: SaveDashboardDrawerProps) => {
const data = useMemo<SaveDashboardData>(() => {
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 (
<Drawer
title={'Save dashboard'}
onClose={onDismiss}
subtitle={dashboard.title}
tabs={
<TabsBar>
<Tab label={'Details'} active={!showDiff} onChangeTab={() => setShowDiff(false)} />
{data.hasChanges && (
<Tab label={'Changes'} active={showDiff} onChangeTab={() => setShowDiff(true)} counter={data.diffCount} />
)}
</TabsBar>
}
scrollableContent
>
{showDiff ? (
<SaveDashboardDiff diff={data.diff} oldValue={dashboardJson} newValue={data.clone} />
) : (
<SaveDashboardForm
dashboard={dashboard}
saveModel={data}
onCancel={onDismiss}
onSuccess={onDismiss}
onSubmit={onSave}
/>
)}
{config.featureToggles.showDashboardValidationWarnings && <DashboardValidation dashboard={dashboard} />}
</Drawer>
);
};

View File

@@ -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<unknown>;
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 (
<Form onSubmit={onFormSubmit}>
{() => {
return (
<Stack gap={2}>
<Stack alignItems="center">
<Button variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button type="submit" disabled={!hasChanges} icon={saving ? 'fa fa-spinner' : undefined}>
Save
</Button>
{!hasChanges && <div>No changes to save</div>}
</Stack>
</Stack>
);
}}
</Form>
);
};

View File

@@ -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 <DashboardLoading initPhase={dashboardState.initPhase} />;
}
if (dashboard.meta.dashboardNotFound) {
return <p>Not available</p>;
}
return (
<Page pageNav={{ text: dashboard.title }} layout={PageLayoutType.Custom}>
<Toolbar dashboard={dashboard} callbackUrl={queryParams.callbackUrl} dashboardJson={dashboardJson} />
{dashboardState.initError && <DashboardFailed initError={dashboardState.initError} />}
<div>
<DashboardGrid dashboard={dashboard} isEditable viewPanel={null} editPanel={null} hidePanelMenus />
</div>
</Page>
);
}
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 (
<PageToolbar title={dashboard.title} buttonOverflowAlignment="right" className={styles.toolbar}>
{!dashboard.timepicker.hidden && (
<DashNavTimeControls dashboard={dashboard} onChangeTimeZone={onChangeTimeZone} />
)}
<ModalsController key="button-save">
{({ showModal, hideModal }) => (
<Button
onClick={() => {
showModal(SaveDashboardDrawer, {
dashboard,
dashboardJson,
onDismiss: hideModal,
onSave: saveDashboard,
});
}}
>
Save
</Button>
)}
</ModalsController>
</PageToolbar>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
toolbar: css`
padding: ${theme.spacing(3, 2)};
`,
};
};

View File

@@ -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 [];
};

View File

@@ -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);

View File

@@ -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,

View File

@@ -80,6 +80,7 @@ export enum DashboardRoutes {
Path = 'path-dashboard',
Scripted = 'scripted-dashboard',
Public = 'public-dashboard',
Embedded = 'embedded-dashboard',
}
export enum DashboardInitPhase {