-
Notifications
You must be signed in to change notification settings - Fork 182
PMM 13702 theme toggle button compatibility dark theme #4682
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: PMM-13652-native-navigation
Are you sure you want to change the base?
Changes from 7 commits
e3bc89d
b017635
f482411
725ca99
50190e6
ed04118
8a8e638
3c28d07
45874b3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { locationService } from '@grafana/runtime'; | ||
| import { locationService, getAppEvents, ThemeChangedEvent, config } from '@grafana/runtime'; | ||
| import { | ||
| ChangeThemeMessage, | ||
| CrossFrameMessenger, | ||
|
|
@@ -33,36 +33,29 @@ export const initialize = () => { | |
| if (!msg.payload) { | ||
| return; | ||
| } | ||
|
|
||
| changeTheme(msg.payload.theme); | ||
| }, | ||
| }); | ||
|
|
||
| messenger.sendMessage({ | ||
| type: 'MESSENGER_READY', | ||
| }); | ||
| messenger.sendMessage({ type: 'MESSENGER_READY' }); | ||
|
|
||
| // set docked state to false | ||
| localStorage.setItem(GRAFANA_DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY, 'false'); | ||
|
|
||
| applyCustomStyles(); | ||
|
|
||
| adjustToolbar(); | ||
|
|
||
| // sync with PMM UI theme | ||
| changeTheme('light'); | ||
| // Wire Grafana theme events to Percona scheme attribute and notify parent | ||
| setupThemeWiring(); | ||
|
|
||
| messenger.sendMessage({ | ||
| type: 'GRAFANA_READY', | ||
| }); | ||
| messenger.sendMessage({ type: 'GRAFANA_READY' }); | ||
|
|
||
| messenger.addListener({ | ||
| type: 'LOCATION_CHANGE', | ||
| onMessage: ({ payload: location }: LocationChangeMessage) => { | ||
| if (!location) { | ||
| return; | ||
| } | ||
|
|
||
| locationService.replace(location); | ||
| }, | ||
| }); | ||
|
|
@@ -71,6 +64,7 @@ export const initialize = () => { | |
| type: 'DOCUMENT_TITLE_CHANGE', | ||
| payload: { title: document.title }, | ||
| }); | ||
|
|
||
| documentTitleObserver.listen((title) => { | ||
| messenger.sendMessage({ | ||
| type: 'DOCUMENT_TITLE_CHANGE', | ||
|
|
@@ -108,10 +102,63 @@ export const initialize = () => { | |
| messenger.sendMessage({ | ||
| id: msg.id, | ||
| type: msg.type, | ||
| payload: { | ||
| url: url, | ||
| }, | ||
| payload: { url }, | ||
| }); | ||
| }, | ||
| }); | ||
| }; | ||
|
|
||
| /** | ||
| * Wires Grafana theme changes (ThemeChangedEvent) to Percona CSS scheme. | ||
| * Ensures <html> has the attribute our CSS reads and informs the parent frame. | ||
| */ | ||
| function setupThemeWiring() { | ||
| // Resolve parent origin robustly (dev vs prod) | ||
| const getParentOrigin = (): string => { | ||
| 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) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a possibility of the theme being other than light/dark at the moment? (In Grafana 12 they have additional themes - https://grafana.com/whats-new/2025-04-11-introducing-experimental-themes/, but we're still on 11) |
||
| // normalize to 'light' | 'dark' | ||
| const mode: 'light' | 'dark' = String(incoming).toLowerCase() === 'dark' ? 'dark' : 'light'; | ||
|
|
||
| const html = document.documentElement; | ||
| const scheme = mode === 'dark' ? 'percona-dark' : 'percona-light'; | ||
|
|
||
| // Set attributes our CSS reads | ||
| html.setAttribute('data-md-color-scheme', scheme); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need to modify the HTML attributes? |
||
| html.setAttribute('data-theme', mode); | ||
| (html.style as CSSStyleDeclaration & { colorScheme: string }).colorScheme = mode; | ||
| // Notify outer PMM UI (new nav) to sync immediately | ||
| try { | ||
| const target = window.top && window.top !== window ? window.top : window.parent || window; | ||
| target?.postMessage({ type: 'grafana.theme.changed', payload: { mode } }, targetOrigin); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use messenger to handle the communication between the |
||
| } 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'; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The initial sync shouldn't happen in the body of the file (since it would get called outside the iframe aswell) |
||
| apply(initialMode); | ||
|
|
||
| // React to Grafana theme changes (Preferences change/changeTheme()) | ||
| getAppEvents().subscribe(ThemeChangedEvent, (evt: any) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please move to the body of initialize method. |
||
| const next = evt?.payload?.colors?.mode ?? (evt?.payload?.isDark ? 'dark' : 'light') ?? 'light'; | ||
| apply(next); | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,62 @@ | ||
| // pmm-compat/src/providers/ThemeProvider.tsx | ||
| // ------------------------------------------------------ | ||
| // Grafana-side theme provider + bridge: | ||
| // 1) Mirrors Grafana theme into ThemeContext (for plugins). | ||
| // 2) Applies canonical DOM attributes inside the iframe. | ||
| // 3) Notifies host UI via postMessage so host can re-theme instantly. | ||
| // ------------------------------------------------------ | ||
|
|
||
| import React, { FC, PropsWithChildren, useEffect, useRef, useState } from 'react'; | ||
| import { ThemeContext } from '@grafana/data'; | ||
| import { config, getAppEvents, ThemeChangedEvent } from '@grafana/runtime'; | ||
| import React, { FC, PropsWithChildren, useEffect, useState } from 'react'; | ||
|
|
||
| type Mode = 'light' | 'dark'; | ||
|
|
||
| export const ThemeProvider: FC<PropsWithChildren> = ({ children }) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think you need to touch this provider at all, I think I'll add a comment there, but this specifically is used just for two toolbar buttons used inside the grafana iframe where we extend the functionality. See |
||
| const [theme, setTheme] = useState(config.theme2); | ||
| const lastSentModeRef = useRef<Mode>(config.theme2.isDark ? 'dark' : 'light'); | ||
|
|
||
| useEffect(() => { | ||
| getAppEvents().subscribe(ThemeChangedEvent, (event) => { | ||
| // Apply DOM attributes for the current theme on mount. | ||
| applyIframeDomTheme(lastSentModeRef.current); | ||
| // Also notify host once on mount (defensive; host may already be in sync). | ||
| postModeToHost(lastSentModeRef.current); | ||
|
|
||
| const sub = getAppEvents().subscribe(ThemeChangedEvent, (event) => { | ||
| setTheme(event.payload); | ||
| const mode: Mode = event?.payload?.isDark ? 'dark' : 'light'; | ||
|
|
||
| // Update iframe DOM and notify the host UI. | ||
| applyIframeDomTheme(mode); | ||
|
|
||
| // De-duplicate messages to avoid noisy bridges. | ||
| if (lastSentModeRef.current !== mode) { | ||
| lastSentModeRef.current = mode; | ||
| postModeToHost(mode); | ||
| } | ||
| }); | ||
|
|
||
| // Unsubscribe on unmount (pmm-compat may be hot-reloaded in dev). | ||
| return () => sub.unsubscribe(); | ||
| }, []); | ||
|
|
||
| return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>; | ||
| }; | ||
|
|
||
| // ----- helpers (iframe context) ----- | ||
|
|
||
| function applyIframeDomTheme(mode: Mode): void { | ||
| // Update DOM attributes used by CSS variables / tokens inside Grafana iframe. | ||
| const html = document.documentElement; | ||
| const scheme = mode === 'dark' ? 'percona-dark' : 'percona-light'; | ||
| html.setAttribute('data-md-color-scheme', scheme); | ||
| html.setAttribute('data-theme', mode); | ||
| html.style.colorScheme = mode; | ||
| } | ||
|
|
||
| function postModeToHost(mode: Mode): void { | ||
| // Inform the parent (host) window. We intentionally use "*" here because | ||
| // host and iframe share origin in PMM, but in dev/proxy setups origin may differ. | ||
| // Host side should still validate origin. | ||
| window.parent?.postMessage({ type: 'grafana.theme.changed', payload: { mode } }, '*'); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,16 +2,14 @@ import { getThemeById } from '@grafana/data'; | |
| import { config, getAppEvents, ThemeChangedEvent } from '@grafana/runtime'; | ||
|
|
||
| /** | ||
| * Changes theme to the provided one | ||
| * | ||
| * Changes theme to the provided one. | ||
| * Based on public/app/core/services/theme.ts in Grafana | ||
| * @param themeId | ||
| */ | ||
| export const changeTheme = async (themeId: 'light' | 'dark'): Promise<void> => { | ||
| const oldTheme = config.theme2; | ||
|
|
||
| const newTheme = getThemeById(themeId); | ||
|
|
||
| // Publish Grafana ThemeChangedEvent | ||
| getAppEvents().publish(new ThemeChangedEvent(newTheme)); | ||
|
|
||
| // Add css file for new theme | ||
|
|
@@ -20,19 +18,154 @@ export const changeTheme = async (themeId: 'light' | 'dark'): Promise<void> => { | |
| newCssLink.rel = 'stylesheet'; | ||
| newCssLink.href = config.bootData.assets[newTheme.colors.mode]; | ||
| newCssLink.onload = () => { | ||
| // Remove old css file | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the reason for this change to code and comments? |
||
| const bodyLinks = document.getElementsByTagName('link'); | ||
| for (let i = 0; i < bodyLinks.length; i++) { | ||
| const link = bodyLinks[i]; | ||
|
|
||
| // Remove old css file after the new one has loaded to avoid flicker | ||
| const links = document.getElementsByTagName('link'); | ||
| for (let i = 0; i < links.length; i++) { | ||
| const link = links[i]; | ||
| if (link.href && link.href.includes(`build/grafana.${oldTheme.colors.mode}`)) { | ||
| // Remove existing link once the new css has loaded to avoid flickering | ||
| // If we add new css at the same time we remove current one the page will be rendered without css | ||
| // As the new css file is loading | ||
| link.remove(); | ||
| } | ||
| } | ||
| }; | ||
| document.head.insertBefore(newCssLink, document.head.firstChild); | ||
| } | ||
| }; | ||
|
|
||
| /* --------------------------- | ||
| * Right → left theme wiring | ||
| * --------------------------*/ | ||
|
|
||
| // Normalize and apply <html> attributes so CSS-based nav updates immediately | ||
| function applyHtmlTheme(modeRaw: unknown) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is there a need to apply the theme to html? The |
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The communication between |
||
| 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Posting messages between |
||
| 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() { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handshake is pretty good idea to not send information where we do not want. |
||
| const isDev = isDevHost(new URL(window.location.href).hostname); | ||
| if (!isDev || targetOriginRef.current !== '*') { | ||
| return; | ||
| } | ||
|
|
||
| // Ask parent for its origin once | ||
| try { | ||
| sendToParent({ type: 'pmm.handshake' }); | ||
| } catch { | ||
| // ignore | ||
| } | ||
|
|
||
| const onAck = (e: MessageEvent<{ type?: string }>) => { | ||
| if (e?.data?.type !== 'pmm.handshake.ack') { | ||
| return; | ||
| } | ||
| // Lock to the explicit origin provided by the parent | ||
| targetOriginRef.current = e.origin || targetOriginRef.current; | ||
| window.removeEventListener('message', onAck); | ||
| }; | ||
| window.addEventListener('message', onAck); | ||
| })(); | ||
|
|
||
| // Initial apply from current Grafana theme and notify parent once | ||
| (function initThemeBridge() { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declaring a function and then immediately calling it like this might lead to unexpected results - this will be called when the |
||
| 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) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're subscribing to theme changes on multiple places each with a different handling why is that? |
||
| try { | ||
| // Type guard for expected payload structure | ||
| if (typeof evt === 'object' && evt !== null && 'payload' in evt) { | ||
| const payload = (evt as { payload?: unknown }).payload; | ||
| const next = | ||
| typeof payload === 'object' && payload !== null && 'colors' in payload | ||
| ? (payload as { colors?: { mode?: string }; isDark?: boolean }).colors?.mode ?? | ||
| ((payload as { isDark?: boolean }).isDark ? 'dark' : 'light') ?? | ||
| 'light' | ||
| : 'light'; | ||
|
|
||
| const mode = applyHtmlTheme(next); | ||
|
|
||
| if (lastSentMode !== mode) { | ||
| sendToParent({ type: 'grafana.theme.changed', payload: { mode } }); | ||
| lastSentMode = mode; | ||
| } | ||
| } | ||
| } catch (err) { | ||
| console.warn('[pmm-compat] Failed to handle ThemeChangedEvent/postMessage:', err); | ||
| } | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Origin handling shouldn't be needed.