From 163b0d182b4cc36eca63c5f054df0a1f35a72a39 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Wed, 27 Sep 2023 15:04:23 +0200 Subject: [PATCH] Connections: Make the "Add new Connection" page work without internet access (#75272) * fix: make the "Add new Connection" page work without internet access * feat: update error message * fix: update the API url in the error log --- .../tabs/ConnectData/ConnectData.tsx | 2 +- .../features/plugins/admin/state/actions.ts | 95 +++++++++++++++++-- .../app/features/plugins/admin/state/hooks.ts | 11 ++- .../features/plugins/admin/state/reducer.ts | 5 +- 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/public/app/features/connections/tabs/ConnectData/ConnectData.tsx b/public/app/features/connections/tabs/ConnectData/ConnectData.tsx index 1f65eb7ac23..a9335668c6c 100644 --- a/public/app/features/connections/tabs/ConnectData/ConnectData.tsx +++ b/public/app/features/connections/tabs/ConnectData/ConnectData.tsx @@ -39,7 +39,7 @@ export function AddNewConnection() { setSearchTerm(e.currentTarget.value.toLowerCase()); }; - const { isLoading, error, plugins } = useGetAll({ + const { error, plugins, isLoading } = useGetAll({ keyword: searchTerm, type: PluginType.datasource, }); diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index ee8d61fe361..ad9b4e83118 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -1,6 +1,7 @@ import { createAction, createAsyncThunk, Update } from '@reduxjs/toolkit'; +import { from, forkJoin, timeout, lastValueFrom, catchError, throwError } from 'rxjs'; -import { PanelPlugin } from '@grafana/data'; +import { PanelPlugin, PluginError } from '@grafana/data'; import { getBackendSrv, isFetchError } from '@grafana/runtime'; import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin'; import { StoreState, ThunkResult } from 'app/types'; @@ -18,16 +19,76 @@ import { STATE_PREFIX } from '../constants'; import { mapLocalToCatalog, mergeLocalsAndRemotes, updatePanels } from '../helpers'; import { CatalogPlugin, RemotePlugin, LocalPlugin } from '../types'; +// Fetches export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, thunkApi) => { try { - const { dispatch } = thunkApi; - const [localPlugins, pluginErrors, { payload: remotePlugins }] = await Promise.all([ - getLocalPlugins(), - getPluginErrors(), - dispatch(fetchRemotePlugins()), - ]); + thunkApi.dispatch({ type: `${STATE_PREFIX}/fetchLocal/pending` }); + thunkApi.dispatch({ type: `${STATE_PREFIX}/fetchRemote/pending` }); - return mergeLocalsAndRemotes(localPlugins, remotePlugins, pluginErrors); + const local$ = from(getLocalPlugins()); + const remote$ = from(getRemotePlugins()); + const pluginErrors$ = from(getPluginErrors()); + + forkJoin({ + local: local$, + remote: remote$, + pluginErrors: pluginErrors$, + }) + .pipe( + // Fetching the list of plugins from GCOM is slow / errors out + timeout({ + each: 500, + with: () => { + remote$ + // The request to fetch remote plugins from GCOM failed + .pipe( + catchError((err) => { + thunkApi.dispatch({ type: `${STATE_PREFIX}/fetchRemote/rejected` }); + return throwError( + () => new Error('Failed to fetch plugins from catalog (default https://grafana.com/api/plugins)') + ); + }) + ) + // Remote plugins loaded after a timeout, updating the store + .subscribe(async (remote: RemotePlugin[]) => { + thunkApi.dispatch({ type: `${STATE_PREFIX}/fetchRemote/fulfilled` }); + + if (remote.length > 0) { + const local = await lastValueFrom(local$); + const pluginErrors = await lastValueFrom(pluginErrors$); + + thunkApi.dispatch(addPlugins(mergeLocalsAndRemotes(local, remote, pluginErrors))); + } + }); + + return forkJoin({ local: local$, pluginErrors: pluginErrors$ }); + }, + }) + ) + .subscribe( + ({ + local, + remote, + pluginErrors, + }: { + local: LocalPlugin[]; + remote?: RemotePlugin[]; + pluginErrors: PluginError[]; + }) => { + thunkApi.dispatch({ type: `${STATE_PREFIX}/fetchLocal/fulfilled` }); + + // Both local and remote plugins are loaded + if (local && remote) { + thunkApi.dispatch(addPlugins(mergeLocalsAndRemotes(local, remote, pluginErrors))); + + // Only remote plugins are loaded (remote timed out) + } else if (local) { + thunkApi.dispatch(addPlugins(mergeLocalsAndRemotes(local, [], pluginErrors))); + } + } + ); + + return null; } catch (e) { return thunkApi.rejectWithValue('Unknown error.'); } @@ -72,6 +133,24 @@ export const fetchDetails = createAsyncThunk, string>( } ); +export const addPlugins = createAction(`${STATE_PREFIX}/addPlugins`); + +// 1. gets remote equivalents from the store (if there are any) +// 2. merges the remote equivalents with the local plugins +// 3. updates the the store with the updated CatalogPlugin objects +export const addLocalPlugins = createAction(`${STATE_PREFIX}/addLocalPlugins`); + +// 1. gets local equivalents from the store (if there are any) +// 2. merges the local equivalents with the remote plugins +// 3. updates the the store with the updated CatalogPlugin objects +export const addRemotePlugins = createAction(`${STATE_PREFIX}/addLocalPlugins`); + +// 1. merges the local and remote plugins +// 2. updates the store with the CatalogPlugin objects +export const addLocalAndRemotePlugins = createAction<{ local: LocalPlugin[]; remote: RemotePlugin[] }>( + `${STATE_PREFIX}/addLocalPlugins` +); + // We are also using the install API endpoint to update the plugin export const install = createAsyncThunk< Update, diff --git a/public/app/features/plugins/admin/state/hooks.ts b/public/app/features/plugins/admin/state/hooks.ts index da9caeec7e0..d499a0833e3 100644 --- a/public/app/features/plugins/admin/state/hooks.ts +++ b/public/app/features/plugins/admin/state/hooks.ts @@ -24,7 +24,9 @@ export const useGetAll = (filters: PluginFilters, sortBy: Sorters = Sorters.name const selector = useMemo(() => selectPlugins(filters), [filters]); const plugins = useSelector(selector); - const { isLoading, error } = useFetchStatus(); + // As the locally installed plugins load quicker than the remote ones, we only show a loading state until these are being loaded + // (In case the remote ones are not loaded within a reasonable timeout, we will merge those with the locally installed plugins once they are loaded) + const { isLoading, error } = useLocalFetchStatus(); const sortedPlugins = sortPlugins(plugins, sortBy); return { @@ -74,6 +76,13 @@ export const useIsRemotePluginsAvailable = () => { return error === null; }; +export const useLocalFetchStatus = () => { + const isLoading = useSelector(selectIsRequestPending('plugins/fetchLocal')); + const error = useSelector(selectRequestError('plugins/fetchLocal')); + + return { isLoading, error }; +}; + export const useFetchStatus = () => { const isLoading = useSelector(selectIsRequestPending(fetchAll.typePrefix)); const error = useSelector(selectRequestError(fetchAll.typePrefix)); diff --git a/public/app/features/plugins/admin/state/reducer.ts b/public/app/features/plugins/admin/state/reducer.ts index 148032855b8..729a91a66bb 100644 --- a/public/app/features/plugins/admin/state/reducer.ts +++ b/public/app/features/plugins/admin/state/reducer.ts @@ -6,13 +6,13 @@ import { STATE_PREFIX } from '../constants'; import { CatalogPlugin, PluginListDisplayMode, ReducerState, RequestStatus } from '../types'; import { - fetchAll, fetchDetails, install, uninstall, loadPluginDashboards, panelPluginLoaded, fetchAllLocal, + addPlugins, } from './actions'; export const pluginsAdapter = createEntityAdapter(); @@ -58,8 +58,7 @@ const slice = createSlice({ }, extraReducers: (builder) => builder - // Fetch All - .addCase(fetchAll.fulfilled, (state, action) => { + .addCase(addPlugins, (state, action: PayloadAction) => { pluginsAdapter.upsertMany(state.items, action.payload); }) // Fetch All local