From 9ac71329a428cbbc51ceed3ab0f6e1aeadabf8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=86=E8=90=8C=E9=97=B7=E6=B2=B9=E7=93=B6?= <253605712@qq.com> Date: Wed, 10 Jun 2026 16:11:11 +0800 Subject: [PATCH] fix(plugin): align plugin list endpoint counts with live endpoint state (#37179) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/workspace/plugin.py | 6 +- api/core/plugin/impl/endpoint.py | 10 +- api/core/plugin/plugin_service.py | 106 +++++++++++++++++- .../console/workspace/test_plugin.py | 8 +- .../plugin/impl/test_endpoint_client_impl.py | 6 +- .../services/plugin/test_plugin_service.py | 95 ++++++++++++++++ .../__tests__/endpoint-list.spec.tsx | 12 +- .../plugin-detail-panel/endpoint-list.tsx | 23 ++-- web/service/use-plugins.spec.tsx | 100 +++++++++++++++++ web/service/use-plugins.ts | 2 + 10 files changed, 345 insertions(+), 23 deletions(-) create mode 100644 web/service/use-plugins.spec.tsx diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index a30a54b945..d0538f2de2 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -19,6 +19,7 @@ from controllers.console.wraps import ( setup_required, with_current_tenant_id, with_current_user, + with_current_user_id, ) from core.plugin.impl.exc import PluginDaemonClientSideError from core.plugin.plugin_service import PluginService @@ -224,11 +225,12 @@ class PluginListApi(Resource): @setup_required @login_required @account_initialization_required + @with_current_user_id @with_current_tenant_id - def get(self, tenant_id: str): + def get(self, tenant_id: str, user_id: str): args = ParserList.model_validate(request.args.to_dict(flat=True)) try: - plugins_with_total = PluginService.list_with_total(tenant_id, args.page, args.page_size) + plugins_with_total = PluginService.list_with_total(tenant_id, user_id, args.page, args.page_size) except PluginDaemonClientSideError as e: return {"code": "plugin_error", "message": e.description}, 400 diff --git a/api/core/plugin/impl/endpoint.py b/api/core/plugin/impl/endpoint.py index b335b42763..015afd9a0f 100644 --- a/api/core/plugin/impl/endpoint.py +++ b/api/core/plugin/impl/endpoint.py @@ -36,7 +36,10 @@ class PluginEndpointClient(BasePluginClient): def list_endpoints(self, tenant_id: str, user_id: str, page: int, page_size: int): """ - List all endpoints for the given tenant and user. + List all endpoints for the given tenant. + + The daemon list route binds only tenant and pagination fields; user_id is + retained in this client signature for consistency with endpoint services. """ return self._request_with_plugin_daemon_response( "GET", @@ -47,7 +50,10 @@ class PluginEndpointClient(BasePluginClient): def list_endpoints_for_single_plugin(self, tenant_id: str, user_id: str, plugin_id: str, page: int, page_size: int): """ - List all endpoints for the given tenant, user and plugin. + List all endpoints for the given tenant and plugin. + + The daemon list route binds tenant, plugin and pagination fields; user_id + is retained in this client signature for consistency with endpoint services. """ return self._request_with_plugin_daemon_response( "GET", diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index a88ddb5f3d..50b35afbcd 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -4,6 +4,12 @@ This module owns plugin daemon management calls that are shared by API services and core runtimes. Plugin model provider discovery is cached here, alongside plugin install, uninstall, and upgrade invalidation, so all cache mutations for plugin-owned provider metadata stay tenant-scoped and in one place. + +The console plugin list also normalizes endpoint setup counters against live +endpoint records. Some plugin daemon builds return stale ``endpoints_*`` +aggregates in ``management/list`` even while plugin-scoped endpoint queries are +current, so the API reconciles those counts before serving workspace plugin +metadata. """ import logging @@ -38,6 +44,7 @@ from core.plugin.entities.plugin_daemon import ( ) from core.plugin.impl.asset import PluginAssetManager from core.plugin.impl.debugging import PluginDebuggingClient +from core.plugin.impl.endpoint import PluginEndpointClient from core.plugin.impl.model import PluginModelClient from core.plugin.impl.plugin import PluginInstaller from extensions.ext_database import db @@ -69,6 +76,9 @@ class PluginService: REDIS_TTL = 60 * 5 # 5 minutes PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX = "plugin_model_providers:tenant_id:" PLUGIN_INSTALL_TASK_TERMINAL_STATUSES = (PluginInstallTaskStatus.Success, PluginInstallTaskStatus.Failed) + # Mirror the detail-panel endpoint query size so list reconciliation and + # the visible endpoint drawer exercise the same daemon pagination path. + ENDPOINT_RECONCILIATION_PAGE_SIZE = 100 @classmethod def _get_plugin_model_providers_cache_key(cls, tenant_id: str) -> str: @@ -287,14 +297,104 @@ class PluginService: return plugins @staticmethod - def list_with_total(tenant_id: str, page: int, page_size: int) -> PluginListResponse: - """ - list all plugins of the tenant + def list_with_total(tenant_id: str, user_id: str, page: int, page_size: int) -> PluginListResponse: + """List tenant plugins with endpoint counts reconciled from live records. + + The plugin daemon's ``management/list`` payload is tenant-scoped, but + some daemon builds undercount or stale-cache plugin endpoint aggregates. + The list response therefore refreshes counters from the daemon's + tenant-scoped endpoint records before returning workspace plugin metadata. """ manager = PluginInstaller() plugins = manager.list_plugins_with_total(tenant_id, page, page_size) + PluginService._reconcile_endpoint_counts(tenant_id, user_id, plugins.list) return plugins + @staticmethod + def _normalize_endpoint_count(value: object) -> int: + """Convert daemon endpoint counters to safe non-negative integers. + + Some daemon builds use ``-1`` as an "unknown / not synced yet" sentinel + for endpoint counters. That value is acceptable internally as a daemon + transport detail, but it must never leak through the console API because + the UI displays these counters directly. + """ + if value is None: + return 0 + + if isinstance(value, bool): + return int(value) + + if isinstance(value, int): + return max(0, value) + + if isinstance(value, str): + try: + return max(0, int(value)) + except ValueError: + return 0 + + return 0 + + @classmethod + def _normalize_plugin_endpoint_counts(cls, plugin: PluginEntity) -> None: + """Clamp endpoint counters on plugin entities before returning them.""" + plugin.endpoints_setups = cls._normalize_endpoint_count(plugin.endpoints_setups) + plugin.endpoints_active = cls._normalize_endpoint_count(plugin.endpoints_active) + + @classmethod + def _reconcile_endpoint_counts(cls, tenant_id: str, user_id: str, plugins: Sequence[PluginEntity]) -> None: + """Refresh endpoint counters from live plugin endpoint records. + + ``management/list`` is the source of truth for plugin installations, but + some daemon versions lag when populating ``endpoints_setups`` and + ``endpoints_active``. The plugin-scoped endpoint listing is the same + tenant-scoped source the console detail panel uses after reinstall flows, + so the list view recomputes counts per plugin instead of trusting stale + daemon aggregates. + """ + endpoint_client = PluginEndpointClient() + + for plugin in plugins: + cls._normalize_plugin_endpoint_counts(plugin) + + if plugin.declaration.endpoint is None: + continue + + page = 1 + endpoints_setups = 0 + endpoints_active = 0 + + try: + while True: + endpoints = endpoint_client.list_endpoints_for_single_plugin( + tenant_id=tenant_id, + user_id=user_id, + plugin_id=plugin.plugin_id, + page=page, + page_size=cls.ENDPOINT_RECONCILIATION_PAGE_SIZE, + ) + endpoints_setups += len(endpoints) + endpoints_active += sum(int(endpoint.enabled) for endpoint in endpoints) + + if len(endpoints) < cls.ENDPOINT_RECONCILIATION_PAGE_SIZE: + break + page += 1 + except Exception: + logger.warning( + ( + "Failed to reconcile live endpoint counters for tenant %s plugin %s; " + "falling back to daemon plugin stats." + ), + tenant_id, + plugin.plugin_id, + exc_info=True, + ) + continue + + plugin.endpoints_setups = cls._normalize_endpoint_count(endpoints_setups) + plugin.endpoints_active = cls._normalize_endpoint_count(endpoints_active) + @staticmethod def list_installations_from_ids(tenant_id: str, ids: Sequence[str]) -> Sequence[PluginInstallation]: """ diff --git a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py index da010558bc..2f9c7d4fd6 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_plugin.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_plugin.py @@ -131,11 +131,15 @@ class TestPluginListApi: with ( app.test_request_context("/?page=1&page_size=10"), - patch("controllers.console.workspace.plugin.PluginService.list_with_total", return_value=mock_list), + patch( + "controllers.console.workspace.plugin.PluginService.list_with_total", + return_value=mock_list, + ) as mock_list_with_total, ): - result = method(api, "t1") + result = method(api, "t1", "u1") assert result["total"] == 1 + mock_list_with_total.assert_called_once_with("t1", "u1", 1, 10) class TestPluginIconApi: diff --git a/api/tests/unit_tests/core/plugin/impl/test_endpoint_client_impl.py b/api/tests/unit_tests/core/plugin/impl/test_endpoint_client_impl.py index 7a24cc01d1..7c9f46be09 100644 --- a/api/tests/unit_tests/core/plugin/impl/test_endpoint_client_impl.py +++ b/api/tests/unit_tests/core/plugin/impl/test_endpoint_client_impl.py @@ -37,7 +37,11 @@ class TestPluginEndpointClientImpl: assert result == ["endpoint"] assert request_mock.call_args.args[1] == "plugin/tenant-1/endpoint/list/plugin" - assert request_mock.call_args.kwargs["params"] == {"plugin_id": "org/plugin", "page": 1, "page_size": 10} + assert request_mock.call_args.kwargs["params"] == { + "plugin_id": "org/plugin", + "page": 1, + "page_size": 10, + } def test_update_endpoint(self, mocker: MockerFixture): client = PluginEndpointClient() diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service.py b/api/tests/unit_tests/services/plugin/test_plugin_service.py index 42edd5582b..f7b8940118 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service.py @@ -221,6 +221,101 @@ class TestPluginModelProviderCache: redis_client.delete.assert_called_once_with("plugin_model_providers:tenant_id:tenant-1") +class TestPluginListEndpointCounts: + def test_list_with_total_reconciles_live_endpoint_counts_for_endpoint_plugins(self) -> None: + """Endpoint-enabled plugins use plugin-scoped live endpoint records instead of stale daemon aggregates.""" + wecom_plugin = SimpleNamespace( + plugin_id="langgenius/wecom-bot", + endpoints_setups=0, + endpoints_active=0, + declaration=SimpleNamespace(endpoint=object()), + ) + tool_plugin = SimpleNamespace( + plugin_id="langgenius/openai", + endpoints_setups=0, + endpoints_active=0, + declaration=SimpleNamespace(endpoint=None), + ) + paged_plugins = SimpleNamespace(list=[wecom_plugin, tool_plugin], total=2) + endpoints = [ + SimpleNamespace(plugin_id="langgenius/wecom-bot", enabled=True), + SimpleNamespace(plugin_id="langgenius/wecom-bot", enabled=True), + SimpleNamespace(plugin_id="langgenius/wecom-bot", enabled=True), + ] + + with ( + patch(f"{MODULE}.PluginInstaller") as installer_cls, + patch(f"{MODULE}.PluginEndpointClient") as endpoint_client_cls, + ): + installer_cls.return_value.list_plugins_with_total.return_value = paged_plugins + endpoint_client_cls.return_value.list_endpoints_for_single_plugin.return_value = endpoints + + from core.plugin.plugin_service import PluginService + + result = PluginService.list_with_total("tenant-1", "user-1", 1, 100) + + assert result is paged_plugins + assert wecom_plugin.endpoints_setups == 3 + assert wecom_plugin.endpoints_active == 3 + assert tool_plugin.endpoints_setups == 0 + assert tool_plugin.endpoints_active == 0 + endpoint_client_cls.return_value.list_endpoints_for_single_plugin.assert_called_once_with( + tenant_id="tenant-1", + user_id="user-1", + plugin_id="langgenius/wecom-bot", + page=1, + page_size=PluginService.ENDPOINT_RECONCILIATION_PAGE_SIZE, + ) + + def test_list_with_total_falls_back_to_sanitized_daemon_counts_when_reconciliation_fails(self) -> None: + """Best-effort reconciliation still clamps daemon sentinel values before returning the list response.""" + wecom_plugin = SimpleNamespace( + plugin_id="langgenius/wecom-bot", + endpoints_setups=-1, + endpoints_active=-1, + declaration=SimpleNamespace(endpoint=object()), + ) + paged_plugins = SimpleNamespace(list=[wecom_plugin], total=1) + + with ( + patch(f"{MODULE}.PluginInstaller") as installer_cls, + patch(f"{MODULE}.PluginEndpointClient") as endpoint_client_cls, + ): + installer_cls.return_value.list_plugins_with_total.return_value = paged_plugins + endpoint_client_cls.return_value.list_endpoints_for_single_plugin.side_effect = RuntimeError( + "endpoint daemon unavailable" + ) + + from core.plugin.plugin_service import PluginService + + result = PluginService.list_with_total("tenant-1", "user-1", 1, 100) + + assert result is paged_plugins + assert wecom_plugin.endpoints_setups == 0 + assert wecom_plugin.endpoints_active == 0 + + def test_list_with_total_clamps_negative_daemon_counts_for_plugins_without_endpoints(self) -> None: + """Plugins that do not expose endpoint setup APIs must still never return negative counters.""" + tool_plugin = SimpleNamespace( + plugin_id="langgenius/openai", + endpoints_setups=-1, + endpoints_active=-1, + declaration=SimpleNamespace(endpoint=None), + ) + paged_plugins = SimpleNamespace(list=[tool_plugin], total=1) + + with patch(f"{MODULE}.PluginInstaller") as installer_cls: + installer_cls.return_value.list_plugins_with_total.return_value = paged_plugins + + from core.plugin.plugin_service import PluginService + + result = PluginService.list_with_total("tenant-1", "user-1", 1, 100) + + assert result is paged_plugins + assert tool_plugin.endpoints_setups == 0 + assert tool_plugin.endpoints_active == 0 + + class TestPluginModelProviderCacheInvalidation: def test_fetch_install_task_invalidates_model_provider_cache_when_finished(self) -> None: """Finished plugin install tasks invalidate tenant provider cache.""" diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx index ec9a8eeccf..d491de3ab1 100644 --- a/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/__tests__/endpoint-list.spec.tsx @@ -1,5 +1,5 @@ import type { PluginDetail } from '@/app/components/plugins/types' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import EndpointList from '../endpoint-list' @@ -18,6 +18,7 @@ const mockEndpoints = [ let mockEndpointListData: { endpoints: typeof mockEndpoints } | undefined const mockInvalidateEndpointList = vi.fn() +const mockInvalidateInstalledPluginList = vi.fn() const mockCreateEndpoint = vi.fn() vi.mock('@/service/use-endpoints', () => ({ @@ -31,6 +32,10 @@ vi.mock('@/service/use-endpoints', () => ({ }), })) +vi.mock('@/service/use-plugins', () => ({ + useInvalidateInstalledPluginList: () => mockInvalidateInstalledPluginList, +})) + vi.mock('@/app/components/tools/utils/to-form-schema', () => ({ toolCredentialToFormSchemas: (schemas: unknown[]) => schemas, })) @@ -178,7 +183,10 @@ describe('EndpointList', () => { fireEvent.click(getAddButton()) fireEvent.click(screen.getByTestId('modal-save')) - expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin') + return waitFor(() => { + expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin') + expect(mockInvalidateInstalledPluginList).toHaveBeenCalled() + }) }) it('should pass correct params to createEndpoint', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx index 99e24a6317..4dfdba5829 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx @@ -2,11 +2,6 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' -import { - RiAddLine, - RiApps2AddLine, - RiBookOpenLine, -} from '@remixicon/react' import { useBoolean } from 'ahooks' import * as React from 'react' import { useMemo } from 'react' @@ -19,6 +14,7 @@ import { useEndpointList, useInvalidateEndpointList, } from '@/service/use-endpoints' +import { useInvalidateInstalledPluginList } from '@/service/use-plugins' import EndpointCard from './endpoint-card' import EndpointModal from './endpoint-modal' import { NAME_FIELD } from './utils' @@ -34,6 +30,7 @@ const EndpointList = ({ detail }: Props) => { const showTopBorder = detail.declaration.tool const { data } = useEndpointList(detail.plugin_id) const invalidateEndpointList = useInvalidateEndpointList() + const invalidateInstalledPluginList = useInvalidateInstalledPluginList() const [isShowEndpointModal, { setTrue: showEndpointModal, @@ -47,6 +44,7 @@ const EndpointList = ({ detail }: Props) => { const { mutate: createEndpoint } = useCreateEndpoint({ onSuccess: async () => { await invalidateEndpointList(detail.plugin_id) + invalidateInstalledPluginList() hideEndpointModal() }, onError: () => { @@ -86,7 +84,7 @@ const EndpointList = ({ detail }: Props) => { >
- +
{t('detailPanel.endpointsTip', { ns: 'plugin' })}
{ rel="noopener noreferrer" className="inline-flex cursor-pointer items-center gap-1 system-xs-regular text-text-accent" > - + {t('detailPanel.endpointsDocLink', { ns: 'plugin' })}
@@ -106,18 +104,21 @@ const EndpointList = ({ detail }: Props) => { aria-label={t('detailPanel.endpointModalTitle', { ns: 'plugin' })} onClick={showEndpointModal} > - + {data.endpoints.length === 0 && (
{t('detailPanel.endpointsEmpty', { ns: 'plugin' })}
)}
- {data.endpoints.map((item, index) => ( + {data.endpoints.map(item => ( invalidateEndpointList(detail.plugin_id)} + handleChange={() => { + invalidateEndpointList(detail.plugin_id) + invalidateInstalledPluginList() + }} pluginDetail={detail} /> ))} diff --git a/web/service/use-plugins.spec.tsx b/web/service/use-plugins.spec.tsx new file mode 100644 index 0000000000..48dd455212 --- /dev/null +++ b/web/service/use-plugins.spec.tsx @@ -0,0 +1,100 @@ +import type { ReactNode } from 'react' +import type { InstalledPluginListWithTotalResponse } from '@/app/components/plugins/types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useInstalledPluginList } from './use-plugins' + +const mockGet = vi.hoisted(() => vi.fn()) + +vi.mock('./base', () => ({ + get: mockGet, + getMarketplace: vi.fn(), + post: vi.fn(), + postMarketplace: vi.fn(), +})) + +vi.mock('./common', () => ({ + fetchModelProviderModelList: vi.fn(), +})) + +vi.mock('./plugins', () => ({ + fetchPluginInfoFromMarketPlace: vi.fn(), + uninstallPlugin: vi.fn(), +})) + +vi.mock('./use-tools', () => ({ + useInvalidateAllBuiltInTools: () => vi.fn(), +})) + +vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({ + default: () => ({ refreshPluginList: vi.fn() }), +})) + +vi.mock('@/app/components/plugins/marketplace/utils', () => ({ + getFormattedPlugin: vi.fn((plugin: unknown) => plugin), +})) + +vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ + default: () => ({ canManagement: true }), +})) + +const createResponse = (endpointsActive: number): InstalledPluginListWithTotalResponse => ({ + plugins: [ + { + plugin_id: 'plugin-1', + endpoints_active: endpointsActive, + endpoints_setups: endpointsActive, + } as unknown as InstalledPluginListWithTotalResponse['plugins'][number], + ], + total: 1, +}) + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const createWrapper = (queryClient: QueryClient) => { + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +describe('useInstalledPluginList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('drops cached plugin list data after unmount so remount fetches fresh counts', async () => { + const queryClient = createQueryClient() + const wrapper = createWrapper(queryClient) + + mockGet + .mockResolvedValueOnce(createResponse(0)) + .mockResolvedValueOnce(createResponse(1)) + + const { result, unmount } = renderHook(() => useInstalledPluginList(), { wrapper }) + + await waitFor(() => { + expect(result.current.data?.plugins[0]?.endpoints_active).toBe(0) + }) + expect(mockGet).toHaveBeenCalledTimes(1) + + unmount() + + await waitFor(() => { + expect(queryClient.getQueryCache().find({ queryKey: ['plugins', 'installedPluginList'] })).toBeUndefined() + }) + + const { result: remountedResult } = renderHook(() => useInstalledPluginList(), { wrapper }) + + await waitFor(() => { + expect(remountedResult.current.data?.plugins[0]?.endpoints_active).toBe(1) + }) + expect(mockGet).toHaveBeenCalledTimes(2) + }) +}) diff --git a/web/service/use-plugins.ts b/web/service/use-plugins.ts index 1222b4513d..c247e8624f 100644 --- a/web/service/use-plugins.ts +++ b/web/service/use-plugins.ts @@ -146,6 +146,7 @@ export const useInstalledPluginList = (disable?: boolean, pageSize = 100) => { isSuccess, } = useInfiniteQuery({ enabled: !disable, + gcTime: 0, queryKey: useInstalledPluginListKey, queryFn: fetchPlugins, getNextPageParam: (lastPage, pages) => { @@ -159,6 +160,7 @@ export const useInstalledPluginList = (disable?: boolean, pageSize = 100) => { return currentPage + 1 }, initialPageParam: 1, + staleTime: 0, }) const plugins = data?.pages.flatMap(page => page.plugins) ?? []