Skip to content
77 changes: 62 additions & 15 deletions ui/apps/pmm-compat/src/compat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { locationService } from '@grafana/runtime';
import { locationService, getAppEvents, ThemeChangedEvent, config } from '@grafana/runtime';
import {
ChangeThemeMessage,
CrossFrameMessenger,
Expand Down Expand Up @@ -33,36 +33,29 @@ export const initialize = () => {
if (!msg.payload) {
return;
}

changeTheme(msg.payload.theme);
},
});

messenger.sendMessage({
type: 'MESSENGER_READY',
});
messenger.sendMessage({ type: 'MESSENGER_READY' });

// set docked state to false
localStorage.setItem(GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, 'false');

applyCustomStyles();

adjustToolbar();

// sync with PMM UI theme
changeTheme('light');
// Wire Grafana theme events to Percona scheme attribute and notify parent
setupThemeWiring();

messenger.sendMessage({
type: 'GRAFANA_READY',
});
messenger.sendMessage({ type: 'GRAFANA_READY' });

messenger.addListener({
type: 'LOCATION_CHANGE',
onMessage: ({ payload: location }: LocationChangeMessage) => {
if (!location) {
return;
}

locationService.replace(location);
},
});
Expand All @@ -71,6 +64,7 @@ export const initialize = () => {
type: 'DOCUMENT_TITLE_CHANGE',
payload: { title: document.title },
});

documentTitleObserver.listen((title) => {
messenger.sendMessage({
type: 'DOCUMENT_TITLE_CHANGE',
Expand Down Expand Up @@ -108,10 +102,63 @@ export const initialize = () => {
messenger.sendMessage({
id: msg.id,
type: msg.type,
payload: {
url: url,
},
payload: { url },
});
},
});
};

/**
* Wires Grafana theme changes (ThemeChangedEvent) to Percona CSS scheme.
* Ensures <html> has the attribute our CSS reads and informs the parent frame.
*/
function setupThemeWiring() {
// Resolve parent origin robustly (dev vs prod)
const getParentOrigin = (): string => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Origin handling shouldn't be needed.

try {
const u = new URL(document.referrer);
return `${u.protocol}//${u.host}`;
} catch {
return '*';
}
};

const isDev =
location.hostname === 'localhost' ||
location.hostname === '127.0.0.1' ||
/^\d+\.\d+\.\d+\.\d+$/.test(location.hostname);

const targetOrigin = isDev ? '*' : getParentOrigin();

// Helper to apply our scheme attribute and notify parent
const apply = (incoming: 'light' | 'dark' | string) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a possibility of the theme being other than light/dark at the moment? (In Grafana 12 they have additional themes - https://grafana.com/whats-new/2025-04-11-introducing-experimental-themes/, but we're still on 11)

// normalize to 'light' | 'dark'
const mode: 'light' | 'dark' = String(incoming).toLowerCase() === 'dark' ? 'dark' : 'light';

const html = document.documentElement;
const scheme = mode === 'dark' ? 'percona-dark' : 'percona-light';

// Set attributes our CSS reads
html.setAttribute('data-md-color-scheme', scheme);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to modify the HTML attributes?

html.setAttribute('data-theme', mode);
(html.style as CSSStyleDeclaration & { colorScheme: string }).colorScheme = mode;
// Notify outer PMM UI (new nav) to sync immediately
try {
const target = window.top && window.top !== window ? window.top : window.parent || window;
target?.postMessage({ type: 'grafana.theme.changed', payload: { mode } }, targetOrigin);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use messenger to handle the communication between the iframe and pmm-ui (no need to handle the target, etc.). We also use predefined message types (see ui/packages/shared/src/types.ts)

} catch (err) {
// eslint-disable-next-line no-console
console.warn('[pmm-compat] failed to post grafana.theme.changed:', err);
}
};

// Initial apply from current Grafana theme config
const initialMode = (config?.theme2?.colors?.mode === 'dark' ? 'dark' : 'light') as 'light' | 'dark';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initial sync shouldn't happen in the body of the file (since it would get called outside the iframe aswell)

apply(initialMode);

// React to Grafana theme changes (Preferences change/changeTheme())
getAppEvents().subscribe(ThemeChangedEvent, (evt: any) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move to the body of initialize method.

const next = evt?.payload?.colors?.mode ?? (evt?.payload?.isDark ? 'dark' : 'light') ?? 'light';
apply(next);
});
}
51 changes: 49 additions & 2 deletions ui/apps/pmm-compat/src/contexts/theme/theme.provider.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,62 @@
// pmm-compat/src/providers/ThemeProvider.tsx
// ------------------------------------------------------
// Grafana-side theme provider + bridge:
// 1) Mirrors Grafana theme into ThemeContext (for plugins).
// 2) Applies canonical DOM attributes inside the iframe.
// 3) Notifies host UI via postMessage so host can re-theme instantly.
// ------------------------------------------------------

