From 570f40e98774e81615c807da4410ea72ff385462 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 4 Jun 2024 14:27:11 +0200 Subject: [PATCH 01/13] skeleton --- .../initialization.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts diff --git a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts new file mode 100644 index 0000000000..dc141018d8 --- /dev/null +++ b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts @@ -0,0 +1,51 @@ +/* +Scenario: User reconfigures plugin successfully +Given user goes to plugin configuration page +When user enters valid URL of OnCall Engine +And OnCall Engine is up and running +And user clicks “Test & Save” +Then user is informed about successful test +And new configuration is saved + +Scenario: User reconfigures plugin unsuccessfully +Given user goes to plugin configuration page +When user enters URL of OnCall Engine +And OnCall Engine is down or wrong URL is provided +And user clicks “Test & Save” +Then user is informed about unsuccessful test +And new configuration is not saved + +Scenario: New viewer user goes to OnCall +Given OSS viewer user has just been created +When viewer user goes to OnCall page +Then OnCall loads as usual + +Scenario: New viewer user goes to OnCall extension +Given OSS viewer user has been just created +When viewer user goes directly to OnCall plugin extension registered in Grafana +Then user sees loading page saying that his/her OnCall data is being synchronized right now +And once sync process is completed user is informed that his/her data has been synced +And OnCall extension is loaded so that user can proceed + +Scenario: Existing user goes to OnCall page or extension +Given OSS user that has already used OnCall before +When user goes to OnCall +Then OnCall loads as usual +*/ + +import { test } from '../fixtures'; +import { goToGrafanaPage } from '../utils/navigation'; + +test.describe('Plugin initialization', () => { + test('plugin config page', async ({ adminRolePage: { page } }) => { + await goToGrafanaPage(page); + expect(page).toHaveText('plugin config page'); + }); +}); + +test.describe('Plugin configuration', () => { + test('plugin config page', async ({ adminRolePage: { page } }) => { + await goToGrafanaPage(page); + expect(page).toHaveText('plugin config page'); + }); +}); From 3d15bdc85211ee1b3e218bc50eab13a2ee0eec41 Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 4 Jun 2024 16:36:52 +0200 Subject: [PATCH 02/13] initialization test --- .../initialization.test.ts | 43 ++++++++++++++++--- grafana-plugin/e2e-tests/utils/forms.ts | 5 ++- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts index dc141018d8..34f4c38ffe 100644 --- a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts +++ b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts @@ -33,19 +33,50 @@ When user goes to OnCall Then OnCall loads as usual */ -import { test } from '../fixtures'; -import { goToGrafanaPage } from '../utils/navigation'; +import { test, expect } from '../fixtures'; +import { clickButton } from '../utils/forms'; +import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation'; test.describe('Plugin initialization', () => { - test('plugin config page', async ({ adminRolePage: { page } }) => { - await goToGrafanaPage(page); - expect(page).toHaveText('plugin config page'); + test('Plugin works for new viewer user right away', async ({ adminRolePage: { page } }) => { + // Create new viewer user + const USER_NAME = `viewer-${new Date().getTime()}`; + await goToGrafanaPage(page, '/admin/users'); + await page.getByRole('link', { name: 'New user' }).click(); + await page.getByLabel('Name *').fill(USER_NAME); + await page.getByLabel('Username').fill(USER_NAME); + await page.getByLabel('Password *').fill(USER_NAME); + await clickButton({ page, buttonText: 'Create user' }); + await page.waitForTimeout(2000); + + // 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' }); + await page.getByText('Welcome to Grafana').waitFor(); + + // Start tracking HTTP response codes + const networkResponseStatuses: number[] = []; + page.on('requestfinished', async (request) => networkResponseStatuses.push((await request.response()).status())); + + // Go to OnCall + await goToOnCallPage(page, 'alert-groups'); + + // Assert that none of the requests failed + const allRequestsPassed = networkResponseStatuses.every( + (status) => `${status}`.startsWith('2') || `${status}`.startsWith('3') + ); + expect(allRequestsPassed).toBeTruthy(); + + // // ...and user sees conent of alert groups page + // await expect(page.getByText('No alert groups found')).toBeVisible(); }); }); test.describe('Plugin configuration', () => { test('plugin config page', async ({ adminRolePage: { page } }) => { await goToGrafanaPage(page); - expect(page).toHaveText('plugin config page'); + expect(page).toBe('plugin config page'); }); }); diff --git a/grafana-plugin/e2e-tests/utils/forms.ts b/grafana-plugin/e2e-tests/utils/forms.ts index cc53d62b65..6ddefa2d01 100644 --- a/grafana-plugin/e2e-tests/utils/forms.ts +++ b/grafana-plugin/e2e-tests/utils/forms.ts @@ -25,6 +25,7 @@ type ClickButtonArgs = { buttonText: string | RegExp; // if provided, use this Locator as the root of our search for the button startingLocator?: Locator; + exact?: boolean; }; export const fillInInput = (page: Page, selector: string, value: string) => page.fill(selector, value); @@ -34,9 +35,9 @@ export const fillInInputByPlaceholderValue = (page: Page, placeholderValue: stri export const getInputByName = (page: Page, name: string): Locator => page.locator(`input[name="${name}"]`); -export const clickButton = async ({ page, buttonText, startingLocator }: ClickButtonArgs): Promise => { +export const clickButton = async ({ page, buttonText, startingLocator, exact }: ClickButtonArgs): Promise => { const baseLocator = startingLocator || page; - await baseLocator.getByRole('button', { name: buttonText, disabled: false }).click(); + await baseLocator.getByRole('button', { name: buttonText, disabled: false, exact }).click(); }; /** From b00f1c4c533b9e83803a86ec622021cd55da272f Mon Sep 17 00:00:00 2001 From: Dominik Date: Tue, 4 Jun 2024 22:10:38 +0200 Subject: [PATCH 03/13] temp changes --- .../initialization.test.ts | 65 +++++++++++++++---- grafana-plugin/e2e-tests/utils/users.ts | 12 +++- grafana-plugin/playwright.config.ts | 2 +- .../MobileAppConnection.tsx | 2 + .../src/plugin/GrafanaPluginRootPage.tsx | 2 +- grafana-plugin/src/types.ts | 2 +- grafana-plugin/src/utils/hooks.tsx | 24 +++++-- grafana-plugin/src/utils/utils.ts | 3 + 8 files changed, 89 insertions(+), 23 deletions(-) diff --git a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts index 34f4c38ffe..7965a2e9a6 100644 --- a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts +++ b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts @@ -36,41 +36,78 @@ Then OnCall loads as usual import { test, expect } from '../fixtures'; import { clickButton } from '../utils/forms'; import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation'; +import { createGrafanaUser } from '../utils/users'; test.describe('Plugin initialization', () => { - test('Plugin works for new viewer user right away', async ({ adminRolePage: { page } }) => { + test('Plugin OnCall pages work for new viewer user right away', async ({ adminRolePage: { page } }) => { // Create new viewer user const USER_NAME = `viewer-${new Date().getTime()}`; - await goToGrafanaPage(page, '/admin/users'); - await page.getByRole('link', { name: 'New user' }).click(); - await page.getByLabel('Name *').fill(USER_NAME); - await page.getByLabel('Username').fill(USER_NAME); - await page.getByLabel('Password *').fill(USER_NAME); - await clickButton({ page, buttonText: 'Create user' }); - await page.waitForTimeout(2000); + await createGrafanaUser(page, 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' }); - await page.getByText('Welcome to Grafana').waitFor(); - // Start tracking HTTP response codes + // Wait till Grafana home page is loaded and start tracking HTTP response codes + await page.getByText('Welcome to Grafana').waitFor(); + await page.waitForLoadState('networkidle'); const networkResponseStatuses: number[] = []; page.on('requestfinished', async (request) => networkResponseStatuses.push((await request.response()).status())); - // Go to OnCall + // Go to OnCall and assert that none of the requests failed await goToOnCallPage(page, 'alert-groups'); + const allRequestsPassed = networkResponseStatuses.every( + (status) => `${status}`.startsWith('2') || `${status}`.startsWith('3') + ); + expect(allRequestsPassed).toBeTruthy(); + + // ...and 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' }); - // Assert that none of the requests failed + // 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'); + 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 + await goToGrafanaPage(page, '/profile?tab=irm'); const allRequestsPassed = networkResponseStatuses.every( (status) => `${status}`.startsWith('2') || `${status}`.startsWith('3') ); expect(allRequestsPassed).toBeTruthy(); - // // ...and user sees conent of alert groups page - // await expect(page.getByText('No alert groups found')).toBeVisible(); + console.log(networkResponseStatuses); + + // ...and user sees content of alert groups page + 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/users.ts b/grafana-plugin/e2e-tests/utils/users.ts index e7c5d15dff..622c71623e 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 { goToOnCallPage } from './navigation'; +import { goToGrafanaPage, goToOnCallPage } from './navigation'; export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: boolean) { await goToOnCallPage(page, 'users'); @@ -43,3 +44,12 @@ export async function viewUsers(page: Page, isAllowedToView = true): Promise => { + 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' }); +}; diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts index 42a37f2524..88b2951658 100644 --- a/grafana-plugin/playwright.config.ts +++ b/grafana-plugin/playwright.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ reporter: [['html', { open: IS_CI ? 'never' : 'always' }]], /* Maximum time one test can run for. */ - timeout: 60_000, + timeout: 20_000, expect: { /** * Maximum time expect() should wait for the condition to be met. diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx index 701c96b1ce..89d70c1c77 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx @@ -21,6 +21,7 @@ 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); @@ -364,6 +365,7 @@ function QRLoading() { export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => { const { userStore } = store; + const {} = useInitializePlugin({ forceReinstall: true }); useEffect(() => { loadData(); diff --git a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index 57b8a5796b..73961e2d11 100644 --- a/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx +++ b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx @@ -42,7 +42,7 @@ import { getQueryParams, isTopNavbar } from './GrafanaPluginRootPage.helpers'; import grafanaGlobalStyle from '!raw-loader!assets/style/grafanaGlobalStyles.css'; export const GrafanaPluginRootPage = (props: AppRootProps) => { - const { isInitialized } = useInitializePlugin(props); + const { isInitialized } = useInitializePlugin({ appRootProps: props }); useOnMount(() => { FaroHelper.initializeFaro(getOnCallApiUrl(props.meta)); diff --git a/grafana-plugin/src/types.ts b/grafana-plugin/src/types.ts index 7bf6c041c0..7967c3c2cc 100644 --- a/grafana-plugin/src/types.ts +++ b/grafana-plugin/src/types.ts @@ -13,7 +13,7 @@ export type OnCallPluginMetaSecureJSONData = { onCallApiToken: string; }; -export type AppRootProps = BaseAppRootProps; +export type AppRootProps = BaseAppRootProps & { forceReinstall?: boolean }; // NOTE: it is possible that plugin.meta.jsonData is null (ex. on first-ever setup) // the typing on AppPluginMeta does not seem correct atm.. diff --git a/grafana-plugin/src/utils/hooks.tsx b/grafana-plugin/src/utils/hooks.tsx index b3b8d1b3bb..c7fd7378c1 100644 --- a/grafana-plugin/src/utils/hooks.tsx +++ b/grafana-plugin/src/utils/hooks.tsx @@ -9,9 +9,11 @@ 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'; export function useForceUpdate() { const [, setValue] = useState(0); @@ -142,8 +144,14 @@ export const useOnMount = (callback: () => void) => { }, []); }; -export const useInitializePlugin = ({ meta }: AppRootProps) => { - const IS_OPEN_SOURCE = meta?.jsonData?.license === GRAFANA_LICENSE_OSS; +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 @@ -154,17 +162,23 @@ export const useInitializePlugin = ({ meta }: AppRootProps) => { }; const initializePlugin = async () => { - if (!meta?.secureJsonFields?.onCallApiToken) { + if (forceReinstall || !appRootProps?.meta?.secureJsonFields?.onCallApiToken) { await install(); } // trigger users sync + let shouldReinstall = false; try { - await makeRequest(`/plugin/status`, { + const { token_ok } = await makeRequest(`/plugin/status`, { method: 'POST', }); + shouldReinstall = !token_ok; } catch (_err) { - await install(); + shouldReinstall = true; + } finally { + if (shouldReinstall) { + await install(); + } } setIsInitialized(true); diff --git a/grafana-plugin/src/utils/utils.ts b/grafana-plugin/src/utils/utils.ts index 99846408d6..bd1d54426d 100644 --- a/grafana-plugin/src/utils/utils.ts +++ b/grafana-plugin/src/utils/utils.ts @@ -1,4 +1,5 @@ import { AppEvents } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { AxiosError } from 'axios'; import { sentenceCase } from 'change-case'; // @ts-ignore @@ -118,3 +119,5 @@ function isFieldEmpty(value: any): boolean { 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'); From 3aa104ce13532c50f2257006ac81d567d2c94b27 Mon Sep 17 00:00:00 2001 From: Dominik Date: Wed, 5 Jun 2024 21:33:04 +0200 Subject: [PATCH 04/13] 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 => { +export const createGrafanaUser = async ({ + page, + username, + role = OrgRole.Viewer, +}: { + page: Page; + username: string; + role?: OrgRole; +}): Promise => { 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, ])} 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 ; } 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) => { )} ); -}; +}); 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 { 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); From 239f8d38d2ed0bb4e6fe2ad7bed74644a851446a Mon Sep 17 00:00:00 2001 From: Dominik Date: Wed, 5 Jun 2024 21:35:58 +0200 Subject: [PATCH 05/13] update --- .../containers/MobileAppConnection/MobileAppConnection.tsx | 3 +-- grafana-plugin/src/utils/hooks.tsx | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx index 837893f014..516065cfa6 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx @@ -9,13 +9,12 @@ 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 { useInitializePlugin } from 'utils/hooks'; import { isMobile, openErrorNotification, openNotification, openWarningNotification } from 'utils/utils'; import styles from './MobileAppConnection.module.scss'; diff --git a/grafana-plugin/src/utils/hooks.tsx b/grafana-plugin/src/utils/hooks.tsx index b9d3b9922e..754993f1b6 100644 --- a/grafana-plugin/src/utils/hooks.tsx +++ b/grafana-plugin/src/utils/hooks.tsx @@ -2,17 +2,14 @@ import React, { ComponentProps, useEffect, useRef, useState } from 'react'; import { ConfirmModal, useStyles2 } from '@grafana/ui'; import { useLocation } from 'react-router-dom'; -import { AppRootProps } from 'types'; import { ActionKey } from 'models/loader/action-keys'; import { LoaderHelper } from 'models/loader/loader.helpers'; -import { makeRequest } from 'network/network'; +import { rootStore } from 'state/rootStore'; import { useStore } from 'state/useStore'; import { LocationHelper } from './LocationHelper'; import { getCommonStyles } from './styles'; -import { getIsRunningOpenSourceVersion } from './utils'; -import { RootStore, rootStore } from 'state/rootStore'; export function useForceUpdate() { const [, setValue] = useState(0); From c87de489e7e2f241433b4b0a6419a50354696f4c Mon Sep 17 00:00:00 2001 From: Dominik Date: Wed, 5 Jun 2024 21:38:28 +0200 Subject: [PATCH 06/13] update --- grafana-plugin/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grafana-plugin/playwright.config.ts b/grafana-plugin/playwright.config.ts index 88b2951658..42a37f2524 100644 --- a/grafana-plugin/playwright.config.ts +++ b/grafana-plugin/playwright.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ reporter: [['html', { open: IS_CI ? 'never' : 'always' }]], /* Maximum time one test can run for. */ - timeout: 20_000, + timeout: 60_000, expect: { /** * Maximum time expect() should wait for the condition to be met. From 5f3fb0c7877167d1d7c17b9e2b5e2cef0dee70d6 Mon Sep 17 00:00:00 2001 From: Dominik Date: Wed, 5 Jun 2024 22:16:15 +0200 Subject: [PATCH 07/13] update --- .../initialization.test.ts | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts index cf75b83c7b..19b186229e 100644 --- a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts +++ b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts @@ -39,6 +39,10 @@ import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation'; import { createGrafanaUser, reloginAndWaitTillGrafanaIsLoaded } from '../utils/users'; test.describe('Plugin initialization', () => { + test.afterAll(async ({ adminRolePage: { page } }) => { + await reloginAndWaitTillGrafanaIsLoaded({ page, username: GRAFANA_ADMIN_USERNAME }); + }); + test('Plugin OnCall pages work for new viewer user right away', async ({ adminRolePage: { page } }) => { // Create new editor user and login as new user const USER_NAME = `viewer-${new Date().getTime()}`; @@ -52,22 +56,20 @@ test.describe('Plugin initialization', () => { // 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(); + const numberOfFailedRequests = networkResponseStatuses.filter( + (status) => !(`${status}`.startsWith('2') || `${status}`.startsWith('3')) + ).length; + expect(numberOfFailedRequests).toBeLessThanOrEqual(1); // we allow /status request to fail once so plugin is reinstalled // ...as well as that user sees content of alert groups page - await expect(page.getByText('No alert groups found')).toBeVisible(); + await expect(page.getByText('No Alert Groups selected')).toBeVisible(); }); test('Extension registered by OnCall plugin works for new editor user right away', async ({ adminRolePage: { page }, }) => { - // Login again as admin - await reloginAndWaitTillGrafanaIsLoaded({ page, username: GRAFANA_ADMIN_USERNAME }); - // Create new editor user and login as new user + await reloginAndWaitTillGrafanaIsLoaded({ page, username: GRAFANA_ADMIN_USERNAME }); const USER_NAME = `editor-${new Date().getTime()}`; await createGrafanaUser({ page, username: USER_NAME, role: OrgRole.Editor }); await page.waitForLoadState('networkidle'); @@ -80,10 +82,10 @@ test.describe('Plugin initialization', () => { // 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(); + const numberOfFailedRequests = networkResponseStatuses.filter( + (status) => !(`${status}`.startsWith('2') || `${status}`.startsWith('3')) + ).length; + expect(numberOfFailedRequests).toBeLessThanOrEqual(1); // we allow /status request to fail once so plugin is reinstalled // ...as well as that user sees content of the extension const extensionContentText = page.getByText('Please connect Grafana Cloud OnCall to use the mobile app'); @@ -91,10 +93,3 @@ test.describe('Plugin initialization', () => { await expect(extensionContentText).toBeVisible(); }); }); - -test.describe('Plugin configuration', () => { - test('plugin config page', async ({ adminRolePage: { page } }) => { - await goToGrafanaPage(page); - expect(page).toBe('plugin config page'); - }); -}); From 1389ebb4a7d4b32ab60108e6582061048d37412f Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 6 Jun 2024 06:31:07 +0200 Subject: [PATCH 08/13] Use separate browser context --- .../pluginInitialization/initialization.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts index 19b186229e..2c338a1429 100644 --- a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts +++ b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts @@ -43,7 +43,11 @@ test.describe('Plugin initialization', () => { await reloginAndWaitTillGrafanaIsLoaded({ page, username: GRAFANA_ADMIN_USERNAME }); }); - test('Plugin OnCall pages work for new viewer user right away', async ({ adminRolePage: { page } }) => { + // Separate browser context to not affect other tests that use logged in admin user in adminRolePage + test('Plugin OnCall pages work for new viewer user right away', async ({ page }) => { + // Login as admin + await reloginAndWaitTillGrafanaIsLoaded({ page, username: GRAFANA_ADMIN_USERNAME }); + // Create new editor user and login as new user const USER_NAME = `viewer-${new Date().getTime()}`; await createGrafanaUser({ page, username: USER_NAME, role: OrgRole.Viewer }); @@ -65,11 +69,10 @@ test.describe('Plugin initialization', () => { await expect(page.getByText('No Alert Groups selected')).toBeVisible(); }); - test('Extension registered by OnCall plugin works for new editor user right away', async ({ - adminRolePage: { page }, - }) => { - // Create new editor user and login as new user + test('Extension registered by OnCall plugin works for new editor user right away', async ({ page }) => { await reloginAndWaitTillGrafanaIsLoaded({ page, username: GRAFANA_ADMIN_USERNAME }); + + // 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'); From 0dbd1277af2fc84109d1484c37256e2821270e3d Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 6 Jun 2024 06:54:44 +0200 Subject: [PATCH 09/13] disable initialization tests temporarily --- .../{initialization.test.ts => initialization.xtest.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename grafana-plugin/e2e-tests/pluginInitialization/{initialization.test.ts => initialization.xtest.ts} (100%) diff --git a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/initialization.xtest.ts similarity index 100% rename from grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts rename to grafana-plugin/e2e-tests/pluginInitialization/initialization.xtest.ts From fb23f136014801c259f03729ef9a8abec32b2e30 Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 6 Jun 2024 07:20:51 +0200 Subject: [PATCH 10/13] update --- ...zation.xtest.ts => initialization.test.ts} | 59 +++++++++++-------- grafana-plugin/e2e-tests/utils/users.ts | 4 +- 2 files changed, 35 insertions(+), 28 deletions(-) rename grafana-plugin/e2e-tests/pluginInitialization/{initialization.xtest.ts => initialization.test.ts} (66%) diff --git a/grafana-plugin/e2e-tests/pluginInitialization/initialization.xtest.ts b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts similarity index 66% rename from grafana-plugin/e2e-tests/pluginInitialization/initialization.xtest.ts rename to grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts index 2c338a1429..19b6860e90 100644 --- a/grafana-plugin/e2e-tests/pluginInitialization/initialization.xtest.ts +++ b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts @@ -34,64 +34,71 @@ Then OnCall loads as usual */ import { test, expect } from '../fixtures'; -import { GRAFANA_ADMIN_USERNAME, OrgRole } from '../utils/constants'; +import { OrgRole } from '../utils/constants'; import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation'; -import { createGrafanaUser, reloginAndWaitTillGrafanaIsLoaded } from '../utils/users'; +import { createGrafanaUser, loginAndWaitTillGrafanaIsLoaded } from '../utils/users'; test.describe('Plugin initialization', () => { - test.afterAll(async ({ adminRolePage: { page } }) => { - await reloginAndWaitTillGrafanaIsLoaded({ page, username: GRAFANA_ADMIN_USERNAME }); - }); - - // Separate browser context to not affect other tests that use logged in admin user in adminRolePage - test('Plugin OnCall pages work for new viewer user right away', async ({ page }) => { - // Login as admin - await reloginAndWaitTillGrafanaIsLoaded({ page, username: GRAFANA_ADMIN_USERNAME }); - - // Create new editor user and login as new user + test('Plugin OnCall pages work for new viewer user right away', async ({ adminRolePage: { page }, browser }) => { + // Create new viewer user and login as new user const USER_NAME = `viewer-${new Date().getTime()}`; await createGrafanaUser({ page, username: USER_NAME, role: OrgRole.Viewer }); - await reloginAndWaitTillGrafanaIsLoaded({ page, username: USER_NAME }); + + // Create new browser context to act as new user + const viewerUserContext = await browser.newContext(); + const viewerUserPage = await viewerUserContext.newPage(); + + await loginAndWaitTillGrafanaIsLoaded({ page: viewerUserPage, username: USER_NAME }); // Start watching for HTTP responses const networkResponseStatuses: number[] = []; - page.on('requestfinished', async (request) => networkResponseStatuses.push((await request.response()).status())); + viewerUserPage.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'); + await goToOnCallPage(viewerUserPage, 'alert-groups'); + await viewerUserPage.waitForLoadState('networkidle'); const numberOfFailedRequests = networkResponseStatuses.filter( (status) => !(`${status}`.startsWith('2') || `${status}`.startsWith('3')) ).length; expect(numberOfFailedRequests).toBeLessThanOrEqual(1); // we allow /status request to fail once so plugin is reinstalled // ...as well as that user sees content of alert groups page - await expect(page.getByText('No Alert Groups selected')).toBeVisible(); + await expect(viewerUserPage.getByText('No Alert Groups selected')).toBeVisible(); }); - test('Extension registered by OnCall plugin works for new editor user right away', async ({ page }) => { - await reloginAndWaitTillGrafanaIsLoaded({ page, username: GRAFANA_ADMIN_USERNAME }); - - // Create new editor user and login as new user + test('Extension registered by OnCall plugin works for new editor user right away', async ({ + adminRolePage: { page }, + browser, + }) => { + // Create new editor 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 }); + + // Create new browser context to act as new user + const editorUserContext = await browser.newContext(); + const editorUserPage = await editorUserContext.newPage(); + + await loginAndWaitTillGrafanaIsLoaded({ page: editorUserPage, username: USER_NAME }); // Start watching for HTTP responses const networkResponseStatuses: number[] = []; - page.on('requestfinished', async (request) => networkResponseStatuses.push((await request.response()).status())); + editorUserPage.on('requestfinished', async (request) => + networkResponseStatuses.push((await request.response()).status()) + ); // 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'); + await goToGrafanaPage(editorUserPage, '/profile?tab=irm'); + await editorUserPage.waitForLoadState('networkidle'); const numberOfFailedRequests = networkResponseStatuses.filter( (status) => !(`${status}`.startsWith('2') || `${status}`.startsWith('3')) ).length; expect(numberOfFailedRequests).toBeLessThanOrEqual(1); // we allow /status request to fail once so plugin is reinstalled // ...as well as that user sees content of the extension - const extensionContentText = page.getByText('Please connect Grafana Cloud OnCall to use the mobile app'); + const extensionContentText = editorUserPage.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/users.ts b/grafana-plugin/e2e-tests/utils/users.ts index c6f22a9dfe..d28b44eeaf 100644 --- a/grafana-plugin/e2e-tests/utils/users.ts +++ b/grafana-plugin/e2e-tests/utils/users.ts @@ -74,8 +74,8 @@ export const createGrafanaUser = async ({ } }; -export const reloginAndWaitTillGrafanaIsLoaded = async ({ page, username }: { page: Page; username: string }) => { - await goToGrafanaPage(page, '/logout'); +export const loginAndWaitTillGrafanaIsLoaded = async ({ page, username }: { page: Page; username: string }) => { + await goToGrafanaPage(page, '/login'); await page.getByLabel('Email or username').fill(username); await page.getByLabel(/Password/).fill(username); await clickButton({ page, buttonText: 'Log in' }); From a4b14f391333144d9a4a4bac0d973f7b54846436 Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 6 Jun 2024 08:23:03 +0200 Subject: [PATCH 11/13] fix userActions tests to respect more created users --- .../e2e-tests/users/usersActions.test.ts | 27 ++++++++++++------- grafana-plugin/e2e-tests/utils/users.ts | 5 ++-- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/grafana-plugin/e2e-tests/users/usersActions.test.ts b/grafana-plugin/e2e-tests/users/usersActions.test.ts index 4988971a0f..863c467a9e 100644 --- a/grafana-plugin/e2e-tests/users/usersActions.test.ts +++ b/grafana-plugin/e2e-tests/users/usersActions.test.ts @@ -2,28 +2,29 @@ import semver from 'semver'; import { test, expect } from '../fixtures'; import { goToOnCallPage } from '../utils/navigation'; -import { viewUsers, accessProfileTabs } from '../utils/users'; +import { verifyThatUserCanViewOtherUsers, accessProfileTabs } from '../utils/users'; test.describe('Users screen actions', () => { test("Admin is allowed to edit other users' profile", async ({ adminRolePage: { page } }) => { await goToOnCallPage(page, 'users'); - await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false })).toHaveCount(3); + const editableUsers = page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false }); + await editableUsers.first().waitFor(); + const editableUsersCount = await editableUsers.count(); + expect(editableUsersCount).toBeGreaterThan(1); }); test('Admin is allowed to view the list of users', async ({ adminRolePage: { page } }) => { - await viewUsers(page); + await verifyThatUserCanViewOtherUsers(page); }); test('Viewer is not allowed to view the list of users', async ({ viewerRolePage: { page } }) => { - await viewUsers(page, false); + await verifyThatUserCanViewOtherUsers(page, false); }); test('Viewer cannot access restricted tabs from View My Profile', async ({ viewerRolePage }) => { const { page } = viewerRolePage; const tabsToCheck = ['tab-phone-verification', 'tab-slack', 'tab-telegram']; - console.log(process.env.CURRENT_GRAFANA_VERSION); - // After 10.3 it's been moved to global user profile if (semver.lt(process.env.CURRENT_GRAFANA_VERSION, '10.3.0')) { tabsToCheck.unshift('tab-mobile-app'); @@ -33,7 +34,7 @@ test.describe('Users screen actions', () => { }); test('Editor is allowed to view the list of users', async ({ editorRolePage }) => { - await viewUsers(editorRolePage.page); + await verifyThatUserCanViewOtherUsers(editorRolePage.page); }); test("Editor cannot view other users' data", async ({ editorRolePage }) => { @@ -43,8 +44,10 @@ test.describe('Users screen actions', () => { await page.getByTestId('users-email').and(page.getByText('editor')).waitFor(); await expect(page.getByTestId('users-email').and(page.getByText('editor'))).toHaveCount(1); - await expect(page.getByTestId('users-email').and(page.getByText('******'))).toHaveCount(2); - await expect(page.getByTestId('users-phone-number').and(page.getByText('******'))).toHaveCount(2); + const maskedEmailsCount = await page.getByTestId('users-email').and(page.getByText('******')).count(); + expect(maskedEmailsCount).toBeGreaterThan(1); + const maskedPhoneNumbersCount = await page.getByTestId('users-phone-number').and(page.getByText('******')).count(); + expect(maskedPhoneNumbersCount).toBeGreaterThan(1); }); test('Editor can access tabs from View My Profile', async ({ editorRolePage }) => { @@ -57,7 +60,11 @@ test.describe('Users screen actions', () => { test("Editor is not allowed to edit other users' profile", async ({ editorRolePage: { page } }) => { await goToOnCallPage(page, 'users'); await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: false })).toHaveCount(1); - await expect(page.getByTestId('users-table').getByRole('button', { name: 'Edit', disabled: true })).toHaveCount(2); + const usersCountWithDisabledEdit = await page + .getByTestId('users-table') + .getByRole('button', { name: 'Edit', disabled: true }) + .count(); + expect(usersCountWithDisabledEdit).toBeGreaterThan(1); }); test('Search updates the table view', async ({ adminRolePage }) => { diff --git a/grafana-plugin/e2e-tests/utils/users.ts b/grafana-plugin/e2e-tests/utils/users.ts index d28b44eeaf..996b4a6e7a 100644 --- a/grafana-plugin/e2e-tests/utils/users.ts +++ b/grafana-plugin/e2e-tests/utils/users.ts @@ -32,13 +32,14 @@ export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: b } } -export async function viewUsers(page: Page, isAllowedToView = true): Promise { +export async function verifyThatUserCanViewOtherUsers(page: Page, isAllowedToView = true): Promise { await goToOnCallPage(page, 'users'); if (isAllowedToView) { const usersTable = page.getByTestId('users-table'); await usersTable.getByRole('row').nth(1).waitFor(); - await expect(usersTable.getByRole('row')).toHaveCount(4); + const usersCount = await page.getByTestId('users-table').getByRole('row').count(); + expect(usersCount).toBeGreaterThan(1); } else { await expect(page.getByTestId('view-users-missing-permission-message')).toHaveText( /You are missing the .* to be able to view OnCall users/ From b31be727d7537b3ece646febfd2757e4c9d51909 Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 6 Jun 2024 15:42:20 +0200 Subject: [PATCH 12/13] update --- .../grafana-oncall-app-provisioning.yaml | 1 - .../configuration.test.ts | 15 ++++++ .../initialization.test.ts | 3 -- .../PluginConfigPage/PluginConfigPage.tsx | 53 ++++++++++++++++++- .../ServiceNowConfigDrawer.tsx | 6 +-- grafana-plugin/src/models/plugin/plugin.ts | 48 +++++++++++++++++ .../src/state/rootBaseStore/RootBaseStore.ts | 41 +------------- grafana-plugin/src/types.ts | 3 +- grafana-plugin/src/utils/hooks.tsx | 4 +- grafana-plugin/src/utils/string.ts | 6 +++ 10 files changed, 127 insertions(+), 53 deletions(-) create mode 100644 grafana-plugin/e2e-tests/pluginInitialization/configuration.test.ts create mode 100644 grafana-plugin/src/models/plugin/plugin.ts diff --git a/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml b/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml index c2a5f7048e..47b8e1d7ee 100644 --- a/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml +++ b/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml @@ -5,5 +5,4 @@ apps: jsonData: stackId: 5 orgId: 100 - license: OpenSource onCallApiUrl: http://oncall-dev-engine:8080 diff --git a/grafana-plugin/e2e-tests/pluginInitialization/configuration.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/configuration.test.ts new file mode 100644 index 0000000000..e554d52c3e --- /dev/null +++ b/grafana-plugin/e2e-tests/pluginInitialization/configuration.test.ts @@ -0,0 +1,15 @@ +import { PLUGIN_ID } from 'utils/consts'; + +import { test, expect } from '../fixtures'; +import { goToGrafanaPage } from '../utils/navigation'; + +test.describe('Plugin configuration', () => { + test('Admin user can see currently applied URL', async ({ adminRolePage: { page } }) => { + const urlInput = page.getByTestId('oncall-api-url-input'); + + await goToGrafanaPage(page, `/plugins/${PLUGIN_ID}`); + const currentlyAppliedURL = await urlInput.inputValue(); + + expect(currentlyAppliedURL).toBe('http://oncall-dev-engine:8080'); + }); +}); diff --git a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts index 19b6860e90..68263442b5 100644 --- a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts +++ b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts @@ -63,9 +63,6 @@ test.describe('Plugin initialization', () => { (status) => !(`${status}`.startsWith('2') || `${status}`.startsWith('3')) ).length; expect(numberOfFailedRequests).toBeLessThanOrEqual(1); // we allow /status request to fail once so plugin is reinstalled - - // ...as well as that user sees content of alert groups page - await expect(viewerUserPage.getByText('No Alert Groups selected')).toBeVisible(); }); test('Extension registered by OnCall plugin works for new editor user right away', async ({ diff --git a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx index 02fff7d5c3..f0b547d80b 100644 --- a/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx +++ b/grafana-plugin/src/containers/PluginConfigPage/PluginConfigPage.tsx @@ -1,3 +1,54 @@ import React from 'react'; -export const PluginConfigPage = () => <>plugin config page; +import { PluginConfigPageProps, PluginMeta } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { Field, HorizontalGroup, Input } from '@grafana/ui'; +import { observer } from 'mobx-react-lite'; +import { Controller, useForm } from 'react-hook-form'; +import { OnCallPluginMetaJSONData } from 'types'; + +import { Button } from 'components/Button/Button'; +import { getOnCallApiUrl } from 'utils/consts'; +import { validateURL } from 'utils/string'; + +type PluginConfigFormValues = { + onCallApiUrl: string; +}; + +export const PluginConfigPage = observer((props: PluginConfigPageProps>) => { + const { handleSubmit, control, formState } = useForm({ + mode: 'onChange', + defaultValues: { onCallApiUrl: getOnCallApiUrl(props.plugin.meta) }, + }); + + const onSubmit = (values: PluginConfigFormValues) => { + // eslint-disable-next-line no-console + console.log(values); + }; + + return ( +
+ ( + + + + )} + /> + + + {config.featureToggles.externalServiceAccounts && } + + + ); +}); diff --git a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer.tsx b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer.tsx index 6e88a07f97..8ea3c7be11 100644 --- a/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer.tsx +++ b/grafana-plugin/src/containers/ServiceNowConfigDrawer/ServiceNowConfigDrawer.tsx @@ -4,7 +4,6 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Drawer, Field, HorizontalGroup, Input, useStyles2, Button } from '@grafana/ui'; import { observer } from 'mobx-react'; -import { parseUrl } from 'query-string'; import { Controller, FormProvider, useForm } from 'react-hook-form'; import { ActionKey } from 'models/loader/action-keys'; @@ -12,6 +11,7 @@ import { ApiSchemas } from 'network/oncall-api/api.types'; import { useCurrentIntegration } from 'pages/integration/OutgoingTab/OutgoingTab.hooks'; import { useStore } from 'state/useStore'; import { useIsLoading } from 'utils/hooks'; +import { validateURL } from 'utils/string'; import { OmitReadonlyMembers } from 'utils/types'; import { openNotification } from 'utils/utils'; @@ -130,10 +130,6 @@ export const ServiceNowConfigDrawer: React.FC ); - function validateURL(urlFieldValue: string): string | boolean { - return !parseUrl(urlFieldValue) ? 'Instance URL is invalid' : true; - } - async function onFormSubmit(formData: FormFields): Promise { const data: OmitReadonlyMembers = { ...currentIntegration, diff --git a/grafana-plugin/src/models/plugin/plugin.ts b/grafana-plugin/src/models/plugin/plugin.ts new file mode 100644 index 0000000000..dc3651befd --- /dev/null +++ b/grafana-plugin/src/models/plugin/plugin.ts @@ -0,0 +1,48 @@ +import { makeAutoObservable } from 'mobx'; + +import { ActionKey } from 'models/loader/action-keys'; +import { makeRequest } from 'network/network'; +import { RootBaseStore } from 'state/rootBaseStore/RootBaseStore'; +import { AutoLoadingState } from 'utils/decorators'; +import { getIsRunningOpenSourceVersion } from 'utils/utils'; + +export class PluginStore { + rootStore: RootBaseStore; + isPluginInitialized = false; + + constructor(rootStore: RootBaseStore) { + makeAutoObservable(this, undefined, { autoBind: true }); + this.rootStore = rootStore; + } + + setIsPluginInitialized(value: boolean) { + this.isPluginInitialized = value; + } + + @AutoLoadingState(ActionKey.INITIALIZE_PLUGIN) + 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/state/rootBaseStore/RootBaseStore.ts b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts index 80b29e205e..26dfa5be4e 100644 --- a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts @@ -16,11 +16,11 @@ 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'; import { OutgoingWebhookStore } from 'models/outgoing_webhook/outgoing_webhook'; +import { PluginStore } from 'models/plugin/plugin'; import { ResolutionNotesStore } from 'models/resolution_note/resolution_note'; import { ScheduleStore } from 'models/schedule/schedule'; import { SlackStore } from 'models/slack/slack'; @@ -34,8 +34,6 @@ 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 ------ // @@ -52,9 +50,6 @@ export class RootBaseStore { @observable recaptchaSiteKey = ''; - @observable - isPluginInitialized = false; - @observable currentlyUndergoingMaintenance = false; @@ -79,6 +74,7 @@ export class RootBaseStore { insightsDatasource?: string; // stores + pluginStore = new PluginStore(this); userStore = new UserStore(this); cloudStore = new CloudStore(this); directPagingStore = new DirectPagingStore(this); @@ -111,11 +107,6 @@ export class RootBaseStore { makeObservable(this); } - @action.bound - setIsPluginInitialized(value: boolean) { - this.isPluginInitialized = value; - } - @action.bound loadBasicData = async () => { const updateFeatures = async () => { @@ -190,32 +181,4 @@ export class RootBaseStore { 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/types.ts b/grafana-plugin/src/types.ts index 7967c3c2cc..05d82fef92 100644 --- a/grafana-plugin/src/types.ts +++ b/grafana-plugin/src/types.ts @@ -5,7 +5,6 @@ export type OnCallPluginMetaJSONData = { orgId: number; onCallApiUrl: string; insightsDatasource?: string; - license: string; }; export type OnCallPluginMetaSecureJSONData = { @@ -13,7 +12,7 @@ export type OnCallPluginMetaSecureJSONData = { onCallApiToken: string; }; -export type AppRootProps = BaseAppRootProps & { forceReinstall?: boolean }; +export type AppRootProps = BaseAppRootProps; // NOTE: it is possible that plugin.meta.jsonData is null (ex. on first-ever setup) // the typing on AppPluginMeta does not seem correct atm.. diff --git a/grafana-plugin/src/utils/hooks.tsx b/grafana-plugin/src/utils/hooks.tsx index 754993f1b6..2ab708b94e 100644 --- a/grafana-plugin/src/utils/hooks.tsx +++ b/grafana-plugin/src/utils/hooks.tsx @@ -145,12 +145,12 @@ 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 isInitialized = rootStore.pluginStore.isPluginInitialized; const isPluginInitializing = rootStore.loaderStore.isLoading(ActionKey.INITIALIZE_PLUGIN); useOnMount(() => { if (!isInitialized && !isPluginInitializing) { - rootStore.initializePlugin(); + rootStore.pluginStore.initializePlugin(); } }); diff --git a/grafana-plugin/src/utils/string.ts b/grafana-plugin/src/utils/string.ts index 212b36b36a..89e7cd2100 100644 --- a/grafana-plugin/src/utils/string.ts +++ b/grafana-plugin/src/utils/string.ts @@ -1,3 +1,5 @@ +import { parseURL } from './url'; + // Truncate a string to a given maximum length, adding ellipsis if it was truncated. export function truncateTitle(title: string, length: number): string { if (title.length <= length) { @@ -25,3 +27,7 @@ export const safeJSONStringify = (value: unknown) => { }; export const VALID_URL_PATTERN = /(http|https)\:\/\/.+?\..+/; + +export function validateURL(urlFieldValue: string): string | boolean { + return !parseURL(urlFieldValue) ? 'URL is invalid' : true; +} From d3add2040b71153559c66a7dfb8b8ec2741859bf Mon Sep 17 00:00:00 2001 From: Dominik Date: Thu, 6 Jun 2024 15:49:26 +0200 Subject: [PATCH 13/13] update --- .../grafana-oncall-app-provisioning.yaml | 1 + .../initialization.test.ts | 35 ------------------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml b/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml index 47b8e1d7ee..c2a5f7048e 100644 --- a/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml +++ b/dev/grafana/provisioning/plugins/grafana-oncall-app-provisioning.yaml @@ -5,4 +5,5 @@ apps: jsonData: stackId: 5 orgId: 100 + license: OpenSource onCallApiUrl: http://oncall-dev-engine:8080 diff --git a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts index 68263442b5..c0f95d0ec8 100644 --- a/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts +++ b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts @@ -1,38 +1,3 @@ -/* -Scenario: User reconfigures plugin successfully -Given user goes to plugin configuration page -When user enters valid URL of OnCall Engine -And OnCall Engine is up and running -And user clicks “Test & Save” -Then user is informed about successful test -And new configuration is saved - -Scenario: User reconfigures plugin unsuccessfully -Given user goes to plugin configuration page -When user enters URL of OnCall Engine -And OnCall Engine is down or wrong URL is provided -And user clicks “Test & Save” -Then user is informed about unsuccessful test -And new configuration is not saved - -Scenario: New viewer user goes to OnCall -Given OSS viewer user has just been created -When viewer user goes to OnCall page -Then OnCall loads as usual - -Scenario: New viewer user goes to OnCall extension -Given OSS viewer user has been just created -When viewer user goes directly to OnCall plugin extension registered in Grafana -Then user sees loading page saying that his/her OnCall data is being synchronized right now -And once sync process is completed user is informed that his/her data has been synced -And OnCall extension is loaded so that user can proceed - -Scenario: Existing user goes to OnCall page or extension -Given OSS user that has already used OnCall before -When user goes to OnCall -Then OnCall loads as usual -*/ - import { test, expect } from '../fixtures'; import { OrgRole } from '../utils/constants'; import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation';