diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0b7e156aebf..45b4b06651c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -480,7 +480,7 @@ playwright.config.ts @grafana/plugins-platform-frontend /public/app/core/components/TimelineChart/ @grafana/dataviz-squad /public/app/core/components/Form/ @grafana/grafana-frontend-platform /public/app/core/components/OptionsUI/ @grafana/dashboards-squad @grafana/dataviz-squad - +/public/app/mock-api-utils.ts @grafana/grafana-frontend-platform /public/app/core/history/ @grafana/observability-traces-and-profiling /public/app/features/admin/ @grafana/identity-access-team diff --git a/conf/defaults.ini b/conf/defaults.ini index ea6f45ddb3d..600cbb9730b 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -2122,9 +2122,6 @@ alert_rules_state = "paused" # Should UI tests fail when console log/warn/erroring? # Does not affect the result when running on CI - only for allowing devs to choose this behaviour locally fail_tests_on_console = true -# Whether or not to enable the MSW mock API, which intercepts requests and returns mock data -# Should only be used for local development or demo purposes -mock_api = false # Whether to enable betterer eslint rules for local development # Useful if you want to always see betterer rules that we're trying to fix so they're more prevalent betterer_eslint_rules = false diff --git a/public/app/app.ts b/public/app/app.ts index 647124ce8dc..8ac26cad2f1 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -62,6 +62,7 @@ import { PluginPage } from './core/components/Page/PluginPage'; import { GrafanaContextType, useReturnToPreviousInternal } from './core/context/GrafanaContext'; import { initializeCrashDetection } from './core/crash'; import { initializeI18n } from './core/internationalization'; +import { postInitTasks, preInitTasks } from './core/lifecycle-hooks'; import { setMonacoEnv } from './core/monacoEnv'; import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks'; import { CorrelationsService } from './core/services/CorrelationsService'; @@ -72,7 +73,6 @@ import { Echo } from './core/services/echo/Echo'; import { reportPerformance } from './core/services/echo/EchoSrv'; import { KeybindingSrv } from './core/services/keybindingSrv'; import { startMeasure, stopMeasure } from './core/utils/metrics'; -import { initDevFeatures } from './dev'; import { initAlerting } from './features/alerting/unified/initAlerting'; import { initAuthConfig } from './features/auth-config'; import { getTimeSrv } from './features/dashboard/services/TimeSrv'; @@ -115,15 +115,12 @@ const extensionsExports = extensionsIndex.keys().map((key) => { return extensionsIndex(key); }); -if (process.env.NODE_ENV === 'development') { - initDevFeatures(); -} - export class GrafanaApp { context!: GrafanaContextType; async init() { try { + await preInitTasks(); // Let iframe container know grafana has started loading parent.postMessage('GrafanaAppInit', '*'); @@ -268,6 +265,8 @@ export class GrafanaApp { app: this, }) ); + + await postInitTasks(); } catch (error) { console.error('Failed to start Grafana', error); window.__grafana_load_failed(); diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts index 60e766d48a2..9f2fff8641f 100644 --- a/public/app/core/copy/appNotification.ts +++ b/public/app/core/copy/appNotification.ts @@ -2,6 +2,7 @@ import { useMemo, ReactElement } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { getMessageFromError } from 'app/core/utils/errors'; +import { dispatch as storeDispatch } from 'app/store/store'; import { AppNotification, AppNotificationSeverity, useDispatch } from 'app/types'; import { notifyApp } from '../actions'; @@ -86,19 +87,50 @@ export function useAppNotification() { const dispatch = useDispatch(); return useMemo( () => ({ - success: (title: string, text = '') => { - dispatch(notifyApp(createSuccessNotification(title, text))); + [AppNotificationSeverity.Success]: (title: string, text = '') => { + dispatch(notifyApp(createNotification(title, text, AppNotificationSeverity.Success))); }, - warning: (title: string, text = '', traceId?: string) => { - dispatch(notifyApp(createWarningNotification(title, text, traceId))); + [AppNotificationSeverity.Warning]: (title: string, text = '', traceId?: string) => { + dispatch(notifyApp(createNotification(title, text, AppNotificationSeverity.Warning, traceId))); }, - error: (title: string, text = '', traceId?: string) => { - dispatch(notifyApp(createErrorNotification(title, text, traceId))); + [AppNotificationSeverity.Error]: (title: string, text = '', traceId?: string) => { + dispatch(notifyApp(createNotification(title, text, AppNotificationSeverity.Error, traceId))); }, - info: (title: string, text = '') => { - dispatch(notifyApp(createInfoNotification(title, text))); + [AppNotificationSeverity.Info]: (title: string, text = '') => { + dispatch(notifyApp(createNotification(title, text, AppNotificationSeverity.Info))); }, }), [dispatch] ); } + +function createNotification( + title: string, + text = '', + severity: AppNotificationSeverity = AppNotificationSeverity.Success, + traceId?: string +) { + const map = { + [AppNotificationSeverity.Success]: (title: string, text = '') => { + return createSuccessNotification(title, text); + }, + [AppNotificationSeverity.Warning]: (title: string, text = '', traceId?: string) => { + return createWarningNotification(title, text, traceId); + }, + [AppNotificationSeverity.Error]: (title: string, text = '', traceId?: string) => { + return createErrorNotification(title, text, traceId); + }, + [AppNotificationSeverity.Info]: (title: string, text = '') => { + return createInfoNotification(title, text); + }, + }; + return map[severity](title, text, traceId); +} + +export function sendAppNotification( + title: string, + text = '', + severity: AppNotificationSeverity = AppNotificationSeverity.Success +) { + storeDispatch(notifyApp(createNotification(title, text, severity))); +} diff --git a/public/app/core/lifecycle-hooks.ts b/public/app/core/lifecycle-hooks.ts new file mode 100644 index 00000000000..1cf2cce1fb8 --- /dev/null +++ b/public/app/core/lifecycle-hooks.ts @@ -0,0 +1,18 @@ +import { initDevFeatures } from 'app/dev'; +import { notifyIfMockApiEnabled } from 'app/mock-api-utils'; + +/** + * Lifecycle tasks that need to be run prior to app initialization, + * such as setting up mock APIs or enabling dev-only features + */ +export async function preInitTasks() { + await initDevFeatures(); +} + +/** + * Lifecycle tasks that need to be run once the app has fully initialized, + * such as notifying if mock APIs are enabled + */ +export async function postInitTasks() { + notifyIfMockApiEnabled(); +} diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 87b8c402930..b7a1ea64501 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -5,6 +5,7 @@ import { getExploreUrl } from 'app/core/utils/explore'; import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer'; import { ShareModal } from 'app/features/dashboard/components/ShareModal/ShareModal'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; +import { toggleMockApiAndReload } from 'app/mock-api-utils'; import { getTimeSrv } from '../../features/dashboard/services/TimeSrv'; import { @@ -55,6 +56,10 @@ export class KeybindingSrv { this.bind('c t', () => toggleTheme(false)); this.bind('c r', () => toggleTheme(true)); + + if (process.env.NODE_ENV === 'development') { + this.bind('c m', () => toggleMockApiAndReload()); + } } bindGlobalEsc() { diff --git a/public/app/dev.ts b/public/app/dev.ts index 8f72f1ab077..19c46dbca8d 100644 --- a/public/app/dev.ts +++ b/public/app/dev.ts @@ -1,5 +1,7 @@ import * as React from 'react'; +import { potentiallySetupMockApi } from './mock-api-utils'; + export async function initDevFeatures() { // if why-render is in url enable why did you render react extension if (window.location.search.indexOf('why-render') !== -1) { @@ -8,4 +10,6 @@ export async function initDevFeatures() { trackAllPureComponents: true, }); } + + await potentiallySetupMockApi(); } diff --git a/public/app/features/commandPalette/actions/staticActions.ts b/public/app/features/commandPalette/actions/staticActions.ts index d1ff72fd81d..a3315334781 100644 --- a/public/app/features/commandPalette/actions/staticActions.ts +++ b/public/app/features/commandPalette/actions/staticActions.ts @@ -5,6 +5,7 @@ import { enrichHelpItem } from 'app/core/components/AppChrome/MegaMenu/utils'; import { performInviteUserClick, shouldRenderInviteUserButton } from 'app/core/components/InviteUserButton/utils'; import { t } from 'app/core/internationalization'; import { changeTheme } from 'app/core/services/theme'; +import { currentMockApiState, toggleMockApiAndReload } from 'app/mock-api-utils'; import { useSelector } from '../../../types'; import { CommandPaletteAction } from '../types'; @@ -75,7 +76,7 @@ function navTreeToActions(navTree: NavModelItem[], parents: NavModelItem[] = []) } function getGlobalActions(): CommandPaletteAction[] { - return [ + const actions: CommandPaletteAction[] = [ { id: 'preferences/theme', name: t('command-palette.action.change-theme', 'Change theme'), @@ -100,6 +101,25 @@ function getGlobalActions(): CommandPaletteAction[] { priority: PREFERENCES_PRIORITY, }, ]; + + if (process.env.NODE_ENV === 'development') { + // eslint-disable @grafana/no-untranslated-strings + const section = 'Dev tooling'; + const currentState = currentMockApiState(); + const mockApiAction = currentState ? 'Disable' : 'Enable'; + actions.push({ + id: 'preferences/dev/toggle-mock-api', + section, + name: `${mockApiAction} Mock API worker and reload`, + subtitle: 'Intercepts requests and returns mock data using MSW', + keywords: 'mock api', + priority: PREFERENCES_PRIORITY, + perform: toggleMockApiAndReload, + }); + // eslint-enable @grafana/no-untranslated-strings + } + + return actions; } export function useStaticActions(): CommandPaletteAction[] { diff --git a/public/app/index.ts b/public/app/index.ts index cfbaee2e5cd..7ca8aba2b14 100644 --- a/public/app/index.ts +++ b/public/app/index.ts @@ -20,15 +20,4 @@ window.__grafana_app_bundle_loaded = true; import app from './app'; -const prepareInit = async () => { - if (process.env.frontend_dev_mock_api) { - return import('test/mock-api/worker').then((workerModule) => { - workerModule.default.start({ onUnhandledRequest: 'bypass' }); - }); - } - return Promise.resolve(); -}; - -prepareInit().then(() => { - app.init(); -}); +app.init(); diff --git a/public/app/mock-api-utils.ts b/public/app/mock-api-utils.ts new file mode 100644 index 00000000000..7141b62b2d3 --- /dev/null +++ b/public/app/mock-api-utils.ts @@ -0,0 +1,39 @@ +import store from 'app/core/store'; + +import { sendAppNotification } from './core/copy/appNotification'; +import { AppNotificationSeverity } from './types'; + +export const STORAGE_MOCK_API_KEY = 'grafana.dev.mockApi'; + +export const currentMockApiState = () => { + return store.getBool(STORAGE_MOCK_API_KEY, false); +}; + +export const toggleMockApiAndReload = () => { + const currentState = currentMockApiState(); + store.set(STORAGE_MOCK_API_KEY, String(!currentState)); + const action = currentState ? 'Disabling' : 'Enabling'; + sendAppNotification(`${action} Mock API`, 'Reloading...', AppNotificationSeverity.Info); + setTimeout(() => { + window.location.reload(); + }, 200); +}; + +export const potentiallySetupMockApi = async () => { + const mockApiEnabled = currentMockApiState(); + if (process.env.NODE_ENV === 'development' && mockApiEnabled) { + const { default: worker } = await import('test/mock-api/worker'); + + worker.start({ onUnhandledRequest: 'bypass' }); + } +}; + +export const notifyIfMockApiEnabled = () => { + if (process.env.NODE_ENV === 'development' && currentMockApiState()) { + sendAppNotification( + 'Mock API currently enabled', + 'Some network requests will be intercepted', + AppNotificationSeverity.Info + ); + } +};