mirror of
https://github.com/grafana/grafana.git
synced 2025-02-16 10:24:54 -06:00
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:
parent
5d0d7dcb3a
commit
fffcee7c1f
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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} />;
|
||||
};
|
||||
|
||||
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -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));
|
||||
|
@ -216,6 +216,10 @@ export enum RequestStatus {
|
||||
Fulfilled = 'Fulfilled',
|
||||
Rejected = 'Rejected',
|
||||
}
|
||||
export type RemotePluginResponse = {
|
||||
plugins: RemotePlugin[];
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export type RequestInfo = {
|
||||
status: RequestStatus;
|
||||
|
Loading…
Reference in New Issue
Block a user