Add generic svg element

This commit is contained in:
Drew Slobodnjak
2025-12-05 12:18:37 -08:00
parent c53d239aae
commit bf2870c8c8
4 changed files with 179 additions and 2 deletions
@@ -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.
+161
View File
@@ -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<SvgConfig, SvgData>) {
const { data } = props;
const styles = useStyles2(getStyles);
if (!data?.content) {
return (
<div className={styles.placeholder}>{t('canvas.svg-element.placeholder', 'Double click to add SVG content')}</div>
);
}
// Check if content already has an SVG wrapper
const hasSvgWrapper = data.content.trim().toLowerCase().startsWith('<svg');
let sanitizedContent: string;
if (hasSvgWrapper) {
// Content already has SVG wrapper, sanitize as-is
sanitizedContent = textUtil.sanitizeSVGContent(data.content);
} else {
// Content is a fragment - wrap in SVG before sanitizing
const wrappedContent = `<svg width="100%" height="100%">${data.content}</svg>`;
sanitizedContent = textUtil.sanitizeSVGContent(wrappedContent);
}
return <div className={styles.container} dangerouslySetInnerHTML={{ __html: sanitizedContent }} />;
}
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<SvgConfig, SvgData> = {
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: '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="currentColor" /></svg>',
},
},
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<SvgConfig>) => {
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 (
<CodeEditor
value={currentValue}
language="xml"
height="200px"
onBlur={(newValue) => {
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: {},
});
},
};
+2
View File
@@ -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];
+2 -2
View File
@@ -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: {