import React, { FC, PropsWithChildren, useEffect, useRef, useState } from 'react';
import { ThemeContext } from '@grafana/data';
import { config, getAppEvents, ThemeChangedEvent } from '@grafana/runtime';
import React, { FC, PropsWithChildren, useEffect, useState } from 'react';

type Mode = 'light' | 'dark';

export const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need to touch this provider at all, I think I'll add a comment there, but this specifically is used just for two toolbar buttons used inside the grafana iframe where we extend the functionality. See ui/apps/pmm-compat/src/compat/toolbar.tsx for usage, but in short we append two divs which we then take as root for two small react apps (just a button for each)

const [theme, setTheme] = useState(config.theme2);
const lastSentModeRef = useRef<Mode>(config.theme2.isDark ? 'dark' : 'light');

useEffect(() => {
getAppEvents().subscribe(ThemeChangedEvent, (event) => {
// Apply DOM attributes for the current theme on mount.
applyIframeDomTheme(lastSentModeRef.current);
// Also notify host once on mount (defensive; host may already be in sync).
postModeToHost(lastSentModeRef.current);

const sub = getAppEvents().subscribe(ThemeChangedEvent, (event) => {
setTheme(event.payload);
const mode: Mode = event?.payload?.isDark ? 'dark' : 'light';

// Update iframe DOM and notify the host UI.
applyIframeDomTheme(mode);

// De-duplicate messages to avoid noisy bridges.
if (lastSentModeRef.current !== mode) {
lastSentModeRef.current = mode;
postModeToHost(mode);
}
});

// Unsubscribe on unmount (pmm-compat may be hot-reloaded in dev).
return () => sub.unsubscribe();
}, []);

return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
};

// ----- helpers (iframe context) -----

function applyIframeDomTheme(mode: Mode): void {
// Update DOM attributes used by CSS variables / tokens inside Grafana iframe.
const html = document.documentElement;
const scheme = mode === 'dark' ? 'percona-dark' : 'percona-light';
html.setAttribute('data-md-color-scheme', scheme);
html.setAttribute('data-theme', mode);
html.style.colorScheme = mode;
}

function postModeToHost(mode: Mode): void {
// Inform the parent (host) window. We intentionally use "*" here because
// host and iframe share origin in PMM, but in dev/proxy setups origin may differ.
// Host side should still validate origin.
window.parent?.postMessage({ type: 'grafana.theme.changed', payload: { mode } }, '*');
}
157 changes: 145 additions & 12 deletions ui/apps/pmm-compat/src/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,14 @@ import { getThemeById } from '@grafana/data';
import { config, getAppEvents, ThemeChangedEvent } from '@grafana/runtime';

/**
* Changes theme to the provided one
*
* Changes theme to the provided one.
* Based on public/app/core/services/theme.ts in Grafana
* @param themeId
*/
export const changeTheme = async (themeId: 'light' | 'dark'): Promise<void> => {
const oldTheme = config.theme2;

const newTheme = getThemeById(themeId);

// Publish Grafana ThemeChangedEvent
getAppEvents().publish(new ThemeChangedEvent(newTheme));

// Add css file for new theme
Expand All @@ -20,19 +18,154 @@ export const changeTheme = async (themeId: 'light' | 'dark'): Promise<void> => {
newCssLink.rel = 'stylesheet';
newCssLink.href = config.bootData.assets[newTheme.colors.mode];
newCssLink.onload = () => {
// Remove old css file
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for this change to code and comments?

const bodyLinks = document.getElementsByTagName('link');
for (let i = 0; i < bodyLinks.length; i++) {
const link = bodyLinks[i];

// Remove old css file after the new one has loaded to avoid flicker
const links = document.getElementsByTagName('link');
for (let i = 0; i < links.length; i++) {
const link = links[i];
if (link.href && link.href.includes(`build/grafana.${oldTheme.colors.mode}`)) {
// Remove existing link once the new css has loaded to avoid flickering
// If we add new css at the same time we remove current one the page will be rendered without css
// As the new css file is loading
link.remove();
}
}
};
document.head.insertBefore(newCssLink, document.head.firstChild);
}
};

/* ---------------------------
* Right → left theme wiring
* --------------------------*/

// Normalize and apply <html> attributes so CSS-based nav updates immediately
function applyHtmlTheme(modeRaw: unknown) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there a need to apply the theme to html? The changeTheme handles the theme change on the grafana side.

const mode: 'light' | 'dark' = String(modeRaw).toLowerCase() === 'dark' ? 'dark' : 'light';
const html = document.documentElement;
const scheme = mode === 'dark' ? 'percona-dark' : 'percona-light';

if (html.getAttribute('data-theme') !== mode) {
html.setAttribute('data-theme', mode);
}
if (html.getAttribute('data-md-color-scheme') !== scheme) {
html.setAttribute('data-md-color-scheme', scheme);
}
(html.style as CSSStyleDeclaration).colorScheme = mode;

return mode;
}

const isIp = (h: string) => /^\d+\.\d+\.\d+\.\d+$/.test(h);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The communication between iframe and pmm-ui has it's place in the CrossFrameMessenger (ui/packages/shared/src/messenger.ts)

const isDevHost = (h: string) => h === 'localhost' || h === '127.0.0.1' || isIp(h);

const parseOrigin = (u: string | URL | null | undefined): string | null => {
try {
const url = typeof u === 'string' ? new URL(u) : u instanceof URL ? u : null;
return url ? `${url.protocol}//${url.host}` : null;
} catch {
return null;
}
};

/**
* Resolve initial target origin (may be '*' in dev).
* - Dev: start with '*' to support split hosts/ports (vite + docker).
* - Prod: concrete origin (document.referrer → window.location.origin).
*/
function resolveInitialTargetOrigin(): string {
const loc = new URL(window.location.href);
if (isDevHost(loc.hostname)) {
return '*';
}
const ref = parseOrigin(document.referrer);
return ref ?? `${loc.protocol}//${loc.host}`;
}

/** Safely obtain a Window to post to (top if cross-framed, otherwise parent/self). */
function resolveTargetWindow(): Window | null {
try {
if (window.top && window.top !== window) {
return window.top;
}
if (window.parent) {
return window.parent;
}
} catch (err) {
console.warn('[pmm-compat] Failed to send handshake:', err);
}
return window;
}

/** Runtime-locked origin (handshake will tighten '*' in dev). */
const targetOriginRef = { current: resolveInitialTargetOrigin() };
let lastSentMode: 'light' | 'dark' | null = null;

/** Send helper that always uses the current locked origin. */
function sendToParent(msg: unknown) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posting messages between iframe and pmm-ui should be handled by CrossFrameMessenger.

const w = resolveTargetWindow();
if (!w) {
return;
}
w.postMessage(msg, targetOriginRef.current);
}

