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/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 new file mode 100644 index 0000000000..c0f95d0ec8 --- /dev/null +++ b/grafana-plugin/e2e-tests/pluginInitialization/initialization.test.ts @@ -0,0 +1,67 @@ +import { test, expect } from '../fixtures'; +import { OrgRole } from '../utils/constants'; +import { goToGrafanaPage, goToOnCallPage } from '../utils/navigation'; +import { createGrafanaUser, loginAndWaitTillGrafanaIsLoaded } from '../utils/users'; + +test.describe('Plugin initialization', () => { + 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 }); + + // 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[] = []; + viewerUserPage.on('requestfinished', async (request) => + networkResponseStatuses.push((await request.response()).status()) + ); + + // Go to OnCall and assert that none of the requests failed + 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 + }); + + 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'); + + // 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[] = []; + 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(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 = 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/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/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/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(); }; /** diff --git a/grafana-plugin/e2e-tests/utils/users.ts b/grafana-plugin/e2e-tests/utils/users.ts index e7c5d15dff..996b4a6e7a 100644 --- a/grafana-plugin/e2e-tests/utils/users.ts +++ b/grafana-plugin/e2e-tests/utils/users.ts @@ -1,6 +1,8 @@ import { Page, expect } from '@playwright/test'; -import { goToOnCallPage } from './navigation'; +import { OrgRole } from './constants'; +import { clickButton } from './forms'; +import { goToGrafanaPage, goToOnCallPage } from './navigation'; export async function accessProfileTabs(page: Page, tabs: string[], hasAccess: boolean) { await goToOnCallPage(page, 'users'); @@ -30,16 +32,55 @@ 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/ ); } } + +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 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' }); + + 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 701c96b1ce..516065cfa6 100644 --- a/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx +++ b/grafana-plugin/src/containers/MobileAppConnection/MobileAppConnection.tsx @@ -14,6 +14,7 @@ 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 } from 'utils/hooks'; import { isMobile, openErrorNotification, openNotification, openWarningNotification } from 'utils/utils'; import styles from './MobileAppConnection.module.scss'; @@ -364,10 +365,13 @@ function QRLoading() { export const MobileAppConnectionWrapper: React.FC<{}> = observer(() => { const { userStore } = store; + const { isInitialized } = useInitializePlugin(); useEffect(() => { - loadData(); - }, []); + if (isInitialized) { + loadData(); + } + }, [isInitialized]); const loadData = async () => { if (!store.isBasicDataLoaded) { @@ -379,7 +383,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/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/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/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/plugin/GrafanaPluginRootPage.tsx b/grafana-plugin/src/plugin/GrafanaPluginRootPage.tsx index 57b8a5796b..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(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..26dfa5be4e 100644 --- a/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts +++ b/grafana-plugin/src/state/rootBaseStore/RootBaseStore.ts @@ -20,6 +20,7 @@ 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'; @@ -49,9 +50,6 @@ export class RootBaseStore { @observable recaptchaSiteKey = ''; - @observable - initializationError = ''; - @observable currentlyUndergoingMaintenance = false; @@ -76,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); @@ -107,6 +106,7 @@ export class RootBaseStore { constructor() { makeObservable(this); } + @action.bound loadBasicData = async () => { const updateFeatures = async () => { @@ -177,16 +177,6 @@ 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; diff --git a/grafana-plugin/src/types.ts b/grafana-plugin/src/types.ts index 7bf6c041c0..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 = { 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 b3b8d1b3bb..2ab708b94e 100644 --- a/grafana-plugin/src/utils/hooks.tsx +++ b/grafana-plugin/src/utils/hooks.tsx @@ -2,15 +2,13 @@ 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 { GRAFANA_LICENSE_OSS } from './consts'; import { getCommonStyles } from './styles'; export function useForceUpdate() { @@ -142,36 +140,18 @@ export const useOnMount = (callback: () => void) => { }, []); }; -export const useInitializePlugin = ({ meta }: AppRootProps) => { - const IS_OPEN_SOURCE = meta?.jsonData?.license === GRAFANA_LICENSE_OSS; - 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 (!meta?.secureJsonFields?.onCallApiToken) { - await install(); - } - - // trigger users sync - try { - await makeRequest(`/plugin/status`, { - method: 'POST', - }); - } catch (_err) { - 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.pluginStore.isPluginInitialized; + const isPluginInitializing = rootStore.loaderStore.isLoading(ActionKey.INITIALIZE_PLUGIN); useOnMount(() => { - initializePlugin(); + if (!isInitialized && !isPluginInitializing) { + rootStore.pluginStore.initializePlugin(); + } }); return { isInitialized }; 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; +} diff --git a/grafana-plugin/src/utils/utils.ts b/grafana-plugin/src/utils/utils.ts index 99846408d6..b559ff2b38 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 @@ -8,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; @@ -118,3 +121,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 = () => !CLOUD_VERSION_REGEX.test(config.apps[PLUGIN_ID]?.version);