Plugin Catalog: support Grafana instances that cannot communicate with gcom (#39638)

* added possibility to track if remote plugins could be fetched.

* adding hook to detect if remote plugins are available.

* feat(catalog): disable installed/all filter if remote plugins are unavailable

* feat(Plugins/Admin): hide the install controls if GCOM is not available

* refactor(Plugins/Admin): group `@grafana` dependencies

* fix(Plugins/Admin): don't show an error alert if a remote plugin is not available

* feat(Plugins/Admin): prefer to use the local version of the readme

* chore(Plugins/Admin): type the mocked state properly

* test(Plugins/Admin): add tests for the Plugin Details when GCOM is not available

* test(Plugins/Admin): add tests for the Browse when GCOM is not available

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
This commit is contained in:
Jack Westbrook 2021-09-28 16:46:29 +02:00 committed by GitHub
parent 5d0d7dcb3a
commit fffcee7c1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 202 additions and 51 deletions

View File

@ -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<LocalPlugin>) => ({ ...lo
export const getRemotePluginMock = (overrides?: Partial<RemotePlugin>) => ({ ...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)

View File

@ -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<CatalogPlugin[]> {
const [localPlugins, remotePlugins, pluginErrors] = await Promise.all([
getLocalPlugins(),
getRemotePlugins(),
getPluginErrors(),
]);
return mergeLocalsAndRemotes(localPlugins, remotePlugins, pluginErrors);
}
export async function getCatalogPlugin(id: string): Promise<CatalogPlugin> {
const { local, remote } = await getPlugin(id);
@ -33,7 +23,11 @@ export async function getPluginDetails(id: string): Promise<CatalogPluginDetails
const localPlugins = await getLocalPlugins();
const local = localPlugins.find((p) => 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<CatalogPluginDetails
grafanaDependency,
pluginDependencies: dependencies?.plugins || [],
links: remote?.json?.info.links || local?.info.links || [],
readme: remote?.readme,
readme: localReadme || remote?.readme,
versions,
};
}
async function getRemotePlugins(): Promise<RemotePlugin[]> {
export async function getRemotePlugins(): Promise<RemotePlugin[]> {
const res = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins`);
return res.items;
}
@ -73,7 +67,7 @@ async function getPlugin(slug: string): Promise<PluginDetails> {
};
}
async function getPluginErrors(): Promise<PluginError[]> {
export async function getPluginErrors(): Promise<PluginError[]> {
try {
return await getBackendSrv().get(`${API_ROOT}/errors`);
} catch (error) {
@ -83,10 +77,10 @@ async function getPluginErrors(): Promise<PluginError[]> {
async function getRemotePlugin(id: string, isInstalled: boolean): Promise<RemotePlugin | undefined> {
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<Version[]> {
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<LocalPlugin[]> {
async function getLocalPluginReadme(id: string): Promise<string> {
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<LocalPlugin[]> {
const installed = await getBackendSrv().get(`${API_ROOT}`, { embedded: 0 });
return installed;
}

View File

@ -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 <ExternallyManagedButton pluginId={plugin.id} pluginStatus={pluginStatus} />;
}
if (!isRemotePluginsAvailable) {
return (
<div className={styles.message}>
The install controls have been disabled because the Grafana server cannot access grafana.com.
</div>
);
}
return <InstallControlsButton plugin={plugin} pluginStatus={pluginStatus} />;
};

View File

@ -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());
});
});
});

View File

@ -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<string>) => {
history.push({ query: { sortBy: value.value } });
@ -78,16 +83,25 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
]}
/>
</div>
<div>
<RadioButtonGroup
value={filterBy}
onChange={onFilterByChange}
options={[
{ value: 'all', label: 'All' },
{ value: 'installed', label: 'Installed' },
]}
/>
</div>
{remotePluginsAvailable ? (
<div>
<RadioButtonGroup value={filterBy} onChange={onFilterByChange} options={filterByOptions} />
</div>
) : (
<Tooltip
content="This filter has been disabled because the Grafana server cannot access grafana.com"
placement="top"
>
<div>
<RadioButtonGroup
disabled={true}
value={filterBy}
onChange={onFilterByChange}
options={filterByOptions}
/>
</div>
</Tooltip>
)}
<div>
<Select
menuShouldPortal

View File

@ -7,8 +7,9 @@ import { config } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import PluginDetailsPage from './PluginDetails';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { CatalogPlugin, PluginTabIds } from '../types';
import { CatalogPlugin, PluginTabIds, RequestStatus, ReducerState } from '../types';
import * as api from '../api';
import { fetchRemotePlugins } from '../state/actions';
import { mockPluginApis, getCatalogPluginMock, getPluginsStateMock } from '../__mocks__';
import { PluginErrorCode, PluginSignatureStatus } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@ -25,7 +26,16 @@ jest.mock('@grafana/runtime', () => {
return mockedRuntime;
});
const renderPluginDetails = (pluginOverride: Partial<CatalogPlugin>, pageId = PluginTabIds.OVERVIEW): RenderResult => {
const renderPluginDetails = (
pluginOverride: Partial<CatalogPlugin>,
{
pageId = PluginTabIds.OVERVIEW,
pluginsStateOverride,
}: {
pageId?: PluginTabIds;
pluginsStateOverride?: ReducerState;
} = {}
): RenderResult => {
const plugin = getCatalogPluginMock(pluginOverride);
const { id } = plugin;
const props = getRouteComponentProps({
@ -39,7 +49,7 @@ const renderPluginDetails = (pluginOverride: Partial<CatalogPlugin>, pageId = Pl
},
});
const store = configureStore({
plugins: getPluginsStateMock([plugin]),
plugins: pluginsStateOverride || getPluginsStateMock([plugin]),
});
return render(
@ -163,7 +173,7 @@ describe('Plugin details page', () => {
],
},
},
PluginTabIds.VERSIONS
{ pageId: PluginTabIds.VERSIONS }
);
// Check if version information is available
@ -332,4 +342,40 @@ describe('Plugin details page', () => {
// Check if the modal disappeared
expect(queryByText('Uninstall Akumuli')).not.toBeInTheDocument();
});
it('should not display the install / uninstall / update buttons if the GCOM api is not available', async () => {
let rendered: RenderResult;
const plugin = getCatalogPluginMock({ id });
const state = getPluginsStateMock([plugin]);
// Mock the store like if the remote plugins request was rejected
const pluginsStateOverride = {
...state,
requests: {
...state.requests,
[fetchRemotePlugins.typePrefix]: {
status: RequestStatus.Rejected,
},
},
};
// Does not show an Install button
rendered = renderPluginDetails({ id }, { pluginsStateOverride });
await waitFor(() => expect(rendered.queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
rendered.unmount();
// Does not show a Uninstall button
rendered = renderPluginDetails({ id, isInstalled: true }, { pluginsStateOverride });
await waitFor(() => expect(rendered.queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
rendered.unmount();
// Does not show an Update button
rendered = renderPluginDetails({ id, isInstalled: true, hasUpdate: true }, { pluginsStateOverride });
await waitFor(() => expect(rendered.queryByRole('button', { name: /update/i })).not.toBeInTheDocument());
// Shows a message to the user
// TODO<Import these texts from a single source of truth instead of having them defined in multiple places>
const message = 'The install controls have been disabled because the Grafana server cannot access grafana.com.';
expect(rendered.getByText(message)).toBeInTheDocument();
});
});

View File

@ -3,19 +3,45 @@ import { getBackendSrv } from '@grafana/runtime';
import { PanelPlugin } from '@grafana/data';
import { StoreState, ThunkResult } from 'app/types';
import { importPanelPlugin } from 'app/features/plugins/plugin_loader';
import { getCatalogPlugins, getPluginDetails, installPlugin, uninstallPlugin } from '../api';
import {
getRemotePlugins,
getPluginErrors,
getLocalPlugins,
getPluginDetails,
installPlugin,
uninstallPlugin,
} from '../api';
import { STATE_PREFIX } from '../constants';
import { updatePanels } from '../helpers';
import { CatalogPlugin } from '../types';
import { mergeLocalsAndRemotes, updatePanels } from '../helpers';
import { CatalogPlugin, RemotePlugin } from '../types';
export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, thunkApi) => {
try {
return await getCatalogPlugins();
const { dispatch } = thunkApi;
const [localPlugins, pluginErrors, { payload: remotePlugins }] = await Promise.all([
getLocalPlugins(),
getPluginErrors(),
dispatch(fetchRemotePlugins()),
]);
return mergeLocalsAndRemotes(localPlugins, remotePlugins, pluginErrors);
} catch (e) {
return thunkApi.rejectWithValue('Unknown error.');
}
});
export const fetchRemotePlugins = createAsyncThunk<RemotePlugin[], void, { rejectValue: RemotePlugin[] }>(
`${STATE_PREFIX}/fetchRemotePlugins`,
async (_, thunkApi) => {
try {
return await getRemotePlugins();
} catch (error) {
error.isHandled = true;
return thunkApi.rejectWithValue([]);
}
}
);
export const fetchDetails = createAsyncThunk(`${STATE_PREFIX}/fetchDetails`, async (id: string, thunkApi) => {
try {
const details = await getPluginDetails(id);

View File

@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchAll, fetchDetails, install, uninstall } from './actions';
import { fetchAll, fetchDetails, fetchRemotePlugins, install, uninstall } from './actions';
import { CatalogPlugin, PluginCatalogStoreState } from '../types';
import {
find,
@ -63,6 +63,11 @@ export const useUninstall = () => {
return (id: string) => dispatch(uninstall(id));
};
export const useIsRemotePluginsAvailable = () => {
const error = useSelector(selectRequestError(fetchRemotePlugins.typePrefix));
return error === null;
};
export const useFetchStatus = () => {
const isLoading = useSelector(selectIsRequestPending(fetchAll.typePrefix));
const error = useSelector(selectRequestError(fetchAll.typePrefix));

View File

@ -216,6 +216,10 @@ export enum RequestStatus {
Fulfilled = 'Fulfilled',
Rejected = 'Rejected',
}
export type RemotePluginResponse = {
plugins: RemotePlugin[];
error?: Error;
};
export type RequestInfo = {
status: RequestStatus;