Embedded Dashboard Panels: Add Grafana Branding (#115198)

* feat: add Grafana logo to embedded panels

- Add Grafana logo watermark to solo panel view (embedded panels)
- Logo appears in top-right corner with subtle background container
- Logo hides on hover to avoid interfering with panel content
- Uses React state to track hover for reliable behavior across nested elements

* minor formatting

* update changes to match public dashboards styling

* match styles of public dashboards

* feat: add responsive Grafana branding to embedded panels

- Add 'Powered by Grafana' branding with text logo to solo panel view
- Implement responsive scaling based on panel dimensions (0.6x to 1.0x)
- Logo and text scale proportionally with panel size
- Branding hides on hover to avoid interfering with panel content
- Matches public dashboard branding pattern for consistency
- Uses ResizeObserver for efficient responsive updates

* feat: add Grafana branding to embedded solo panels

- Add 'Powered by Grafana' branding with text logo to embedded panels
- Create SoloPanelPageLogo component for reusable branding
- Implement responsive scaling based on panel dimensions
- Add hover-to-hide functionality to avoid content overlap
- Logo scales between 0.6x and 1.0x based on panel size

* refactor: move scale calculation into SoloPanelPageLogo component

- Move responsive scale calculation logic from SoloPanelRenderer to SoloPanelPageLogo
- Logo component now manages its own scaling based on container dimensions
- Improves separation of concerns and component encapsulation

* feat: add hideLogo query parameter to disable embedded panel branding

- Add hideLogo query parameter support to SoloPanelPage
- Logo can be hidden via ?hideLogo, ?hideLogo=true, or ?hideLogo=1
- Useful for customers who want to disable branding and for image rendering scenarios
- Update Props interface to include hideLogo in queryParams type

* feat: hide logo in panel image renderer URLs

- Add hideLogo=true parameter to image renderer URLs in ShareLinkTab
- Ensures logo is hidden when generating panel images through share feature
- Update test to expect hideLogo=true in render URL

* feat: hide logo in old dashboard sharing panel image URLs

- Add hideLogo=true parameter to buildImageUrl in ShareModal utils
- Ensures logo is hidden when generating panel images through old share modal
- Update all ShareLink tests to expect hideLogo=true in render URLs

* test: add comprehensive tests for SoloPanelPage and SoloPanelPageLogo

- Add SoloPanelPageLogo tests covering rendering, hover behavior, theme selection, and scaling
- Add SoloPanelPage tests covering logo visibility based on hideLogo prop
- Test logo hiding functionality (most important behavior)
- Test responsive scaling based on container dimensions
- Test ResizeObserver integration
- All 14 tests passing

* refactor: centralize hideLogo handling in SoloPanelPageLogo

Move hideLogo parsing and decision-making into SoloPanelPageLogo so SoloPanelPage/SoloPanelRenderer only pass through the raw query param value.

* chore: clean up solo logo test and share link params

Remove a duplicate SVG mock in SoloPanelPageLogo.test, and simplify ShareLinkTab image URL building without changing behavior.

* chore: revert ShareLinkTab image query refactor

Restore the previous image URL query-param mutation logic in ShareLinkTab to reduce risk.

* chore: set hideLogo once for ShareLinkTab image URLs

Avoid passing hideLogo twice when building the rendered image URL.

* fix: handle boolean hideLogo query param in SoloPanelPageLogo

Handle query params that are represented as booleans (e.g., ?hideLogo) and arrays, and avoid calling trim() on non-strings.

* fix i18n

* fix(dashboard-scene): address SoloPanelPageLogo review feedback

Avoid double-scaling logo margin, clarify scaling comments, and extend tests for null/array values and ResizeObserver cleanup.

* update margin left on logo to better match text spacing
This commit is contained in:
Nathan Marrs
2025-12-18 15:01:16 -08:00
committed by GitHub
parent 72e1f1e546
commit 0ec716a433
9 changed files with 663 additions and 25 deletions
@@ -89,7 +89,7 @@ describe('ShareLinkTab', () => {
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
).toHaveAttribute(
'href',
'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=A$panel-12&__feature.dashboardSceneSolo=true&width=1000&height=500&tz=Pacific%2FEaster'
'http://dashboards.grafana.com/grafana/render/d-solo/dash-1?from=2019-02-11T13:00:00.000Z&to=2019-02-11T19:00:00.000Z&panelId=A$panel-12&__feature.dashboardSceneSolo=true&hideLogo=true&width=1000&height=500&tz=Pacific%2FEaster'
);
});
});
@@ -81,6 +81,9 @@ export class ShareLinkTab extends SceneObjectBase<ShareLinkTabState> implements
imageQueryParams['__feature.dashboardSceneSolo'] = true;
}
// hide Grafana logo in the rendered image
urlParamsUpdate.hideLogo = 'true';
const imageUrl = getDashboardUrl({
uid: dashboard.state.uid,
currentQueryParams: window.location.search,
@@ -0,0 +1,152 @@
import { render, screen } from '@testing-library/react';
import { useParams } from 'react-router-dom-v5-compat';
import { SceneTimeRange, VizPanel } from '@grafana/scenes';
import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageStateManager';
import { DashboardScene } from '../scene/DashboardScene';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { SoloPanelRenderer } from './SoloPanelPage';
// Mock dependencies
jest.mock('react-router-dom-v5-compat', () => ({
useParams: jest.fn(),
}));
jest.mock('../pages/DashboardScenePageStateManager', () => ({
getDashboardScenePageStateManager: jest.fn(),
}));
jest.mock('../scene/SoloPanelContext', () => ({
SoloPanelContextProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
useDefineSoloPanelContext: jest.fn(() => ({})),
}));
jest.mock('./SoloPanelPageLogo', () => ({
shouldHideSoloPanelLogo: (hideLogo?: unknown) => {
if (hideLogo === undefined) {
return false;
}
if (hideLogo === true) {
return true;
}
if (hideLogo === false) {
return false;
}
if (Array.isArray(hideLogo)) {
hideLogo = hideLogo[0] ?? '';
}
const normalized = String(hideLogo).trim().toLowerCase();
return normalized !== 'false' && normalized !== '0';
},
SoloPanelPageLogo: ({ isHovered, hideLogo }: { isHovered: boolean; hideLogo?: unknown }) => {
if (hideLogo === true) {
return null;
}
if (hideLogo === false) {
return (
<div data-testid="solo-panel-logo" data-hovered={String(isHovered)}>
Logo
</div>
);
}
if (Array.isArray(hideLogo)) {
hideLogo = hideLogo[0] ?? '';
}
if (hideLogo !== undefined) {
const normalized = String(hideLogo).trim().toLowerCase();
if (normalized !== 'false' && normalized !== '0') {
return null;
}
}
return (
<div data-testid="solo-panel-logo" data-hovered={String(isHovered)}>
Logo
</div>
);
},
}));
describe('SoloPanelPage', () => {
const mockStateManager = {
useState: jest.fn(() => ({
dashboard: null,
loadError: null,
})),
loadDashboard: jest.fn(),
clearState: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
(getDashboardScenePageStateManager as jest.Mock).mockReturnValue(mockStateManager);
(useParams as jest.Mock).mockReturnValue({ uid: 'test-uid', type: undefined, slug: undefined });
});
describe('SoloPanelRenderer', () => {
const createMockDashboard = () => {
const panel = new VizPanel({
title: 'Test Panel',
pluginId: 'table',
key: 'panel-1',
});
const dashboard = new DashboardScene({
title: 'Test Dashboard',
uid: 'test-dash',
$timeRange: new SceneTimeRange({}),
body: DefaultGridLayoutManager.fromVizPanels([panel]),
});
// Mock the activate method
dashboard.activate = jest.fn(() => jest.fn());
// Mock useState to return the dashboard state object with required properties
dashboard.useState = jest.fn(() => ({
controls: {
useState: jest.fn(() => ({
refreshPicker: {
activate: jest.fn(() => jest.fn()),
},
})),
},
body: {
Component: () => <div data-testid="panel-content">Panel Content</div>,
},
})) as unknown as typeof dashboard.useState;
return dashboard;
};
it('should render the panel', () => {
const dashboard = createMockDashboard();
render(<SoloPanelRenderer dashboard={dashboard} panelId="panel-1" hideLogo={undefined} />);
// The panel should be rendered (we can't easily test the actual panel content without more setup)
expect(screen.getByTestId('solo-panel-logo')).toBeInTheDocument();
});
it('should render logo when hideLogo is false', () => {
const dashboard = createMockDashboard();
render(<SoloPanelRenderer dashboard={dashboard} panelId="panel-1" hideLogo={undefined} />);
expect(screen.getByTestId('solo-panel-logo')).toBeInTheDocument();
});
it('should not render logo when hideLogo is true', () => {
const dashboard = createMockDashboard();
render(<SoloPanelRenderer dashboard={dashboard} panelId="panel-1" hideLogo="true" />);
expect(screen.queryByTestId('solo-panel-logo')).not.toBeInTheDocument();
});
it('should initialize with isHovered as false', () => {
const dashboard = createMockDashboard();
render(<SoloPanelRenderer dashboard={dashboard} panelId="panel-1" hideLogo={undefined} />);
const logo = screen.getByTestId('solo-panel-logo');
expect(logo).toHaveAttribute('data-hovered', 'false');
});
});
});
@@ -1,9 +1,9 @@
// Libraries
import { css } from '@emotion/css';
import { useEffect } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, UrlQueryValue } from '@grafana/data';
import { t } from '@grafana/i18n';
import { UrlSyncContextProvider } from '@grafana/scenes';
import { Alert, Box, useStyles2 } from '@grafana/ui';
@@ -17,7 +17,10 @@ import { getDashboardScenePageStateManager } from '../pages/DashboardScenePageSt
import { DashboardScene } from '../scene/DashboardScene';
import { SoloPanelContextProvider, useDefineSoloPanelContext } from '../scene/SoloPanelContext';
export interface Props extends GrafanaRouteComponentProps<DashboardPageRouteParams, { panelId: string }> {}
import { SoloPanelPageLogo } from './SoloPanelPageLogo';
export interface Props
extends GrafanaRouteComponentProps<DashboardPageRouteParams, { panelId: string; hideLogo?: UrlQueryValue }> {}
/**
* Used for iframe embedding and image rendering of single panels
@@ -52,18 +55,28 @@ export function SoloPanelPage({ queryParams }: Props) {
return (
<UrlSyncContextProvider scene={dashboard}>
<SoloPanelRenderer dashboard={dashboard} panelId={queryParams.panelId} />
<SoloPanelRenderer dashboard={dashboard} panelId={queryParams.panelId} hideLogo={queryParams.hideLogo} />
</UrlSyncContextProvider>
);
}
export default SoloPanelPage;
export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: DashboardScene; panelId: string }) {
export function SoloPanelRenderer({
dashboard,
panelId,
hideLogo,
}: {
dashboard: DashboardScene;
panelId: string;
hideLogo?: UrlQueryValue;
}) {
const { controls, body } = dashboard.useState();
const refreshPicker = controls?.useState()?.refreshPicker;
const styles = useStyles2(getStyles);
const soloPanelContext = useDefineSoloPanelContext(panelId)!;
const [isHovered, setIsHovered] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const dashDeactivate = dashboard.activate();
@@ -76,11 +89,19 @@ export function SoloPanelRenderer({ dashboard, panelId }: { dashboard: Dashboard
}, [dashboard, refreshPicker]);
return (
<div className={styles.container}>
<div
ref={containerRef}
className={styles.container}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<SoloPanelPageLogo containerRef={containerRef} isHovered={isHovered} hideLogo={hideLogo} />
{renderHiddenVariables(dashboard)}
<SoloPanelContextProvider value={soloPanelContext} dashboard={dashboard} singleMatch={true}>
<body.Component model={body} />
</SoloPanelContextProvider>
<div className={styles.panelWrapper}>
<SoloPanelContextProvider value={soloPanelContext} dashboard={dashboard} singleMatch={true}>
<body.Component model={body} />
</SoloPanelContextProvider>
</div>
</div>
);
}
@@ -107,15 +128,23 @@ function renderHiddenVariables(dashboard: DashboardScene) {
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
position: 'fixed',
bottom: 0,
right: 0,
margin: 0,
left: 0,
top: 0,
const getStyles = (theme: GrafanaTheme2) => {
const panelWrapper = css({
width: '100%',
height: '100%',
}),
});
});
return {
container: css({
position: 'fixed',
bottom: 0,
right: 0,
margin: 0,
left: 0,
top: 0,
width: '100%',
height: '100%',
}),
panelWrapper,
};
};
@@ -0,0 +1,291 @@
import { render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { shouldHideSoloPanelLogo, SoloPanelPageLogo } from './SoloPanelPageLogo';
// Mock the theme hook
const mockUseTheme2 = jest.fn();
const mockUseStyles2 = jest.fn((fn) => fn({} as GrafanaTheme2));
jest.mock('@grafana/ui', () => ({
...jest.requireActual('@grafana/ui'),
useTheme2: () => mockUseTheme2(),
useStyles2: (fn: (theme: GrafanaTheme2) => Record<string, unknown>) => mockUseStyles2(fn),
}));
// Mock the logo images for dark and light modes
jest.mock('img/grafana_text_logo_dark.svg', () => 'grafana-text-logo-dark.svg');
jest.mock('img/grafana_text_logo_light.svg', () => 'grafana-text-logo-light.svg');
// Mock ResizeObserver
global.ResizeObserver = jest.fn().mockImplementation((callback) => {
return {
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
// Helper to trigger resize
trigger: (width: number, height: number) => {
callback([{ contentRect: { width, height } }]);
},
};
});
// Helper function to assign a mock div to a ref
function assignMockDivToRef(ref: React.RefObject<HTMLDivElement>, mockDiv: HTMLDivElement) {
// Use type assertion to bypass readonly restriction in tests
(ref as { current: HTMLDivElement | null }).current = mockDiv;
}
describe('SoloPanelPageLogo', () => {
describe('shouldHideSoloPanelLogo', () => {
it('treats null as false', () => {
expect(shouldHideSoloPanelLogo(null)).toBe(false);
});
it('treats presence (empty string) as true', () => {
expect(shouldHideSoloPanelLogo('')).toBe(true);
});
it('treats true/1 as true', () => {
expect(shouldHideSoloPanelLogo('true')).toBe(true);
expect(shouldHideSoloPanelLogo('1')).toBe(true);
expect(shouldHideSoloPanelLogo(' TRUE ')).toBe(true);
});
it('treats false/0 as false', () => {
expect(shouldHideSoloPanelLogo('false')).toBe(false);
expect(shouldHideSoloPanelLogo('0')).toBe(false);
expect(shouldHideSoloPanelLogo(' FALSE ')).toBe(false);
});
it('treats boolean true as true and boolean false as false', () => {
expect(shouldHideSoloPanelLogo(true)).toBe(true);
expect(shouldHideSoloPanelLogo(false)).toBe(false);
});
it('treats undefined as false', () => {
expect(shouldHideSoloPanelLogo(undefined)).toBe(false);
});
it('handles array values (uses the first value)', () => {
expect(shouldHideSoloPanelLogo([''])).toBe(true);
expect(shouldHideSoloPanelLogo(['true'])).toBe(true);
expect(shouldHideSoloPanelLogo(['1'])).toBe(true);
expect(shouldHideSoloPanelLogo(['false'])).toBe(false);
expect(shouldHideSoloPanelLogo(['0'])).toBe(false);
expect(shouldHideSoloPanelLogo(['false', 'true'])).toBe(false);
});
});
const mockTheme = {
isDark: false,
colors: {
background: { primary: '#ffffff' },
border: { weak: '#e0e0e0' },
text: { secondary: '#666666' },
},
shape: { radius: { default: '4px' } },
shadows: { z3: '0 2px 4px rgba(0,0,0,0.1)' },
typography: { body: { fontSize: '14px' } },
spacing: jest.fn((n: number) => `${n * 8}px`),
transitions: {
handleMotion: jest.fn(() => ({})),
},
} as unknown as GrafanaTheme2;
beforeEach(() => {
jest.clearAllMocks();
mockUseTheme2.mockReturnValue({
...mockTheme,
isDark: false,
});
mockUseStyles2.mockImplementation((fn) => fn(mockTheme));
});
it('should render the logo component', () => {
const containerRef = createRef<HTMLDivElement>();
const mockDiv = document.createElement('div');
mockDiv.getBoundingClientRect = jest.fn(() => ({
width: 800,
height: 600,
top: 0,
left: 0,
bottom: 600,
right: 800,
x: 0,
y: 0,
toJSON: jest.fn(),
}));
assignMockDivToRef(containerRef, mockDiv);
render(<SoloPanelPageLogo containerRef={containerRef} isHovered={false} hideLogo={undefined} />);
expect(screen.getByText('Powered by')).toBeInTheDocument();
expect(screen.getByAltText('Grafana')).toBeInTheDocument();
});
it('should hide logo when isHovered is true', () => {
const containerRef = createRef<HTMLDivElement>();
const mockDiv = document.createElement('div');
mockDiv.getBoundingClientRect = jest.fn(() => ({
width: 800,
height: 600,
top: 0,
left: 0,
bottom: 600,
right: 800,
x: 0,
y: 0,
toJSON: jest.fn(),
}));
assignMockDivToRef(containerRef, mockDiv);
render(<SoloPanelPageLogo containerRef={containerRef} isHovered={true} hideLogo={undefined} />);
// The logo should still be in the DOM but with reduced opacity
const poweredByText = screen.getByText('Powered by');
expect(poweredByText).toBeInTheDocument();
// The logoHidden class should be applied (we can't easily test the class name without more setup)
});
it('should show logo when isHovered is false', () => {
const containerRef = createRef<HTMLDivElement>();
const mockDiv = document.createElement('div');
mockDiv.getBoundingClientRect = jest.fn(() => ({
width: 800,
height: 600,
top: 0,
left: 0,
bottom: 600,
right: 800,
x: 0,
y: 0,
toJSON: jest.fn(),
}));
assignMockDivToRef(containerRef, mockDiv);
render(<SoloPanelPageLogo containerRef={containerRef} isHovered={false} hideLogo={undefined} />);
// The logo should be visible
expect(screen.getByText('Powered by')).toBeInTheDocument();
expect(screen.getByAltText('Grafana')).toBeInTheDocument();
});
it('should use dark logo in dark theme', () => {
const containerRef = createRef<HTMLDivElement>();
const mockDiv = document.createElement('div');
mockDiv.getBoundingClientRect = jest.fn(() => ({
width: 800,
height: 600,
top: 0,
left: 0,
bottom: 600,
right: 800,
x: 0,
y: 0,
toJSON: jest.fn(),
}));
assignMockDivToRef(containerRef, mockDiv);
mockUseTheme2.mockReturnValue({
...mockTheme,
isDark: true,
});
render(<SoloPanelPageLogo containerRef={containerRef} isHovered={false} hideLogo={undefined} />);
const logo = screen.getByAltText('Grafana');
expect(logo).toHaveAttribute('src', 'grafana-text-logo-light.svg');
});
it('should use correct logo based on theme', () => {
const containerRef = createRef<HTMLDivElement>();
const mockDiv = document.createElement('div');
mockDiv.getBoundingClientRect = jest.fn(() => ({
width: 800,
height: 600,
top: 0,
left: 0,
bottom: 600,
right: 800,
x: 0,
y: 0,
toJSON: jest.fn(),
}));
assignMockDivToRef(containerRef, mockDiv);
// The beforeEach sets isDark: false by default, so this should work
// But the previous test might have changed it, so let's ensure it's reset
mockUseTheme2.mockClear();
mockUseTheme2.mockReturnValue({
...mockTheme,
isDark: false,
});
render(<SoloPanelPageLogo containerRef={containerRef} isHovered={false} hideLogo={undefined} />);
const logo = screen.getByAltText('Grafana');
// Verify logo is rendered (the exact src depends on theme, which is tested in other tests)
expect(logo).toBeInTheDocument();
expect(logo).toHaveAttribute('src');
});
it('should apply scaling styles based on container dimensions', () => {
const containerRef = createRef<HTMLDivElement>();
const mockDiv = document.createElement('div');
mockDiv.getBoundingClientRect = jest.fn(() => ({
width: 400,
height: 300,
top: 0,
left: 0,
bottom: 300,
right: 400,
x: 0,
y: 0,
toJSON: jest.fn(),
}));
assignMockDivToRef(containerRef, mockDiv);
render(<SoloPanelPageLogo containerRef={containerRef} isHovered={false} hideLogo={undefined} />);
// Find the logo container by looking for the "Powered by" text's parent
const poweredByText = screen.getByText('Powered by');
const logoContainer = poweredByText.parentElement as HTMLElement;
expect(logoContainer).toBeInTheDocument();
// Check that inline styles are applied (scaling should be between 0.6 and 1.0)
expect(logoContainer.style.fontSize).toBeTruthy();
expect(logoContainer.style.top).toBeTruthy();
expect(logoContainer.style.right).toBeTruthy();
});
it('should observe container resize', () => {
const containerRef = createRef<HTMLDivElement>();
const mockDiv = document.createElement('div');
mockDiv.getBoundingClientRect = jest.fn(() => ({
width: 800,
height: 600,
top: 0,
left: 0,
bottom: 600,
right: 800,
x: 0,
y: 0,
toJSON: jest.fn(),
}));
assignMockDivToRef(containerRef, mockDiv);
const { unmount } = render(
<SoloPanelPageLogo containerRef={containerRef} isHovered={false} hideLogo={undefined} />
);
expect(ResizeObserver).toHaveBeenCalled();
const resizeObserverInstance = (ResizeObserver as jest.Mock).mock.results[0].value;
expect(resizeObserverInstance.observe).toHaveBeenCalledWith(mockDiv);
unmount();
expect(resizeObserverInstance.disconnect).toHaveBeenCalled();
});
});
@@ -0,0 +1,159 @@
import { css, cx } from '@emotion/css';
import { useEffect, useState } from 'react';
import { GrafanaTheme2, UrlQueryValue } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { useStyles2, useTheme2 } from '@grafana/ui';
import grafanaTextLogoDarkSvg from 'img/grafana_text_logo_dark.svg';
import grafanaTextLogoLightSvg from 'img/grafana_text_logo_light.svg';
interface SoloPanelPageLogoProps {
containerRef: React.RefObject<HTMLDivElement>;
isHovered: boolean;
hideLogo?: UrlQueryValue;
}
export function shouldHideSoloPanelLogo(hideLogo?: UrlQueryValue): boolean {
if (hideLogo === undefined || hideLogo === null) {
return false;
}
// React-router / locationSearchToObject can represent a "present but no value" query param as boolean true.
if (hideLogo === true) {
return true;
}
if (hideLogo === false) {
return false;
}
const value = Array.isArray(hideLogo) ? String(hideLogo[0] ?? '') : String(hideLogo);
// Treat presence as "true", except explicit disable values.
// Examples:
// - ?hideLogo => hide
// - ?hideLogo=true => hide
// - ?hideLogo=1 => hide
// - ?hideLogo=false => show
// - ?hideLogo=0 => show
const normalized = value.trim().toLowerCase();
return normalized !== 'false' && normalized !== '0';
}
export function SoloPanelPageLogo({ containerRef, isHovered, hideLogo }: SoloPanelPageLogoProps) {
const shouldHide = shouldHideSoloPanelLogo(hideLogo);
const [scale, setScale] = useState(1);
const styles = useStyles2(getStyles);
const theme = useTheme2();
const grafanaLogo = theme.isDark ? grafanaTextLogoLightSvg : grafanaTextLogoDarkSvg;
// Calculate responsive scale based on panel dimensions
useEffect(() => {
const updateScale = () => {
if (!containerRef.current) {
return;
}
const { width, height } = containerRef.current.getBoundingClientRect();
// Use the smaller dimension to ensure it scales appropriately for both wide and tall panels
const minDimension = Math.min(width, height);
// Base scale calculation: scales from 0.6 (for small panels ~200px) up to 1.0 when the smaller dimension is ~800px
// Clamp to a maximum of 1.0 for larger panels
const baseScale = Math.max(0.6, Math.min(1.0, 0.6 + (minDimension - 200) / 600));
// Also consider width specifically for very wide but short panels; reaches 1.0 when width is ~1000px
const widthScale = Math.max(0.6, Math.min(1.0, 0.6 + (width - 200) / 800));
// Use the average of both for balanced scaling; panels around 1000x1000px (or larger in both dimensions) reach a scale of 1.0
const finalScale = Math.min(1.0, (baseScale + widthScale) / 2);
setScale(finalScale);
};
updateScale();
const resizeObserver = new ResizeObserver(updateScale);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, [containerRef]);
if (shouldHide) {
return null;
}
return (
<div
className={cx(styles.logoContainer, isHovered && styles.logoHidden)}
style={{
fontSize: `${scale * 100}%`,
top: `${8 * scale}px`,
right: `${8 * scale}px`,
padding: `${8 * scale}px ${8 * scale}px`,
}}
>
<span className={styles.text}>
<Trans i18nKey="embedded-panel.powered-by">Powered by</Trans>
</span>
<img
src={grafanaLogo}
alt="Grafana"
className={styles.logo}
style={{
height: `${16 * scale}px`,
marginLeft: '0.25em',
}}
/>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
const logoContainer = css({
position: 'absolute',
// top, right, and padding will be set via inline styles for scaling
backgroundColor: theme.colors.background.primary,
borderRadius: theme.shape.radius.default,
opacity: 0.9,
pointerEvents: 'none',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
boxShadow: theme.shadows.z3,
border: `1px solid ${theme.colors.border.weak}`,
// Base font size - will be scaled via inline style
fontSize: theme.typography.body.fontSize,
lineHeight: 1.2,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'opacity 0.2s ease-in-out',
},
});
const logoHidden = css({
opacity: 0,
});
const text = css({
color: theme.colors.text.secondary,
// fontSize will be inherited from parent container's scale
lineHeight: 1.2,
display: 'block',
});
const logo = css({
// height will be set via inline style (16px * scale) to scale with panel size
display: 'block',
flexShrink: 0,
});
return {
logoContainer,
logoHidden,
text,
logo,
};
};
@@ -106,7 +106,7 @@ describe('ShareModal', () => {
render(<ShareLink {...props} />);
const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&scale=1&tz=UTC';
const params = '?from=1000&to=2000&orgId=1&panelId=22&hideLogo=true&width=1000&height=500&scale=1&tz=UTC';
expect(
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
).toHaveAttribute('href', base + params);
@@ -117,7 +117,7 @@ describe('ShareModal', () => {
render(<ShareLink {...props} />);
const base = 'http://dashboards.grafana.com/render/dashboard-solo/script/my-dash.js';
const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&scale=1&tz=UTC';
const params = '?from=1000&to=2000&orgId=1&panelId=22&hideLogo=true&width=1000&height=500&scale=1&tz=UTC';
expect(
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
).toHaveAttribute('href', base + params);
@@ -154,7 +154,7 @@ describe('ShareModal', () => {
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
).toHaveAttribute(
'href',
base + path + '?from=1000&to=2000&orgId=1&panelId=1&width=1000&height=500&scale=1&tz=UTC'
base + path + '?from=1000&to=2000&orgId=1&panelId=1&hideLogo=true&width=1000&height=500&scale=1&tz=UTC'
);
});
@@ -172,7 +172,7 @@ describe('ShareModal', () => {
render(<ShareLink {...props} />);
const base = 'http://dashboards.grafana.com/render/d-solo/abcdefghi/my-dash';
const params = '?from=1000&to=2000&orgId=1&panelId=22&width=1000&height=500&scale=1&tz=UTC';
const params = '?from=1000&to=2000&orgId=1&panelId=22&hideLogo=true&width=1000&height=500&scale=1&tz=UTC';
expect(
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
).toHaveAttribute('href', base + params);
@@ -213,7 +213,7 @@ describe('when appUrl is set in the grafana config', () => {
await screen.findByRole('link', { name: selectors.pages.SharePanelModal.linkToRenderedImage })
).toHaveAttribute(
'href',
`http://dashboards.grafana.com/render/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}&width=1000&height=500&scale=1&tz=UTC`
`http://dashboards.grafana.com/render/d-solo/${mockDashboard.uid}?orgId=1&from=1000&to=2000&panelId=${mockPanel.id}&hideLogo=true&width=1000&height=500&scale=1&tz=UTC`
);
});
});
@@ -142,6 +142,7 @@ export function buildImageUrl(
let imageUrl = soloUrl.replace(config.appSubUrl + '/dashboard-solo/', config.appSubUrl + '/render/dashboard-solo/');
imageUrl = imageUrl.replace(config.appSubUrl + '/d-solo/', config.appSubUrl + '/render/d-solo/');
imageUrl +=
`&hideLogo=true` +
`&width=${config.rendererDefaultImageWidth}` +
`&height=${config.rendererDefaultImageHeight}` +
`&scale=${config.rendererDefaultImageScale}` +
+3
View File
@@ -7166,6 +7166,9 @@
"time-range-label": "Lock time range"
}
},
"embedded-panel": {
"powered-by": "Powered by"
},
"empty-list-cta": {
"pro-tip": "ProTip: {{proTip}}"
},