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>
This commit is contained in:
呆萌闷油瓶 2026-06-10 16:11:11 +08:00 committed by GitHub
parent 4fb3210f9a
commit 9ac71329a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 345 additions and 23 deletions

View File

@ -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

View File

@ -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",

View File

@ -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]:
"""

View File

@ -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:

View File

@ -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()

View File

@ -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."""

View File

@ -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', () => {

View File

@ -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) => {
>
<div className="flex flex-col gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<RiApps2AddLine className="size-4 text-text-tertiary" />
<span aria-hidden className="i-ri-apps-2-add-line size-4 text-text-tertiary" />
</div>
<div className="system-xs-regular text-text-tertiary">{t('detailPanel.endpointsTip', { ns: 'plugin' })}</div>
<a
@ -95,7 +93,7 @@ const EndpointList = ({ detail }: Props) => {
rel="noopener noreferrer"
className="inline-flex cursor-pointer items-center gap-1 system-xs-regular text-text-accent"
>
<RiBookOpenLine className="size-3" />
<span aria-hidden className="i-ri-book-open-line size-3" />
{t('detailPanel.endpointsDocLink', { ns: 'plugin' })}
</a>
</div>
@ -106,18 +104,21 @@ const EndpointList = ({ detail }: Props) => {
aria-label={t('detailPanel.endpointModalTitle', { ns: 'plugin' })}
onClick={showEndpointModal}
>
<RiAddLine className="size-4" />
<span aria-hidden className="i-ri-add-line size-4" />
</ActionButton>
</div>
{data.endpoints.length === 0 && (
<div className="mb-1 flex justify-center rounded-[10px] bg-background-section p-3 system-xs-regular text-text-tertiary">{t('detailPanel.endpointsEmpty', { ns: 'plugin' })}</div>
)}
<div className="flex flex-col gap-2">
{data.endpoints.map((item, index) => (
{data.endpoints.map(item => (
<EndpointCard
key={index}
key={item.id}
data={item}
handleChange={() => invalidateEndpointList(detail.plugin_id)}
handleChange={() => {
invalidateEndpointList(detail.plugin_id)
invalidateInstalledPluginList()
}}
pluginDetail={detail}
/>
))}

View File

@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
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)
})
})

View File

@ -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) ?? []