diff --git a/ui/apps/pmm-compat/src/compat.ts b/ui/apps/pmm-compat/src/compat.ts index a9b3a8d97e5..842a260ef86 100644 --- a/ui/apps/pmm-compat/src/compat.ts +++ b/ui/apps/pmm-compat/src/compat.ts @@ -1,10 +1,11 @@ -import { locationService } from '@grafana/runtime'; +import { locationService, getAppEvents, ThemeChangedEvent, config } from '@grafana/runtime'; import { ChangeThemeMessage, CrossFrameMessenger, DashboardVariablesMessage, HistoryAction, LocationChangeMessage, + ColorMode, } from '@pmm/shared'; import { GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, @@ -25,36 +26,59 @@ export const initialize = () => { return; } + // Register messenger towards PMM UI (top frame) const messenger = new CrossFrameMessenger('GRAFANA').setTargetWindow(window.top!).register(); + // React to PMM → Grafana theme changes messenger.addListener({ type: 'CHANGE_THEME', onMessage: (msg: ChangeThemeMessage) => { if (!msg.payload) { return; } - + // Apply Grafana theme (handled on Grafana side) 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'); - + // -------- Theme relay: Grafana (right) → PMM UI (left) -------- + // Initial emit from Grafana current config + const initial: ColorMode = config?.theme2?.colors?.mode === 'dark' ? 'dark' : 'light'; messenger.sendMessage({ - type: 'GRAFANA_READY', + type: 'GRAFANA_THEME_CHANGED', + payload: { theme: initial }, + }); + + // Forward future Grafana ThemeChangedEvent to PMM UI + getAppEvents().subscribe(ThemeChangedEvent, (evt: unknown) => { + // Grafana 11 emits ThemeChangedEvent with different shapes; normalize robustly. + // Try known fields, then fall back to 'light'. + const raw = + // @ts-expect-error — best-effort probing of possible event shapes + (evt?.payload?.colors?.mode as string | undefined) ?? + // @ts-expect-error — some places provide { theme: 'dark'|'light' } + (evt?.theme as string | undefined) ?? + // @ts-expect-error — older shape: isDark boolean + ((evt?.payload?.isDark as boolean | undefined) ? 'dark' : undefined); + + const next: ColorMode = raw?.toLowerCase() === 'dark' ? 'dark' : 'light'; + + messenger.sendMessage({ + type: 'GRAFANA_THEME_CHANGED', + payload: { theme: next }, + }); }); + // -------------------------------------------------------------- + + messenger.sendMessage({ type: 'GRAFANA_READY' }); messenger.addListener({ type: 'LOCATION_CHANGE', @@ -62,7 +86,6 @@ export const initialize = () => { if (!location) { return; } - locationService.replace(location); }, }); @@ -71,6 +94,7 @@ export const initialize = () => { type: 'DOCUMENT_TITLE_CHANGE', payload: { title: document.title }, }); + documentTitleObserver.listen((title) => { messenger.sendMessage({ type: 'DOCUMENT_TITLE_CHANGE', @@ -80,7 +104,6 @@ export const initialize = () => { let prevLocation: Location | undefined; locationService.getHistory().listen((location: Location, action: HistoryAction) => { - // re-add custom toolbar buttons after closing kiosk mode if (prevLocation?.search.includes('kiosk') && !location.search.includes('kiosk')) { adjustToolbar(); } @@ -108,9 +131,7 @@ export const initialize = () => { messenger.sendMessage({ id: msg.id, type: msg.type, - payload: { - url: url, - }, + payload: { url }, }); }, }); diff --git a/ui/apps/pmm-compat/src/theme.ts b/ui/apps/pmm-compat/src/theme.ts index 5f9f2def2d8..459edc08696 100644 --- a/ui/apps/pmm-compat/src/theme.ts +++ b/ui/apps/pmm-compat/src/theme.ts @@ -1,38 +1,99 @@ -import { getThemeById } from '@grafana/data'; +import { getThemeById, type GrafanaTheme2 } from '@grafana/data'; import { config, getAppEvents, ThemeChangedEvent } from '@grafana/runtime'; +import type { CrossFrameMessenger, Message } from '@pmm/shared'; /** - * Changes theme to the provided one - * - * Based on public/app/core/services/theme.ts in Grafana - * @param themeId + * Normalize any input to strict 'light' | 'dark'. */ -export const changeTheme = async (themeId: 'light' | 'dark'): Promise => { - const oldTheme = config.theme2; +const normalizeMode = (incoming: unknown): 'light' | 'dark' => { + return String(incoming).toLowerCase() === 'dark' ? 'dark' : 'light'; +}; - const newTheme = getThemeById(themeId); +/** + * Apply Grafana theme by id and ensure the proper CSS bundle is loaded. + * Based on Grafana's public/app/core/services/theme.ts (trimmed). + */ +const applyGrafanaTheme = async (mode: 'light' | 'dark'): Promise => { + const oldTheme = config.theme2; + const newTheme = getThemeById(mode); + // Publish Grafana ThemeChangedEvent so Grafana UI re-themes itself getAppEvents().publish(new ThemeChangedEvent(newTheme)); - // Add css file for new theme + // If mode actually changed, ensure the correct CSS bundle is present if (oldTheme.colors.mode !== newTheme.colors.mode) { - const newCssLink = document.createElement('link'); - newCssLink.rel = 'stylesheet'; - newCssLink.href = config.bootData.assets[newTheme.colors.mode]; - newCssLink.onload = () => { - // Remove old css file - const bodyLinks = document.getElementsByTagName('link'); - for (let i = 0; i < bodyLinks.length; i++) { - const link = bodyLinks[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(); + const cssHref = config.bootData.assets[newTheme.colors.mode]; + if (cssHref) { + const newCssLink = document.createElement('link'); + newCssLink.rel = 'stylesheet'; + newCssLink.href = cssHref; + newCssLink.onload = () => { + // Remove the opposite mode's stylesheet once the new one is safely loaded + const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]')) as HTMLLinkElement[]; + for (const link of links) { + if (link !== newCssLink && typeof link.href === 'string') { + const isOldDark = oldTheme.colors.mode === 'dark' && link.href.includes('/dark.'); + const isOldLight = oldTheme.colors.mode === 'light' && link.href.includes('/light.'); + if (isOldDark || isOldLight) { + link.parentElement?.removeChild(link); + } + } } - } - }; - document.head.insertBefore(newCssLink, document.head.firstChild); + }; + document.head.appendChild(newCssLink); + } } + + return newTheme; +}; + +/** + * Public API kept for callers inside this plugin (no HTML attributes here). + */ +export const changeTheme = async (themeId: 'light' | 'dark'): Promise => { + await applyGrafanaTheme(themeId); +}; + +/** + * Initialize theme sync inside the Grafana iframe. + * - Single subscription to Grafana ThemeChangedEvent => notify PMM UI (left). + * - Listen to CHANGE_THEME from PMM UI => apply locally via changeTheme(). + * - Perform initial one-shot sync after listeners are in place. + * - No IIFEs, no window.postMessage, no origin handshake, no HTML attributes. + */ +export const initialize = (messenger: CrossFrameMessenger): void => { + // Guard to avoid double init if initialize() gets called twice + if ((window as any).__pmmThemeInitDone) { + return; + } + (window as any).__pmmThemeInitDone = true; + + // Outgoing: when Grafana emits ThemeChangedEvent, tell PMM UI once per change + const onThemeChanged = (evt: ThemeChangedEvent) => { + // In Grafana 10+, the new theme is carried in the event's theme + const nextMode = normalizeMode((evt as any)?.theme?.colors?.mode ?? config.theme2.colors.mode); + messenger.sendMessage({ + type: 'GRAFANA_THEME_CHANGED', + payload: { mode: nextMode }, + }); + }; + + // Subscribe once; PMM side should avoid ping-pong with its own guard flag + getAppEvents().subscribe(ThemeChangedEvent, onThemeChanged); + + // Incoming: apply theme when PMM UI asks us to change + messenger.addListener<'CHANGE_THEME', { mode?: string }>({ + type: 'CHANGE_THEME', + onMessage: async (msg: Message<'CHANGE_THEME', { mode?: string }>) => { + const requested = normalizeMode(msg.payload?.mode); + await changeTheme(requested); + }, + }); + + // Initial one-shot sync (after listeners are registered) + const currentMode = normalizeMode(config.theme2.colors.mode); + messenger.sendMessage({ + type: 'GRAFANA_THEME_CHANGED', + payload: { mode: currentMode }, + }); }; diff --git a/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx b/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx index c11478cb56c..683ba2fc6c8 100644 --- a/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx +++ b/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx @@ -1,123 +1,163 @@ import { FC, PropsWithChildren, useEffect, useRef, useState } from 'react'; -import { GrafanaContext } from './grafana.context'; import { useLocation, useNavigate, useNavigationType } from 'react-router'; +import { GrafanaContext } from './grafana.context'; import { - GRAFANA_SUB_PATH, - PMM_NEW_NAV_GRAFANA_PATH, - PMM_NEW_NAV_PATH, + GRAFANA_SUB_PATH, + PMM_NEW_NAV_GRAFANA_PATH, + PMM_NEW_NAV_PATH, } from 'lib/constants'; -import { DocumentTitleUpdateMessage, LocationChangeMessage } from '@pmm/shared'; -import messenger from 'lib/messenger'; +import { ColorMode } from '@pmm/shared'; import { getLocationUrl } from './grafana.utils'; import { updateDocumentTitle } from 'lib/utils/document.utils'; -import { useColorMode } from 'hooks/theme'; import { useKioskMode } from 'hooks/utils/useKioskMode'; +import { useColorMode } from 'hooks/theme'; +import { useSetTheme } from 'themes/setTheme'; +import messenger from 'lib/messenger'; + +type NavState = { fromGrafana?: boolean } | null; + +// ---- Minimal message shapes to avoid `any` ---- +type ThemeChangedPayload = { theme?: ColorMode }; +type LocationChangedPayload = { + pathname?: string; + search?: string; + hash?: string; + // We only guard for 'POP' + action?: 'PUSH' | 'POP' | 'REPLACE' | string; +} & Record; +type TitleChangedPayload = { title?: string }; + +type MessengerMessage

= { + type: string; + payload?: P; +}; + +type GrafanaLocationParam = Parameters[0]; + +const isBrowser = () => + typeof window !== 'undefined' && typeof window.addEventListener === 'function'; export const GrafanaProvider: FC = ({ children }) => { - const navigationType = useNavigationType(); - const location = useLocation(); - const src = location.pathname.replace(PMM_NEW_NAV_PATH, ''); - const isGrafanaPage = src.startsWith(GRAFANA_SUB_PATH); - const [isLoaded, setIsloaded] = useState(false); - const { colorMode } = useColorMode(); - const frameRef = useRef(null); - const navigate = useNavigate(); - const kioskMode = useKioskMode(); - - useEffect(() => { - if (isGrafanaPage) { - setIsloaded(true); - } - }, [isGrafanaPage]); - - useEffect(() => { - // don't send location change if it's coming from within grafana or is POP type - if ( - !location.pathname.includes('/graph') || - (location.state?.fromGrafana && navigationType !== 'POP') - ) { - return; - } - - messenger.sendMessage({ - type: 'LOCATION_CHANGE', - payload: { - ...location, - pathname: location.pathname.replace(PMM_NEW_NAV_GRAFANA_PATH, ''), - action: navigationType, - }, - }); - }, [location, navigationType]); - - useEffect(() => { - if (!isLoaded) { - return; - } - - messenger - .setTargetWindow(frameRef.current?.contentWindow!, '#grafana-iframe') - .register(); - - // send current PMM theme to Grafana - messenger.waitForMessage('MESSENGER_READY').then(() => { - messenger.sendMessage({ - type: 'CHANGE_THEME', - payload: { - theme: colorMode, - }, - }); - }); - - messenger.addListener({ - type: 'LOCATION_CHANGE', - onMessage: ({ payload: location }: LocationChangeMessage) => { - if (!location || location.action === 'POP') { - return; + const navigationType = useNavigationType(); + const location = useLocation(); + const src = location.pathname.replace(PMM_NEW_NAV_PATH, ''); + const isGrafanaPage = src.startsWith(GRAFANA_SUB_PATH); + + const [isLoaded, setIsLoaded] = useState(false); + const frameRef = useRef(null); + const navigate = useNavigate(); + const kioskMode = useKioskMode(); + + // Left-side theme source of truth + const { colorMode } = useColorMode(); + // Deterministic local apply that does not broadcast/persist (prevents ping-pong) + const { setFromGrafana } = useSetTheme(); + + // Avoid sending the same theme repeatedly + const lastSentThemeRef = useRef('light'); + + useEffect(() => { + if (isGrafanaPage) setIsLoaded(true); + }, [isGrafanaPage]); + + // Register messenger for iframe and handle incoming messages + useEffect(() => { + if (!isLoaded || !isBrowser()) return; + + // setTargetWindow expects Window, so ensure contentWindow exists + const target = frameRef.current?.contentWindow; + if (target) { + messenger.setTargetWindow(target); } + messenger.register(); + + // Grafana -> PMM: theme changed inside iframe + messenger.addListener({ + type: 'GRAFANA_THEME_CHANGED', + onMessage: (message: MessengerMessage) => { + // Defensive read: normalize to 'light' | 'dark' + const next: ColorMode = message.payload?.theme === 'dark' ? 'dark' : 'light'; + // Apply locally without re-broadcast/persist + setFromGrafana(next).catch((err: unknown) => { + console.warn('[GrafanaProvider] setFromGrafana failed:', err); + }); + }, + }); + + // Grafana -> PMM: route changes (except POP) + messenger.addListener({ + type: 'LOCATION_CHANGE', + onMessage: (message: { type: string; payload?: LocationChangedPayload }) => { + const loc = message.payload; + if (!loc || loc.action === 'POP') return; + + // Adapt incoming payload to the exact type expected by getLocationUrl + const adapted = loc as unknown as GrafanaLocationParam; + + navigate(getLocationUrl(adapted), { + state: { fromGrafana: true }, + replace: true, + }); + }, + }); - navigate(getLocationUrl(location), { - state: { fromGrafana: true }, - replace: true, + // Grafana -> PMM: document title + messenger.addListener({ + type: 'DOCUMENT_TITLE_CHANGE', + onMessage: (message: MessengerMessage) => { + const payload = message.payload; + if (!payload?.title) return; + updateDocumentTitle(payload.title); + }, }); - }, - }); - - messenger.addListener({ - type: 'DOCUMENT_TITLE_CHANGE', - onMessage: ({ payload }: DocumentTitleUpdateMessage) => { - if (!payload) { - return; + + // Cleanup once provider unmounts + return () => { + messenger.unregister(); + }; + }, [isLoaded, navigate, setFromGrafana]); + + // PMM -> Grafana: propagate location (except when it came from Grafana) + useEffect(() => { + if (!isBrowser()) return; + + const state = location.state as NavState; + if (!location.pathname.includes('/graph') || state?.fromGrafana) return; + + messenger.sendMessage({ + type: 'LOCATION_CHANGE', + payload: { + ...location, + pathname: location.pathname.replace(PMM_NEW_NAV_GRAFANA_PATH, ''), + action: navigationType, + }, + }); + }, [location, navigationType]); + + // PMM -> Grafana: propagate theme when it changes on the left + useEffect(() => { + if (!isLoaded || !isBrowser()) return; + + const mode: ColorMode = colorMode === 'dark' ? 'dark' : 'light'; + if (lastSentThemeRef.current !== mode) { + lastSentThemeRef.current = mode; + messenger.sendMessage({ + type: 'CHANGE_THEME', + payload: { theme: mode }, + }); } + }, [isLoaded, colorMode]); - updateDocumentTitle(payload.title); - }, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoaded]); - - useEffect(() => { - if (!isLoaded) { - return; - } - - messenger.sendMessage({ - type: 'CHANGE_THEME', - payload: { - theme: colorMode, - }, - }); - }, [isLoaded, colorMode]); - - return ( - - {children} - - ); + return ( + + {children} + + ); }; diff --git a/ui/apps/pmm/src/hooks/theme.ts b/ui/apps/pmm/src/hooks/theme.ts index 38791659a0e..6a636f79ccf 100644 --- a/ui/apps/pmm/src/hooks/theme.ts +++ b/ui/apps/pmm/src/hooks/theme.ts @@ -1,17 +1,28 @@ import { ColorModeContext } from '@percona/design'; import { useContext } from 'react'; import { useUpdatePreferences } from './api/useUser'; +import messenger from 'lib/messenger'; +import type { MessageType } from '@pmm/shared'; export const useColorMode = () => { - const { colorMode, toggleColorMode } = useContext(ColorModeContext); - const { mutate } = useUpdatePreferences(); + const { colorMode, toggleColorMode } = useContext(ColorModeContext); + const { mutate } = useUpdatePreferences(); - const toggleMode = () => { - toggleColorMode(); - mutate({ - theme: colorMode === 'light' ? 'dark' : 'light', - }); - }; + const onToggle = () => { + const next = colorMode === 'light' ? 'dark' : 'light'; - return { colorMode, toggleColorMode: toggleMode }; -}; + // 1) local apply (left UI) + toggleColorMode(); + + // 2) tell Grafana iframe to switch immediately + messenger.sendMessage({ + type: 'CHANGE_THEME' as MessageType, + payload: { theme: next }, + }); + + // 3) persist in Grafana Preferences + mutate({ theme: next }); + }; + + return { colorMode, toggleColorMode: onToggle }; +}; \ No newline at end of file diff --git a/ui/apps/pmm/src/themes/setTheme.ts b/ui/apps/pmm/src/themes/setTheme.ts new file mode 100644 index 00000000000..1e0de655faf --- /dev/null +++ b/ui/apps/pmm/src/themes/setTheme.ts @@ -0,0 +1,93 @@ +import { useContext, useRef } from 'react'; +import { ColorModeContext } from '@percona/design'; +import messenger from 'lib/messenger'; +import { ColorMode } from '@pmm/shared'; +import { grafanaApi } from 'api/api'; +import { useUpdatePreferences } from '../hooks/api/useUser'; + +/** Normalize any incoming value to 'light' | 'dark'. */ +function normalizeMode(v: unknown): ColorMode { + if (typeof v === 'string' && v.toLowerCase() === 'dark') return 'dark'; + if (v === true) return 'dark'; + return 'light'; +} + +/** + * useSetTheme centralizes theme changes for: + * - local PMM UI (left) + * - persistence to Grafana preferences + * - broadcasting to Grafana iframe (right) + */ +export function useSetTheme() { + const { colorMode, toggleColorMode } = useContext(ColorModeContext); + const modeRef = useRef(normalizeMode(colorMode)); + + const { mutateAsync: updatePreferences } = useUpdatePreferences(); + + // Keep the reference always up-to-date with current color mode + modeRef.current = normalizeMode(colorMode); + + /** Apply theme locally via design system only (no direct DOM mutations). */ + const applyLocal = (nextRaw: unknown) => { + const next = normalizeMode(nextRaw); + if (modeRef.current !== next) { + // design system exposes only toggle, so flip when needed + toggleColorMode(); + modeRef.current = next; + } + }; + + /** Low-level primitive to apply/persist/broadcast theme as needed. */ + const setThemeBase = async ( + nextRaw: unknown, + opts: { broadcast?: boolean; persist?: boolean } = { + broadcast: true, + persist: true, + } + ) => { + const next = normalizeMode(nextRaw); + + // 1) Local apply (instant, idempotent) + applyLocal(next); + + // 2) Persist to Grafana (only for left-initiated actions) + if (opts.persist) { + await updatePreferences({ theme: next }); + try { + await grafanaApi.put('/user/preferences', { theme: next }); + } catch (err) { + console.warn( + '[useSetTheme] Failed to persist theme to Grafana preferences:', + err + ); + } + } + + // 3) Notify iframe (only when we are the initiator, not when syncing from Grafana) + if (opts.broadcast) { + try { + messenger.sendMessage({ + type: 'CHANGE_THEME', + payload: { mode: next }, + }); + } catch (err) { + console.warn('[useSetTheme] Failed to send CHANGE_THEME message:', err); + } + } + }; + + /** + * Public API: + * - setTheme(next): left action — apply + persist + broadcast. + * - setFromGrafana(next): right→left sync — apply only (no persist, no broadcast). + */ + async function setTheme(next: ColorMode | string | boolean) { + await setThemeBase(next, { broadcast: true, persist: true }); + } + + async function setFromGrafana(next: ColorMode | string | boolean) { + await setThemeBase(next, { broadcast: false, persist: false }); + } + + return { setTheme, setFromGrafana }; +} diff --git a/ui/apps/pmm/src/themes/theme.context.ts b/ui/apps/pmm/src/themes/theme.context.ts new file mode 100644 index 00000000000..e6d359debc1 --- /dev/null +++ b/ui/apps/pmm/src/themes/theme.context.ts @@ -0,0 +1,16 @@ +import { createContext, useContext } from 'react'; + +export type ColorMode = 'light' | 'dark'; + +export type ThemeContextValue = { + mode: ColorMode; + setTheme: (mode: ColorMode) => Promise; +}; + +export const ThemeCtx = createContext(null); + +export const usePmmTheme = (): ThemeContextValue => { + const ctx = useContext(ThemeCtx); + if (!ctx) throw new Error('usePmmTheme must be used within ThemeProvider'); + return ctx; +}; \ No newline at end of file diff --git a/ui/packages/shared/src/types.ts b/ui/packages/shared/src/types.ts index 43d413365fb..a34a24313d8 100644 --- a/ui/packages/shared/src/types.ts +++ b/ui/packages/shared/src/types.ts @@ -8,6 +8,7 @@ export type MessageType = | 'DASHBOARD_VARIABLES' | 'GRAFANA_READY' | 'DOCUMENT_TITLE_CHANGE' + | 'GRAFANA_THEME_CHANGED' | 'CHANGE_THEME'; export interface Message {