diff --git a/api/controllers/console/workspace/trigger_providers.py b/api/controllers/console/workspace/trigger_providers.py index 268473d6d1..78d41aba07 100644 --- a/api/controllers/console/workspace/trigger_providers.py +++ b/api/controllers/console/workspace/trigger_providers.py @@ -1,7 +1,9 @@ import logging +from typing import Any from flask import make_response, redirect, request from flask_restx import Resource, reqparse +from pydantic import BaseModel, Field from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, Forbidden @@ -32,6 +34,19 @@ from ..wraps import ( logger = logging.getLogger(__name__) +class TriggerSubscriptionUpdateRequest(BaseModel): + """Request payload for updating a trigger subscription""" + + name: str | None = Field(default=None, description="Subscription instance name") + properties: dict[str, Any] | None = Field(default=None, description="Subscription properties") + + +console_ns.schema_model( + TriggerSubscriptionUpdateRequest.__name__, + TriggerSubscriptionUpdateRequest.model_json_schema(ref_template="#/definitions/{model}"), +) + + @console_ns.route("/workspaces/current/trigger-provider//icon") class TriggerProviderIconApi(Resource): @setup_required @@ -289,6 +304,38 @@ class TriggerSubscriptionBuilderBuildApi(Resource): raise ValueError(str(e)) from e +@console_ns.route( + "/workspaces/current/trigger-provider//subscriptions/update", +) +class TriggerSubscriptionUpdateApi(Resource): + @console_ns.expect(console_ns.models[TriggerSubscriptionUpdateRequest.__name__]) + @setup_required + @login_required + @edit_permission_required + @account_initialization_required + def post(self, subscription_id: str): + """Update a subscription instance""" + user = current_user + assert user.current_tenant_id is not None + + args = TriggerSubscriptionUpdateRequest.model_validate(console_ns.payload) + + try: + return jsonable_encoder( + TriggerProviderService.update_trigger_subscription( + tenant_id=user.current_tenant_id, + subscription_id=subscription_id, + name=args.name, + properties=args.properties, + ) + ) + except ValueError as e: + raise BadRequest(str(e)) + except Exception as e: + logger.exception("Error updating subscription", exc_info=e) + raise + + @console_ns.route( "/workspaces/current/trigger-provider//subscriptions/delete", ) diff --git a/api/core/trigger/utils/encryption.py b/api/core/trigger/utils/encryption.py index 026a65aa23..b12291e299 100644 --- a/api/core/trigger/utils/encryption.py +++ b/api/core/trigger/utils/encryption.py @@ -67,12 +67,16 @@ def create_trigger_provider_encrypter_for_subscription( def delete_cache_for_subscription(tenant_id: str, provider_id: str, subscription_id: str): - cache = TriggerProviderCredentialsCache( + TriggerProviderCredentialsCache( tenant_id=tenant_id, provider_id=provider_id, credential_id=subscription_id, - ) - cache.delete() + ).delete() + TriggerProviderPropertiesCache( + tenant_id=tenant_id, + provider_id=provider_id, + subscription_id=subscription_id, + ).delete() def create_trigger_provider_encrypter_for_properties( diff --git a/api/services/trigger/trigger_provider_service.py b/api/services/trigger/trigger_provider_service.py index 668e4c5be2..d2ad7b9251 100644 --- a/api/services/trigger/trigger_provider_service.py +++ b/api/services/trigger/trigger_provider_service.py @@ -209,6 +209,77 @@ class TriggerProviderService: logger.exception("Failed to add trigger provider") raise ValueError(str(e)) + @classmethod + def update_trigger_subscription( + cls, + tenant_id: str, + subscription_id: str, + name: str | None = None, + properties: Mapping[str, Any] | None = None, + ) -> Mapping[str, Any]: + """ + Update an existing trigger subscription. + + :param tenant_id: Tenant ID + :param subscription_id: Subscription instance ID + :param name: Optional new name for this subscription + :param properties: Optional new properties + :return: Success response with updated subscription info + """ + with Session(db.engine, expire_on_commit=False) as session: + # Use distributed lock to prevent race conditions on the same subscription + lock_key = f"trigger_subscription_update_lock:{tenant_id}_{subscription_id}" + with redis_client.lock(lock_key, timeout=20): + subscription: TriggerSubscription | None = ( + session.query(TriggerSubscription).filter_by(tenant_id=tenant_id, id=subscription_id).first() + ) + if not subscription: + raise ValueError(f"Trigger subscription {subscription_id} not found") + + provider_id = TriggerProviderID(subscription.provider_id) + provider_controller = TriggerManager.get_trigger_provider(tenant_id, provider_id) + + # Check for name uniqueness if name is being updated + if name is not None and name != subscription.name: + existing = ( + session.query(TriggerSubscription) + .filter_by(tenant_id=tenant_id, provider_id=str(provider_id), name=name) + .first() + ) + if existing: + raise ValueError(f"Subscription name '{name}' already exists for this provider") + subscription.name = name + + # Update properties if provided + if properties is not None: + properties_encrypter, properties_cache = create_provider_encrypter( + tenant_id=tenant_id, + config=provider_controller.get_properties_schema(), + cache=NoOpProviderCredentialCache(), + ) + # Handle hidden values - preserve original encrypted values + original_properties = properties_encrypter.decrypt(subscription.properties) + new_properties: dict[str, Any] = { + key: value if value != HIDDEN_VALUE else original_properties.get(key, UNKNOWN_VALUE) + for key, value in properties.items() + } + subscription.properties = dict(properties_encrypter.encrypt(new_properties)) + properties_cache.delete() + + session.commit() + + # Clear subscription cache + delete_cache_for_subscription( + tenant_id=tenant_id, + provider_id=subscription.provider_id, + subscription_id=subscription.id, + ) + + return { + "result": "success", + "id": str(subscription.id), + } + @classmethod def get_subscription_by_id(cls, tenant_id: str, subscription_id: str | None = None) -> TriggerSubscription | None: """ diff --git a/api/services/trigger/trigger_subscription_builder_service.py b/api/services/trigger/trigger_subscription_builder_service.py index 571393c782..37f852da3e 100644 --- a/api/services/trigger/trigger_subscription_builder_service.py +++ b/api/services/trigger/trigger_subscription_builder_service.py @@ -453,11 +453,12 @@ class TriggerSubscriptionBuilderService: if not subscription_builder: return None - # response to validation endpoint - controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( - tenant_id=subscription_builder.tenant_id, provider_id=TriggerProviderID(subscription_builder.provider_id) - ) try: + # response to validation endpoint + controller: PluginTriggerProviderController = TriggerManager.get_trigger_provider( + tenant_id=subscription_builder.tenant_id, + provider_id=TriggerProviderID(subscription_builder.provider_id), + ) dispatch_response: TriggerDispatchResponse = controller.dispatch( request=request, subscription=subscription_builder.to_subscription(), diff --git a/web/app/components/plugins/plugin-detail-panel/index.tsx b/web/app/components/plugins/plugin-detail-panel/index.tsx index 380d2329f6..e790eb6436 100644 --- a/web/app/components/plugins/plugin-detail-panel/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/index.tsx @@ -66,7 +66,7 @@ const PluginDetailPanel: FC = ({
{detail.declaration.category === PluginCategoryEnum.trigger && ( <> - + )} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/edit-modal.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit-modal.tsx new file mode 100644 index 0000000000..96a7472d96 --- /dev/null +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/edit-modal.tsx @@ -0,0 +1,151 @@ +'use client' +import { BaseForm } from '@/app/components/base/form/components/base' +import type { FormRefObject, FormSchema } from '@/app/components/base/form/types' +import { FormTypeEnum } from '@/app/components/base/form/types' +import Modal from '@/app/components/base/modal/modal' +import Toast from '@/app/components/base/toast' +import type { ParametersSchema, PluginDetail } from '@/app/components/plugins/types' +import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance' +import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' +import { useUpdateTriggerSubscription } from '@/service/use-triggers' +import { useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { usePluginStore } from '../store' +import { useSubscriptionList } from './use-subscription-list' +import { ReadmeShowType } from '../../readme-panel/store' + +type Props = { + onClose: () => void + subscription: TriggerSubscription + pluginDetail?: PluginDetail +} + +// Normalize backend type to FormTypeEnum +const normalizeFormType = (type: string): FormTypeEnum => { + switch (type) { + case 'string': + case 'text': + return FormTypeEnum.textInput + case 'password': + case 'secret': + return FormTypeEnum.secretInput + case 'number': + case 'integer': + return FormTypeEnum.textNumber + case 'boolean': + return FormTypeEnum.boolean + case 'select': + return FormTypeEnum.select + default: + if (Object.values(FormTypeEnum).includes(type as FormTypeEnum)) + return type as FormTypeEnum + return FormTypeEnum.textInput + } +} + +export const EditModal = ({ onClose, subscription, pluginDetail }: Props) => { + const { t } = useTranslation() + const detail = usePluginStore(state => state.detail) + const { refetch } = useSubscriptionList() + + const { mutate: updateSubscription, isPending: isUpdating } = useUpdateTriggerSubscription() + + const propertiesSchema = useMemo( + () => detail?.declaration?.trigger?.subscription_schema || [], + [detail?.declaration?.trigger?.subscription_schema], + ) + const formRef = useRef(null) + + const handleConfirm = () => { + // Use needTransformWhenSecretFieldIsPristine to handle secret fields + // When secret field is not modified, it will be transformed to '[__HIDDEN__]' + // Backend will preserve original value when receiving '[__HIDDEN__]' + const formValues = formRef.current?.getFormValues({ + needTransformWhenSecretFieldIsPristine: true, + }) + if (!formValues?.isCheckValidated) + return + + const name = formValues.values.subscription_name as string + + // Extract properties values (exclude subscription_name and callback_url) + const properties = { ...formValues.values } + delete properties.subscription_name + delete properties.callback_url + + updateSubscription( + { + subscriptionId: subscription.id, + name, + properties: Object.keys(properties).length > 0 ? properties : undefined, + }, + { + onSuccess: () => { + Toast.notify({ + type: 'success', + message: t('pluginTrigger.subscription.list.item.actions.edit.success'), + }) + refetch?.() + onClose() + }, + onError: (error: any) => { + Toast.notify({ + type: 'error', + message: error?.message || t('pluginTrigger.subscription.list.item.actions.edit.error'), + }) + }, + }, + ) + } + + const formSchemas: FormSchema[] = useMemo(() => [ + { + name: 'subscription_name', + label: t('pluginTrigger.modal.form.subscriptionName.label'), + placeholder: t('pluginTrigger.modal.form.subscriptionName.placeholder'), + type: FormTypeEnum.textInput, + required: true, + default: subscription.name, + }, + { + name: 'callback_url', + label: t('pluginTrigger.modal.form.callbackUrl.label'), + placeholder: t('pluginTrigger.modal.form.callbackUrl.placeholder'), + type: FormTypeEnum.textInput, + required: false, + default: subscription.endpoint || '', + disabled: true, + tooltip: t('pluginTrigger.modal.form.callbackUrl.tooltip'), + showCopy: true, + }, + ...propertiesSchema.map((schema: ParametersSchema) => ({ + ...schema, + type: normalizeFormType(schema.type as string), + tooltip: schema.description, + default: (subscription.properties as Record)?.[schema.name] ?? schema.default, + })), + ], [t, subscription.name, subscription.endpoint, subscription.properties, propertiesSchema]) + + return ( + + {pluginDetail && ( + + )} + + + ) +} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx index 8acb8f40df..e1f96eabc5 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/index.tsx @@ -1,5 +1,6 @@ import { withErrorBoundary } from '@/app/components/base/error-boundary' import Loading from '@/app/components/base/loading' +import type { PluginDetail } from '@/app/components/plugins/types' import { SubscriptionListView } from './list-view' import { SubscriptionSelectorView } from './selector-view' import { useSubscriptionList } from './use-subscription-list' @@ -18,6 +19,7 @@ type SubscriptionListProps = { mode?: SubscriptionListMode selectedId?: string onSelect?: (v: SimpleSubscription, callback?: () => void) => void + pluginDetail?: PluginDetail } export { SubscriptionSelectorEntry } from './selector-entry' @@ -26,6 +28,7 @@ export const SubscriptionList = withErrorBoundary(({ mode = SubscriptionListMode.PANEL, selectedId, onSelect, + pluginDetail, }: SubscriptionListProps) => { const { isLoading, refetch } = useSubscriptionList() if (isLoading) { @@ -47,5 +50,5 @@ export const SubscriptionList = withErrorBoundary(({ ) } - return + return }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx index a64d2f4070..2fface8f19 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/list-view.tsx @@ -1,5 +1,6 @@ 'use client' import Tooltip from '@/app/components/base/tooltip' +import type { PluginDetail } from '@/app/components/plugins/types' import cn from '@/utils/classnames' import React from 'react' import { useTranslation } from 'react-i18next' @@ -9,10 +10,12 @@ import { useSubscriptionList } from './use-subscription-list' type SubscriptionListViewProps = { showTopBorder?: boolean + pluginDetail?: PluginDetail } export const SubscriptionListView: React.FC = ({ showTopBorder = false, + pluginDetail, }) => { const { t } = useTranslation() const { subscriptions } = useSubscriptionList() @@ -41,6 +44,7 @@ export const SubscriptionListView: React.FC = ({ ))}
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx index b2a86b5c76..7e5641eae4 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/subscription-card.tsx @@ -1,26 +1,34 @@ 'use client' import ActionButton from '@/app/components/base/action-button' import Tooltip from '@/app/components/base/tooltip' +import type { PluginDetail } from '@/app/components/plugins/types' import type { TriggerSubscription } from '@/app/components/workflow/block-selector/types' import cn from '@/utils/classnames' import { RiDeleteBinLine, + RiEditLine, RiWebhookLine, } from '@remixicon/react' import { useBoolean } from 'ahooks' import { useTranslation } from 'react-i18next' import { DeleteConfirm } from './delete-confirm' +import { EditModal } from './edit-modal' type Props = { data: TriggerSubscription + pluginDetail?: PluginDetail } -const SubscriptionCard = ({ data }: Props) => { +const SubscriptionCard = ({ data, pluginDetail }: Props) => { const { t } = useTranslation() const [isShowDeleteModal, { setTrue: showDeleteModal, setFalse: hideDeleteModal, }] = useBoolean(false) + const [isShowEditModal, { + setTrue: showEditModal, + setFalse: hideEditModal, + }] = useBoolean(false) return ( <> @@ -40,12 +48,20 @@ const SubscriptionCard = ({ data }: Props) => { - - - +
+ + + + + + +
@@ -78,6 +94,14 @@ const SubscriptionCard = ({ data }: Props) => { workflowsInUse={data.workflows_in_use} /> )} + + {isShowEditModal && ( + + )} ) } diff --git a/web/i18n/en-US/common.ts b/web/i18n/en-US/common.ts index 11cc866fde..45f0d7dd31 100644 --- a/web/i18n/en-US/common.ts +++ b/web/i18n/en-US/common.ts @@ -18,6 +18,7 @@ const translation = { cancel: 'Cancel', clear: 'Clear', save: 'Save', + saving: 'Saving...', yes: 'Yes', no: 'No', deleteConfirmTitle: 'Delete?', diff --git a/web/i18n/en-US/plugin-trigger.ts b/web/i18n/en-US/plugin-trigger.ts index aedd0c6225..89c604362b 100644 --- a/web/i18n/en-US/plugin-trigger.ts +++ b/web/i18n/en-US/plugin-trigger.ts @@ -30,6 +30,11 @@ const translation = { unauthorized: 'Manual', }, actions: { + edit: { + title: 'Edit Subscription', + success: 'Subscription updated successfully', + error: 'Failed to update subscription', + }, delete: 'Delete', deleteConfirm: { title: 'Delete {{name}}?', diff --git a/web/service/use-triggers.ts b/web/service/use-triggers.ts index 67522d2e55..030c570f29 100644 --- a/web/service/use-triggers.ts +++ b/web/service/use-triggers.ts @@ -211,6 +211,25 @@ export const useDeleteTriggerSubscription = () => { }) } +export type UpdateTriggerSubscriptionPayload = { + subscriptionId: string + name?: string + properties?: Record +} + +export const useUpdateTriggerSubscription = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'update-subscription'], + mutationFn: (payload: UpdateTriggerSubscriptionPayload) => { + const { subscriptionId, ...body } = payload + return post<{ result: string; id: string }>( + `/workspaces/current/trigger-provider/${subscriptionId}/subscriptions/update`, + { body }, + ) + }, + }) +} + export const useTriggerSubscriptionBuilderLogs = ( provider: string, subscriptionBuilderId: string,