/** Dev-only handshake: lock '*' to the real origin after the first ACK. */
(function setupOriginHandshake() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handshake is pretty good idea to not send information where we do not want.

const isDev = isDevHost(new URL(window.location.href).hostname);
if (!isDev || targetOriginRef.current !== '*') {
return;
}

// Ask parent for its origin once
try {
sendToParent({ type: 'pmm.handshake' });
} catch {
// ignore
}

const onAck = (e: MessageEvent<{ type?: string }>) => {
if (e?.data?.type !== 'pmm.handshake.ack') {
return;
}
// Lock to the explicit origin provided by the parent
targetOriginRef.current = e.origin || targetOriginRef.current;
window.removeEventListener('message', onAck);
};
window.addEventListener('message', onAck);
})();

// Initial apply from current Grafana theme and notify parent once
(function initThemeBridge() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declaring a function and then immediately calling it like this might lead to unexpected results - this will be called when the theme.ts file is imported and the communication between iframe <> pmm-ui might not be initialized (or the plugin will not be inside an iframe at all)

const initial: 'light' | 'dark' = config?.theme2?.colors?.mode === 'dark' ? 'dark' : 'light';
const mode = applyHtmlTheme(initial);
try {
if (lastSentMode !== mode) {
sendToParent({ type: 'grafana.theme.changed', payload: { mode } });
lastSentMode = mode;
}
} catch (err) {
console.warn('[pmm-compat] failed to post initial grafana.theme.changed:', err);
}
})();

// React to Grafana ThemeChangedEvent (Preferences change/changeTheme())
getAppEvents().subscribe(ThemeChangedEvent, (evt: unknown) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're subscribing to theme changes on multiple places each with a different handling why is that?

try {
// Type guard for expected payload structure
if (typeof evt === 'object' && evt !== null && 'payload' in evt) {
const payload = (evt as { payload?: unknown }).payload;
const next =
typeof payload === 'object' && payload !== null && 'colors' in payload
? (payload as { colors?: { mode?: string }; isDark?: boolean }).colors?.mode ??
((payload as { isDark?: boolean }).isDark ? 'dark' : 'light') ??
'light'
: 'light';

const mode = applyHtmlTheme(next);

if (lastSentMode !== mode) {
sendToParent({ type: 'grafana.theme.changed', payload: { mode } });
lastSentMode = mode;
}
}
} catch (err) {
console.warn('[pmm-compat] Failed to handle ThemeChangedEvent/postMessage:', err);
}
});
Loading
Loading