diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index d1485bc1c0..0eee0092cb 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -578,6 +578,25 @@ class PluginUpgradeFromGithubApi(Resource): raise ValueError(e) +@console_ns.route("/workspaces/current/plugin/upgrade/batch") +class PluginBatchUpgradeApi(Resource): + @setup_required + @login_required + @account_initialization_required + @plugin_permission_required(install_required=True) + def post(self): + """ + Batch upgrade all marketplace plugins that have updates available + """ + _, tenant_id = current_account_with_tenant() + + try: + result = PluginService.batch_upgrade_plugins_from_marketplace(tenant_id) + return jsonable_encoder(result) + except PluginDaemonClientSideError as e: + raise ValueError(e) + + @console_ns.route("/workspaces/current/plugin/uninstall") class PluginUninstallApi(Resource): @console_ns.expect(console_ns.models[ParserUninstall.__name__]) diff --git a/api/services/plugin/plugin_service.py b/api/services/plugin/plugin_service.py index 411c335c17..9205a10052 100644 --- a/api/services/plugin/plugin_service.py +++ b/api/services/plugin/plugin_service.py @@ -337,6 +337,91 @@ class PluginService: }, ) + @staticmethod + def batch_upgrade_plugins_from_marketplace(tenant_id: str) -> dict[str, dict]: + """ + Batch upgrade all marketplace plugins that have updates available + + Returns a dict with: + - success: list of successfully upgraded plugins + - failed: list of failed upgrades with error messages + - skipped: list of plugins skipped (no updates or errors) + """ + if not dify_config.MARKETPLACE_ENABLED: + raise ValueError("marketplace is not enabled") + + manager = PluginInstaller() + result = { + "success": [], + "failed": [], + "skipped": [], + } + + # Get all installed plugins + plugins = manager.list_plugins(tenant_id) + + # Filter marketplace plugins only + marketplace_plugins = [plugin for plugin in plugins if plugin.source == PluginInstallationSource.Marketplace] + + if not marketplace_plugins: + return result + + # Get latest versions for all marketplace plugins + plugin_ids = [plugin.plugin_id for plugin in marketplace_plugins] + latest_versions = PluginService.fetch_latest_plugin_version(plugin_ids) + + # Upgrade each plugin if newer version is available + for plugin in marketplace_plugins: + try: + latest_info = latest_versions.get(plugin.plugin_id) + if not latest_info: + result["skipped"].append( + { + "plugin_id": plugin.plugin_id, + "reason": "no_update_info", + "current_version": plugin.version, + } + ) + continue + + # Check if update is needed + if latest_info.version == plugin.version: + result["skipped"].append( + { + "plugin_id": plugin.plugin_id, + "reason": "already_latest", + "current_version": plugin.version, + } + ) + continue + + # Perform upgrade + PluginService.upgrade_plugin_with_marketplace( + tenant_id, plugin.plugin_unique_identifier, latest_info.unique_identifier + ) + + result["success"].append( + { + "plugin_id": plugin.plugin_id, + "from_version": plugin.version, + "to_version": latest_info.version, + "from_identifier": plugin.plugin_unique_identifier, + "to_identifier": latest_info.unique_identifier, + } + ) + + except Exception as e: + logger.exception("Failed to upgrade plugin %s", plugin.plugin_id) + result["failed"].append( + { + "plugin_id": plugin.plugin_id, + "current_version": plugin.version, + "error": str(e), + } + ) + + return result + @staticmethod def upload_pkg(tenant_id: str, pkg: bytes, verify_signature: bool = False) -> PluginDecodeResponse: """ diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index efb665197a..7c068fa422 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -5,14 +5,16 @@ import { RiBookOpenLine, RiDragDropLine, RiEqualizer2Line, + RiRefreshLine, } from '@remixicon/react' import { useBoolean } from 'ahooks' import { noop } from 'es-toolkit/function' import Link from 'next/link' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import TabSlider from '@/app/components/base/tab-slider' +import { useToastContext } from '@/app/components/base/toast' import Tooltip from '@/app/components/base/tooltip' import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal' import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' @@ -20,7 +22,8 @@ import { useGlobalPublicStore } from '@/context/global-public-context' import { useDocLink } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' import { usePluginInstallation } from '@/hooks/use-query-params' -import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' +import { batchUpgradePlugins, fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' +import { useInvalidateInstalledPluginList } from '@/service/use-plugins' import { sleep } from '@/utils' import { cn } from '@/utils/classnames' import { PLUGIN_PAGE_TABS_MAP } from '../hooks' @@ -48,6 +51,8 @@ const PluginPage = ({ const { t } = useTranslation() const docLink = useDocLink() useDocumentTitle(t('metadata.title', { ns: 'plugin' })) + const { notify } = useToastContext() + const invalidateInstalledPluginList = useInvalidateInstalledPluginList() // Use nuqs hook for installation state const [{ packageId, bundleInfo }, setInstallState] = usePluginInstallation() @@ -60,6 +65,9 @@ const PluginPage = ({ setFalse: doHideInstallFromMarketplace, }] = useBoolean(false) + const [isBatchUpgrading, setIsBatchUpgrading] = useState(false) + const [showBatchUpgradeTooltip, setShowBatchUpgradeTooltip] = useState(true) + const hideInstallFromMarketplace = () => { doHideInstallFromMarketplace() setInstallState(null) @@ -134,6 +142,45 @@ const PluginPage = ({ enabled: isPluginsTab && canManagement, }) + const handleBatchUpgrade = useCallback(async () => { + // Hide tooltip immediately when clicked + setShowBatchUpgradeTooltip(false) + setIsBatchUpgrading(true) + try { + const result = await batchUpgradePlugins() + const { success, failed, skipped } = result + + // If there are updates (success or failed), show submitted message + if (success.length > 0 || failed.length > 0) { + notify({ + type: 'success', + message: t('batchUpgrade.submittedMessage', { ns: 'plugin' }), + }) + } + // If all plugins are already up to date (only skipped) + else if (skipped.length > 0) { + notify({ + type: 'info', + message: t('batchUpgrade.noUpdatesMessage', { ns: 'plugin' }), + }) + } + + invalidateInstalledPluginList() + } + catch (error) { + console.error('Failed to batch upgrade plugins:', error) + notify({ + type: 'error', + message: t('batchUpgrade.errorMessage', { ns: 'plugin' }), + }) + } + finally { + setIsBatchUpgrading(false) + // Re-enable tooltip after a short delay + setTimeout(() => setShowBatchUpgradeTooltip(true), 500) + } + }, [t, notify, invalidateInstalledPluginList]) + const { dragging, fileUploader, fileChangeHandle, removeFile } = uploaderProps return (
) } + { + isPluginsTab && canManagement && ( + <> +