From e3bc89d45e46d9c11f41cdcd330f03e95b8c9a15 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Fri, 17 Oct 2025 16:04:43 +0300 Subject: [PATCH 01/20] PMM-13702:UI theme toggle button and compatibility with dark theme --- ui/apps/pmm-compat/src/compat.ts | 221 +++++++++++------- .../src/contexts/theme/theme.provider.tsx | 51 +++- ui/apps/pmm-compat/src/theme.ts | 157 ++++++++++++- ui/apps/pmm/src/App.tsx | 10 + .../components/sidebar/nav-item/NavItem.tsx | 73 +++--- .../src/contexts/grafana/grafana.provider.tsx | 153 +++++++++--- ui/apps/pmm/src/hooks/theme.ts | 42 +++- .../pmm/src/hooks/useGrafanaThemeSyncOnce.ts | 92 ++++++++ ui/apps/pmm/src/themes/setTheme.ts | 127 ++++++++++ ui/apps/pmm/tsconfig.node.json | 4 +- ui/apps/pmm/vite.config.ts | 82 ++++--- ui/docker-compose.yml | 4 +- 12 files changed, 815 insertions(+), 201 deletions(-) create mode 100644 ui/apps/pmm/src/hooks/useGrafanaThemeSyncOnce.ts create mode 100644 ui/apps/pmm/src/themes/setTheme.ts diff --git a/ui/apps/pmm-compat/src/compat.ts b/ui/apps/pmm-compat/src/compat.ts index a9b3a8d97e5..6001400f6ce 100644 --- a/ui/apps/pmm-compat/src/compat.ts +++ b/ui/apps/pmm-compat/src/compat.ts @@ -1,16 +1,16 @@ -import { locationService } from '@grafana/runtime'; +import { locationService, getAppEvents, ThemeChangedEvent, config } from '@grafana/runtime'; import { - ChangeThemeMessage, - CrossFrameMessenger, - DashboardVariablesMessage, - HistoryAction, - LocationChangeMessage, + ChangeThemeMessage, + CrossFrameMessenger, + DashboardVariablesMessage, + HistoryAction, + LocationChangeMessage, } from '@pmm/shared'; import { - GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, - GRAFANA_LOGIN_PATH, - GRAFANA_SUB_PATH, - PMM_UI_PATH, + GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, + GRAFANA_LOGIN_PATH, + GRAFANA_SUB_PATH, + PMM_UI_PATH, } from 'lib/constants'; import { applyCustomStyles } from 'styles'; import { changeTheme } from 'theme'; @@ -19,99 +19,148 @@ import { isWithinIframe, getLinkWithVariables } from 'lib/utils'; import { documentTitleObserver } from 'lib/utils/document'; export const initialize = () => { - if (!isWithinIframe() && !window.location.pathname.startsWith(GRAFANA_LOGIN_PATH)) { - // redirect user to the new UI - window.location.replace(window.location.href.replace(GRAFANA_SUB_PATH, PMM_UI_PATH)); - return; - } - - const messenger = new CrossFrameMessenger('GRAFANA').setTargetWindow(window.top!).register(); - - messenger.addListener({ - type: 'CHANGE_THEME', - onMessage: (msg: ChangeThemeMessage) => { - if (!msg.payload) { + if (!isWithinIframe() && !window.location.pathname.startsWith(GRAFANA_LOGIN_PATH)) { + // redirect user to the new UI + window.location.replace(window.location.href.replace(GRAFANA_SUB_PATH, PMM_UI_PATH)); return; - } - - changeTheme(msg.payload.theme); - }, - }); + } - messenger.sendMessage({ - type: 'MESSENGER_READY', - }); + const messenger = new CrossFrameMessenger('GRAFANA').setTargetWindow(window.top!).register(); - // set docked state to false - localStorage.setItem(GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, 'false'); + messenger.addListener({ + type: 'CHANGE_THEME', + onMessage: (msg: ChangeThemeMessage) => { + if (!msg.payload) { + return; + } + changeTheme(msg.payload.theme); + }, + }); - applyCustomStyles(); + messenger.sendMessage({ type: 'MESSENGER_READY' }); - adjustToolbar(); + // set docked state to false + localStorage.setItem(GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, 'false'); - // sync with PMM UI theme - changeTheme('light'); + applyCustomStyles(); + adjustToolbar(); - messenger.sendMessage({ - type: 'GRAFANA_READY', - }); + // Wire Grafana theme events to Percona scheme attribute and notify parent + setupThemeWiring(); - messenger.addListener({ - type: 'LOCATION_CHANGE', - onMessage: ({ payload: location }: LocationChangeMessage) => { - if (!location) { - return; - } + messenger.sendMessage({ type: 'GRAFANA_READY' }); - locationService.replace(location); - }, - }); + messenger.addListener({ + type: 'LOCATION_CHANGE', + onMessage: ({ payload: location }: LocationChangeMessage) => { + if (!location) { + return; + } + locationService.replace(location); + }, + }); - messenger.sendMessage({ - type: 'DOCUMENT_TITLE_CHANGE', - payload: { title: document.title }, - }); - documentTitleObserver.listen((title) => { messenger.sendMessage({ - type: 'DOCUMENT_TITLE_CHANGE', - payload: { title }, + type: 'DOCUMENT_TITLE_CHANGE', + payload: { title: document.title }, }); - }); - 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(); - } - - messenger.sendMessage({ - type: 'LOCATION_CHANGE', - payload: { - action, - ...location, - }, + documentTitleObserver.listen((title) => { + messenger.sendMessage({ + type: 'DOCUMENT_TITLE_CHANGE', + payload: { title }, + }); }); - prevLocation = location; - }); + 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(); + } + + messenger.sendMessage({ + type: 'LOCATION_CHANGE', + payload: { + action, + ...location, + }, + }); + + prevLocation = location; + }); - messenger.addListener({ - type: 'DASHBOARD_VARIABLES', - onMessage: (msg: DashboardVariablesMessage) => { - if (!msg.payload || !msg.payload.url) { - return; - } + messenger.addListener({ + type: 'DASHBOARD_VARIABLES', + onMessage: (msg: DashboardVariablesMessage) => { + if (!msg.payload || !msg.payload.url) { + return; + } - const url = getLinkWithVariables(msg.payload.url); + const url = getLinkWithVariables(msg.payload.url); - messenger.sendMessage({ - id: msg.id, - type: msg.type, - payload: { - url: url, + messenger.sendMessage({ + id: msg.id, + type: msg.type, + payload: { url }, + }); }, - }); - }, - }); + }); }; + +/** + * Wires Grafana theme changes (ThemeChangedEvent) to Percona CSS scheme. + * Ensures has the attribute our CSS reads and informs the parent frame. + */ +// comments in English only +function setupThemeWiring() { + // Resolve parent origin robustly (dev vs prod) + const getParentOrigin = (): string => { + 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) => { + // 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); + html.setAttribute('data-theme', mode); + (html.style as any).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); + } 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'; + apply(initialMode); + + // React to Grafana theme changes (Preferences change/changeTheme()) + getAppEvents().subscribe(ThemeChangedEvent, (evt: any) => { + const next = evt?.payload?.colors?.mode ?? (evt?.payload?.isDark ? 'dark' : 'light') ?? 'light'; + apply(next); + }); +} diff --git a/ui/apps/pmm-compat/src/contexts/theme/theme.provider.tsx b/ui/apps/pmm-compat/src/contexts/theme/theme.provider.tsx index 6c029d27c10..e2426769a88 100644 --- a/ui/apps/pmm-compat/src/contexts/theme/theme.provider.tsx +++ b/ui/apps/pmm-compat/src/contexts/theme/theme.provider.tsx @@ -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 = ({ children }) => { const [theme, setTheme] = useState(config.theme2); + const lastSentModeRef = useRef(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 {children}; }; + +// ----- 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 } }, '*'); +} diff --git a/ui/apps/pmm-compat/src/theme.ts b/ui/apps/pmm-compat/src/theme.ts index 5f9f2def2d8..9fa6edc01be 100644 --- a/ui/apps/pmm-compat/src/theme.ts +++ b/ui/apps/pmm-compat/src/theme.ts @@ -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 => { const oldTheme = config.theme2; - const newTheme = getThemeById(themeId); + // Publish Grafana ThemeChangedEvent getAppEvents().publish(new ThemeChangedEvent(newTheme)); // Add css file for new theme @@ -20,15 +18,11 @@ export const changeTheme = async (themeId: 'light' | 'dark'): Promise => { 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]; - + // 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(); } } @@ -36,3 +30,142 @@ export const changeTheme = async (themeId: 'light' | 'dark'): Promise => { document.head.insertBefore(newCssLink, document.head.firstChild); } }; + +/* --------------------------- + * Right → left theme wiring + * --------------------------*/ + +// Normalize and apply attributes so CSS-based nav updates immediately +function applyHtmlTheme(modeRaw: unknown) { + 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); +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) { + 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() { + 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() { + 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) => { + 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); + } +}); diff --git a/ui/apps/pmm/src/App.tsx b/ui/apps/pmm/src/App.tsx index bea3a456fed..cfb923ecbcc 100644 --- a/ui/apps/pmm/src/App.tsx +++ b/ui/apps/pmm/src/App.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -7,6 +8,7 @@ import { ThemeContextProvider } from '@percona/design'; import { NotistackMuiSnackbar } from '@percona/ui-lib'; import { SnackbarProvider } from 'notistack'; import pmmThemeOptions from 'themes/PmmTheme'; +import { useGrafanaThemeSyncOnce } from 'hooks/useGrafanaThemeSyncOnce'; const queryClient = new QueryClient({ defaultOptions: { @@ -16,11 +18,19 @@ const queryClient = new QueryClient({ }, }); +const ThemeSyncGuard: React.FC = () => { + const ref = React.useRef<'light' | 'dark'>('light'); + // Mount the Grafana→PMM theme bridge under the SAME ThemeContextProvider + useGrafanaThemeSyncOnce(ref); + return null; +}; + const App = () => ( + = ({ item, drawerOpen, level = 0 }) => { const location = useLocation(); @@ -32,6 +34,34 @@ const NavItem: FC = ({ item, drawerOpen, level = 0 }) => { const dataTestid = `navitem-${item.id}`; const navigate = useNavigate(); + // Detect "theme-toggle" item and compute dynamic label/icon from current palette mode. + const isThemeToggle = item.id === 'theme-toggle'; + const paletteMode = (theme.palette?.mode ?? 'light') as 'light' | 'dark'; + const themeToggleLabel = + paletteMode === 'dark' ? 'Change to Light Theme' : 'Change to Dark Theme'; + const themeToggleIcon = paletteMode === 'dark' ? 'theme-light' : 'theme-dark'; + + // Use the exact prop type from NavItemIcon to keep the union type. + type IconName = NonNullable['icon']>; + + // Resolve icon for this item; make sure it's never undefined when passed down. + const resolvedIcon: IconName | undefined = isThemeToggle + ? (themeToggleIcon as IconName) + : (item.icon as IconName | undefined); + + const { setTheme } = useSetTheme(); + + // Handle click for the action item: instant UI update + persist + iframe sync. + const handleThemeToggleClick = useCallback(async () => { + try { + const next: 'light' | 'dark' = paletteMode === 'dark' ? 'light' : 'dark'; + await setTheme(next); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[NavItem] Theme toggle failed:', err); + } + }, [paletteMode, setTheme]); + useEffect(() => { if (active && drawerOpen) { setIsOpen(true); @@ -74,24 +104,19 @@ const NavItem: FC = ({ item, drawerOpen, level = 0 }) => { direction="row" alignItems="center" justifyContent="space-between" - sx={{ - width: level === 0 ? DRAWER_WIDTH : undefined, - }} + sx={{ width: level === 0 ? DRAWER_WIDTH : undefined }} > {item.icon && ( - + )} = ({ item, drawerOpen, level = 0 }) => { ({ @@ -152,36 +175,30 @@ const NavItem: FC = ({ item, drawerOpen, level = 0 }) => { return ( - + - {item.icon && ( + {resolvedIcon && ( - + )} diff --git a/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx b/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx index c11478cb56c..819571e48c2 100644 --- a/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx +++ b/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx @@ -1,6 +1,6 @@ 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, @@ -10,28 +10,84 @@ import { DocumentTitleUpdateMessage, LocationChangeMessage } from '@pmm/shared'; import messenger from 'lib/messenger'; 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'; + +type Mode = 'light' | 'dark'; + +/** Reads canonical mode from attributes set by our theme hook. */ +const readHtmlMode = (): Mode => + document.documentElement + .getAttribute('data-md-color-scheme') + ?.includes('dark') + ? 'dark' + : 'light'; + +/** Normalizes any incoming value to 'light' | 'dark'. */ +const normalizeMode = (v: unknown): Mode => + typeof v === 'string' && v.toLowerCase() === 'dark' + ? 'dark' + : v === true + ? 'dark' + : 'light'; + +/** Resolve optional Grafana origin provided via env (e.g. https://pmm.example.com). */ +const resolveGrafanaOrigin = (): string | undefined => { + const raw = (import.meta as ImportMeta)?.env?.VITE_GRAFANA_ORIGIN as + | string + | undefined; + if (!raw) return undefined; + try { + return new URL(raw).origin; + } catch { + return undefined; + } +}; + +/** Build a trust predicate for postMessage origins. */ +const makeIsTrustedOrigin = () => { + // In dev, we accept any origin to support split hosts/ports (vite + docker) + if (import.meta.env.DEV) return () => true; + + const set = new Set([window.location.origin]); + const grafanaOrigin = resolveGrafanaOrigin(); + if (grafanaOrigin) set.add(grafanaOrigin); + return (origin: string) => set.has(origin); +}; 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 [isLoaded, setIsLoaded] = useState(false); const frameRef = useRef(null); const navigate = useNavigate(); const kioskMode = useKioskMode(); + // Ensure our theme context is mounted (also mounts the global theme sync hook elsewhere) + useColorMode(); + + const { setFromGrafana } = useSetTheme(); + + // Remember last theme we sent to avoid resending the same value. + const lastSentThemeRef = useRef(readHtmlMode()); + + // Trusted-origin predicate for postMessage validation. + const isTrustedOriginRef = useRef<(o: string) => boolean>(() => true); useEffect(() => { - if (isGrafanaPage) { - setIsloaded(true); - } + isTrustedOriginRef.current = makeIsTrustedOrigin(); + }, []); + + // Mark iframe area as loaded when we hit /graph/* + useEffect(() => { + if (isGrafanaPage) setIsLoaded(true); }, [isGrafanaPage]); + // Propagate location changes to Grafana (except POP from Grafana itself) 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') @@ -49,64 +105,87 @@ export const GrafanaProvider: FC = ({ children }) => { }); }, [location, navigationType]); + // Set up messenger and standard listeners once iframe area is ready useEffect(() => { - if (!isLoaded) { - return; - } + if (!isLoaded) return; messenger .setTargetWindow(frameRef.current?.contentWindow!, '#grafana-iframe') .register(); - // send current PMM theme to Grafana + // Send the current canonical theme to Grafana once messenger is ready. messenger.waitForMessage('MESSENGER_READY').then(() => { - messenger.sendMessage({ - type: 'CHANGE_THEME', - payload: { - theme: colorMode, - }, - }); + const mode = readHtmlMode(); // take from , already synced with Grafana Prefs + if (lastSentThemeRef.current !== mode) { + lastSentThemeRef.current = mode; + messenger.sendMessage({ + type: 'CHANGE_THEME', + payload: { theme: mode }, + }); + } }); + // Mirror Grafana → PMM route changes (except POP) messenger.addListener({ type: 'LOCATION_CHANGE', - onMessage: ({ payload: location }: LocationChangeMessage) => { - if (!location || location.action === 'POP') { - return; - } - - navigate(getLocationUrl(location), { + onMessage: ({ payload: loc }: LocationChangeMessage) => { + if (!loc || loc.action === 'POP') return; + navigate(getLocationUrl(loc), { state: { fromGrafana: true }, replace: true, }); }, }); + // Mirror Grafana document title messenger.addListener({ type: 'DOCUMENT_TITLE_CHANGE', onMessage: ({ payload }: DocumentTitleUpdateMessage) => { - if (!payload) { - return; - } - + if (!payload) return; updateDocumentTitle(payload.title); }, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoaded]); + // If outer theme changes (our hook updates ), reflect it to Grafana quickly useEffect(() => { - if (!isLoaded) { - return; + if (!isLoaded) return; + const mode = readHtmlMode(); // canonical + if (lastSentThemeRef.current !== mode) { + lastSentThemeRef.current = mode; + messenger.sendMessage({ type: 'CHANGE_THEME', payload: { theme: mode } }); } + }, [isLoaded, location]); // re-evaluate on navigation; inexpensive and safe - messenger.sendMessage({ - type: 'CHANGE_THEME', - payload: { - theme: colorMode, - }, - }); - }, [isLoaded, colorMode]); + // Hard guarantee: listen for grafana.theme.changed on /graph/* pages and apply locally (no persist/broadcast). + useEffect(() => { + if (!isLoaded) return; + + const onMsg = ( + e: MessageEvent<{ + type?: string; + payload?: { mode?: string; payloadMode?: string; isDark?: boolean }; + }> + ) => { + // Security: ignore unexpected origins in production + if (!isTrustedOriginRef.current(e.origin)) return; + + if (!e?.data || e.data.type !== 'grafana.theme.changed') return; + const p = e.data.payload ?? {}; + const raw = p.mode ?? p.payloadMode ?? (p.isDark ? 'dark' : 'light'); + const desired = normalizeMode(raw); + + // Apply locally only to avoid ping-pong; persistence is handled by left action. + setFromGrafana(desired).catch((err) => + // eslint-disable-next-line no-console + console.warn('[GrafanaProvider] setFromGrafana failed:', err) + ); + }; + + window.addEventListener('message', onMsg); + return () => window.removeEventListener('message', onMsg); + }, [isLoaded, setFromGrafana]); return ( { const { colorMode, toggleColorMode } = useContext(ColorModeContext); - const { mutate } = useUpdatePreferences(); + const { mutateAsync } = useUpdatePreferences(); + const colorModeRef = useRef(colorMode); + + useEffect(() => { + colorModeRef.current = colorMode; + }, [colorMode]); + + useGrafanaThemeSyncOnce(colorModeRef); - const toggleMode = () => { + const toggleMode = useCallback(async () => { + const prev = colorModeRef.current; + const next: Mode = prev === 'light' ? 'dark' : 'light'; + + // Optimistic UI update toggleColorMode(); - mutate({ - theme: colorMode === 'light' ? 'dark' : 'light', - }); - }; + colorModeRef.current = next; + + try { + await mutateAsync({ theme: next }); + } catch (err) { + // Rollback on failure + console.warn('[useColorMode] Persist failed, rolling back:', err); + toggleColorMode(); + colorModeRef.current = prev; + } finally { + // Best-effort persistence in localStorage + try { + localStorage.setItem('colorMode', colorModeRef.current); + } catch (e) { + console.warn('[useColorMode] localStorage set failed:', e); + } + } + }, [toggleColorMode, mutateAsync]); return { colorMode, toggleColorMode: toggleMode }; }; diff --git a/ui/apps/pmm/src/hooks/useGrafanaThemeSyncOnce.ts b/ui/apps/pmm/src/hooks/useGrafanaThemeSyncOnce.ts new file mode 100644 index 00000000000..185f8a53df5 --- /dev/null +++ b/ui/apps/pmm/src/hooks/useGrafanaThemeSyncOnce.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef } from 'react'; +import { useSetTheme } from 'themes/setTheme'; + +type Mode = 'light' | 'dark'; + +interface GrafanaPreferences { + theme?: 'dark' | 'light' | string; +} + +declare global { + interface Window { + __pmm_has_theme_listener__?: string; + } +} + +/** + * One-time sync with Grafana preferences and live postMessage events. + * In dev we accept any origin to support split hosts/ports (vite + docker). + * In prod we only accept messages from allowed origins. + */ +export function useGrafanaThemeSyncOnce( + colorModeRef: React.MutableRefObject +) { + const syncedRef = useRef(false); + const { setFromGrafana } = useSetTheme(); + + useEffect(() => { + if (syncedRef.current) return; + syncedRef.current = true; + + // ----- origin allow-list ------------------------------------------------- + // By default allow current host (PMM UI) and optional Grafana origin + const allowed = new Set([window.location.origin]); + const grafanaOrigin = + (import.meta as ImportMeta).env.VITE_GRAFANA_ORIGIN as string | undefined; + if (grafanaOrigin) allowed.add(grafanaOrigin); + + const isTrustedOrigin = (origin: string) => + import.meta.env.DEV ? true : allowed.has(origin); + // ------------------------------------------------------------------------ + + const ensureMode = (desired: Mode) => { + setFromGrafana(desired).catch((err) => { + // eslint-disable-next-line no-console + console.warn('[useGrafanaThemeSyncOnce] apply failed:', err); + }); + colorModeRef.current = desired; + try { + localStorage.setItem('colorMode', desired); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[useGrafanaThemeSyncOnce] localStorage set failed:', err); + } + }; + + fetch('/graph/api/user/preferences', { credentials: 'include' }) + .then(async (r): Promise => + r.ok ? ((await r.json()) as GrafanaPreferences) : null + ) + .then((prefs) => { + if (!prefs) return; + const desired: Mode = prefs.theme === 'dark' ? 'dark' : 'light'; + ensureMode(desired); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.warn('[useGrafanaThemeSyncOnce] read prefs failed:', err); + }); + + const onMsg = ( + e: MessageEvent<{ + type?: string; + payload?: { mode?: string; payloadMode?: string; isDark?: boolean }; + }> + ) => { + // Security: ignore unexpected origins in production + if (!isTrustedOrigin(e.origin)) return; + + const data = e.data; + if (!data || data.type !== 'grafana.theme.changed') return; + + const p = data.payload ?? {}; + const raw = p.mode ?? p.payloadMode ?? (p.isDark ? 'dark' : 'light'); + const desired: Mode = + String(raw).toLowerCase() === 'dark' ? 'dark' : 'light'; + ensureMode(desired); + }; + + window.addEventListener('message', onMsg); + return () => window.removeEventListener('message', onMsg); + }, [colorModeRef, setFromGrafana]); +} diff --git a/ui/apps/pmm/src/themes/setTheme.ts b/ui/apps/pmm/src/themes/setTheme.ts new file mode 100644 index 00000000000..40806ed9bb1 --- /dev/null +++ b/ui/apps/pmm/src/themes/setTheme.ts @@ -0,0 +1,127 @@ +import { useContext, useRef } from 'react'; +import { ColorModeContext } from '@percona/design'; +import messenger from 'lib/messenger'; +import { grafanaApi } from 'api/api'; + +type Mode = 'light' | 'dark'; + +/** Normalizes any incoming value to 'light' | 'dark'. */ +function normalizeMode(v: unknown): Mode { + if (typeof v === 'string' && v.toLowerCase() === 'dark') return 'dark'; + if (v === true) return 'dark'; + return 'light'; +} + +/** Idempotently applies theme attributes to . */ +function applyDocumentTheme(mode: Mode) { + const html = document.documentElement as HTMLElement & { + style: CSSStyleDeclaration & { colorScheme?: string }; + }; + const scheme = mode === 'dark' ? 'percona-dark' : 'percona-light'; + const wantDark = mode === 'dark'; + const hasDarkClass = html.classList.contains('dark'); + + // If everything (including the Tailwind 'dark' class) is already correct, skip. + if ( + html.getAttribute('data-theme') === mode && + html.getAttribute('data-md-color-scheme') === scheme && + html.style.colorScheme === mode && + hasDarkClass === wantDark + ) { + return; + } + + html.classList.toggle('dark', wantDark); + + html.setAttribute('data-theme', mode); + html.setAttribute('data-md-color-scheme', scheme); + html.style.colorScheme = mode; +} + +/** + * 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)); + + // Keep the reference always up-to-date with current color mode + modeRef.current = normalizeMode(colorMode); + + /** Apply theme locally (left UI) in an idempotent way */ + 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; + } + // Ensure attributes match immediately (CSS consumes these) + applyDocumentTheme(next); + + try { + localStorage.setItem('colorMode', next); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[useSetTheme] Failed to save theme to localStorage:', err); + } + }; + + /** Low-level primitive with options to avoid ping-pong and over-persisting */ + 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) { + try { + await grafanaApi.put('/user/preferences', { theme: next }); + } catch (err) { + // eslint-disable-next-line no-console + console.warn( + '[useSetTheme] Failed to persist theme to Grafana preferences:', + err + ); + } + } + + // 3) notify iframe (only when we are the initiator, not when we sync from Grafana) + if (opts.broadcast) { + try { + messenger.sendMessage({ + type: 'CHANGE_THEME', + payload: { theme: next }, + }); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[useSetTheme] Failed to send CHANGE_THEME message:', err); + } + } + }; + + /** + * Public API kept backward compatible: + * - setTheme(next): left action — apply + persist + broadcast (same behavior as before) + * - setFromGrafana(next): right→left sync — apply only (no persist, no broadcast) + */ + async function setTheme(next: Mode | string | boolean) { + await setThemeBase(next, { broadcast: true, persist: true }); + } + + async function setFromGrafana(next: Mode | string | boolean) { + await setThemeBase(next, { broadcast: false, persist: false }); + } + + return { setTheme, setFromGrafana }; +} diff --git a/ui/apps/pmm/tsconfig.node.json b/ui/apps/pmm/tsconfig.node.json index 42872c59f5b..22410d9918e 100644 --- a/ui/apps/pmm/tsconfig.node.json +++ b/ui/apps/pmm/tsconfig.node.json @@ -6,5 +6,7 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts"] + "include": [ + "./vite.config.ts" + ] } diff --git a/ui/apps/pmm/vite.config.ts b/ui/apps/pmm/vite.config.ts index 69737e9c501..12fd6f9d957 100644 --- a/ui/apps/pmm/vite.config.ts +++ b/ui/apps/pmm/vite.config.ts @@ -1,33 +1,63 @@ import tsconfigPaths from 'vite-tsconfig-paths'; import react from '@vitejs/plugin-react-swc'; -import { defineConfig } from 'vitest/config'; -import svgr from 'vite-plugin-svgr' +import svgr from 'vite-plugin-svgr'; +import { defineConfig, loadEnv } from 'vite'; -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [tsconfigPaths({ root: '.' }), react(), svgr()], - base: '/pmm-ui', - server: { - proxy: { - '/v1': { - target: '/', - }, - }, - host: '0.0.0.0', - strictPort: true, - hmr: { - clientPort: 5173, - }, - allowedHosts: ['host.docker.internal'], - }, - test: { - globals: true, - environment: 'jsdom', - setupFiles: 'src/setupTests.ts', +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + const target = env.VITE_API_URL || 'http://localhost'; + + return { + plugins: [ + tsconfigPaths({ root: '.' }), + svgr({ + svgrOptions: { exportType: 'default' }, + }), + react(), + ], + base: '/pmm-ui', server: { - deps: { - fallbackCJS: true, + host: '0.0.0.0', + strictPort: true, + hmr: { clientPort: 5173 }, + allowedHosts: ['host.docker.internal', 'localhost', '127.0.0.1'], + proxy: { + '/graph': { + target, + changeOrigin: true, + secure: false, + ws: true, + // make Grafana cookies valid for localhost:5173 + cookieDomainRewrite: '', // <— add + cookiePathRewrite: '/', // <— add + // drop Secure on HTTP in dev (optional but useful) + configure: (proxy) => { + proxy.on('proxyRes', (proxyRes) => { + const setCookie = proxyRes.headers['set-cookie']; + if (Array.isArray(setCookie)) { + proxyRes.headers['set-cookie'] = setCookie.map( + (c) => + c + .replace(/;\s*Path=\/graph/gi, '; Path=/') // <— force / + .replace(/;\s*Secure/gi, '') // <— drop Secure in dev + ); + } + }); + }, + }, + + '/qan-api': { target, changeOrigin: true, secure: false }, + '/inventory-api': { target, changeOrigin: true, secure: false }, + '/prometheus': { target, changeOrigin: true, secure: false }, + '/vmalert': { target, changeOrigin: true, secure: false }, + '/alertmanager': { target, changeOrigin: true, secure: false }, + '/v1': { + target, + changeOrigin: true, + secure: false, + cookieDomainRewrite: '', + }, }, }, - }, + }; }); diff --git a/ui/docker-compose.yml b/ui/docker-compose.yml index b1fd9b62917..7bb9eb21bde 100644 --- a/ui/docker-compose.yml +++ b/ui/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: pmm-server # Temporary till we have arm builds platform: linux/amd64 - image: ${PMM_SERVER_IMAGE:-perconalab/pmm-server:3-dev-container} + image: perconalab/pmm-server-fb:PR-3984-2fb3471 ports: - 80:9080 - 443:8443 @@ -14,7 +14,7 @@ services: # - pmm-data:/srv # Uncomment to use custom (FE) grafana code - #- '../../grafana/public:/usr/share/grafana/public' + - ../../grafana/public:/usr/share/grafana/public # PMM compat plugin - ./apps/pmm-compat/dist:/srv/grafana/plugins/pmm-compat From b017635d1e157636fdae91fded8576edccf99012 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Fri, 17 Oct 2025 16:19:19 +0300 Subject: [PATCH 02/20] resolve errors --- ui/apps/pmm-compat/src/compat.ts | 257 +++++++++--------- .../components/sidebar/nav-item/NavItem.tsx | 3 +- .../src/contexts/grafana/grafana.provider.tsx | 1 - .../pmm/src/hooks/useGrafanaThemeSyncOnce.ts | 12 +- ui/apps/pmm/src/themes/setTheme.ts | 3 - 5 files changed, 134 insertions(+), 142 deletions(-) diff --git a/ui/apps/pmm-compat/src/compat.ts b/ui/apps/pmm-compat/src/compat.ts index 6001400f6ce..2e223f05b8d 100644 --- a/ui/apps/pmm-compat/src/compat.ts +++ b/ui/apps/pmm-compat/src/compat.ts @@ -1,16 +1,16 @@ import { locationService, getAppEvents, ThemeChangedEvent, config } from '@grafana/runtime'; import { - ChangeThemeMessage, - CrossFrameMessenger, - DashboardVariablesMessage, - HistoryAction, - LocationChangeMessage, + ChangeThemeMessage, + CrossFrameMessenger, + DashboardVariablesMessage, + HistoryAction, + LocationChangeMessage, } from '@pmm/shared'; import { - GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, - GRAFANA_LOGIN_PATH, - GRAFANA_SUB_PATH, - PMM_UI_PATH, + GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, + GRAFANA_LOGIN_PATH, + GRAFANA_SUB_PATH, + PMM_UI_PATH, } from 'lib/constants'; import { applyCustomStyles } from 'styles'; import { changeTheme } from 'theme'; @@ -19,93 +19,93 @@ import { isWithinIframe, getLinkWithVariables } from 'lib/utils'; import { documentTitleObserver } from 'lib/utils/document'; export const initialize = () => { - if (!isWithinIframe() && !window.location.pathname.startsWith(GRAFANA_LOGIN_PATH)) { - // redirect user to the new UI - window.location.replace(window.location.href.replace(GRAFANA_SUB_PATH, PMM_UI_PATH)); + if (!isWithinIframe() && !window.location.pathname.startsWith(GRAFANA_LOGIN_PATH)) { + // redirect user to the new UI + window.location.replace(window.location.href.replace(GRAFANA_SUB_PATH, PMM_UI_PATH)); + return; + } + + const messenger = new CrossFrameMessenger('GRAFANA').setTargetWindow(window.top!).register(); + + messenger.addListener({ + type: 'CHANGE_THEME', + onMessage: (msg: ChangeThemeMessage) => { + if (!msg.payload) { return; - } + } + changeTheme(msg.payload.theme); + }, + }); - const messenger = new CrossFrameMessenger('GRAFANA').setTargetWindow(window.top!).register(); + messenger.sendMessage({ type: 'MESSENGER_READY' }); - messenger.addListener({ - type: 'CHANGE_THEME', - onMessage: (msg: ChangeThemeMessage) => { - if (!msg.payload) { - return; - } - changeTheme(msg.payload.theme); - }, - }); + // set docked state to false + localStorage.setItem(GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, 'false'); - messenger.sendMessage({ type: 'MESSENGER_READY' }); + applyCustomStyles(); + adjustToolbar(); - // set docked state to false - localStorage.setItem(GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, 'false'); + // Wire Grafana theme events to Percona scheme attribute and notify parent + setupThemeWiring(); - applyCustomStyles(); - adjustToolbar(); + messenger.sendMessage({ type: 'GRAFANA_READY' }); - // Wire Grafana theme events to Percona scheme attribute and notify parent - setupThemeWiring(); + messenger.addListener({ + type: 'LOCATION_CHANGE', + onMessage: ({ payload: location }: LocationChangeMessage) => { + if (!location) { + return; + } + locationService.replace(location); + }, + }); - messenger.sendMessage({ type: 'GRAFANA_READY' }); + messenger.sendMessage({ + type: 'DOCUMENT_TITLE_CHANGE', + payload: { title: document.title }, + }); - messenger.addListener({ - type: 'LOCATION_CHANGE', - onMessage: ({ payload: location }: LocationChangeMessage) => { - if (!location) { - return; - } - locationService.replace(location); - }, + documentTitleObserver.listen((title) => { + messenger.sendMessage({ + type: 'DOCUMENT_TITLE_CHANGE', + payload: { title }, }); + }); + + 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(); + } messenger.sendMessage({ - type: 'DOCUMENT_TITLE_CHANGE', - payload: { title: document.title }, + type: 'LOCATION_CHANGE', + payload: { + action, + ...location, + }, }); - documentTitleObserver.listen((title) => { - messenger.sendMessage({ - type: 'DOCUMENT_TITLE_CHANGE', - payload: { title }, - }); - }); + prevLocation = location; + }); - 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(); - } - - messenger.sendMessage({ - type: 'LOCATION_CHANGE', - payload: { - action, - ...location, - }, - }); - - prevLocation = location; - }); + messenger.addListener({ + type: 'DASHBOARD_VARIABLES', + onMessage: (msg: DashboardVariablesMessage) => { + if (!msg.payload || !msg.payload.url) { + return; + } - messenger.addListener({ - type: 'DASHBOARD_VARIABLES', - onMessage: (msg: DashboardVariablesMessage) => { - if (!msg.payload || !msg.payload.url) { - return; - } - - const url = getLinkWithVariables(msg.payload.url); - - messenger.sendMessage({ - id: msg.id, - type: msg.type, - payload: { url }, - }); - }, - }); + const url = getLinkWithVariables(msg.payload.url); + + messenger.sendMessage({ + id: msg.id, + type: msg.type, + payload: { url }, + }); + }, + }); }; /** @@ -114,53 +114,52 @@ export const initialize = () => { */ // comments in English only function setupThemeWiring() { - // Resolve parent origin robustly (dev vs prod) - const getParentOrigin = (): string => { - 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) => { - // 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); - html.setAttribute('data-theme', mode); - (html.style as any).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); - } 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'; - apply(initialMode); - - // React to Grafana theme changes (Preferences change/changeTheme()) - getAppEvents().subscribe(ThemeChangedEvent, (evt: any) => { - const next = evt?.payload?.colors?.mode ?? (evt?.payload?.isDark ? 'dark' : 'light') ?? 'light'; - apply(next); - }); + // Resolve parent origin robustly (dev vs prod) + const getParentOrigin = (): string => { + 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) => { + // 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); + 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); + } 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'; + apply(initialMode); + + // React to Grafana theme changes (Preferences change/changeTheme()) + getAppEvents().subscribe(ThemeChangedEvent, (evt: any) => { + const next = evt?.payload?.colors?.mode ?? (evt?.payload?.isDark ? 'dark' : 'light') ?? 'light'; + apply(next); + }); } diff --git a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.tsx b/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.tsx index 69289a5dbdf..a6fc1ae79ec 100644 --- a/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.tsx +++ b/ui/apps/pmm/src/components/sidebar/nav-item/NavItem.tsx @@ -20,7 +20,7 @@ import NavItemIcon from './nav-item-icon/NavItemIcon'; import IconButton from '@mui/material/IconButton'; import NavItemTooltip from './nav-item-tooltip/NavItemTooltip'; import { DRAWER_WIDTH } from '../drawer/Drawer.constants'; -import { useSetTheme } from 'themes/setTheme.ts'; +import { useSetTheme } from 'themes/setTheme'; const NavItem: FC = ({ item, drawerOpen, level = 0 }) => { const location = useLocation(); @@ -57,7 +57,6 @@ const NavItem: FC = ({ item, drawerOpen, level = 0 }) => { const next: 'light' | 'dark' = paletteMode === 'dark' ? 'light' : 'dark'; await setTheme(next); } catch (err) { - // eslint-disable-next-line no-console console.warn('[NavItem] Theme toggle failed:', err); } }, [paletteMode, setTheme]); diff --git a/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx b/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx index 819571e48c2..b7df7748bcb 100644 --- a/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx +++ b/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx @@ -178,7 +178,6 @@ export const GrafanaProvider: FC = ({ children }) => { // Apply locally only to avoid ping-pong; persistence is handled by left action. setFromGrafana(desired).catch((err) => - // eslint-disable-next-line no-console console.warn('[GrafanaProvider] setFromGrafana failed:', err) ); }; diff --git a/ui/apps/pmm/src/hooks/useGrafanaThemeSyncOnce.ts b/ui/apps/pmm/src/hooks/useGrafanaThemeSyncOnce.ts index 185f8a53df5..44c2af4f6b0 100644 --- a/ui/apps/pmm/src/hooks/useGrafanaThemeSyncOnce.ts +++ b/ui/apps/pmm/src/hooks/useGrafanaThemeSyncOnce.ts @@ -31,8 +31,8 @@ export function useGrafanaThemeSyncOnce( // ----- origin allow-list ------------------------------------------------- // By default allow current host (PMM UI) and optional Grafana origin const allowed = new Set([window.location.origin]); - const grafanaOrigin = - (import.meta as ImportMeta).env.VITE_GRAFANA_ORIGIN as string | undefined; + const grafanaOrigin = (import.meta as ImportMeta).env + .VITE_GRAFANA_ORIGIN as string | undefined; if (grafanaOrigin) allowed.add(grafanaOrigin); const isTrustedOrigin = (origin: string) => @@ -41,21 +41,20 @@ export function useGrafanaThemeSyncOnce( const ensureMode = (desired: Mode) => { setFromGrafana(desired).catch((err) => { - // eslint-disable-next-line no-console console.warn('[useGrafanaThemeSyncOnce] apply failed:', err); }); colorModeRef.current = desired; try { localStorage.setItem('colorMode', desired); } catch (err) { - // eslint-disable-next-line no-console console.warn('[useGrafanaThemeSyncOnce] localStorage set failed:', err); } }; fetch('/graph/api/user/preferences', { credentials: 'include' }) - .then(async (r): Promise => - r.ok ? ((await r.json()) as GrafanaPreferences) : null + .then( + async (r): Promise => + r.ok ? ((await r.json()) as GrafanaPreferences) : null ) .then((prefs) => { if (!prefs) return; @@ -63,7 +62,6 @@ export function useGrafanaThemeSyncOnce( ensureMode(desired); }) .catch((err) => { - // eslint-disable-next-line no-console console.warn('[useGrafanaThemeSyncOnce] read prefs failed:', err); }); diff --git a/ui/apps/pmm/src/themes/setTheme.ts b/ui/apps/pmm/src/themes/setTheme.ts index 40806ed9bb1..3c1b0bd2efd 100644 --- a/ui/apps/pmm/src/themes/setTheme.ts +++ b/ui/apps/pmm/src/themes/setTheme.ts @@ -65,7 +65,6 @@ export function useSetTheme() { try { localStorage.setItem('colorMode', next); } catch (err) { - // eslint-disable-next-line no-console console.warn('[useSetTheme] Failed to save theme to localStorage:', err); } }; @@ -88,7 +87,6 @@ export function useSetTheme() { try { await grafanaApi.put('/user/preferences', { theme: next }); } catch (err) { - // eslint-disable-next-line no-console console.warn( '[useSetTheme] Failed to persist theme to Grafana preferences:', err @@ -104,7 +102,6 @@ export function useSetTheme() { payload: { theme: next }, }); } catch (err) { - // eslint-disable-next-line no-console console.warn('[useSetTheme] Failed to send CHANGE_THEME message:', err); } } From f482411addf2b970cc2ac974346790a8e67708c5 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Fri, 17 Oct 2025 16:20:33 +0300 Subject: [PATCH 03/20] Please enter the commit message for your changes. Lines starting --- ui/apps/pmm-compat/src/compat.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/apps/pmm-compat/src/compat.ts b/ui/apps/pmm-compat/src/compat.ts index 2e223f05b8d..67a26780835 100644 --- a/ui/apps/pmm-compat/src/compat.ts +++ b/ui/apps/pmm-compat/src/compat.ts @@ -112,7 +112,6 @@ export const initialize = () => { * Wires Grafana theme changes (ThemeChangedEvent) to Percona CSS scheme. * Ensures has the attribute our CSS reads and informs the parent frame. */ -// comments in English only function setupThemeWiring() { // Resolve parent origin robustly (dev vs prod) const getParentOrigin = (): string => { From 725ca9912f86cd5c7b58c2cee87958a143aa299d Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Mon, 20 Oct 2025 12:46:54 +0300 Subject: [PATCH 04/20] resolve unit testes errros --- .../src/contexts/grafana/grafana.provider.tsx | 152 +++++++++++------- ui/apps/pmm/src/setupTests.ts | 57 +++++++ ui/apps/pmm/vitest.config.ts | 47 ++++++ 3 files changed, 201 insertions(+), 55 deletions(-) create mode 100644 ui/apps/pmm/vitest.config.ts diff --git a/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx b/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx index b7df7748bcb..dfcca45d68f 100644 --- a/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx +++ b/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx @@ -7,7 +7,6 @@ import { PMM_NEW_NAV_PATH, } from 'lib/constants'; import { DocumentTitleUpdateMessage, LocationChangeMessage } from '@pmm/shared'; -import messenger from 'lib/messenger'; import { getLocationUrl } from './grafana.utils'; import { updateDocumentTitle } from 'lib/utils/document.utils'; import { useKioskMode } from 'hooks/utils/useKioskMode'; @@ -16,13 +15,18 @@ import { useSetTheme } from 'themes/setTheme'; type Mode = 'light' | 'dark'; +const isBrowser = (): boolean => + typeof window !== 'undefined' && typeof window.addEventListener === 'function'; + /** Reads canonical mode from attributes set by our theme hook. */ -const readHtmlMode = (): Mode => - document.documentElement +const readHtmlMode = (): Mode => { + if (!isBrowser()) return 'light'; + return document.documentElement .getAttribute('data-md-color-scheme') ?.includes('dark') ? 'dark' : 'light'; +}; /** Normalizes any incoming value to 'light' | 'dark'. */ const normalizeMode = (v: unknown): Mode => @@ -34,7 +38,8 @@ const normalizeMode = (v: unknown): Mode => /** Resolve optional Grafana origin provided via env (e.g. https://pmm.example.com). */ const resolveGrafanaOrigin = (): string | undefined => { - const raw = (import.meta as ImportMeta)?.env?.VITE_GRAFANA_ORIGIN as + // Import meta may be undefined in tests; guard access. + const raw = (import.meta as ImportMeta | undefined)?.env?.VITE_GRAFANA_ORIGIN as | string | undefined; if (!raw) return undefined; @@ -47,9 +52,10 @@ const resolveGrafanaOrigin = (): string | undefined => { /** Build a trust predicate for postMessage origins. */ const makeIsTrustedOrigin = () => { - // In dev, we accept any origin to support split hosts/ports (vite + docker) - if (import.meta.env.DEV) return () => true; + // In dev, accept any origin to support split hosts/ports (vite + docker) + if ((import.meta as ImportMeta | undefined)?.env?.DEV) return () => true; + if (!isBrowser()) return () => false; const set = new Set([window.location.origin]); const grafanaOrigin = resolveGrafanaOrigin(); if (grafanaOrigin) set.add(grafanaOrigin); @@ -73,7 +79,11 @@ export const GrafanaProvider: FC = ({ children }) => { const { setFromGrafana } = useSetTheme(); // Remember last theme we sent to avoid resending the same value. - const lastSentThemeRef = useRef(readHtmlMode()); + // Do not read document during initial render (tests/SSR friendly). + const lastSentThemeRef = useRef('light'); + + // Keep messenger instance lazily loaded and scoped to browser only. + const messengerRef = useRef(null); // Trusted-origin predicate for postMessage validation. const isTrustedOriginRef = useRef<(o: string) => boolean>(() => true); @@ -86,16 +96,87 @@ export const GrafanaProvider: FC = ({ children }) => { if (isGrafanaPage) setIsLoaded(true); }, [isGrafanaPage]); + // Lazily import and register messenger when iframe area is ready (browser only). + useEffect(() => { + if (!isLoaded || !isBrowser()) return; + + let mounted = true; + (async () => { + try { + const mod = await import('lib/messenger'); + if (!mounted) return; + + const messenger = mod.default; + messengerRef.current = messenger; + + messenger.setTargetWindow(frameRef.current?.contentWindow!, '#grafana-iframe').register(); + + // Initialize lastSentThemeRef from DOM now that we are in browser. + lastSentThemeRef.current = readHtmlMode(); + + // Send the current canonical theme to Grafana once messenger is ready. + messenger.waitForMessage?.('MESSENGER_READY').then(() => { + const mode = readHtmlMode(); + if (lastSentThemeRef.current !== mode) { + lastSentThemeRef.current = mode; + messenger.sendMessage?.({ + type: 'CHANGE_THEME', + payload: { theme: mode }, + }); + } + }); + + // Mirror Grafana → PMM route changes (except POP) + messenger.addListener?.({ + type: 'LOCATION_CHANGE', + onMessage: ({ payload: loc }: LocationChangeMessage) => { + if (!loc || (loc as any).action === 'POP') return; + navigate(getLocationUrl(loc), { + state: { fromGrafana: true }, + replace: true, + }); + }, + }); + + // Mirror Grafana document title + messenger.addListener?.({ + type: 'DOCUMENT_TITLE_CHANGE', + onMessage: ({ payload }: DocumentTitleUpdateMessage) => { + if (!payload) return; + updateDocumentTitle(payload.title); + }, + }); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('[GrafanaProvider] lazy messenger setup failed:', err); + } + })(); + + return () => { + mounted = false; + try { + messengerRef.current?.unregister?.(); + } catch { + // no-op + } + messengerRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded]); + // Propagate location changes to Grafana (except POP from Grafana itself) useEffect(() => { + if (!isBrowser()) return; if ( !location.pathname.includes('/graph') || - (location.state?.fromGrafana && navigationType !== 'POP') + (location.state as any)?.fromGrafana ) { return; } + const messenger = messengerRef.current; + if (!messenger) return; - messenger.sendMessage({ + messenger.sendMessage?.({ type: 'LOCATION_CHANGE', payload: { ...location, @@ -105,62 +186,22 @@ export const GrafanaProvider: FC = ({ children }) => { }); }, [location, navigationType]); - // Set up messenger and standard listeners once iframe area is ready - useEffect(() => { - if (!isLoaded) return; - - messenger - .setTargetWindow(frameRef.current?.contentWindow!, '#grafana-iframe') - .register(); - - // Send the current canonical theme to Grafana once messenger is ready. - messenger.waitForMessage('MESSENGER_READY').then(() => { - const mode = readHtmlMode(); // take from , already synced with Grafana Prefs - if (lastSentThemeRef.current !== mode) { - lastSentThemeRef.current = mode; - messenger.sendMessage({ - type: 'CHANGE_THEME', - payload: { theme: mode }, - }); - } - }); - - // Mirror Grafana → PMM route changes (except POP) - messenger.addListener({ - type: 'LOCATION_CHANGE', - onMessage: ({ payload: loc }: LocationChangeMessage) => { - if (!loc || loc.action === 'POP') return; - navigate(getLocationUrl(loc), { - state: { fromGrafana: true }, - replace: true, - }); - }, - }); - - // Mirror Grafana document title - messenger.addListener({ - type: 'DOCUMENT_TITLE_CHANGE', - onMessage: ({ payload }: DocumentTitleUpdateMessage) => { - if (!payload) return; - updateDocumentTitle(payload.title); - }, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoaded]); - // If outer theme changes (our hook updates ), reflect it to Grafana quickly useEffect(() => { - if (!isLoaded) return; + if (!isLoaded || !isBrowser()) return; const mode = readHtmlMode(); // canonical if (lastSentThemeRef.current !== mode) { lastSentThemeRef.current = mode; - messenger.sendMessage({ type: 'CHANGE_THEME', payload: { theme: mode } }); + messengerRef.current?.sendMessage?.({ + type: 'CHANGE_THEME', + payload: { theme: mode }, + }); } }, [isLoaded, location]); // re-evaluate on navigation; inexpensive and safe // Hard guarantee: listen for grafana.theme.changed on /graph/* pages and apply locally (no persist/broadcast). useEffect(() => { - if (!isLoaded) return; + if (!isLoaded || !isBrowser()) return; const onMsg = ( e: MessageEvent<{ @@ -178,6 +219,7 @@ export const GrafanaProvider: FC = ({ children }) => { // Apply locally only to avoid ping-pong; persistence is handled by left action. setFromGrafana(desired).catch((err) => + // eslint-disable-next-line no-console console.warn('[GrafanaProvider] setFromGrafana failed:', err) ); }; diff --git a/ui/apps/pmm/src/setupTests.ts b/ui/apps/pmm/src/setupTests.ts index 7b0828bfa80..c3f8e4c65c9 100644 --- a/ui/apps/pmm/src/setupTests.ts +++ b/ui/apps/pmm/src/setupTests.ts @@ -1 +1,58 @@ +/** + * Why this file exists: + * - Some UI code and libraries (e.g. MUI) expect browser APIs that jsdom does not fully implement. + * - Tests run in Node, so we provide light polyfills to match what the components need. + * + * What this file adds: + * 1) Extends Jest-DOM matchers for better assertions in @testing-library/react tests. + * 2) Polyfills `window.matchMedia` so MUI and responsive code paths do not crash in jsdom. + * 3) Provides `TextEncoder`/`TextDecoder` on Node globals because some libs access them. + * 4) Adds a minimal `ResizeObserver` stub for components that read layout changes. + * + * Scope: + * - Only testing environment is affected. Production code is unchanged. + */ import '@testing-library/jest-dom'; + +/** Minimal matchMedia polyfill for jsdom/MUI. */ +if (typeof window !== 'undefined' && !window.matchMedia) { + window.matchMedia = (query: string): MediaQueryList => { + return { + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }; + }; +} + +/** TextEncoder/TextDecoder for Node test env (some libs expect them). */ +import { TextEncoder as NodeTextEncoder, TextDecoder as NodeTextDecoder } from 'util'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const g: any = globalThis; +if (typeof g.TextEncoder === 'undefined') g.TextEncoder = NodeTextEncoder; +if (typeof g.TextDecoder === 'undefined') g.TextDecoder = NodeTextDecoder; + +/** Optional: ResizeObserver stub if components expect it. */ +declare global { + // Provide a minimal type to avoid "any" + interface Window { + ResizeObserver?: new () => { + observe: (target: Element) => void; + unobserve: (target: Element) => void; + disconnect: () => void; + }; + } +} + +if (typeof window !== 'undefined' && !window.ResizeObserver) { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +} diff --git a/ui/apps/pmm/vitest.config.ts b/ui/apps/pmm/vitest.config.ts new file mode 100644 index 00000000000..8ba065599c9 --- /dev/null +++ b/ui/apps/pmm/vitest.config.ts @@ -0,0 +1,47 @@ +/** + * Why this file exists: + * - Our tests import UI code that depends on browser-only APIs (MUI, postMessage, etc.). + * - Vitest (Node) needs a browser-like environment (jsdom) and proper module resolution for MUI and local path aliases. + * - MUI sometimes performs "directory imports" (e.g. '@mui/material/CssBaseline') which Node ESM cannot resolve. + * + * What this config fixes: + * 1) Enables a DOM-like environment for React/MUI via `environment: 'jsdom'`. + * 2) Makes Vitest treat common testing globals (`describe`, `it`, `vi`, `expect`) without imports. + * 3) Forces all dependencies to be inlined/transformed by Vite so that MUI "directory imports" + * get rewritten properly even when they appear inside other packages (e.g. @percona/design). + * 4) Adds minimal path aliases used by the app in tests (utils, lib, hooks, types, etc.). + * 5) Adds a safety alias for CssBaseline to avoid Node ESM "directory import" resolution errors. + * + * Notes: + * - If Vitest later warns that `deps.inline` is deprecated, switch to `server.deps.inline` (example below). + */ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['src/setupTests.ts'], + deps: { + inline: true, + }, + }, + resolve: { + conditions: ['browser', 'module', 'import', 'default'], + alias: { + utils: resolve(__dirname, 'src/utils'), + lib: resolve(__dirname, 'src/lib'), + hooks: resolve(__dirname, 'src/hooks'), + themes: resolve(__dirname, 'src/themes'), + components: resolve(__dirname, 'src/components'), + contexts: resolve(__dirname, 'src/contexts'), + pages: resolve(__dirname, 'src/pages'), + api: resolve(__dirname, 'src/api'), + icons: resolve(__dirname, 'src/icons'), + types: resolve(__dirname, 'src/types'), + assets: resolve(__dirname, 'src/assets'), + '@mui/material/CssBaseline': '@mui/material/node/CssBaseline/index.js', + }, + }, +}); From 50190e60fa15bffb33cf4a5d1626a6d786af31c6 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Mon, 20 Oct 2025 12:54:42 +0300 Subject: [PATCH 05/20] resolve lint errors --- .../src/contexts/grafana/grafana.provider.tsx | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx b/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx index dfcca45d68f..8812b1627d7 100644 --- a/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx +++ b/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx @@ -15,6 +15,24 @@ import { useSetTheme } from 'themes/setTheme'; type Mode = 'light' | 'dark'; +/** Minimal runtime shape of our messenger to avoid `any`. */ +type MessengerLike = { + setTargetWindow: (win: Window | null, fallbackSelector?: string) => MessengerLike; + register: () => MessengerLike; + unregister: () => void; + waitForMessage?: (type: string, timeoutMs?: number) => Promise; + addListener?: (args: { + type: T; + onMessage: (message: { type: T; payload: P }) => void; + }) => void; + sendMessage?: (message: { + type: T; + payload?: P; + }) => void; +}; + +type NavState = { fromGrafana?: boolean } | null; + const isBrowser = (): boolean => typeof window !== 'undefined' && typeof window.addEventListener === 'function'; @@ -38,7 +56,6 @@ const normalizeMode = (v: unknown): Mode => /** Resolve optional Grafana origin provided via env (e.g. https://pmm.example.com). */ const resolveGrafanaOrigin = (): string | undefined => { - // Import meta may be undefined in tests; guard access. const raw = (import.meta as ImportMeta | undefined)?.env?.VITE_GRAFANA_ORIGIN as | string | undefined; @@ -52,7 +69,6 @@ const resolveGrafanaOrigin = (): string | undefined => { /** Build a trust predicate for postMessage origins. */ const makeIsTrustedOrigin = () => { - // In dev, accept any origin to support split hosts/ports (vite + docker) if ((import.meta as ImportMeta | undefined)?.env?.DEV) return () => true; if (!isBrowser()) return () => false; @@ -79,11 +95,10 @@ export const GrafanaProvider: FC = ({ children }) => { const { setFromGrafana } = useSetTheme(); // Remember last theme we sent to avoid resending the same value. - // Do not read document during initial render (tests/SSR friendly). const lastSentThemeRef = useRef('light'); // Keep messenger instance lazily loaded and scoped to browser only. - const messengerRef = useRef(null); + const messengerRef = useRef(null); // Trusted-origin predicate for postMessage validation. const isTrustedOriginRef = useRef<(o: string) => boolean>(() => true); @@ -106,10 +121,10 @@ export const GrafanaProvider: FC = ({ children }) => { const mod = await import('lib/messenger'); if (!mounted) return; - const messenger = mod.default; + const messenger = mod.default as MessengerLike; messengerRef.current = messenger; - messenger.setTargetWindow(frameRef.current?.contentWindow!, '#grafana-iframe').register(); + messenger.setTargetWindow(frameRef.current?.contentWindow ?? null, '#grafana-iframe').register(); // Initialize lastSentThemeRef from DOM now that we are in browser. lastSentThemeRef.current = readHtmlMode(); @@ -127,10 +142,10 @@ export const GrafanaProvider: FC = ({ children }) => { }); // Mirror Grafana → PMM route changes (except POP) - messenger.addListener?.({ + messenger.addListener?.< 'LOCATION_CHANGE', LocationChangeMessage['payload'] >({ type: 'LOCATION_CHANGE', - onMessage: ({ payload: loc }: LocationChangeMessage) => { - if (!loc || (loc as any).action === 'POP') return; + onMessage: ({ payload: loc }) => { + if (!loc || loc.action === 'POP') return; navigate(getLocationUrl(loc), { state: { fromGrafana: true }, replace: true, @@ -139,15 +154,14 @@ export const GrafanaProvider: FC = ({ children }) => { }); // Mirror Grafana document title - messenger.addListener?.({ + messenger.addListener?.< 'DOCUMENT_TITLE_CHANGE', DocumentTitleUpdateMessage['payload'] >({ type: 'DOCUMENT_TITLE_CHANGE', - onMessage: ({ payload }: DocumentTitleUpdateMessage) => { + onMessage: ({ payload }) => { if (!payload) return; updateDocumentTitle(payload.title); }, }); } catch (err) { - // eslint-disable-next-line no-console console.warn('[GrafanaProvider] lazy messenger setup failed:', err); } })(); @@ -167,10 +181,9 @@ export const GrafanaProvider: FC = ({ children }) => { // Propagate location changes to Grafana (except POP from Grafana itself) useEffect(() => { if (!isBrowser()) return; - if ( - !location.pathname.includes('/graph') || - (location.state as any)?.fromGrafana - ) { + + const state = location.state as NavState; + if (!location.pathname.includes('/graph') || state?.fromGrafana) { return; } const messenger = messengerRef.current; @@ -219,7 +232,6 @@ export const GrafanaProvider: FC = ({ children }) => { // Apply locally only to avoid ping-pong; persistence is handled by left action. setFromGrafana(desired).catch((err) => - // eslint-disable-next-line no-console console.warn('[GrafanaProvider] setFromGrafana failed:', err) ); }; From ed0411851ccc4dfa4e40ff8c3c678c677e07a29c Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Mon, 20 Oct 2025 15:00:34 +0300 Subject: [PATCH 06/20] ci: retrigger checks From 8a8e638ede8c060b061cbf77548be39ca04b7b0e Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Tue, 21 Oct 2025 11:54:41 +0300 Subject: [PATCH 07/20] return docker-compose yml to it start version --- ui/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/docker-compose.yml b/ui/docker-compose.yml index 7bb9eb21bde..b1fd9b62917 100644 --- a/ui/docker-compose.yml +++ b/ui/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: pmm-server # Temporary till we have arm builds platform: linux/amd64 - image: perconalab/pmm-server-fb:PR-3984-2fb3471 + image: ${PMM_SERVER_IMAGE:-perconalab/pmm-server:3-dev-container} ports: - 80:9080 - 443:8443 @@ -14,7 +14,7 @@ services: # - pmm-data:/srv # Uncomment to use custom (FE) grafana code - - ../../grafana/public:/usr/share/grafana/public + #- '../../grafana/public:/usr/share/grafana/public' # PMM compat plugin - ./apps/pmm-compat/dist:/srv/grafana/plugins/pmm-compat From 3c28d075af9fa034d31870d925e94d804fb9f383 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Wed, 22 Oct 2025 13:14:49 +0300 Subject: [PATCH 08/20] revert vite config --- ui/apps/pmm/vite.config.ts | 80 ++++++++++++-------------------------- 1 file changed, 25 insertions(+), 55 deletions(-) diff --git a/ui/apps/pmm/vite.config.ts b/ui/apps/pmm/vite.config.ts index 12fd6f9d957..18b763ccb1d 100644 --- a/ui/apps/pmm/vite.config.ts +++ b/ui/apps/pmm/vite.config.ts @@ -1,63 +1,33 @@ import tsconfigPaths from 'vite-tsconfig-paths'; import react from '@vitejs/plugin-react-swc'; +import { defineConfig } from 'vitest/config'; import svgr from 'vite-plugin-svgr'; -import { defineConfig, loadEnv } from 'vite'; -export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), ''); - const target = env.VITE_API_URL || 'http://localhost'; - - return { - plugins: [ - tsconfigPaths({ root: '.' }), - svgr({ - svgrOptions: { exportType: 'default' }, - }), - react(), - ], - base: '/pmm-ui', +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [tsconfigPaths({ root: '.' }), react(), svgr()], + base: '/pmm-ui', + server: { + proxy: { + '/v1': { + target: '/', + }, + }, + host: '0.0.0.0', + strictPort: true, + hmr: { + clientPort: 5173, + }, + allowedHosts: ['host.docker.internal'], + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: 'src/setupTests.ts', server: { - host: '0.0.0.0', - strictPort: true, - hmr: { clientPort: 5173 }, - allowedHosts: ['host.docker.internal', 'localhost', '127.0.0.1'], - proxy: { - '/graph': { - target, - changeOrigin: true, - secure: false, - ws: true, - // make Grafana cookies valid for localhost:5173 - cookieDomainRewrite: '', // <— add - cookiePathRewrite: '/', // <— add - // drop Secure on HTTP in dev (optional but useful) - configure: (proxy) => { - proxy.on('proxyRes', (proxyRes) => { - const setCookie = proxyRes.headers['set-cookie']; - if (Array.isArray(setCookie)) { - proxyRes.headers['set-cookie'] = setCookie.map( - (c) => - c - .replace(/;\s*Path=\/graph/gi, '; Path=/') // <— force / - .replace(/;\s*Secure/gi, '') // <— drop Secure in dev - ); - } - }); - }, - }, - - '/qan-api': { target, changeOrigin: true, secure: false }, - '/inventory-api': { target, changeOrigin: true, secure: false }, - '/prometheus': { target, changeOrigin: true, secure: false }, - '/vmalert': { target, changeOrigin: true, secure: false }, - '/alertmanager': { target, changeOrigin: true, secure: false }, - '/v1': { - target, - changeOrigin: true, - secure: false, - cookieDomainRewrite: '', - }, + deps: { + fallbackCJS: true, }, }, - }; + }, }); From 45874b34440c76147b441a5ffe8319b46518a968 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Wed, 22 Oct 2025 13:17:11 +0300 Subject: [PATCH 09/20] revert code --- ui/apps/pmm/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/apps/pmm/vite.config.ts b/ui/apps/pmm/vite.config.ts index 18b763ccb1d..69737e9c501 100644 --- a/ui/apps/pmm/vite.config.ts +++ b/ui/apps/pmm/vite.config.ts @@ -1,7 +1,7 @@ import tsconfigPaths from 'vite-tsconfig-paths'; import react from '@vitejs/plugin-react-swc'; import { defineConfig } from 'vitest/config'; -import svgr from 'vite-plugin-svgr'; +import svgr from 'vite-plugin-svgr' // https://vitejs.dev/config/ export default defineConfig({ From ff2af22995e7da76e436623e6d729b2ac00af68f Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Thu, 23 Oct 2025 17:25:37 +0300 Subject: [PATCH 10/20] =?UTF-8?q?PMM-13702:=20Step=20A=20=E2=80=94=20Theme?= =?UTF-8?q?Provider/Context=20+=20wrap=20in=20main=20(no=20messenger=20yet?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/apps/pmm/src/main.tsx | 8 ++++++-- ui/apps/pmm/src/themes/theme.context.ts | 16 ++++++++++++++++ ui/apps/pmm/src/themes/theme.provider.tsx | 14 ++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 ui/apps/pmm/src/themes/theme.context.ts create mode 100644 ui/apps/pmm/src/themes/theme.provider.tsx diff --git a/ui/apps/pmm/src/main.tsx b/ui/apps/pmm/src/main.tsx index 5a0654ace92..bbe6f501114 100644 --- a/ui/apps/pmm/src/main.tsx +++ b/ui/apps/pmm/src/main.tsx @@ -1,9 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import App from './App.tsx'; +import App from './App'; +import { ThemeProvider as PmmThemeProvider } from './themes/theme.provider'; ReactDOM.createRoot(document.getElementById('root')!).render( - + + + ); + 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/apps/pmm/src/themes/theme.provider.tsx b/ui/apps/pmm/src/themes/theme.provider.tsx new file mode 100644 index 00000000000..c0e3cac87ed --- /dev/null +++ b/ui/apps/pmm/src/themes/theme.provider.tsx @@ -0,0 +1,14 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { ThemeCtx, type ColorMode } from './theme.context'; + +export const ThemeProvider: React.FC = ({ children }) => { + const [mode, setMode] = useState('light'); + + const setTheme = useCallback(async (next: ColorMode) => { + setMode(next); // passive: no side-effects yet + }, []); + + const value = useMemo(() => ({ mode, setTheme }), [mode, setTheme]); + + return {children}; +}; From 5bd5d6fc204da3708fa15c82cf373afa233387a3 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Tue, 28 Oct 2025 11:33:50 +0200 Subject: [PATCH 11/20] add chamges in ui/apps/pmm-compat/src/compat --- ui/apps/pmm-compat/src/compat.ts | 243 ++++++++++------------ ui/apps/pmm/src/main.tsx | 8 +- ui/apps/pmm/src/themes/theme.provider.tsx | 14 -- ui/docker-compose.yml | 2 +- ui/packages/shared/src/types.ts | 1 + 5 files changed, 113 insertions(+), 155 deletions(-) delete mode 100644 ui/apps/pmm/src/themes/theme.provider.tsx diff --git a/ui/apps/pmm-compat/src/compat.ts b/ui/apps/pmm-compat/src/compat.ts index 67a26780835..a7bbb4bfa3c 100644 --- a/ui/apps/pmm-compat/src/compat.ts +++ b/ui/apps/pmm-compat/src/compat.ts @@ -1,16 +1,16 @@ import { locationService, getAppEvents, ThemeChangedEvent, config } from '@grafana/runtime'; import { - ChangeThemeMessage, - CrossFrameMessenger, - DashboardVariablesMessage, - HistoryAction, - LocationChangeMessage, + ChangeThemeMessage, + CrossFrameMessenger, + DashboardVariablesMessage, + HistoryAction, + LocationChangeMessage, } from '@pmm/shared'; import { - GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, - GRAFANA_LOGIN_PATH, - GRAFANA_SUB_PATH, - PMM_UI_PATH, + GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, + GRAFANA_LOGIN_PATH, + GRAFANA_SUB_PATH, + PMM_UI_PATH, } from 'lib/constants'; import { applyCustomStyles } from 'styles'; import { changeTheme } from 'theme'; @@ -18,147 +18,122 @@ import { adjustToolbar } from 'compat/toolbar'; import { isWithinIframe, getLinkWithVariables } from 'lib/utils'; import { documentTitleObserver } from 'lib/utils/document'; +type ColorMode = 'light' | 'dark'; + +// Keep using the same string message types the file already uses elsewhere. +// If your shared package exports MessageType enum, swap the string with it: +// e.g. MessageType.GRAFANA_THEME_CHANGED / MessageType.CHANGE_THEME +const MSG_GRAFANA_THEME_CHANGED = 'GRAFANA_THEME_CHANGED' as const; + export const initialize = () => { - if (!isWithinIframe() && !window.location.pathname.startsWith(GRAFANA_LOGIN_PATH)) { - // redirect user to the new UI - window.location.replace(window.location.href.replace(GRAFANA_SUB_PATH, PMM_UI_PATH)); - return; - } - - const messenger = new CrossFrameMessenger('GRAFANA').setTargetWindow(window.top!).register(); - - messenger.addListener({ - type: 'CHANGE_THEME', - onMessage: (msg: ChangeThemeMessage) => { - if (!msg.payload) { + if (!isWithinIframe() && !window.location.pathname.startsWith(GRAFANA_LOGIN_PATH)) { + // redirect user to the new UI + window.location.replace(window.location.href.replace(GRAFANA_SUB_PATH, PMM_UI_PATH)); return; - } - changeTheme(msg.payload.theme); - }, - }); + } - messenger.sendMessage({ type: 'MESSENGER_READY' }); + // 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); + }, + }); - // set docked state to false - localStorage.setItem(GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, 'false'); + messenger.sendMessage({ type: 'MESSENGER_READY' }); - applyCustomStyles(); - adjustToolbar(); + // set docked state to false + localStorage.setItem(GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, 'false'); - // Wire Grafana theme events to Percona scheme attribute and notify parent - setupThemeWiring(); + applyCustomStyles(); + adjustToolbar(); - messenger.sendMessage({ type: 'GRAFANA_READY' }); + // -------- 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: MSG_GRAFANA_THEME_CHANGED, + payload: { theme: initial }, + }); - messenger.addListener({ - type: 'LOCATION_CHANGE', - onMessage: ({ payload: location }: LocationChangeMessage) => { - if (!location) { - return; - } - locationService.replace(location); - }, - }); + // 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: MSG_GRAFANA_THEME_CHANGED, + payload: { theme: next }, + }); + }); + // -------------------------------------------------------------- - messenger.sendMessage({ - type: 'DOCUMENT_TITLE_CHANGE', - payload: { title: document.title }, - }); + messenger.sendMessage({ type: 'GRAFANA_READY' }); - documentTitleObserver.listen((title) => { - messenger.sendMessage({ - type: 'DOCUMENT_TITLE_CHANGE', - payload: { title }, + messenger.addListener({ + type: 'LOCATION_CHANGE', + onMessage: ({ payload: location }: LocationChangeMessage) => { + if (!location) return; + locationService.replace(location); + }, }); - }); - - 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(); - } messenger.sendMessage({ - type: 'LOCATION_CHANGE', - payload: { - action, - ...location, - }, + type: 'DOCUMENT_TITLE_CHANGE', + payload: { title: document.title }, }); - prevLocation = location; - }); + documentTitleObserver.listen((title) => { + messenger.sendMessage({ + type: 'DOCUMENT_TITLE_CHANGE', + payload: { title }, + }); + }); - messenger.addListener({ - type: 'DASHBOARD_VARIABLES', - onMessage: (msg: DashboardVariablesMessage) => { - if (!msg.payload || !msg.payload.url) { - return; - } + 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(); + } + + messenger.sendMessage({ + type: 'LOCATION_CHANGE', + payload: { + action, + ...location, + }, + }); + + prevLocation = location; + }); - const url = getLinkWithVariables(msg.payload.url); + messenger.addListener({ + type: 'DASHBOARD_VARIABLES', + onMessage: (msg: DashboardVariablesMessage) => { + if (!msg.payload || !msg.payload.url) return; - messenger.sendMessage({ - id: msg.id, - type: msg.type, - payload: { url }, - }); - }, - }); -}; + const url = getLinkWithVariables(msg.payload.url); -/** - * Wires Grafana theme changes (ThemeChangedEvent) to Percona CSS scheme. - * Ensures has the attribute our CSS reads and informs the parent frame. - */ -function setupThemeWiring() { - // Resolve parent origin robustly (dev vs prod) - const getParentOrigin = (): string => { - 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) => { - // 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); - 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); - } 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'; - apply(initialMode); - - // React to Grafana theme changes (Preferences change/changeTheme()) - getAppEvents().subscribe(ThemeChangedEvent, (evt: any) => { - const next = evt?.payload?.colors?.mode ?? (evt?.payload?.isDark ? 'dark' : 'light') ?? 'light'; - apply(next); - }); -} + messenger.sendMessage({ + id: msg.id, + type: msg.type, + payload: { url }, + }); + }, + }); +}; diff --git a/ui/apps/pmm/src/main.tsx b/ui/apps/pmm/src/main.tsx index bbe6f501114..5a0654ace92 100644 --- a/ui/apps/pmm/src/main.tsx +++ b/ui/apps/pmm/src/main.tsx @@ -1,13 +1,9 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import App from './App'; -import { ThemeProvider as PmmThemeProvider } from './themes/theme.provider'; +import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( - - - + ); - diff --git a/ui/apps/pmm/src/themes/theme.provider.tsx b/ui/apps/pmm/src/themes/theme.provider.tsx deleted file mode 100644 index c0e3cac87ed..00000000000 --- a/ui/apps/pmm/src/themes/theme.provider.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { ThemeCtx, type ColorMode } from './theme.context'; - -export const ThemeProvider: React.FC = ({ children }) => { - const [mode, setMode] = useState('light'); - - const setTheme = useCallback(async (next: ColorMode) => { - setMode(next); // passive: no side-effects yet - }, []); - - const value = useMemo(() => ({ mode, setTheme }), [mode, setTheme]); - - return {children}; -}; diff --git a/ui/docker-compose.yml b/ui/docker-compose.yml index b1fd9b62917..e0184a2ec84 100644 --- a/ui/docker-compose.yml +++ b/ui/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: pmm-server # Temporary till we have arm builds platform: linux/amd64 - image: ${PMM_SERVER_IMAGE:-perconalab/pmm-server:3-dev-container} + image: perconalab/pmm-server-fb:PR-3984-2fb3471 ports: - 80:9080 - 443:8443 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 { From e2f466c57c11f45717ceb6b040c9fb8655661edf Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Tue, 28 Oct 2025 11:48:53 +0200 Subject: [PATCH 12/20] resolve lint erros in compat --- ui/apps/pmm-compat/src/compat.ts | 210 ++++++++++++++++--------------- 1 file changed, 108 insertions(+), 102 deletions(-) diff --git a/ui/apps/pmm-compat/src/compat.ts b/ui/apps/pmm-compat/src/compat.ts index a7bbb4bfa3c..91229a0df8a 100644 --- a/ui/apps/pmm-compat/src/compat.ts +++ b/ui/apps/pmm-compat/src/compat.ts @@ -1,16 +1,16 @@ import { locationService, getAppEvents, ThemeChangedEvent, config } from '@grafana/runtime'; import { - ChangeThemeMessage, - CrossFrameMessenger, - DashboardVariablesMessage, - HistoryAction, - LocationChangeMessage, + ChangeThemeMessage, + CrossFrameMessenger, + DashboardVariablesMessage, + HistoryAction, + LocationChangeMessage, } from '@pmm/shared'; import { - GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, - GRAFANA_LOGIN_PATH, - GRAFANA_SUB_PATH, - PMM_UI_PATH, + GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, + GRAFANA_LOGIN_PATH, + GRAFANA_SUB_PATH, + PMM_UI_PATH, } from 'lib/constants'; import { applyCustomStyles } from 'styles'; import { changeTheme } from 'theme'; @@ -26,114 +26,120 @@ type ColorMode = 'light' | 'dark'; const MSG_GRAFANA_THEME_CHANGED = 'GRAFANA_THEME_CHANGED' as const; export const initialize = () => { - if (!isWithinIframe() && !window.location.pathname.startsWith(GRAFANA_LOGIN_PATH)) { - // redirect user to the new UI - window.location.replace(window.location.href.replace(GRAFANA_SUB_PATH, PMM_UI_PATH)); + if (!isWithinIframe() && !window.location.pathname.startsWith(GRAFANA_LOGIN_PATH)) { + // redirect user to the new UI + window.location.replace(window.location.href.replace(GRAFANA_SUB_PATH, PMM_UI_PATH)); + 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' }); + + // set docked state to false + localStorage.setItem(GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, 'false'); + + applyCustomStyles(); + adjustToolbar(); + + // -------- 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: MSG_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'; - // 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: MSG_GRAFANA_THEME_CHANGED, + payload: { theme: next }, }); + }); + // -------------------------------------------------------------- - messenger.sendMessage({ type: 'MESSENGER_READY' }); + messenger.sendMessage({ type: 'GRAFANA_READY' }); - // set docked state to false - localStorage.setItem(GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, 'false'); + messenger.addListener({ + type: 'LOCATION_CHANGE', + onMessage: ({ payload: location }: LocationChangeMessage) => { + if (!location) { + return; + } + locationService.replace(location); + }, + }); - applyCustomStyles(); - adjustToolbar(); + messenger.sendMessage({ + type: 'DOCUMENT_TITLE_CHANGE', + payload: { title: document.title }, + }); - // -------- Theme relay: Grafana (right) → PMM UI (left) -------- - // Initial emit from Grafana current config - const initial: ColorMode = config?.theme2?.colors?.mode === 'dark' ? 'dark' : 'light'; + documentTitleObserver.listen((title) => { messenger.sendMessage({ - type: MSG_GRAFANA_THEME_CHANGED, - payload: { theme: initial }, + type: 'DOCUMENT_TITLE_CHANGE', + payload: { title }, }); + }); - // 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: MSG_GRAFANA_THEME_CHANGED, - payload: { theme: next }, - }); - }); - // -------------------------------------------------------------- - - messenger.sendMessage({ type: 'GRAFANA_READY' }); - - messenger.addListener({ - type: 'LOCATION_CHANGE', - onMessage: ({ payload: location }: LocationChangeMessage) => { - if (!location) return; - locationService.replace(location); - }, - }); + 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(); + } messenger.sendMessage({ - type: 'DOCUMENT_TITLE_CHANGE', - payload: { title: document.title }, - }); - - documentTitleObserver.listen((title) => { - messenger.sendMessage({ - type: 'DOCUMENT_TITLE_CHANGE', - payload: { title }, - }); + type: 'LOCATION_CHANGE', + payload: { + action, + ...location, + }, }); - 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(); - } - - messenger.sendMessage({ - type: 'LOCATION_CHANGE', - payload: { - action, - ...location, - }, - }); - - prevLocation = location; - }); + prevLocation = location; + }); - messenger.addListener({ - type: 'DASHBOARD_VARIABLES', - onMessage: (msg: DashboardVariablesMessage) => { - if (!msg.payload || !msg.payload.url) return; + messenger.addListener({ + type: 'DASHBOARD_VARIABLES', + onMessage: (msg: DashboardVariablesMessage) => { + if (!msg.payload || !msg.payload.url) { + return; + } - const url = getLinkWithVariables(msg.payload.url); + const url = getLinkWithVariables(msg.payload.url); - messenger.sendMessage({ - id: msg.id, - type: msg.type, - payload: { url }, - }); - }, - }); + messenger.sendMessage({ + id: msg.id, + type: msg.type, + payload: { url }, + }); + }, + }); }; From fa3527522893037adfa1ef38bc7dd42d53f5b3c5 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Tue, 28 Oct 2025 12:29:17 +0200 Subject: [PATCH 13/20] theme: broadcast CHANGE_THEME on local toggle and persist preferences; restore App/NavItem to baseline --- ui/apps/pmm/src/App.tsx | 10 --- .../components/sidebar/nav-item/NavItem.tsx | 72 ++++++++----------- ui/apps/pmm/src/hooks/theme.ts | 55 +++++--------- 3 files changed, 47 insertions(+), 90 deletions(-) diff --git a/ui/apps/pmm/src/App.tsx b/ui/apps/pmm/src/App.tsx index cfb923ecbcc..bea3a456fed 100644 --- a/ui/apps/pmm/src/App.tsx +++ b/ui/apps/pmm/src/App.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -8,7 +7,6 @@ import { ThemeContextProvider } from '@percona/design'; import { NotistackMuiSnackbar } from '@percona/ui-lib'; import { SnackbarProvider } from 'notistack'; import pmmThemeOptions from 'themes/PmmTheme'; -import { useGrafanaThemeSyncOnce } from 'hooks/useGrafanaThemeSyncOnce'; const queryClient = new QueryClient({ defaultOptions: { @@ -18,19 +16,11 @@ const queryClient = new QueryClient({ }, }); -const ThemeSyncGuard: React.FC = () => { - const ref = React.useRef<'light' | 'dark'>('light'); - // Mount the Grafana→PMM theme bridge under the SAME ThemeContextProvider - useGrafanaThemeSyncOnce(ref); - return null; -}; - const App = () => ( - = ({ item, drawerOpen, level = 0 }) => { const location = useLocation(); @@ -34,33 +32,6 @@ const NavItem: FC = ({ item, drawerOpen, level = 0 }) => { const dataTestid = `navitem-${item.id}`; const navigate = useNavigate(); - // Detect "theme-toggle" item and compute dynamic label/icon from current palette mode. - const isThemeToggle = item.id === 'theme-toggle'; - const paletteMode = (theme.palette?.mode ?? 'light') as 'light' | 'dark'; - const themeToggleLabel = - paletteMode === 'dark' ? 'Change to Light Theme' : 'Change to Dark Theme'; - const themeToggleIcon = paletteMode === 'dark' ? 'theme-light' : 'theme-dark'; - - // Use the exact prop type from NavItemIcon to keep the union type. - type IconName = NonNullable['icon']>; - - // Resolve icon for this item; make sure it's never undefined when passed down. - const resolvedIcon: IconName | undefined = isThemeToggle - ? (themeToggleIcon as IconName) - : (item.icon as IconName | undefined); - - const { setTheme } = useSetTheme(); - - // Handle click for the action item: instant UI update + persist + iframe sync. - const handleThemeToggleClick = useCallback(async () => { - try { - const next: 'light' | 'dark' = paletteMode === 'dark' ? 'light' : 'dark'; - await setTheme(next); - } catch (err) { - console.warn('[NavItem] Theme toggle failed:', err); - } - }, [paletteMode, setTheme]); - useEffect(() => { if (active && drawerOpen) { setIsOpen(true); @@ -103,19 +74,24 @@ const NavItem: FC = ({ item, drawerOpen, level = 0 }) => { direction="row" alignItems="center" justifyContent="space-between" - sx={{ width: level === 0 ? DRAWER_WIDTH : undefined }} + sx={{ + width: level === 0 ? DRAWER_WIDTH : undefined, + }} > {item.icon && ( - + )} = ({ item, drawerOpen, level = 0 }) => { ({ @@ -174,30 +152,36 @@ const NavItem: FC = ({ item, drawerOpen, level = 0 }) => { return ( - + - {resolvedIcon && ( + {item.icon && ( - + )} diff --git a/ui/apps/pmm/src/hooks/theme.ts b/ui/apps/pmm/src/hooks/theme.ts index 85a96c119f5..6a636f79ccf 100644 --- a/ui/apps/pmm/src/hooks/theme.ts +++ b/ui/apps/pmm/src/hooks/theme.ts @@ -1,45 +1,28 @@ import { ColorModeContext } from '@percona/design'; -import { useCallback, useContext, useEffect, useRef } from 'react'; +import { useContext } from 'react'; import { useUpdatePreferences } from './api/useUser'; -import { useGrafanaThemeSyncOnce } from './useGrafanaThemeSyncOnce'; - -type Mode = 'light' | 'dark'; +import messenger from 'lib/messenger'; +import type { MessageType } from '@pmm/shared'; export const useColorMode = () => { - const { colorMode, toggleColorMode } = useContext(ColorModeContext); - const { mutateAsync } = useUpdatePreferences(); - const colorModeRef = useRef(colorMode); - - useEffect(() => { - colorModeRef.current = colorMode; - }, [colorMode]); + const { colorMode, toggleColorMode } = useContext(ColorModeContext); + const { mutate } = useUpdatePreferences(); - useGrafanaThemeSyncOnce(colorModeRef); + const onToggle = () => { + const next = colorMode === 'light' ? 'dark' : 'light'; - const toggleMode = useCallback(async () => { - const prev = colorModeRef.current; - const next: Mode = prev === 'light' ? 'dark' : 'light'; + // 1) local apply (left UI) + toggleColorMode(); - // Optimistic UI update - toggleColorMode(); - colorModeRef.current = next; + // 2) tell Grafana iframe to switch immediately + messenger.sendMessage({ + type: 'CHANGE_THEME' as MessageType, + payload: { theme: next }, + }); - try { - await mutateAsync({ theme: next }); - } catch (err) { - // Rollback on failure - console.warn('[useColorMode] Persist failed, rolling back:', err); - toggleColorMode(); - colorModeRef.current = prev; - } finally { - // Best-effort persistence in localStorage - try { - localStorage.setItem('colorMode', colorModeRef.current); - } catch (e) { - console.warn('[useColorMode] localStorage set failed:', e); - } - } - }, [toggleColorMode, mutateAsync]); + // 3) persist in Grafana Preferences + mutate({ theme: next }); + }; - return { colorMode, toggleColorMode: toggleMode }; -}; + return { colorMode, toggleColorMode: onToggle }; +}; \ No newline at end of file From d9c22011f798ea8bf1fab45b9cffbc6b23b71831 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Tue, 28 Oct 2025 12:37:42 +0200 Subject: [PATCH 14/20] tests/theme: remove unused useGrafanaThemeSyncOnce and vitest.config; tidy setupTests --- .../pmm/src/hooks/useGrafanaThemeSyncOnce.ts | 90 ------------------- ui/apps/pmm/src/setupTests.ts | 57 ------------ ui/apps/pmm/vitest.config.ts | 47 ---------- 3 files changed, 194 deletions(-) delete mode 100644 ui/apps/pmm/src/hooks/useGrafanaThemeSyncOnce.ts delete mode 100644 ui/apps/pmm/vitest.config.ts diff --git a/ui/apps/pmm/src/hooks/useGrafanaThemeSyncOnce.ts b/ui/apps/pmm/src/hooks/useGrafanaThemeSyncOnce.ts deleted file mode 100644 index 44c2af4f6b0..00000000000 --- a/ui/apps/pmm/src/hooks/useGrafanaThemeSyncOnce.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useSetTheme } from 'themes/setTheme'; - -type Mode = 'light' | 'dark'; - -interface GrafanaPreferences { - theme?: 'dark' | 'light' | string; -} - -declare global { - interface Window { - __pmm_has_theme_listener__?: string; - } -} - -/** - * One-time sync with Grafana preferences and live postMessage events. - * In dev we accept any origin to support split hosts/ports (vite + docker). - * In prod we only accept messages from allowed origins. - */ -export function useGrafanaThemeSyncOnce( - colorModeRef: React.MutableRefObject -) { - const syncedRef = useRef(false); - const { setFromGrafana } = useSetTheme(); - - useEffect(() => { - if (syncedRef.current) return; - syncedRef.current = true; - - // ----- origin allow-list ------------------------------------------------- - // By default allow current host (PMM UI) and optional Grafana origin - const allowed = new Set([window.location.origin]); - const grafanaOrigin = (import.meta as ImportMeta).env - .VITE_GRAFANA_ORIGIN as string | undefined; - if (grafanaOrigin) allowed.add(grafanaOrigin); - - const isTrustedOrigin = (origin: string) => - import.meta.env.DEV ? true : allowed.has(origin); - // ------------------------------------------------------------------------ - - const ensureMode = (desired: Mode) => { - setFromGrafana(desired).catch((err) => { - console.warn('[useGrafanaThemeSyncOnce] apply failed:', err); - }); - colorModeRef.current = desired; - try { - localStorage.setItem('colorMode', desired); - } catch (err) { - console.warn('[useGrafanaThemeSyncOnce] localStorage set failed:', err); - } - }; - - fetch('/graph/api/user/preferences', { credentials: 'include' }) - .then( - async (r): Promise => - r.ok ? ((await r.json()) as GrafanaPreferences) : null - ) - .then((prefs) => { - if (!prefs) return; - const desired: Mode = prefs.theme === 'dark' ? 'dark' : 'light'; - ensureMode(desired); - }) - .catch((err) => { - console.warn('[useGrafanaThemeSyncOnce] read prefs failed:', err); - }); - - const onMsg = ( - e: MessageEvent<{ - type?: string; - payload?: { mode?: string; payloadMode?: string; isDark?: boolean }; - }> - ) => { - // Security: ignore unexpected origins in production - if (!isTrustedOrigin(e.origin)) return; - - const data = e.data; - if (!data || data.type !== 'grafana.theme.changed') return; - - const p = data.payload ?? {}; - const raw = p.mode ?? p.payloadMode ?? (p.isDark ? 'dark' : 'light'); - const desired: Mode = - String(raw).toLowerCase() === 'dark' ? 'dark' : 'light'; - ensureMode(desired); - }; - - window.addEventListener('message', onMsg); - return () => window.removeEventListener('message', onMsg); - }, [colorModeRef, setFromGrafana]); -} diff --git a/ui/apps/pmm/src/setupTests.ts b/ui/apps/pmm/src/setupTests.ts index c3f8e4c65c9..7b0828bfa80 100644 --- a/ui/apps/pmm/src/setupTests.ts +++ b/ui/apps/pmm/src/setupTests.ts @@ -1,58 +1 @@ -/** - * Why this file exists: - * - Some UI code and libraries (e.g. MUI) expect browser APIs that jsdom does not fully implement. - * - Tests run in Node, so we provide light polyfills to match what the components need. - * - * What this file adds: - * 1) Extends Jest-DOM matchers for better assertions in @testing-library/react tests. - * 2) Polyfills `window.matchMedia` so MUI and responsive code paths do not crash in jsdom. - * 3) Provides `TextEncoder`/`TextDecoder` on Node globals because some libs access them. - * 4) Adds a minimal `ResizeObserver` stub for components that read layout changes. - * - * Scope: - * - Only testing environment is affected. Production code is unchanged. - */ import '@testing-library/jest-dom'; - -/** Minimal matchMedia polyfill for jsdom/MUI. */ -if (typeof window !== 'undefined' && !window.matchMedia) { - window.matchMedia = (query: string): MediaQueryList => { - return { - matches: false, - media: query, - onchange: null, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, - }; - }; -} - -/** TextEncoder/TextDecoder for Node test env (some libs expect them). */ -import { TextEncoder as NodeTextEncoder, TextDecoder as NodeTextDecoder } from 'util'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const g: any = globalThis; -if (typeof g.TextEncoder === 'undefined') g.TextEncoder = NodeTextEncoder; -if (typeof g.TextDecoder === 'undefined') g.TextDecoder = NodeTextDecoder; - -/** Optional: ResizeObserver stub if components expect it. */ -declare global { - // Provide a minimal type to avoid "any" - interface Window { - ResizeObserver?: new () => { - observe: (target: Element) => void; - unobserve: (target: Element) => void; - disconnect: () => void; - }; - } -} - -if (typeof window !== 'undefined' && !window.ResizeObserver) { - window.ResizeObserver = class { - observe() {} - unobserve() {} - disconnect() {} - }; -} diff --git a/ui/apps/pmm/vitest.config.ts b/ui/apps/pmm/vitest.config.ts deleted file mode 100644 index 8ba065599c9..00000000000 --- a/ui/apps/pmm/vitest.config.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Why this file exists: - * - Our tests import UI code that depends on browser-only APIs (MUI, postMessage, etc.). - * - Vitest (Node) needs a browser-like environment (jsdom) and proper module resolution for MUI and local path aliases. - * - MUI sometimes performs "directory imports" (e.g. '@mui/material/CssBaseline') which Node ESM cannot resolve. - * - * What this config fixes: - * 1) Enables a DOM-like environment for React/MUI via `environment: 'jsdom'`. - * 2) Makes Vitest treat common testing globals (`describe`, `it`, `vi`, `expect`) without imports. - * 3) Forces all dependencies to be inlined/transformed by Vite so that MUI "directory imports" - * get rewritten properly even when they appear inside other packages (e.g. @percona/design). - * 4) Adds minimal path aliases used by the app in tests (utils, lib, hooks, types, etc.). - * 5) Adds a safety alias for CssBaseline to avoid Node ESM "directory import" resolution errors. - * - * Notes: - * - If Vitest later warns that `deps.inline` is deprecated, switch to `server.deps.inline` (example below). - */ -import { defineConfig } from 'vitest/config'; -import { resolve } from 'path'; - -export default defineConfig({ - test: { - environment: 'jsdom', - globals: true, - setupFiles: ['src/setupTests.ts'], - deps: { - inline: true, - }, - }, - resolve: { - conditions: ['browser', 'module', 'import', 'default'], - alias: { - utils: resolve(__dirname, 'src/utils'), - lib: resolve(__dirname, 'src/lib'), - hooks: resolve(__dirname, 'src/hooks'), - themes: resolve(__dirname, 'src/themes'), - components: resolve(__dirname, 'src/components'), - contexts: resolve(__dirname, 'src/contexts'), - pages: resolve(__dirname, 'src/pages'), - api: resolve(__dirname, 'src/api'), - icons: resolve(__dirname, 'src/icons'), - types: resolve(__dirname, 'src/types'), - assets: resolve(__dirname, 'src/assets'), - '@mui/material/CssBaseline': '@mui/material/node/CssBaseline/index.js', - }, - }, -}); From cdb279f7666f67bbd17d595263004d569b358001 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Tue, 28 Oct 2025 17:43:04 +0200 Subject: [PATCH 15/20] revert file ui/apps/pmm-compat/src/contexts/theme/theme.provider --- .../src/contexts/theme/theme.provider.tsx | 51 +------------------ ui/apps/pmm/tsconfig.node.json | 4 +- 2 files changed, 3 insertions(+), 52 deletions(-) diff --git a/ui/apps/pmm-compat/src/contexts/theme/theme.provider.tsx b/ui/apps/pmm-compat/src/contexts/theme/theme.provider.tsx index e2426769a88..6c029d27c10 100644 --- a/ui/apps/pmm-compat/src/contexts/theme/theme.provider.tsx +++ b/ui/apps/pmm-compat/src/contexts/theme/theme.provider.tsx @@ -1,62 +1,15 @@ -// 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'; - -type Mode = 'light' | 'dark'; +import React, { FC, PropsWithChildren, useEffect, useState } from 'react'; export const ThemeProvider: FC = ({ children }) => { const [theme, setTheme] = useState(config.theme2); - const lastSentModeRef = useRef(config.theme2.isDark ? 'dark' : 'light'); useEffect(() => { - // 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) => { + 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 {children}; }; - -// ----- 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 } }, '*'); -} diff --git a/ui/apps/pmm/tsconfig.node.json b/ui/apps/pmm/tsconfig.node.json index 22410d9918e..42872c59f5b 100644 --- a/ui/apps/pmm/tsconfig.node.json +++ b/ui/apps/pmm/tsconfig.node.json @@ -6,7 +6,5 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": [ - "./vite.config.ts" - ] + "include": ["vite.config.ts"] } From 21d79c15eddb78ecf4a8f1a48176f03d184eb3d3 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Wed, 29 Oct 2025 12:11:53 +0200 Subject: [PATCH 16/20] =?UTF-8?q?PMM-13702:=20refactor=20GrafanaProvider?= =?UTF-8?q?=20=E2=80=93=20clean=20messenger=20typing,=20remove=20html=20at?= =?UTF-8?q?trs,=20fully=20typed=20without=20any?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/contexts/grafana/grafana.provider.tsx | 366 +++++++----------- 1 file changed, 137 insertions(+), 229 deletions(-) diff --git a/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx b/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx index 8812b1627d7..683ba2fc6c8 100644 --- a/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx +++ b/ui/apps/pmm/src/contexts/grafana/grafana.provider.tsx @@ -2,254 +2,162 @@ import { FC, PropsWithChildren, useEffect, useRef, useState } from 'react'; 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 { ColorMode } from '@pmm/shared'; import { getLocationUrl } from './grafana.utils'; import { updateDocumentTitle } from 'lib/utils/document.utils'; import { useKioskMode } from 'hooks/utils/useKioskMode'; import { useColorMode } from 'hooks/theme'; import { useSetTheme } from 'themes/setTheme'; - -type Mode = 'light' | 'dark'; - -/** Minimal runtime shape of our messenger to avoid `any`. */ -type MessengerLike = { - setTargetWindow: (win: Window | null, fallbackSelector?: string) => MessengerLike; - register: () => MessengerLike; - unregister: () => void; - waitForMessage?: (type: string, timeoutMs?: number) => Promise; - addListener?: (args: { - type: T; - onMessage: (message: { type: T; payload: P }) => void; - }) => void; - sendMessage?: (message: { - type: T; - payload?: P; - }) => void; -}; +import messenger from 'lib/messenger'; type NavState = { fromGrafana?: boolean } | null; -const isBrowser = (): boolean => - typeof window !== 'undefined' && typeof window.addEventListener === 'function'; - -/** Reads canonical mode from attributes set by our theme hook. */ -const readHtmlMode = (): Mode => { - if (!isBrowser()) return 'light'; - return document.documentElement - .getAttribute('data-md-color-scheme') - ?.includes('dark') - ? 'dark' - : 'light'; -}; - -/** Normalizes any incoming value to 'light' | 'dark'. */ -const normalizeMode = (v: unknown): Mode => - typeof v === 'string' && v.toLowerCase() === 'dark' - ? 'dark' - : v === true - ? 'dark' - : 'light'; - -/** Resolve optional Grafana origin provided via env (e.g. https://pmm.example.com). */ -const resolveGrafanaOrigin = (): string | undefined => { - const raw = (import.meta as ImportMeta | undefined)?.env?.VITE_GRAFANA_ORIGIN as - | string - | undefined; - if (!raw) return undefined; - try { - return new URL(raw).origin; - } catch { - return undefined; - } +// ---- 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; }; -/** Build a trust predicate for postMessage origins. */ -const makeIsTrustedOrigin = () => { - if ((import.meta as ImportMeta | undefined)?.env?.DEV) return () => true; +type GrafanaLocationParam = Parameters[0]; - if (!isBrowser()) return () => false; - const set = new Set([window.location.origin]); - const grafanaOrigin = resolveGrafanaOrigin(); - if (grafanaOrigin) set.add(grafanaOrigin); - return (origin: string) => set.has(origin); -}; +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 frameRef = useRef(null); - const navigate = useNavigate(); - const kioskMode = useKioskMode(); - - // Ensure our theme context is mounted (also mounts the global theme sync hook elsewhere) - useColorMode(); - - const { setFromGrafana } = useSetTheme(); - - // Remember last theme we sent to avoid resending the same value. - const lastSentThemeRef = useRef('light'); - - // Keep messenger instance lazily loaded and scoped to browser only. - const messengerRef = useRef(null); - - // Trusted-origin predicate for postMessage validation. - const isTrustedOriginRef = useRef<(o: string) => boolean>(() => true); - useEffect(() => { - isTrustedOriginRef.current = makeIsTrustedOrigin(); - }, []); - - // Mark iframe area as loaded when we hit /graph/* - useEffect(() => { - if (isGrafanaPage) setIsLoaded(true); - }, [isGrafanaPage]); - - // Lazily import and register messenger when iframe area is ready (browser only). - useEffect(() => { - if (!isLoaded || !isBrowser()) 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); + }); + }, + }); - let mounted = true; - (async () => { - try { - const mod = await import('lib/messenger'); - if (!mounted) return; + // 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, + }); + }, + }); - const messenger = mod.default as MessengerLike; - messengerRef.current = messenger; + // 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.setTargetWindow(frameRef.current?.contentWindow ?? null, '#grafana-iframe').register(); + // 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]); - // Initialize lastSentThemeRef from DOM now that we are in browser. - lastSentThemeRef.current = readHtmlMode(); + // PMM -> Grafana: propagate theme when it changes on the left + useEffect(() => { + if (!isLoaded || !isBrowser()) return; - // Send the current canonical theme to Grafana once messenger is ready. - messenger.waitForMessage?.('MESSENGER_READY').then(() => { - const mode = readHtmlMode(); - if (lastSentThemeRef.current !== mode) { + const mode: ColorMode = colorMode === 'dark' ? 'dark' : 'light'; + if (lastSentThemeRef.current !== mode) { lastSentThemeRef.current = mode; - messenger.sendMessage?.({ - type: 'CHANGE_THEME', - payload: { theme: mode }, + messenger.sendMessage({ + type: 'CHANGE_THEME', + payload: { theme: mode }, }); - } - }); - - // Mirror Grafana → PMM route changes (except POP) - messenger.addListener?.< 'LOCATION_CHANGE', LocationChangeMessage['payload'] >({ - type: 'LOCATION_CHANGE', - onMessage: ({ payload: loc }) => { - if (!loc || loc.action === 'POP') return; - navigate(getLocationUrl(loc), { - state: { fromGrafana: true }, - replace: true, - }); - }, - }); - - // Mirror Grafana document title - messenger.addListener?.< 'DOCUMENT_TITLE_CHANGE', DocumentTitleUpdateMessage['payload'] >({ - type: 'DOCUMENT_TITLE_CHANGE', - onMessage: ({ payload }) => { - if (!payload) return; - updateDocumentTitle(payload.title); - }, - }); - } catch (err) { - console.warn('[GrafanaProvider] lazy messenger setup failed:', err); - } - })(); - - return () => { - mounted = false; - try { - messengerRef.current?.unregister?.(); - } catch { - // no-op - } - messengerRef.current = null; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isLoaded]); - - // Propagate location changes to Grafana (except POP from Grafana itself) - useEffect(() => { - if (!isBrowser()) return; - - const state = location.state as NavState; - if (!location.pathname.includes('/graph') || state?.fromGrafana) { - return; - } - const messenger = messengerRef.current; - if (!messenger) return; - - messenger.sendMessage?.({ - type: 'LOCATION_CHANGE', - payload: { - ...location, - pathname: location.pathname.replace(PMM_NEW_NAV_GRAFANA_PATH, ''), - action: navigationType, - }, - }); - }, [location, navigationType]); - - // If outer theme changes (our hook updates ), reflect it to Grafana quickly - useEffect(() => { - if (!isLoaded || !isBrowser()) return; - const mode = readHtmlMode(); // canonical - if (lastSentThemeRef.current !== mode) { - lastSentThemeRef.current = mode; - messengerRef.current?.sendMessage?.({ - type: 'CHANGE_THEME', - payload: { theme: mode }, - }); - } - }, [isLoaded, location]); // re-evaluate on navigation; inexpensive and safe - - // Hard guarantee: listen for grafana.theme.changed on /graph/* pages and apply locally (no persist/broadcast). - useEffect(() => { - if (!isLoaded || !isBrowser()) return; - - const onMsg = ( - e: MessageEvent<{ - type?: string; - payload?: { mode?: string; payloadMode?: string; isDark?: boolean }; - }> - ) => { - // Security: ignore unexpected origins in production - if (!isTrustedOriginRef.current(e.origin)) return; - - if (!e?.data || e.data.type !== 'grafana.theme.changed') return; - const p = e.data.payload ?? {}; - const raw = p.mode ?? p.payloadMode ?? (p.isDark ? 'dark' : 'light'); - const desired = normalizeMode(raw); - - // Apply locally only to avoid ping-pong; persistence is handled by left action. - setFromGrafana(desired).catch((err) => - console.warn('[GrafanaProvider] setFromGrafana failed:', err) - ); - }; - - window.addEventListener('message', onMsg); - return () => window.removeEventListener('message', onMsg); - }, [isLoaded, setFromGrafana]); - - return ( - - {children} - - ); + } + }, [isLoaded, colorMode]); + + return ( + + {children} + + ); }; From c1af3aab93c329bdea20c15bc7f5612900fdd8b2 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Wed, 29 Oct 2025 12:52:51 +0200 Subject: [PATCH 17/20] refactoring code in compat.ts --- ui/apps/pmm-compat/src/compat.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/ui/apps/pmm-compat/src/compat.ts b/ui/apps/pmm-compat/src/compat.ts index 91229a0df8a..842a260ef86 100644 --- a/ui/apps/pmm-compat/src/compat.ts +++ b/ui/apps/pmm-compat/src/compat.ts @@ -5,6 +5,7 @@ import { DashboardVariablesMessage, HistoryAction, LocationChangeMessage, + ColorMode, } from '@pmm/shared'; import { GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, @@ -18,13 +19,6 @@ import { adjustToolbar } from 'compat/toolbar'; import { isWithinIframe, getLinkWithVariables } from 'lib/utils'; import { documentTitleObserver } from 'lib/utils/document'; -type ColorMode = 'light' | 'dark'; - -// Keep using the same string message types the file already uses elsewhere. -// If your shared package exports MessageType enum, swap the string with it: -// e.g. MessageType.GRAFANA_THEME_CHANGED / MessageType.CHANGE_THEME -const MSG_GRAFANA_THEME_CHANGED = 'GRAFANA_THEME_CHANGED' as const; - export const initialize = () => { if (!isWithinIframe() && !window.location.pathname.startsWith(GRAFANA_LOGIN_PATH)) { // redirect user to the new UI @@ -59,7 +53,7 @@ export const initialize = () => { // Initial emit from Grafana current config const initial: ColorMode = config?.theme2?.colors?.mode === 'dark' ? 'dark' : 'light'; messenger.sendMessage({ - type: MSG_GRAFANA_THEME_CHANGED, + type: 'GRAFANA_THEME_CHANGED', payload: { theme: initial }, }); @@ -78,7 +72,7 @@ export const initialize = () => { const next: ColorMode = raw?.toLowerCase() === 'dark' ? 'dark' : 'light'; messenger.sendMessage({ - type: MSG_GRAFANA_THEME_CHANGED, + type: 'GRAFANA_THEME_CHANGED', payload: { theme: next }, }); }); @@ -110,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(); } From 313dacdf4a98c5800c953d3bf146bd92ff980ea6 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Wed, 29 Oct 2025 13:04:15 +0200 Subject: [PATCH 18/20] refactoring code in theme.ts --- ui/apps/pmm-compat/src/theme.ts | 228 +++++++++++--------------------- 1 file changed, 78 insertions(+), 150 deletions(-) diff --git a/ui/apps/pmm-compat/src/theme.ts b/ui/apps/pmm-compat/src/theme.ts index 9fa6edc01be..459edc08696 100644 --- a/ui/apps/pmm-compat/src/theme.ts +++ b/ui/apps/pmm-compat/src/theme.ts @@ -1,171 +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 + * Normalize any input to strict 'light' | 'dark'. */ -export const changeTheme = async (themeId: 'light' | 'dark'): Promise => { +const normalizeMode = (incoming: unknown): 'light' | 'dark' => { + return String(incoming).toLowerCase() === 'dark' ? 'dark' : 'light'; +}; + +/** + * 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(themeId); + const newTheme = getThemeById(mode); - // Publish Grafana ThemeChangedEvent + // 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 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}`)) { - 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); - } -}; - -/* --------------------------- - * Right → left theme wiring - * --------------------------*/ - -// Normalize and apply attributes so CSS-based nav updates immediately -function applyHtmlTheme(modeRaw: unknown) { - 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); + }; + document.head.appendChild(newCssLink); + } } - (html.style as CSSStyleDeclaration).colorScheme = mode; - - return mode; -} -const isIp = (h: string) => /^\d+\.\d+\.\d+\.\d+$/.test(h); -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; - } + return newTheme; }; /** - * 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). + * Public API kept for callers inside this plugin (no HTML attributes here). */ -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) { - const w = resolveTargetWindow(); - if (!w) { - return; - } - w.postMessage(msg, targetOriginRef.current); -} +export const changeTheme = async (themeId: 'light' | 'dark'): Promise => { + await applyGrafanaTheme(themeId); +}; -/** Dev-only handshake: lock '*' to the real origin after the first ACK. */ -(function setupOriginHandshake() { - const isDev = isDevHost(new URL(window.location.href).hostname); - if (!isDev || targetOriginRef.current !== '*') { +/** + * 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; } - - // 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 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 }, + }); }; - window.addEventListener('message', onAck); -})(); - -// Initial apply from current Grafana theme and notify parent once -(function initThemeBridge() { - 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) => { - 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); - } -}); + // 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 }, + }); +}; From c8699ec4b347e656a8d99880a13296d3be8cf3b4 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Wed, 29 Oct 2025 13:31:30 +0200 Subject: [PATCH 19/20] refactor code in setTheme --- ui/apps/pmm/src/themes/setTheme.ts | 69 ++++++++---------------------- 1 file changed, 19 insertions(+), 50 deletions(-) diff --git a/ui/apps/pmm/src/themes/setTheme.ts b/ui/apps/pmm/src/themes/setTheme.ts index 3c1b0bd2efd..1e0de655faf 100644 --- a/ui/apps/pmm/src/themes/setTheme.ts +++ b/ui/apps/pmm/src/themes/setTheme.ts @@ -1,43 +1,17 @@ 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'; -type Mode = 'light' | 'dark'; - -/** Normalizes any incoming value to 'light' | 'dark'. */ -function normalizeMode(v: unknown): Mode { +/** 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'; } -/** Idempotently applies theme attributes to . */ -function applyDocumentTheme(mode: Mode) { - const html = document.documentElement as HTMLElement & { - style: CSSStyleDeclaration & { colorScheme?: string }; - }; - const scheme = mode === 'dark' ? 'percona-dark' : 'percona-light'; - const wantDark = mode === 'dark'; - const hasDarkClass = html.classList.contains('dark'); - - // If everything (including the Tailwind 'dark' class) is already correct, skip. - if ( - html.getAttribute('data-theme') === mode && - html.getAttribute('data-md-color-scheme') === scheme && - html.style.colorScheme === mode && - hasDarkClass === wantDark - ) { - return; - } - - html.classList.toggle('dark', wantDark); - - html.setAttribute('data-theme', mode); - html.setAttribute('data-md-color-scheme', scheme); - html.style.colorScheme = mode; -} - /** * useSetTheme centralizes theme changes for: * - local PMM UI (left) @@ -46,12 +20,14 @@ function applyDocumentTheme(mode: Mode) { */ export function useSetTheme() { const { colorMode, toggleColorMode } = useContext(ColorModeContext); - const modeRef = useRef(normalizeMode(colorMode)); + 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 (left UI) in an idempotent way */ + /** Apply theme locally via design system only (no direct DOM mutations). */ const applyLocal = (nextRaw: unknown) => { const next = normalizeMode(nextRaw); if (modeRef.current !== next) { @@ -59,17 +35,9 @@ export function useSetTheme() { toggleColorMode(); modeRef.current = next; } - // Ensure attributes match immediately (CSS consumes these) - applyDocumentTheme(next); - - try { - localStorage.setItem('colorMode', next); - } catch (err) { - console.warn('[useSetTheme] Failed to save theme to localStorage:', err); - } }; - /** Low-level primitive with options to avoid ping-pong and over-persisting */ + /** Low-level primitive to apply/persist/broadcast theme as needed. */ const setThemeBase = async ( nextRaw: unknown, opts: { broadcast?: boolean; persist?: boolean } = { @@ -79,11 +47,12 @@ export function useSetTheme() { ) => { const next = normalizeMode(nextRaw); - // 1) local apply (instant, idempotent) + // 1) Local apply (instant, idempotent) applyLocal(next); - // 2) persist to Grafana (only for left-initiated actions) + // 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) { @@ -94,12 +63,12 @@ export function useSetTheme() { } } - // 3) notify iframe (only when we are the initiator, not when we sync from Grafana) + // 3) Notify iframe (only when we are the initiator, not when syncing from Grafana) if (opts.broadcast) { try { messenger.sendMessage({ type: 'CHANGE_THEME', - payload: { theme: next }, + payload: { mode: next }, }); } catch (err) { console.warn('[useSetTheme] Failed to send CHANGE_THEME message:', err); @@ -108,15 +77,15 @@ export function useSetTheme() { }; /** - * Public API kept backward compatible: - * - setTheme(next): left action — apply + persist + broadcast (same behavior as before) - * - setFromGrafana(next): right→left sync — apply only (no persist, no broadcast) + * Public API: + * - setTheme(next): left action — apply + persist + broadcast. + * - setFromGrafana(next): right→left sync — apply only (no persist, no broadcast). */ - async function setTheme(next: Mode | string | boolean) { + async function setTheme(next: ColorMode | string | boolean) { await setThemeBase(next, { broadcast: true, persist: true }); } - async function setFromGrafana(next: Mode | string | boolean) { + async function setFromGrafana(next: ColorMode | string | boolean) { await setThemeBase(next, { broadcast: false, persist: false }); } From 82debe9b4039ea5c1a8db209f2fb9f2fe468d504 Mon Sep 17 00:00:00 2001 From: dmitri-saricev-3pillargloball Date: Wed, 29 Oct 2025 13:32:53 +0200 Subject: [PATCH 20/20] revert docker-compose --- ui/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/docker-compose.yml b/ui/docker-compose.yml index e0184a2ec84..b1fd9b62917 100644 --- a/ui/docker-compose.yml +++ b/ui/docker-compose.yml @@ -4,7 +4,7 @@ services: container_name: pmm-server # Temporary till we have arm builds platform: linux/amd64 - image: perconalab/pmm-server-fb:PR-3984-2fb3471 + image: ${PMM_SERVER_IMAGE:-perconalab/pmm-server:3-dev-container} ports: - 80:9080 - 443:8443