diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/canvas/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/canvas/index.md index 821d547d7d3..478cef2fd1b 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/canvas/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/canvas/index.md @@ -128,6 +128,20 @@ The server element lets you easily represent a single server, a stack of servers {{< figure src="/media/docs/grafana/canvas-server-element-9-4-0.png" max-width="650px" alt="Canvas server element" >}} +#### SVG + +The SVG element lets you add custom SVG graphics to the canvas. You can enter raw SVG markup in the content field, and the element will render it with proper sanitization to prevent XSS attacks. This element is useful for creating custom icons, logos, or complex graphics that aren't available in the standard shape elements. + +SVG element features: + +- **Sanitized content**: All SVG content is automatically sanitized for security +- **Data binding**: SVG content can be bound to field data using template variables +- **Scalable**: SVG graphics scale cleanly at any size + +The SVG element supports the following configuration options: + +- **SVG Content**: Enter raw SVG markup. Content will be sanitized automatically. + #### Button The button element lets you add a basic button to the canvas. Button elements support triggering basic, unauthenticated API calls. [API settings](#button-api-options) are found in the button element editor. You can also pass template variables in the API editor. diff --git a/public/app/features/canvas/elements/svg.tsx b/public/app/features/canvas/elements/svg.tsx new file mode 100644 index 00000000000..1d2a634e4df --- /dev/null +++ b/public/app/features/canvas/elements/svg.tsx @@ -0,0 +1,161 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2, textUtil } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { PositionDimensionMode, ScalarDimensionMode, TextDimensionConfig, TextDimensionMode } from '@grafana/schema'; +import { CodeEditor, useStyles2 } from '@grafana/ui'; +import { DimensionContext } from 'app/features/dimensions/context'; + +import { CanvasElementItem, CanvasElementOptions, CanvasElementProps } from '../element'; + +export interface SvgConfig { + content?: TextDimensionConfig; +} + +interface SvgData { + content: string; +} + +export function SvgDisplay(props: CanvasElementProps) { + const { data } = props; + const styles = useStyles2(getStyles); + + if (!data?.content) { + return ( +
{t('canvas.svg-element.placeholder', 'Double click to add SVG content')}
+ ); + } + + // Check if content already has an SVG wrapper + const hasSvgWrapper = data.content.trim().toLowerCase().startsWith('${data.content}`; + sanitizedContent = textUtil.sanitizeSVGContent(wrappedContent); + } + + return
; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + '& svg': { + width: '100%', + height: '100%', + }, + }), + placeholder: css({ + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + textAlign: 'center', + padding: theme.spacing(1), + border: `1px dashed ${theme.colors.border.weak}`, + borderRadius: theme.shape.radius.default, + }), +}); + +export const svgItem: CanvasElementItem = { + id: 'svg', + name: 'SVG', + description: 'Generic SVG element with sanitized content', + + display: SvgDisplay, + + hasEditMode: false, + + defaultSize: { + width: 100, + height: 100, + }, + + getNewOptions: (options) => ({ + ...options, + config: { + content: { + mode: TextDimensionMode.Fixed, + fixed: '', + }, + }, + background: { + color: { + fixed: 'transparent', + }, + }, + placement: { + width: options?.placement?.width ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + height: options?.placement?.height ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + top: options?.placement?.top ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + left: options?.placement?.left ?? { fixed: 100, mode: PositionDimensionMode.Fixed }, + rotation: options?.placement?.rotation ?? { fixed: 0, mode: ScalarDimensionMode.Clamped, min: 0, max: 360 }, + }, + links: options?.links ?? [], + }), + + prepareData: (dimensionContext: DimensionContext, elementOptions: CanvasElementOptions) => { + const svgConfig = elementOptions.config; + + const data: SvgData = { + content: svgConfig?.content ? dimensionContext.getText(svgConfig.content).value() : '', + }; + + return data; + }, + + registerOptionsUI: (builder) => { + const category = [t('canvas.svg-element.category', 'SVG')]; + + builder.addCustomEditor({ + category, + id: 'svgContent', + path: 'config.content', + name: t('canvas.svg-element.content', 'SVG Content'), + description: t('canvas.svg-element.content-description', 'Enter SVG content.'), + editor: ({ value, onChange }) => { + const currentValue = value?.fixed || ''; + + return ( + { + onChange({ + ...value, + mode: TextDimensionMode.Fixed, + fixed: newValue, + }); + }} + monacoOptions={{ + minimap: { enabled: false }, + lineNumbers: 'on', + wordWrap: 'on', + scrollBeyondLastLine: false, + folding: false, + renderLineHighlight: 'none', + overviewRulerBorder: false, + hideCursorInOverviewRuler: true, + overviewRulerLanes: 0, + }} + /> + ); + }, + settings: {}, + }); + }, +}; diff --git a/public/app/features/canvas/registry.ts b/public/app/features/canvas/registry.ts index 61c2be7409e..9ebad902087 100644 --- a/public/app/features/canvas/registry.ts +++ b/public/app/features/canvas/registry.ts @@ -12,6 +12,7 @@ import { metricValueItem } from './elements/metricValue'; import { parallelogramItem } from './elements/parallelogram'; import { rectangleItem } from './elements/rectangle'; import { serverItem } from './elements/server/server'; +import { svgItem } from './elements/svg'; import { textItem } from './elements/text'; import { triangleItem } from './elements/triangle'; import { windTurbineItem } from './elements/windTurbine'; @@ -33,6 +34,7 @@ export const defaultElementItems = [ triangleItem, cloudItem, parallelogramItem, + svgItem, ]; export const advancedElementItems = [buttonItem, windTurbineItem, droneTopItem, droneFrontItem, droneSideItem]; diff --git a/public/app/plugins/panel/canvas/panelcfg.cue b/public/app/plugins/panel/canvas/panelcfg.cue index 72c324a9594..81e302a6cb0 100644 --- a/public/app/plugins/panel/canvas/panelcfg.cue +++ b/public/app/plugins/panel/canvas/panelcfg.cue @@ -34,7 +34,7 @@ composableKinds: PanelCfg: { vertical?: VerticalConstraint } @cuetsy(kind="interface") - Placement: { + Placement: { top?: ui.PositionDimensionConfig left?: ui.PositionDimensionConfig right?: ui.PositionDimensionConfig @@ -44,7 +44,7 @@ composableKinds: PanelCfg: { height?: ui.PositionDimensionConfig rotation?: ui.ScalarDimensionConfig - } @cuetsy(kind="interface") + } @cuetsy(kind="interface") BackgroundImageSize: "original" | "contain" | "cover" | "fill" | "tile" @cuetsy(kind="enum", memberNames="Original|Contain|Cover|Fill|Tile") BackgroundConfig: {