diff --git a/public/app/features/plugins/admin/components/UpdateAllModal.tsx b/public/app/features/plugins/admin/components/UpdateAllModal.tsx new file mode 100644 index 00000000000..8ef7d3a3d6a --- /dev/null +++ b/public/app/features/plugins/admin/components/UpdateAllModal.tsx @@ -0,0 +1,283 @@ +import { css } from '@emotion/css'; +import { useEffect, useMemo, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { Checkbox, ConfirmModal, EmptyState, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { useInstall, useInstallStatus } from '../state/hooks'; +import { CatalogPlugin } from '../types'; + +type UpdateError = { + id: string; + message: string; +}; + +function getIcon({ + id, + inProgress, + errorMap, + selectedPlugins, +}: { + id: string; + inProgress: boolean; + errorMap: Map; + selectedPlugins?: Set; +}) { + if (errorMap && errorMap.has(id)) { + return ( + + + + ); + } + if (inProgress && selectedPlugins?.has(id)) { + return ; + } + return ''; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + table: css({ + marginTop: theme.spacing(2), + width: '100%', + borderCollapse: 'collapse', + }), + tableRow: css({ + borderBottom: `1px solid ${theme.colors.border.weak}`, + td: { + paddingRight: theme.spacing(1), + }, + }), + icon: css({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }), + header: css({ + textAlign: 'left', + padding: theme.spacing(1), + borderBottom: `2px solid ${theme.colors.border.strong}`, + th: { + paddingRight: theme.spacing(1), + }, + }), + data: css({ + padding: '10px', + }), + footer: css({ + fontSize: theme.typography.bodySmall.fontSize, + marginTop: theme.spacing(3), + }), + noPluginsMessage: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }), + tableContainer: css({ + overflowY: 'auto', + overflowX: 'hidden', + height: theme.spacing(32), + }), + modalContainer: css({ + height: theme.spacing(41), + }), +}); + +type ModalBodyProps = { + plugins: CatalogPlugin[]; + inProgress: boolean; + selectedPlugins?: Set; + onCheckboxChange: (id: string) => void; + errorMap: Map; +}; + +const ModalBody = ({ plugins, inProgress, selectedPlugins, onCheckboxChange, errorMap }: ModalBodyProps) => { + const styles = useStyles2(getStyles); + + return ( +
+ {plugins.length === 0 ? ( + + ) : ( + <> +
+ The following plugins have update available +
+
+ + + + + + + + + + + + {plugins.map(({ id, name, installedVersion, latestVersion }: CatalogPlugin) => ( + + + + + + + + ))} + +
+ Update + + Name + + Installed + + Available +
+ onCheckboxChange(id)} value={selectedPlugins?.has(id)} /> + {name}{installedVersion}{latestVersion}{getIcon({ id, inProgress, errorMap, selectedPlugins })}
+
+ {config.pluginAdminExternalManageEnabled && config.featureToggles.managedPluginsInstall && ( +
+ + * It may take a few minutes for the plugins to be available for usage. + +
+ )} + + )} +
+ ); +}; + +type Props = { + isOpen: boolean; + onDismiss: () => void; + plugins: CatalogPlugin[]; +}; + +export const UpdateAllModal = ({ isOpen, onDismiss, plugins }: Props) => { + const install = useInstall(); + const { error } = useInstallStatus(); + const [errorMap, setErrorMap] = useState(new Map()); + const [inProgress, setInProgress] = useState(false); + const [selectedPlugins, setSelectedPlugins] = useState>(); + + const pluginsSet = useMemo(() => new Set(plugins.map((plugin) => plugin.id)), [plugins]); + const installsRemaining = plugins.length; + + // Updates the component state on every plugins change, since the installation will change the store content + useEffect(() => { + if (inProgress) { + selectedPlugins?.forEach((id) => { + if (!pluginsSet.has(id)) { + setSelectedPlugins((prevSelectedPlugins) => { + const newSelectedPlugins = new Set(prevSelectedPlugins); + newSelectedPlugins.delete(id); + return newSelectedPlugins; + }); + } + }); + + if (selectedPlugins?.size === 0) { + setInProgress(false); + } + } + }, [inProgress, pluginsSet, selectedPlugins]); + + // Initialize the component with all the plugins selected + useEffect(() => { + if (selectedPlugins === undefined && plugins.length > 0) { + const initialSelectedPlugins = new Set(plugins.map((plugin) => plugin.id)); + setSelectedPlugins(initialSelectedPlugins); + } + }, [plugins, selectedPlugins]); + + // Updates the component state on every error that comes from the store + useEffect(() => { + if (inProgress && error && !errorMap.has(error.id) && selectedPlugins?.has(error.id)) { + setErrorMap((prevErrorMap) => { + const newErrorMap = new Map(prevErrorMap); + newErrorMap.set(error.id, error); + return newErrorMap; + }); + + setSelectedPlugins((prevSelectedPlugins) => { + const newSelectedPlugins = new Set(prevSelectedPlugins); + newSelectedPlugins.delete(error.id); + return newSelectedPlugins; + }); + } + }, [error, errorMap, inProgress, selectedPlugins]); + + const onConfirm = () => { + if (!inProgress) { + setInProgress(true); + plugins.forEach((plugin) => { + if (selectedPlugins?.has(plugin.id)) { + install(plugin.id, plugin.latestVersion, true); + } + }); + } + }; + + const onDismissClick = () => { + setErrorMap(new Map()); + setInProgress(false); + setSelectedPlugins(undefined); + onDismiss(); + }; + + const onCheckboxChange = (id: string) => { + setSelectedPlugins((prevSelectedPlugins) => { + const newSelectedPlugins = new Set(prevSelectedPlugins); + if (newSelectedPlugins.has(id)) { + newSelectedPlugins.delete(id); + } else { + newSelectedPlugins.add(id); + } + return newSelectedPlugins; + }); + if (errorMap.has(id)) { + setErrorMap((prevErrorMap) => { + const newErrorMap = new Map(prevErrorMap); + newErrorMap.delete(id); + return newErrorMap; + }); + } + }; + + return ( + + } + onConfirm={installsRemaining > 0 ? onConfirm : onDismissClick} + onDismiss={onDismissClick} + disabled={selectedPlugins?.size === 0 || inProgress} + confirmText={ + installsRemaining > 0 + ? `${t('plugins.catalog.update-all.modal-confirmation', 'Update')} (${selectedPlugins?.size})` + : t('plugins.catalog.update-all.modal-dismiss', 'Close') + } + /> + ); +}; + +export default UpdateAllModal; diff --git a/public/app/features/plugins/admin/helpers.ts b/public/app/features/plugins/admin/helpers.ts index 86d441763d6..8c8f83088a3 100644 --- a/public/app/features/plugins/admin/helpers.ts +++ b/public/app/features/plugins/admin/helpers.ts @@ -201,6 +201,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat angularDetected, isFullyInstalled: true, iam: plugin.iam, + latestVersion: plugin.latestVersion, }; } diff --git a/public/app/features/plugins/admin/pages/Browse.tsx b/public/app/features/plugins/admin/pages/Browse.tsx index e85dedc6fb4..8a724d79784 100644 --- a/public/app/features/plugins/admin/pages/Browse.tsx +++ b/public/app/features/plugins/admin/pages/Browse.tsx @@ -1,11 +1,12 @@ import { css } from '@emotion/css'; -import { ReactElement } from 'react'; +import { ReactElement, useState } from 'react'; import { useLocation } from 'react-router-dom-v5-compat'; import { SelectableValue, GrafanaTheme2, PluginType } from '@grafana/data'; import { locationSearchToObject } from '@grafana/runtime'; -import { Select, RadioButtonGroup, useStyles2, Tooltip, Field } from '@grafana/ui'; +import { Select, RadioButtonGroup, useStyles2, Tooltip, Field, Button } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; +import { Trans } from 'app/core/internationalization'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { ROUTES as CONNECTIONS_ROUTES } from 'app/features/connections/constants'; @@ -15,9 +16,10 @@ import { HorizontalGroup } from '../components/HorizontalGroup'; import { PluginList } from '../components/PluginList'; import { RoadmapLinks } from '../components/RoadmapLinks'; import { SearchField } from '../components/SearchField'; +import { UpdateAllModal } from '../components/UpdateAllModal'; import { Sorters } from '../helpers'; import { useHistory } from '../hooks/useHistory'; -import { useGetAll, useIsRemotePluginsAvailable } from '../state/hooks'; +import { useGetAll, useGetUpdatable, useIsRemotePluginsAvailable } from '../state/hooks'; export default function Browse({ route }: GrafanaRouteComponentProps): ReactElement | null { const location = useLocation(); @@ -46,6 +48,10 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem { value: 'has-update', label: 'New Updates' }, ]; + const updatablePlugins = useGetUpdatable(); + const [showUpdateModal, setShowUpdateModal] = useState(false); + const disableUpdateAllButton = updatablePlugins.length <= 0; + const onSortByChange = (value: SelectableValue) => { history.push({ query: { sortBy: value.value } }); }; @@ -62,6 +68,10 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem history.push({ query: { filterBy, filterByType, q } }); }; + const onUpdateAll = () => { + setShowUpdateModal(true); + }; + // How should we handle errors? if (error) { console.error(error.message); @@ -77,9 +87,15 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem . ); + const updateAll = ( + + ); return ( - + @@ -147,6 +163,11 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem + setShowUpdateModal(false)} + plugins={updatablePlugins} + /> ); diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index 06d4402dd4c..87b5930e312 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -213,6 +213,8 @@ export const install = createAsyncThunk< } catch (e) { console.error(e); if (isFetchError(e)) { + // add id to identify errors in multiple requests + e.data.id = id; return thunkApi.rejectWithValue(e.data); } diff --git a/public/app/features/plugins/admin/state/hooks.ts b/public/app/features/plugins/admin/state/hooks.ts index a1cd84b3e16..b0d6df6fb00 100644 --- a/public/app/features/plugins/admin/state/hooks.ts +++ b/public/app/features/plugins/admin/state/hooks.ts @@ -34,6 +34,11 @@ export const useGetAll = (filters: PluginFilters, sortBy: Sorters = Sorters.name }; }; +export const useGetUpdatable = () => { + const { plugins: installed } = useGetAll({ isInstalled: true }); + return installed.filter((p) => !p.isCore && !p.isManaged && !p.isProvisioned && p.hasUpdate && p.latestVersion); +}; + export const useGetSingle = (id: string): CatalogPlugin | undefined => { useFetchAll(); useFetchDetails(id); diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 93517af5261..4f0796826ff 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -43,6 +43,7 @@ export interface CatalogPlugin extends WithAccessControlMetadata { isPreinstalled: { found: boolean; withVersion: boolean }; // Indicates that the plugin is pre-installed // `isPublished` is TRUE if the plugin is published to grafana.com isPublished: boolean; + latestVersion?: string; name: string; orgName: string; signature: PluginSignatureStatus; @@ -156,6 +157,7 @@ export type LocalPlugin = WithAccessControlMetadata & { dev?: boolean; enabled: boolean; hasUpdate: boolean; + latestVersion: string; id: string; info: { author: Rel; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 0df4ec38e38..81e2f412c07 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1829,6 +1829,22 @@ } }, "plugins": { + "catalog": { + "update-all": { + "all-plugins-updated": "All plugins updated!", + "available-header": "Available", + "button": "Update all", + "cloud-update-message": "* It may take a few minutes for the plugins to be available for usage.", + "error": "Error updating plugin:", + "header": "The following plugins have update available", + "installed-header": "Installed", + "modal-confirmation": "Update", + "modal-dismiss": "Close", + "modal-title": "Update Plugins", + "name-header": "Name", + "update-header": "Update" + } + }, "details": { "labels": { "dependencies": "Dependencies", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index bda49fe87ed..c7bd960da56 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1829,6 +1829,22 @@ } }, "plugins": { + "catalog": { + "update-all": { + "all-plugins-updated": "Åľľ pľūģįʼnş ūpđäŧęđ!", + "available-header": "Åväįľäþľę", + "button": "Ůpđäŧę äľľ", + "cloud-update-message": "* Ĩŧ mäy ŧäĸę ä ƒęŵ mįʼnūŧęş ƒőř ŧĥę pľūģįʼnş ŧő þę äväįľäþľę ƒőř ūşäģę.", + "error": "Ēřřőř ūpđäŧįʼnģ pľūģįʼn:", + "header": "Ŧĥę ƒőľľőŵįʼnģ pľūģįʼnş ĥävę ūpđäŧę äväįľäþľę", + "installed-header": "Ĩʼnşŧäľľęđ", + "modal-confirmation": "Ůpđäŧę", + "modal-dismiss": "Cľőşę", + "modal-title": "Ůpđäŧę Pľūģįʼnş", + "name-header": "Ńämę", + "update-header": "Ůpđäŧę" + } + }, "details": { "labels": { "dependencies": "Đępęʼnđęʼnčįęş",