diff --git a/src/components/AccessToken.tsx b/src/components/AccessToken.tsx deleted file mode 100644 index 9b39b7cb9..000000000 --- a/src/components/AccessToken.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Alert, Button, Modal, useStyles2 } from '@grafana/ui'; -import { css } from '@emotion/css'; - -import { FaroEvent, reportError, reportEvent } from 'faro'; -import { useSMDS } from 'hooks/useSMDS'; -import { Clipboard } from 'components/Clipboard'; - -const getStyles = (theme: GrafanaTheme2) => ({ - vericalSpace: css` - margin-top: 10px; - `, -}); - -export const AccessToken = () => { - const smDS = useSMDS(); - const [showModal, setShowModal] = useState(false); - const [error, setError] = useState(); - const [token, setToken] = useState(); - const styles = useStyles2(getStyles); - - const showTokenModal = async () => { - try { - reportEvent(FaroEvent.CREATE_ACCESS_TOKEN); - const token = await smDS.createApiToken(); - setToken(token); - setShowModal(true); - } catch (e) { - const cast = e as Error; - reportError(cast, FaroEvent.CREATE_ACCESS_TOKEN); - setError(cast.message ?? 'There was an error creating a new access token'); - } - }; - return ( -
-
Access tokens
-
-
- You can use an SM access token to authenticate with the synthetic monitoring api. Check out the{' '} - - Synthetic Monitoring API Go client - {' '} - or the{' '} - - Grafana Terraform Provider - {' '} - documentation to learn more about how to interact with the synthetic monitoring API. -
- -
- setShowModal(false)}> - <> - {error && } - {token && } - - -
- ); -}; diff --git a/src/page/AppInitializer.tsx b/src/components/AppInitializer.tsx similarity index 94% rename from src/page/AppInitializer.tsx rename to src/components/AppInitializer.tsx index 59a02f041..3c8a46f43 100644 --- a/src/page/AppInitializer.tsx +++ b/src/components/AppInitializer.tsx @@ -2,6 +2,7 @@ import React, { PropsWithChildren } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Alert, Button, Spinner, useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; +import { DataTestIds } from 'test/dataTestIds'; import { ROUTES } from 'types'; import { hasGlobalPermission } from 'utils'; @@ -14,6 +15,7 @@ interface Props { buttonText: string; } +// TODO: Does this really belong under /page? export const AppInitializer = ({ redirectTo, buttonText }: PropsWithChildren) => { const { jsonData } = useMeta(); const styles = useStyles2(getStyles); @@ -42,7 +44,7 @@ export const AppInitializer = ({ redirectTo, buttonText }: PropsWithChildren +
diff --git a/src/components/BackendAddress/BackendAddress.tsx b/src/components/BackendAddress/BackendAddress.tsx deleted file mode 100644 index 414cd2230..000000000 --- a/src/components/BackendAddress/BackendAddress.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; - -import { useSMDS } from 'hooks/useSMDS'; - -type BackendAddressProps = { - omitHttp?: boolean; - text?: string; -}; - -export const BackendAddress = ({ omitHttp }: BackendAddressProps) => { - const smDS = useSMDS(); - const backendAddress = smDS.instanceSettings.jsonData.apiHost || ``; - const display = omitHttp ? backendAddress.replace('https://', '') : backendAddress; - - return ( - <> -
Backend address
-

- The agent will need to connect to the instance of the Synthetics API that corresponds with the region of your - stack. This is the backend address for your stack: -

-
{display}
- - ); -}; diff --git a/src/components/BackendAddress/index.ts b/src/components/BackendAddress/index.ts deleted file mode 100644 index 6c9a30302..000000000 --- a/src/components/BackendAddress/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BackendAddress } from './BackendAddress'; diff --git a/src/components/Clipboard/Clipboard.tsx b/src/components/Clipboard/Clipboard.tsx index e358f6bbd..359baba2d 100644 --- a/src/components/Clipboard/Clipboard.tsx +++ b/src/components/Clipboard/Clipboard.tsx @@ -1,18 +1,13 @@ import React from 'react'; import { AppEvents } from '@grafana/data'; -import { useStyles } from '@grafana/ui'; +import { useStyles2 } from '@grafana/ui'; import appEvents from 'grafana/app/core/app_events'; import { css, cx } from '@emotion/css'; +import { Preformatted } from '../Preformatted'; import { CopyToClipboard } from './CopyToClipboard'; const getStyles = () => ({ - code: css` - width: 100%; - word-break: break-all; - overflow-y: scroll; - max-height: 100%; - `, container: css` display: flex; flex-direction: column; @@ -26,16 +21,18 @@ interface Props { content: string; className?: string; truncate?: boolean; + highlight?: string | string[]; + isCode?: boolean; } -export function Clipboard({ content, className, truncate }: Props) { - const styles = useStyles(getStyles); +export function Clipboard({ content, className, truncate, highlight, isCode }: Props) { + const styles = useStyles2(getStyles); return (
-
+      
         {truncate ? content.slice(0, 150) + '...' : content}
-      
+ >) => { - return ( - - - - - - - - ); -}; diff --git a/src/components/LinkedDatasourceView.tsx b/src/components/LinkedDatasourceView.tsx index fc2648d8e..e9b32b381 100644 --- a/src/components/LinkedDatasourceView.tsx +++ b/src/components/LinkedDatasourceView.tsx @@ -1,45 +1,62 @@ import React from 'react'; +import { Alert, Card, Tag } from '@grafana/ui'; -import { useCanWriteLogs, useCanWriteMetrics } from 'hooks/useDSPermission'; +import { useCanWriteLogs, useCanWriteMetrics, useCanWriteSM } from 'hooks/useDSPermission'; import { useLogsDS } from 'hooks/useLogsDS'; import { useMetricsDS } from 'hooks/useMetricsDS'; +import { useSMDS } from 'hooks/useSMDS'; interface LinkedDatasourceViewProps { - type: 'loki' | 'prometheus'; + type: 'loki' | 'prometheus' | 'synthetic-monitoring-datasource'; } export const LinkedDatasourceView = ({ type }: LinkedDatasourceViewProps) => { const metricsDS = useMetricsDS(); const logsDS = useLogsDS(); + const smDS = useSMDS(); + + const canEditSM = useCanWriteSM(); const canEditLogs = useCanWriteLogs(); const canEditMetrics = useCanWriteMetrics(); const canEditMap = { prometheus: canEditMetrics, loki: canEditLogs, + 'synthetic-monitoring-datasource': canEditSM, }; const dsMap = { prometheus: metricsDS, loki: logsDS, + 'synthetic-monitoring-datasource': smDS, }; const ds = dsMap[type]; if (!ds) { - return null; + return ( + + "{type}" data source is missing. Please configure it in the data sources settings. + + ); } const showHref = canEditMap[type]; - const Tag = showHref ? 'a' : 'div'; return ( - - -
- {ds.name} - {ds.type} -
-
+ + {ds.name} + + + + + {type !== 'synthetic-monitoring-datasource' && ( + + + + )} + + {ds.type} + ); }; diff --git a/src/components/Preformatted.test.tsx b/src/components/Preformatted.test.tsx new file mode 100644 index 000000000..a9462632f --- /dev/null +++ b/src/components/Preformatted.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; + +import { Preformatted } from './Preformatted'; + +describe('Preformatted', () => { + it('should render content within a pre tag', async () => { + const sampleContent = '__sample_content__'; + render({sampleContent}); + const pre = screen.queryByText(sampleContent); + + expect(pre).toBeInTheDocument(); + }); + + it('should render content within a pre tag, within a code tag', async () => { + const sampleContent = '__sample_content__'; + render({sampleContent}); + const pre = screen.queryByText(sampleContent, { selector: 'code' }); + + expect(pre).toBeInTheDocument(); + }); + + it.each([ + { + highlight: '__sample__', + content: `This is a __sample__ content.`, // Note: 'This' is used to find the pre tag + expectedLength: 1, + }, + { + highlight: '__target__', + content: `This should highlight __target__ and __target__`, // Note: 'This' is used to find the pre tag + expectedLength: 2, + }, + ])( + 'should take string as highlight prop ($expectedLength counts)', + async ({ highlight, content, expectedLength }) => { + render({content}); + + const pre = screen.getByText(/^This/, { selector: 'pre' }); + expect(pre).toBeInTheDocument(); + const highlighted = pre && within(pre).queryAllByText(highlight, { selector: 'strong' }); + expect(highlighted).toHaveLength(expectedLength); + } + ); + + it.each([ + { + highlight: ['__sample__'], + content: `This should replace __sample__`, // Note: 'This' is used to find the pre tag + expectedLength: 1, + }, + { + highlight: ['__sample__', '__target__'], + content: `This should replace __sample__ and __target__`, // Note: 'This' is used to find the pre tag + expectedLength: 2, + }, + { + highlight: ['__sample__', '__target__', '__another__'], + content: `This should replace __sample__, __another__ and __target__`, // Note: 'This' is used to find the pre tag + expectedLength: 3, + }, + ])('should take array as highlight prop ($expectedLength counts)', async ({ highlight, content, expectedLength }) => { + render({content}); + + const pre = screen.getByText(/^This/, { selector: 'pre' }); + expect(pre).toBeInTheDocument(); + + const count = highlight.reduce((acc, word) => { + const match = pre && within(pre).queryAllByText(word, { selector: 'strong' }); + return acc + match.length; + }, 0); + + expect(count).toBe(expectedLength); + }); +}); diff --git a/src/components/Preformatted.tsx b/src/components/Preformatted.tsx new file mode 100644 index 000000000..c71e1feb7 --- /dev/null +++ b/src/components/Preformatted.tsx @@ -0,0 +1,66 @@ +import React, { Children, Fragment, PropsWithChildren, ReactNode } from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { css, cx } from '@emotion/css'; +import { DataTestIds } from 'test/dataTestIds'; + +function highlightCode(children: ReactNode, highlight?: string): ReactNode { + if (!highlight) { + return children; + } + const elements = Children.toArray(children); + return elements.map((child) => { + if (typeof child === 'string') { + return child + .split(highlight) + .flatMap((item, index) => [item, {highlight}]) + .slice(0, -1); + } + return child; + }); +} + +function doHighlights(children: ReactNode, highlights?: string | string[]): ReactNode { + const highlightsArray = Array.isArray(highlights) ? highlights : [highlights]; + + return highlights + ? highlightsArray.reduce((acc, currentValue, currentIndex) => { + return highlightCode(acc, currentValue); + }, children) + : children; +} + +export function Preformatted({ + children, + className, + highlight, + isCode = false, +}: PropsWithChildren<{ className?: string; highlight?: string | string[]; isCode?: boolean }>) { + const styles = useStyles2(getStyles); + const Wrapper = isCode ? 'code' : Fragment; + + return ( +
+      {doHighlights(children, highlight)}
+    
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + overflowY: 'auto', + maxHeight: '100%', + whiteSpace: 'pre-wrap', + backgroundColor: theme.colors.background.canvas, + marginBottom: theme.spacing(2), + '& strong': { + color: theme.colors.warning.text, + }, + '& code': { + padding: 0, + margin: 0, + }, + }), + }; +} diff --git a/src/components/ProbeTokenModal/ProbeTokenModal.tsx b/src/components/ProbeTokenModal/ProbeTokenModal.tsx index 8aca1cb8b..68faab688 100644 --- a/src/components/ProbeTokenModal/ProbeTokenModal.tsx +++ b/src/components/ProbeTokenModal/ProbeTokenModal.tsx @@ -13,7 +13,7 @@ type TokenModalProps = { export const ProbeTokenModal = ({ actionText, isOpen, onDismiss, token }: TokenModalProps) => { return ( - + This is the only time you will see this token. If you need to view it again, you will need to reset the token. diff --git a/src/components/ProgrammaticManagement.tsx b/src/components/ProgrammaticManagement.tsx deleted file mode 100644 index 136e1171c..000000000 --- a/src/components/ProgrammaticManagement.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; - -import { useCanWriteSM } from 'hooks/useDSPermission'; -import { useInitialised } from 'hooks/useInitialised'; -import { AccessToken } from 'components/AccessToken'; -import { TerraformConfig } from 'components/TerraformConfig'; - -export const ProgrammaticManagement = () => { - const initialised = useInitialised(); - const canCreateAccessToken = useCanWriteSM(); - - return ( -
-

Programmatic management

- {initialised && canCreateAccessToken && } -
- -
- ); -}; diff --git a/src/components/Routing.test.tsx b/src/components/Routing.test.tsx index 509615cc0..6b131cd4c 100644 --- a/src/components/Routing.test.tsx +++ b/src/components/Routing.test.tsx @@ -20,6 +20,7 @@ function renderUninitialisedRouting(options?: CustomRenderOptions) { // Mocking these pages because they renders scenes, which makes jest explode jest.mock('page/DashboardPage'); jest.mock('page/SceneHomepage'); + const notaRoute = `${PLUGIN_URL_PATH}/404`; describe('Renders specific welcome pages when app is not initializd', () => { @@ -56,7 +57,8 @@ describe('Renders specific welcome pages when app is not initializd', () => { test(`Route Config`, async () => { renderUninitialisedRouting({ path: getRoute(ROUTES.Config) }); - const text = await screen.findByText(/Plugin version:/); + + const text = await screen.findByText('Synthetic Monitoring is not yet initialized'); expect(text).toBeInTheDocument(); }); diff --git a/src/components/Routing.tsx b/src/components/Routing.tsx index 9186b115c..1734bb671 100644 --- a/src/components/Routing.tsx +++ b/src/components/Routing.tsx @@ -12,7 +12,11 @@ import { useQuery } from 'hooks/useQuery'; import { AlertingPage } from 'page/AlertingPage'; import { AlertingWelcomePage } from 'page/AlertingWelcomePage'; import { ChecksWelcomePage } from 'page/ChecksWelcomePage'; -import { ConfigPage } from 'page/ConfigPage'; +import { ConfigPageLayout } from 'page/ConfigPageLayout'; +import { AccessTokensTab } from 'page/ConfigPageLayout/tabs/AccessTokensTab'; +import { GeneralTab } from 'page/ConfigPageLayout/tabs/GeneralTab'; +import { TerraformTab } from 'page/ConfigPageLayout/tabs/TerraformTab'; +import { UninitializedTab } from 'page/ConfigPageLayout/tabs/UninitializedTab'; import { DashboardPage } from 'page/DashboardPage'; import { EditCheck } from 'page/EditCheck'; import { EditProbe } from 'page/EditProbe'; @@ -100,7 +104,11 @@ export const InitialisedRouter = () => { } /> - } /> + + } /> + } /> + } /> + } /> @@ -136,7 +144,10 @@ export const UninitialisedRouter = () => { } /> } /> } /> - } /> + + } /> + } /> + {/* TODO: Create 404 instead of navigating to home(?) */} } /> diff --git a/src/components/TerraformConfig/TerraformConfig.test.tsx b/src/components/TerraformConfig/TerraformConfig.test.tsx deleted file mode 100644 index 462d6428e..000000000 --- a/src/components/TerraformConfig/TerraformConfig.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import { screen, waitFor } from '@testing-library/react'; -import { BASIC_PING_CHECK } from 'test/fixtures/checks'; -import { TERRAFORM_BASIC_PING_CHECK } from 'test/fixtures/terraform'; -import { apiRoute } from 'test/handlers'; -import { render } from 'test/render'; -import { server } from 'test/server'; - -import { TerraformConfig } from './TerraformConfig'; - -const renderTerraformConfig = async () => { - server.use( - apiRoute('listChecks', { - result: () => { - return { - json: [BASIC_PING_CHECK], - }; - }, - }) - ); - - return waitFor(() => render()); -}; - -const openConfig = async () => { - const { user } = await renderTerraformConfig(); - const launchButton = await screen.findByText('Generate config'); - await user.click(launchButton); - const modalHeader = await screen.findByText('Terraform config'); - expect(modalHeader).toBeInTheDocument(); -}; - -it('renders without crashing', async () => { - await openConfig(); -}); - -it('displays correct config', async () => { - await openConfig(); - const config = await screen.findAllByTestId('clipboard-content'); - - if (!config[0].textContent) { - throw new Error('config has no content'); - } - - expect(JSON.parse(config[0].textContent)).toEqual(TERRAFORM_BASIC_PING_CHECK); -}); diff --git a/src/components/TerraformConfig/TerraformConfig.tsx b/src/components/TerraformConfig/TerraformConfig.tsx deleted file mode 100644 index a38573575..000000000 --- a/src/components/TerraformConfig/TerraformConfig.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Alert, Button, Modal, TextLink, useStyles2 } from '@grafana/ui'; -import { css } from '@emotion/css'; - -import { FaroEvent, reportEvent } from 'faro'; -import { useMeta } from 'hooks/useMeta'; -import { useSMDS } from 'hooks/useSMDS'; -import { useTerraformConfig } from 'hooks/useTerraformConfig'; -import { Clipboard } from 'components/Clipboard'; -import { QueryErrorBoundary } from 'components/QueryErrorBoundary'; - -const getStyles = (theme: GrafanaTheme2) => ({ - modal: css` - max-height: 100%; - `, - clipboard: css` - max-height: 500px; - margin-top: 10px; - margin-bottom: 10px; - `, - text: css` - max-width: 600px; - `, - vericalSpace: css` - margin-top: 10px; - margin-bottom: 10px; - `, -}); - -export const TerraformConfig = () => { - const styles = useStyles2(getStyles); - const { enabled } = useMeta(); - const smDS = useSMDS(); - const initialized = enabled && smDS; - - return ( -
-
Terraform
-
- You can manage synthetic monitoring checks via Terraform. Export your current checks as config - {!initialized && ( -

- Note: You need to initialize the plugin before importing this configuration. -

- )} -
- - - Generate config - - } - > - - -
- ); -}; - -const GenerateButton = () => { - const { config, checkCommands, probeCommands, error } = useTerraformConfig(); - const [showModal, setShowModal] = useState(false); - const styles = useStyles2(getStyles); - const { enabled } = useMeta(); - const smDS = useSMDS(); - const initialized = enabled && smDS; - - return ( - <> - - setShowModal(false)} - contentClassName={styles.modal} - > - {initialized && error && } - {config && (checkCommands || probeCommands) && ( - <> - - The exported config is using{' '} - Terraform JSON syntax. You can place - this config in a file with a tf.json extension and import as a module. See the{' '} - - Terraform provider docs - {' '} - for more details. - -
tf.json
- - {initialized && checkCommands && ( - <> -
Import existing checks into Terraform
- - - )} - {initialized && probeCommands && ( - <> -
Import custom probes into Terraform
- - - )} - - )} -
- - ); -}; diff --git a/src/components/TerraformConfig/index.ts b/src/components/TerraformConfig/index.ts deleted file mode 100644 index 401ea9f30..000000000 --- a/src/components/TerraformConfig/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TerraformConfig } from './TerraformConfig'; diff --git a/src/configPage/PluginConfigPage/PluginConfigPage.hooks.ts b/src/configPage/PluginConfigPage/PluginConfigPage.hooks.ts new file mode 100644 index 000000000..428e403a4 --- /dev/null +++ b/src/configPage/PluginConfigPage/PluginConfigPage.hooks.ts @@ -0,0 +1,76 @@ +import { useEffect, useMemo, useState } from 'react'; +import { config } from '@grafana/runtime'; + +import type { SMDataSource } from 'datasource/DataSource'; + +import { getDataSource } from './PluginConfigPage.utils'; + +export function useDatasource(): [SMDataSource | undefined, boolean] { + const [datasource, setDatasource] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getDataSource().then((ds) => { + setDatasource(ds ?? undefined); + setIsLoading(false); + }); + }, []); + + return useMemo(() => [datasource, isLoading], [datasource, isLoading]); +} + +export interface DataSourceInfo { + name: string; + url: string; + logo: string; + type: string; +} + +interface DataSourceInfoWithDataSource extends DataSourceInfo { + dataSource: SMDataSource; +} + +type DataSourceInfoList = [DataSourceInfoWithDataSource, ...DataSourceInfo[]] | []; + +export function useLinkedDataSources(): { + api: DataSourceInfoList[0]; + linked: DataSourceInfo[]; + isLoading: boolean; +} { + const [smDatasource, isLoading] = useDatasource(); + const [list, setList] = useState([]); + + useEffect(() => { + if (smDatasource) { + const { metrics, logs } = smDatasource.instanceSettings.jsonData; + const linkedDataSources = Object.values(config.datasources) + .filter((ds) => { + return ds?.uid && [metrics?.uid, logs?.uid].includes(ds.uid); + }) + .map((ds) => { + return { + name: ds.name, + type: ds.type, + url: `/datasources/edit/${ds.uid}/`, + logo: ds.meta.info.logos.small, + } as DataSourceInfo; + }); + + setList([ + { + name: smDatasource.name, + type: smDatasource.type, + url: `/datasources/edit/${smDatasource.uid}/`, + logo: smDatasource.meta.info.logos.small, + dataSource: smDatasource, + }, + ...linkedDataSources, + ]); + } + }, [smDatasource]); + + return useMemo(() => { + const [api, ...linked] = list; + return { api, linked, isLoading }; + }, [list, isLoading]); +} diff --git a/src/configPage/PluginConfigPage/PluginConfigPage.test.tsx b/src/configPage/PluginConfigPage/PluginConfigPage.test.tsx new file mode 100644 index 000000000..1b197b461 --- /dev/null +++ b/src/configPage/PluginConfigPage/PluginConfigPage.test.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { CompatRouter, Route, Routes } from 'react-router-dom-v5-compat'; +import { render, screen, waitFor, within } from '@testing-library/react'; + +import { SMDataSource } from '../../datasource/DataSource'; +import { DataTestIds } from '../../test/dataTestIds'; +import { SM_DATASOURCE } from '../../test/fixtures/datasources'; +import { PluginConfigPage } from './PluginConfigPage'; +import { getDataSource } from './PluginConfigPage.utils'; +jest.mock('./PluginConfigPage.utils'); + +function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + ); +} + +async function renderPluginConfigPage(plugin: any) { + render(, { wrapper: Wrapper }); + await waitFor(() => screen.getByTestId(DataTestIds.TEST_PLUGIN_CONFIG_PAGE), { timeout: 3000 }); +} + +beforeEach(() => { + (getDataSource as jest.Mock).mockImplementation(() => Promise.resolve(new SMDataSource(SM_DATASOURCE))); +}); + +describe('PluginConfigPage', () => { + describe('app not initialized', () => { + it('should show initialization required alert', async () => { + (getDataSource as jest.Mock).mockReturnValue(Promise.resolve(undefined)); + + await renderPluginConfigPage({ + meta: { + enabled: true, + }, + }); + + const alert = screen.queryByTestId('data-testid Alert info'); + const alertTitle = alert && within(alert).getByText('Initialization required'); + const alertLink = alert && within(alert).getByRole('link', { name: 'Synthetic Monitoring app' }); + const goToAppButton = screen.queryByRole('link', { name: 'Go to the Synthetic Monitoring app' }); + + expect(alertTitle).toBeInTheDocument(); + expect(alertLink).toBeInTheDocument(); + expect(goToAppButton).toBeInTheDocument(); + }); + }); + + describe('plugin enabled', () => { + const plugin = { + meta: { + enabled: true, + }, + }; + + it('should show app home alert', async () => { + await renderPluginConfigPage(plugin); + + const appHomeAlert = screen.queryByText(/^Are you looking to configure Synthetic Monitoring\?/i); + expect(appHomeAlert).toBeInTheDocument(); + }); + + it('should show app config text with link to config page', async () => { + await renderPluginConfigPage(plugin); + + const appConfigText = screen.queryByText(/^For app configuration and settings, go to the/); + const appConfigLink = appConfigText && within(appConfigText).getByRole('link', { name: 'config page' }); + + expect(appConfigText).toBeInTheDocument(); + expect(appConfigLink).toBeInTheDocument(); + }); + + it('should show goto app button', async () => { + await renderPluginConfigPage(plugin); + + // It's a `LinkButton` component + const goToAppButton = screen.queryByRole('link', { name: 'Go to the Synthetic Monitoring app' }); + expect(goToAppButton).toBeInTheDocument(); + }); + + it('should NOT show enable button', async () => { + await renderPluginConfigPage(plugin); + + // It's a `LinkButton` component + const goToAppButton = screen.queryByRole('button', { name: 'Enable Synthetic Monitoring' }); + expect(goToAppButton).not.toBeInTheDocument(); + }); + }); + + describe('plugin disabled', () => { + const plugin = { + meta: { + enabled: false, + }, + }; + it('should NOT show app home alert', async () => { + await renderPluginConfigPage(plugin); + + const appHomeAlert = screen.queryByText(/^Are you looking to configure Synthetic Monitoring\?/i); + expect(appHomeAlert).not.toBeInTheDocument(); + }); + + it('should NOT show app config text with link to config page', async () => { + await renderPluginConfigPage(plugin); + + const appConfigText = screen.queryByText(/^For app configuration and settings, go to the/); + expect(appConfigText).not.toBeInTheDocument(); + }); + + it('should NOT show goto app button', async () => { + await renderPluginConfigPage(plugin); + + // It's a `LinkButton` component + const goToAppButton = screen.queryByRole('link', { name: 'Go to the Synthetic Monitoring app' }); + expect(goToAppButton).not.toBeInTheDocument(); + }); + + it('should show enable button', async () => { + await renderPluginConfigPage(plugin); + + // It's a `LinkButton` component + const goToAppButton = screen.queryByRole('button', { name: 'Enable Synthetic Monitoring' }); + expect(goToAppButton).toBeInTheDocument(); + }); + }); + + describe('data sources', () => { + it('should show data source', async () => { + await renderPluginConfigPage({ + meta: { + enabled: true, + }, + }); + + const dataSource = screen.queryByTestId(DataTestIds.TEST_PLUGIN_CONFIG_PAGE_LINKED_DATASOURCES); + const dataSourceName = dataSource && within(dataSource).getByText(SM_DATASOURCE.type); + + expect(dataSource).toBeInTheDocument(); + expect(dataSourceName).toBeInTheDocument(); + }); + + it('should show linked data sources', async () => { + await renderPluginConfigPage({ + meta: { + enabled: true, + }, + }); + + const linkedDataSources = screen.queryByTestId(DataTestIds.TEST_PLUGIN_CONFIG_PAGE_LINKED_DATASOURCES); + const linkedDataSourcesCount = + linkedDataSources && + within(linkedDataSources).getByText(/Linked data sources \(\W?2\W?\)/i, { selector: 'h3' }); + + expect(linkedDataSources).toBeInTheDocument(); + expect(linkedDataSourcesCount).toBeInTheDocument(); + }); + + it('should indicate missing data source', async () => { + (getDataSource as jest.Mock).mockReturnValue( + Promise.resolve( + new SMDataSource({ + ...SM_DATASOURCE, + jsonData: { + ...SM_DATASOURCE.jsonData, + metrics: { + ...SM_DATASOURCE.jsonData.metrics, + uid: '__missing__', // This will result in a missing data source + }, + }, + }) + ) + ); + await renderPluginConfigPage({ + meta: { + enabled: true, + }, + }); + + const linkedDataSources = screen.queryByTestId(DataTestIds.TEST_PLUGIN_CONFIG_PAGE_LINKED_DATASOURCES); + const linkedDataSourcesCount = + linkedDataSources && + within(linkedDataSources).getByText(/Linked data sources \(\W?1\W?\)/i, { selector: 'h3' }); + const missingContainer = + linkedDataSources && + within(linkedDataSources).getByTestId(DataTestIds.TEST_PLUGIN_CONFIG_PAGE_LINKED_DATASOURCES_ERROR); + + expect(linkedDataSources).toBeInTheDocument(); + expect(linkedDataSourcesCount).toBeInTheDocument(); + expect(missingContainer).toBeInTheDocument(); + expect(missingContainer).toHaveTextContent(/Missing the following data source\(s\): prometheus/i); + }); + }); +}); diff --git a/src/configPage/PluginConfigPage/PluginConfigPage.tsx b/src/configPage/PluginConfigPage/PluginConfigPage.tsx new file mode 100644 index 000000000..4291dc109 --- /dev/null +++ b/src/configPage/PluginConfigPage/PluginConfigPage.tsx @@ -0,0 +1,173 @@ +import React, { useEffect, useState } from 'react'; +import { AppPluginMeta, GrafanaTheme2, PluginConfigPageProps } from '@grafana/data'; +import { Alert, Badge, Button, Card, Divider, LinkButton, TextLink, useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { DataTestIds } from 'test/dataTestIds'; + +import { ProvisioningJsonData, ROUTES } from 'types'; +import { hasGlobalPermission } from 'utils'; +import type { SMDataSource } from 'datasource/DataSource'; +import { getRoute } from 'components/Routing.utils'; + +import { DataSourceInfo, useLinkedDataSources } from './PluginConfigPage.hooks'; +import { enablePlugin } from './PluginConfigPage.utils'; + +function isInitialized(dataSource: SMDataSource | undefined): dataSource is SMDataSource { + return dataSource?.type === 'synthetic-monitoring-datasource'; +} + +function getMissingDataSourceTypes(list: DataSourceInfo[]): string[] { + return ['loki', 'prometheus'].filter((type) => !list.find((ds) => ds.type === type)); +} + +/** + * Plugin config page for Synthetic Monitoring + * This page is shown when the user navigates to the plugin config page (not the app config page). + * It allows the user to enable the plugin and view linked data sources. + * Note: Try to keep this component as simple as possible, and avoid adding complex logic as well as App specific logic and context. + * + * @param {PluginConfigPageProps>} plugin - The plugin metadata + * @constructor + */ +export function PluginConfigPage({ + plugin, +}: Omit>, 'query'>) { + const isEnabled = plugin.meta.enabled; + const appConfigUrl = getRoute(ROUTES.Config); + const appHomeUrl = getRoute(ROUTES.Home); + const [isEnabling, setIsEnabling] = useState(false); + const isEnableDisabled = !hasGlobalPermission(`plugins:write`); + const { api, linked, isLoading } = useLinkedDataSources(); + const initialized = isInitialized(api?.dataSource); + + const styles = useStyles2(getStyles); + + // Precautionary measure, in case the component would get new props instead of the location being reloaded + useEffect(() => { + if (isEnabled) { + setIsEnabling(false); + } + }, [isEnabled]); + + const handleEnable = async () => { + setIsEnabling(true); + await enablePlugin(plugin.meta); + }; + + if (isLoading) { + // This is more or less instant, so no need for a spinner + return null; + } + + return ( +
+ {isEnabled && initialized && ( + + Are you looking to configure Synthetic Monitoring? You can do that in the{' '} + Synthetic Monitoring app. + + )} + {!initialized && ( + + Before you can start using Synthetic Monitoring, the app needs to be initialized. You can do this in the{' '} + Synthetic Monitoring app. + + )} +

Plugin config

+ +

+ Here you can enable or disable the Synthetic Monitoring plugin and view linked data sources. +
+ {isEnabled && ( + + For app configuration and settings, go to the{' '} + config page for the Synthetic Monitoring app + + )} +

+ + {initialized && ( +
+
+

Data source

+ + {api.name} + + + + + {api.type} + + +
+ +
+

Linked data sources ({linked.length})

+ {linked.length < 2 && ( + +
+ There was an issue loading one or more linked data sources. If you are experiencing issues using + Synthetic Monitoring, seek help on the{' '} + + community site + + . +
+
+
+ Missing the following data source(s):  +
+ {getMissingDataSourceTypes(linked).map((type) => ( + + ))} +
+
+
+ )} + + {linked.map((ds) => ( + + {ds.name} + + + + + {ds.type} + + + ))} +
+
+ )} + + + + {isEnabled && Go to the Synthetic Monitoring app} + {!isEnabled && ( + + )} +
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + heading: css({ + ...theme.typography.h1, + }), + section: css({ + marginBottom: theme.spacing(4), + }), + badgeContainer: css({ + display: 'inline-flex', + gap: theme.spacing(1), + }), + }; +} diff --git a/src/configPage/PluginConfigPage/PluginConfigPage.utils.ts b/src/configPage/PluginConfigPage/PluginConfigPage.utils.ts new file mode 100644 index 000000000..d78bc144b --- /dev/null +++ b/src/configPage/PluginConfigPage/PluginConfigPage.utils.ts @@ -0,0 +1,33 @@ +import { AppPluginMeta } from '@grafana/data'; +import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; +import { firstValueFrom } from 'rxjs'; + +import { ProvisioningJsonData } from 'types'; + +import type { SMDataSource } from '../../datasource/DataSource'; + +export async function enablePlugin(meta: AppPluginMeta) { + await firstValueFrom( + getBackendSrv().fetch({ + url: `/api/plugins/${meta.id}/settings`, + method: 'POST', + data: { + enabled: true, + pinned: true, + }, + }) + ); + + window.location.reload(); +} + +export async function getDataSource() { + const datasource = getDataSourceSrv() + .getList() + .find((ds) => ds.type === 'synthetic-monitoring-datasource'); + if (!datasource?.name) { + return undefined; + } + + return (await getDataSourceSrv().get(datasource?.name)) as SMDataSource; +} diff --git a/src/configPage/PluginConfigPage/index.ts b/src/configPage/PluginConfigPage/index.ts new file mode 100644 index 000000000..8847e9cad --- /dev/null +++ b/src/configPage/PluginConfigPage/index.ts @@ -0,0 +1 @@ +export * from './PluginConfigPage'; diff --git a/src/contexts/MetaContext.tsx b/src/contexts/MetaContext.tsx index f43eac324..e45f0304e 100644 --- a/src/contexts/MetaContext.tsx +++ b/src/contexts/MetaContext.tsx @@ -3,7 +3,7 @@ import { AppPluginMeta } from '@grafana/data'; import type { ProvisioningJsonData } from 'types'; -interface VerifiedMeta extends Omit, 'jsonData'> { +export interface VerifiedMeta extends Omit, 'jsonData'> { jsonData: ProvisioningJsonData; } diff --git a/src/hooks/useAppInitializer.ts b/src/hooks/useAppInitializer.ts index 4af75706f..e09f5eaa3 100644 --- a/src/hooks/useAppInitializer.ts +++ b/src/hooks/useAppInitializer.ts @@ -83,6 +83,7 @@ function ensureNameAndUidMatch( throw new Error('Invalid provisioning. Could not find datasources'); } +// TODO: Allow for the `redirectTo` to be a string (so that we can implement "return to" behaviour after initialization) export const useAppInitializer = (redirectTo?: ROUTES) => { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); diff --git a/src/hooks/useBackendAddress.tsx b/src/hooks/useBackendAddress.tsx new file mode 100644 index 000000000..38d936bfa --- /dev/null +++ b/src/hooks/useBackendAddress.tsx @@ -0,0 +1,23 @@ +import { useMemo } from 'react'; + +import { useSMDS } from './useSMDS'; + +const BACKEND_ADDRESS_DESCRIPTION = + 'The Synthetic Monitoring Agent will need to connect to the instance of the Synthetics API that corresponds with the region of your stack.'; + +/** + * Returns the backend address of the Synthetic Monitoring instance + * This hook exists so that the address can be displayed in multiple places, with different styling. + * + * @todo Remove the `omitHttp` parameter and always display the address without the protocol? + * @param {boolean} omitHttp + */ +export function useBackendAddress(omitHttp?: boolean) { + const smDS = useSMDS(); + const backendAddress = smDS.instanceSettings.jsonData.apiHost || ``; + const display = omitHttp ? backendAddress.replace('https://', '') : backendAddress; + + return useMemo(() => { + return [display, BACKEND_ADDRESS_DESCRIPTION]; + }, [display]); +} diff --git a/src/hooks/useDSPermission.ts b/src/hooks/useDSPermission.ts index 16fe5bbe5..a011b6773 100644 --- a/src/hooks/useDSPermission.ts +++ b/src/hooks/useDSPermission.ts @@ -40,6 +40,7 @@ export function useCanReadSM() { // we've rolled this back to respect org roles // this will change when we do proper plugin RBAC in the near future +// Note: this is used by `PluginConfigPage`, which is not wrapped in any app context export function useCanWriteSM() { const orgRole = config.bootData.user.orgRole; diff --git a/src/hooks/useTerraformConfig.test.tsx b/src/hooks/useTerraformConfig.test.tsx index 5555a193e..c140e6dea 100644 --- a/src/hooks/useTerraformConfig.test.tsx +++ b/src/hooks/useTerraformConfig.test.tsx @@ -65,7 +65,7 @@ describe('terraform config generation', () => { const result = await renderTerraformHook([BASIC_PING_CHECK], [PRIVATE_PROBE]); expect(result.current.probeCommands).toEqual([ - `terraform import grafana_synthetic_monitoring_probe.${PRIVATE_PROBE.name} ${PRIVATE_PROBE.id}:`, + `terraform import grafana_synthetic_monitoring_probe.${PRIVATE_PROBE.name} ${PRIVATE_PROBE.id}:`, ]); }); @@ -73,8 +73,8 @@ describe('terraform config generation', () => { const result = await renderTerraformHook([BASIC_PING_CHECK], [PRIVATE_PROBE, UNSELECTED_PRIVATE_PROBE]); expect(result.current.probeCommands).toEqual([ - `terraform import grafana_synthetic_monitoring_probe.${PRIVATE_PROBE.name} ${PRIVATE_PROBE.id}:`, - `terraform import grafana_synthetic_monitoring_probe.${UNSELECTED_PRIVATE_PROBE.name} ${UNSELECTED_PRIVATE_PROBE.id}:`, + `terraform import grafana_synthetic_monitoring_probe.${PRIVATE_PROBE.name} ${PRIVATE_PROBE.id}:`, + `terraform import grafana_synthetic_monitoring_probe.${UNSELECTED_PRIVATE_PROBE.name} ${UNSELECTED_PRIVATE_PROBE.id}:`, ]); }); @@ -98,8 +98,8 @@ describe('terraform config generation', () => { expect(result.current.config).toEqual({ provider: { grafana: { - auth: '', - sm_access_token: '', + auth: '', + sm_access_token: '', sm_url: SM_DATASOURCE.jsonData.apiHost, url: '', }, @@ -194,8 +194,8 @@ describe('terraform config generation', () => { expect(result.current.config).toEqual({ provider: { grafana: { - auth: '', - sm_access_token: '', + auth: '', + sm_access_token: '', sm_url: SM_DATASOURCE.jsonData.apiHost, url: '', }, diff --git a/src/hooks/useTerraformConfig.ts b/src/hooks/useTerraformConfig.ts index 19c7aa409..bab284ee4 100644 --- a/src/hooks/useTerraformConfig.ts +++ b/src/hooks/useTerraformConfig.ts @@ -49,9 +49,9 @@ function generateTerraformConfig(probes: Probe[], checks: Check[], apiHost?: str provider: { grafana: { url: runtimeConfig.appUrl, - auth: '', - sm_url: apiHost ?? '', - sm_access_token: '', + auth: '', + sm_url: apiHost ?? '', + sm_access_token: '', }, }, resource: {}, @@ -72,7 +72,7 @@ function generateTerraformConfig(probes: Probe[], checks: Check[], apiHost?: str const probeCommands = Object.keys(probesConfig).map((probeName) => { const probeId = probes.find((probe) => sanitizeName(probe.name) === probeName)?.id; - return `terraform import grafana_synthetic_monitoring_probe.${probeName} ${probeId}:`; + return `terraform import grafana_synthetic_monitoring_probe.${probeName} ${probeId}:`; }); return { config, checkCommands, probeCommands }; @@ -80,11 +80,11 @@ function generateTerraformConfig(probes: Probe[], checks: Check[], apiHost?: str export function useTerraformConfig() { const smDS = useSMDS(); - const { data: probes = [], error: probesError } = useProbes(); - const { data: checks = [], error: checksError } = useChecks(); + const { data: probes = [], error: probesError, isLoading: isFetchingProbes } = useProbes(); + const { data: checks = [], error: checksError, isLoading: isFetchingChecks } = useChecks(); const apiHost = smDS.instanceSettings.jsonData?.apiHost; const generated = generateTerraformConfig(probes, checks, apiHost); const error = probesError || checksError; - - return { ...(generated ?? {}), error }; + const isLoading = isFetchingProbes || isFetchingChecks; + return { ...(generated ?? {}), error, isLoading }; } diff --git a/src/module.ts b/src/module.ts index e3cdf511e..b1faf173b 100644 --- a/src/module.ts +++ b/src/module.ts @@ -5,11 +5,12 @@ import { config } from '@grafana/runtime'; import { ProvisioningJsonData } from './types'; import { getFaroConfig } from 'faro'; import { App } from 'components/App'; -import { ConfigPageWrapper } from 'components/ConfigPageWrapper'; + +import { PluginConfigPage } from './configPage/PluginConfigPage'; const { env, url, name } = getFaroConfig(); -// faro was filling up the console with error logs and it annoyed me so I disabled it for localhost +// faro was filling up the console with error logs, and it annoyed me, so I disabled it for localhost if (window.location.hostname !== 'localhost') { initializeFaro({ url, @@ -29,6 +30,6 @@ if (window.location.hostname !== 'localhost') { export const plugin = new AppPlugin().setRootPage(App).addConfigPage({ title: 'Config', icon: 'cog', - body: ConfigPageWrapper, + body: PluginConfigPage, id: 'config', }); diff --git a/src/page/ConfigPage/ConfigPage.test.tsx b/src/page/ConfigPage/ConfigPage.test.tsx deleted file mode 100644 index f98a3a432..000000000 --- a/src/page/ConfigPage/ConfigPage.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react'; -import { LOGS_DATASOURCE, METRICS_DATASOURCE, SM_DATASOURCE } from 'test/fixtures/datasources'; -import { CREATE_ACCESS_TOKEN } from 'test/fixtures/tokens'; -import { render } from 'test/render'; -import { runTestWithoutLogsAccess, runTestWithoutMetricsAccess } from 'test/utils'; - -import { ConfigPage } from './ConfigPage'; - -describe(` uninitialised state`, () => { - it(`renders the setup button`, async () => { - render(); - - const setupButton = await screen.findByText(/Setup/i); - expect(setupButton).toBeInTheDocument(); - }); - - it(`renders the plugin version`, async () => { - render(); - - const pluginVersion = await screen.findByText(`Plugin version: %VERSION%`); - expect(pluginVersion).toBeInTheDocument(); - }); - - it(`does not render metrics datasource when the user doesn't have access`, async () => { - runTestWithoutMetricsAccess(); - - render(); - const title = await screen.findByText(`Linked Data Sources`); - - expect(title).toBeInTheDocument(); - expect(screen.queryByText(METRICS_DATASOURCE.name)).not.toBeInTheDocument(); - }); - - it(`does not render logs datasource when the user doesn't have access`, async () => { - runTestWithoutLogsAccess(); - - render(); - const title = await screen.findByText(`Linked Data Sources`); - - expect(title).toBeInTheDocument(); - expect(screen.queryByText(LOGS_DATASOURCE.name)).not.toBeInTheDocument(); - }); -}); - -describe(` initialised state`, () => { - it(`renders the linked data sources`, async () => { - render(); - const promName = await screen.findByText(METRICS_DATASOURCE.name); - - expect(promName).toBeInTheDocument(); - expect(screen.getByText(LOGS_DATASOURCE.name)).toBeInTheDocument(); - }); - - it(`renders the backend address`, async () => { - render(); - - const withoutHttps = SM_DATASOURCE.jsonData.apiHost.replace('https://', ''); - const backendAddress = await screen.findByText(withoutHttps); - expect(backendAddress).toBeInTheDocument(); - }); - - it(`renders access token generation and can generate a token`, async () => { - const { user } = render(); - - const accessTokenButton = await screen.findByText(/Generate access token/); - expect(accessTokenButton).toBeInTheDocument(); - await user.click(accessTokenButton); - - const accessToken = await screen.findByText(CREATE_ACCESS_TOKEN); - expect(accessToken).toBeInTheDocument(); - }); - - it(`renders terraform config`, async () => { - render(); - - const terraformConfigButton = await screen.findByText(/Generate config/); - expect(terraformConfigButton).toBeInTheDocument(); - }); - - it(`renders disabling the plugin`, async () => { - render(); - - const disableButton = await screen.findByText(/Disable synthetic monitoring/i); - expect(disableButton).toBeInTheDocument(); - }); - - it(`renders the plugin version`, async () => { - render(); - - const pluginVersion = await screen.findByText(`Plugin version: %VERSION%`); - expect(pluginVersion).toBeInTheDocument(); - }); -}); diff --git a/src/page/ConfigPage/ConfigPage.tsx b/src/page/ConfigPage/ConfigPage.tsx deleted file mode 100644 index 0336e0ef0..000000000 --- a/src/page/ConfigPage/ConfigPage.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; -import { PluginPage } from '@grafana/runtime'; -import { Container, useStyles2 } from '@grafana/ui'; -import { css } from '@emotion/css'; - -import { useMeta } from 'hooks/useMeta'; -import { BackendAddress } from 'components/BackendAddress'; -import { ConfigActions } from 'components/ConfigActions'; -import { LinkedDatasourceView } from 'components/LinkedDatasourceView'; -import { ProgrammaticManagement } from 'components/ProgrammaticManagement'; - -export function ConfigPage({ initialized }: { initialized?: boolean }) { - const styles = useStyles2(getStyles); - const meta = useMeta(); - - return ( - -
-
-

- Synthetic Monitoring is a blackbox monitoring solution provided as part of{' '} - - Grafana Cloud - - . If you don't already have a Grafana Cloud service,{' '} - - sign up now - - . -

-
- {initialized && ( -
-
-

Linked Data Sources

- - - - -
-
- -
-
- )} -
{initialized && }
-
-
- -
-
Plugin version: {meta.info.version}
-
-
- ); -} - -function getStyles(theme: GrafanaTheme2) { - return { - tenantConfig: css({ - marginTop: theme.spacing(4), - background: theme.colors.background.primary, - }), - paddingX: css({ - paddingLeft: theme.spacing(4), - paddingRight: theme.spacing(4), - }), - linkedDatasources: css({ - marginTop: theme.spacing(4), - }), - backendAddress: css({ - marginTop: theme.spacing(4), - }), - programmaticManagement: css({ - padding: theme.spacing(2, 0), - }), - configActions: css({ - paddingBottom: theme.spacing(2), - }), - }; -} diff --git a/src/page/ConfigPage/index.ts b/src/page/ConfigPage/index.ts deleted file mode 100644 index 58ad32d4b..000000000 --- a/src/page/ConfigPage/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ConfigPage } from './ConfigPage'; diff --git a/src/page/ConfigPageLayout/ConfigContent.tsx b/src/page/ConfigPageLayout/ConfigContent.tsx new file mode 100644 index 000000000..7361390ce --- /dev/null +++ b/src/page/ConfigPageLayout/ConfigContent.tsx @@ -0,0 +1,63 @@ +import React, { Fragment, PropsWithChildren, ReactNode } from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Box, useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { DataTestIds } from 'test/dataTestIds'; + +import { CenteredSpinner } from 'components/CenteredSpinner'; + +export interface ConfigContentProps extends PropsWithChildren { + title?: NonNullable; + loading?: boolean; +} + +export function ConfigContent({ title, children, loading = false }: ConfigContentProps) { + const styles = useStyles2(getStyles); + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {title &&

{title}

} + {children} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + display: 'flex', + flexDirection: 'column', + // Take down the size a notch without disturbing the a11y + '& > section > h2': { + ...theme.typography.h3, + }, + '& > section > h3': { + ...theme.typography.h4, + }, + '& > section > h4': { + ...theme.typography.h5, + }, + }), +}); + +ConfigContent.Section = function ConfigContentSection({ + title, + children, + className, +}: PropsWithChildren<{ title?: ReactNode; className?: string }>) { + const Container = className ? 'div' : Fragment; + + return ( + + {!!title &&

{title}

} + {children} +
+ ); +}; diff --git a/src/page/ConfigPageLayout/ConfigPageLayout.test.tsx b/src/page/ConfigPageLayout/ConfigPageLayout.test.tsx new file mode 100644 index 000000000..d6fe722c4 --- /dev/null +++ b/src/page/ConfigPageLayout/ConfigPageLayout.test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { CompatRouter, Route, Routes } from 'react-router-dom-v5-compat'; +import { render } from '@testing-library/react'; + +import { ROUTES } from '../../types'; + +import { getRoute } from '../../components/Routing.utils'; +import { DataTestIds } from '../../test/dataTestIds'; +import { ConfigPageLayout } from './ConfigPageLayout'; + +function Wrapper({ initialEntries = ['/'] }) { + return ( + + + + + index
} /> + access-tokens
} /> + terraform} /> + + + + + ); +} + +function renderPage(path = '') { + return render(); +} + +describe('ConfigPageLayout', () => { + it.each([ + ['/', 'indexRoute'], + ['/access-tokens', 'indexAccessTokens'], + ['/terraform', 'terraform'], + ])('should render (path: %s)', (path, testId) => { + const { getByTestId } = renderPage(path); + expect(getByTestId(testId)).toBeInTheDocument(); + }); + + it.each([ + ['/', 'General'], + ['/access-tokens', 'Access tokens'], + ['/terraform', 'Terraform'], + ])('should show correct active tab (path: %s)', (path, text) => { + const { getByTestId } = renderPage(path); + // Test id is generated by the text of the tab + const activeTab = getByTestId(DataTestIds.CONFIG_PAGE_LAYOUT_ACTIVE_TAB); + expect(activeTab).toBeInTheDocument(); + expect(activeTab).toHaveTextContent(text); + }); +}); diff --git a/src/page/ConfigPageLayout/ConfigPageLayout.tsx b/src/page/ConfigPageLayout/ConfigPageLayout.tsx new file mode 100644 index 000000000..ecb651524 --- /dev/null +++ b/src/page/ConfigPageLayout/ConfigPageLayout.tsx @@ -0,0 +1,66 @@ +import React, { useCallback, useMemo } from 'react'; +import { matchPath, Outlet, useLocation } from 'react-router-dom-v5-compat'; +import { NavModelItem } from '@grafana/data'; +import { PluginPage } from '@grafana/runtime'; + +import { ROUTES } from 'types'; +import { getRoute } from 'components/Routing.utils'; + +function getConfigTabUrl(tab = '/') { + return `${getRoute(ROUTES.Config)}/${tab}`.replace(/\/+/g, '/'); +} + +function useActiveTab(route: ROUTES) { + const fullRoute = getRoute(route); + const location = useLocation(); + + return useCallback( + (path?: string) => { + const url = `${fullRoute}/${path ?? ''}`.replace(/\/+/g, '/'); + return Boolean(matchPath(url ?? '', location.pathname)); + }, + [fullRoute, location.pathname] + ); +} + +export function ConfigPageLayout() { + const activeTab = useActiveTab(ROUTES.Config); + + const pageNav: NavModelItem = useMemo( + () => ({ + icon: 'sliders-v-alt', + text: 'Config', + subTitle: 'Configure your Synthetic Monitoring settings', + url: getConfigTabUrl(), + hideFromBreadcrumbs: true, // It will stack with the parent breadcrumb ('config') + + children: [ + { + icon: 'cog', + text: 'General', + url: getConfigTabUrl(), + active: activeTab(''), + }, + { + icon: 'key-skeleton-alt', + text: 'Access tokens', + url: getConfigTabUrl('access-tokens'), + active: activeTab('access-tokens'), + }, + { + icon: 'brackets-curly', + text: 'Terraform', + url: getConfigTabUrl('terraform'), + active: activeTab('terraform'), + }, + ], + }), + [activeTab] + ); + + return ( + + + + ); +} diff --git a/src/page/ConfigPageLayout/index.ts b/src/page/ConfigPageLayout/index.ts new file mode 100644 index 000000000..eb7bac92a --- /dev/null +++ b/src/page/ConfigPageLayout/index.ts @@ -0,0 +1 @@ +export { ConfigPageLayout } from './ConfigPageLayout'; diff --git a/src/page/ConfigPageLayout/tabs/AccessTokensTab.test.tsx b/src/page/ConfigPageLayout/tabs/AccessTokensTab.test.tsx new file mode 100644 index 000000000..1f629d044 --- /dev/null +++ b/src/page/ConfigPageLayout/tabs/AccessTokensTab.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { DataTestIds } from '../../../test/dataTestIds'; +import { render } from '../../../test/render'; +import { AccessTokensTab } from './AccessTokensTab'; + +async function renderAccessTokensTab() { + const result = render(); + await result.findByTestId(DataTestIds.CONFIG_CONTENT); + + return result; +} + +describe('AccessTokensTab', () => { + it('should render', async () => { + const { container } = await renderAccessTokensTab(); + expect(container).toBeInTheDocument(); + }); + + it('should render with title', async () => { + const { getByText } = await renderAccessTokensTab(); + expect(getByText('Access tokens')).toBeInTheDocument(); + }); + + it('should have a section on access tokens', async () => { + const { getByText } = await renderAccessTokensTab(); + expect(getByText('Access tokens', { selector: 'h2' })).toBeInTheDocument(); + }); + + it('should have a section on synthetic monitoring', async () => { + const { getByText, queryByText } = await renderAccessTokensTab(); + expect(getByText('Synthetic Monitoring', { selector: 'h3' })).toBeInTheDocument(); + expect(queryByText('Generate access token', { selector: 'button > span' })).toBeInTheDocument(); + }); + + it('should have a section on private probes', async () => { + const { getByText } = await renderAccessTokensTab(); + expect(getByText('Private probes', { selector: 'h3' })).toBeInTheDocument(); + }); +}); diff --git a/src/page/ConfigPageLayout/tabs/AccessTokensTab.tsx b/src/page/ConfigPageLayout/tabs/AccessTokensTab.tsx new file mode 100644 index 000000000..d6d2ed31d --- /dev/null +++ b/src/page/ConfigPageLayout/tabs/AccessTokensTab.tsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import { Alert, Button, Modal, Space, TextLink } from '@grafana/ui'; + +import { FaroEvent, reportError, reportEvent } from 'faro'; +import { useCanWriteSM } from 'hooks/useDSPermission'; +import { useSMDS } from 'hooks/useSMDS'; +import { Clipboard } from 'components/Clipboard'; + +import { ConfigContent } from '../ConfigContent'; + +export function AccessTokensTab() { + const canCreateAccessToken = useCanWriteSM(); + const smDS = useSMDS(); + const [showModal, setShowModal] = useState(false); + const [error, setError] = useState(); + const [token, setToken] = useState(); + + const showTokenModal = async () => { + try { + reportEvent(FaroEvent.CREATE_ACCESS_TOKEN); + const token = await smDS.createApiToken(); + setToken(token); + setShowModal(true); + } catch (e) { + const cast = e as Error; + reportError(cast, FaroEvent.CREATE_ACCESS_TOKEN); + setError(cast.message ?? 'There was an error creating a new access token'); + } + }; + + return ( + + + You can use an SM access token to authenticate with the synthetic monitoring api. Check out the{' '} + + Synthetic Monitoring API Go client + {' '} + or the{' '} + + Grafana Terraform Provider + {' '} + documentation to learn more about how to interact with the synthetic monitoring API. + + + + + + Each private probe has its own access token. You will only ever see the access token when you first create the + private probe, and if you "Reset access token" for an already created probe. If you need to view it + again, you will need to reset the token. + + + setShowModal(false)}> + <> + {error && } + {token && } + + + + ); +} diff --git a/src/page/ConfigPageLayout/tabs/GeneralTab.test.tsx b/src/page/ConfigPageLayout/tabs/GeneralTab.test.tsx new file mode 100644 index 000000000..2e1541716 --- /dev/null +++ b/src/page/ConfigPageLayout/tabs/GeneralTab.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +import { LinkedDatasourceView } from '../../../components/LinkedDatasourceView'; +import { DataTestIds } from '../../../test/dataTestIds'; +import { SM_DATASOURCE } from '../../../test/fixtures/datasources'; +import { render } from '../../../test/render'; +import { GeneralTab } from './GeneralTab'; + +jest.mock('../../../components/LinkedDatasourceView', () => { + return { + LinkedDatasourceView: jest.fn(() =>
LinkedDatasourceView
), + }; +}); + +async function renderGeneralTab(metaOverrides?: any) { + const result = render(, { meta: metaOverrides }); + await result.findByTestId(DataTestIds.CONFIG_CONTENT); + + return result; +} + +describe('GeneralTab', () => { + it('should render', async () => { + const { container } = await renderGeneralTab(); + expect(container).toBeInTheDocument(); + }); + + it('should render with title', async () => { + const { getByText } = await renderGeneralTab(); + expect(getByText('General')).toBeInTheDocument(); + }); + + it('should have a section on private probes', async () => { + const { getByText, getByTestId } = await renderGeneralTab(); + expect(getByText('Private probes', { selector: 'h3' })).toBeInTheDocument(); + expect(getByText('Backend address', { selector: 'h4' })).toBeInTheDocument(); + + const expectedBackendAddress = SM_DATASOURCE.jsonData.apiHost.replace('https://', ''); + expect(getByTestId(DataTestIds.PREFORMATTED)).toHaveTextContent(expectedBackendAddress); + }); + + it('should have a section on data sources', async () => { + const { getByText } = await renderGeneralTab(); + expect(getByText('Data sources', { selector: 'h3' })).toBeInTheDocument(); + expect(LinkedDatasourceView).toHaveBeenNthCalledWith(1, { type: 'synthetic-monitoring-datasource' }, {}); + expect(LinkedDatasourceView).toHaveBeenNthCalledWith(2, { type: 'prometheus' }, {}); + expect(LinkedDatasourceView).toHaveBeenNthCalledWith(3, { type: 'loki' }, {}); + }); + + it('should show plugin version', async () => { + const { getByText } = await renderGeneralTab(); + // version is collected from meta.info, hence '%VERSION%' when running inside test + expect(getByText('%VERSION%', { exact: false })).toBeInTheDocument(); + }); +}); diff --git a/src/page/ConfigPageLayout/tabs/GeneralTab.tsx b/src/page/ConfigPageLayout/tabs/GeneralTab.tsx new file mode 100644 index 000000000..786c2b8d5 --- /dev/null +++ b/src/page/ConfigPageLayout/tabs/GeneralTab.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { Alert, Space, TextLink, useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; + +import { useBackendAddress } from 'hooks/useBackendAddress'; +import { useCanWriteSM } from 'hooks/useDSPermission'; +import { useMeta } from 'hooks/useMeta'; +import { LinkedDatasourceView } from 'components/LinkedDatasourceView'; + +import { Preformatted } from '../../../components/Preformatted'; +import { ConfigContent } from '../ConfigContent'; + +export function GeneralTab() { + const meta = useMeta(); + // This may be false in play.grafana.net + const isSignedIn = config.bootData.user?.isSignedIn ?? false; + const canWriteSM = useCanWriteSM(); + const [backendAddress, backendAddressDescription] = useBackendAddress(true); + const styles = useStyles2(getStyles); + + return ( + <> + {!isSignedIn && ( + + + Synthetic Monitoring is a blackbox monitoring solution provided as part of{' '} + + Grafana Cloud + + . If you don't already have a Grafana Cloud service,{' '} + + sign up now + + . + + + )} + + + In addition to the public probes run by Grafana Labs, you can also{' '} + + install private probes + + . These are only accessible to you and only write data to your Grafana Cloud account. Private probes are + instances of the open source Grafana{' '} + + Synthetic Monitoring Agent + + . + +

Backend address

+ {backendAddressDescription} + {backendAddress} +
+ + + + + + + + + {canWriteSM ? Plugin : 'Plugin'} version:{' '} + {meta.info.version} + +
+ + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + pre: css({ + marginTop: theme.spacing(1), + marginBottom: 0, + }), + }; +} diff --git a/src/page/ConfigPageLayout/tabs/TerraformTab.test.tsx b/src/page/ConfigPageLayout/tabs/TerraformTab.test.tsx new file mode 100644 index 000000000..b5de8f7f5 --- /dev/null +++ b/src/page/ConfigPageLayout/tabs/TerraformTab.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { within } from '@testing-library/react'; + +import { DataTestIds } from '../../../test/dataTestIds'; +import { BASIC_PING_CHECK } from '../../../test/fixtures/checks'; +import { PRIVATE_PROBE } from '../../../test/fixtures/probes'; +import { TERRAFORM_BASIC_PING_CHECK } from '../../../test/fixtures/terraform'; +import { apiRoute } from '../../../test/handlers'; +import { render } from '../../../test/render'; +import { server } from '../../../test/server'; +import { TerraformTab } from './TerraformTab'; + +async function renderTerraformTab() { + server.use( + apiRoute('listChecks', { + result: () => { + return { + json: [BASIC_PING_CHECK], + }; + }, + }) + ); + + const result = render(); + await result.findByTestId(DataTestIds.CONFIG_CONTENT); + + return result; +} + +describe('TerraformTab', () => { + it('should render', async () => { + const { container } = await renderTerraformTab(); + expect(container).toBeInTheDocument(); + }); + + it('should show correct heading', async () => { + const { getByText } = await renderTerraformTab(); + expect(getByText('Terraform config', { selector: 'h2' })).toBeInTheDocument(); + }); + + it('should show prerequisites', async () => { + const { getByText } = await renderTerraformTab(); + expect(getByText('Prerequisites', { selector: 'h3' })).toBeInTheDocument(); + expect(getByText('Grafana API key', { selector: 'a' })).toBeInTheDocument(); + expect(getByText('Synthetic Monitoring access token', { selector: 'a' })).toBeInTheDocument(); + }); + + it('should show "Terraform and JSON" ', async () => { + const { getByTestId } = await renderTerraformTab(); + // Terraform and JSON + + const alert = getByTestId('data-testid Alert info'); + expect(within(alert).getByText('Terraform and JSON')).toBeInTheDocument(); + expect(alert).toBeInTheDocument(); + }); + + it('should show `tf.json` with replace vars', async () => { + const { getByText } = await renderTerraformTab(); + expect(getByText('Exported config', { selector: 'h3' })).toBeInTheDocument(); + expect(getByText('GRAFANA_SERVICE_TOKEN', { selector: 'a > strong', exact: false })).toBeInTheDocument(); + expect(getByText('SM_ACCESS_TOKEN', { selector: 'a > strong', exact: false })).toBeInTheDocument(); + }); + + it('should show correct terraform config', async () => { + const { getAllByTestId } = await renderTerraformTab(); + const preformatted = getAllByTestId(DataTestIds.PREFORMATTED); + // Since content escapes '<' and '>', we need to replace them back + const content = JSON.parse((preformatted[0].textContent ?? '').replace('<', '<').replace('>', '>')); + expect(content).toEqual(TERRAFORM_BASIC_PING_CHECK); + }); + + describe('import existing checks', () => { + it('should show "Import existing checks"', async () => { + const { getByText } = await renderTerraformTab(); + expect(getByText('Import existing checks into Terraform', { selector: 'h3' })).toBeInTheDocument(); + }); + + it('should show correct check import commands', async () => { + const { getByText, getAllByTestId } = await renderTerraformTab(); + expect(getByText('Import existing checks into Terraform', { selector: 'h3' })).toBeInTheDocument(); + const preformatted = getAllByTestId(DataTestIds.PREFORMATTED); + expect(preformatted[1]).toHaveTextContent( + 'terraform import grafana_synthetic_monitoring_check.Job_name_for_ping_grafana_com 5' + ); + }); + + describe('import custom probes', () => { + it('should show "Import custom probes"', async () => { + const { getByText } = await renderTerraformTab(); + expect(getByText('Import custom probes into Terraform', { selector: 'h3' })).toBeInTheDocument(); + }); + + it('should show replace vars for custom probes', async () => { + const { getByText } = await renderTerraformTab(); + expect(getByText('PROBE_ACCESS_TOKEN', { selector: 'a > strong', exact: false })).toBeInTheDocument(); + }); + + it('should show correct probe import commands', async () => { + server.use( + apiRoute(`listProbes`, { + result: () => { + return { + json: [PRIVATE_PROBE], + }; + }, + }) + ); + const { getAllByTestId } = await renderTerraformTab(); + const preformatted = getAllByTestId(DataTestIds.PREFORMATTED); + expect(preformatted[2]).toHaveTextContent( + 'terraform import grafana_synthetic_monitoring_probe.tacos 1:' + ); + }); + }); + }); +}); diff --git a/src/page/ConfigPageLayout/tabs/TerraformTab.tsx b/src/page/ConfigPageLayout/tabs/TerraformTab.tsx new file mode 100644 index 000000000..77f6ce2af --- /dev/null +++ b/src/page/ConfigPageLayout/tabs/TerraformTab.tsx @@ -0,0 +1,118 @@ +import React, { useEffect } from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Alert, Text, TextLink, useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { generateRoutePath } from 'routes'; + +import { ROUTES } from 'types'; +import { FaroEvent, reportEvent } from 'faro'; +import { useTerraformConfig } from 'hooks/useTerraformConfig'; +import { Clipboard } from 'components/Clipboard'; + +import { ConfigContent } from '../ConfigContent'; + +export function TerraformTab() { + const { config, checkCommands, probeCommands, error, isLoading } = useTerraformConfig(); + const styles = useStyles2(getStyles); + useEffect(() => { + reportEvent(FaroEvent.SHOW_TERRAFORM_CONFIG); + }, []); + + if (isLoading) { + return ; + } + + return ( + + {error && } +

+ You can manage Synthetic monitoring checks using Terraform as well as export your current checks as + configuration. +

+ + +
+ + Grafana API key + +
+ +
+ + Synthetic Monitoring access token + +
+
+ + + + The exported config is using{' '} + + Terraform JSON syntax + + . You can place this config in a file with a tf.json extension and import as a module. See the{' '} + + Terraform provider docs + {' '} + for more details. + + + Replace{' '} + + {''} + {' '} + and{' '} + + {''} + + , with their respective value. + + ', '']} + content={JSON.stringify(config, null, 2)} + className={styles.clipboard} + isCode + /> + + + {checkCommands && ( + + + + )} + + {probeCommands && ( + + + Replace{' '} + + {''} + {' '} + with each probe's access token. + + + + + )} +
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + clipboard: css({ + maxHeight: 500, + marginTop: 10, + marginBottom: 10, + }), + codeLink: css({ + fontFamily: theme.typography.code.fontFamily, + fontSize: '0.8571428571em', + }), + }; +} diff --git a/src/page/ConfigPageLayout/tabs/UninitializedTab.test.tsx b/src/page/ConfigPageLayout/tabs/UninitializedTab.test.tsx new file mode 100644 index 000000000..3e2f90c01 --- /dev/null +++ b/src/page/ConfigPageLayout/tabs/UninitializedTab.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; + +import { ROUTES } from '../../../types'; + +import { AppInitializer } from '../../../components/AppInitializer'; +import { DataTestIds } from '../../../test/dataTestIds'; +import { render } from '../../../test/render'; +import { UninitializedTab } from './UninitializedTab'; + +jest.mock('../../../components/AppInitializer', () => { + return { + AppInitializer: jest + .fn() + .mockImplementation(({ buttonText, redirectTo }: { buttonText: string; redirectTo?: ROUTES }) => ( +
+ +
+ )), + }; +}); + +async function renderUninitializedTab() { + const result = render(); + await screen.findByText('Synthetic Monitoring is not yet initialized'); + + return result; +} + +describe('', () => { + it('should render', () => { + renderUninitializedTab(); + }); + + it('should show initialization button', async () => { + const { getByText } = await renderUninitializedTab(); + const button = getByText('Initialize plugin', { selector: 'button' }); + + expect(button).toBeInTheDocument(); + }); + + it('should use ', async () => { + await renderUninitializedTab(); + expect(AppInitializer).toHaveBeenCalledWith({ buttonText: 'Initialize plugin', redirectTo: ROUTES.Config }, {}); + }); +}); diff --git a/src/page/ConfigPageLayout/tabs/UninitializedTab.tsx b/src/page/ConfigPageLayout/tabs/UninitializedTab.tsx new file mode 100644 index 000000000..cfef8acd3 --- /dev/null +++ b/src/page/ConfigPageLayout/tabs/UninitializedTab.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { EmptyState, TextLink } from '@grafana/ui'; + +import { ROUTES } from 'types'; +import { AppInitializer } from 'components/AppInitializer'; + +import { ConfigContent } from '../ConfigContent'; + +export function UninitializedTab() { + // For some reason the 'call-to-action' variant causes infinity loop in the test (if the image is shown) + const hideImage = process.env.NODE_ENV === 'test'; + + return ( + + } + > +

+ The plugin is installed and enabled but still requires initialization. Click the button below to get started + or take a look at the{' '} + + documentation + + . +

+
+
+ ); +} diff --git a/src/page/NewProbe/NewProbe.tsx b/src/page/NewProbe/NewProbe.tsx index b6a643890..6283c0070 100644 --- a/src/page/NewProbe/NewProbe.tsx +++ b/src/page/NewProbe/NewProbe.tsx @@ -1,14 +1,15 @@ import React, { useCallback, useState } from 'react'; import { PluginPage } from '@grafana/runtime'; -import { Alert, useTheme2 } from '@grafana/ui'; +import { Alert, Label, useTheme2 } from '@grafana/ui'; import { css } from '@emotion/css'; import { ExtendedProbe, type Probe, ROUTES } from 'types'; import { type AddProbeResult } from 'datasource/responses.types'; import { useCreateProbe } from 'data/useProbes'; +import { useBackendAddress } from 'hooks/useBackendAddress'; import { useNavigation } from 'hooks/useNavigation'; -import { BackendAddress } from 'components/BackendAddress'; import { DocsLink } from 'components/DocsLink'; +import { Preformatted } from 'components/Preformatted'; import { ProbeEditor } from 'components/ProbeEditor'; import { ProbeTokenModal } from 'components/ProbeTokenModal'; @@ -81,10 +82,12 @@ export const NewProbe = () => { const SettingUpAProbe = () => { const theme = useTheme2(); + const [address, description] = useBackendAddress(true); return (
- + + {address} Learn how to run a private probe diff --git a/src/page/SubsectionWelcomePage.tsx b/src/page/SubsectionWelcomePage.tsx index 06121371c..09e3ade6d 100644 --- a/src/page/SubsectionWelcomePage.tsx +++ b/src/page/SubsectionWelcomePage.tsx @@ -5,10 +5,9 @@ import { Stack, Text, useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; import { ROUTES } from 'types'; +import { AppInitializer } from 'components/AppInitializer'; import { Card } from 'components/Card'; -import { AppInitializer } from './AppInitializer'; - interface Props { children: React.ReactNode; redirectTo: ROUTES; diff --git a/src/page/WelcomePage.tsx b/src/page/WelcomePage.tsx index 947ed869e..d9346641d 100644 --- a/src/page/WelcomePage.tsx +++ b/src/page/WelcomePage.tsx @@ -5,10 +5,9 @@ import { Stack, Text, useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; import { useMeta } from 'hooks/useMeta'; +import { AppInitializer } from 'components/AppInitializer'; import { WelcomeTabs } from 'components/WelcomeTabs/WelcomeTabs'; -import { AppInitializer } from './AppInitializer'; - export const WelcomePage = () => { const styles = useStyles2(getStyles); const { info } = useMeta(); diff --git a/src/test/dataTestIds.ts b/src/test/dataTestIds.ts index 491e90dfa..129560274 100644 --- a/src/test/dataTestIds.ts +++ b/src/test/dataTestIds.ts @@ -14,4 +14,12 @@ export enum DataTestIds { TEST_ROUTER_INFO = 'test-router-info', TEST_ROUTER_INFO_PATHNAME = 'test-router-info-pathname', TEST_ROUTER_INFO_SEARCH = 'test-router-info-search', + TEST_PLUGIN_CONFIG_PAGE = 'test-plugin-config-page', + TEST_PLUGIN_CONFIG_PAGE_LINKED_DATASOURCES = 'test-plugin-config-page-linked-datasources', + TEST_PLUGIN_CONFIG_PAGE_LINKED_DATASOURCES_ERROR = 'test-plugin-config-page-linked-datasources-error', + CONFIG_PAGE_LAYOUT_ACTIVE_TAB = 'config-page-layout-active-tab', + APP_INITIALIZER = 'app-initializer', + CONFIG_CONTENT = 'config-content', + CONFIG_CONTENT_LOADING = 'config-content-loading', + PREFORMATTED = 'preformatted', } diff --git a/src/test/fixtures/terraform.ts b/src/test/fixtures/terraform.ts index 6688e395f..58b5b4fae 100644 --- a/src/test/fixtures/terraform.ts +++ b/src/test/fixtures/terraform.ts @@ -36,8 +36,8 @@ const nameKey = sanitizeName(`${BASIC_PING_CHECK.job}_${BASIC_PING_CHECK.target} export const TERRAFORM_BASIC_PING_CHECK = { provider: { grafana: { - auth: '', - sm_access_token: '', + auth: '', + sm_access_token: '', sm_url: SM_DATASOURCE.jsonData.apiHost, url: '', }, diff --git a/src/test/mocks/@grafana/runtime.tsx b/src/test/mocks/@grafana/runtime.tsx index 5e24e318e..603a29f17 100644 --- a/src/test/mocks/@grafana/runtime.tsx +++ b/src/test/mocks/@grafana/runtime.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { OrgRole } from '@grafana/data'; +import React, { ReactNode } from 'react'; +import { NavModelItem, OrgRole } from '@grafana/data'; import { BackendSrvRequest } from '@grafana/runtime'; import axios from 'axios'; import { from } from 'rxjs'; @@ -7,6 +7,8 @@ import { LOGS_DATASOURCE, METRICS_DATASOURCE, SM_DATASOURCE } from 'test/fixture import { SMDataSource } from 'datasource/DataSource'; +import { DataTestIds } from '../../dataTestIds'; + jest.mock('@grafana/runtime', () => { const actual = jest.requireActual('@grafana/runtime'); @@ -56,12 +58,15 @@ jest.mock('@grafana/runtime', () => { getLocationSrv: () => ({ update: (args: any) => args, }), - PluginPage: ({ actions, children, pageNav }: any) => { + PluginPage: ({ actions, children, pageNav }: { actions: any; children: ReactNode; pageNav: NavModelItem }) => { return (

{pageNav?.text}

{actions}
{children} +
+ {pageNav?.children?.find((child) => child.active)?.text ?? 'No active tab'} +
); }, diff --git a/src/types.ts b/src/types.ts index 6fa323922..ec2de16fe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -677,16 +677,15 @@ export interface UsageValues { export enum ROUTES { Alerts = 'alerts', + CheckDashboard = 'checks/:id', Checks = 'checks', ChooseCheckGroup = 'checks/choose-type', - Config = 'config', - CheckDashboard = 'checks/:id', + Config = 'config', // config (index) EditCheck = 'checks/:id/edit', ViewProbe = 'probes/:id', EditProbe = 'probes/:id/edit', Home = 'home', NewCheck = 'checks/new', - NewCheckType = 'checks/new/:checkTypeGroup', NewProbe = 'probes/new', Probes = 'probes', Redirect = 'redirect',