mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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 |
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface FeatureToggles {
|
||||
extraThemes?: boolean;
|
||||
lokiPredefinedOperations?: boolean;
|
||||
pluginsFrontendSandbox?: boolean;
|
||||
dashboardEmbed?: boolean;
|
||||
frontendSandboxMonitorOnly?: boolean;
|
||||
sqlDatasourceDatabaseSelection?: boolean;
|
||||
cloudWatchLogsMonacoEditor?: boolean;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)};
|
||||
`,
|
||||
};
|
||||
};
|
||||
@@ -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 [];
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -80,6 +80,7 @@ export enum DashboardRoutes {
|
||||
Path = 'path-dashboard',
|
||||
Scripted = 'scripted-dashboard',
|
||||
Public = 'public-dashboard',
|
||||
Embedded = 'embedded-dashboard',
|
||||
}
|
||||
|
||||
export enum DashboardInitPhase {
|
||||
|
||||
Reference in New Issue
Block a user