diff --git a/public/app/features/plugins/admin/__mocks__/mockHelpers.ts b/public/app/features/plugins/admin/__mocks__/mockHelpers.ts index 93b4d93a163..6c1a9949bea 100644 --- a/public/app/features/plugins/admin/__mocks__/mockHelpers.ts +++ b/public/app/features/plugins/admin/__mocks__/mockHelpers.ts @@ -1,7 +1,6 @@ import { setBackendSrv } from '@grafana/runtime'; -import { PluginsState } from 'app/types'; import { API_ROOT, GRAFANA_API_ROOT } from '../constants'; -import { CatalogPlugin, LocalPlugin, RemotePlugin, Version } from '../types'; +import { CatalogPlugin, LocalPlugin, RemotePlugin, Version, ReducerState, RequestStatus } from '../types'; import remotePluginMock from './remotePlugin.mock'; import localPluginMock from './localPlugin.mock'; import catalogPluginMock from './catalogPlugin.mock'; @@ -16,7 +15,7 @@ export const getLocalPluginMock = (overrides?: Partial) => ({ ...lo export const getRemotePluginMock = (overrides?: Partial) => ({ ...remotePluginMock, ...overrides }); // Returns a mock for the Redux store state of plugins -export const getPluginsStateMock = (plugins: CatalogPlugin[] = []): PluginsState => ({ +export const getPluginsStateMock = (plugins: CatalogPlugin[] = []): ReducerState => ({ // @ts-ignore - We don't need the rest of the properties here as we are using the "new" reducer (public/app/features/plugins/admin/state/reducer.ts) items: { ids: plugins.map(({ id }) => id), @@ -24,12 +23,20 @@ export const getPluginsStateMock = (plugins: CatalogPlugin[] = []): PluginsState }, requests: { 'plugins/fetchAll': { - status: 'Fulfilled', + status: RequestStatus.Fulfilled, }, 'plugins/fetchDetails': { - status: 'Fulfilled', + status: RequestStatus.Fulfilled, }, }, + // Backward compatibility + plugins: [], + errors: [], + searchQuery: '', + hasFetched: false, + dashboards: [], + isLoadingPluginDashboards: false, + panels: {}, }); // Mocks a plugin by considering what needs to be mocked from GCOM and what needs to be mocked locally (local Grafana API) diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index 59f96e07c67..20e41b9c325 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -1,7 +1,7 @@ import { getBackendSrv } from '@grafana/runtime'; +import { PluginError, renderMarkdown } from '@grafana/data'; import { API_ROOT, GRAFANA_API_ROOT } from './constants'; -import { mergeLocalsAndRemotes, mergeLocalAndRemote } from './helpers'; -import { PluginError } from '@grafana/data'; +import { mergeLocalAndRemote } from './helpers'; import { PluginDetails, Org, @@ -13,16 +13,6 @@ import { PluginVersion, } from './types'; -export async function getCatalogPlugins(): Promise { - const [localPlugins, remotePlugins, pluginErrors] = await Promise.all([ - getLocalPlugins(), - getRemotePlugins(), - getPluginErrors(), - ]); - - return mergeLocalsAndRemotes(localPlugins, remotePlugins, pluginErrors); -} - export async function getCatalogPlugin(id: string): Promise { const { local, remote } = await getPlugin(id); @@ -33,7 +23,11 @@ export async function getPluginDetails(id: string): Promise p.id === id); const isInstalled = Boolean(local); - const [remote, versions] = await Promise.all([getRemotePlugin(id, isInstalled), getPluginVersions(id)]); + const [remote, versions, localReadme] = await Promise.all([ + getRemotePlugin(id, isInstalled), + getPluginVersions(id), + getLocalPluginReadme(id), + ]); const dependencies = remote?.json?.dependencies; // Prepend semver range when we fallback to grafanaVersion (deprecated in favour of grafanaDependency) // otherwise plugins cannot be installed. @@ -47,12 +41,12 @@ export async function getPluginDetails(id: string): Promise { +export async function getRemotePlugins(): Promise { const res = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins`); return res.items; } @@ -73,7 +67,7 @@ async function getPlugin(slug: string): Promise { }; } -async function getPluginErrors(): Promise { +export async function getPluginErrors(): Promise { try { return await getBackendSrv().get(`${API_ROOT}/errors`); } catch (error) { @@ -83,10 +77,10 @@ async function getPluginErrors(): Promise { async function getRemotePlugin(id: string, isInstalled: boolean): Promise { try { - return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}`); + return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}`, {}); } catch (error) { - // this might be a plugin that doesn't exist on gcom. - error.isHandled = isInstalled; + // It can happen that GCOM is not available, in that case we show a limited set of information to the user. + error.isHandled = true; return; } } @@ -99,11 +93,25 @@ async function getPluginVersions(id: string): Promise { return (versions.items || []).map(({ version, createdAt }) => ({ version, createdAt })); } catch (error) { + // It can happen that GCOM is not available, in that case we show a limited set of information to the user. + error.isHandled = true; return []; } } -async function getLocalPlugins(): Promise { +async function getLocalPluginReadme(id: string): Promise { + try { + const markdown: string = await getBackendSrv().get(`${API_ROOT}/${id}/markdown/help`); + const markdownAsHtml = markdown ? renderMarkdown(markdown) : ''; + + return markdownAsHtml; + } catch (error) { + error.isHandled = true; + return ''; + } +} + +export async function getLocalPlugins(): Promise { const installed = await getBackendSrv().get(`${API_ROOT}`, { embedded: 0 }); return installed; } diff --git a/public/app/features/plugins/admin/components/InstallControls/index.tsx b/public/app/features/plugins/admin/components/InstallControls/index.tsx index f0d3ae37e97..16110ddce1e 100644 --- a/public/app/features/plugins/admin/components/InstallControls/index.tsx +++ b/public/app/features/plugins/admin/components/InstallControls/index.tsx @@ -6,10 +6,11 @@ import { config } from '@grafana/runtime'; import { HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data'; -import { CatalogPlugin, PluginStatus } from '../../types'; -import { isGrafanaAdmin, getExternalManageLink } from '../../helpers'; import { ExternallyManagedButton } from './ExternallyManagedButton'; import { InstallControlsButton } from './InstallControlsButton'; +import { CatalogPlugin, PluginStatus } from '../../types'; +import { isGrafanaAdmin, getExternalManageLink } from '../../helpers'; +import { useIsRemotePluginsAvailable } from '../../state/hooks'; interface Props { plugin: CatalogPlugin; @@ -20,6 +21,7 @@ export const InstallControls = ({ plugin }: Props) => { const isExternallyManaged = config.pluginAdminExternalManageEnabled; const hasPermission = isGrafanaAdmin(); const grafanaDependency = plugin.details?.grafanaDependency; + const isRemotePluginsAvailable = useIsRemotePluginsAvailable(); const unsupportedGrafanaVersion = grafanaDependency ? !satisfies(config.buildInfo.version, grafanaDependency, { // needed for when running against main @@ -78,6 +80,14 @@ export const InstallControls = ({ plugin }: Props) => { return ; } + if (!isRemotePluginsAvailable) { + return ( +
+ The install controls have been disabled because the Grafana server cannot access grafana.com. +
+ ); + } + return ; }; diff --git a/public/app/features/plugins/admin/pages/Browse.test.tsx b/public/app/features/plugins/admin/pages/Browse.test.tsx index a1a5e347b19..2a4083d3c80 100644 --- a/public/app/features/plugins/admin/pages/Browse.test.tsx +++ b/public/app/features/plugins/admin/pages/Browse.test.tsx @@ -6,7 +6,8 @@ import { locationService } from '@grafana/runtime'; import { PluginType } from '@grafana/data'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; import { configureStore } from 'app/store/configureStore'; -import { PluginAdminRoutes, CatalogPlugin } from '../types'; +import { fetchRemotePlugins } from '../state/actions'; +import { PluginAdminRoutes, CatalogPlugin, ReducerState, RequestStatus } from '../types'; import { getCatalogPluginMock, getPluginsStateMock } from '../__mocks__'; import BrowsePage from './Browse'; @@ -17,8 +18,12 @@ jest.mock('@grafana/runtime', () => { return { ...original, pluginAdminEnabled: true }; }); -const renderBrowse = (path = '/plugins', plugins: CatalogPlugin[] = []): RenderResult => { - const store = configureStore({ plugins: getPluginsStateMock(plugins) }); +const renderBrowse = ( + path = '/plugins', + plugins: CatalogPlugin[] = [], + pluginsStateOverride?: ReducerState +): RenderResult => { + const store = configureStore({ plugins: pluginsStateOverride || getPluginsStateMock(plugins) }); locationService.push(path); const props = getRouteComponentProps({ route: { routeName: PluginAdminRoutes.Home } as any, @@ -288,4 +293,30 @@ describe('Browse list of plugins', () => { ]); }); }); + + describe('when GCOM api is not available', () => { + it('should disable the All / Installed filter', async () => { + const plugins = [ + getCatalogPluginMock({ id: 'plugin-1', name: 'Plugin 1', isInstalled: true }), + getCatalogPluginMock({ id: 'plugin-3', name: 'Plugin 2', isInstalled: true }), + getCatalogPluginMock({ id: 'plugin-4', name: 'Plugin 3', isInstalled: true }), + ]; + const state = getPluginsStateMock(plugins); + + // Mock the store like if the remote plugins request was rejected + const stateOverride = { + ...state, + requests: { + ...state.requests, + [fetchRemotePlugins.typePrefix]: { + status: RequestStatus.Rejected, + }, + }, + }; + + // The radio input for the filters should be disabled + const { getByRole } = renderBrowse('/plugins', [], stateOverride); + await waitFor(() => expect(getByRole('radio', { name: 'Installed' })).toBeDisabled()); + }); + }); }); diff --git a/public/app/features/plugins/admin/pages/Browse.tsx b/public/app/features/plugins/admin/pages/Browse.tsx index 86ddc8ec002..53774c2e6ab 100644 --- a/public/app/features/plugins/admin/pages/Browse.tsx +++ b/public/app/features/plugins/admin/pages/Browse.tsx @@ -1,7 +1,7 @@ import React, { ReactElement } from 'react'; import { css } from '@emotion/css'; import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; -import { LoadingPlaceholder, Select, RadioButtonGroup, useStyles2 } from '@grafana/ui'; +import { LoadingPlaceholder, Select, RadioButtonGroup, useStyles2, Tooltip } from '@grafana/ui'; import { useLocation } from 'react-router-dom'; import { locationSearchToObject } from '@grafana/runtime'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; @@ -15,7 +15,7 @@ import { Page } from 'app/core/components/Page/Page'; import { useSelector } from 'react-redux'; import { StoreState } from 'app/types/store'; import { getNavModel } from 'app/core/selectors/navModel'; -import { useGetAll, useGetAllWithFilters } from '../state/hooks'; +import { useGetAll, useGetAllWithFilters, useIsRemotePluginsAvailable } from '../state/hooks'; import { Sorters } from '../helpers'; export default function Browse({ route }: GrafanaRouteComponentProps): ReactElement | null { @@ -26,6 +26,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem const navModel = useSelector((state: StoreState) => getNavModel(state.navIndex, navModelId)); const styles = useStyles2(getStyles); const history = useHistory(); + const remotePluginsAvailable = useIsRemotePluginsAvailable(); const query = (locationSearch.q as string) || ''; const filterBy = (locationSearch.filterBy as string) || 'installed'; const filterByType = (locationSearch.filterByType as string) || 'all'; @@ -36,6 +37,10 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem filterByType, sortBy, }); + const filterByOptions = [ + { value: 'all', label: 'All' }, + { value: 'installed', label: 'Installed' }, + ]; const onSortByChange = (value: SelectableValue) => { history.push({ query: { sortBy: value.value } }); @@ -78,16 +83,25 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem ]} /> -
- -
+ {remotePluginsAvailable ? ( +
+ +
+ ) : ( + +
+ +
+
+ )}