From 3aa104ce13532c50f2257006ac81d567d2c94b27 Mon Sep 17 00:00:00 2001 From: Dominik <dominik.broj@grafana.com> Date: Wed, 5 Jun 2024 21:33:04 +0200 Subject: [PATCH] update --- grafana-plugin/e2e-tests/globalSetup.ts | 8 +-- .../initialization.test.ts | 57 +++++++------------ grafana-plugin/e2e-tests/utils/constants.ts | 7 +++ grafana-plugin/e2e-tests/utils/users.ts | 34 ++++++++++- .../ExtensionLinkMenu/ExtensionLinkMenu.tsx | 3 +- .../MobileAppConnection.tsx | 13 +++-- .../src/models/loader/action-keys.ts | 1 + .../src/plugin/GrafanaPluginRootPage.tsx | 6 +- .../src/state/rootBaseStore/RootBaseStore.ts | 49 ++++++++++++---- .../src/utils/authorization/authorization.ts | 4 +- grafana-plugin/src/utils/consts.ts | 5 +- grafana-plugin/src/utils/faro.ts | 3 +- grafana-plugin/src/utils/hooks.tsx | 53 ++++------------- grafana-plugin/src/utils/utils.ts | 4 +- 14 files changed, 132 insertions(+), 115 deletions(-) diff --git a/grafana-plugin/e2e-tests/globalSetup.ts b/grafana-plugin/e2e-tests/globalSetup.ts index f65ff516af..7a3ec6d048 100644 --- a/grafana-plugin/e2e-tests/globalSetup.ts +++ b/grafana-plugin/e2e-tests/globalSetup.ts @@ -18,15 +18,9 @@ import { GRAFANA_VIEWER_USERNAME, IS_CLOUD, IS_OPEN_SOURCE, + OrgRole, } from './utils/constants'; -enum OrgRole { - None = 'None', - Viewer = 'Viewer', - Editor = 'Editor', - Admin = 'Admin', -} - type UserCreationSettings = { adminAuthedRequest: APIRequestContext; role: OrgRole; diff --git a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts index 7965a2e9a6..cf75b83c7b 100644 --- a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts +++ b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts @@ -34,77 +34,58 @@ Then OnCall loads as usual */ import { test, expect } from '../fixtures'; -import { clickButton } from '../utils/forms'; +import { GRAFANA_ADMIN_USERNAME, OrgRole } from '../utils/constants'; import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation'; -import { createGrafanaUser } from '../utils/users'; +import { createGrafanaUser, reloginAndWaitTillGrafanaIsLoaded } from '../utils/users'; test.describe('Plugin initialization', () => { test('Plugin OnCall pages work for new viewer user right away', async ({ adminRolePage: { page } }) => { - // Create new viewer user + // Create new editor user and login as new user const USER_NAME = `viewer-${new Date().getTime()}`; - await createGrafanaUser(page, USER_NAME); + await createGrafanaUser({ page, username: USER_NAME, role: OrgRole.Viewer }); + await reloginAndWaitTillGrafanaIsLoaded({ page, username: USER_NAME }); - // Login as new user - await goToGrafanaPage(page, '/logout'); - await page.getByLabel('Email or username').fill(USER_NAME); - await page.getByLabel(/Password/).fill(USER_NAME); - await clickButton({ page, buttonText: 'Log in' }); - - // Wait till Grafana home page is loaded and start tracking HTTP response codes - await page.getByText('Welcome to Grafana').waitFor(); - await page.waitForLoadState('networkidle'); + // Start watching for HTTP responses const networkResponseStatuses: number[] = []; page.on('requestfinished', async (request) => networkResponseStatuses.push((await request.response()).status())); // Go to OnCall and assert that none of the requests failed await goToOnCallPage(page, 'alert-groups'); + await page.waitForLoadState('networkidle'); const allRequestsPassed = networkResponseStatuses.every( (status) => `${status}`.startsWith('2') || `${status}`.startsWith('3') ); expect(allRequestsPassed).toBeTruthy(); - // ...and user sees content of alert groups page + // ...as well as that user sees content of alert groups page await expect(page.getByText('No alert groups found')).toBeVisible(); }); test('Extension registered by OnCall plugin works for new editor user right away', async ({ adminRolePage: { page }, }) => { - // Create new editor user - const USER_NAME = `editor-${new Date().getTime()}`; - await createGrafanaUser(page, USER_NAME); - await clickButton({ page, buttonText: 'Create user' }); - await clickButton({ page, buttonText: 'Change role' }); - await page - .locator('div') - .filter({ hasText: /^Viewer$/ }) - .nth(1) - .click(); - await page.getByText(/Editor/).click(); - await clickButton({ page, buttonText: 'Save' }); - - // Login as new user - await goToGrafanaPage(page, '/logout'); - await page.getByLabel('Email or username').fill(USER_NAME); - await page.getByLabel(/Password/).fill(USER_NAME); - await clickButton({ page, buttonText: 'Log in' }); + // Login again as admin + await reloginAndWaitTillGrafanaIsLoaded({ page, username: GRAFANA_ADMIN_USERNAME }); - // Wait till Grafana home page is loaded and start tracking HTTP response codes - await page.getByText('Welcome to Grafana').waitFor(); + // Create new editor user and login as new user + const USER_NAME = `editor-${new Date().getTime()}`; + await createGrafanaUser({ page, username: USER_NAME, role: OrgRole.Editor }); await page.waitForLoadState('networkidle'); + await reloginAndWaitTillGrafanaIsLoaded({ page, username: USER_NAME }); + + // Start watching for HTTP responses const networkResponseStatuses: number[] = []; page.on('requestfinished', async (request) => networkResponseStatuses.push((await request.response()).status())); - // Go to profile -> IRM tab where OnCall plugin extension is registered + // Go to profile -> IRM tab where OnCall plugin extension is registered and assert that none of the requests failed await goToGrafanaPage(page, '/profile?tab=irm'); + await page.waitForLoadState('networkidle'); const allRequestsPassed = networkResponseStatuses.every( (status) => `${status}`.startsWith('2') || `${status}`.startsWith('3') ); expect(allRequestsPassed).toBeTruthy(); - console.log(networkResponseStatuses); - - // ...and user sees content of alert groups page + // ...as well as that user sees content of the extension const extensionContentText = page.getByText('Please connect Grafana Cloud OnCall to use the mobile app'); await extensionContentText.waitFor(); await expect(extensionContentText).toBeVisible(); diff --git a/grafana-plugin/e2e-tests/utils/constants.ts b/grafana-plugin/e2e-tests/utils/constants.ts index f6969efdf7..ea2d1c37a2 100644 --- a/grafana-plugin/e2e-tests/utils/constants.ts +++ b/grafana-plugin/e2e-tests/utils/constants.ts @@ -10,3 +10,10 @@ export const GRAFANA_ADMIN_PASSWORD = process.env.GRAFANA_ADMIN_PASSWORD || 'onc export const IS_OPEN_SOURCE = (process.env.IS_OPEN_SOURCE || 'true').toLowerCase() === 'true'; export const IS_CLOUD = !IS_OPEN_SOURCE; + +export enum OrgRole { + None = 'None', + Viewer = 'Viewer', + Editor = 'Editor', + Admin = 'Admin', +} diff --git a/grafana-plugin/e2e-tests/utils/users.ts b/grafana-plugin/e2e-tests/utils/users.ts index 622c71623e..c6f22a9dfe 100644 --- a/grafana-plugin/e2e-tests/utils/users.ts +++ b/grafana-plugin/e2e-tests/utils/users.ts @@ -1,6 +1,7 @@ import { Page, expect } from '@playwright/test'; -import { clickButton } from './forms'; +import { OrgRole } from './constants'; +import { clickButton } from './forms'; import { goToGrafanaPage, goToOnCallPage } from './navigation'; export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: boolean) { @@ -45,11 +46,40 @@ export async function viewUsers(page: Page, isAllowedToView = true): Promise<voi } } -export const createGrafanaUser = async (page: Page, username: string): Promise<void> => { +export const createGrafanaUser = async ({ + page, + username, + role = OrgRole.Viewer, +}: { + page: Page; + username: string; + role?: OrgRole; +}): Promise<void> => { await goToGrafanaPage(page, '/admin/users'); await page.getByRole('link', { name: 'New user' }).click(); await page.getByLabel('Name *').fill(username); await page.getByLabel('Username').fill(username); await page.getByLabel('Password *').fill(username); await clickButton({ page, buttonText: 'Create user' }); + + if (role !== OrgRole.Viewer) { + await clickButton({ page, buttonText: 'Change role' }); + await page + .locator('div') + .filter({ hasText: /^Viewer$/ }) + .nth(1) + .click(); + await page.getByText(new RegExp(role)).click(); + await clickButton({ page, buttonText: 'Save' }); + } +}; + +export const reloginAndWaitTillGrafanaIsLoaded = async ({ page, username }: { page: Page; username: string }) => { + await goToGrafanaPage(page, '/logout'); + await page.getByLabel('Email or username').fill(username); + await page.getByLabel(/Password/).fill(username); + await clickButton({ page, buttonText: 'Log in' }); + + await page.getByText('Welcome to Grafana').waitFor(); + await page.waitForLoadState('networkidle'); }; diff --git a/grafana-plugin/src/components/ExtensionLinkMenu/ExtensionLinkMenu.tsx b/grafana-plugin/src/components/ExtensionLinkMenu/ExtensionLinkMenu.tsx index ebe8958247..1abbcd5c3e 100644 --- a/grafana-plugin/src/components/ExtensionLinkMenu/ExtensionLinkMenu.tsx +++ b/grafana-plugin/src/components/ExtensionLinkMenu/ExtensionLinkMenu.tsx @@ -4,6 +4,7 @@ import { locationUtil, PluginExtensionLink, PluginExtensionTypes } from '@grafan import { IconName, Menu } from '@grafana/ui'; import { PluginBridge, SupportedPlugin } from 'components/PluginBridge/PluginBridge'; +import { PLUGIN_ID } from 'utils/consts'; import { truncateTitle } from 'utils/string'; type Props = { @@ -68,7 +69,7 @@ function DeclareIncidentMenuItem({ extensions, declareIncidentLink, grafanaIncid icon: 'fire', category: 'Incident', title: 'Declare incident', - pluginId: 'grafana-oncall-app', + pluginId: PLUGIN_ID, } as Partial<PluginExtensionLink>, ])} </Menu.Group> diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx index 89d70c1c77..837893f014 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx @@ -9,11 +9,13 @@ import { Block } from 'components/GBlock/Block'; import { PluginLink } from 'components/PluginLink/PluginLink'; import { Text } from 'components/Text/Text'; import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/WithPermissionControlDisplay'; +import { ActionKey } from 'models/loader/action-keys'; import { UserHelper } from 'models/user/user.helpers'; import { ApiSchemas } from 'network/oncall-api/api.types'; import { AppFeature } from 'state/features'; import { RootStore, rootStore as store } from 'state/rootStore'; import { UserActions } from 'utils/authorization/authorization'; +import { useInitializePlugin, useOnMount } from 'utils/hooks'; import { isMobile, openErrorNotification, openNotification, openWarningNotification } from 'utils/utils'; import styles from './MobileAppConnection.module.scss'; @@ -21,7 +23,6 @@ import { DisconnectButton } from './parts/DisconnectButton/DisconnectButton'; import { DownloadIcons } from './parts/DownloadIcons/DownloadIcons'; import { LinkLoginButton } from './parts/LinkLoginButton/LinkLoginButton'; import { QRCode } from './parts/QRCode/QRCode'; -import { useInitializePlugin } from 'utils/hooks'; const cx = cn.bind(styles); @@ -365,11 +366,13 @@ function QRLoading() { export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => { const { userStore } = store; - const {} = useInitializePlugin({ forceReinstall: true }); + const { isInitialized } = useInitializePlugin(); useEffect(() => { - loadData(); - }, []); + if (isInitialized) { + loadData(); + } + }, [isInitialized]); const loadData = async () => { if (!store.isBasicDataLoaded) { @@ -381,7 +384,7 @@ export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => { } }; - if (store.isBasicDataLoaded && userStore.currentUserPk) { + if (isInitialized && store.isBasicDataLoaded && userStore.currentUserPk) { return <MobileAppConnection userPk={userStore.currentUserPk} />; } diff --git a/grafana-plugin/src/models/loader/action-keys.ts b/grafana-plugin/src/models/loader/action-keys.ts index 0b093adb53..00b403af53 100644 --- a/grafana-plugin/src/models/loader/action-keys.ts +++ b/grafana-plugin/src/models/loader/action-keys.ts @@ -1,4 +1,5 @@ export enum ActionKey { + INITIALIZE_PLUGIN = 'INITIALIZE_PLUGIN', UPDATE_INTEGRATION = 'UPDATE_INTEGRATION', ADD_NEW_COLUMN_TO_ALERT_GROUP = 'ADD_NEW_COLUMN_TO_ALERT_GROUP', REMOVE_COLUMN_FROM_ALERT_GROUP = 'REMOVE_COLUMN_FROM_ALERT_GROUP', diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index 73961e2d11..82ff1cac2f 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx @@ -41,8 +41,8 @@ import { getQueryParams, isTopNavbar } from './GrafanaPluginRootPage.helpers'; import grafanaGlobalStyle from '!raw-loader!assets/style/grafanaGlobalStyles.css'; -export const GrafanaPluginRootPage = (props: AppRootProps) => { - const { isInitialized } = useInitializePlugin({ appRootProps: props }); +export const GrafanaPluginRootPage = observer((props: AppRootProps) => { + const { isInitialized } = useInitializePlugin(); useOnMount(() => { FaroHelper.initializeFaro(getOnCallApiUrl(props.meta)); @@ -66,7 +66,7 @@ export const GrafanaPluginRootPage = (props: AppRootProps) => { )} </ErrorBoundary> ); -}; +}); export const Root = observer((props: AppRootProps) => { const { isBasicDataLoaded, loadBasicData, loadMasterData, pageTitle } = useStore(); diff --git a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts index 7302a80e7d..80b29e205e 100644 --- a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts @@ -16,6 +16,7 @@ import { GlobalSettingStore } from 'models/global_setting/global_setting'; import { GrafanaTeamStore } from 'models/grafana_team/grafana_team'; import { HeartbeatStore } from 'models/heartbeat/heartbeat'; import { LabelStore } from 'models/label/label'; +import { ActionKey } from 'models/loader/action-keys'; import { LoaderStore } from 'models/loader/loader'; import { MSTeamsChannelStore } from 'models/msteams_channel/msteams_channel'; import { OrganizationStore } from 'models/organization/organization'; @@ -33,6 +34,8 @@ import { ApiSchemas } from 'network/oncall-api/api.types'; import { AppFeature } from 'state/features'; import { retryFailingPromises } from 'utils/async'; import { APP_VERSION, CLOUD_VERSION_REGEX, GRAFANA_LICENSE_CLOUD, GRAFANA_LICENSE_OSS } from 'utils/consts'; +import { AutoLoadingState } from 'utils/decorators'; +import { getIsRunningOpenSourceVersion } from 'utils/utils'; // ------ Dashboard ------ // @@ -50,7 +53,7 @@ export class RootBaseStore { recaptchaSiteKey = ''; @observable - initializationError = ''; + isPluginInitialized = false; @observable currentlyUndergoingMaintenance = false; @@ -107,6 +110,12 @@ export class RootBaseStore { constructor() { makeObservable(this); } + + @action.bound + setIsPluginInitialized(value: boolean) { + this.isPluginInitialized = value; + } + @action.bound loadBasicData = async () => { const updateFeatures = async () => { @@ -177,18 +186,36 @@ export class RootBaseStore { this.pageTitle = title; } - @action - async removeSlackIntegration() { - await this.slackStore.removeSlackIntegration(); - } - - @action - async installSlackIntegration() { - await this.slackStore.installSlackIntegration(); - } - @action.bound async getApiUrlForSettings() { return this.onCallApiUrl; } + + @AutoLoadingState(ActionKey.INITIALIZE_PLUGIN) + @action.bound + async initializePlugin() { + const IS_OPEN_SOURCE = getIsRunningOpenSourceVersion(); + + // create oncall api token and save in plugin settings + const install = async () => { + await makeRequest(`/plugin${IS_OPEN_SOURCE ? '/self-hosted' : ''}/install`, { + method: 'POST', + }); + }; + + // trigger users sync + try { + // TODO: once we improve backend we should get rid of token_ok check and call install() only in catch block + const { token_ok } = await makeRequest(`/plugin/status`, { + method: 'POST', + }); + if (!token_ok) { + await install(); + } + } catch (_err) { + await install(); + } + + this.setIsPluginInitialized(true); + } } diff --git a/grafana-plugin/src/utils/authorization/authorization.ts b/grafana-plugin/src/utils/authorization/authorization.ts index d04afb775e..d6989d5713 100644 --- a/grafana-plugin/src/utils/authorization/authorization.ts +++ b/grafana-plugin/src/utils/authorization/authorization.ts @@ -2,7 +2,7 @@ import { OrgRole } from '@grafana/data'; import { config } from '@grafana/runtime'; import { contextSrv } from 'grafana/app/core/core'; -const ONCALL_PERMISSION_PREFIX = 'grafana-oncall-app'; +import { PLUGIN_ID } from 'utils/consts'; export type UserAction = { permission: string; @@ -110,7 +110,7 @@ export const generateMissingPermissionMessage = (permission: UserAction): string `You are missing the ${determineRequiredAuthString(permission)}`; export const generatePermissionString = (resource: Resource, action: Action, includePrefix: boolean): string => - `${includePrefix ? `${ONCALL_PERMISSION_PREFIX}.` : ''}${resource}:${action}`; + `${includePrefix ? `${PLUGIN_ID}.` : ''}${resource}:${action}`; const constructAction = ( resource: Resource, diff --git a/grafana-plugin/src/utils/consts.ts b/grafana-plugin/src/utils/consts.ts index c8e406f569..474898092e 100644 --- a/grafana-plugin/src/utils/consts.ts +++ b/grafana-plugin/src/utils/consts.ts @@ -28,7 +28,8 @@ export const BREAKPOINT_TABS = 1024; // Default redirect page export const DEFAULT_PAGE = 'alert-groups'; -export const PLUGIN_ROOT = '/a/grafana-oncall-app'; +export const PLUGIN_ID = 'grafana-oncall-app'; +export const PLUGIN_ROOT = `/a/${PLUGIN_ID}`; // Environment options list for onCallApiUrl export const ONCALL_PROD = 'https://oncall-prod-us-central-0.grafana.net/oncall'; @@ -54,7 +55,7 @@ export const getOnCallApiUrl = (meta?: OnCallAppPluginMeta) => { return undefined; }; -export const getOnCallApiPath = (subpath = '') => `/api/plugins/grafana-oncall-app/resources${subpath}`; +export const getOnCallApiPath = (subpath = '') => `/api/plugins/${PLUGIN_ID}/resources${subpath}`; // Faro export const FARO_ENDPOINT_DEV = diff --git a/grafana-plugin/src/utils/faro.ts b/grafana-plugin/src/utils/faro.ts index 3d91a0020a..9b8b8bd027 100644 --- a/grafana-plugin/src/utils/faro.ts +++ b/grafana-plugin/src/utils/faro.ts @@ -9,6 +9,7 @@ import { ONCALL_DEV, ONCALL_OPS, ONCALL_PROD, + PLUGIN_ID, } from './consts'; import { safeJSONStringify } from './string'; @@ -54,7 +55,7 @@ class BaseFaroHelper { persistent: true, }, beforeSend: (event) => { - if ((event.meta.page?.url ?? '').includes('grafana-oncall-app')) { + if ((event.meta.page?.url ?? '').includes(PLUGIN_ID)) { return event; } diff --git a/grafana-plugin/src/utils/hooks.tsx b/grafana-plugin/src/utils/hooks.tsx index c7fd7378c1..b9d3b9922e 100644 --- a/grafana-plugin/src/utils/hooks.tsx +++ b/grafana-plugin/src/utils/hooks.tsx @@ -9,11 +9,10 @@ import { LoaderHelper } from 'models/loader/loader.helpers'; import { makeRequest } from 'network/network'; import { useStore } from 'state/useStore'; -import { config } from '@grafana/runtime'; import { LocationHelper } from './LocationHelper'; -import { GRAFANA_LICENSE_OSS } from './consts'; import { getCommonStyles } from './styles'; import { getIsRunningOpenSourceVersion } from './utils'; +import { RootStore, rootStore } from 'state/rootStore'; export function useForceUpdate() { const [, setValue] = useState(0); @@ -144,48 +143,18 @@ export const useOnMount = (callback: () => void) => { }, []); }; -export const useInitializePlugin = ({ - appRootProps, - forceReinstall, -}: { - appRootProps?: AppRootProps; - forceReinstall?: boolean; -}) => { - const IS_OPEN_SOURCE = getIsRunningOpenSourceVersion(); - const [isInitialized, setIsInitialized] = useState(false); - - // create oncall api token and save in plugin settings - const install = async () => { - await makeRequest(`/plugin${IS_OPEN_SOURCE ? '/self-hosted' : ''}/install`, { - method: 'POST', - }); - }; - - const initializePlugin = async () => { - if (forceReinstall || !appRootProps?.meta?.secureJsonFields?.onCallApiToken) { - await install(); - } - - // trigger users sync - let shouldReinstall = false; - try { - const { token_ok } = await makeRequest(`/plugin/status`, { - method: 'POST', - }); - shouldReinstall = !token_ok; - } catch (_err) { - shouldReinstall = true; - } finally { - if (shouldReinstall) { - await install(); - } - } - - setIsInitialized(true); - }; +export const useInitializePlugin = () => { + /* + We need to rely on rootStore imported directly (not provided via context) + because this hook is invoked out of plugin root (in plugin extension) + */ + const isInitialized = rootStore.isPluginInitialized; + const isPluginInitializing = rootStore.loaderStore.isLoading(ActionKey.INITIALIZE_PLUGIN); useOnMount(() => { - initializePlugin(); + if (!isInitialized && !isPluginInitializing) { + rootStore.initializePlugin(); + } }); return { isInitialized }; diff --git a/grafana-plugin/src/utils/utils.ts b/grafana-plugin/src/utils/utils.ts index bd1d54426d..b559ff2b38 100644 --- a/grafana-plugin/src/utils/utils.ts +++ b/grafana-plugin/src/utils/utils.ts @@ -9,6 +9,8 @@ import { isArray, concat, every, isEmpty, isObject, isPlainObject, flatMap, map, import { isNetworkError } from 'network/network'; import { getGrafanaVersion } from 'plugin/GrafanaPluginRootPage.helpers'; +import { CLOUD_VERSION_REGEX, PLUGIN_ID } from './consts'; + export class KeyValuePair<T = string | number> { key: T; value: string; @@ -120,4 +122,4 @@ export const allFieldsEmpty = (obj: any) => every(obj, isFieldEmpty); export const isMobile = window.matchMedia('(max-width: 768px)').matches; -export const getIsRunningOpenSourceVersion = () => config.apps['grafana-oncall-app'].version.startsWith('r'); +export const getIsRunningOpenSourceVersion = () => !CLOUD_VERSION_REGEX.test(config.apps[PLUGIN_ID]?.version);