diff --git a/api/.importlinter b/api/.importlinter index 99bff19c96d..df3929e6f89 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -423,6 +423,22 @@ forbidden_modules = sqlalchemy werkzeug +[importlinter:contract:file-grant-service-boundary] +name = File grant application service is framework and persistence neutral +type = forbidden +source_modules = + services.file_grant_service + services.entities.file_grant_entities +forbidden_modules = + configs + controllers + extensions + flask + models + repositories + sqlalchemy + werkzeug + [importlinter:contract:account-activation-service-boundary] name = Account activation application service is framework and persistence neutral type = forbidden diff --git a/api/controllers/console/socketio/workflow.py b/api/controllers/console/socketio/workflow.py index 545af703dda..4569310bbe4 100644 --- a/api/controllers/console/socketio/workflow.py +++ b/api/controllers/console/socketio/workflow.py @@ -93,12 +93,10 @@ def handle_collaboration_event(sid, data): 1. mouse_move 2. vars_and_features_update 3. sync_request (ask leader to update graph) - 4. app_state_update - 5. mcp_server_update - 6. workflow_update - 7. comments_update - 8. node_panel_presence - 9. graph_view_state (session reports tab visibility; drives leader election) + 4. workflow_update + 5. comments_update + 6. node_panel_presence + 7. graph_view_state (session reports tab visibility; drives leader election) """ return collaboration_service.relay_collaboration_event(sid, data) diff --git a/api/controllers/files/__init__.py b/api/controllers/files/__init__.py index f8976b86b9f..42b3761b92d 100644 --- a/api/controllers/files/__init__.py +++ b/api/controllers/files/__init__.py @@ -14,12 +14,13 @@ api = ExternalApi( files_ns = Namespace("files", description="File operations", path="/") -from . import image_preview, tool_files, upload +from . import appdeploy_files, image_preview, tool_files, upload api.add_namespace(files_ns) __all__ = [ "api", + "appdeploy_files", "bp", "files_ns", "image_preview", diff --git a/api/controllers/files/appdeploy_files.py b/api/controllers/files/appdeploy_files.py new file mode 100644 index 00000000000..265884cf7df --- /dev/null +++ b/api/controllers/files/appdeploy_files.py @@ -0,0 +1,401 @@ +"""File endpoints reached with an AppDeploy file grant. + +Upload, remote-upload, produce, and resolve authenticate with a Bearer grant; +content authenticates with a per-file token in the query string, because an +```` cannot carry a header. + +They stay on the public ``files`` blueprint rather than moving under +``inner_api``, whose contract is a fully trusted caller holding the master key. +None of the five meets it: ``content`` is a browser-facing surface, ``upload`` +carries an end user's payload, and ``produced`` and ``resolve`` are called by +workers executing third-party plugin code. The one genuinely server-to-server +step, minting the grant, already lives in ``inner_api``. +""" + +from __future__ import annotations + +from typing import IO +from urllib.parse import quote +from uuid import UUID + +from flask import Response, request +from flask_restx import Resource +from pydantic import BaseModel, Field, HttpUrl, ValidationError +from werkzeug.datastructures import FileStorage + +import services +from controllers.common.errors import ( + BlockedFileExtensionError, + FilenameNotExistsError, + FileTooLargeError, + NoFileUploadedError, + RemoteFileUploadError, + TooManyFilesError, + UnsupportedFileTypeError, +) +from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models +from controllers.files import files_ns +from controllers.files.wraps import FileGrantInvalidError, GrantedFileNotFoundError, file_grant_required +from extensions.ext_application_services import application_services +from fields.base import ResponseModel +from fields.file_fields import FileResponse +from fields.file_grant_fields import ResolvedFileResponse +from libs.exception import BaseHTTPException +from libs.helper import dump_response +from services.entities.file_grant_entities import ( + FileContent, + FileGrantClaims, + FileGrantContext, + FileGrantScope, + FileKind, + FileRef, + StoredUpload, +) +from services.errors.file_grant import ( + EndUserNotFoundError, + InvalidFileGrantError, + RemoteFileUnavailableError, + TooManyFileRefsError, +) +from services.file_grant_service import MAX_FILE_GRANT_REFS + +# Everything outside this whitelist is served as an attachment. Produced files +# carry a plugin-declared MIME type, so SVG and the rest of the XML family must +# never render in the viewer's origin. +INLINE_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"}) + + +class InvalidFileRequestError(BaseHTTPException): + error_code = "invalid_request" + description = "The file request is malformed." + code = 400 + + +class FileRefPayload(BaseModel): + id: str + kind: FileKind + + +class RemoteFileUploadPayload(BaseModel): + url: HttpUrl = Field(description="Remote file URL to fetch and store") + + +class FileResolvePayload(BaseModel): + files: list[FileRefPayload] = Field(default_factory=list, max_length=MAX_FILE_GRANT_REFS) + + +class FileContentQuery(BaseModel): + token: str = Field(description="Signed content token scoped to this file") + + +class RemoteFileUploadResponse(FileResponse): + """Dify's upload shape plus the ``url`` key its remote-upload clients read. + + Dify has no service-api remote upload for this endpoint to stand in for, and + the web and console one it does have answers under ``url``. Carrying both + names lets a client of either move over untouched; they hold one URL. + """ + + url: str + + +class ProducedFileResponse(ResponseModel): + id: str + name: str + size: int + mime_type: str | None = None + url: str + internal_url: str + + +class FileResolveResponse(ResponseModel): + files: list[ResolvedFileResponse] + + +register_schema_models(files_ns, RemoteFileUploadPayload, FileResolvePayload) +register_response_schema_models( + files_ns, FileResponse, RemoteFileUploadResponse, ProducedFileResponse, FileResolveResponse +) + + +@files_ns.route("/appdeploy/upload") +class GrantedFileUploadApi(Resource): + """Store one uploaded file against the grant's end user.""" + + @file_grant_required(FileGrantScope.UPLOAD) + @files_ns.doc("grant_upload_file") + @files_ns.doc( + responses={ + 201: "File uploaded", + 400: "No file uploaded, the file has no name, or its extension is blocked", + 401: "Invalid grant", + 403: "Grant lacks the upload scope", + 413: "File too large", + 415: "Unsupported file type", + } + ) + @files_ns.response(201, "File uploaded", files_ns.models[FileResponse.__name__]) + def post(self, grant: FileGrantClaims): + upload = _single_upload() + upload_file = _store_upload( + grant, + filename=upload.filename or "", + stream=upload.stream, + mimetype=upload.mimetype, + ) + return _granted_file_response(upload_file), 201 + + +@files_ns.route("/appdeploy/remote-upload") +class GrantedRemoteFileUploadApi(Resource): + """Fetch a remote URL through the SSRF-safe fetcher and store it.""" + + @file_grant_required(FileGrantScope.UPLOAD) + @files_ns.doc("grant_upload_remote_file") + @files_ns.expect(files_ns.models[RemoteFileUploadPayload.__name__]) + @files_ns.doc( + responses={ + 201: "Remote file uploaded", + 400: "Invalid URL, unfetchable remote file, or a blocked extension", + 401: "Invalid grant", + 403: "Grant lacks the upload scope", + 413: "File too large", + 415: "Unsupported file type", + } + ) + @files_ns.response(201, "Remote file uploaded", files_ns.models[RemoteFileUploadResponse.__name__]) + def post(self, grant: FileGrantClaims): + try: + payload = RemoteFileUploadPayload.model_validate(files_ns.payload or {}) + except ValidationError as exc: + raise InvalidFileRequestError(str(exc)) from exc + + try: + upload_file = application_services().file_grants.store_remote_upload( + context=_grant_context(grant), + url=str(payload.url), + ) + except RemoteFileUnavailableError as exc: + raise RemoteFileUploadError(f"Failed to fetch file from {payload.url}") from exc + except EndUserNotFoundError as exc: + raise GrantedFileNotFoundError() from exc + except services.errors.file.FileTooLargeError as exc: + raise FileTooLargeError(exc.description) from exc + except services.errors.file.BlockedFileExtensionError as exc: + raise BlockedFileExtensionError(exc.description) from exc + except services.errors.file.UnsupportedFileTypeError: + raise UnsupportedFileTypeError() + return _remote_granted_file_response(upload_file), 201 + + +@files_ns.route("/appdeploy/produced") +class ProducedFileApi(Resource): + """Store one file produced by a running workflow node.""" + + @file_grant_required(FileGrantScope.PRODUCE) + @files_ns.doc("grant_upload_produced_file") + @files_ns.doc( + responses={ + 201: "Produced file stored", + 400: "No file uploaded", + 401: "Invalid grant", + 403: "Grant lacks the produce scope", + 413: "File too large", + } + ) + @files_ns.response(201, "Produced file stored", files_ns.models[ProducedFileResponse.__name__]) + def post(self, grant: FileGrantClaims): + upload = _single_upload() + try: + tool_file, access = application_services().file_grants.store_produced( + context=_grant_context(grant), + filename=upload.filename, + stream=upload.stream, + mimetype=upload.mimetype, + ) + except services.errors.file.FileTooLargeError as exc: + raise FileTooLargeError(exc.description) + except EndUserNotFoundError as exc: + raise GrantedFileNotFoundError() from exc + + return ProducedFileResponse( + id=tool_file.id, + name=tool_file.name, + size=tool_file.size, + mime_type=tool_file.mime_type, + url=access.external_url, + internal_url=access.internal_url, + ).model_dump(mode="json"), 201 + + +@files_ns.route("/appdeploy/resolve") +class GrantedFileResolveApi(Resource): + """Re-check ownership and sign fresh URLs at the moment of use.""" + + @file_grant_required(FileGrantScope.RESOLVE) + @files_ns.doc("grant_resolve_files") + @files_ns.expect(files_ns.models[FileResolvePayload.__name__]) + @files_ns.doc( + responses={ + 200: "Files resolved", + 400: "Malformed request", + 401: "Invalid grant", + 403: "Grant lacks the resolve scope", + } + ) + @files_ns.response(200, "Files resolved", files_ns.models[FileResolveResponse.__name__]) + def post(self, grant: FileGrantClaims): + try: + payload = FileResolvePayload.model_validate(files_ns.payload or {}) + except ValidationError as exc: + raise InvalidFileRequestError(str(exc)) from exc + + refs = [FileRef(id=ref.id, kind=ref.kind) for ref in payload.files] + try: + resolved = application_services().file_grants.resolve_file_access( + context=_grant_context(grant), + refs=refs, + ) + except EndUserNotFoundError as exc: + raise GrantedFileNotFoundError() from exc + except TooManyFileRefsError as exc: + raise InvalidFileRequestError(str(exc)) from exc + + return FileResolveResponse( + files=[ResolvedFileResponse.from_resolved(ref.id, file) for ref, file in zip(refs, resolved, strict=True)] + ).model_dump(mode="json") + + +@files_ns.route("/appdeploy//content") +class GrantedFileContentApi(Resource): + """Stream one file's bytes to a holder of its content token.""" + + @files_ns.doc("grant_file_content") + @files_ns.doc(params=query_params_from_model(FileContentQuery)) + @files_ns.doc( + responses={ + 200: "File stream returned", + 401: "Invalid or expired content token", + 404: "File not found", + } + ) + def get(self, file_id: UUID): + try: + query = FileContentQuery.model_validate(request.args.to_dict(flat=True)) + except ValidationError as exc: + raise FileGrantInvalidError() from exc + + try: + content = application_services().file_grants.load_content( + token=query.token, + requested_file_id=str(file_id), + ) + except InvalidFileGrantError as exc: + raise FileGrantInvalidError() from exc + if content is None: + raise GrantedFileNotFoundError() + + return _content_response(content) + + +def _content_response(content: FileContent) -> Response: + mime_type = _normalized_mime_type(content.mime_type) + inline = mime_type in INLINE_MIME_TYPES + + response = Response( + content.stream, + mimetype=mime_type if inline else "application/octet-stream", + direct_passthrough=True, + headers={}, + ) + response.headers["X-Content-Type-Options"] = "nosniff" + if not inline: + encoded_filename = quote(content.name or "") + response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}" + response.headers["Content-Type"] = "application/octet-stream" + # The sibling preview endpoints advertise `Accept-Ranges` for audio and video. + # Every such type downloads here, so the hint could only ever ride on a + # response no player will seek. + if content.size > 0: + response.headers["Content-Length"] = str(content.size) + return response + + +def _normalized_mime_type(mime_type: str | None) -> str: + return mime_type.split(";", 1)[0].strip().lower() if mime_type else "" + + +def _single_upload() -> FileStorage: + if "file" not in request.files: + raise NoFileUploadedError() + if len(request.files) > 1: + raise TooManyFilesError() + + upload = request.files["file"] + if not upload.filename: + raise FilenameNotExistsError() + return upload + + +def _store_upload( + grant: FileGrantClaims, + *, + filename: str, + stream: IO[bytes], + mimetype: str, +) -> StoredUpload: + try: + return application_services().file_grants.store_upload( + context=_grant_context(grant), + filename=filename, + stream=stream, + mimetype=mimetype, + ) + except EndUserNotFoundError as exc: + raise GrantedFileNotFoundError() from exc + except services.errors.file.FileTooLargeError as exc: + raise FileTooLargeError(exc.description) + except services.errors.file.BlockedFileExtensionError as exc: + raise BlockedFileExtensionError(exc.description) from exc + except services.errors.file.UnsupportedFileTypeError: + raise UnsupportedFileTypeError() + + +def _grant_context(grant: FileGrantClaims) -> FileGrantContext: + return FileGrantContext(tenant_id=grant.tenant_id, app_id=grant.app_id, end_user_id=grant.sub) + + +def _granted_file_response(upload_file: StoredUpload) -> dict[str, object]: + """Answer an upload exactly as dify's own upload endpoints answer it. + + A client moving off ``POST /v1/files/upload`` must not have to read a second + shape, so the same model reads the same ``upload_files`` row: every key dify + leaves null for such a row is null here too. Only the value of ``source_url`` + is ours. Dify signs a ``file-preview`` URL there and this channel signs a + content-token URL, which keeps that key's promise of a signed URL that + retrieves the file while keeping the grant its only way in. + """ + + signed_url, _ = application_services().file_grants.content_urls(file_id=upload_file.id, kind=FileKind.UPLOAD) + return dump_response(FileResponse, upload_file) | {"source_url": signed_url} + + +def _remote_granted_file_response(upload_file: StoredUpload) -> dict[str, object]: + """Answer a remote upload with the upload shape plus dify's ``url`` key. + + Reuses the URL already signed for ``source_url`` rather than signing a + second one, so the two keys are one value under the two names dify's two + kinds of client look for. + """ + + response = _granted_file_response(upload_file) + return response | {"url": response["source_url"]} + + +__all__ = [ + "GrantedFileContentApi", + "GrantedFileResolveApi", + "GrantedFileUploadApi", + "GrantedRemoteFileUploadApi", + "ProducedFileApi", +] diff --git a/api/controllers/files/wraps.py b/api/controllers/files/wraps.py new file mode 100644 index 00000000000..b72925028d8 --- /dev/null +++ b/api/controllers/files/wraps.py @@ -0,0 +1,57 @@ +"""Bearer authentication for the AppDeploy file grant endpoints.""" + +from collections.abc import Callable +from functools import wraps + +from flask import request + +from extensions.ext_application_services import application_services +from libs.exception import BaseHTTPException +from services.entities.file_grant_entities import FileGrantClaims, FileGrantScope + + +class FileGrantInvalidError(BaseHTTPException): + error_code = "grant_invalid" + description = "The file grant is missing, malformed, or expired." + code = 401 + + +class FileGrantScopeDeniedError(BaseHTTPException): + error_code = "grant_scope_denied" + description = "The file grant does not carry the required scope." + code = 403 + + +class GrantedFileNotFoundError(BaseHTTPException): + error_code = "file_not_found" + description = "File not found." + code = 404 + + +def file_grant_required[**P, R](scope: FileGrantScope) -> Callable[[Callable[P, R]], Callable[P, R]]: + """Require a valid file grant carrying ``scope`` and inject its claims.""" + + def decorator(view: Callable[P, R]) -> Callable[P, R]: + @wraps(view) + def decorated(*args: P.args, **kwargs: P.kwargs) -> R: + kwargs["grant"] = _authenticated_claims(scope) + return view(*args, **kwargs) + + return decorated + + return decorator + + +def _authenticated_claims(scope: FileGrantScope) -> FileGrantClaims: + scheme, _, token = request.headers.get("Authorization", "").partition(" ") + if scheme.lower() != "bearer" or not token: + raise FileGrantInvalidError() + + claims = application_services().file_grants.decode_grant(token) + if claims is None: + raise FileGrantInvalidError() + + if scope not in claims.scopes: + raise FileGrantScopeDeniedError() + + return claims diff --git a/api/controllers/inner_api/__init__.py b/api/controllers/inner_api/__init__.py index cb7ddd7f107..1656df8aaff 100644 --- a/api/controllers/inner_api/__init__.py +++ b/api/controllers/inner_api/__init__.py @@ -21,6 +21,7 @@ from .agent import files as _agent_files from .agent import llm as _agent_llm from .agent import tools as _agent_tools from .app import dsl as _app_dsl +from .app import file_grants as _app_file_grants from .knowledge import retrieval as _knowledge_retrieval from .plugin import agent_config as _agent_config from .plugin import plugin as _plugin @@ -35,6 +36,7 @@ __all__ = [ "_agent_llm", "_agent_tools", "_app_dsl", + "_app_file_grants", "_knowledge_retrieval", "_mail", "_plugin", diff --git a/api/controllers/inner_api/app/file_grants.py b/api/controllers/inner_api/app/file_grants.py new file mode 100644 index 00000000000..0acc9754c73 --- /dev/null +++ b/api/controllers/inner_api/app/file_grants.py @@ -0,0 +1,203 @@ +"""Mint AppDeploy file grants for the enterprise control plane. + +This is the only endpoint that asserts an AppDeploy identity: the application +service upserts the subject's ``EndUser`` row, validates the files the caller +claims to reference, and signs a short-lived grant. +""" + +from __future__ import annotations + +from flask_restx import Resource +from pydantic import BaseModel, Field, ValidationError + +from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.console.wraps import setup_required +from controllers.files.wraps import GrantedFileNotFoundError +from controllers.inner_api import inner_api_ns +from controllers.inner_api.wraps import enterprise_inner_api_only +from extensions.ext_application_services import application_services +from fields.base import ResponseModel +from fields.file_grant_fields import ResolvedFileResponse +from libs.exception import BaseHTTPException +from services.entities.file_grant_entities import FileGrantMintRequest, FileGrantScope, FileKind, FileRef +from services.file_grant_service import ( + AppNotFoundError, + EndUserNotFoundError, + TooManyFileRefsError, +) +from services.file_grant_service import ( + GrantedFileNotFoundError as ServiceGrantedFileNotFoundError, +) +from services.file_grant_service import ( + GrantTtlTooLongError as ServiceGrantTtlTooLongError, +) +from services.file_grant_service import ( + InvalidGrantRequestError as ServiceInvalidGrantRequestError, +) +from services.file_grant_service import ( + InvalidSubjectError as ServiceInvalidSubjectError, +) + + +class InvalidGrantRequestError(BaseHTTPException): + error_code = "invalid_request" + description = "The file grant request is malformed." + code = 400 + + +class GrantTtlTooLongError(BaseHTTPException): + error_code = "grant_ttl_too_long" + description = "The requested file grant lifetime exceeds its allowed window." + code = 400 + + +class InvalidSubjectError(BaseHTTPException): + error_code = "invalid_subject" + description = "The subject is empty or contains a NUL byte." + code = 400 + + +class GrantAppNotFoundError(BaseHTTPException): + error_code = "app_not_found" + description = "App not found." + code = 404 + + +class FileGrantFileRef(BaseModel): + id: str + kind: FileKind + + +class FileGrantMintPayload(BaseModel): + tenant_id: str + app_id: str + subject: str + is_anonymous: bool = False + scopes: list[FileGrantScope] + ttl_seconds: int = Field(gt=0) + # Recorded by the enterprise caller's own audit log; it never enters the + # grant because a rotated key must not strand the files it uploaded. + actor_key_digest: str | None = None + file_ids: list[FileGrantFileRef] = Field(default_factory=list) + optional_file_ids: list[FileGrantFileRef] = Field(default_factory=list) + run_deadline: int | None = None + + +class FileGrantLimits(ResponseModel): + file_size_limit: int + image_file_size_limit: int + audio_file_size_limit: int + video_file_size_limit: int + workflow_file_upload_limit: int + batch_count_limit: int + + +class FileGrantFileMetadata(ResponseModel): + id: str + kind: FileKind + name: str + size: int + extension: str + mime_type: str | None = None + + +class FileGrantMintResponse(ResponseModel): + grant: str + expires_at: int + limits: FileGrantLimits + files: list[FileGrantFileMetadata] + optional_files: list[ResolvedFileResponse] + + +register_schema_models(inner_api_ns, FileGrantMintPayload) +register_response_schema_models(inner_api_ns, FileGrantMintResponse) + + +@inner_api_ns.route("/enterprise/file-grants") +class EnterpriseFileGrantApi(Resource): + """Assert one AppDeploy subject and sign a grant for it.""" + + @setup_required + @enterprise_inner_api_only + @inner_api_ns.doc("enterprise_mint_file_grant") + @inner_api_ns.expect(inner_api_ns.models[FileGrantMintPayload.__name__]) + @inner_api_ns.response( + 200, + "File grant minted", + inner_api_ns.models[FileGrantMintResponse.__name__], + ) + @inner_api_ns.doc( + responses={ + 400: "Invalid request, subject, or grant TTL", + 404: "App not found, or a required file is not owned by the subject", + } + ) + def post(self): + try: + payload = FileGrantMintPayload.model_validate(inner_api_ns.payload or {}) + except ValidationError as exc: + raise InvalidGrantRequestError(str(exc)) from exc + + try: + result = application_services().file_grants.mint( + FileGrantMintRequest( + tenant_id=payload.tenant_id, + app_id=payload.app_id, + subject=payload.subject, + is_anonymous=payload.is_anonymous, + scopes=tuple(payload.scopes), + ttl_seconds=payload.ttl_seconds, + file_refs=tuple(FileRef(id=ref.id, kind=ref.kind) for ref in payload.file_ids), + optional_file_refs=tuple(FileRef(id=ref.id, kind=ref.kind) for ref in payload.optional_file_ids), + run_deadline=payload.run_deadline, + ) + ) + except AppNotFoundError as exc: + raise GrantAppNotFoundError() from exc + except ServiceGrantTtlTooLongError as exc: + raise GrantTtlTooLongError() from exc + except ServiceInvalidSubjectError as exc: + raise InvalidSubjectError() from exc + except (ServiceInvalidGrantRequestError, TooManyFileRefsError) as exc: + raise InvalidGrantRequestError(str(exc)) from exc + except (EndUserNotFoundError, ServiceGrantedFileNotFoundError) as exc: + raise GrantedFileNotFoundError() from exc + + return FileGrantMintResponse( + grant=result.grant, + expires_at=result.expires_at, + limits=FileGrantLimits( + file_size_limit=result.limits.file_size_limit, + image_file_size_limit=result.limits.image_file_size_limit, + audio_file_size_limit=result.limits.audio_file_size_limit, + video_file_size_limit=result.limits.video_file_size_limit, + workflow_file_upload_limit=result.limits.workflow_file_upload_limit, + batch_count_limit=result.limits.batch_count_limit, + ), + files=[ + FileGrantFileMetadata( + id=file.id, + kind=file.kind, + name=file.name, + size=file.size, + extension=file.extension, + mime_type=file.mime_type, + ) + for file in result.files + ], + optional_files=[ + ResolvedFileResponse.from_resolved(ref.id, access) + for ref, access in zip( + payload.optional_file_ids, + result.optional_files, + strict=True, + ) + ], + ).model_dump(mode="json") + + +__all__ = [ + "EnterpriseFileGrantApi", + "FileGrantMintPayload", + "FileGrantMintResponse", +] diff --git a/api/controllers/web/app.py b/api/controllers/web/app.py index 1e985b77cc1..929cd6ae51a 100644 --- a/api/controllers/web/app.py +++ b/api/controllers/web/app.py @@ -26,6 +26,7 @@ from libs.passport import PassportService from libs.token import extract_webapp_passport from models.model import App, EndUser from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError +from services.web_passport_service import WebAppAuthType from services.webapp_access_query_service import ( WebAppAccessAppNotFoundError, WebAppAccessReferenceRequiredError, @@ -184,12 +185,16 @@ class AppWebAuthPermission(Resource): if not tk: raise Unauthorized("Access token is missing.") decoded = PassportService().verify(tk) - user_id = decoded.get("user_id", "visitor") except Unauthorized: raise WebAppAuthRequiredError() from None except Exception: logger.exception("Unexpected error during auth verification") raise + if decoded.get("auth_type") != WebAppAuthType.INTERNAL: + raise WebAppAuthRequiredError() + user_id = decoded.get("user_id") + if not user_id: + raise WebAppAuthRequiredError() try: is_allowed = webapp_access.is_user_allowed(user_id=str(user_id), app_id=app_id) diff --git a/api/controllers/web/wraps.py b/api/controllers/web/wraps.py index 69e0edb059f..d6aac7e97c6 100644 --- a/api/controllers/web/wraps.py +++ b/api/controllers/web/wraps.py @@ -19,6 +19,7 @@ from models.model import App, EndUser, Site from services.app_service import AppService from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode, WebAppSettings from services.system_feature_service import SystemFeatureService +from services.web_passport_gateways import resolve_web_app_auth_type from services.webapp_auth_service import WebAppAuthService @@ -133,6 +134,14 @@ def _validate_user_accessibility( if not webapp_settings: raise WebAppAuthRequiredError("Web app settings not found.") + auth_type = decoded.get("auth_type") + if not auth_type: + raise WebAppAuthRequiredError("Missing auth_type in the token.") + + expected_auth_type = resolve_web_app_auth_type(webapp_settings.access_mode) + if auth_type != expected_auth_type: + raise WebAppAuthRequiredError() + if WebAppAuthService.is_app_require_permission_check( access_mode=webapp_settings.access_mode, session=db.session() ): @@ -140,10 +149,7 @@ def _validate_user_accessibility( if not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_id): raise WebAppAuthAccessDeniedError() - auth_type = decoded.get("auth_type") granted_at = decoded.get("granted_at") - if not auth_type: - raise WebAppAuthAccessDeniedError("Missing auth_type in the token.") if not granted_at: raise WebAppAuthAccessDeniedError("Missing granted_at in the token.") # check if sso has been updated diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index d39a84c150c..7863e5a5b72 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -1,6 +1,7 @@ """Composition root for application services used by transport adapters.""" import json +import time from collections.abc import Mapping from dataclasses import dataclass from datetime import UTC, datetime @@ -18,8 +19,10 @@ from constants.languages import languages from core.db.session_factory import get_session_maker from core.helper.ssrf_proxy import ssrf_proxy from core.schemas.schema_manager import SchemaManager +from core.tools.tool_file_manager import ToolFileManager from enums import DeploymentEdition, WebAppAccessMode from extensions.ext_redis import RedisClientWrapper, redis_client +from extensions.ext_storage import storage from libs.datetime_utils import naive_utc_now from libs.helper import RateLimiter from libs.oauth import GitHubOAuth, GoogleOAuth @@ -39,6 +42,7 @@ from repositories.data_source_api_key_auth_repository import SQLAlchemyDataSourc from repositories.data_source_oauth_binding_repository import SQLAlchemyDataSourceOAuthBindingRepository from repositories.explore_banner_query_repository import ExploreBannerQueryRepository from repositories.factory import DifyAPIRepositoryFactory +from repositories.file_grant_repository import FileGrantRepository from repositories.installation_state_repository import InstallationStateRepository from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository @@ -132,10 +136,13 @@ from services.billing_service import BillingService from services.compliance_download_service import ComplianceDownloadService from services.data_source_oauth_service import DataSourceOAuthService, InvalidDataSourceOAuthProviderError from services.enterprise.enterprise_service import EnterpriseService +from services.entities.file_grant_entities import FileGrantLimits from services.errors.enterprise import EnterpriseServiceError from services.explore_banner_query_service import ExploreBannerQueryService from services.feature_query_service import FeatureQueryService from services.feature_service_gateway import FeatureServiceGateway +from services.file_grant_gateways import FileGrantFileGateway, FileGrantRemoteFileGateway, FileGrantTokenGateway +from services.file_grant_service import FileGrantService from services.file_service import FileService from services.init_validation_service import InitValidationService from services.inner_mail_service import InnerMailService @@ -235,6 +242,7 @@ class ApplicationServices: schema_definitions: SchemaDefinitionService setup: SetupService feature_queries: FeatureQueryService + file_grants: FileGrantService files: FileService oauth_server: OAuthServerService init_validation: InitValidationService @@ -293,6 +301,37 @@ def _build_oauth_server_service( ) +def _build_file_grant_service(*, database_client: sessionmaker[Session]) -> FileGrantService: + repository = FileGrantRepository(session_factory=database_client) + return FileGrantService( + repository=repository, + files=FileGrantFileGateway( + load_end_user=repository.get_end_user, + subject_exists=repository.subject_exists, + file_service=FileService(session_factory=database_client), + tool_files=ToolFileManager(), + storage=storage, + ), + remote_files=FileGrantRemoteFileGateway(), + tokens=FileGrantTokenGateway( + secret_key=dify_config.SECRET_KEY, + external_files_url=dify_config.FILES_URL, + internal_files_url=dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL, + content_token_ttl_seconds=dify_config.FILES_ACCESS_TIMEOUT, + now=lambda: int(time.time()), + ), + limits=FileGrantLimits( + file_size_limit=dify_config.UPLOAD_FILE_SIZE_LIMIT, + image_file_size_limit=dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT, + audio_file_size_limit=dify_config.UPLOAD_AUDIO_FILE_SIZE_LIMIT, + video_file_size_limit=dify_config.UPLOAD_VIDEO_FILE_SIZE_LIMIT, + workflow_file_upload_limit=dify_config.WORKFLOW_FILE_UPLOAD_LIMIT, + batch_count_limit=dify_config.UPLOAD_FILE_BATCH_LIMIT, + ), + now=lambda: int(time.time()), + ) + + def _build_account_oauth_service( *, database_client: sessionmaker[Session], @@ -574,6 +613,7 @@ def build_application_services( features=feature_gateway, app_dsl_version=CURRENT_APP_DSL_VERSION, ), + file_grants=_build_file_grant_service(database_client=database_client), files=file_service, oauth_server=_build_oauth_server_service(database_client=database_client, redis=redis), init_validation=InitValidationService( diff --git a/api/fields/file_grant_fields.py b/api/fields/file_grant_fields.py new file mode 100644 index 00000000000..b0c550bb425 --- /dev/null +++ b/api/fields/file_grant_fields.py @@ -0,0 +1,49 @@ +"""Response DTOs shared by the AppDeploy file grant surfaces.""" + +from __future__ import annotations + +from fields.base import ResponseModel +from services.entities.file_grant_entities import FileKind, ResolvedFileAccess + + +class ResolvedFileResponse(ResponseModel): + """One requested file reference, either resolved or accounted for. + + The resolve endpoint and optional mint references answer item by item so one + missing history file cannot fail a whole run. A file that exists but belongs + to another owner is reported exactly like one that never existed. + """ + + id: str + ok: bool + kind: FileKind | None = None + name: str | None = None + size: int | None = None + extension: str | None = None + mime_type: str | None = None + url: str | None = None + internal_url: str | None = None + error: str | None = None + + @classmethod + def from_resolved(cls, file_id: str, access: ResolvedFileAccess | None) -> ResolvedFileResponse: + """Describe one resolved reference without performing infrastructure work.""" + + if access is None: + return cls(id=file_id, ok=False, error="not_found") + + file = access.file + return cls( + id=file.id, + ok=True, + kind=file.kind, + name=file.name, + size=file.size, + extension=file.extension, + mime_type=file.mime_type, + url=access.external_url, + internal_url=access.internal_url, + ) + + +__all__ = ["ResolvedFileResponse"] diff --git a/api/models/enums.py b/api/models/enums.py index 4cdd46a4e12..944047d3ddd 100644 --- a/api/models/enums.py +++ b/api/models/enums.py @@ -208,6 +208,7 @@ class InvokeFrom(StrEnum): class EndUserType(StrEnum): """Persisted type values for the ``end_users.type`` column.""" + APP_DEPLOY = "app-deploy" BROWSER = "browser" MCP = "mcp" OPENAPI = "openapi" diff --git a/api/repositories/file_grant_repository.py b/api/repositories/file_grant_repository.py new file mode 100644 index 00000000000..98ff0fa8b16 --- /dev/null +++ b/api/repositories/file_grant_repository.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import os +from collections.abc import Sequence + +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from models.enums import CreatorUserRole, EndUserType +from models.model import App, EndUser, UploadFile +from models.tools import ToolFile +from services.entities.file_grant_entities import ( + FileContentRecord, + FileGrantContext, + FileKind, + FileRef, + ResolvedFile, +) + + +class FileGrantRepository: + def __init__(self, *, session_factory: sessionmaker[Session]) -> None: + self._session_factory = session_factory + + def get_or_create_subject( + self, + *, + tenant_id: str, + app_id: str, + session_id: str, + external_user_id: str, + is_anonymous: bool, + ) -> str | None: + with self._session_factory() as session: + end_user = session.scalar( + self._subject_statement( + tenant_id=tenant_id, + app_id=app_id, + session_id=session_id, + require_app=True, + ).limit(1) + ) + if end_user is not None: + return end_user.id + + with self._session_factory.begin() as session: + app = session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant_id).with_for_update()) + if app is None: + return None + + end_user = session.scalar( + self._subject_statement( + tenant_id=tenant_id, + app_id=app_id, + session_id=session_id, + require_app=False, + ).limit(1) + ) + if end_user is None: + end_user = EndUser( + tenant_id=tenant_id, + app_id=app_id, + type=EndUserType.APP_DEPLOY, + is_anonymous=is_anonymous, + session_id=session_id, + external_user_id=external_user_id, + ) + session.add(end_user) + session.flush() + return end_user.id + + def subject_exists(self, context: FileGrantContext) -> bool: + with self._session_factory() as session: + return ( + session.scalar( + select(EndUser.id) + .where( + EndUser.id == context.end_user_id, + EndUser.tenant_id == context.tenant_id, + EndUser.app_id == context.app_id, + EndUser.type == EndUserType.APP_DEPLOY, + ) + .limit(1) + ) + is not None + ) + + def get_end_user(self, context: FileGrantContext) -> EndUser | None: + with self._session_factory(expire_on_commit=False) as session: + return session.scalar( + select(EndUser) + .where( + EndUser.id == context.end_user_id, + EndUser.tenant_id == context.tenant_id, + EndUser.app_id == context.app_id, + EndUser.type == EndUserType.APP_DEPLOY, + ) + .limit(1) + ) + + def resolve_owned_files( + self, + *, + context: FileGrantContext, + refs: Sequence[FileRef], + ) -> list[ResolvedFile | None]: + upload_ids = {ref.id for ref in refs if ref.kind == FileKind.UPLOAD} + tool_ids = {ref.id for ref in refs if ref.kind == FileKind.TOOL} + + with self._session_factory() as session: + uploads = self._load_uploads(session, context=context, file_ids=upload_ids) + tool_files = self._load_tool_files(session, context=context, file_ids=tool_ids) + + return [uploads.get(ref.id) if ref.kind == FileKind.UPLOAD else tool_files.get(ref.id) for ref in refs] + + def get_content_record(self, *, file_id: str, kind: FileKind) -> FileContentRecord | None: + with self._session_factory() as session: + match kind: + case FileKind.UPLOAD: + upload_file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1)) + if upload_file is None: + return None + return FileContentRecord( + name=upload_file.name, + size=upload_file.size, + mime_type=upload_file.mime_type, + storage_key=upload_file.key, + ) + case FileKind.TOOL: + tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id).limit(1)) + if tool_file is None: + return None + return FileContentRecord( + name=tool_file.name or "", + size=tool_file.size, + mime_type=tool_file.mimetype, + storage_key=tool_file.file_key, + ) + + @staticmethod + def _subject_statement(*, tenant_id: str, app_id: str, session_id: str, require_app: bool): + statement = select(EndUser) + if require_app: + statement = statement.join(App, App.id == EndUser.app_id) + predicates = [ + EndUser.tenant_id == tenant_id, + EndUser.app_id == app_id, + EndUser.session_id == session_id, + EndUser.type == EndUserType.APP_DEPLOY, + ] + if require_app: + predicates.append(App.tenant_id == tenant_id) + return statement.where(*predicates) + + @staticmethod + def _load_uploads( + session: Session, + *, + context: FileGrantContext, + file_ids: set[str], + ) -> dict[str, ResolvedFile]: + if not file_ids: + return {} + rows = session.scalars( + select(UploadFile).where( + UploadFile.id.in_(file_ids), + UploadFile.tenant_id == context.tenant_id, + UploadFile.created_by_role == CreatorUserRole.END_USER, + UploadFile.created_by == context.end_user_id, + ) + ).all() + return { + row.id: ResolvedFile( + id=row.id, + kind=FileKind.UPLOAD, + name=row.name, + size=row.size, + extension=row.extension, + mime_type=row.mime_type, + ) + for row in rows + } + + @staticmethod + def _load_tool_files( + session: Session, + *, + context: FileGrantContext, + file_ids: set[str], + ) -> dict[str, ResolvedFile]: + if not file_ids: + return {} + rows = session.scalars( + select(ToolFile).where( + ToolFile.id.in_(file_ids), + ToolFile.tenant_id == context.tenant_id, + ToolFile.user_id == context.end_user_id, + ) + ).all() + return { + row.id: ResolvedFile( + id=row.id, + kind=FileKind.TOOL, + name=row.name or "", + size=row.size, + extension=os.path.splitext(row.name or "")[1].lstrip(".").lower(), + mime_type=row.mimetype, + ) + for row in rows + } + + +__all__ = ["FileGrantRepository"] diff --git a/api/services/end_user_service.py b/api/services/end_user_service.py index c15e9949abb..a01d3a8128f 100644 --- a/api/services/end_user_service.py +++ b/api/services/end_user_service.py @@ -63,6 +63,11 @@ class EndUserService: EndUser.tenant_id == tenant_id, EndUser.app_id == app_id, EndUser.session_id == user_id, + # An AppDeploy row is never a legacy row this could upgrade: + # the type was added after the split, and FileGrantService + # reads its rows by type. Retyping one here would hide it + # from that read and strand the files it owns. + EndUser.type != EndUserType.APP_DEPLOY, ) .order_by( # Prioritize records with matching type (0 = match, 1 = no match) diff --git a/api/services/entities/file_grant_entities.py b/api/services/entities/file_grant_entities.py new file mode 100644 index 00000000000..5d7ffb44d7d --- /dev/null +++ b/api/services/entities/file_grant_entities.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel + + +class FileKind(StrEnum): + UPLOAD = "upload" + TOOL = "tool" + + +class FileGrantScope(StrEnum): + UPLOAD = "upload" + RESOLVE = "resolve" + PRODUCE = "produce" + + +class FileGrantClaims(BaseModel): + sub: str + tenant_id: str + app_id: str + scopes: list[FileGrantScope] + exp: int + + +class FileContentClaims(BaseModel): + kind: FileKind + file_id: str + exp: int + + +@dataclass(frozen=True, slots=True) +class FileGrantContext: + tenant_id: str + app_id: str + end_user_id: str + + +@dataclass(frozen=True, slots=True) +class FileRef: + id: str + kind: FileKind + + +@dataclass(frozen=True, slots=True) +class ResolvedFile: + id: str + kind: FileKind + name: str + size: int + extension: str + mime_type: str | None + + +@dataclass(frozen=True, slots=True) +class ResolvedFileAccess: + file: ResolvedFile + external_url: str + internal_url: str + + +@dataclass(frozen=True, slots=True) +class FileContentRecord: + name: str + size: int + mime_type: str | None + storage_key: str + + +@dataclass(frozen=True, slots=True) +class FileContent: + name: str + size: int + mime_type: str | None + stream: Iterable[bytes] + + +@dataclass(frozen=True, slots=True) +class StoredUpload: + id: str + name: str + size: int + extension: str | None + mime_type: str | None + created_by: str | None + created_at: datetime | None + tenant_id: str | None + source_url: str + + +@dataclass(frozen=True, slots=True) +class StoredProducedFile: + id: str + name: str + size: int + mime_type: str | None + + +@dataclass(frozen=True, slots=True) +class RemoteFile: + filename: str + mimetype: str + content: bytes + + +@dataclass(frozen=True, slots=True) +class FileGrantLimits: + file_size_limit: int + image_file_size_limit: int + audio_file_size_limit: int + video_file_size_limit: int + workflow_file_upload_limit: int + batch_count_limit: int + + +@dataclass(frozen=True, slots=True) +class FileGrantMintRequest: + tenant_id: str + app_id: str + subject: str + is_anonymous: bool + scopes: tuple[FileGrantScope, ...] + ttl_seconds: int + file_refs: tuple[FileRef, ...] + optional_file_refs: tuple[FileRef, ...] + run_deadline: int | None + + +@dataclass(frozen=True, slots=True) +class FileGrantMintResult: + grant: str + expires_at: int + limits: FileGrantLimits + files: tuple[ResolvedFile, ...] + optional_files: tuple[ResolvedFileAccess | None, ...] diff --git a/api/services/errors/file_grant.py b/api/services/errors/file_grant.py new file mode 100644 index 00000000000..17f08db9115 --- /dev/null +++ b/api/services/errors/file_grant.py @@ -0,0 +1,34 @@ +class AppNotFoundError(Exception): + pass + + +class EndUserNotFoundError(Exception): + pass + + +class InvalidGrantRequestError(Exception): + pass + + +class InvalidFileGrantError(Exception): + pass + + +class GrantTtlTooLongError(Exception): + pass + + +class GrantedFileNotFoundError(Exception): + pass + + +class InvalidSubjectError(Exception): + pass + + +class RemoteFileUnavailableError(Exception): + pass + + +class TooManyFileRefsError(Exception): + pass diff --git a/api/services/file_grant_gateways.py b/api/services/file_grant_gateways.py new file mode 100644 index 00000000000..3fdf2493ee8 --- /dev/null +++ b/api/services/file_grant_gateways.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import mimetypes +import os +import re +import urllib.parse +from collections.abc import Callable, Sequence +from typing import IO, cast +from uuid import uuid4 + +import httpx +import jwt +from pydantic import ValidationError + +from core.file import remote_fetcher +from core.helper import ssrf_proxy +from core.tools.tool_file_manager import ToolFileManager, resolve_extension +from extensions.ext_storage import Storage +from models.model import EndUser +from services.entities.file_grant_entities import ( + FileContent, + FileContentClaims, + FileContentRecord, + FileGrantClaims, + FileGrantContext, + FileGrantScope, + FileKind, + RemoteFile, + StoredProducedFile, + StoredUpload, +) +from services.errors.file import FileTooLargeError +from services.errors.file_grant import EndUserNotFoundError +from services.file_service import FileService + +FILE_GRANT_AUDIENCE = "dify-files" +FILE_CONTENT_AUDIENCE = "dify-files-content" +_ALGORITHM = "HS256" + + +class FileGrantTokenGateway: + def __init__( + self, + *, + secret_key: str, + external_files_url: str, + internal_files_url: str, + content_token_ttl_seconds: int, + now: Callable[[], int], + ) -> None: + self._secret_key = secret_key + self._external_files_url = external_files_url + self._internal_files_url = internal_files_url + self._content_token_ttl_seconds = content_token_ttl_seconds + self._now = now + + def issue_grant( + self, + *, + context: FileGrantContext, + scopes: Sequence[FileGrantScope], + ttl_seconds: int, + ) -> tuple[str, int]: + expires_at = self._now() + ttl_seconds + token = jwt.encode( + { + "aud": FILE_GRANT_AUDIENCE, + "sub": context.end_user_id, + "tenant_id": context.tenant_id, + "app_id": context.app_id, + "scopes": [str(scope) for scope in scopes], + "exp": expires_at, + }, + self._secret_key, + algorithm=_ALGORITHM, + ) + return token, expires_at + + def decode_grant(self, token: str) -> FileGrantClaims | None: + payload = self._decode( + token, audience=FILE_GRANT_AUDIENCE, required=["exp", "sub", "tenant_id", "app_id", "scopes"] + ) + if payload is None: + return None + try: + return FileGrantClaims.model_validate(payload) + except ValidationError: + return None + + def issue_content_urls(self, *, file_id: str, kind: FileKind) -> tuple[str, str]: + external_token = self._issue_content_token(file_id=file_id, kind=kind) + internal_token = self._issue_content_token(file_id=file_id, kind=kind) + path = f"/files/appdeploy/{file_id}/content" + return ( + f"{self._external_files_url}{path}?token={external_token}", + f"{self._internal_files_url}{path}?token={internal_token}", + ) + + def decode_content_token(self, token: str) -> FileContentClaims | None: + payload = self._decode( + token, + audience=FILE_CONTENT_AUDIENCE, + required=["exp", "kind", "file_id"], + ) + if payload is None: + return None + try: + return FileContentClaims.model_validate(payload) + except ValidationError: + return None + + def _issue_content_token(self, *, file_id: str, kind: FileKind) -> str: + return jwt.encode( + { + "aud": FILE_CONTENT_AUDIENCE, + "kind": str(kind), + "file_id": file_id, + "nonce": os.urandom(8).hex(), + "exp": self._now() + self._content_token_ttl_seconds, + }, + self._secret_key, + algorithm=_ALGORITHM, + ) + + def _decode(self, token: str, *, audience: str, required: list[str]) -> dict[str, object] | None: + try: + return cast( + dict[str, object], + jwt.decode( + token, + self._secret_key, + algorithms=[_ALGORITHM], + audience=audience, + options={"require": ["aud", *required]}, + ), + ) + except jwt.PyJWTError: + return None + + +class FileGrantFileGateway: + def __init__( + self, + *, + load_end_user: Callable[[FileGrantContext], EndUser | None], + subject_exists: Callable[[FileGrantContext], bool], + file_service: FileService, + tool_files: ToolFileManager, + storage: Storage, + ) -> None: + self._load_end_user = load_end_user + self._subject_exists = subject_exists + self._file_service = file_service + self._tool_files = tool_files + self._storage = storage + + def store_upload_stream( + self, + *, + context: FileGrantContext, + filename: str, + stream: IO[bytes], + mimetype: str, + ) -> StoredUpload: + if not self._subject_exists(context): + raise EndUserNotFoundError(context.end_user_id) + extension = os.path.splitext(filename)[1].lstrip(".").lower() + limit = FileService.file_size_limit(extension=extension) + content = stream.read(limit + 1) + if len(content) > limit: + raise FileTooLargeError(f"File size exceeded. The limit is {limit} bytes.") + return self.store_upload( + context=context, + filename=filename, + content=content, + mimetype=mimetype, + ) + + def store_upload( + self, + *, + context: FileGrantContext, + filename: str, + content: bytes, + mimetype: str, + source_url: str = "", + ) -> StoredUpload: + end_user = self._load_end_user(context) + if end_user is None: + raise EndUserNotFoundError(context.end_user_id) + upload = self._file_service.upload_file( + filename=filename, + content=content, + mimetype=mimetype, + user=end_user, + source_url=source_url, + ) + return StoredUpload( + id=upload.id, + name=upload.name, + size=upload.size, + extension=upload.extension, + mime_type=upload.mime_type, + created_by=upload.created_by, + created_at=upload.created_at, + tenant_id=upload.tenant_id, + source_url=upload.source_url, + ) + + def store_produced( + self, + *, + context: FileGrantContext, + filename: str | None, + stream: IO[bytes], + mimetype: str, + ) -> StoredProducedFile: + extension = resolve_extension(filename=filename, mimetype=mimetype).lstrip(".").lower() + limit = FileService.file_size_limit(extension=extension) + content = stream.read(limit + 1) + if len(content) > limit: + raise FileTooLargeError(f"File size exceeded. The limit is {limit} bytes.") + stored = self._tool_files.create_file_by_raw( + user_id=context.end_user_id, + tenant_id=context.tenant_id, + conversation_id=None, + file_binary=content, + mimetype=mimetype, + filename=filename, + ) + return StoredProducedFile( + id=stored.id, + name=stored.name or "", + size=stored.size, + mime_type=stored.mimetype, + ) + + def open_content(self, record: FileContentRecord) -> FileContent: + return FileContent( + name=record.name, + size=record.size, + mime_type=record.mime_type, + stream=self._storage.load(record.storage_key, stream=True), + ) + + +class FileGrantRemoteFileGateway: + def fetch(self, url: str) -> RemoteFile | None: + try: + metadata = remote_fetcher.make_request("HEAD", url=url, follow_redirects=True) + if metadata.status_code != httpx.codes.OK: + metadata.close() + metadata = remote_fetcher.make_request( + "GET", + url=url, + timeout=3, + follow_redirects=True, + stream_response=True, + ) + if metadata.status_code != httpx.codes.OK: + metadata.close() + return None + + filename, extension, mimetype = self._file_info(metadata) + limit = FileService.file_size_limit(extension=extension) + declared_size = self._declared_size(metadata) + if declared_size is not None and declared_size > limit: + metadata.close() + raise FileTooLargeError(f"File size exceeded. The limit is {limit} bytes.") + + if metadata.request.method == "HEAD": + metadata.close() + response = remote_fetcher.make_request( + "GET", + url=url, + timeout=3, + follow_redirects=True, + stream_response=True, + ) + if response.status_code != httpx.codes.OK: + response.close() + return None + else: + response = metadata + + try: + buffered = ssrf_proxy.buffer_response(response, max_response_bytes=limit) + except ssrf_proxy.ResponseTooLargeError as exc: + raise FileTooLargeError(f"File size exceeded. The limit is {limit} bytes.") from exc + return RemoteFile(filename=filename, mimetype=mimetype, content=buffered.content) + except (httpx.RequestError, ssrf_proxy.UnsupportedResponseEncodingError): + return None + + @staticmethod + def _file_info(response: httpx.Response) -> tuple[str, str, str]: + parsed_url = urllib.parse.urlparse(str(response.url)) + filename = urllib.parse.unquote(os.path.basename(parsed_url.path)) + if not filename: + content_disposition = response.headers.get("Content-Disposition", "") + filename_match = re.search(r'filename="?([^";]+)', content_disposition) + filename = filename_match.group(1) if filename_match else uuid4().hex + extension = os.path.splitext(filename)[1].lstrip(".").lower() + mimetype = ( + mimetypes.guess_type(filename)[0] + or response.headers.get("Content-Type", "").split(";", 1)[0].strip() + or "application/octet-stream" + ) + return filename, extension, mimetype + + @staticmethod + def _declared_size(response: httpx.Response) -> int | None: + value = response.headers.get("Content-Length") + if value is None: + return None + try: + return int(value) + except ValueError: + return None + + +__all__ = [ + "FILE_CONTENT_AUDIENCE", + "FILE_GRANT_AUDIENCE", + "FileGrantFileGateway", + "FileGrantRemoteFileGateway", + "FileGrantTokenGateway", +] diff --git a/api/services/file_grant_service.py b/api/services/file_grant_service.py new file mode 100644 index 00000000000..1cf542f278c --- /dev/null +++ b/api/services/file_grant_service.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import base64 +import hashlib +from collections.abc import Callable, Sequence +from typing import IO, Protocol + +from services.entities.file_grant_entities import ( + FileContent, + FileContentClaims, + FileContentRecord, + FileGrantClaims, + FileGrantContext, + FileGrantLimits, + FileGrantMintRequest, + FileGrantMintResult, + FileGrantScope, + FileKind, + FileRef, + RemoteFile, + ResolvedFile, + ResolvedFileAccess, + StoredProducedFile, + StoredUpload, +) +from services.errors.file_grant import ( + AppNotFoundError, + EndUserNotFoundError, + GrantedFileNotFoundError, + GrantTtlTooLongError, + InvalidFileGrantError, + InvalidGrantRequestError, + InvalidSubjectError, + RemoteFileUnavailableError, + TooManyFileRefsError, +) + +MAX_SESSION_GRANT_TTL_SECONDS = 7200 +MAX_WORKFLOW_EXECUTION_SECONDS = 24 * 60 * 60 +RUN_GRANT_EXPIRY_GRACE_SECONDS = 5 * 60 +MAX_RUN_GRANT_TTL_SECONDS = MAX_WORKFLOW_EXECUTION_SECONDS + RUN_GRANT_EXPIRY_GRACE_SECONDS +MAX_FILE_GRANT_REFS = 100 + + +class FileGrantRepository(Protocol): + def get_or_create_subject( + self, + *, + tenant_id: str, + app_id: str, + session_id: str, + external_user_id: str, + is_anonymous: bool, + ) -> str | None: ... + + def subject_exists(self, context: FileGrantContext) -> bool: ... + + def resolve_owned_files( + self, + *, + context: FileGrantContext, + refs: Sequence[FileRef], + ) -> list[ResolvedFile | None]: ... + + def get_content_record(self, *, file_id: str, kind: FileKind) -> FileContentRecord | None: ... + + +class FileGrantFiles(Protocol): + def store_upload_stream( + self, + *, + context: FileGrantContext, + filename: str, + stream: IO[bytes], + mimetype: str, + ) -> StoredUpload: ... + + def store_upload( + self, + *, + context: FileGrantContext, + filename: str, + content: bytes, + mimetype: str, + source_url: str = "", + ) -> StoredUpload: ... + + def store_produced( + self, + *, + context: FileGrantContext, + filename: str | None, + stream: IO[bytes], + mimetype: str, + ) -> StoredProducedFile: ... + + def open_content(self, record: FileContentRecord) -> FileContent: ... + + +class FileGrantTokens(Protocol): + def issue_grant( + self, + *, + context: FileGrantContext, + scopes: Sequence[FileGrantScope], + ttl_seconds: int, + ) -> tuple[str, int]: ... + + def decode_grant(self, token: str) -> FileGrantClaims | None: ... + + def issue_content_urls(self, *, file_id: str, kind: FileKind) -> tuple[str, str]: ... + + def decode_content_token(self, token: str) -> FileContentClaims | None: ... + + +class FileGrantRemoteFiles(Protocol): + def fetch(self, url: str) -> RemoteFile | None: ... + + +class FileGrantService: + def __init__( + self, + *, + repository: FileGrantRepository, + files: FileGrantFiles, + tokens: FileGrantTokens, + remote_files: FileGrantRemoteFiles, + limits: FileGrantLimits, + now: Callable[[], int], + ) -> None: + self._repository = repository + self._files = files + self._tokens = tokens + self._remote_files = remote_files + self._limits = limits + self._now = now + + @staticmethod + def session_id_for_subject(subject: str) -> str: + digest = hashlib.sha256(subject.encode()).digest() + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + + def mint(self, request: FileGrantMintRequest) -> FileGrantMintResult: + self._validate_subject(request.subject) + self._validate_ref_count((*request.file_refs, *request.optional_file_refs)) + ttl_seconds = self._effective_ttl_seconds(request) + + end_user_id = self._repository.get_or_create_subject( + tenant_id=request.tenant_id, + app_id=request.app_id, + session_id=self.session_id_for_subject(request.subject), + external_user_id=request.subject[:255], + is_anonymous=request.is_anonymous, + ) + if end_user_id is None: + raise AppNotFoundError(request.app_id) + + context = FileGrantContext(request.tenant_id, request.app_id, end_user_id) + strict_file_count = len(request.file_refs) + resolved_files = self._repository.resolve_owned_files( + context=context, + refs=(*request.file_refs, *request.optional_file_refs), + ) + strict_files = resolved_files[:strict_file_count] + if any(file is None for file in strict_files): + raise GrantedFileNotFoundError() + optional_files = resolved_files[strict_file_count:] + grant, expires_at = self._tokens.issue_grant( + context=context, + scopes=request.scopes, + ttl_seconds=ttl_seconds, + ) + return FileGrantMintResult( + grant=grant, + expires_at=expires_at, + limits=self._limits, + files=tuple(file for file in strict_files if file is not None), + optional_files=tuple(self._with_access(file) if file is not None else None for file in optional_files), + ) + + def decode_grant(self, token: str) -> FileGrantClaims | None: + return self._tokens.decode_grant(token) + + def store_upload( + self, + *, + context: FileGrantContext, + filename: str, + stream: IO[bytes], + mimetype: str, + ) -> StoredUpload: + return self._files.store_upload_stream( + context=context, + filename=filename, + stream=stream, + mimetype=mimetype, + ) + + def store_remote_upload(self, *, context: FileGrantContext, url: str) -> StoredUpload: + if not self._repository.subject_exists(context): + raise EndUserNotFoundError(context.end_user_id) + remote_file = self._remote_files.fetch(url) + if remote_file is None: + raise RemoteFileUnavailableError(url) + return self._files.store_upload( + context=context, + filename=remote_file.filename, + content=remote_file.content, + mimetype=remote_file.mimetype, + source_url=url, + ) + + def store_produced( + self, + *, + context: FileGrantContext, + filename: str | None, + stream: IO[bytes], + mimetype: str, + ) -> tuple[StoredProducedFile, ResolvedFileAccess]: + if not self._repository.subject_exists(context): + raise EndUserNotFoundError(context.end_user_id) + stored = self._files.store_produced( + context=context, + filename=filename, + stream=stream, + mimetype=mimetype, + ) + file = ResolvedFile(stored.id, FileKind.TOOL, stored.name, stored.size, "", stored.mime_type) + return stored, self._with_access(file) + + def resolve_files( + self, + *, + context: FileGrantContext, + refs: Sequence[FileRef], + ) -> list[ResolvedFile | None]: + self._validate_ref_count(refs) + if not self._repository.subject_exists(context): + raise EndUserNotFoundError(context.end_user_id) + return self._repository.resolve_owned_files(context=context, refs=refs) + + def resolve_file_access( + self, + *, + context: FileGrantContext, + refs: Sequence[FileRef], + ) -> list[ResolvedFileAccess | None]: + return [ + self._with_access(file) if file is not None else None + for file in self.resolve_files(context=context, refs=refs) + ] + + def load_content(self, *, token: str, requested_file_id: str) -> FileContent | None: + claims = self._tokens.decode_content_token(token) + if claims is None: + raise InvalidFileGrantError() + if claims.file_id != requested_file_id: + return None + record = self._repository.get_content_record(file_id=requested_file_id, kind=claims.kind) + return self._files.open_content(record) if record is not None else None + + def content_urls(self, *, file_id: str, kind: FileKind) -> tuple[str, str]: + return self._tokens.issue_content_urls(file_id=file_id, kind=kind) + + def _with_access(self, file: ResolvedFile) -> ResolvedFileAccess: + external_url, internal_url = self._tokens.issue_content_urls(file_id=file.id, kind=file.kind) + return ResolvedFileAccess(file=file, external_url=external_url, internal_url=internal_url) + + def _effective_ttl_seconds(self, request: FileGrantMintRequest) -> int: + if request.run_deadline is None: + if request.ttl_seconds > MAX_SESSION_GRANT_TTL_SECONDS: + raise GrantTtlTooLongError() + return request.ttl_seconds + + now = self._now() + if FileGrantScope.PRODUCE not in request.scopes: + raise InvalidGrantRequestError("A run deadline requires the produce scope.") + if request.run_deadline <= now: + raise InvalidGrantRequestError("The run deadline has expired.") + if request.run_deadline > now + MAX_WORKFLOW_EXECUTION_SECONDS: + raise InvalidGrantRequestError("The run deadline exceeds the workflow execution limit.") + if request.ttl_seconds > MAX_RUN_GRANT_TTL_SECONDS: + raise GrantTtlTooLongError() + return min(request.ttl_seconds, request.run_deadline - now + RUN_GRANT_EXPIRY_GRACE_SECONDS) + + @staticmethod + def _validate_subject(subject: str) -> None: + if not subject.strip() or "\x00" in subject: + raise InvalidSubjectError() + + @staticmethod + def _validate_ref_count(refs: Sequence[FileRef]) -> None: + if len(refs) > MAX_FILE_GRANT_REFS: + raise TooManyFileRefsError(f"A file grant request may contain at most {MAX_FILE_GRANT_REFS} references.") + + +__all__ = [ + "MAX_FILE_GRANT_REFS", + "MAX_RUN_GRANT_TTL_SECONDS", + "MAX_SESSION_GRANT_TTL_SECONDS", + "MAX_WORKFLOW_EXECUTION_SECONDS", + "RUN_GRANT_EXPIRY_GRACE_SECONDS", + "AppNotFoundError", + "EndUserNotFoundError", + "FileGrantService", + "GrantTtlTooLongError", + "GrantedFileNotFoundError", + "InvalidFileGrantError", + "InvalidGrantRequestError", + "InvalidSubjectError", + "RemoteFileUnavailableError", + "TooManyFileRefsError", +] diff --git a/api/services/file_service.py b/api/services/file_service.py index 4497639eb25..9c387720002 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -131,21 +131,32 @@ class FileService: file_size: int, default_file_size_limit: int | None = None, ) -> bool: + return file_size <= FileService.file_size_limit( + extension=extension, + default_file_size_limit=default_file_size_limit, + ) + + @staticmethod + def file_size_limit( + *, + extension: str, + default_file_size_limit: int | None = None, + ) -> int: + """Return the size an extension is allowed, in bytes.""" + if extension in IMAGE_EXTENSIONS: - file_size_limit = dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024 + file_size_limit = dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT elif extension in VIDEO_EXTENSIONS: - file_size_limit = dify_config.UPLOAD_VIDEO_FILE_SIZE_LIMIT * 1024 * 1024 + file_size_limit = dify_config.UPLOAD_VIDEO_FILE_SIZE_LIMIT elif extension in AUDIO_EXTENSIONS: - file_size_limit = dify_config.UPLOAD_AUDIO_FILE_SIZE_LIMIT * 1024 * 1024 + file_size_limit = dify_config.UPLOAD_AUDIO_FILE_SIZE_LIMIT else: # Context-specific uploads may override the default limit without changing media-specific limits. file_size_limit = ( - (default_file_size_limit if default_file_size_limit is not None else dify_config.UPLOAD_FILE_SIZE_LIMIT) - * 1024 - * 1024 + default_file_size_limit if default_file_size_limit is not None else dify_config.UPLOAD_FILE_SIZE_LIMIT ) - return file_size <= file_size_limit + return file_size_limit * 1024 * 1024 def get_file_base64(self, file_id: str) -> str: with self._session_maker(expire_on_commit=False) as session: diff --git a/api/services/web_passport_gateways.py b/api/services/web_passport_gateways.py index f6cf423302a..774a864e74a 100644 --- a/api/services/web_passport_gateways.py +++ b/api/services/web_passport_gateways.py @@ -10,6 +10,16 @@ from services.enterprise.enterprise_service import PERMISSION_CHECK_MODES, WebAp from services.web_passport_service import WebAppAuthType, WebPassportUnauthorizedError +def resolve_web_app_auth_type(access_mode: str) -> WebAppAuthType: + if access_mode == WebAppAccessMode.PUBLIC: + return WebAppAuthType.PUBLIC + if access_mode in PERMISSION_CHECK_MODES: + return WebAppAuthType.INTERNAL + if access_mode == WebAppAccessMode.SSO_VERIFIED: + return WebAppAuthType.EXTERNAL + raise ValueError(f"Unsupported web app access mode: {access_mode}") + + class DeploymentWebPassportAuthGateway: def __init__( self, @@ -25,13 +35,7 @@ class DeploymentWebPassportAuthGateway: def get_app_auth_type(self, app_id: str) -> WebAppAuthType: access_mode = self._get_app_access_mode(app_id).access_mode - if access_mode == WebAppAccessMode.PUBLIC: - return WebAppAuthType.PUBLIC - if access_mode in PERMISSION_CHECK_MODES: - return WebAppAuthType.INTERNAL - if access_mode == WebAppAccessMode.SSO_VERIFIED: - return WebAppAuthType.EXTERNAL - raise ValueError(f"Unsupported web app access mode: {access_mode}") + return resolve_web_app_auth_type(access_mode) class PassportTokenGateway: diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py b/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py index 9a143ab3bc3..5bda3738af5 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py @@ -91,8 +91,8 @@ class TestValidateUserAccessibility: def test_missing_auth_type_raises(self) -> None: decoded = {"user_id": "u1", "granted_at": 1} - settings = SimpleNamespace(access_mode="public") - with pytest.raises(WebAppAuthAccessDeniedError, match="auth_type"): + settings = SimpleNamespace(access_mode="private") + with pytest.raises(WebAppAuthRequiredError, match="auth_type"): _validate_user_accessibility( decoded=decoded, app_code="code", @@ -103,7 +103,7 @@ class TestValidateUserAccessibility: def test_missing_granted_at_raises(self) -> None: decoded = {"user_id": "u1", "auth_type": "external"} - settings = SimpleNamespace(access_mode="public") + settings = SimpleNamespace(access_mode="sso_verified") with pytest.raises(WebAppAuthAccessDeniedError, match="granted_at"): _validate_user_accessibility( decoded=decoded, @@ -121,7 +121,7 @@ class TestValidateUserAccessibility: mock_sso_time.return_value = datetime.now(UTC) old_granted = int((datetime.now(UTC) - timedelta(hours=1)).timestamp()) decoded = {"user_id": "u1", "auth_type": "external", "granted_at": old_granted} - settings = SimpleNamespace(access_mode="public") + settings = SimpleNamespace(access_mode="sso_verified") with pytest.raises(WebAppAuthAccessDeniedError, match="SSO settings"): _validate_user_accessibility( decoded=decoded, @@ -139,7 +139,7 @@ class TestValidateUserAccessibility: mock_workspace_sso.return_value = datetime.now(UTC) old_granted = int((datetime.now(UTC) - timedelta(hours=1)).timestamp()) decoded = {"user_id": "u1", "auth_type": "internal", "granted_at": old_granted} - settings = SimpleNamespace(access_mode="public") + settings = SimpleNamespace(access_mode="private") with pytest.raises(WebAppAuthAccessDeniedError, match="SSO settings"): _validate_user_accessibility( decoded=decoded, @@ -157,7 +157,7 @@ class TestValidateUserAccessibility: mock_sso_time.return_value = datetime.now(UTC) - timedelta(hours=2) recent_granted = int(datetime.now(UTC).timestamp()) decoded = {"user_id": "u1", "auth_type": "external", "granted_at": recent_granted} - settings = SimpleNamespace(access_mode="public") + settings = SimpleNamespace(access_mode="sso_verified") _validate_user_accessibility( decoded=decoded, app_code="code", @@ -172,8 +172,8 @@ class TestValidateUserAccessibility: def test_permission_check_denies_unauthorized_user( self, mock_perm: MagicMock, mock_app_id: MagicMock, mock_allowed: MagicMock ) -> None: - decoded = {"user_id": "u1", "auth_type": "external", "granted_at": int(datetime.now(UTC).timestamp())} - settings = SimpleNamespace(access_mode="internal") + decoded = {"user_id": "u1", "auth_type": "internal", "granted_at": int(datetime.now(UTC).timestamp())} + settings = SimpleNamespace(access_mode="private") with pytest.raises(WebAppAuthAccessDeniedError): _validate_user_accessibility( decoded=decoded, @@ -183,6 +183,37 @@ class TestValidateUserAccessibility: webapp_settings=settings, ) + @pytest.mark.parametrize( + ("access_mode", "auth_type"), + [ + pytest.param("private", "external", id="private-rejects-external"), + pytest.param("private_all", "external", id="private-all-rejects-external"), + pytest.param("sso_verified", "internal", id="sso-verified-rejects-internal"), + ], + ) + @patch("controllers.web.wraps.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp") + @patch("controllers.web.wraps.WebAppAuthService.is_app_require_permission_check") + def test_auth_type_must_match_current_access_mode( + self, + mock_permission_check: MagicMock, + mock_allowed: MagicMock, + access_mode: str, + auth_type: str, + ) -> None: + decoded = {"user_id": "u1", "auth_type": auth_type, "granted_at": int(datetime.now(UTC).timestamp())} + + with pytest.raises(WebAppAuthRequiredError): + _validate_user_accessibility( + decoded=decoded, + app_code="code", + app_web_auth_enabled=True, + system_webapp_auth_enabled=True, + webapp_settings=SimpleNamespace(access_mode=access_mode), + ) + + mock_permission_check.assert_not_called() + mock_allowed.assert_not_called() + class TestDecodeJwtToken: @pytest.fixture diff --git a/api/tests/unit_tests/controllers/files/test_appdeploy_files.py b/api/tests/unit_tests/controllers/files/test_appdeploy_files.py new file mode 100644 index 00000000000..b9e7d6e289e --- /dev/null +++ b/api/tests/unit_tests/controllers/files/test_appdeploy_files.py @@ -0,0 +1,833 @@ +"""Tests for the file endpoints reached with an AppDeploy file grant.""" + +import time +from collections.abc import Callable, Iterator +from datetime import datetime +from io import BytesIO +from types import SimpleNamespace +from typing import IO, cast +from unittest.mock import MagicMock, patch +from uuid import UUID + +import jwt +import pytest +from flask import Flask +from sqlalchemy import update +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from controllers.common.errors import ( + BlockedFileExtensionError, + FilenameNotExistsError, + FileTooLargeError, + NoFileUploadedError, + TooManyFilesError, +) +from controllers.files.appdeploy_files import ( + GrantedFileContentApi, + GrantedFileResolveApi, + GrantedFileUploadApi, + GrantedRemoteFileUploadApi, + InvalidFileRequestError, + ProducedFileApi, +) +from controllers.files.wraps import FileGrantInvalidError, GrantedFileNotFoundError +from extensions.ext_application_services import _build_file_grant_service +from extensions.storage.storage_type import StorageType +from libs.datetime_utils import naive_utc_now +from models.enums import CreatorUserRole, EndUserType +from models.model import EndUser, UploadFile +from models.tools import ToolFile +from services.entities.file_grant_entities import FileGrantContext, FileGrantScope, FileKind, RemoteFile +from services.errors.file import FileTooLargeError as FileTooLargeServiceError +from services.file_grant_gateways import FILE_CONTENT_AUDIENCE, FileGrantFileGateway +from services.file_grant_service import MAX_FILE_GRANT_REFS, FileGrantService +from tests.unit_tests.file_grant_test_utils import issue_file_grant + +CONTROLLER_MODULE = "controllers.files.appdeploy_files" + +SECRET_KEY = "file-grant-test-secret-long-enough-for-hs256" +TENANT_ID = "11111111-1111-4111-8111-111111111111" +OTHER_TENANT_ID = "1a1a1a1a-1111-4111-8111-111111111111" +APP_ID = "22222222-2222-4222-8222-222222222222" +FILE_ID = UUID("66666666-6666-4666-8666-666666666666") +UPLOAD_FILE_ID = "77777777-7777-4777-8777-777777777777" +UPLOADED_AT = datetime(2026, 8, 20, 12, 0) + +# What ``POST /v1/files/upload`` answers with. The grant channel is a drop-in +# for it, so the whole key set travels together and a key dify leaves null must +# still be present and null here. +DIFY_UPLOAD_RESPONSE_KEYS = frozenset( + { + "id", + "reference", + "name", + "size", + "extension", + "mime_type", + "created_by", + "created_at", + "preview_url", + "source_url", + "original_url", + "user_id", + "tenant_id", + "conversation_id", + "file_key", + } +) + +# The subset dify's own web upload client reads, from +# ``web/app/components/base/file-uploader/utils.ts``. +WEB_CLIENT_KEYS = frozenset( + { + "id", + "name", + "size", + "extension", + "mime_type", + "created_by", + "created_at", + "preview_url", + "source_url", + } +) + + +@pytest.fixture(autouse=True) +def granted_config(config_overrides: Callable[..., None]) -> None: + config_overrides( + SECRET_KEY=SECRET_KEY, + FILES_URL="https://files.example.com", + INTERNAL_FILES_URL="http://dify-api.dify.svc:5001", + FILES_ACCESS_TIMEOUT=300, + ) + + +@pytest.fixture +def sqlite_db(sqlite_engine: Engine) -> Iterator[FileGrantService]: + service = _build_file_grant_service(database_client=sessionmaker(bind=sqlite_engine, expire_on_commit=False)) + services = SimpleNamespace(file_grants=service) + with ( + patch(f"{CONTROLLER_MODULE}.application_services", return_value=services), + patch("controllers.files.wraps.application_services", return_value=services), + ): + yield service + + +@pytest.fixture +def file_gateway(sqlite_db: FileGrantService) -> FileGrantFileGateway: + return cast(FileGrantFileGateway, sqlite_db._files) + + +@pytest.fixture +def end_user(sqlite_session: Session) -> EndUser: + record = EndUser( + tenant_id=TENANT_ID, + app_id=APP_ID, + type=EndUserType.APP_DEPLOY, + is_anonymous=True, + session_id="seeded", + external_user_id="adp1.seeded", + ) + sqlite_session.add(record) + sqlite_session.commit() + return record + + +def _bearer(*scopes: FileGrantScope, end_user_id: str, tenant_id: str = TENANT_ID) -> dict[str, str]: + token, _ = issue_file_grant( + end_user_id=end_user_id, + tenant_id=tenant_id, + app_id=APP_ID, + scopes=scopes, + ttl_seconds=600, + ) + return {"Authorization": f"Bearer {token}"} + + +def _content_token(*, file_id: str, kind: FileKind, expires_in: int = 300) -> str: + return jwt.encode( + { + "aud": FILE_CONTENT_AUDIENCE, + "kind": str(kind), + "file_id": file_id, + "nonce": "0011223344556677", + "exp": int(time.time()) + expires_in, + }, + SECRET_KEY, + algorithm="HS256", + ) + + +def _stub_upload_file(**overrides: object) -> SimpleNamespace: + """Stand in for the ``upload_files`` row ``FileService`` hands back.""" + + return SimpleNamespace( + **{ + "id": UPLOAD_FILE_ID, + "name": "report.pdf", + "size": 2048, + "extension": "pdf", + "mime_type": "application/pdf", + "tenant_id": TENANT_ID, + "created_by": "99999999-9999-4999-8999-999999999999", + "created_at": UPLOADED_AT, + "source_url": "", + **overrides, + } + ) + + +def _persist_upload_file(session: Session, *, owner_id: str, tenant_id: str = TENANT_ID) -> UploadFile: + upload_file = UploadFile( + tenant_id=tenant_id, + storage_type=StorageType.OPENDAL, + key="upload_files/report.pdf", + name="report.pdf", + size=2048, + extension="pdf", + mime_type="application/pdf", + created_by=owner_id, + created_by_role=CreatorUserRole.END_USER, + created_at=naive_utc_now(), + used=False, + ) + session.add(upload_file) + session.commit() + return upload_file + + +def _persist_tool_file(session: Session, *, owner_id: str, mimetype: str = "image/png") -> ToolFile: + tool_file = ToolFile( + user_id=owner_id, + tenant_id=TENANT_ID, + conversation_id=None, + file_key="tools/chart.png", + mimetype=mimetype, + name="chart.png", + size=64, + ) + session.add(tool_file) + session.commit() + return tool_file + + +def test_upload_stores_the_file_for_the_grant_end_user( + app: Flask, end_user: EndUser, file_gateway: FileGrantFileGateway +) -> None: + with ( + patch.object(file_gateway, "store_upload", wraps=file_gateway.store_upload) as store_upload, + patch.object(file_gateway._file_service, "upload_file", return_value=_stub_upload_file()), + ): + with app.test_request_context( + "/files/appdeploy/upload", + method="POST", + headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id), + data={"file": (BytesIO(b"pdf-bytes"), "report.pdf")}, + content_type="multipart/form-data", + ): + body, status = GrantedFileUploadApi().post() + + assert status == 201 + assert body["id"] == UPLOAD_FILE_ID + assert body["extension"] == "pdf" + assert store_upload.call_args.kwargs["context"].end_user_id == end_user.id + + +def test_upload_answers_in_dify_s_own_upload_shape( + app: Flask, end_user: EndUser, file_gateway: FileGrantFileGateway +) -> None: + """A client moving off ``POST /v1/files/upload`` must not meet a second shape.""" + + with patch.object( + file_gateway._file_service, + "upload_file", + return_value=_stub_upload_file(created_by=end_user.id), + ): + with app.test_request_context( + "/files/appdeploy/upload", + method="POST", + headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id), + data={"file": (BytesIO(b"pdf-bytes"), "report.pdf")}, + content_type="multipart/form-data", + ): + body, _ = GrantedFileUploadApi().post() + + source_url = body.pop("source_url") + assert body == { + "id": UPLOAD_FILE_ID, + "reference": None, + "name": "report.pdf", + "size": 2048, + "extension": "pdf", + "mime_type": "application/pdf", + "created_by": end_user.id, + "created_at": int(UPLOADED_AT.timestamp()), + "preview_url": None, + "original_url": None, + "user_id": None, + "tenant_id": TENANT_ID, + "conversation_id": None, + "file_key": None, + } + assert source_url.startswith(f"https://files.example.com/files/appdeploy/{UPLOAD_FILE_ID}/content?token=") + + +def test_upload_carries_every_key_dify_s_web_client_reads( + app: Flask, end_user: EndUser, file_gateway: FileGrantFileGateway +) -> None: + with patch.object(file_gateway._file_service, "upload_file", return_value=_stub_upload_file()): + with app.test_request_context( + "/files/appdeploy/upload", + method="POST", + headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id), + data={"file": (BytesIO(b"pdf-bytes"), "report.pdf")}, + content_type="multipart/form-data", + ): + body, _ = GrantedFileUploadApi().post() + + assert set(body) >= WEB_CLIENT_KEYS + + +@pytest.mark.usefixtures("sqlite_db") +def test_upload_rejects_a_grant_from_another_tenant(app: Flask, end_user: EndUser) -> None: + with app.test_request_context( + "/files/appdeploy/upload", + method="POST", + headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id, tenant_id=OTHER_TENANT_ID), + data={"file": (BytesIO(b"pdf-bytes"), "report.pdf")}, + content_type="multipart/form-data", + ): + with pytest.raises(GrantedFileNotFoundError): + GrantedFileUploadApi().post() + + +@pytest.mark.usefixtures("sqlite_db") +def test_upload_applies_the_shared_per_extension_size_limit( + app: Flask, end_user: EndUser, config_overrides: Callable[..., None] +) -> None: + config_overrides(UPLOAD_FILE_SIZE_LIMIT=1) + + with app.test_request_context( + "/files/appdeploy/upload", + method="POST", + headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id), + data={"file": (BytesIO(b"0" * (1024 * 1024 + 1)), "big.bin")}, + content_type="multipart/form-data", + ): + with pytest.raises(FileTooLargeError): + GrantedFileUploadApi().post() + + +@pytest.mark.usefixtures("sqlite_db") +def test_upload_rejects_a_blacklisted_extension( + app: Flask, end_user: EndUser, config_overrides: Callable[..., None] +) -> None: + config_overrides(inner_UPLOAD_FILE_EXTENSION_BLACKLIST="exe") + + with app.test_request_context( + "/files/appdeploy/upload", + method="POST", + headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id), + data={"file": (BytesIO(b"MZ"), "payload.exe")}, + content_type="multipart/form-data", + ): + with pytest.raises(BlockedFileExtensionError): + GrantedFileUploadApi().post() + + +@pytest.mark.usefixtures("sqlite_db") +def test_upload_rejects_a_request_carrying_no_file(app: Flask, end_user: EndUser) -> None: + with app.test_request_context( + "/files/appdeploy/upload", + method="POST", + headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id), + data={"note": "no file here"}, + content_type="multipart/form-data", + ): + with pytest.raises(NoFileUploadedError): + GrantedFileUploadApi().post() + + +@pytest.mark.usefixtures("sqlite_db") +def test_upload_rejects_a_batch_of_files(app: Flask, end_user: EndUser) -> None: + with app.test_request_context( + "/files/appdeploy/upload", + method="POST", + headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id), + data={ + "file": (BytesIO(b"first"), "first.pdf"), + "second": (BytesIO(b"second"), "second.pdf"), + }, + content_type="multipart/form-data", + ): + with pytest.raises(TooManyFilesError): + GrantedFileUploadApi().post() + + +@pytest.mark.usefixtures("sqlite_db") +def test_upload_rejects_a_file_without_a_name(app: Flask, end_user: EndUser) -> None: + with app.test_request_context( + "/files/appdeploy/upload", + method="POST", + headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id), + data={"file": (BytesIO(b"nameless"), "")}, + content_type="multipart/form-data", + ): + with pytest.raises(FilenameNotExistsError): + GrantedFileUploadApi().post() + + +def test_remote_upload_fetches_through_the_ssrf_safe_fetcher( + app: Flask, end_user: EndUser, sqlite_db: FileGrantService, file_gateway: FileGrantFileGateway +) -> None: + url = "https://example.com/docs/report.pdf" + + with ( + patch.object( + sqlite_db._remote_files, + "fetch", + return_value=RemoteFile(filename="report.pdf", mimetype="application/pdf", content=b"pdf-bytes"), + ) as fetch, + patch.object(file_gateway._file_service, "upload_file", return_value=_stub_upload_file()) as upload_file, + ): + with app.test_request_context( + "/files/appdeploy/remote-upload", + method="POST", + headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id), + json={"url": url}, + ): + with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns: + files_ns.payload = {"url": url} + body, status = GrantedRemoteFileUploadApi().post() + + assert status == 201 + assert body["id"] == UPLOAD_FILE_ID + fetch.assert_called_once_with(url) + kwargs = upload_file.call_args.kwargs + assert kwargs["source_url"] == url + assert kwargs["content"] == b"pdf-bytes" + assert kwargs["user"].id == end_user.id + + +def test_remote_upload_answers_in_the_upload_shape_plus_dify_s_url_key( + app: Flask, end_user: EndUser, sqlite_db: FileGrantService, file_gateway: FileGrantFileGateway +) -> None: + """Dify's own remote upload answers under ``url``, so this one answers under both.""" + + url = "https://example.com/docs/report.pdf" + with ( + patch.object( + sqlite_db._remote_files, + "fetch", + return_value=RemoteFile(filename="report.pdf", mimetype="application/pdf", content=b"pdf-bytes"), + ), + patch.object( + file_gateway._file_service, + "upload_file", + return_value=_stub_upload_file(source_url=url), + ), + ): + with app.test_request_context( + "/files/appdeploy/remote-upload", + method="POST", + headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id), + json={"url": url}, + ): + with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns: + files_ns.payload = {"url": url} + body, _ = GrantedRemoteFileUploadApi().post() + + assert set(body) == DIFY_UPLOAD_RESPONSE_KEYS | {"url"} + # The row records where the bytes came from; the response hands back the + # signed URL that fetches them, exactly as dify's own remote upload does. + assert body["source_url"].startswith(f"https://files.example.com/files/appdeploy/{UPLOAD_FILE_ID}/content?token=") + # One URL under both names, not two signings of the same file. + assert body["url"] == body["source_url"] + + +def test_remote_upload_honours_the_size_precheck(app: Flask, end_user: EndUser, sqlite_db: FileGrantService) -> None: + url = "https://example.com/docs/huge.pdf" + with patch.object( + sqlite_db._remote_files, + "fetch", + side_effect=FileTooLargeServiceError("too large"), + ): + with app.test_request_context( + "/files/appdeploy/remote-upload", + method="POST", + headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id), + json={"url": url}, + ): + with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns: + files_ns.payload = {"url": url} + with pytest.raises(FileTooLargeError): + GrantedRemoteFileUploadApi().post() + + +def test_produced_stores_a_tool_file_and_returns_both_urls( + app: Flask, end_user: EndUser, file_gateway: FileGrantFileGateway +) -> None: + tool_file = SimpleNamespace( + id="88888888-8888-4888-8888-888888888888", + name="chart.png", + size=64, + mimetype="image/png", + ) + + with patch.object(file_gateway._tool_files, "create_file_by_raw", return_value=tool_file) as create_file: + with app.test_request_context( + "/files/appdeploy/produced", + method="POST", + headers=_bearer(FileGrantScope.PRODUCE, end_user_id=end_user.id), + data={"file": (BytesIO(b"0" * 16), "chart.png")}, + content_type="multipart/form-data", + ): + body, status = ProducedFileApi().post() + + assert status == 201 + kwargs = create_file.call_args.kwargs + assert kwargs["user_id"] == end_user.id + assert kwargs["tenant_id"] == TENANT_ID + assert kwargs["conversation_id"] is None + assert body["url"].startswith(f"https://files.example.com/files/appdeploy/{tool_file.id}/content?token=") + assert body["internal_url"].startswith( + f"http://dify-api.dify.svc:5001/files/appdeploy/{tool_file.id}/content?token=" + ) + + +def test_produced_rejects_a_grant_whose_subject_was_deleted(app: Flask, file_gateway: FileGrantFileGateway) -> None: + with patch.object(file_gateway._tool_files, "create_file_by_raw") as create_file: + with app.test_request_context( + "/files/appdeploy/produced", + method="POST", + headers=_bearer( + FileGrantScope.PRODUCE, + end_user_id="99999999-9999-4999-8999-999999999999", + ), + data={"file": (BytesIO(b"content"), "chart.png")}, + content_type="multipart/form-data", + ): + with pytest.raises(GrantedFileNotFoundError): + ProducedFileApi().post() + + create_file.assert_not_called() + + +@pytest.fixture +def one_megabyte_image_limit(config_overrides: Callable[..., None]) -> int: + """Hold images to one mebibyte while every other kind stays far above it.""" + + config_overrides( + UPLOAD_FILE_SIZE_LIMIT=64, + UPLOAD_IMAGE_FILE_SIZE_LIMIT=1, + UPLOAD_VIDEO_FILE_SIZE_LIMIT=64, + UPLOAD_AUDIO_FILE_SIZE_LIMIT=64, + ) + return 1024 * 1024 + + +def test_produced_accepts_a_file_of_exactly_the_per_extension_limit( + app: Flask, end_user: EndUser, file_gateway: FileGrantFileGateway, one_megabyte_image_limit: int +) -> None: + with patch.object( + file_gateway._tool_files, + "create_file_by_raw", + return_value=SimpleNamespace( + id="88888888-8888-4888-8888-888888888888", + name="chart.png", + size=one_megabyte_image_limit, + mimetype="image/png", + ), + ) as create_file: + with app.test_request_context( + "/files/appdeploy/produced", + method="POST", + headers=_bearer(FileGrantScope.PRODUCE, end_user_id=end_user.id), + data={"file": (BytesIO(b"0" * one_megabyte_image_limit), "chart.png")}, + content_type="multipart/form-data", + ): + _, status = ProducedFileApi().post() + + assert status == 201 + assert len(create_file.call_args.kwargs["file_binary"]) == one_megabyte_image_limit + + +def test_produced_rejects_a_file_one_byte_over_the_per_extension_limit( + app: Flask, end_user: EndUser, file_gateway: FileGrantFileGateway, one_megabyte_image_limit: int +) -> None: + """``create_file_by_raw`` has no limit of its own, so nothing else would stop this.""" + + with patch.object(file_gateway._tool_files, "create_file_by_raw") as create_file: + with app.test_request_context( + "/files/appdeploy/produced", + method="POST", + headers=_bearer(FileGrantScope.PRODUCE, end_user_id=end_user.id), + data={"file": (BytesIO(b"0" * (one_megabyte_image_limit + 1)), "chart.png")}, + content_type="multipart/form-data", + ): + with pytest.raises(FileTooLargeError) as raised: + ProducedFileApi().post() + + assert raised.value.code == 413 + create_file.assert_not_called() + + +class _CountingStream: + """A body that reports how much of itself a reader actually pulled.""" + + def __init__(self, size: int) -> None: + self._remaining = size + self.bytes_read = 0 + + def read(self, size: int = -1) -> bytes: + served = self._remaining if size < 0 else min(size, self._remaining) + self._remaining -= served + self.bytes_read += served + return b"0" * served + + +def test_produced_stops_reading_an_oversized_body_at_the_per_extension_limit( + end_user: EndUser, + sqlite_db: FileGrantService, + file_gateway: FileGrantFileGateway, + one_megabyte_image_limit: int, +) -> None: + """The caller is a worker running plugin code with no proxy body limit in front of it.""" + + stream = _CountingStream(one_megabyte_image_limit * 64) + + with patch.object(file_gateway._tool_files, "create_file_by_raw") as create_file: + with pytest.raises(FileTooLargeServiceError): + sqlite_db.store_produced( + context=FileGrantContext(TENANT_ID, APP_ID, end_user.id), + filename="chart.png", + stream=cast(IO[bytes], stream), + mimetype="image/png", + ) + + assert stream.bytes_read == one_megabyte_image_limit + 1 + create_file.assert_not_called() + + +@pytest.mark.usefixtures("sqlite_db") +def test_resolve_signs_urls_per_item_and_hides_foreign_files( + app: Flask, end_user: EndUser, sqlite_session: Session +) -> None: + owned = _persist_upload_file(sqlite_session, owner_id=end_user.id) + foreign = _persist_upload_file(sqlite_session, owner_id="00000000-0000-4000-8000-000000000000") + payload = { + "files": [ + {"id": owned.id, "kind": "upload"}, + {"id": foreign.id, "kind": "upload"}, + ] + } + + with app.test_request_context( + "/files/appdeploy/resolve", + method="POST", + headers=_bearer(FileGrantScope.RESOLVE, end_user_id=end_user.id), + json=payload, + ): + with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns: + files_ns.payload = payload + body = GrantedFileResolveApi().post() + + resolved, hidden = body["files"] + assert resolved["ok"] is True + assert resolved["kind"] == "upload" + assert resolved["extension"] == "pdf" + assert resolved["url"].startswith(f"https://files.example.com/files/appdeploy/{owned.id}/content?token=") + assert resolved["internal_url"].startswith( + f"http://dify-api.dify.svc:5001/files/appdeploy/{owned.id}/content?token=" + ) + assert hidden == { + "id": foreign.id, + "ok": False, + "kind": None, + "name": None, + "size": None, + "extension": None, + "mime_type": None, + "url": None, + "internal_url": None, + "error": "not_found", + } + + +@pytest.mark.usefixtures("sqlite_db") +def test_resolve_answers_a_mixed_batch_item_by_item(app: Flask, end_user: EndUser, sqlite_session: Session) -> None: + owned_upload = _persist_upload_file(sqlite_session, owner_id=end_user.id) + owned_tool = _persist_tool_file(sqlite_session, owner_id=end_user.id) + missing_id = "44444444-4444-4444-8444-444444444444" + payload = { + "files": [ + {"id": owned_upload.id, "kind": "upload"}, + {"id": missing_id, "kind": "upload"}, + # A real file, but looked up in the wrong table. + {"id": owned_upload.id, "kind": "tool"}, + {"id": owned_tool.id, "kind": "tool"}, + ] + } + + with app.test_request_context( + "/files/appdeploy/resolve", + method="POST", + headers=_bearer(FileGrantScope.RESOLVE, end_user_id=end_user.id), + json=payload, + ): + with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns: + files_ns.payload = payload + body = GrantedFileResolveApi().post() + + assert [(file["id"], file["ok"], file["kind"], file["error"]) for file in body["files"]] == [ + (owned_upload.id, True, "upload", None), + (missing_id, False, None, "not_found"), + (owned_upload.id, False, None, "not_found"), + (owned_tool.id, True, "tool", None), + ] + + +@pytest.mark.usefixtures("sqlite_db") +def test_resolve_returns_an_empty_batch_unchanged(app: Flask, end_user: EndUser) -> None: + payload: dict[str, object] = {"files": []} + + with app.test_request_context( + "/files/appdeploy/resolve", + method="POST", + headers=_bearer(FileGrantScope.RESOLVE, end_user_id=end_user.id), + json=payload, + ): + with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns: + files_ns.payload = payload + assert GrantedFileResolveApi().post() == {"files": []} + + +def test_resolve_rejects_an_unbounded_batch(app: Flask, end_user: EndUser, sqlite_db: FileGrantService) -> None: + del sqlite_db + payload = { + "files": [ + {"id": f"00000000-0000-4000-8000-{index:012d}", "kind": "upload"} + for index in range(MAX_FILE_GRANT_REFS + 1) + ] + } + with app.test_request_context( + "/files/appdeploy/resolve", + method="POST", + headers=_bearer(FileGrantScope.RESOLVE, end_user_id=end_user.id), + json=payload, + ): + with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns: + files_ns.payload = payload + with pytest.raises(InvalidFileRequestError): + GrantedFileResolveApi().post() + + +@pytest.fixture +def stored_bytes(file_gateway: FileGrantFileGateway) -> Iterator[MagicMock]: + with patch.object(file_gateway._storage, "load", return_value=iter([b"file-bytes"])) as load: + yield load + + +@pytest.mark.usefixtures("sqlite_db", "stored_bytes") +@pytest.mark.parametrize( + ("mime_type", "expected_content_type", "expects_attachment"), + [ + ("image/png", "image/png", False), + ("image/jpeg", "image/jpeg", False), + ("image/gif", "image/gif", False), + ("image/webp", "image/webp", False), + # Case and parameters are normalized before the whitelist is consulted. + ("IMAGE/PNG; charset=binary", "image/png", False), + ("application/pdf", "application/octet-stream", True), + ("image/svg+xml", "application/octet-stream", True), + ("text/html", "application/octet-stream", True), + ("application/xhtml+xml", "application/octet-stream", True), + ("text/javascript", "application/octet-stream", True), + ("audio/mpeg", "application/octet-stream", True), + ("video/mp4", "application/octet-stream", True), + ], +) +def test_content_disposition_follows_the_inline_whitelist( + app: Flask, + sqlite_session: Session, + mime_type: str, + expected_content_type: str, + expects_attachment: bool, +) -> None: + tool_file = _persist_tool_file(sqlite_session, owner_id="anyone", mimetype=mime_type) + token = _content_token(file_id=tool_file.id, kind=FileKind.TOOL) + + with app.test_request_context(f"/files/appdeploy/{tool_file.id}/content", query_string={"token": token}): + response = GrantedFileContentApi().get(UUID(tool_file.id)) + + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["Content-Type"].startswith(expected_content_type) + assert ("Content-Disposition" in response.headers) is expects_attachment + if expects_attachment: + assert response.headers["Content-Disposition"] == "attachment; filename*=UTF-8''chart.png" + # Range is never honoured here, so the hint must not be advertised either. + assert "Accept-Ranges" not in response.headers + assert response.headers["Content-Length"] == "64" + + +@pytest.mark.usefixtures("sqlite_db", "stored_bytes") +def test_content_downloads_a_file_with_no_recorded_mime_type(app: Flask, sqlite_session: Session) -> None: + upload_file = _persist_upload_file(sqlite_session, owner_id="anyone") + sqlite_session.execute(update(UploadFile).where(UploadFile.id == upload_file.id).values(mime_type=None)) + sqlite_session.commit() + token = _content_token(file_id=upload_file.id, kind=FileKind.UPLOAD) + + with app.test_request_context(f"/files/appdeploy/{upload_file.id}/content", query_string={"token": token}): + response = GrantedFileContentApi().get(UUID(upload_file.id)) + + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["Content-Type"].startswith("application/octet-stream") + assert response.headers["Content-Disposition"] == "attachment; filename*=UTF-8''report.pdf" + + +@pytest.mark.usefixtures("sqlite_db", "stored_bytes") +def test_content_rejects_an_expired_token(app: Flask, sqlite_session: Session) -> None: + tool_file = _persist_tool_file(sqlite_session, owner_id="anyone") + token = _content_token(file_id=tool_file.id, kind=FileKind.TOOL, expires_in=-1) + + with app.test_request_context(f"/files/appdeploy/{tool_file.id}/content", query_string={"token": token}): + with pytest.raises(FileGrantInvalidError): + GrantedFileContentApi().get(UUID(tool_file.id)) + + +@pytest.mark.usefixtures("sqlite_db", "stored_bytes") +def test_content_rejects_a_token_minted_for_another_file(app: Flask, sqlite_session: Session) -> None: + tool_file = _persist_tool_file(sqlite_session, owner_id="anyone") + token = _content_token(file_id=str(FILE_ID), kind=FileKind.TOOL) + + with app.test_request_context(f"/files/appdeploy/{tool_file.id}/content", query_string={"token": token}): + with pytest.raises(GrantedFileNotFoundError): + GrantedFileContentApi().get(UUID(tool_file.id)) + + +@pytest.mark.usefixtures("sqlite_db", "stored_bytes") +def test_content_rejects_a_token_naming_the_wrong_table(app: Flask, sqlite_session: Session) -> None: + tool_file = _persist_tool_file(sqlite_session, owner_id="anyone") + token = _content_token(file_id=tool_file.id, kind=FileKind.UPLOAD) + + with app.test_request_context(f"/files/appdeploy/{tool_file.id}/content", query_string={"token": token}): + with pytest.raises(GrantedFileNotFoundError): + GrantedFileContentApi().get(UUID(tool_file.id)) + + +@pytest.mark.usefixtures("sqlite_db", "stored_bytes") +def test_content_rejects_a_file_grant_replayed_as_a_content_token(app: Flask, sqlite_session: Session) -> None: + tool_file = _persist_tool_file(sqlite_session, owner_id="anyone") + grant, _ = issue_file_grant( + end_user_id="anyone", + tenant_id=TENANT_ID, + app_id=APP_ID, + scopes=[FileGrantScope.RESOLVE], + ttl_seconds=600, + ) + + with app.test_request_context(f"/files/appdeploy/{tool_file.id}/content", query_string={"token": grant}): + with pytest.raises(FileGrantInvalidError): + GrantedFileContentApi().get(UUID(tool_file.id)) diff --git a/api/tests/unit_tests/controllers/files/test_file_grant_wraps.py b/api/tests/unit_tests/controllers/files/test_file_grant_wraps.py new file mode 100644 index 00000000000..893001f54df --- /dev/null +++ b/api/tests/unit_tests/controllers/files/test_file_grant_wraps.py @@ -0,0 +1,151 @@ +"""Tests for the Bearer file-grant decorator.""" + +import time +from collections.abc import Callable +from types import SimpleNamespace +from typing import cast + +import jwt +import pytest +from flask import Flask + +from controllers.files.wraps import FileGrantInvalidError, FileGrantScopeDeniedError, file_grant_required +from libs.passport import PassportService +from services.entities.file_grant_entities import FileGrantClaims, FileGrantScope +from services.file_grant_gateways import FILE_GRANT_AUDIENCE +from tests.unit_tests.file_grant_test_utils import issue_file_grant, token_gateway + +SECRET_KEY = "file-grant-test-secret-long-enough-for-hs256" +TENANT_ID = "11111111-1111-4111-8111-111111111111" +APP_ID = "22222222-2222-4222-8222-222222222222" +END_USER_ID = "55555555-5555-4555-8555-555555555555" + + +@pytest.fixture(autouse=True) +def granted_config(config_overrides: Callable[..., None]) -> None: + config_overrides(SECRET_KEY=SECRET_KEY) + + +@pytest.fixture(autouse=True) +def file_grant_service(granted_config: None, monkeypatch: pytest.MonkeyPatch) -> None: + del granted_config + service = SimpleNamespace(decode_grant=token_gateway().decode_grant) + monkeypatch.setattr( + "controllers.files.wraps.application_services", + lambda: SimpleNamespace(file_grants=service), + ) + + +@file_grant_required(FileGrantScope.UPLOAD) +def _view(grant: FileGrantClaims) -> FileGrantClaims: + return grant + + +def _call(app: Flask, authorization: str | None) -> FileGrantClaims: + headers: dict[str, str] = {"Authorization": authorization} if authorization is not None else {} + with app.test_request_context("/", method="POST", headers=headers): + return cast(Callable[[], FileGrantClaims], _view)() + + +def _grant(*scopes: FileGrantScope, ttl_seconds: int = 600) -> str: + token, _ = issue_file_grant( + end_user_id=END_USER_ID, + tenant_id=TENANT_ID, + app_id=APP_ID, + scopes=scopes, + ttl_seconds=ttl_seconds, + ) + return token + + +def test_valid_grant_is_injected_into_the_view(app: Flask) -> None: + claims = _call(app, f"Bearer {_grant(FileGrantScope.UPLOAD, FileGrantScope.RESOLVE)}") + + assert claims.sub == END_USER_ID + assert claims.tenant_id == TENANT_ID + assert claims.app_id == APP_ID + assert claims.scopes == [FileGrantScope.UPLOAD, FileGrantScope.RESOLVE] + + +def test_missing_authorization_is_rejected(app: Flask) -> None: + with pytest.raises(FileGrantInvalidError): + _call(app, None) + + +def test_non_bearer_authorization_is_rejected(app: Flask) -> None: + with pytest.raises(FileGrantInvalidError): + _call(app, f"Basic {_grant(FileGrantScope.UPLOAD)}") + + +def test_webapp_passport_cannot_be_replayed_as_a_grant(app: Flask) -> None: + """The passport is signed with the same key, so only ``aud`` separates them.""" + + passport = PassportService().issue( + { + "iss": "SELF_HOSTED", + "sub": "Web API Passport", + "app_id": APP_ID, + "end_user_id": END_USER_ID, + "exp": int(time.time()) + 600, + } + ) + + with pytest.raises(FileGrantInvalidError): + _call(app, f"Bearer {passport}") + + +def test_content_token_audience_is_not_accepted_as_a_grant(app: Flask) -> None: + content_token = jwt.encode( + { + "aud": "dify-files-content", + "kind": "upload", + "file_id": "66666666-6666-4666-8666-666666666666", + "exp": int(time.time()) + 600, + }, + SECRET_KEY, + algorithm="HS256", + ) + + with pytest.raises(FileGrantInvalidError): + _call(app, f"Bearer {content_token}") + + +def test_expired_grant_is_rejected(app: Flask) -> None: + expired = jwt.encode( + { + "aud": FILE_GRANT_AUDIENCE, + "sub": END_USER_ID, + "tenant_id": TENANT_ID, + "app_id": APP_ID, + "scopes": ["upload"], + "exp": int(time.time()) - 1, + }, + SECRET_KEY, + algorithm="HS256", + ) + + with pytest.raises(FileGrantInvalidError): + _call(app, f"Bearer {expired}") + + +def test_grant_signed_with_another_key_is_rejected(app: Flask) -> None: + forged = jwt.encode( + { + "aud": FILE_GRANT_AUDIENCE, + "sub": END_USER_ID, + "tenant_id": TENANT_ID, + "app_id": APP_ID, + "scopes": ["upload"], + "exp": int(time.time()) + 600, + }, + "some-other-secret-long-enough-for-hs256-signing", + algorithm="HS256", + ) + + with pytest.raises(FileGrantInvalidError): + _call(app, f"Bearer {forged}") + + +def test_grant_without_the_required_scope_is_denied(app: Flask) -> None: + with pytest.raises(FileGrantScopeDeniedError): + _call(app, f"Bearer {_grant(FileGrantScope.RESOLVE, FileGrantScope.PRODUCE)}") diff --git a/api/tests/unit_tests/controllers/inner_api/app/test_file_grants.py b/api/tests/unit_tests/controllers/inner_api/app/test_file_grants.py new file mode 100644 index 00000000000..97b5e1adffc --- /dev/null +++ b/api/tests/unit_tests/controllers/inner_api/app/test_file_grants.py @@ -0,0 +1,493 @@ +"""Tests for the AppDeploy file grant minting endpoint.""" + +import inspect +import os +import time +from collections.abc import Callable, Iterator +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import jwt +import pytest +from flask import Flask +from sqlalchemy import select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from controllers.files.wraps import GrantedFileNotFoundError +from controllers.inner_api.app.file_grants import ( + EnterpriseFileGrantApi, + GrantAppNotFoundError, + GrantTtlTooLongError, + InvalidGrantRequestError, + InvalidSubjectError, +) +from extensions.ext_application_services import _build_file_grant_service +from extensions.storage.storage_type import StorageType +from libs.datetime_utils import naive_utc_now +from models.enums import CreatorUserRole, EndUserType +from models.model import App, EndUser, UploadFile +from models.tools import ToolFile +from services import end_user_service +from services.end_user_service import EndUserService +from services.file_grant_gateways import FILE_GRANT_AUDIENCE +from services.file_grant_service import ( + MAX_RUN_GRANT_TTL_SECONDS, + MAX_SESSION_GRANT_TTL_SECONDS, + MAX_WORKFLOW_EXECUTION_SECONDS, + RUN_GRANT_EXPIRY_GRACE_SECONDS, + FileGrantService, +) + +CONTROLLER_MODULE = "controllers.inner_api.app.file_grants" + +SECRET_KEY = "file-grant-test-secret-long-enough-for-hs256" +TENANT_ID = "11111111-1111-4111-8111-111111111111" +APP_ID = "22222222-2222-4222-8222-222222222222" +SUBJECT = "adp1.dGVzdC1zdWJqZWN0" + + +@pytest.fixture +def granted_config(config_overrides: Callable[..., None]) -> None: + config_overrides( + SECRET_KEY=SECRET_KEY, + FILES_URL="https://files.example.com", + INTERNAL_FILES_URL="http://dify-api.dify.svc:5001", + FILES_ACCESS_TIMEOUT=300, + ) + + +@pytest.fixture +def seeded_app(sqlite_session: Session) -> App: + app_model = App( + id=APP_ID, + tenant_id=TENANT_ID, + name="deployed app", + mode="workflow", + enable_site=True, + enable_api=True, + ) + sqlite_session.add(app_model) + sqlite_session.commit() + return app_model + + +def _mint(app: Flask, payload: dict[str, object]) -> dict[str, object]: + handler = EnterpriseFileGrantApi() + with app.test_request_context("/", method="POST", json=payload): + with patch(f"{CONTROLLER_MODULE}.inner_api_ns") as mock_ns: + mock_ns.payload = payload + return inspect.unwrap(handler.post)(handler) + + +def _subject_of(response: dict[str, object]) -> str: + grant = response["grant"] + assert isinstance(grant, str) + return str(jwt.decode(grant, SECRET_KEY, algorithms=["HS256"], audience=FILE_GRANT_AUDIENCE)["sub"]) + + +def _payload(**overrides: object) -> dict[str, object]: + return { + "tenant_id": TENANT_ID, + "app_id": APP_ID, + "subject": SUBJECT, + "is_anonymous": True, + "scopes": ["upload"], + "ttl_seconds": 600, + } | overrides + + +def _persist_upload_file(session: Session, *, owner_id: str, tenant_id: str = TENANT_ID) -> UploadFile: + upload_file = UploadFile( + tenant_id=tenant_id, + storage_type=StorageType.OPENDAL, + key="upload_files/report.pdf", + name="report.pdf", + size=2048, + extension="pdf", + mime_type="application/pdf", + created_by=owner_id, + created_by_role=CreatorUserRole.END_USER, + created_at=naive_utc_now(), + used=False, + ) + session.add(upload_file) + session.commit() + return upload_file + + +def _persist_tool_file(session: Session, *, owner_id: str, tenant_id: str = TENANT_ID) -> ToolFile: + tool_file = ToolFile( + user_id=owner_id, + tenant_id=tenant_id, + conversation_id=None, + file_key="tools/chart.png", + mimetype="image/png", + name="chart.png", + size=64, + ) + session.add(tool_file) + session.commit() + return tool_file + + +@pytest.fixture +def sqlite_db(sqlite_engine: Engine, granted_config: None) -> Iterator[None]: + del granted_config + service = _build_file_grant_service(database_client=sessionmaker(bind=sqlite_engine, expire_on_commit=False)) + services = SimpleNamespace(file_grants=service) + with patch(f"{CONTROLLER_MODULE}.application_services", return_value=services): + yield + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_creates_exactly_one_end_user_and_reuses_it(app: Flask, sqlite_session: Session) -> None: + first = _mint(app, _payload()) + second = _mint(app, _payload(ttl_seconds=900)) + + end_users = list(sqlite_session.scalars(select(EndUser).where(EndUser.tenant_id == TENANT_ID)).all()) + assert len(end_users) == 1 + assert end_users[0].type == EndUserType.APP_DEPLOY + assert end_users[0].session_id == FileGrantService.session_id_for_subject(SUBJECT) + assert end_users[0].external_user_id == SUBJECT + + first_grant = first["grant"] + second_grant = second["grant"] + assert isinstance(first_grant, str) + assert isinstance(second_grant, str) + first_claims = jwt.decode(first_grant, SECRET_KEY, algorithms=["HS256"], audience=FILE_GRANT_AUDIENCE) + second_claims = jwt.decode(second_grant, SECRET_KEY, algorithms=["HS256"], audience=FILE_GRANT_AUDIENCE) + assert first_claims["sub"] == second_claims["sub"] == end_users[0].id + assert first_claims["tenant_id"] == TENANT_ID + assert first_claims["app_id"] == APP_ID + assert first_claims["scopes"] == ["upload"] + first_expires_at = first["expires_at"] + second_expires_at = second["expires_at"] + assert isinstance(first_expires_at, int) + assert isinstance(second_expires_at, int) + assert second_expires_at > first_expires_at + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_returns_dify_upload_limits(app: Flask, config_overrides: Callable[..., None]) -> None: + config_overrides( + UPLOAD_FILE_SIZE_LIMIT=15, + UPLOAD_IMAGE_FILE_SIZE_LIMIT=10, + UPLOAD_AUDIO_FILE_SIZE_LIMIT=50, + UPLOAD_VIDEO_FILE_SIZE_LIMIT=100, + WORKFLOW_FILE_UPLOAD_LIMIT=10, + UPLOAD_FILE_BATCH_LIMIT=5, + ) + + response = _mint(app, _payload()) + + assert response["limits"] == { + "file_size_limit": 15, + "image_file_size_limit": 10, + "audio_file_size_limit": 50, + "video_file_size_limit": 100, + "workflow_file_upload_limit": 10, + "batch_count_limit": 5, + } + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_rejects_ttl_over_the_cap_before_touching_identity(app: Flask, sqlite_session: Session) -> None: + with pytest.raises(GrantTtlTooLongError): + _mint(app, _payload(ttl_seconds=MAX_SESSION_GRANT_TTL_SECONDS + 1)) + + assert sqlite_session.scalars(select(EndUser)).all() == [] + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_accepts_a_session_ttl_exactly_at_the_cap(app: Flask) -> None: + before = int(time.time()) + + response = _mint(app, _payload(ttl_seconds=MAX_SESSION_GRANT_TTL_SECONDS)) + + expires_at = response["expires_at"] + assert isinstance(expires_at, int) + assert MAX_SESSION_GRANT_TTL_SECONDS <= expires_at - before <= MAX_SESSION_GRANT_TTL_SECONDS + 5 + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_accepts_a_run_ttl_until_deadline_plus_grace(app: Flask) -> None: + now = int(time.time()) + run_duration = MAX_RUN_GRANT_TTL_SECONDS - RUN_GRANT_EXPIRY_GRACE_SECONDS + + response = _mint( + app, + _payload( + scopes=["resolve", "produce"], + ttl_seconds=MAX_RUN_GRANT_TTL_SECONDS, + run_deadline=now + run_duration, + ), + ) + + expires_at = response["expires_at"] + assert isinstance(expires_at, int) + assert MAX_RUN_GRANT_TTL_SECONDS <= expires_at - now <= MAX_RUN_GRANT_TTL_SECONDS + 5 + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_rejects_a_run_ttl_past_deadline_grace(app: Flask) -> None: + now = int(time.time()) + + with pytest.raises(GrantTtlTooLongError): + _mint( + app, + _payload( + scopes=["resolve", "produce"], + ttl_seconds=MAX_RUN_GRANT_TTL_SECONDS + 1, + run_deadline=now + MAX_WORKFLOW_EXECUTION_SECONDS, + ), + ) + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_rejects_an_expired_run_deadline(app: Flask) -> None: + with pytest.raises(InvalidGrantRequestError): + _mint( + app, + _payload(scopes=["resolve", "produce"], ttl_seconds=1, run_deadline=int(time.time()) - 1), + ) + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_rejects_a_run_deadline_without_produce_scope(app: Flask) -> None: + with pytest.raises(InvalidGrantRequestError): + _mint(app, _payload(run_deadline=int(time.time()) + 60)) + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_rejects_a_run_deadline_beyond_the_workflow_limit(app: Flask) -> None: + with pytest.raises(InvalidGrantRequestError): + _mint( + app, + _payload( + scopes=["resolve", "produce"], + run_deadline=int(time.time()) + MAX_WORKFLOW_EXECUTION_SECONDS + 1, + ), + ) + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_caps_a_run_grant_at_deadline_plus_grace(app: Flask) -> None: + now = int(time.time()) + run_deadline = now + 60 + + response = _mint( + app, + _payload( + scopes=["resolve", "produce"], + ttl_seconds=1200, + run_deadline=run_deadline, + ), + ) + + expires_at = response["expires_at"] + assert isinstance(expires_at, int) + assert ( + run_deadline + RUN_GRANT_EXPIRY_GRACE_SECONDS + <= expires_at + <= (run_deadline + RUN_GRANT_EXPIRY_GRACE_SECONDS + 1) + ) + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +@pytest.mark.parametrize("ttl_seconds", [0, -1]) +def test_mint_rejects_a_non_positive_ttl(app: Flask, ttl_seconds: int) -> None: + with pytest.raises(InvalidGrantRequestError): + _mint(app, _payload(ttl_seconds=ttl_seconds)) + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +@pytest.mark.parametrize("subject", ["", " ", "\t\n", "adp1.with\x00nul", "\x00"]) +def test_mint_rejects_an_unusable_subject(app: Flask, subject: str, sqlite_session: Session) -> None: + """A NUL would reach ``external_user_id`` verbatim and blow up on PostgreSQL.""" + + with pytest.raises(InvalidSubjectError): + _mint(app, _payload(subject=subject)) + + assert sqlite_session.scalars(select(EndUser)).all() == [] + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_folds_an_oversized_subject_into_one_identity(app: Flask, sqlite_session: Session) -> None: + """``external_user_id`` truncates at 255, so ``session_id`` is what keeps them apart.""" + + long_subject = "adp1." + "s" * 4000 + sibling = long_subject[:-1] + "t" + + first = _mint(app, _payload(subject=long_subject)) + again = _mint(app, _payload(subject=long_subject)) + other = _mint(app, _payload(subject=sibling)) + + end_users = sqlite_session.scalars(select(EndUser).order_by(EndUser.created_at)).all() + assert len(end_users) == 2 + assert all(len(end_user.external_user_id) == 255 for end_user in end_users) + assert _subject_of(first) == _subject_of(again) != _subject_of(other) + + +@pytest.mark.usefixtures("granted_config", "sqlite_db") +def test_mint_rejects_unknown_app(app: Flask) -> None: + with pytest.raises(GrantAppNotFoundError): + _mint(app, _payload()) + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_returns_strict_metadata_without_urls(app: Flask, sqlite_session: Session) -> None: + owner_id = _subject_of(_mint(app, _payload())) + upload_file = _persist_upload_file(sqlite_session, owner_id=owner_id) + tool_file = _persist_tool_file(sqlite_session, owner_id=owner_id) + + response = _mint( + app, + _payload(file_ids=[{"id": upload_file.id, "kind": "upload"}, {"id": tool_file.id, "kind": "tool"}]), + ) + + assert response["files"] == [ + { + "id": upload_file.id, + "kind": "upload", + "name": "report.pdf", + "size": 2048, + "extension": "pdf", + "mime_type": "application/pdf", + }, + { + "id": tool_file.id, + "kind": "tool", + "name": "chart.png", + "size": 64, + "extension": "png", + "mime_type": "image/png", + }, + ] + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_fails_the_whole_strict_batch_on_one_miss(app: Flask, sqlite_session: Session) -> None: + owner_id = _subject_of(_mint(app, _payload())) + upload_file = _persist_upload_file(sqlite_session, owner_id=owner_id) + + with pytest.raises(GrantedFileNotFoundError): + _mint( + app, + _payload( + file_ids=[ + {"id": upload_file.id, "kind": "upload"}, + {"id": "33333333-3333-4333-8333-333333333333", "kind": "upload"}, + ] + ), + ) + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_hides_files_owned_by_another_subject(app: Flask, sqlite_session: Session) -> None: + other_owner = _subject_of(_mint(app, _payload(subject="adp1.other"))) + foreign_file = _persist_upload_file(sqlite_session, owner_id=other_owner) + + with pytest.raises(GrantedFileNotFoundError): + _mint(app, _payload(file_ids=[{"id": foreign_file.id, "kind": "upload"}])) + + +@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db") +def test_mint_reports_optional_files_item_by_item(app: Flask, sqlite_session: Session) -> None: + owner_id = _subject_of(_mint(app, _payload())) + upload_file = _persist_upload_file(sqlite_session, owner_id=owner_id) + missing_id = "44444444-4444-4444-8444-444444444444" + + response = _mint( + app, + _payload( + optional_file_ids=[ + {"id": upload_file.id, "kind": "upload"}, + {"id": missing_id, "kind": "tool"}, + ] + ), + ) + + optional_files = response["optional_files"] + assert isinstance(optional_files, list) + present, absent = optional_files + assert present["ok"] is True + assert present["name"] == "report.pdf" + assert present["url"].startswith(f"https://files.example.com/files/appdeploy/{upload_file.id}/content?token=") + assert present["internal_url"].startswith( + f"http://dify-api.dify.svc:5001/files/appdeploy/{upload_file.id}/content?token=" + ) + assert absent == { + "id": missing_id, + "ok": False, + "kind": None, + "name": None, + "size": None, + "extension": None, + "mime_type": None, + "url": None, + "internal_url": None, + "error": "not_found", + } + assert response["files"] == [] + + +@pytest.mark.usefixtures("seeded_app") +def test_end_user_service_never_retypes_an_app_deploy_row( + sqlite_engine: Engine, + sqlite_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Retyping would hide the row from the grant read and strand its files.""" + + subject = "subject-that-also-reaches-the-service-api" + session_id = FileGrantService.session_id_for_subject(subject) + owner = EndUser( + tenant_id=TENANT_ID, + app_id=APP_ID, + type=EndUserType.APP_DEPLOY, + is_anonymous=True, + session_id=session_id, + external_user_id=session_id, + ) + sqlite_session.add(owner) + sqlite_session.commit() + owner_id = owner.id + monkeypatch.setattr(end_user_service, "db", SimpleNamespace(engine=sqlite_engine)) + + EndUserService.get_or_create_end_user_by_type(EndUserType.SERVICE_API, TENANT_ID, APP_ID, session_id) + + sqlite_session.expire_all() + persisted_owner = sqlite_session.get(EndUser, owner_id) + assert persisted_owner is not None + assert persisted_owner.type == EndUserType.APP_DEPLOY + + +SKIPPED_DIRECTORY_NAMES = frozenset({".git", ".venv", "__pycache__", "migrations", "node_modules", "tests"}) + + +def test_app_deploy_end_users_have_exactly_one_writer() -> None: + """``end_users`` has no unique constraint, so a second writer would fork identities. + + ``end_user_service`` names the type only to exclude it from the legacy retype; + the behavioural guard above is what holds that exclusion in place. + """ + + api_root = Path(__file__).resolve().parents[5] + assert api_root.name == "api" + + referencing_modules: set[str] = set() + for directory, subdirectories, filenames in os.walk(api_root): + subdirectories[:] = [name for name in subdirectories if name not in SKIPPED_DIRECTORY_NAMES] + for filename in filenames: + if not filename.endswith(".py"): + continue + path = Path(directory) / filename + if "EndUserType.APP_DEPLOY" in path.read_text(encoding="utf-8"): + referencing_modules.add(path.relative_to(api_root).as_posix()) + + assert referencing_modules == { + "repositories/file_grant_repository.py", + "services/end_user_service.py", + } diff --git a/api/tests/unit_tests/controllers/web/test_app.py b/api/tests/unit_tests/controllers/web/test_app.py index 005c23e7f25..9b1247bf431 100644 --- a/api/tests/unit_tests/controllers/web/test_app.py +++ b/api/tests/unit_tests/controllers/web/test_app.py @@ -216,18 +216,17 @@ class TestAppWebAuthPermission: extract_passport.assert_not_called() @pytest.mark.parametrize( - ("decoded", "expected_user_id", "allowed"), + ("user_id", "allowed"), [ - pytest.param({"user_id": "user-1"}, "user-1", True, id="identified-user"), - pytest.param({}, "visitor", False, id="visitor-fallback"), + pytest.param("user-1", True, id="allowed-user"), + pytest.param("user-2", False, id="denied-user"), ], ) @patch("controllers.web.app.application_services") def test_checks_private_app_permission( self, application_services: MagicMock, - decoded: dict[str, str], - expected_user_id: str, + user_id: str, allowed: bool, app: Flask, ) -> None: @@ -241,14 +240,41 @@ class TestAppWebAuthPermission: patch("controllers.web.app.extract_webapp_passport", return_value="passport") as extract_passport, patch("controllers.web.app.PassportService") as passport_service, ): - passport_service.return_value.verify.return_value = decoded + passport_service.return_value.verify.return_value = {"user_id": user_id, "auth_type": "internal"} result = AppWebAuthPermission().get() assert result == {"result": allowed} webapp_access.requires_permission_check.assert_called_once_with("app-1") extract_passport.assert_called_once() passport_service.return_value.verify.assert_called_once_with("passport") - webapp_access.is_user_allowed.assert_called_once_with(user_id=expected_user_id, app_id="app-1") + webapp_access.is_user_allowed.assert_called_once_with(user_id=user_id, app_id="app-1") + + @pytest.mark.parametrize( + "decoded", + [ + pytest.param({}, id="missing-auth-type"), + pytest.param({"auth_type": "internal"}, id="missing-user-id"), + pytest.param({"user_id": "sso_external_user", "auth_type": "external"}, id="external-auth-type"), + ], + ) + @patch("controllers.web.app.application_services") + def test_private_app_requires_internal_identity( + self, application_services: MagicMock, decoded: dict[str, str], app: Flask + ) -> None: + webapp_access = MagicMock() + webapp_access.requires_permission_check.return_value = True + application_services.return_value = SimpleNamespace(webapp_access=webapp_access) + + with ( + app.test_request_context("/webapp/permission?appId=app-1", headers={"X-App-Code": "code1"}), + patch("controllers.web.app.extract_webapp_passport", return_value="passport"), + patch("controllers.web.app.PassportService") as passport_service, + ): + passport_service.return_value.verify.return_value = decoded + with pytest.raises(WebAppAuthRequiredError): + AppWebAuthPermission().get() + + webapp_access.is_user_allowed.assert_not_called() @pytest.mark.parametrize("failing_method", ["requires_permission_check", "is_user_allowed"]) @patch("controllers.web.app.application_services") @@ -264,7 +290,7 @@ class TestAppWebAuthPermission: application_services.return_value = SimpleNamespace(webapp_access=webapp_access) passport_service = MagicMock() - passport_service.return_value.verify.return_value = {"user_id": "user-1"} + passport_service.return_value.verify.return_value = {"user_id": "user-1", "auth_type": "internal"} with ( app.test_request_context("/webapp/permission?appId=app-1", headers={"X-App-Code": "code1"}), patch("controllers.web.app.extract_webapp_passport", return_value="passport"), diff --git a/api/tests/unit_tests/file_grant_test_utils.py b/api/tests/unit_tests/file_grant_test_utils.py new file mode 100644 index 00000000000..0fb7eae9408 --- /dev/null +++ b/api/tests/unit_tests/file_grant_test_utils.py @@ -0,0 +1,31 @@ +import time +from collections.abc import Sequence + +from configs import dify_config +from services.entities.file_grant_entities import FileGrantContext, FileGrantScope +from services.file_grant_gateways import FileGrantTokenGateway + + +def token_gateway() -> FileGrantTokenGateway: + return FileGrantTokenGateway( + secret_key=dify_config.SECRET_KEY, + external_files_url=dify_config.FILES_URL, + internal_files_url=dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL, + content_token_ttl_seconds=dify_config.FILES_ACCESS_TIMEOUT, + now=lambda: int(time.time()), + ) + + +def issue_file_grant( + *, + end_user_id: str, + tenant_id: str, + app_id: str, + scopes: Sequence[FileGrantScope], + ttl_seconds: int, +) -> tuple[str, int]: + return token_gateway().issue_grant( + context=FileGrantContext(tenant_id=tenant_id, app_id=app_id, end_user_id=end_user_id), + scopes=scopes, + ttl_seconds=ttl_seconds, + ) diff --git a/api/tests/unit_tests/models/test_end_user_type.py b/api/tests/unit_tests/models/test_end_user_type.py index 222945814b4..af6407c7b7c 100644 --- a/api/tests/unit_tests/models/test_end_user_type.py +++ b/api/tests/unit_tests/models/test_end_user_type.py @@ -15,6 +15,7 @@ API_ROOT = Path(__file__).resolve().parents[3] def test_end_user_type_covers_persisted_creation_values(): assert {member.value for member in EndUserType} == { + "app-deploy", "browser", "mcp", "openapi", diff --git a/api/tests/unit_tests/repositories/test_file_grant_repository.py b/api/tests/unit_tests/repositories/test_file_grant_repository.py new file mode 100644 index 00000000000..b27353a622b --- /dev/null +++ b/api/tests/unit_tests/repositories/test_file_grant_repository.py @@ -0,0 +1,75 @@ +from sqlalchemy import Engine, event +from sqlalchemy.orm import Session, sessionmaker + +from extensions.storage.storage_type import StorageType +from libs.datetime_utils import naive_utc_now +from models.enums import CreatorUserRole +from models.model import UploadFile +from models.tools import ToolFile +from repositories.file_grant_repository import FileGrantRepository +from services.entities.file_grant_entities import FileGrantContext, FileKind, FileRef + + +def test_resolve_owned_files_uses_one_query_per_file_kind( + sqlite_engine: Engine, + sqlite_session_factory: sessionmaker[Session], +) -> None: + end_user_id = "11111111-1111-4111-8111-111111111111" + tenant_id = "22222222-2222-4222-8222-222222222222" + with sqlite_session_factory.begin() as session: + upload = UploadFile( + tenant_id=tenant_id, + storage_type=StorageType.OPENDAL, + key="upload_files/report.pdf", + name="report.pdf", + size=10, + extension="pdf", + mime_type="application/pdf", + created_by=end_user_id, + created_by_role=CreatorUserRole.END_USER, + created_at=naive_utc_now(), + used=False, + ) + tool_file = ToolFile( + user_id=end_user_id, + tenant_id=tenant_id, + conversation_id=None, + file_key="tools/chart.png", + mimetype="image/png", + name="chart.png", + size=20, + ) + session.add_all([upload, tool_file]) + session.flush() + upload_id = upload.id + tool_file_id = tool_file.id + + statements: list[str] = [] + + def record_statement( + _connection: object, + _cursor: object, + statement: str, + _parameters: object, + _context: object, + _executemany: object, + ) -> None: + statements.append(statement) + + event.listen(sqlite_engine, "before_cursor_execute", record_statement) + try: + refs = tuple( + FileRef(id=upload_id, kind=FileKind.UPLOAD) + if index % 2 == 0 + else FileRef(id=tool_file_id, kind=FileKind.TOOL) + for index in range(100) + ) + resolved = FileGrantRepository(session_factory=sqlite_session_factory).resolve_owned_files( + context=FileGrantContext(tenant_id, "app-1", end_user_id), + refs=refs, + ) + finally: + event.remove(sqlite_engine, "before_cursor_execute", record_statement) + + assert len(statements) == 2 + assert [file.kind if file is not None else None for file in resolved] == [ref.kind for ref in refs] diff --git a/api/tests/unit_tests/services/test_file_grant_gateways.py b/api/tests/unit_tests/services/test_file_grant_gateways.py new file mode 100644 index 00000000000..2b909f2ca03 --- /dev/null +++ b/api/tests/unit_tests/services/test_file_grant_gateways.py @@ -0,0 +1,90 @@ +from collections.abc import Callable +from unittest.mock import patch + +import httpx +import pytest + +from services.errors.file import FileTooLargeError +from services.file_grant_gateways import FileGrantRemoteFileGateway + + +def test_remote_file_gateway_bounds_a_get_without_content_length( + config_overrides: Callable[..., None], +) -> None: + config_overrides(UPLOAD_FILE_SIZE_LIMIT=1) + url = "https://example.com/report.pdf" + head = httpx.Response(200, request=httpx.Request("HEAD", url)) + download = httpx.Response( + 200, + content=b"0" * (1024 * 1024 + 1), + request=httpx.Request("GET", url), + ) + + with patch("services.file_grant_gateways.remote_fetcher.make_request", side_effect=[head, download]) as request: + with pytest.raises(FileTooLargeError): + FileGrantRemoteFileGateway().fetch(url) + + assert request.call_args_list[1].kwargs["stream_response"] is True + assert download.is_closed + + +def test_remote_file_gateway_uses_the_actual_body_size_when_content_length_is_incorrect( + config_overrides: Callable[..., None], +) -> None: + config_overrides(UPLOAD_FILE_SIZE_LIMIT=1) + url = "https://example.com/report.pdf" + head = httpx.Response( + 200, + headers={"Content-Length": "1"}, + request=httpx.Request("HEAD", url), + ) + download = httpx.Response( + 200, + headers={"Content-Length": "1"}, + content=b"0" * (1024 * 1024 + 1), + request=httpx.Request("GET", url), + ) + + with patch("services.file_grant_gateways.remote_fetcher.make_request", side_effect=[head, download]): + with pytest.raises(FileTooLargeError): + FileGrantRemoteFileGateway().fetch(url) + + assert download.is_closed + + +def test_remote_file_gateway_rejects_encoded_content_that_cannot_be_safely_bounded( + config_overrides: Callable[..., None], +) -> None: + config_overrides(UPLOAD_FILE_SIZE_LIMIT=1) + url = "https://example.com/report.pdf" + head = httpx.Response(200, request=httpx.Request("HEAD", url)) + download = httpx.Response( + 200, + headers={"Content-Encoding": "gzip"}, + stream=httpx.ByteStream(b"compressed"), + request=httpx.Request("GET", url), + ) + + with patch("services.file_grant_gateways.remote_fetcher.make_request", side_effect=[head, download]): + assert FileGrantRemoteFileGateway().fetch(url) is None + + assert download.is_closed + + +def test_remote_file_gateway_returns_bounded_content(config_overrides: Callable[..., None]) -> None: + config_overrides(UPLOAD_FILE_SIZE_LIMIT=1) + url = "https://example.com/report.pdf" + head = httpx.Response( + 200, + headers={"Content-Length": "9", "Content-Type": "application/pdf"}, + request=httpx.Request("HEAD", url), + ) + download = httpx.Response(200, content=b"pdf-bytes", request=httpx.Request("GET", url)) + + with patch("services.file_grant_gateways.remote_fetcher.make_request", side_effect=[head, download]): + file = FileGrantRemoteFileGateway().fetch(url) + + assert file is not None + assert file.filename == "report.pdf" + assert file.mimetype == "application/pdf" + assert file.content == b"pdf-bytes" diff --git a/api/tests/unit_tests/services/test_file_grant_service.py b/api/tests/unit_tests/services/test_file_grant_service.py new file mode 100644 index 00000000000..741eb9471d4 --- /dev/null +++ b/api/tests/unit_tests/services/test_file_grant_service.py @@ -0,0 +1,147 @@ +from io import BytesIO +from unittest.mock import MagicMock + +import pytest + +from services.entities.file_grant_entities import ( + FileGrantContext, + FileGrantLimits, + FileGrantMintRequest, + FileGrantScope, + FileKind, + FileRef, + ResolvedFile, +) +from services.errors.file_grant import EndUserNotFoundError, GrantTtlTooLongError +from services.file_grant_service import MAX_SESSION_GRANT_TTL_SECONDS, FileGrantService + + +def _service() -> tuple[FileGrantService, MagicMock, MagicMock, MagicMock, MagicMock]: + repository = MagicMock() + repository.get_or_create_subject.return_value = "end-user-1" + repository.subject_exists.return_value = True + repository.resolve_owned_files.return_value = list[ResolvedFile | None]() + files = MagicMock() + tokens = MagicMock() + tokens.issue_grant.return_value = ("grant", 1600) + remote_files = MagicMock() + service = FileGrantService( + repository=repository, + files=files, + tokens=tokens, + remote_files=remote_files, + limits=FileGrantLimits(15, 10, 50, 100, 10, 5), + now=lambda: 1000, + ) + return service, repository, files, tokens, remote_files + + +def _mint_request( + *, + ttl_seconds: int = 600, + file_refs: tuple[FileRef, ...] = (), + optional_file_refs: tuple[FileRef, ...] = (), +) -> FileGrantMintRequest: + return FileGrantMintRequest( + tenant_id="tenant-1", + app_id="app-1", + subject="subject-1", + is_anonymous=True, + scopes=(FileGrantScope.UPLOAD,), + ttl_seconds=ttl_seconds, + file_refs=file_refs, + optional_file_refs=optional_file_refs, + run_deadline=None, + ) + + +def test_mint_rejects_an_invalid_ttl_before_persistence() -> None: + service, repository, _files, tokens, _remote_files = _service() + + with pytest.raises(GrantTtlTooLongError): + service.mint(_mint_request(ttl_seconds=MAX_SESSION_GRANT_TTL_SECONDS + 1)) + + repository.get_or_create_subject.assert_not_called() + tokens.issue_grant.assert_not_called() + + +def test_mint_orchestrates_identity_resolution_and_token_issuance() -> None: + service, repository, _files, tokens, _remote_files = _service() + + result = service.mint(_mint_request()) + + assert result.grant == "grant" + repository.get_or_create_subject.assert_called_once() + tokens.issue_grant.assert_called_once_with( + context=FileGrantContext("tenant-1", "app-1", "end-user-1"), + scopes=(FileGrantScope.UPLOAD,), + ttl_seconds=600, + ) + + +def test_mint_resolves_required_and_optional_files_in_one_batch() -> None: + service, repository, _files, tokens, _remote_files = _service() + required_ref = FileRef(id="upload-1", kind=FileKind.UPLOAD) + optional_ref = FileRef(id="tool-1", kind=FileKind.TOOL) + required_file = ResolvedFile("upload-1", FileKind.UPLOAD, "report.pdf", 10, "pdf", "application/pdf") + optional_file = ResolvedFile("tool-1", FileKind.TOOL, "chart.png", 20, "png", "image/png") + repository.resolve_owned_files.return_value = [required_file, optional_file] + tokens.issue_content_urls.return_value = ("https://files/tool-1", "http://files/tool-1") + + result = service.mint( + _mint_request( + file_refs=(required_ref,), + optional_file_refs=(optional_ref,), + ) + ) + + repository.resolve_owned_files.assert_called_once_with( + context=FileGrantContext("tenant-1", "app-1", "end-user-1"), + refs=(required_ref, optional_ref), + ) + assert result.files == (required_file,) + assert result.optional_files[0] is not None + assert result.optional_files[0].file == optional_file + + +def test_store_produced_rejects_a_deleted_subject_before_reading_the_file() -> None: + service, repository, files, _tokens, _remote_files = _service() + repository.subject_exists.return_value = False + stream = BytesIO(b"produced content") + + with pytest.raises(EndUserNotFoundError): + service.store_produced( + context=FileGrantContext("tenant-1", "app-1", "deleted-user"), + filename="result.txt", + stream=stream, + mimetype="text/plain", + ) + + assert stream.tell() == 0 + files.store_produced.assert_not_called() + + +def test_store_remote_upload_rejects_a_deleted_subject_before_fetching() -> None: + service, repository, _files, _tokens, remote_files = _service() + repository.subject_exists.return_value = False + + with pytest.raises(EndUserNotFoundError): + service.store_remote_upload( + context=FileGrantContext("tenant-1", "app-1", "deleted-user"), + url="https://example.com/report.pdf", + ) + + remote_files.fetch.assert_not_called() + + +def test_resolve_rejects_a_deleted_subject_before_querying_files() -> None: + service, repository, _files, _tokens, _remote_files = _service() + repository.subject_exists.return_value = False + + with pytest.raises(EndUserNotFoundError): + service.resolve_files( + context=FileGrantContext("tenant-1", "app-1", "deleted-user"), + refs=(), + ) + + repository.resolve_owned_files.assert_not_called() diff --git a/api/tests/unit_tests/services/test_file_service.py b/api/tests/unit_tests/services/test_file_service.py index 49367183d9c..470235cf610 100644 --- a/api/tests/unit_tests/services/test_file_service.py +++ b/api/tests/unit_tests/services/test_file_service.py @@ -258,6 +258,20 @@ class TestFileService: is False ) + def test_file_size_limit(self, config_overrides: Callable[..., None]): + config_overrides( + UPLOAD_IMAGE_FILE_SIZE_LIMIT=10, + UPLOAD_VIDEO_FILE_SIZE_LIMIT=20, + UPLOAD_AUDIO_FILE_SIZE_LIMIT=30, + UPLOAD_FILE_SIZE_LIMIT=5, + ) + + assert FileService.file_size_limit(extension="jpg") == 10 * 1024 * 1024 + assert FileService.file_size_limit(extension="mp4") == 20 * 1024 * 1024 + assert FileService.file_size_limit(extension="mp3") == 30 * 1024 * 1024 + assert FileService.file_size_limit(extension="txt") == 5 * 1024 * 1024 + assert FileService.file_size_limit(extension="txt", default_file_size_limit=7) == 7 * 1024 * 1024 + def test_get_file_base64_success(self, file_service: FileService, db_session: Session): self._persist_upload_file(db_session, key="test_key") diff --git a/e2e/features/step-definitions/agent-v2/access-point-helpers.ts b/e2e/features/step-definitions/agent-v2/access-point-helpers.ts index 654455d1283..f0e5c01d776 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-helpers.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-helpers.ts @@ -28,7 +28,7 @@ export const getAccessRegion = (world: DifyWorld) => export type AccessSurfaceName = 'Web app' | 'Backend service API' export const getAccessSurfaceCard = (world: DifyWorld, surface: AccessSurfaceName) => - getAccessRegion(world).getByRole('article', { name: surface }).first() + getAccessRegion(world).getByRole('region', { name: surface }).first() export const getWebAppCard = (world: DifyWorld) => getAccessSurfaceCard(world, 'Web app') diff --git a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts index 53eba93fdae..a71d04f295d 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts @@ -22,9 +22,9 @@ Then('I should see the Agent v2 Web app access URL', async function (this: DifyW const webAppCard = getWebAppCard(this) await expect(webAppCard.getByRole('heading', { name: 'Web app' })).toBeVisible() - await expect(webAppCard.getByText('Web App URL')).toBeVisible() + await expect(webAppCard.getByText('Access URL')).toBeVisible() await expect(webAppCard.getByLabel('Copy access URL')).toBeEnabled() - await expect(webAppCard.getByRole('link', { name: 'Launch' })).toBeVisible() + await expect(webAppCard.getByRole('link', { name: 'Open' })).toBeVisible() }) When('I copy the Agent v2 Web app access URL', async function (this: DifyWorld) { @@ -38,11 +38,11 @@ Then('the Agent v2 Web app access URL should show it was copied', async function When('I launch the Agent v2 Web app', async function (this: DifyWorld) { await recordComposerDraftSnapshot(this) - const launchLink = getWebAppCard(this).getByRole('link', { name: 'Launch' }) - const href = await launchLink.getAttribute('href') - if (!href) throw new Error('Agent v2 Web app Launch link does not expose an href.') + const openLink = getWebAppCard(this).getByRole('link', { name: 'Open' }) + const href = await openLink.getAttribute('href') + if (!href) throw new Error('Agent v2 Web app Open link does not expose an href.') - const [webAppPage] = await Promise.all([this.getPage().waitForEvent('popup'), launchLink.click()]) + const [webAppPage] = await Promise.all([this.getPage().waitForEvent('popup'), openLink.click()]) this.agentBuilder.accessPoint.webAppURL = href this.agentBuilder.accessPoint.webAppPage = webAppPage @@ -130,7 +130,7 @@ When('I close the Agent v2 Web app', async function (this: DifyWorld) { When('I open Agent v2 Embedded configuration', async function (this: DifyWorld) { await recordComposerDraftSnapshot(this) - await getWebAppCard(this).getByRole('button', { name: 'Embedded' }).click() + await getWebAppCard(this).getByRole('button', { name: 'Embed Into Site' }).click() }) Then('I should see the Agent v2 Embedded configuration dialog', async function (this: DifyWorld) { @@ -143,7 +143,7 @@ Then('I should see the Agent v2 Embedded configuration dialog', async function ( When('I open Agent v2 Web app customization', async function (this: DifyWorld) { await recordComposerDraftSnapshot(this) - await getWebAppCard(this).getByRole('button', { name: 'Custom Frontend' }).click() + await getWebAppCard(this).getByRole('button', { name: 'Custom frontend' }).click() }) Then('I should see the Agent v2 Web app customization dialog', async function (this: DifyWorld) { @@ -156,7 +156,7 @@ Then('I should see the Agent v2 Web app customization dialog', async function (t When('I open Agent v2 Web app settings', async function (this: DifyWorld) { await recordComposerDraftSnapshot(this) - await getWebAppCard(this).getByRole('button', { name: 'Branding' }).click() + await getWebAppCard(this).getByRole('button', { name: 'Settings' }).click() }) Then('I should see the Agent v2 Web app settings dialog', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/agent-v2/access-point.steps.ts b/e2e/features/step-definitions/agent-v2/access-point.steps.ts index 38706e41a7d..956d4f0b776 100644 --- a/e2e/features/step-definitions/agent-v2/access-point.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point.steps.ts @@ -81,7 +81,7 @@ Then( await expect(webAppCard.getByText('Out of service')).toBeVisible({ timeout: 30_000 }) await expect(webAppCard.getByLabel('Toggle Web app access')).toBeDisabled() - await expect(webAppCard.getByRole('button', { name: 'Launch' })).toBeDisabled() + await expect(webAppCard.getByRole('button', { name: 'Open' })).toBeDisabled() await expect(serviceApiCard.getByText('Out of service')).toBeVisible() await expect(serviceApiCard.getByLabel('Toggle Backend service API access')).toBeDisabled() await expect(serviceApiCard.getByRole('button', { name: /^API Key\b/ })).toBeDisabled() @@ -94,9 +94,9 @@ When( const accessSurfaceCard = getAccessSurfaceCard(this, surface) if (surface === 'Web app') { - const launchLink = accessSurfaceCard.getByRole('link', { name: 'Launch' }) - const href = await launchLink.getAttribute('href') - if (!href) throw new Error('Agent v2 Web app Launch link does not expose an href.') + const openLink = accessSurfaceCard.getByRole('link', { name: 'Open' }) + const href = await openLink.getAttribute('href') + if (!href) throw new Error('Agent v2 Web app Open link does not expose an href.') this.agentBuilder.accessPoint.webAppURL = href } @@ -122,7 +122,7 @@ Then( await expect(toggle).toBeEnabled() await expect(toggle).toHaveAttribute('aria-checked', 'false') if (surface === 'Web app') - await expect(accessSurfaceCard.getByRole('button', { name: 'Launch' })).toBeDisabled() + await expect(accessSurfaceCard.getByRole('button', { name: 'Open' })).toBeDisabled() }, ) @@ -136,6 +136,6 @@ Then( await expect(toggle).toBeEnabled() await expect(toggle).toHaveAttribute('aria-checked', 'true') if (surface === 'Web app') - await expect(accessSurfaceCard.getByRole('link', { name: 'Launch' })).toBeVisible() + await expect(accessSurfaceCard.getByRole('link', { name: 'Open' })).toBeVisible() }, ) diff --git a/e2e/features/step-definitions/apps/create-app.steps.ts b/e2e/features/step-definitions/apps/create-app.steps.ts index 88113afa3fb..f632f310a33 100644 --- a/e2e/features/step-definitions/apps/create-app.steps.ts +++ b/e2e/features/step-definitions/apps/create-app.steps.ts @@ -20,6 +20,10 @@ const getLatestCreatedAppId = (world: DifyWorld) => { return appId } +const expectAppEditorContent = async (world: DifyWorld) => { + await expect(world.getPage().getByRole('link', { name: 'Orchestrate' })).toBeVisible() +} + When('I start creating a blank app', async function (this: DifyWorld) { await openBlankAppCreation(this.getPage()) }) @@ -78,14 +82,17 @@ Then('I should land on the app editor', async function (this: DifyWorld) { await expect(this.getPage()).toHaveURL( new RegExp(`/app/${appId}/(workflow|configuration)(?:\\?.*)?$`), ) + await expectAppEditorContent(this) }) Then('I should land on the workflow editor', async function (this: DifyWorld) { const appId = getLatestCreatedAppId(this) await expect(this.getPage()).toHaveURL(new RegExp(`/app/${appId}/workflow(?:\\?.*)?$`)) + await expectAppEditorContent(this) }) Then('I should land on the app configuration page', async function (this: DifyWorld) { const appId = getLatestCreatedAppId(this) await expect(this.getPage()).toHaveURL(new RegExp(`/app/${appId}/configuration(?:\\?.*)?$`)) + await expectAppEditorContent(this) }) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 8c87c9ae25d..d85da0688e3 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4491,12 +4491,6 @@ "eslint-react/set-state-in-effect": { "count": 4 }, - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - }, "no-restricted-imports": { "count": 1 } diff --git a/packages/contracts/generated/enterprise-app-deploy/types.gen.ts b/packages/contracts/generated/enterprise-app-deploy/types.gen.ts index c64c433d4b6..f7c6aa4f4b9 100644 --- a/packages/contracts/generated/enterprise-app-deploy/types.gen.ts +++ b/packages/contracts/generated/enterprise-app-deploy/types.gen.ts @@ -4,6 +4,8 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } +export type GoogleProtobufValue = unknown + export const EnvironmentStatus = { ENVIRONMENT_STATUS_UNSPECIFIED: 'ENVIRONMENT_STATUS_UNSPECIFIED', ENVIRONMENT_STATUS_PENDING: 'ENVIRONMENT_STATUS_PENDING', @@ -21,11 +23,27 @@ export const ApplicationInteractionStatus = { APPLICATION_INTERACTION_STATUS_FAILED: 'APPLICATION_INTERACTION_STATUS_FAILED', APPLICATION_INTERACTION_STATUS_PARTIAL_SUCCEEDED: 'APPLICATION_INTERACTION_STATUS_PARTIAL_SUCCEEDED', + APPLICATION_INTERACTION_STATUS_STOPPED: 'APPLICATION_INTERACTION_STATUS_STOPPED', + APPLICATION_INTERACTION_STATUS_PAUSED: 'APPLICATION_INTERACTION_STATUS_PAUSED', } as const export type ApplicationInteractionStatus = (typeof ApplicationInteractionStatus)[keyof typeof ApplicationInteractionStatus] +export const ApplicationInteractionSource = { + APPLICATION_INTERACTION_SOURCE_UNSPECIFIED: 'APPLICATION_INTERACTION_SOURCE_UNSPECIFIED', + APPLICATION_INTERACTION_SOURCE_WEB_APP: 'APPLICATION_INTERACTION_SOURCE_WEB_APP', + APPLICATION_INTERACTION_SOURCE_SERVICE_API: 'APPLICATION_INTERACTION_SOURCE_SERVICE_API', + APPLICATION_INTERACTION_SOURCE_TRIGGER: 'APPLICATION_INTERACTION_SOURCE_TRIGGER', + APPLICATION_INTERACTION_SOURCE_EXPLORE: 'APPLICATION_INTERACTION_SOURCE_EXPLORE', + APPLICATION_INTERACTION_SOURCE_DEBUGGER: 'APPLICATION_INTERACTION_SOURCE_DEBUGGER', + APPLICATION_INTERACTION_SOURCE_VALIDATION: 'APPLICATION_INTERACTION_SOURCE_VALIDATION', + APPLICATION_INTERACTION_SOURCE_OPENAPI: 'APPLICATION_INTERACTION_SOURCE_OPENAPI', +} as const + +export type ApplicationInteractionSource = + (typeof ApplicationInteractionSource)[keyof typeof ApplicationInteractionSource] + export const EnvironmentMode = { ENVIRONMENT_MODE_UNSPECIFIED: 'ENVIRONMENT_MODE_UNSPECIFIED', ENVIRONMENT_MODE_SHARED: 'ENVIRONMENT_MODE_SHARED', @@ -124,36 +142,17 @@ export const EnvironmentBackend = { export type EnvironmentBackend = (typeof EnvironmentBackend)[keyof typeof EnvironmentBackend] -export const EnvironmentManagedBy = { - ENVIRONMENT_MANAGED_BY_UNSPECIFIED: 'ENVIRONMENT_MANAGED_BY_UNSPECIFIED', - ENVIRONMENT_MANAGED_BY_SYSTEM: 'ENVIRONMENT_MANAGED_BY_SYSTEM', - ENVIRONMENT_MANAGED_BY_USER: 'ENVIRONMENT_MANAGED_BY_USER', +export const RuntimeState = { + RUNTIME_STATE_UNSPECIFIED: 'RUNTIME_STATE_UNSPECIFIED', + RUNTIME_STATE_UNDEPLOYED: 'RUNTIME_STATE_UNDEPLOYED', + RUNTIME_STATE_RUNNING: 'RUNTIME_STATE_RUNNING', + RUNTIME_STATE_STARTING: 'RUNTIME_STATE_STARTING', + RUNTIME_STATE_STOPPING: 'RUNTIME_STATE_STOPPING', + RUNTIME_STATE_ERROR: 'RUNTIME_STATE_ERROR', + RUNTIME_STATE_UNKNOWN: 'RUNTIME_STATE_UNKNOWN', } as const -export type EnvironmentManagedBy = (typeof EnvironmentManagedBy)[keyof typeof EnvironmentManagedBy] - -export const EnvironmentDeployedAppStatus = { - ENVIRONMENT_DEPLOYED_APP_STATUS_UNSPECIFIED: 'ENVIRONMENT_DEPLOYED_APP_STATUS_UNSPECIFIED', - ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYED: 'ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYED', - ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYING: 'ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYING', - ENVIRONMENT_DEPLOYED_APP_STATUS_FAILED: 'ENVIRONMENT_DEPLOYED_APP_STATUS_FAILED', - ENVIRONMENT_DEPLOYED_APP_STATUS_UNDEPLOYED: 'ENVIRONMENT_DEPLOYED_APP_STATUS_UNDEPLOYED', -} as const - -export type EnvironmentDeployedAppStatus = - (typeof EnvironmentDeployedAppStatus)[keyof typeof EnvironmentDeployedAppStatus] - -export const DeploymentStatus = { - DEPLOYMENT_STATUS_UNSPECIFIED: 'DEPLOYMENT_STATUS_UNSPECIFIED', - DEPLOYMENT_STATUS_UNDEPLOYED: 'DEPLOYMENT_STATUS_UNDEPLOYED', - DEPLOYMENT_STATUS_DEPLOYING: 'DEPLOYMENT_STATUS_DEPLOYING', - DEPLOYMENT_STATUS_RUNNING: 'DEPLOYMENT_STATUS_RUNNING', - DEPLOYMENT_STATUS_UNDEPLOYING: 'DEPLOYMENT_STATUS_UNDEPLOYING', - DEPLOYMENT_STATUS_INVALID: 'DEPLOYMENT_STATUS_INVALID', - DEPLOYMENT_STATUS_FAILED: 'DEPLOYMENT_STATUS_FAILED', -} as const - -export type DeploymentStatus = (typeof DeploymentStatus)[keyof typeof DeploymentStatus] +export type RuntimeState = (typeof RuntimeState)[keyof typeof RuntimeState] export const EnvVarValueSource = { ENV_VAR_VALUE_SOURCE_UNSPECIFIED: 'ENV_VAR_VALUE_SOURCE_UNSPECIFIED', @@ -169,6 +168,7 @@ export const EnvVarValueType = { ENV_VAR_VALUE_TYPE_STRING: 'ENV_VAR_VALUE_TYPE_STRING', ENV_VAR_VALUE_TYPE_NUMBER: 'ENV_VAR_VALUE_TYPE_NUMBER', ENV_VAR_VALUE_TYPE_SECRET: 'ENV_VAR_VALUE_TYPE_SECRET', + ENV_VAR_VALUE_TYPE_LLM: 'ENV_VAR_VALUE_TYPE_LLM', } as const export type EnvVarValueType = (typeof EnvVarValueType)[keyof typeof EnvVarValueType] @@ -203,22 +203,17 @@ export type AppEnvironment = { export type ApplicationInteraction = { id: string timestamp: string - workflowRunId: string status: ApplicationInteractionStatus durationSeconds: number totalTokens: string workspace: NamedRef environment: NamedRef app: NamedRef - operator?: Operator - invokeFrom: string - traceId: string - difyTraceId: string - deploymentVersionId: string + operator?: NamedRef + source: ApplicationInteractionSource + version?: WorkflowVersion + traceId?: string error?: string - body?: string - attributesJson?: string - resourceAttributesJson?: string } export type BatchGetSourceVersionDeploymentsRequest = { @@ -262,7 +257,7 @@ export type CreateEnvironmentRequest = { displayName: string description?: string mode: EnvironmentMode - cpuPool: number + cpuPoolMillicores: number namespace?: string maxMemoryMib?: string } @@ -292,12 +287,7 @@ export type CredentialSlot = { last_deployed_credential_id?: string icon?: string icon_dark?: string -} - -export type DashboardApp = { - id: string - workspaceId: string - displayName: string + workflow_as_tool_dependency?: WorkflowAsToolDependency } export type DeleteEnvironmentApiKeyResponse = { @@ -308,6 +298,15 @@ export type DeleteEnvironmentResponse = { [key: string]: unknown } +export type DeleteServiceApiConversationRequest = { + conversationId: string + user: string +} + +export type DeleteServiceApiConversationResponse = { + [key: string]: unknown +} + export type DeployWorkflowResponse = { operation: DeploymentOperationReceipt } @@ -358,8 +357,7 @@ export type Environment = { statusMessage: string lastError?: Error namespace?: string - managedBy?: EnvironmentManagedBy - cpuPool: number + cpuPoolMillicores: number createdAt: string updatedAt: string memory?: RunnerMemory @@ -381,6 +379,14 @@ export type EnvironmentAccess = { enable_api: boolean } +export type EnvironmentActivity = { + environmentId: string + invocationCount: string + failedInvocationCount: string + failedDeploymentCount: string + deployedAppCount: string +} + export type EnvironmentApiKey = { id: string type: string @@ -393,13 +399,15 @@ export type EnvironmentDeployedApp = { deploymentId: string workspace: NamedRef app: NamedRef - status: EnvironmentDeployedAppStatus + runtimeState: RuntimeState currentVersion?: WorkflowVersion deployedAt?: string deployedBy?: Operator latestAttempt?: EnvironmentDeployedAppAttempt sizing?: RunnerSizing occupiesPool?: boolean + recentInvocationCount?: string + versionsBehind?: number } export type EnvironmentDeployedAppAttempt = { @@ -412,13 +420,6 @@ export type EnvironmentDeployedAppAttempt = { finalizedAt?: string } -export type EnvironmentDeployedAppSummary = { - total: number - deployed: number - deploying: number - failed: number -} - export type EnvironmentDeployment = { environment: DeploymentEnvironment deployment?: EnvironmentDeploymentState @@ -435,7 +436,7 @@ export type EnvironmentDeploymentOperation = { } export type EnvironmentDeploymentState = { - status: DeploymentStatus + runtimeState: RuntimeState current_version?: WorkflowVersion versions_behind?: number deployed_at?: number @@ -449,17 +450,17 @@ export type EnvironmentMcpServer = { export type EnvironmentPoolComposition = { topApps?: Array - otherCpu?: number + otherCpuMillicores?: number otherAppCount?: number } export type EnvironmentPoolShare = { app: NamedRef - isolatedCpu: number + isolatedCpuMillicores: number } export type EnvironmentPoolUsage = { - occupiedCpu: number + occupiedCpuMillicores: number appCount: number } @@ -478,10 +479,16 @@ export type EnvironmentTrigger = { [key: string]: unknown } +export type EnvironmentVariableGroup = { + from_app?: WorkflowReference + from_workflow_as_tool?: WorkflowAsToolSource + environment_variable_slots: Array +} + export type EnvironmentVariableInput = { key: string value_source: EnvVarValueSource - value?: string + value?: GoogleProtobufValue } export type EnvironmentVariableSlot = { @@ -490,8 +497,8 @@ export type EnvironmentVariableSlot = { description: string has_configured_value: boolean has_last_deployed_value: boolean - configured_value?: string - last_deployed_value?: string + configured_value?: GoogleProtobufValue + last_deployed_value?: GoogleProtobufValue } export type EnvironmentWebAppAccessModeUpdate = { @@ -540,7 +547,6 @@ export type Error = { | 'APPDEPLOY_APP_LOG_INVALID_TIME_RANGE' | 'APPDEPLOY_APP_LOG_INVALID_CURSOR' | 'APPDEPLOY_APP_LOG_CURSOR_FILTER_MISMATCH' - | 'APPDEPLOY_APP_LOG_ID_INVALID' | 'APPDEPLOY_UNSUPPORTED_NODE_TYPE' | 'APPDEPLOY_UNSUPPORTED_TOOL_PROVIDER_TYPE' | 'APPDEPLOY_TOOL_PROVIDER_TYPE_INVALID' @@ -552,15 +558,12 @@ export type Error = { | 'APPDEPLOY_INVALID_WORKFLOW_ID' | 'APPDEPLOY_INVALID_DEPLOYMENT_VERSION_ID' | 'APPDEPLOY_DEVELOPER_API_URL_NOT_CONFIGURED' - | 'APPDEPLOY_INVALID_DEPLOYMENT_OPERATION_ID' - | 'APPDEPLOY_APP_LOG_EXPORT_RANGE_TOO_WIDE' - | 'APPDEPLOY_APP_LOG_EXPORT_TOO_MANY_ROWS' - | 'APPDEPLOY_APP_LOG_EXPORT_TOO_LARGE' | 'APPDEPLOY_UNAUTHORIZED' | 'APPDEPLOY_FORBIDDEN' | 'APPDEPLOY_APP_RUNNER_AUTH_REQUIRED' | 'APPDEPLOY_APP_RUNNER_INVALID_JOIN_TOKEN' | 'APPDEPLOY_APP_RUNNER_INVALID_CONTROL_TOKEN' + | 'APPDEPLOY_WEB_APP_ACCESS_DENIED' | 'APPDEPLOY_ENVIRONMENT_NOT_FOUND' | 'APPDEPLOY_DEPLOYMENT_NOT_FOUND' | 'APPDEPLOY_REVISION_NOT_FOUND' @@ -571,15 +574,15 @@ export type Error = { | 'APPDEPLOY_ACCESS_SUBJECT_NOT_FOUND' | 'APPDEPLOY_API_KEY_NOT_FOUND' | 'APPDEPLOY_SOURCE_VERSION_NOT_FOUND' - | 'APPDEPLOY_APP_LOG_NOT_FOUND' | 'APPDEPLOY_WORKSPACE_NOT_FOUND' | 'APPDEPLOY_APP_RUNNER_NOT_FOUND' | 'APPDEPLOY_WORKFLOW_NOT_FOUND' - | 'APPDEPLOY_DEPLOYMENT_OPERATION_NOT_FOUND' | 'APPDEPLOY_RUN_FILE_NOT_FOUND' | 'APPDEPLOY_APPLICATION_UNAVAILABLE' | 'APPDEPLOY_TARGET_ENVIRONMENT_REMOVED' | 'APPDEPLOY_VERSION_UNAVAILABLE' + | 'APPDEPLOY_CONVERSATION_NOT_FOUND' + | 'APPDEPLOY_CHAT_MESSAGE_NOT_FOUND' | 'APPDEPLOY_CONFLICT' | 'APPDEPLOY_DEPLOYMENT_IN_PROGRESS' | 'APPDEPLOY_ALREADY_UNDEPLOYED' @@ -618,10 +621,13 @@ export type Error = { | 'APPDEPLOY_ENVIRONMENT_CPU_POOL_EXHAUSTED' | 'APPDEPLOY_RESOURCE_NOT_APPLICABLE_FOR_MODE' | 'APPDEPLOY_ENVIRONMENT_CPU_POOL_BELOW_ALLOCATED' + | 'APPDEPLOY_CHAT_CONTEXT_TOO_LARGE' + | 'APPDEPLOY_FILE_GRANT_UNAVAILABLE' | 'APPDEPLOY_APP_RUNNER_CONTROL_NOT_CONFIGURED' | 'APPDEPLOY_RUNTIME_ASSIGNMENT_FAILED' | 'APPDEPLOY_REVISION_TIMEOUT' | 'APPDEPLOY_INTERNAL_ERROR' + | 'APPDEPLOY_RECEIPT_RETRY' | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_AUTH_REJECTED' | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_NAMESPACE_MISSING' | 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_INSUFFICIENT_RBAC' @@ -640,12 +646,14 @@ export type Error = { detailCode?: string } -export type GetApplicationInteractionResponse = { - interaction: ApplicationInteraction +export type GetApplicationInteractionSummaryResponse = { + totalCount: string + failedCount: string + lookbackStart: string } -export type GetDeploymentOperationResponse = { - operation: DeploymentOperation +export type GetEnvironmentActivityResponse = { + data: Array } export type GetEnvironmentCapabilitiesResponse = { @@ -673,12 +681,17 @@ export type GetWebAppAccessModeResponse = { accessMode?: string } +export type GetWebAppLoginStatusResponse = { + logged_in?: boolean + app_logged_in?: boolean +} + export type GetWebAppPermissionResponse = { result?: boolean } export type GetWorkflowDeploymentOptionsResponse = { - environment_variable_slots: Array + environment_variable_groups: Array credential_slots: Array } @@ -686,14 +699,15 @@ export type ListAppEnvironmentsResponse = { data: Array } -export type ListApplicationInteractionsResponse = { - data: Array +export type ListApplicationInteractionAppsResponse = { + data: Array pagination: Pagination } -export type ListAppsResponse = { - data: Array - pagination: Pagination +export type ListApplicationInteractionsResponse = { + data: Array + nextPageToken?: string + previousPageToken?: string } export type ListDeploymentOperationsResponse = { @@ -707,7 +721,6 @@ export type ListEnvironmentApiKeysResponse = { export type ListEnvironmentDeployedAppsResponse = { data: Array - summary: EnvironmentDeployedAppSummary pagination: Pagination } @@ -724,11 +737,34 @@ export type ListEnvironmentsResponse = { pagination: Pagination } +export type ListOperationAppsResponse = { + data: Array + pagination: Pagination +} + +export type MintServiceApiFileGrantRequest = { + tenantId?: string + appId?: string + environmentId?: string + user?: string +} + +export type MintServiceApiFileGrantResponse = { + grant?: string + expiresAt?: string +} + export type NamedRef = { id: string displayName: string } +export type OperationApp = { + id?: string + workspaceId?: string + displayName?: string +} + export type Operator = { type: OperatorType id: string @@ -744,6 +780,20 @@ export type PrepareAppDeletionRequest = { appId?: string } +export type RenameServiceApiConversationRequest = { + conversationId: string + user: string + name?: string + autoGenerate?: boolean +} + +export type RenameWebAppConversationRequest = { + appCode: string + conversationId: string + name?: string + autoGenerate?: boolean +} + export type ResolveApiTokenRouteRequest = { token?: string } @@ -753,17 +803,14 @@ export type ResolveApiTokenRouteResponse = { namespace?: string serviceName?: string servicePort?: number - environmentStatus?: EnvironmentStatus appId?: string tenantId?: string deploymentId?: string servingRevisionId?: string - deploymentStatus?: DeploymentStatus - revoked?: boolean - unavailableReason?: string targetKind?: RouteTargetKind directUpstream?: string - deploymentGeneration?: string + assignmentGeneration?: string + decision?: string } export type ResolveWebAppRouteRequest = { @@ -777,18 +824,18 @@ export type ResolveWebAppRouteResponse = { namespace?: string serviceName?: string servicePort?: number - environmentStatus?: EnvironmentStatus appId?: string tenantId?: string deploymentId?: string servingRevisionId?: string - deploymentStatus?: DeploymentStatus - unavailableReason?: string targetKind?: RouteTargetKind directUpstream?: string - deploymentGeneration?: string - endUserId?: string - authType?: string + assignmentGeneration?: string + userId?: string + userFrom?: string + userAuthType?: string + fileGrant?: string + fileGrantExpiresAt?: string } export type RetryEnvironmentBootstrapRequest = { @@ -806,7 +853,7 @@ export type RunnerMemory = { } export type RunnerSizing = { - isolatedCpu: number + isolatedCpuMillicores: number memory: RunnerMemory } @@ -818,7 +865,12 @@ export type SimpleAccount = { export type SourceVersionDeployment = { sourceVersionId?: string - environments?: Array + environments?: Array +} + +export type SourceVersionDeploymentEnvironment = { + id?: string + name?: string } export type TestConnectionRequest = { @@ -843,6 +895,7 @@ export type UnsupportedNode = { type: string title: string provider?: UnsupportedNodeProvider + workflow_as_tool_dependency?: WorkflowAsToolDependency } export type UnsupportedNodeProvider = { @@ -855,15 +908,15 @@ export type UnsupportedNodeProvider = { export type UpdateEnvironmentDeployedAppResourcesRequest = { environmentId: string deploymentId: string - isolatedCpu: number + isolatedCpuMillicores: number maxMemoryMib?: string } export type UpdateEnvironmentDeployedAppResourcesResponse = { deploymentId: string - isolatedCpu: number - allocatedCpuCount: number - poolCpuCount: number + isolatedCpuMillicores: number + allocatedCpuMillicores: number + poolCpuMillicores: number memory: RunnerMemory } @@ -871,7 +924,7 @@ export type UpdateEnvironmentRequest = { environmentId?: string displayName?: string description?: string - cpuPool?: number + cpuPoolMillicores?: number maxMemoryMib?: string } @@ -879,16 +932,46 @@ export type UpdateEnvironmentResponse = { environment: Environment } -export type WorkflowDeploymentEnvironment = { - id?: string - name?: string +export type UpdateServiceApiConversationVariableRequest = { + conversationId: string + variableId: string + user: string + value: GoogleProtobufValue +} + +export type WorkflowAsToolDependency = { + paths: Array +} + +export type WorkflowAsToolSource = { + workflow: WorkflowReference + paths: Array } export type WorkflowDeploymentInput = { - environment_variables?: Array + environment_variable_groups: Array credentials?: Array } +export type WorkflowEnvironmentVariableInputGroup = { + workflow_id: string + environment_variables: Array +} + +export type WorkflowPath = { + workflows: Array +} + +export type WorkflowReference = { + app_id: string + workflow_id: string + name: string + icon: string + icon_background: string + icon_type: string + icon_url?: string +} + export type WorkflowVersion = { version?: string marked_name?: string @@ -898,7 +981,6 @@ export type WorkflowVersion = { created_at?: number created_by?: SimpleAccount dsl_hash?: string - deleted?: boolean } export type Pagination = { @@ -920,6 +1002,10 @@ export type DeleteEnvironmentResponseWritable = { [key: string]: unknown } +export type DeleteServiceApiConversationResponseWritable = { + [key: string]: unknown +} + export type EnvironmentWritable = { id: string displayName: string @@ -930,8 +1016,7 @@ export type EnvironmentWritable = { statusMessage: string lastError?: Error namespace?: string - managedBy?: EnvironmentManagedBy - cpuPool: number + cpuPoolMillicores: number createdAt: string updatedAt: string memory?: RunnerMemoryWritable @@ -942,13 +1027,15 @@ export type EnvironmentDeployedAppWritable = { deploymentId: string workspace: NamedRef app: NamedRef - status: EnvironmentDeployedAppStatus + runtimeState: RuntimeState currentVersion?: WorkflowVersion deployedAt?: string deployedBy?: Operator latestAttempt?: EnvironmentDeployedAppAttempt sizing?: RunnerSizingWritable occupiesPool?: boolean + recentInvocationCount?: string + versionsBehind?: number } export type EnvironmentMcpServerWritable = { @@ -966,7 +1053,6 @@ export type GetEnvironmentResponseWritable = { export type ListEnvironmentDeployedAppsResponseWritable = { data: Array - summary: EnvironmentDeployedAppSummary pagination: Pagination } @@ -984,15 +1070,15 @@ export type RunnerMemoryWritable = { } export type RunnerSizingWritable = { - isolatedCpu: number + isolatedCpuMillicores: number memory: RunnerMemoryWritable } export type UpdateEnvironmentDeployedAppResourcesResponseWritable = { deploymentId: string - isolatedCpu: number - allocatedCpuCount: number - poolCpuCount: number + isolatedCpuMillicores: number + allocatedCpuMillicores: number + poolCpuMillicores: number memory: RunnerMemoryWritable } diff --git a/packages/contracts/generated/enterprise-app-deploy/zod.gen.ts b/packages/contracts/generated/enterprise-app-deploy/zod.gen.ts index c823bce2015..ae2591ffbf2 100644 --- a/packages/contracts/generated/enterprise-app-deploy/zod.gen.ts +++ b/packages/contracts/generated/enterprise-app-deploy/zod.gen.ts @@ -2,6 +2,11 @@ import * as z from 'zod' +/** + * Represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values. + */ +export const zGoogleProtobufValue = z.unknown() + export const zEnvironmentStatus = z.enum([ 'ENVIRONMENT_STATUS_UNSPECIFIED', 'ENVIRONMENT_STATUS_PENDING', @@ -16,6 +21,19 @@ export const zApplicationInteractionStatus = z.enum([ 'APPLICATION_INTERACTION_STATUS_SUCCEEDED', 'APPLICATION_INTERACTION_STATUS_FAILED', 'APPLICATION_INTERACTION_STATUS_PARTIAL_SUCCEEDED', + 'APPLICATION_INTERACTION_STATUS_STOPPED', + 'APPLICATION_INTERACTION_STATUS_PAUSED', +]) + +export const zApplicationInteractionSource = z.enum([ + 'APPLICATION_INTERACTION_SOURCE_UNSPECIFIED', + 'APPLICATION_INTERACTION_SOURCE_WEB_APP', + 'APPLICATION_INTERACTION_SOURCE_SERVICE_API', + 'APPLICATION_INTERACTION_SOURCE_TRIGGER', + 'APPLICATION_INTERACTION_SOURCE_EXPLORE', + 'APPLICATION_INTERACTION_SOURCE_DEBUGGER', + 'APPLICATION_INTERACTION_SOURCE_VALIDATION', + 'APPLICATION_INTERACTION_SOURCE_OPENAPI', ]) export const zEnvironmentMode = z.enum([ @@ -79,28 +97,14 @@ export const zEnvironmentBackend = z.enum([ 'ENVIRONMENT_BACKEND_EXTERNAL', ]) -export const zEnvironmentManagedBy = z.enum([ - 'ENVIRONMENT_MANAGED_BY_UNSPECIFIED', - 'ENVIRONMENT_MANAGED_BY_SYSTEM', - 'ENVIRONMENT_MANAGED_BY_USER', -]) - -export const zEnvironmentDeployedAppStatus = z.enum([ - 'ENVIRONMENT_DEPLOYED_APP_STATUS_UNSPECIFIED', - 'ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYED', - 'ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYING', - 'ENVIRONMENT_DEPLOYED_APP_STATUS_FAILED', - 'ENVIRONMENT_DEPLOYED_APP_STATUS_UNDEPLOYED', -]) - -export const zDeploymentStatus = z.enum([ - 'DEPLOYMENT_STATUS_UNSPECIFIED', - 'DEPLOYMENT_STATUS_UNDEPLOYED', - 'DEPLOYMENT_STATUS_DEPLOYING', - 'DEPLOYMENT_STATUS_RUNNING', - 'DEPLOYMENT_STATUS_UNDEPLOYING', - 'DEPLOYMENT_STATUS_INVALID', - 'DEPLOYMENT_STATUS_FAILED', +export const zRuntimeState = z.enum([ + 'RUNTIME_STATE_UNSPECIFIED', + 'RUNTIME_STATE_UNDEPLOYED', + 'RUNTIME_STATE_RUNNING', + 'RUNTIME_STATE_STARTING', + 'RUNTIME_STATE_STOPPING', + 'RUNTIME_STATE_ERROR', + 'RUNTIME_STATE_UNKNOWN', ]) export const zEnvVarValueSource = z.enum([ @@ -115,6 +119,7 @@ export const zEnvVarValueType = z.enum([ 'ENV_VAR_VALUE_TYPE_STRING', 'ENV_VAR_VALUE_TYPE_NUMBER', 'ENV_VAR_VALUE_TYPE_SECRET', + 'ENV_VAR_VALUE_TYPE_LLM', ]) export const zOperatorType = z.enum([ @@ -173,7 +178,10 @@ export const zCreateEnvironmentRequest = z.object({ displayName: z.string(), description: z.string().optional(), mode: zEnvironmentMode, - cpuPool: z.number(), + cpuPoolMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), namespace: z.string().optional(), maxMemoryMib: z.string().optional(), }) @@ -192,25 +200,17 @@ export const zCredentialSelectionInput = z.object({ credential_id: z.string(), }) -export const zCredentialSlot = z.object({ - provider_id: z.string(), - category: zPluginCategory, - candidates: z.array(zCredentialCandidate), - last_deployed_credential_id: z.string().optional(), - icon: z.string().optional(), - icon_dark: z.string().optional(), -}) - -export const zDashboardApp = z.object({ - id: z.string(), - workspaceId: z.string(), - displayName: z.string(), -}) - export const zDeleteEnvironmentApiKeyResponse = z.record(z.string(), z.unknown()) export const zDeleteEnvironmentResponse = z.record(z.string(), z.unknown()) +export const zDeleteServiceApiConversationRequest = z.object({ + conversationId: z.string(), + user: z.string(), +}) + +export const zDeleteServiceApiConversationResponse = z.record(z.string(), z.unknown()) + export const zDeploymentEnvironment = z.object({ id: z.string(), display_name: z.string(), @@ -257,6 +257,18 @@ export const zEnvironmentAccess = z.object({ enable_api: z.boolean(), }) +/** + * EnvironmentActivity reports a numerator and a denominator rather than a rate, + * so a caller can tell a healthy environment from one nothing has called. + */ +export const zEnvironmentActivity = z.object({ + environmentId: z.string(), + invocationCount: z.string(), + failedInvocationCount: z.string(), + failedDeploymentCount: z.string(), + deployedAppCount: z.string(), +}) + export const zEnvironmentApiKey = z.object({ id: z.string(), type: z.string(), @@ -275,29 +287,13 @@ export const zEnvironmentDeployedAppAttempt = z.object({ finalizedAt: z.iso.datetime().optional(), }) -export const zEnvironmentDeployedAppSummary = z.object({ - total: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), - deployed: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), - deploying: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), - failed: z - .int() - .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) - .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), -}) - export const zEnvironmentMcpServer = z.record(z.string(), z.unknown()) export const zEnvironmentPoolUsage = z.object({ - occupiedCpu: z.number(), + occupiedCpuMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), appCount: z .int() .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) @@ -320,7 +316,7 @@ export const zEnvironmentTrigger = z.record(z.string(), z.unknown()) export const zEnvironmentVariableInput = z.object({ key: z.string(), value_source: zEnvVarValueSource, - value: z.string().optional(), + value: zGoogleProtobufValue.optional(), }) export const zEnvironmentVariableSlot = z.object({ @@ -329,8 +325,8 @@ export const zEnvironmentVariableSlot = z.object({ description: z.string(), has_configured_value: z.boolean(), has_last_deployed_value: z.boolean(), - configured_value: z.string().optional(), - last_deployed_value: z.string().optional(), + configured_value: zGoogleProtobufValue.optional(), + last_deployed_value: zGoogleProtobufValue.optional(), }) export const zEnvironmentWebAppSubjectAccountData = z.object({ @@ -384,7 +380,6 @@ export const zError = z.object({ 'APPDEPLOY_APP_LOG_INVALID_TIME_RANGE', 'APPDEPLOY_APP_LOG_INVALID_CURSOR', 'APPDEPLOY_APP_LOG_CURSOR_FILTER_MISMATCH', - 'APPDEPLOY_APP_LOG_ID_INVALID', 'APPDEPLOY_UNSUPPORTED_NODE_TYPE', 'APPDEPLOY_UNSUPPORTED_TOOL_PROVIDER_TYPE', 'APPDEPLOY_TOOL_PROVIDER_TYPE_INVALID', @@ -396,15 +391,12 @@ export const zError = z.object({ 'APPDEPLOY_INVALID_WORKFLOW_ID', 'APPDEPLOY_INVALID_DEPLOYMENT_VERSION_ID', 'APPDEPLOY_DEVELOPER_API_URL_NOT_CONFIGURED', - 'APPDEPLOY_INVALID_DEPLOYMENT_OPERATION_ID', - 'APPDEPLOY_APP_LOG_EXPORT_RANGE_TOO_WIDE', - 'APPDEPLOY_APP_LOG_EXPORT_TOO_MANY_ROWS', - 'APPDEPLOY_APP_LOG_EXPORT_TOO_LARGE', 'APPDEPLOY_UNAUTHORIZED', 'APPDEPLOY_FORBIDDEN', 'APPDEPLOY_APP_RUNNER_AUTH_REQUIRED', 'APPDEPLOY_APP_RUNNER_INVALID_JOIN_TOKEN', 'APPDEPLOY_APP_RUNNER_INVALID_CONTROL_TOKEN', + 'APPDEPLOY_WEB_APP_ACCESS_DENIED', 'APPDEPLOY_ENVIRONMENT_NOT_FOUND', 'APPDEPLOY_DEPLOYMENT_NOT_FOUND', 'APPDEPLOY_REVISION_NOT_FOUND', @@ -415,15 +407,15 @@ export const zError = z.object({ 'APPDEPLOY_ACCESS_SUBJECT_NOT_FOUND', 'APPDEPLOY_API_KEY_NOT_FOUND', 'APPDEPLOY_SOURCE_VERSION_NOT_FOUND', - 'APPDEPLOY_APP_LOG_NOT_FOUND', 'APPDEPLOY_WORKSPACE_NOT_FOUND', 'APPDEPLOY_APP_RUNNER_NOT_FOUND', 'APPDEPLOY_WORKFLOW_NOT_FOUND', - 'APPDEPLOY_DEPLOYMENT_OPERATION_NOT_FOUND', 'APPDEPLOY_RUN_FILE_NOT_FOUND', 'APPDEPLOY_APPLICATION_UNAVAILABLE', 'APPDEPLOY_TARGET_ENVIRONMENT_REMOVED', 'APPDEPLOY_VERSION_UNAVAILABLE', + 'APPDEPLOY_CONVERSATION_NOT_FOUND', + 'APPDEPLOY_CHAT_MESSAGE_NOT_FOUND', 'APPDEPLOY_CONFLICT', 'APPDEPLOY_DEPLOYMENT_IN_PROGRESS', 'APPDEPLOY_ALREADY_UNDEPLOYED', @@ -462,10 +454,13 @@ export const zError = z.object({ 'APPDEPLOY_ENVIRONMENT_CPU_POOL_EXHAUSTED', 'APPDEPLOY_RESOURCE_NOT_APPLICABLE_FOR_MODE', 'APPDEPLOY_ENVIRONMENT_CPU_POOL_BELOW_ALLOCATED', + 'APPDEPLOY_CHAT_CONTEXT_TOO_LARGE', + 'APPDEPLOY_FILE_GRANT_UNAVAILABLE', 'APPDEPLOY_APP_RUNNER_CONTROL_NOT_CONFIGURED', 'APPDEPLOY_RUNTIME_ASSIGNMENT_FAILED', 'APPDEPLOY_REVISION_TIMEOUT', 'APPDEPLOY_INTERNAL_ERROR', + 'APPDEPLOY_RECEIPT_RETRY', 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_AUTH_REJECTED', 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_NAMESPACE_MISSING', 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_INSUFFICIENT_RBAC', @@ -486,6 +481,16 @@ export const zError = z.object({ detailCode: z.string().optional(), }) +export const zGetApplicationInteractionSummaryResponse = z.object({ + totalCount: z.string(), + failedCount: z.string(), + lookbackStart: z.iso.datetime(), +}) + +export const zGetEnvironmentActivityResponse = z.object({ + data: z.array(zEnvironmentActivity), +}) + export const zGetEnvironmentCapabilitiesResponse = z.object({ backend: zEnvironmentBackend, supportedModes: z.array( @@ -506,13 +511,13 @@ export const zGetWebAppAccessModeResponse = z.object({ accessMode: z.string().optional(), }) -export const zGetWebAppPermissionResponse = z.object({ - result: z.boolean().optional(), +export const zGetWebAppLoginStatusResponse = z.object({ + logged_in: z.boolean().optional(), + app_logged_in: z.boolean().optional(), }) -export const zGetWorkflowDeploymentOptionsResponse = z.object({ - environment_variable_slots: z.array(zEnvironmentVariableSlot), - credential_slots: z.array(zCredentialSlot), +export const zGetWebAppPermissionResponse = z.object({ + result: z.boolean().optional(), }) export const zListAppEnvironmentsResponse = z.object({ @@ -527,6 +532,18 @@ export const zListEnvironmentTriggersResponse = z.object({ data: z.array(zEnvironmentTrigger), }) +export const zMintServiceApiFileGrantRequest = z.object({ + tenantId: z.string().optional(), + appId: z.string().optional(), + environmentId: z.string().optional(), + user: z.string().optional(), +}) + +export const zMintServiceApiFileGrantResponse = z.object({ + grant: z.string().optional(), + expiresAt: z.string().optional(), +}) + export const zNamedRef = z.object({ id: z.string(), displayName: z.string(), @@ -534,7 +551,10 @@ export const zNamedRef = z.object({ export const zEnvironmentPoolShare = z.object({ app: zNamedRef, - isolatedCpu: z.number(), + isolatedCpuMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), }) /** @@ -544,7 +564,11 @@ export const zEnvironmentPoolShare = z.object({ */ export const zEnvironmentPoolComposition = z.object({ topApps: z.array(zEnvironmentPoolShare).optional(), - otherCpu: z.number().optional(), + otherCpuMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), otherAppCount: z .int() .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) @@ -552,42 +576,37 @@ export const zEnvironmentPoolComposition = z.object({ .optional(), }) +export const zOperationApp = z.object({ + id: z.string().optional(), + workspaceId: z.string().optional(), + displayName: z.string().optional(), +}) + export const zOperator = z.object({ type: zOperatorType, id: z.string(), display_name: z.string(), }) -export const zApplicationInteraction = z.object({ - id: z.string(), - timestamp: z.iso.datetime(), - workflowRunId: z.string(), - status: zApplicationInteractionStatus, - durationSeconds: z.number(), - totalTokens: z.string(), - workspace: zNamedRef, - environment: zNamedRef, - app: zNamedRef, - operator: zOperator.optional(), - invokeFrom: z.string(), - traceId: z.string(), - difyTraceId: z.string(), - deploymentVersionId: z.string(), - error: z.string().optional(), - body: z.string().optional(), - attributesJson: z.string().optional(), - resourceAttributesJson: z.string().optional(), -}) - -export const zGetApplicationInteractionResponse = z.object({ - interaction: zApplicationInteraction, -}) - export const zPrepareAppDeletionRequest = z.object({ tenantId: z.string().optional(), appId: z.string().optional(), }) +export const zRenameServiceApiConversationRequest = z.object({ + conversationId: z.string(), + user: z.string(), + name: z.string().optional(), + autoGenerate: z.boolean().optional(), +}) + +export const zRenameWebAppConversationRequest = z.object({ + appCode: z.string(), + conversationId: z.string(), + name: z.string().optional(), + autoGenerate: z.boolean().optional(), +}) + export const zResolveApiTokenRouteRequest = z.object({ token: z.string().optional(), }) @@ -601,17 +620,14 @@ export const zResolveApiTokenRouteResponse = z.object({ .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), - environmentStatus: zEnvironmentStatus.optional(), appId: z.string().optional(), tenantId: z.string().optional(), deploymentId: z.string().optional(), servingRevisionId: z.string().optional(), - deploymentStatus: zDeploymentStatus.optional(), - revoked: z.boolean().optional(), - unavailableReason: z.string().optional(), targetKind: zRouteTargetKind.optional(), directUpstream: z.string().optional(), - deploymentGeneration: z.string().optional(), + assignmentGeneration: z.string().optional(), + decision: z.string().optional(), }) export const zResolveWebAppRouteRequest = z.object({ @@ -629,18 +645,18 @@ export const zResolveWebAppRouteResponse = z.object({ .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) .optional(), - environmentStatus: zEnvironmentStatus.optional(), appId: z.string().optional(), tenantId: z.string().optional(), deploymentId: z.string().optional(), servingRevisionId: z.string().optional(), - deploymentStatus: zDeploymentStatus.optional(), - unavailableReason: z.string().optional(), targetKind: zRouteTargetKind.optional(), directUpstream: z.string().optional(), - deploymentGeneration: z.string().optional(), - endUserId: z.string().optional(), - authType: z.string().optional(), + assignmentGeneration: z.string().optional(), + userId: z.string().optional(), + userFrom: z.string().optional(), + userAuthType: z.string().optional(), + fileGrant: z.string().optional(), + fileGrantExpiresAt: z.string().optional(), }) export const zRetryEnvironmentBootstrapRequest = z.object({ @@ -667,8 +683,10 @@ export const zEnvironment = z.object({ statusMessage: z.string(), lastError: zError.optional(), namespace: z.string().optional(), - managedBy: zEnvironmentManagedBy.optional(), - cpuPool: z.number(), + cpuPoolMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), createdAt: z.iso.datetime(), updatedAt: z.iso.datetime(), memory: zRunnerMemory.optional(), @@ -693,7 +711,10 @@ export const zRetryEnvironmentBootstrapResponse = z.object({ * in an isolated environment; elsewhere the runner belongs to the environment. */ export const zRunnerSizing = z.object({ - isolatedCpu: z.number(), + isolatedCpuMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), memory: zRunnerMemory, }) @@ -707,6 +728,20 @@ export const zSimpleAccount = z.object({ email: z.string().optional(), }) +export const zSourceVersionDeploymentEnvironment = z.object({ + id: z.string().optional(), + name: z.string().optional(), +}) + +export const zSourceVersionDeployment = z.object({ + sourceVersionId: z.string().optional(), + environments: z.array(zSourceVersionDeploymentEnvironment).optional(), +}) + +export const zBatchGetSourceVersionDeploymentsResponse = z.object({ + items: z.array(zSourceVersionDeployment).optional(), +}) + export const zTestConnectionRequest = z.object({ environmentId: z.string().optional(), }) @@ -731,29 +766,30 @@ export const zUnsupportedNodeProvider = z.object({ provider_name: z.string(), }) -export const zUnsupportedNode = z.object({ - id: z.string(), - type: z.string(), - title: z.string(), - provider: zUnsupportedNodeProvider.optional(), -}) - -export const zPrecheckWorkflowDeploymentResponse = z.object({ - unsupported_nodes: z.array(zUnsupportedNode), -}) - export const zUpdateEnvironmentDeployedAppResourcesRequest = z.object({ environmentId: z.string(), deploymentId: z.string(), - isolatedCpu: z.number(), + isolatedCpuMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), maxMemoryMib: z.string().optional(), }) export const zUpdateEnvironmentDeployedAppResourcesResponse = z.object({ deploymentId: z.string(), - isolatedCpu: z.number(), - allocatedCpuCount: z.number(), - poolCpuCount: z.number(), + isolatedCpuMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + allocatedCpuMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + poolCpuMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), memory: zRunnerMemory, }) @@ -761,7 +797,11 @@ export const zUpdateEnvironmentRequest = z.object({ environmentId: z.string().optional(), displayName: z.string().optional(), description: z.string().optional(), - cpuPool: z.number().optional(), + cpuPoolMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), maxMemoryMib: z.string().optional(), }) @@ -769,25 +809,79 @@ export const zUpdateEnvironmentResponse = z.object({ environment: zEnvironment, }) -export const zWorkflowDeploymentEnvironment = z.object({ - id: z.string().optional(), - name: z.string().optional(), +export const zUpdateServiceApiConversationVariableRequest = z.object({ + conversationId: z.string(), + variableId: z.string(), + user: z.string(), + value: zGoogleProtobufValue, }) -export const zSourceVersionDeployment = z.object({ - sourceVersionId: z.string().optional(), - environments: z.array(zWorkflowDeploymentEnvironment).optional(), -}) - -export const zBatchGetSourceVersionDeploymentsResponse = z.object({ - items: z.array(zSourceVersionDeployment).optional(), +export const zWorkflowEnvironmentVariableInputGroup = z.object({ + workflow_id: z.string(), + environment_variables: z.array(zEnvironmentVariableInput), }) export const zWorkflowDeploymentInput = z.object({ - environment_variables: z.array(zEnvironmentVariableInput).optional(), + environment_variable_groups: z.array(zWorkflowEnvironmentVariableInputGroup), credentials: z.array(zCredentialSelectionInput).optional(), }) +export const zWorkflowReference = z.object({ + app_id: z.string(), + workflow_id: z.string(), + name: z.string(), + icon: z.string(), + icon_background: z.string(), + icon_type: z.string(), + icon_url: z.string().optional(), +}) + +export const zWorkflowPath = z.object({ + workflows: z.array(zWorkflowReference), +}) + +export const zWorkflowAsToolDependency = z.object({ + paths: z.array(zWorkflowPath), +}) + +export const zCredentialSlot = z.object({ + provider_id: z.string(), + category: zPluginCategory, + candidates: z.array(zCredentialCandidate), + last_deployed_credential_id: z.string().optional(), + icon: z.string().optional(), + icon_dark: z.string().optional(), + workflow_as_tool_dependency: zWorkflowAsToolDependency.optional(), +}) + +export const zUnsupportedNode = z.object({ + id: z.string(), + type: z.string(), + title: z.string(), + provider: zUnsupportedNodeProvider.optional(), + workflow_as_tool_dependency: zWorkflowAsToolDependency.optional(), +}) + +export const zPrecheckWorkflowDeploymentResponse = z.object({ + unsupported_nodes: z.array(zUnsupportedNode), +}) + +export const zWorkflowAsToolSource = z.object({ + workflow: zWorkflowReference, + paths: z.array(zWorkflowPath), +}) + +export const zEnvironmentVariableGroup = z.object({ + from_app: zWorkflowReference.optional(), + from_workflow_as_tool: zWorkflowAsToolSource.optional(), + environment_variable_slots: z.array(zEnvironmentVariableSlot), +}) + +export const zGetWorkflowDeploymentOptionsResponse = z.object({ + environment_variable_groups: z.array(zEnvironmentVariableGroup), + credential_slots: z.array(zCredentialSlot), +}) + export const zWorkflowVersion = z.object({ version: z.string().optional(), marked_name: z.string().optional(), @@ -801,7 +895,22 @@ export const zWorkflowVersion = z.object({ created_at: z.number().optional(), created_by: zSimpleAccount.optional(), dsl_hash: z.string().optional(), - deleted: z.boolean().optional(), +}) + +export const zApplicationInteraction = z.object({ + id: z.string(), + timestamp: z.iso.datetime(), + status: zApplicationInteractionStatus, + durationSeconds: z.number(), + totalTokens: z.string(), + workspace: zNamedRef, + environment: zNamedRef, + app: zNamedRef, + operator: zNamedRef.optional(), + source: zApplicationInteractionSource, + version: zWorkflowVersion.optional(), + traceId: z.string().optional(), + error: z.string().optional(), }) export const zDeploymentOperation = z.object({ @@ -824,13 +933,19 @@ export const zEnvironmentDeployedApp = z.object({ deploymentId: z.string(), workspace: zNamedRef, app: zNamedRef, - status: zEnvironmentDeployedAppStatus, + runtimeState: zRuntimeState, currentVersion: zWorkflowVersion.optional(), deployedAt: z.iso.datetime().optional(), deployedBy: zOperator.optional(), latestAttempt: zEnvironmentDeployedAppAttempt.optional(), sizing: zRunnerSizing.optional(), occupiesPool: z.boolean().optional(), + recentInvocationCount: z.string().optional(), + versionsBehind: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), }) export const zEnvironmentDeploymentOperation = z.object({ @@ -843,7 +958,7 @@ export const zEnvironmentDeploymentOperation = z.object({ }) export const zEnvironmentDeploymentState = z.object({ - status: zDeploymentStatus, + runtimeState: zRuntimeState, current_version: zWorkflowVersion.optional(), versions_behind: z .int() @@ -861,14 +976,16 @@ export const zEnvironmentDeployment = z.object({ access: zEnvironmentAccess, }) -export const zGetDeploymentOperationResponse = z.object({ - operation: zDeploymentOperation, -}) - export const zGetEnvironmentDeploymentResponse = z.object({ environment_deployment: zEnvironmentDeployment, }) +export const zListApplicationInteractionsResponse = z.object({ + data: z.array(zApplicationInteraction), + nextPageToken: z.string().optional(), + previousPageToken: z.string().optional(), +}) + export const zListEnvironmentDeploymentsResponse = z.object({ environment_deployments: z.array(zEnvironmentDeployment), }) @@ -899,13 +1016,8 @@ export const zPagination = z.object({ .optional(), }) -export const zListApplicationInteractionsResponse = z.object({ - data: z.array(zApplicationInteraction), - pagination: zPagination, -}) - -export const zListAppsResponse = z.object({ - data: z.array(zDashboardApp), +export const zListApplicationInteractionAppsResponse = z.object({ + data: z.array(zNamedRef), pagination: zPagination, }) @@ -916,7 +1028,6 @@ export const zListDeploymentOperationsResponse = z.object({ export const zListEnvironmentDeployedAppsResponse = z.object({ data: z.array(zEnvironmentDeployedApp), - summary: zEnvironmentDeployedAppSummary, pagination: zPagination, }) @@ -925,10 +1036,17 @@ export const zListEnvironmentsResponse = z.object({ pagination: zPagination, }) +export const zListOperationAppsResponse = z.object({ + data: z.array(zOperationApp), + pagination: zPagination, +}) + export const zDeleteEnvironmentApiKeyResponseWritable = z.record(z.string(), z.unknown()) export const zDeleteEnvironmentResponseWritable = z.record(z.string(), z.unknown()) +export const zDeleteServiceApiConversationResponseWritable = z.record(z.string(), z.unknown()) + export const zEnvironmentMcpServerWritable = z.record(z.string(), z.unknown()) export const zEnvironmentTriggerWritable = z.record(z.string(), z.unknown()) @@ -951,8 +1069,10 @@ export const zEnvironmentWritable = z.object({ statusMessage: z.string(), lastError: zError.optional(), namespace: z.string().optional(), - managedBy: zEnvironmentManagedBy.optional(), - cpuPool: z.number(), + cpuPoolMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), createdAt: z.iso.datetime(), updatedAt: z.iso.datetime(), memory: zRunnerMemoryWritable.optional(), @@ -982,7 +1102,10 @@ export const zRetryEnvironmentBootstrapResponseWritable = z.object({ * in an isolated environment; elsewhere the runner belongs to the environment. */ export const zRunnerSizingWritable = z.object({ - isolatedCpu: z.number(), + isolatedCpuMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), memory: zRunnerMemoryWritable, }) @@ -990,26 +1113,40 @@ export const zEnvironmentDeployedAppWritable = z.object({ deploymentId: z.string(), workspace: zNamedRef, app: zNamedRef, - status: zEnvironmentDeployedAppStatus, + runtimeState: zRuntimeState, currentVersion: zWorkflowVersion.optional(), deployedAt: z.iso.datetime().optional(), deployedBy: zOperator.optional(), latestAttempt: zEnvironmentDeployedAppAttempt.optional(), sizing: zRunnerSizingWritable.optional(), occupiesPool: z.boolean().optional(), + recentInvocationCount: z.string().optional(), + versionsBehind: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + .optional(), }) export const zListEnvironmentDeployedAppsResponseWritable = z.object({ data: z.array(zEnvironmentDeployedAppWritable), - summary: zEnvironmentDeployedAppSummary, pagination: zPagination, }) export const zUpdateEnvironmentDeployedAppResourcesResponseWritable = z.object({ deploymentId: z.string(), - isolatedCpu: z.number(), - allocatedCpuCount: z.number(), - poolCpuCount: z.number(), + isolatedCpuMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + allocatedCpuMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), + poolCpuMillicores: z + .int() + .min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }) + .max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }), memory: zRunnerMemoryWritable, }) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index c56c8ac546c..e37f124dd9a 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -166,7 +166,9 @@ const AppDetailLayout: FC = (props) => { (routeAppDetail.mode === AppModeEnum.AGENT || !appACLCapabilities.canAccessConfig)) || (isAccessPointPath && !appACLCapabilities.canViewAccessPoint) || (isDeployPath && - (routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy)) + ((routeAppDetail.mode !== AppModeEnum.WORKFLOW && + routeAppDetail.mode !== AppModeEnum.ADVANCED_CHAT) || + !appACLCapabilities.canDeploy)) ) { router.replace( getRedirectionPath(routeAppDetail, { diff --git a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx index 1287b47fa79..c9b5582b030 100644 --- a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx +++ b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx @@ -46,6 +46,8 @@ describe('Splash', () => { beforeEach(() => { vi.clearAllMocks() webAppState.shareCode = 'share-app' + webAppState.webAppAccessMode = 'public' + webAppState.embeddedUserId = 'embedded-user' navigationMocks.pathname = '/chatbot/share-app' window.history.replaceState({}, '', navigationMocks.pathname) navigationMocks.searchParams = new URLSearchParams({ @@ -140,6 +142,74 @@ describe('Splash', () => { expect(await screen.findByText('share.common.appUnavailable')).toBeInTheDocument() }) + it('should redirect an unauthenticated sso verified environment to the sign-in page', async () => { + navigationMocks.searchParams = new URLSearchParams() + navigationMocks.pathname = '/environment/chat/environment-app' + window.history.replaceState({}, '', navigationMocks.pathname) + webAppState.shareCode = 'environment-app' + webAppState.webAppAccessMode = 'sso_verified' + + render( + +
share application
+
, + ) + + await waitFor(() => { + expect(navigationMocks.replace).toHaveBeenCalledWith( + '/webapp-signin?redirect_url=%2Fenvironment%2Fchat%2Fenvironment-app', + ) + }) + expect(fetchAccessTokenMock).not.toHaveBeenCalled() + expect(screen.queryByText('share application')).not.toBeInTheDocument() + }) + + it('should redirect an sso verified environment when its passport cannot be issued', async () => { + navigationMocks.searchParams = new URLSearchParams({ + query: 'keep-me', + web_sso_token: 'expired-token', + }) + navigationMocks.pathname = '/environment/chat/environment-app' + window.history.replaceState({}, '', navigationMocks.pathname) + webAppState.shareCode = 'environment-app' + webAppState.webAppAccessMode = 'sso_verified' + webAppAuthMocks.webAppLoginStatus.mockResolvedValue({ + userLoggedIn: true, + appLoggedIn: false, + }) + fetchAccessTokenMock.mockRejectedValue(new Response(null, { status: 401 })) + + render( + +
share application
+
, + ) + + await waitFor(() => { + expect(navigationMocks.replace).toHaveBeenCalledWith( + '/webapp-signin?redirect_url=%2Fenvironment%2Fchat%2Fenvironment-app%3Fquery%3Dkeep-me', + ) + }) + expect(webAppAuthMocks.webAppLogout).toHaveBeenCalledWith({ + kind: 'environment', + code: 'environment-app', + }) + expect(screen.queryByText('share application')).not.toBeInTheDocument() + }) + + it('should keep the existing authentication surface for an ordinary Web App', async () => { + navigationMocks.searchParams = new URLSearchParams() + + render( + +
share application
+
, + ) + + expect(await screen.findByText('share application')).toBeInTheDocument() + expect(navigationMocks.replace).not.toHaveBeenCalled() + }) + it('should expose the unavailable-state action as a button', () => { navigationMocks.searchParams = new URLSearchParams({ code: '404', diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index 9558c6acaa4..eaede783da1 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -4,11 +4,13 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { isWebAppSigninPath, + navigateAfterWebAppLogin, resolveWebAppLoginRedirect, } from '@/app/(shareLayout)/webapp-signin/login-redirect' import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' import { useWebAppStore } from '@/context/web-app-context' +import { AccessMode } from '@/models/access-control' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { fetchAccessToken } from '@/service/share' import { resolveWebAppAddress } from '@/service/webapp-address' @@ -76,11 +78,25 @@ function Splash({ children }: PropsWithChildren) { if (tokenFromUrl) setWebAppAccessToken(tokenFromUrl) const redirectOrFinish = () => { - if (loginRedirect) replaceLoginRedirect(loginRedirect.target, router.replace, basePath) + if (loginRedirect) navigateAfterWebAppLogin(loginRedirect, router.replace, basePath) else setIsLoading(false) } - const proceedToAuth = () => { + const proceedToAuth = (authenticationRequired: boolean) => { + if ( + authenticationRequired && + address.kind === 'environment' && + webAppAccessMode !== AccessMode.PUBLIC && + !isSigninRoute + ) { + const redirectSearchParams = new URLSearchParams(searchParams) + redirectSearchParams.delete('web_sso_token') + const redirectSearch = redirectSearchParams.toString() + const redirectTarget = redirectSearch ? `${pathname}?${redirectSearch}` : pathname + const signinSearchParams = new URLSearchParams({ redirect_url: redirectTarget }) + router.replace(`/webapp-signin?${signinSearchParams.toString()}`) + return + } setIsLoading(false) } @@ -88,13 +104,12 @@ function Splash({ children }: PropsWithChildren) { // if access mode is public, user login is always true, but the app login(passport) may be expired const { userLoggedIn, appLoggedIn } = await webAppLoginStatus( effectiveShareCode, - webAppAccessMode, embeddedUserId || undefined, ) if (userLoggedIn && appLoggedIn) { redirectOrFinish() } else if (!userLoggedIn && !appLoggedIn) { - proceedToAuth() + proceedToAuth(true) } else if (!userLoggedIn && appLoggedIn) { redirectOrFinish() } else if (userLoggedIn && !appLoggedIn) { @@ -112,7 +127,7 @@ function Splash({ children }: PropsWithChildren) { return } await webAppLogout(address) - proceedToAuth() + proceedToAuth(error instanceof Response && error.status === 401) } } })() diff --git a/web/app/(shareLayout)/environment/chat/[token]/page.tsx b/web/app/(shareLayout)/environment/chat/[token]/page.tsx new file mode 100644 index 00000000000..fc55ec54af5 --- /dev/null +++ b/web/app/(shareLayout)/environment/chat/[token]/page.tsx @@ -0,0 +1,14 @@ +'use client' +import * as React from 'react' +import ChatWithHistoryWrap from '@/app/components/base/chat/chat-with-history' +import AuthenticatedLayout from '../../../components/authenticated-layout' + +const EnvironmentChat = () => { + return ( + + + + ) +} + +export default React.memo(EnvironmentChat) diff --git a/web/app/(shareLayout)/env/workflow/[token]/page.tsx b/web/app/(shareLayout)/environment/workflow/[token]/page.tsx similarity index 100% rename from web/app/(shareLayout)/env/workflow/[token]/page.tsx rename to web/app/(shareLayout)/environment/workflow/[token]/page.tsx diff --git a/web/app/(shareLayout)/webapp-signin/__tests__/login-redirect.spec.ts b/web/app/(shareLayout)/webapp-signin/__tests__/login-redirect.spec.ts index 94e0872c01e..ada96f415d9 100644 --- a/web/app/(shareLayout)/webapp-signin/__tests__/login-redirect.spec.ts +++ b/web/app/(shareLayout)/webapp-signin/__tests__/login-redirect.spec.ts @@ -1,4 +1,4 @@ -import { resolveWebAppLoginRedirect } from '../login-redirect' +import { navigateAfterWebAppLogin, resolveWebAppLoginRedirect } from '../login-redirect' describe('resolveWebAppLoginRedirect', () => { // Covers the canonical relative redirect shape used by share applications. @@ -27,14 +27,14 @@ describe('resolveWebAppLoginRedirect', () => { it('should resolve an environment workflow redirect', () => { const result = resolveWebAppLoginRedirect( - '/env/workflow/workflow-app', + '/environment/workflow/workflow-app', 'https://self-hosted.example.com', ) expect(result).toEqual({ appCode: 'workflow-app', address: { kind: 'environment', code: 'workflow-app' }, - target: { kind: 'internal', href: '/env/workflow/workflow-app' }, + target: { kind: 'internal', href: '/environment/workflow/workflow-app' }, }) }) }) @@ -84,3 +84,68 @@ describe('resolveWebAppLoginRedirect', () => { }) }) }) + +describe('navigateAfterWebAppLogin', () => { + const routerReplace = vi.fn() + const locationReplace = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('location', { + ...window.location, + replace: locationReplace, + } as unknown as Location) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('reloads the document after an environment login', () => { + navigateAfterWebAppLogin( + { + appCode: 'environment-app', + address: { kind: 'environment', code: 'environment-app' }, + target: { kind: 'internal', href: '/environment/chat/environment-app' }, + }, + routerReplace, + '', + ) + + expect(locationReplace).toHaveBeenCalledWith('/environment/chat/environment-app') + expect(routerReplace).not.toHaveBeenCalled() + }) + + it.each([ + ['/environment/chat/environment-app', '/console/environment/chat/environment-app'], + ['/console/environment/chat/environment-app', '/console/environment/chat/environment-app'], + ])('keeps the deployment base path when reloading %s', (href, expected) => { + navigateAfterWebAppLogin( + { + appCode: 'environment-app', + address: { kind: 'environment', code: 'environment-app' }, + target: { kind: 'internal', href }, + }, + routerReplace, + '/console', + ) + + expect(locationReplace).toHaveBeenCalledWith(expected) + expect(routerReplace).not.toHaveBeenCalled() + }) + + it('keeps client navigation for an ordinary Web App login', () => { + navigateAfterWebAppLogin( + { + appCode: 'share-app', + address: { kind: 'default', code: 'share-app' }, + target: { kind: 'internal', href: '/chatbot/share-app' }, + }, + routerReplace, + '', + ) + + expect(routerReplace).toHaveBeenCalledWith('/chatbot/share-app') + expect(locationReplace).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx b/web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx index 2459bf07329..89fc70c051a 100644 --- a/web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx +++ b/web/app/(shareLayout)/webapp-signin/__tests__/page.spec.tsx @@ -84,7 +84,7 @@ describe('WebSSOForm environment access modes', () => { beforeEach(() => { vi.clearAllMocks() navigationMocks.searchParams = new URLSearchParams({ - redirect_url: '/env/workflow/workflow-app', + redirect_url: '/environment/workflow/workflow-app', }) }) @@ -105,7 +105,7 @@ describe('WebSSOForm environment access modes', () => { await waitFor(() => { expect(serviceMocks.fetchWebSAMLSSOUrl).toHaveBeenCalledWith( 'workflow-app', - '/env/workflow/workflow-app', + '/environment/workflow/workflow-app', ) }) expect(navigationMocks.push).toHaveBeenCalledWith('https://idp.example/authorize') diff --git a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx index 6a408735326..a17c95678af 100644 --- a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx @@ -3,10 +3,12 @@ import type { FormEvent } from 'react' import { Button } from '@langgenius/dify-ui/button' import { Input } from '@langgenius/dify-ui/input' import { toast } from '@langgenius/dify-ui/toast' -import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect' +import { + navigateAfterWebAppLogin, + resolveWebAppLoginRedirect, +} from '@/app/(shareLayout)/webapp-signin/login-redirect' import Countdown from '@/app/components/signin/countdown' import { useLocale } from '@/context/i18n' import { useWebAppStore } from '@/context/web-app-context' @@ -67,7 +69,7 @@ export default function CheckCode() { userId: embeddedUserId || undefined, }) setWebAppPassport(loginRedirect.address, access_token) - replaceLoginRedirect(loginRedirect.target, router.replace, basePath) + navigateAfterWebAppLogin(loginRedirect, router.replace, basePath) } } catch (error) { console.error(error) @@ -107,7 +109,10 @@ export default function CheckCode() { return (
- +

@@ -160,7 +165,7 @@ export default function CheckCode() { className="flex h-9 cursor-pointer appearance-none items-center justify-center text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden" > - + {t(($) => $.back, { ns: 'login' })} diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx index 0c741a89a12..2fc180f80bb 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx @@ -8,7 +8,10 @@ import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-u import { toast } from '@langgenius/dify-ui/toast' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect' +import { + navigateAfterWebAppLogin, + resolveWebAppLoginRedirect, +} from '@/app/(shareLayout)/webapp-signin/login-redirect' import { emailRegex } from '@/config' import { useWebAppStore } from '@/context/web-app-context' import Link from '@/next/link' @@ -84,7 +87,7 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut userId: embeddedUserId || undefined, }) setWebAppPassport(loginRedirect.address, access_token) - replaceLoginRedirect(loginRedirect.target, router.replace, basePath) + navigateAfterWebAppLogin(loginRedirect, router.replace, basePath) } else { toast.error(res.data) } diff --git a/web/app/(shareLayout)/webapp-signin/login-redirect.ts b/web/app/(shareLayout)/webapp-signin/login-redirect.ts index 216e61f338c..26a29d0d1f5 100644 --- a/web/app/(shareLayout)/webapp-signin/login-redirect.ts +++ b/web/app/(shareLayout)/webapp-signin/login-redirect.ts @@ -2,6 +2,7 @@ import type { WebAppAddress } from '@/service/webapp-address' import type { LoginRedirectTarget } from '@/utils/login-redirect' import { parseWebAppAddress } from '@/service/webapp-address' import { resolveLoginRedirectTarget } from '@/utils/login-redirect' +import { replaceLoginRedirect } from '@/utils/login-redirect.client' const INTERNAL_PATH_PARSE_BASE = 'https://login-redirect.invalid' @@ -11,6 +12,34 @@ export type WebAppLoginRedirect = { target: LoginRedirectTarget } +function addBasePathOnce(href: string, basePath: string) { + const normalizedBasePath = basePath === '/' ? '' : basePath.replace(/\/+$/, '') + if (!normalizedBasePath) return href + + const url = new URL(href, INTERNAL_PATH_PARSE_BASE) + if (url.pathname === normalizedBasePath || url.pathname.startsWith(`${normalizedBasePath}/`)) + return href + + return `${normalizedBasePath}${url.pathname}${url.search}${url.hash}` +} + +export function navigateAfterWebAppLogin( + loginRedirect: WebAppLoginRedirect, + routerReplace: (href: string) => void, + basePath: string, +) { + if (loginRedirect.address.kind === 'environment') { + const href = + loginRedirect.target.kind === 'internal' + ? addBasePathOnce(loginRedirect.target.href, basePath) + : loginRedirect.target.href + globalThis.location.replace(href) + return + } + + replaceLoginRedirect(loginRedirect.target, routerReplace, basePath) +} + export function isWebAppSigninPath(pathname: string): boolean { let candidate = pathname diff --git a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx index ae31b4c5ed2..27546c4eb51 100644 --- a/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/app-detail-section.spec.tsx @@ -210,20 +210,23 @@ describe('AppDetailSection', () => { ).not.toBeInTheDocument() }) - it('should render deploy navigation with app deploy ACL regardless of the legacy workspace role', () => { - // Arrange - mockAppMode = 'workflow' - mockAppPermissionKeys = [AppACLPermission.Deploy] + it.each(['workflow', 'advanced-chat'])( + 'should render deploy navigation for a %s app with app deploy ACL regardless of the legacy workspace role', + (mode) => { + // Arrange + mockAppMode = mode + mockAppPermissionKeys = [AppACLPermission.Deploy] - // Act - render() + // Act + render() - // Assert - expect(screen.getByRole('link', { name: 'common.appMenus.deploy' })).toHaveAttribute( - 'href', - '/app/app-1/deploy', - ) - }) + // Assert + expect(screen.getByRole('link', { name: 'common.appMenus.deploy' })).toHaveAttribute( + 'href', + '/app/app-1/deploy', + ) + }, + ) it.each([ { diff --git a/web/app/components/app-sidebar/app-detail-section.tsx b/web/app/components/app-sidebar/app-detail-section.tsx index 3b2035b23a3..3cd1d813912 100644 --- a/web/app/components/app-sidebar/app-detail-section.tsx +++ b/web/app/components/app-sidebar/app-detail-section.tsx @@ -98,7 +98,6 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => { const appId = appDetail.id const isWorkflowApp = appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT - const supportsAppDeploy = appDetail.mode === AppModeEnum.WORKFLOW const supportsAnnotations = appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION const supportsResourceAccess = appDetail.mode !== AppModeEnum.AGENT @@ -130,7 +129,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => { }, ] : []), - ...(supportsAppDeploy && appACLCapabilities.canDeploy + ...(isWorkflowApp && appACLCapabilities.canDeploy ? [ { name: t(($) => $['appMenus.deploy'], { ns: 'common' }), diff --git a/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx b/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx index 38d88d3a5af..1d08a65fbb0 100644 --- a/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/access-point-card.spec.tsx @@ -1,7 +1,8 @@ -import type { AccessPointStatus } from '../shared/access-point-status' +import type { AccessPointStatus } from '@/app/components/base/access-point/status' import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AccessPointCard } from '@/app/components/base/access-point/card' import { render } from '@/test/console/render' -import { AccessPointCard } from '../shared/access-point-card' describe('AccessPointCard', () => { it('marks the card when it is the highlighted access point', () => { @@ -11,6 +12,7 @@ describe('AccessPointCard', () => { description="Web application access" icon="i-ri-robot-2-line" status="inService" + statusLabel="In service" highlighted > Access URL @@ -39,6 +41,7 @@ describe('AccessPointCard', () => { description="Web application access" icon="i-ri-robot-2-line" status={status} + statusLabel={label} onEnabledChange={vi.fn()} > Access URL @@ -51,4 +54,34 @@ describe('AccessPointCard', () => { else expect(card).not.toHaveAttribute('aria-busy') expect(screen.queryByRole('switch')).not.toBeInTheDocument() }) + + it('keeps an unavailable switch focusable and explains why it is disabled', async () => { + const user = userEvent.setup() + const onEnabledChange = vi.fn() + render( + + Access URL + , + ) + + const accessSwitch = screen.getByRole('switch', { name: 'Toggle Web App' }) + expect(accessSwitch).toHaveAttribute('aria-disabled', 'true') + + await user.tab() + expect(accessSwitch).toHaveFocus() + expect(await screen.findByRole('tooltip')).toHaveTextContent('Publish first') + + await user.click(accessSwitch) + expect(onEnabledChange).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/app/access-point/__tests__/access-point-url.spec.tsx b/web/app/components/app/access-point/__tests__/access-point-url.spec.tsx index 56461a8aa52..af58acf2416 100644 --- a/web/app/components/app/access-point/__tests__/access-point-url.spec.tsx +++ b/web/app/components/app/access-point/__tests__/access-point-url.spec.tsx @@ -1,6 +1,7 @@ import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AccessPointUrl } from '@/app/components/base/access-point/url' import { render } from '@/test/console/render' -import { AccessPointUrl } from '../shared/access-point-url' const endpointProps = { label: 'Access URL', @@ -34,6 +35,22 @@ describe('AccessPointUrl', () => { expect(openLink).toHaveAttribute('rel', 'noopener noreferrer') }) + it('explains why the open action is disabled', async () => { + const user = userEvent.setup() + render( + , + ) + + await user.hover(screen.getByRole('button', { name: 'Open' })) + expect(await screen.findByRole('tooltip')).toHaveTextContent('Publish first') + }) + it('shows an unavailable endpoint without replacing it with a loading skeleton', () => { render() diff --git a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx index f4bc42f1d7f..c4ba3dd2d24 100644 --- a/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx +++ b/web/app/components/app/access-point/__tests__/built-in-access-points.spec.tsx @@ -57,7 +57,6 @@ vi.mock('@/service/use-workflow', () => ({ vi.mock('../shared/use-access-point-actions', () => ({ useAccessPointActions: () => ({ - handleAppStateChanged: vi.fn(), handleResult: vi.fn(), refreshAppDetail: vi.fn(), saveSiteConfig: vi.fn(), diff --git a/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx b/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx index d203cb47af7..663731e5e60 100644 --- a/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx +++ b/web/app/components/app/access-point/__tests__/deployed-environment-access-points.spec.tsx @@ -1,4 +1,4 @@ -import type { AccessPoint } from '@/app/components/app/deploy/access-point' +import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { screen, within } from '@testing-library/react' import { render } from '@/test/console/render' import { DeployedEnvironmentAccessPoints } from '../deployed-environment-access-points' diff --git a/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx b/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx index 9f35888e191..8a9f11c2444 100644 --- a/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx +++ b/web/app/components/app/access-point/__tests__/environment-access-point-cards.spec.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from 'react' +import { toast } from '@langgenius/dify-ui/toast' import { QueryClientProvider } from '@tanstack/react-query' -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { render } from '@/test/console/render' import { createTestQueryClient } from '@/test/query-client' @@ -84,6 +85,8 @@ vi.mock('@/features/system-features/client', () => ({ }), })) +let mockAppMode = 'workflow' + vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.test/en${path}`, })) @@ -93,11 +96,13 @@ vi.mock('@/app/components/app/store', () => ({ selector({ appDetail: { id: 'app-1', + get mode() { + return mockAppMode + }, icon: '🤖', icon_background: '#FFEAD5', icon_type: 'emoji', icon_url: null, - mode: 'workflow', site: { access_token: 'built-in-code', app_base_url: 'https://built-in.example.test', @@ -195,6 +200,17 @@ function renderCard(ui: ReactElement) { return render({ui}) } +function createDeferredPromise() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + + return { promise, reject, resolve } +} + describe('environment access point cards', () => { beforeEach(() => { vi.clearAllMocks() @@ -227,6 +243,25 @@ describe('environment access point cards', () => { }) }) + it('sends a chatflow app to the chat web app shell', async () => { + mockAppMode = 'advanced-chat' + + renderCard( + , + ) + + expect(await screen.findByText(/environment\/chat\/site-code/)).toHaveTextContent( + 'https://site.example.test/environment/chat/site-code', + ) + + mockAppMode = 'workflow' + }) + it('renders the real environment Web app URL and workflow actions without Embed', async () => { renderCard( { />, ) - expect(await screen.findByText(/env\/workflow\/site-code/)).toHaveTextContent( - 'https://site.example.test/env/workflow/site-code', + expect(await screen.findByText(/environment\/workflow\/site-code/)).toHaveTextContent( + 'https://site.example.test/environment/workflow/site-code', ) expect( await screen.findByRole('button', { @@ -292,6 +327,23 @@ describe('environment access point cards', () => { ).not.toBeInTheDocument() }) + it('does not announce the access control placeholder as loading after the Site query fails', async () => { + mocks.getSite.mockRejectedValueOnce(new Error('Site unavailable')) + + renderCard( + , + ) + + const card = screen.getByRole('region', { name: /webApp\.title/ }) + await screen.findAllByText('deployments.health.ENVIRONMENT_STATUS_FAILED') + expect(within(card).queryByRole('status', { name: 'common.loading' })).not.toBeInTheDocument() + }) + it('uses environment Site mutations for status and URL reset, and opens its access container', async () => { const user = userEvent.setup() renderCard( @@ -339,6 +391,49 @@ describe('environment access point cards', () => { }) }) + it('optimistically serializes environment Web app changes without a success toast', async () => { + const user = userEvent.setup() + const firstToggle = createDeferredPromise() + const secondToggle = createDeferredPromise() + mocks.updateSite + .mockReturnValueOnce(firstToggle.promise) + .mockReturnValueOnce(secondToggle.promise) + renderCard( + , + ) + + const accessSwitch = await screen.findByRole('switch') + await user.click(accessSwitch) + + expect(accessSwitch).toHaveAttribute('aria-checked', 'false') + expect(mocks.updateSite).toHaveBeenCalledTimes(1) + + await user.click(accessSwitch) + + expect(accessSwitch).toHaveAttribute('aria-checked', 'true') + expect(mocks.updateSite).toHaveBeenCalledTimes(1) + + firstToggle.resolve({ ...site, enabled: false }) + + await waitFor(() => { + expect(mocks.updateSite).toHaveBeenCalledTimes(2) + }) + expect(mocks.updateSite.mock.calls[1]?.[0]).toEqual({ + body: { enabled: true }, + params: environmentParams, + }) + + secondToggle.resolve({ ...site, enabled: true }) + + await screen.findByRole('link', { name: /studio\.accessPoint\.open/ }) + expect(toast.success).not.toHaveBeenCalled() + }) + it('opens Customize and Settings with environment endpoint data', async () => { const user = userEvent.setup() renderCard( @@ -350,7 +445,7 @@ describe('environment access point cards', () => { />, ) - await screen.findByText(/env\/workflow\/site-code/) + await screen.findByText(/environment\/workflow\/site-code/) await user.click(screen.getByRole('button', { name: /customize\.entry/ })) expect(screen.getByRole('dialog', { name: 'environment customize' })).toHaveTextContent( 'https://api.example.test/v1', @@ -452,6 +547,29 @@ describe('environment access point cards', () => { }) }) + it('rolls back a failed environment Service API change and shows only an error toast', async () => { + const user = userEvent.setup() + const toggle = createDeferredPromise() + mocks.updateApi.mockReturnValueOnce(toggle.promise) + renderCard( + , + ) + + await screen.findByText(api.base_url) + const accessSwitch = screen.getByRole('switch') + await user.click(accessSwitch) + + expect(accessSwitch).toHaveAttribute('aria-checked', 'false') + + toggle.reject(new Error('request failed')) + + await waitFor(() => { + expect(accessSwitch).toHaveAttribute('aria-checked', 'true') + }) + expect(toast.error).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully') + expect(toast.success).not.toHaveBeenCalled() + }) + it('keeps environment API keys and external documentation available when the API is stopped', async () => { mocks.getApi.mockResolvedValue({ ...api, diff --git a/web/app/components/app/access-point/__tests__/index.spec.tsx b/web/app/components/app/access-point/__tests__/index.spec.tsx index 4cfdb2c5637..493216c0316 100644 --- a/web/app/components/app/access-point/__tests__/index.spec.tsx +++ b/web/app/components/app/access-point/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import type { AppEnvironment } from '@dify/contracts/enterprise-app-deploy/types.gen' import type { ReactNode } from 'react' -import type { AccessPoint as AccessPointType } from '@/app/components/app/deploy/access-point' +import type { AccessPoint as AccessPointType } from '@/app/components/app/deploy/utils/access-point' import { EnvironmentStatus } from '@dify/contracts/enterprise-app-deploy/types.gen' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' diff --git a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx index e4704b39979..1f96c4a5429 100644 --- a/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/mcp-card.spec.tsx @@ -1,8 +1,11 @@ import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils' -import { screen } from '@testing-library/react' +import { toast } from '@langgenius/dify-ui/toast' +import { QueryClientProvider } from '@tanstack/react-query' +import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { BlockEnum } from '@/app/components/workflow/types' import { render } from '@/test/console/render' +import { createTestQueryClient } from '@/test/query-client' import { AppModeEnum } from '@/types/app' import { MCPAccessPointCard } from '../built-in-access-points/mcp-card' @@ -17,6 +20,30 @@ const mocks = vi.hoisted(() => ({ updateServer: vi.fn(), })) +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + apps: { + byAppId: { + server: { + put: { + mutationOptions: (options = {}) => ({ + mutationFn: mocks.updateServer, + ...options, + }), + }, + }, + }, + }, + }, +})) + vi.mock('@/service/use-tools', () => ({ useInvalidateMCPServerDetail: () => mocks.invalidateServerDetail, useMCPServerDetail: () => mocks.serverDetail, @@ -24,10 +51,6 @@ vi.mock('@/service/use-tools', () => ({ isPending: false, mutateAsync: mocks.refreshServerCode, }), - useUpdateMCPServer: () => ({ - isPending: false, - mutateAsync: mocks.updateServer, - }), })) vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({ @@ -74,11 +97,39 @@ const publishedWorkflow = { }, } as unknown as PublishedWorkflow +function createDeferredPromise() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + + return { promise, reject, resolve } +} + +function renderCard(cardAppInfo: AccessPointAppInfo = appInfo, workflow?: PublishedWorkflow) { + const queryClient = createTestQueryClient() + + return render( + + + , + ) +} + describe('MCPAccessPointCard', () => { beforeEach(() => { vi.clearAllMocks() mocks.serverDetail.data = undefined mocks.serverDetail.isPending = false + mocks.updateServer.mockResolvedValue(undefined) }) afterEach(() => { @@ -91,15 +142,7 @@ describe('MCPAccessPointCard', () => { .spyOn(globalThis, 'fetch') .mockResolvedValue(new Response('{}', { status: 200 })) - render( - , - ) + renderCard() await user.click(screen.getByRole('button', { name: /addDescription/ })) @@ -122,15 +165,7 @@ describe('MCPAccessPointCard', () => { it('uses workflow inputs when the app model config is null', async () => { const user = userEvent.setup() - render( - , - ) + renderCard(workflowAppInfo, publishedWorkflow) await user.click(screen.getByRole('button', { name: /addDescription/ })) @@ -144,15 +179,7 @@ describe('MCPAccessPointCard', () => { it('shows loading without reporting an environment failure', () => { mocks.serverDetail.isPending = true - render( - , - ) + renderCard(workflowAppInfo, publishedWorkflow) const card = screen.getByRole('region', { name: /mcp\.server\.title/ }) expect(card).toHaveAttribute('aria-busy', 'true') @@ -161,4 +188,68 @@ describe('MCPAccessPointCard', () => { screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'), ).not.toBeInTheDocument() }) + + it('rolls back a failed status change and shows only an error toast', async () => { + const user = userEvent.setup() + const toggle = createDeferredPromise() + mocks.serverDetail.data = { + id: 'server-1', + server_code: 'server-code', + status: 'active', + } + mocks.updateServer.mockReturnValueOnce(toggle.promise) + renderCard() + + const accessSwitch = screen.getByRole('switch') + await user.click(accessSwitch) + + expect(accessSwitch).toHaveAttribute('aria-checked', 'false') + + toggle.reject(new Error('request failed')) + + await waitFor(() => { + expect(accessSwitch).toHaveAttribute('aria-checked', 'true') + }) + expect(toast.error).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully') + expect(toast.success).not.toHaveBeenCalled() + }) + + it('optimistically serializes rapid status changes without a busy switch', async () => { + const user = userEvent.setup() + const firstToggle = createDeferredPromise() + const secondToggle = createDeferredPromise() + mocks.serverDetail.data = { + id: 'server-1', + server_code: 'server-code', + status: 'active', + } + mocks.updateServer + .mockReturnValueOnce(firstToggle.promise) + .mockReturnValueOnce(secondToggle.promise) + renderCard() + + const accessSwitch = screen.getByRole('switch') + await user.click(accessSwitch) + + expect(accessSwitch).toHaveAttribute('aria-checked', 'false') + expect(accessSwitch).toBeEnabled() + + await user.click(accessSwitch) + + expect(accessSwitch).toHaveAttribute('aria-checked', 'true') + expect(mocks.updateServer).toHaveBeenCalledTimes(1) + + firstToggle.resolve() + + await waitFor(() => { + expect(mocks.updateServer).toHaveBeenCalledTimes(2) + }) + + secondToggle.resolve() + + await waitFor(() => { + expect(mocks.invalidateServerDetail).toHaveBeenCalledTimes(2) + }) + expect(accessSwitch).toHaveAttribute('aria-checked', 'true') + }) }) diff --git a/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx b/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx index 4fc9bf3913e..3fac29c2f3d 100644 --- a/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/service-api-card.spec.tsx @@ -1,8 +1,9 @@ -import type { ReactElement } from 'react' import type { AccessPointAppInfo } from '../shared/utils' +import { toast } from '@langgenius/dify-ui/toast' +import { QueryClientProvider } from '@tanstack/react-query' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { createQueryClientWrapper } from '@/test/console/query-client' +import { useStore as useAppStore } from '@/app/components/app/store' import { render } from '@/test/console/render' import { createTestQueryClient } from '@/test/query-client' import { AppModeEnum } from '@/types/app' @@ -10,13 +11,13 @@ import { ServiceApiAccessPointCard } from '../built-in-access-points/service-api const mocks = vi.hoisted(() => ({ apiSecretKeyButtonProps: vi.fn(), - toastError: vi.fn(), - updateApiStatus: vi.fn().mockResolvedValue({}), + apiEnable: vi.fn(), })) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { - error: mocks.toastError, + error: vi.fn(), + success: vi.fn(), }, })) @@ -27,7 +28,7 @@ vi.mock('@/service/client', () => ({ apiEnable: { post: { mutationOptions: (options = {}) => ({ - mutationFn: mocks.updateApiStatus, + mutationFn: mocks.apiEnable, ...options, }), }, @@ -65,13 +66,58 @@ function createAppInfo( } as AccessPointAppInfo } -function renderWithQueryClient(ui: ReactElement) { - return render(ui, { wrapper: createQueryClientWrapper(createTestQueryClient()) }) +function renderCard( + mode: AppModeEnum, + availability: 'available' | 'loading' | 'unavailable' = 'available', + canManage = true, + overrides: Partial = {}, +) { + useAppStore.setState({ appDetail: createAppInfo(mode, overrides) }) + const queryClient = createTestQueryClient() + + return render( + + + , + ) +} + +function StoreConnectedServiceApiCard({ + availability, + canManage, +}: { + availability: 'available' | 'loading' | 'unavailable' + canManage: boolean +}) { + const appInfo = useAppStore((state) => state.appDetail) + if (!appInfo) return null + + return ( + + ) +} + +function createDeferredPromise() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + + return { promise, reject, resolve } } describe('ServiceApiAccessPointCard', () => { beforeEach(() => { vi.clearAllMocks() + mocks.apiEnable.mockResolvedValue({ + enable_api: true, + }) }) it.each([ @@ -81,14 +127,7 @@ describe('ServiceApiAccessPointCard', () => { [AppModeEnum.AGENT_CHAT, '/api-reference/guides/chat'], [AppModeEnum.COMPLETION, '/api-reference/guides/completion'], ])('links %s apps to the matching external API reference', (mode, path) => { - renderWithQueryClient( - , - ) + renderCard(mode) const apiReferenceLink = screen.getByRole('link', { name: /apiInfo\.doc/ }) @@ -99,36 +138,20 @@ describe('ServiceApiAccessPointCard', () => { it('updates API status through the generated contract', async () => { const user = userEvent.setup() - const onAppStateChanged = vi.fn() - renderWithQueryClient( - , - ) + renderCard(AppModeEnum.WORKFLOW) await user.click(screen.getByRole('switch')) await waitFor(() => { - expect(mocks.updateApiStatus.mock.calls[0]?.[0]).toEqual({ + expect(mocks.apiEnable.mock.calls[0]?.[0]).toEqual({ params: { app_id: 'app-1' }, body: { enable_api: false }, }) - expect(onAppStateChanged).toHaveBeenCalledTimes(1) }) }) it('shows loading without reporting an environment failure', () => { - renderWithQueryClient( - , - ) + renderCard(AppModeEnum.WORKFLOW, 'loading') const card = screen.getByRole('region', { name: /serviceApi\.title/ }) expect(card).toHaveAttribute('aria-busy', 'true') @@ -139,14 +162,7 @@ describe('ServiceApiAccessPointCard', () => { }) it('keeps API keys and external documentation available when the API is stopped', () => { - renderWithQueryClient( - , - ) + renderCard(AppModeEnum.WORKFLOW, 'available', true, { enable_api: false }) expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeEnabled() const apiReferenceLink = screen.getByRole('link', { name: /apiInfo\.doc/ }) @@ -157,30 +173,51 @@ describe('ServiceApiAccessPointCard', () => { }) it('disables API management without Access Point management permission', () => { - renderWithQueryClient( - , - ) + renderCard(AppModeEnum.WORKFLOW, 'available', false) expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeDisabled() expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true') }) it('disables API keys and external documentation when the access point is unavailable', () => { - renderWithQueryClient( - , - ) + renderCard(AppModeEnum.WORKFLOW, 'unavailable') expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeDisabled() expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toBeDisabled() }) + + it('rolls back a failed status change and shows only an error toast', async () => { + const user = userEvent.setup() + const toggle = createDeferredPromise<{ enable_api: boolean }>() + mocks.apiEnable.mockReturnValueOnce(toggle.promise) + renderCard(AppModeEnum.WORKFLOW) + + const accessSwitch = screen.getByRole('switch') + await user.click(accessSwitch) + + expect(accessSwitch).toHaveAttribute('aria-checked', 'false') + + toggle.reject(new Error('request failed')) + + await waitFor(() => { + expect(accessSwitch).toHaveAttribute('aria-checked', 'true') + }) + expect(toast.error).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully') + expect(toast.success).not.toHaveBeenCalled() + }) + + it('keeps successful status changes silent', async () => { + const user = userEvent.setup() + mocks.apiEnable.mockResolvedValueOnce({ enable_api: false }) + renderCard(AppModeEnum.WORKFLOW) + + const accessSwitch = screen.getByRole('switch') + await user.click(accessSwitch) + + await waitFor(() => { + expect(useAppStore.getState().appDetail?.enable_api).toBe(false) + }) + expect(accessSwitch).toHaveAttribute('aria-checked', 'false') + expect(toast.success).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx b/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx index 32120bfe8d8..aeac874d8ba 100644 --- a/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/trigger-card.spec.tsx @@ -1,13 +1,16 @@ import type { AccessPointAppInfo } from '../shared/utils' import type { AppTrigger } from '@/service/use-tools' -import { screen } from '@testing-library/react' +import { toast } from '@langgenius/dify-ui/toast' +import { QueryClientProvider } from '@tanstack/react-query' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { render } from '@/test/console/render' +import { createTestQueryClient } from '@/test/query-client' import { AppModeEnum } from '@/types/app' import { TriggerAccessPointCard } from '../built-in-access-points/trigger-card' const mocks = vi.hoisted(() => ({ invalidateTriggers: vi.fn(), - setTriggerStatus: vi.fn(), setTriggerStatuses: vi.fn(), triggerQuery: { data: undefined as { data: AppTrigger[] } | undefined, @@ -16,24 +19,46 @@ const mocks = vi.hoisted(() => ({ updateTriggerStatus: vi.fn(), })) +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + }, +})) + vi.mock('@/app/components/workflow/store/trigger-status', () => ({ - useTriggerStatusStore: () => ({ - setTriggerStatus: mocks.setTriggerStatus, - setTriggerStatuses: mocks.setTriggerStatuses, - }), + useTriggerStatusStore: ( + selector: (state: { setTriggerStatuses: typeof mocks.setTriggerStatuses }) => unknown, + ) => + selector({ + setTriggerStatuses: mocks.setTriggerStatuses, + }), })) vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.test/en${path}`, })) +vi.mock('@/service/client', () => ({ + consoleQuery: { + apps: { + byAppId: { + triggerEnable: { + post: { + mutationOptions: (options = {}) => ({ + mutationFn: mocks.updateTriggerStatus, + ...options, + }), + }, + }, + }, + }, + }, +})) + vi.mock('@/service/use-tools', () => ({ useAppTriggers: () => mocks.triggerQuery, useInvalidateAppTriggers: () => mocks.invalidateTriggers, - useUpdateTriggerStatus: () => ({ - isPending: false, - mutateAsync: mocks.updateTriggerStatus, - }), })) vi.mock('@/service/use-triggers', () => ({ @@ -64,21 +89,32 @@ function createTrigger(id: string, status: AppTrigger['status']): AppTrigger { } function renderCard(availability: 'available' | 'loading' | 'unavailable') { + const queryClient = createTestQueryClient() + render( - , + + + , ) } +function createDeferredPromise() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + + return { promise, reject, resolve } +} + describe('TriggerAccessPointCard', () => { beforeEach(() => { vi.clearAllMocks() mocks.triggerQuery.data = undefined mocks.triggerQuery.isLoading = false + mocks.updateTriggerStatus.mockResolvedValue(undefined) }) it('shows loading without reporting an environment failure', () => { @@ -145,4 +181,84 @@ describe('TriggerAccessPointCard', () => { screen.queryByText('deployments.studio.accessPoint.noTriggerNodes'), ).not.toBeInTheDocument() }) + + it('keeps successful trigger changes silent', async () => { + const user = userEvent.setup() + mocks.triggerQuery.data = { + data: [createTrigger('disabled', 'disabled')], + } + renderCard('available') + + await user.click(screen.getByRole('switch', { name: 'Trigger disabled' })) + + await waitFor(() => { + expect(mocks.updateTriggerStatus).toHaveBeenCalledWith( + { + params: { + app_id: 'app-1', + }, + body: { + trigger_id: 'disabled', + enable_trigger: true, + }, + }, + expect.any(Object), + ) + expect(mocks.invalidateTriggers).toHaveBeenCalledWith('app-1') + }) + expect(toast.success).not.toHaveBeenCalled() + }) + + it('shows an error toast when a trigger change fails', async () => { + const user = userEvent.setup() + mocks.triggerQuery.data = { + data: [createTrigger('enabled', 'enabled')], + } + mocks.updateTriggerStatus.mockRejectedValueOnce(new Error('request failed')) + renderCard('available') + + await user.click(screen.getByRole('switch', { name: 'Trigger enabled' })) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully') + }) + expect(toast.success).not.toHaveBeenCalled() + }) + + it('optimistically serializes rapid changes for each trigger without disabling its switch', async () => { + const user = userEvent.setup() + const firstToggle = createDeferredPromise() + const secondToggle = createDeferredPromise() + mocks.triggerQuery.data = { + data: [createTrigger('enabled', 'enabled')], + } + mocks.updateTriggerStatus + .mockReturnValueOnce(firstToggle.promise) + .mockReturnValueOnce(secondToggle.promise) + renderCard('available') + + const triggerSwitch = screen.getByRole('switch', { name: 'Trigger enabled' }) + await user.click(triggerSwitch) + + expect(triggerSwitch).toHaveAttribute('aria-checked', 'false') + expect(triggerSwitch).toBeEnabled() + + await user.click(triggerSwitch) + + expect(triggerSwitch).toHaveAttribute('aria-checked', 'true') + expect(mocks.updateTriggerStatus).toHaveBeenCalledTimes(1) + + firstToggle.resolve() + + await waitFor(() => { + expect(mocks.updateTriggerStatus).toHaveBeenCalledTimes(2) + }) + + secondToggle.resolve() + + await waitFor(() => { + expect(mocks.invalidateTriggers).toHaveBeenCalledTimes(2) + }) + expect(triggerSwitch).toHaveAttribute('aria-checked', 'true') + }) }) diff --git a/web/app/components/app/access-point/__tests__/use-access-point-actions.spec.ts b/web/app/components/app/access-point/__tests__/use-access-point-actions.spec.ts index 7d25007c40f..3d192055cce 100644 --- a/web/app/components/app/access-point/__tests__/use-access-point-actions.spec.ts +++ b/web/app/components/app/access-point/__tests__/use-access-point-actions.spec.ts @@ -5,10 +5,7 @@ import { createTestQueryClient } from '@/test/query-client' import { useAccessPointActions } from '../shared/use-access-point-actions' const mocks = vi.hoisted(() => ({ - emit: vi.fn(), fetchAppDetail: vi.fn().mockResolvedValue({ id: 'app-1' }), - getSocket: vi.fn(), - onAppStateUpdate: vi.fn(() => vi.fn()), setAppDetail: vi.fn(), toast: vi.fn(), updateAppSiteConfig: vi.fn().mockResolvedValue({}), @@ -21,14 +18,6 @@ vi.mock('@/app/components/app/store', () => ({ selector({ setAppDetail: mocks.setAppDetail }), })) -vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({ - collaborationManager: { onAppStateUpdate: mocks.onAppStateUpdate }, -})) - -vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({ - webSocketClient: { getSocket: mocks.getSocket }, -})) - vi.mock('@/service/apps', () => ({ fetchAppDetail: mocks.fetchAppDetail, updateAppSiteConfig: mocks.updateAppSiteConfig, @@ -81,10 +70,9 @@ function renderActions(appId = 'app-1', canManageAccessPoint = true) { describe('useAccessPointActions', () => { beforeEach(() => { vi.clearAllMocks() - mocks.getSocket.mockReturnValue({ emit: mocks.emit }) }) - it('refreshes and broadcasts a successful access point result', async () => { + it('refreshes after a successful access point result', async () => { const { result } = renderActions() act(() => result.current.handleResult(null)) @@ -93,25 +81,17 @@ describe('useAccessPointActions', () => { expect(mocks.fetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' }) expect(mocks.setAppDetail).toHaveBeenCalledWith({ id: 'app-1' }) }) - expect(mocks.emit).toHaveBeenCalledWith( - 'collaboration_event', - expect.objectContaining({ - type: 'app_state_update', - timestamp: expect.any(Number), - }), - ) expect(mocks.toast).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully', { type: 'success', }) }) - it('reports a failed result without refreshing or broadcasting stale state', () => { + it('reports a failed result without refreshing stale state', () => { const { result } = renderActions() act(() => result.current.handleResult(new Error('request failed'))) expect(mocks.fetchAppDetail).not.toHaveBeenCalled() - expect(mocks.getSocket).not.toHaveBeenCalled() expect(mocks.toast).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully', { type: 'error', }) diff --git a/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx b/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx index c1be2f4f69a..aa96922b3d6 100644 --- a/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx +++ b/web/app/components/app/access-point/__tests__/web-app-card.spec.tsx @@ -1,10 +1,12 @@ import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils' import type { InputVar, Node } from '@/app/components/workflow/types' -import { screen, waitFor } from '@testing-library/react' +import { toast } from '@langgenius/dify-ui/toast' +import { QueryClientProvider } from '@tanstack/react-query' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { useStore as useAppStore } from '@/app/components/app/store' import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import { AccessMode } from '@/models/access-control' -import { createQueryClientWrapper } from '@/test/console/query-client' import { render } from '@/test/console/render' import { createTestQueryClient } from '@/test/query-client' import { AppModeEnum } from '@/types/app' @@ -12,14 +14,14 @@ import { basePath } from '@/utils/var' import { WebAppAccessPointCard } from '../built-in-access-points/web-app-card' const mocks = vi.hoisted(() => ({ + siteEnable: vi.fn(), resetSiteAccessToken: vi.fn().mockResolvedValue({}), - toastError: vi.fn(), - updateSiteStatus: vi.fn().mockResolvedValue({}), })) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { - error: mocks.toastError, + error: vi.fn(), + success: vi.fn(), }, })) @@ -30,7 +32,7 @@ vi.mock('@/service/client', () => ({ siteEnable: { post: { mutationOptions: (options = {}) => ({ - mutationFn: mocks.updateSiteStatus, + mutationFn: mocks.siteEnable, ...options, }), }, @@ -111,30 +113,67 @@ function renderCard( workflow?: PublishedWorkflow, { canManageAccessPoint = true, - onAppStateChanged = vi.fn().mockResolvedValue(undefined), + onRefreshApp = vi.fn().mockResolvedValue(undefined), }: { canManageAccessPoint?: boolean - onAppStateChanged?: () => Promise + onRefreshApp?: () => Promise } = {}, ) { + useAppStore.setState({ appDetail: createAppInfo(mode) }) const queryClient = createTestQueryClient() - render( + + return render( + + + , + ) +} + +function StoreConnectedWebAppCard({ + availability, + canManageAccessPoint, + onRefreshApp, + workflow, +}: { + availability: 'available' | 'loading' | 'unavailable' + canManageAccessPoint: boolean + onRefreshApp: () => Promise + workflow?: PublishedWorkflow +}) { + const appInfo = useAppStore((state) => state.appDetail) + if (!appInfo) return null + + return ( , - { wrapper: createQueryClientWrapper(queryClient) }, + /> ) } +function createDeferredPromise() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve + reject = promiseReject + }) + + return { promise, reject, resolve } +} + const startNode: Node<{ variables: InputVar[] }> = { id: 'start', position: { x: 0, y: 0 }, @@ -179,6 +218,10 @@ const workflowWithHiddenInput: NonNullable = { describe('WebAppAccessPointCard', () => { beforeEach(() => { vi.clearAllMocks() + mocks.siteEnable.mockResolvedValue({ + enable_site: true, + }) + mocks.resetSiteAccessToken.mockResolvedValue({}) }) afterEach(() => { @@ -216,43 +259,15 @@ describe('WebAppAccessPointCard', () => { it('updates site status through the generated contract', async () => { const user = userEvent.setup() - const onAppStateChanged = vi.fn().mockResolvedValue(undefined) - renderCard(AppModeEnum.CHAT, 'available', undefined, { onAppStateChanged }) + renderCard(AppModeEnum.CHAT) await user.click(screen.getByRole('switch')) await waitFor(() => { - expect(mocks.updateSiteStatus.mock.calls[0]?.[0]).toEqual({ + expect(mocks.siteEnable.mock.calls[0]?.[0]).toEqual({ params: { app_id: 'app-1' }, body: { enable_site: false }, }) - expect(onAppStateChanged).toHaveBeenCalledTimes(1) - }) - }) - - it('keeps the status switch pending until the app state refresh completes', async () => { - const user = userEvent.setup() - let resolveRefresh: (() => void) | undefined - const refresh = new Promise((resolve) => { - resolveRefresh = resolve - }) - const onAppStateChanged = vi.fn(() => refresh) - renderCard(AppModeEnum.CHAT, 'available', undefined, { onAppStateChanged }) - - const statusSwitch = screen.getByRole('switch') - await user.click(statusSwitch) - - await waitFor(() => { - expect(onAppStateChanged).toHaveBeenCalledTimes(1) - expect(statusSwitch).toHaveAttribute('aria-busy', 'true') - expect(statusSwitch).toHaveAttribute('aria-disabled', 'true') - }) - - resolveRefresh?.() - - await waitFor(() => { - expect(statusSwitch).not.toHaveAttribute('aria-busy', 'true') - expect(statusSwitch).not.toHaveAttribute('aria-disabled', 'true') }) }) @@ -262,13 +277,13 @@ describe('WebAppAccessPointCard', () => { await user.click(screen.getByRole('switch')) - expect(mocks.updateSiteStatus).not.toHaveBeenCalled() + expect(mocks.siteEnable).not.toHaveBeenCalled() }) it('resets the site access token through the generated contract', async () => { const user = userEvent.setup() - const onAppStateChanged = vi.fn().mockResolvedValue(undefined) - renderCard(AppModeEnum.CHAT, 'available', undefined, { onAppStateChanged }) + const onRefreshApp = vi.fn().mockResolvedValue(undefined) + renderCard(AppModeEnum.CHAT, 'available', undefined, { onRefreshApp }) await user.click(screen.getByRole('button', { name: /overview\.appInfo\.regenerate/ })) await user.click(screen.getByRole('button', { name: /operation\.confirm/ })) @@ -277,23 +292,21 @@ describe('WebAppAccessPointCard', () => { expect(mocks.resetSiteAccessToken.mock.calls[0]?.[0]).toEqual({ params: { app_id: 'app-1' }, }) - expect(onAppStateChanged).toHaveBeenCalledTimes(1) + expect(onRefreshApp).toHaveBeenCalledTimes(1) }) }) it('keeps generated mutation failures inside the card owner', async () => { const user = userEvent.setup() const error = new Error('request failed') - const onAppStateChanged = vi.fn().mockResolvedValue(undefined) - mocks.updateSiteStatus.mockRejectedValueOnce(error) - renderCard(AppModeEnum.CHAT, 'available', undefined, { onAppStateChanged }) + mocks.siteEnable.mockRejectedValueOnce(error) + renderCard(AppModeEnum.CHAT) await user.click(screen.getByRole('switch')) await waitFor(() => { - expect(mocks.toastError).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully') + expect(toast.error).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully') }) - expect(onAppStateChanged).not.toHaveBeenCalled() }) it('passes hidden Chatflow inputs to the embed dialog', async () => { @@ -333,6 +346,53 @@ describe('WebAppAccessPointCard', () => { ).not.toBeInTheDocument() }) + it('does not announce an unavailable access control entry as loading', () => { + renderCard(AppModeEnum.WORKFLOW, 'unavailable') + + const card = screen.getByRole('region', { name: /webApp\.title/ }) + expect(within(card).queryByRole('status', { name: 'common.loading' })).not.toBeInTheDocument() + }) + + it('optimistically serializes status changes without a success toast', async () => { + const user = userEvent.setup() + const firstToggle = createDeferredPromise<{ enable_site: boolean }>() + const secondToggle = createDeferredPromise<{ enable_site: boolean }>() + mocks.siteEnable + .mockReturnValueOnce(firstToggle.promise) + .mockReturnValueOnce(secondToggle.promise) + renderCard(AppModeEnum.CHAT) + + const accessSwitch = screen.getByRole('switch') + await user.click(accessSwitch) + + expect(accessSwitch).toHaveAttribute('aria-checked', 'false') + expect(mocks.siteEnable).toHaveBeenCalledTimes(1) + + await user.click(accessSwitch) + + expect(accessSwitch).toHaveAttribute('aria-checked', 'true') + expect(mocks.siteEnable).toHaveBeenCalledTimes(1) + expect( + screen.queryByRole('link', { name: /studio\.accessPoint\.open/ }), + ).not.toBeInTheDocument() + + firstToggle.resolve({ enable_site: false }) + + await waitFor(() => { + expect(mocks.siteEnable).toHaveBeenCalledTimes(2) + }) + expect(mocks.siteEnable.mock.calls[1]?.[0]).toEqual({ + body: { enable_site: true }, + params: { app_id: 'app-1' }, + }) + + secondToggle.resolve({ enable_site: true }) + + await screen.findByRole('link', { name: /studio\.accessPoint\.open/ }) + expect(accessSwitch).toHaveAttribute('aria-checked', 'true') + expect(toast.success).not.toHaveBeenCalled() + }) + it('disables Web App management actions without Access Point management', () => { renderCard(AppModeEnum.CHAT, 'available', undefined, { canManageAccessPoint: false }) diff --git a/web/app/components/app/access-point/built-in-access-points/index.tsx b/web/app/components/app/access-point/built-in-access-points/index.tsx index b516f0d5b45..1713f41cdbd 100644 --- a/web/app/components/app/access-point/built-in-access-points/index.tsx +++ b/web/app/components/app/access-point/built-in-access-points/index.tsx @@ -1,6 +1,6 @@ 'use client' -import type { AccessPoint } from '@/app/components/app/deploy/access-point' +import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point' import { Button, buttonVariants } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useSuspenseQuery } from '@tanstack/react-query' @@ -112,7 +112,6 @@ export function BuiltInAccessPoints({ canManageAccess={canReleaseAndVersion} canManageAccessPoint={canManageAccessPoint} showAccessControl={systemFeatures.webapp_auth.enabled} - onAppStateChanged={actions.handleAppStateChanged} onRefreshApp={actions.refreshAppDetail} onSaveSiteConfig={actions.saveSiteConfig} workflow={workflow} @@ -122,7 +121,6 @@ export function BuiltInAccessPoints({ appInfo={appInfo} availability={appCardAvailability} canManage={canManageAccessPoint} - onAppStateChanged={actions.handleAppStateChanged} highlighted={highlightedAccessPoint === 'serviceApi'} /> )} diff --git a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx index dbca8400485..a3cd201d887 100644 --- a/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/mcp-card.tsx @@ -11,19 +11,22 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' +import { useMutation } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { AccessPointCard } from '@/app/components/base/access-point/card' +import { AccessPointUrl } from '@/app/components/base/access-point/url' import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal' import { BlockEnum } from '@/app/components/workflow/types' +import { consoleQuery } from '@/service/client' import { useInvalidateMCPServerDetail, useMCPServerDetail, useRefreshMCPServerCode, - useUpdateMCPServer, } from '@/service/use-tools' import { AppModeEnum } from '@/types/app' -import { AccessPointCard } from '../shared/access-point-card' -import { AccessPointUrl } from '../shared/access-point-url' +import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label' import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils' type MCPAccessPointCardProps = { @@ -49,20 +52,33 @@ export function MCPAccessPointCard({ const workflowApp = appInfo.mode === AppModeEnum.WORKFLOW const [showServerModal, setShowServerModal] = useState(false) const [showRegenerate, setShowRegenerate] = useState(false) - const [pendingStatus, setPendingStatus] = useState(null) const basicConfig = appInfo.model_config const basicAppInputForm = basicConfig?.user_input_form const { data: detail, isPending: serverDetailLoading } = useMCPServerDetail( appInfo.id, Boolean(appInfo.id), ) - const { mutateAsync: updateServer, isPending: statusUpdating } = useUpdateMCPServer() - const { mutateAsync: refreshServerCode, isPending: regenerating } = useRefreshMCPServerCode() const invalidateServerDetail = useInvalidateMCPServerDetail() + const updateServerMutation = useMutation( + consoleQuery.apps.byAppId.server.put.mutationOptions({ + scope: { + id: `app-mcp-toggle:${appInfo.id}`, + }, + onSuccess: () => invalidateServerDetail(appInfo.id), + onError: () => { + toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) + }, + }), + ) + const { mutateAsync: refreshServerCode, isPending: regenerating } = useRefreshMCPServerCode() const serverPublished = Boolean(detail?.id) const serverActivated = detail?.status === 'active' - const activated = pendingStatus ?? serverActivated + const pendingStatus = updateServerMutation.variables?.body.status + const activated = + updateServerMutation.isPending && pendingStatus != null + ? pendingStatus === 'active' + : serverActivated const serverUrl = serverPublished ? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${detail?.server_code}/mcp` : '***********' @@ -98,26 +114,24 @@ export function MCPAccessPointCard({ ) }, [advancedApp, basicAppInputs, workflowNodes]) - const handleStatusChange = async (enabled: boolean) => { + const handleStatusChange = (enabled: boolean) => { if (!canManageAccessPoint || loading || unavailable) return if (enabled && !serverPublished) { setShowServerModal(true) return } - setPendingStatus(enabled) - try { - await updateServer({ - appID: appInfo.id, + updateServerMutation.mutate({ + params: { + app_id: appInfo.id, + }, + body: { id: detail?.id || '', description: detail?.description || '', parameters: detail?.parameters || {}, status: enabled ? 'active' : 'inactive', - }) - invalidateServerDetail(appInfo.id) - } finally { - setPendingStatus(null) - } + }, + }) } const handleRegenerate = async () => { @@ -134,6 +148,7 @@ export function MCPAccessPointCard({ : activated ? 'inService' : 'disabled' + const statusLabel = useAccessPointStatusLabel(status) return ( <> @@ -144,10 +159,10 @@ export function MCPAccessPointCard({ })} icon="i-custom-vender-integrations-mcp" status={status} + statusLabel={statusLabel} highlighted={highlighted} switchDisabled={!canManageAccessPoint} switchLabel={t(($) => $['mcp.server.title'], { ns: 'tools' })} - switchLoading={statusUpdating} onEnabledChange={loading || unavailable ? undefined : handleStatusChange} actions={

- {triggers.map((trigger) => { - const enabled = trigger.status === 'enabled' - const statusLabel = enabled - ? t(($) => $['agentDetail.access.status.inService'], { - ns: 'agentV2', - }) - : t(($) => $['overview.status.disable'], { - ns: 'appOverview', - }) - - return ( -
- - - {trigger.title} - - - {trigger.provider_name} - - - - {statusLabel} - - void toggleTrigger(trigger, nextEnabled)} - /> -
- ) - })} + {triggers.map((trigger) => ( + + ))}
)} diff --git a/web/app/components/app/access-point/built-in-access-points/web-app-card.tsx b/web/app/components/app/access-point/built-in-access-points/web-app-card.tsx index a1183897442..787db1dc263 100644 --- a/web/app/components/app/access-point/built-in-access-points/web-app-card.tsx +++ b/web/app/components/app/access-point/built-in-access-points/web-app-card.tsx @@ -1,9 +1,9 @@ 'use client' import type { SelectorParam } from 'i18next' -import type { AccessPointAvailability } from '../shared/access-point-status' import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils' import type { ConfigParams } from '@/app/components/app/overview/settings' +import type { AccessPointAvailability } from '@/app/components/base/access-point/status' import { AlertDialog, AlertDialogActions, @@ -23,16 +23,21 @@ import CustomizeModal from '@/app/components/app/overview/customize' import EmbeddedModal from '@/app/components/app/overview/embedded' import SettingsModal from '@/app/components/app/overview/settings' import { WorkflowLaunchDialog } from '@/app/components/app/overview/workflow-launch-dialog' +import { useStore as useAppStore } from '@/app/components/app/store' +import { AccessPointCard } from '@/app/components/base/access-point/card' +import { getAccessPointStatus } from '@/app/components/base/access-point/status' +import { AccessPointUrl } from '@/app/components/base/access-point/url' import AppIcon from '@/app/components/base/app-icon' import { AccessMode } from '@/models/access-control' import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control' import { consoleQuery } from '@/service/client' import { AppModeEnum } from '@/types/app' -import { AccessPointCard } from '../shared/access-point-card' -import { getAccessPointStatus } from '../shared/access-point-status' -import { AccessPointUrl } from '../shared/access-point-url' +import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label' import { getBuiltInAccessUrls, getHiddenStartInputs } from '../shared/utils' -import { WebAppAccessControlEntry } from '../shared/web-app-access-control' +import { + WebAppAccessControlEntry, + WebAppAccessControlEntrySkeleton, +} from '../shared/web-app-access-control' const ACCESS_MODE_ICON_MAP: Record = { [AccessMode.ORGANIZATION]: 'i-ri-building-line', @@ -56,7 +61,6 @@ type WebAppAccessPointCardProps = { canManageAccessPoint: boolean highlighted?: boolean showAccessControl: boolean - onAppStateChanged: () => Promise onRefreshApp: () => Promise onSaveSiteConfig: (params: ConfigParams) => Promise workflow: PublishedWorkflow @@ -69,22 +73,34 @@ export function WebAppAccessPointCard({ canManageAccess, canManageAccessPoint, highlighted, - onAppStateChanged, onRefreshApp, onSaveSiteConfig, showAccessControl, workflow, }: WebAppAccessPointCardProps) { const { t } = useTranslation() + const setAppDetail = useAppStore((state) => state.setAppDetail) const [showSettings, setShowSettings] = useState(false) const [showEmbedded, setShowEmbedded] = useState(false) const [showCustomize, setShowCustomize] = useState(false) const [showAccess, setShowAccess] = useState(false) const [showRegenerate, setShowRegenerate] = useState(false) const [showWorkflowLaunch, setShowWorkflowLaunch] = useState(false) - const updateSiteStatus = useMutation( + const toggleSiteMutation = useMutation( consoleQuery.apps.byAppId.siteEnable.post.mutationOptions({ - onSuccess: onAppStateChanged, + scope: { + id: `app-web-app-toggle:${appInfo.id}`, + }, + onSuccess: (updatedApp) => { + const currentAppDetail = useAppStore.getState().appDetail + if (!currentAppDetail || currentAppDetail.id !== appInfo.id) return + + setAppDetail({ + ...currentAppDetail, + enable_site: updatedApp.enable_site, + updated_at: updatedApp.updated_at ?? currentAppDetail.updated_at, + }) + }, onError: () => { toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' })) }, @@ -93,7 +109,7 @@ export function WebAppAccessPointCard({ const resetSiteAccessToken = useMutation( consoleQuery.apps.byAppId.site.accessTokenReset.post.mutationOptions({ onSuccess: async () => { - await onAppStateChanged() + await onRefreshApp() setShowRegenerate(false) }, onError: () => { @@ -103,7 +119,13 @@ export function WebAppAccessPointCard({ }), ) const { webApp: webAppUrl } = getBuiltInAccessUrls(appInfo) - const running = availability === 'available' && appInfo.enable_site + const pendingEnabled = toggleSiteMutation.variables?.body.enable_site + const optimisticEnabled = + toggleSiteMutation.isPending && pendingEnabled !== undefined + ? pendingEnabled + : appInfo.enable_site + const running = availability === 'available' && optimisticEnabled + const actionsAvailable = running && !toggleSiteMutation.isPending const supportsEmbedded = appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW const hiddenLaunchVariables = getHiddenStartInputs(workflow) @@ -120,22 +142,27 @@ export function WebAppAccessPointCard({ appInfo.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS || Boolean(accessSubjects?.groups?.length || accessSubjects?.members?.length) - const handleStatusChange = (enabled: boolean) => { - if (!canManageAccessPoint) return - - updateSiteStatus.mutate({ - params: { app_id: appInfo.id }, - body: { enable_site: enabled }, - }) - } - const handleRegenerate = () => { if (!canManageAccessPoint || resetSiteAccessToken.isPending) return resetSiteAccessToken.mutate({ params: { app_id: appInfo.id } }) } + const handleEnabledChange = (enabled: boolean) => { + if (!canManageAccessPoint) return + + toggleSiteMutation.mutate({ + params: { + app_id: appInfo.id, + }, + body: { + enable_site: enabled, + }, + }) + } + const status = getAccessPointStatus(availability, running) + const statusLabel = useAccessPointStatusLabel(status) return ( <> @@ -154,18 +181,18 @@ export function WebAppAccessPointCard({ /> } status={status} + statusLabel={statusLabel} highlighted={highlighted} switchDisabled={!canManageAccessPoint} switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })} - switchLoading={updateSiteStatus.isPending} - onEnabledChange={availability === 'available' ? handleStatusChange : undefined} + onEnabledChange={availability === 'available' ? handleEnabledChange : undefined} actions={ <> {hiddenLaunchVariables.length > 0 && ( - ) : ( -
- - -
- )} + {!accessConfigured && ( + + {t(($) => $['publishApp.notSet'], { ns: 'app' })} + + )} +
+ +
+ ) } diff --git a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx index d41d63422eb..688ea7212fa 100644 --- a/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/add-member-or-group-pop.spec.tsx @@ -1,10 +1,12 @@ import type { AccessControlSubjects } from '../specific-groups-or-members' import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' +import { RadioGroup } from '@langgenius/dify-ui/radio-group' import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' -import { SubjectType } from '@/models/access-control' +import { AccessMode, SubjectType } from '@/models/access-control' import { renderWithAccountProfile as render } from '@/test/console/account-profile' +import AccessControlItem from '../access-control-item' import AddMemberOrGroupDialog from '../add-member-or-group-pop' const mockUseSearchForWhiteListCandidates = vi.fn() @@ -50,6 +52,20 @@ function ControlledDialog({ return } +function DialogInsideAccessOption() { + return ( + {}} + > + + + + + ) +} + describe('AddMemberOrGroupDialog', () => { const baseGroup = createGroup() const baseMember = createMember() @@ -142,6 +158,25 @@ describe('AddMemberOrGroupDialog', () => { expect(memberToggle).toHaveAttribute('aria-pressed', 'true') }) + it('should stay open when expanding a group inside an access option', async () => { + const user = userEvent.setup() + render() + + const addButton = screen.getByRole('button', { name: 'common.operation.add' }) + await user.click(addButton) + await user.click( + screen.getByRole('button', { + name: 'app.accessControlDialog.operateGroupAndMember.expand', + }), + ) + + expect(addButton).toHaveAttribute('aria-expanded', 'true') + expect(mockUseSearchForWhiteListCandidates).toHaveBeenLastCalledWith( + expect.objectContaining({ groupId: baseGroup.id }), + true, + ) + }) + it('should show the empty state when no candidates are returned', async () => { mockUseSearchForWhiteListCandidates.mockReturnValue({ isLoading: false, diff --git a/web/app/components/app/app-access-control/add-member-or-group-pop/index.tsx b/web/app/components/app/app-access-control/add-member-or-group-pop/index.tsx index 455e223057e..ebd3b76327a 100644 --- a/web/app/components/app/app-access-control/add-member-or-group-pop/index.tsx +++ b/web/app/components/app/app-access-control/add-member-or-group-pop/index.tsx @@ -113,11 +113,7 @@ export default function AddMemberOrGroupDialog({ ? selectedAccessSubjects.groups.some((group) => group.id === subject.subjectId) : selectedAccessSubjects.members.some((member) => member.id === subject.subjectId) - const statusText = isLoading - ? t(($) => $.loading, { ns: 'common' }) - : hasResults - ? null - : noResultLabel + const statusText = hasResults ? null : noResultLabel return ( @@ -137,14 +133,16 @@ export default function AddMemberOrGroupDialog({ event.stopPropagation()} > {searchLabel} - + @@ -184,16 +182,15 @@ export default function AddMemberOrGroupDialog({ aria-live="polite" aria-atomic="true" className={ - statusText ? 'flex min-h-7 items-center justify-center px-2 py-0.5' : 'h-0' + isLoading || statusText + ? 'flex min-h-7 items-center justify-center px-2 py-0.5 system-sm-regular text-text-tertiary' + : 'h-0' } > {isLoading ? ( - <> - {statusText} - - + ) : ( statusText )} diff --git a/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx b/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx index 4337890d40e..e151473db72 100644 --- a/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/environment-deployment-flow.spec.tsx @@ -4,10 +4,10 @@ import type { QueryClient } from '@tanstack/react-query' import { DeploymentOperationStatus, DeploymentOperationType, - DeploymentStatus, EnvironmentStatus, EnvVarValueType, OperatorType, + RuntimeState, } from '@dify/contracts/enterprise-app-deploy/types.gen' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -140,11 +140,11 @@ const latestVersion = { function createDeployment({ deployed = true, latest = false, - status = DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState = RuntimeState.RUNTIME_STATE_RUNNING, }: { deployed?: boolean latest?: boolean - status?: NonNullable['status'] + runtimeState?: NonNullable['runtimeState'] } = {}): EnvironmentDeployment { const currentVersion = latest ? { @@ -173,7 +173,7 @@ function createDeployment({ id: 'user-1', type: OperatorType.OPERATOR_TYPE_ACCOUNT, }, - status, + runtimeState, versions_behind: latest ? 0 : 1, }, environment: { @@ -219,7 +219,7 @@ function createFlowQueryClient(environmentId: string, environmentInUse = false) queryClient.setQueryDefaults(deploymentOptionsQuery.queryKey, { staleTime: Infinity }) queryClient.setQueryData(deploymentOptionsQuery.queryKey, { credential_slots: [], - environment_variable_slots: [], + environment_variable_groups: [], }) }) @@ -291,7 +291,12 @@ function renderFlow( { canViewAccessPoint = true, isDeploymentError = false, - }: { canViewAccessPoint?: boolean; isDeploymentError?: boolean } = {}, + onConfigurationOpenChange = vi.fn(), + }: { + canViewAccessPoint?: boolean + isDeploymentError?: boolean + onConfigurationOpenChange?: (open: boolean) => void + } = {}, ) { const queryClient = createFlowQueryClient(deployment.environment.id) @@ -307,6 +312,7 @@ function renderFlow( isDeploymentError={isDeploymentError} isDeploymentLoading={false} latestVersion={latestVersion} + onConfigurationOpenChange={onConfigurationOpenChange} onGoToPublish={vi.fn()} />, { queryClient }, @@ -461,11 +467,31 @@ async function expectDeploymentRequest( ) expect(await deployRequest.json()).toEqual({ credentials: [], - environment_variables: [], + environment_variable_groups: [], }) } describe('PublisherEnvironmentFlow', () => { + it('shows the shared loading state while deployment details load', () => { + render( + Environment tabs} + isEnvironmentInUse={false} + isDeploymentError={false} + isDeploymentLoading + latestVersion={null} + onGoToPublish={vi.fn()} + />, + ) + + expect(screen.getByText('Environment tabs')).toBeInTheDocument() + expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument() + }) + it('formats deployed_at as a Unix timestamp in seconds', () => { const deployment = createDeployment() const deployedAt = deployment.deployment?.deployed_at @@ -554,6 +580,7 @@ describe('PublisherEnvironmentFlow', () => { await user.click(screen.getByRole('button', { name: 'All versions' })) expect(screen.getByRole('heading', { name: 'Deploy to Staging' })).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Deploy to Staging' })).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Back' })) @@ -561,17 +588,12 @@ describe('PublisherEnvironmentFlow', () => { expect(screen.getByRole('button', { name: 'All versions' })).toBeInTheDocument() }) - it.each([ - DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, - DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING, - ])( - 'disables deployment triggers but keeps environment navigation available while the status is %s', - (status) => { - renderFlow(createDeployment({ deployed: false, status })) + it.each([RuntimeState.RUNTIME_STATE_STARTING, RuntimeState.RUNTIME_STATE_STOPPING])( + 'disables deployment triggers but keeps environment navigation available while the state is %s', + (runtimeState) => { + renderFlow(createDeployment({ deployed: false, runtimeState })) - const deployButtonName = - status === DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING ? 'Deploying...' : 'Deploy latest' - expect(screen.getByRole('button', { name: deployButtonName })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeDisabled() expect(screen.getByRole('button', { name: 'All versions' })).toBeDisabled() expect(screen.getByRole('link', { name: 'Access Point' })).toHaveAttribute( 'href', @@ -596,7 +618,7 @@ describe('PublisherEnvironmentFlow', () => { it('keeps the deployment target and progress controls when a deploying status refresh fails', () => { const deployment = createDeployment({ - status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, }) deployment.deployment!.latest_operation = { activity_at: 1_785_456_000, @@ -638,16 +660,16 @@ describe('PublisherEnvironmentFlow', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) - it.each([ - DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYED, - DeploymentStatus.DEPLOYMENT_STATUS_FAILED, - ])('shows the undeployed state when terminal status %s has no current version', (status) => { - renderFlow(createDeployment({ deployed: false, status })) + it.each([RuntimeState.RUNTIME_STATE_UNDEPLOYED, RuntimeState.RUNTIME_STATE_ERROR])( + 'shows the undeployed state when terminal state %s has no current version', + (runtimeState) => { + renderFlow(createDeployment({ deployed: false, runtimeState })) - expect(screen.getByText('Not deployed yet')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeEnabled() - expect(screen.getByRole('button', { name: 'All versions' })).toBeEnabled() - }) + expect(screen.getByText('Not deployed yet')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'All versions' })).toBeEnabled() + }, + ) it('deploys the latest version directly and goes back to version selection', async () => { const user = userEvent.setup() @@ -663,6 +685,29 @@ describe('PublisherEnvironmentFlow', () => { expect(screen.getByRole('heading', { name: 'Deploy to Staging' })).toBeInTheDocument() }) + it('reports whether deployment configuration is active', async () => { + const user = userEvent.setup() + const onConfigurationOpenChange = vi.fn() + const view = renderFlow(createDeployment(), { onConfigurationOpenChange }) + + await user.click(screen.getByRole('button', { name: 'Deploy latest' })) + expect(onConfigurationOpenChange).toHaveBeenLastCalledWith(true) + + await user.click(screen.getByRole('button', { name: 'Back' })) + expect(onConfigurationOpenChange).toHaveBeenLastCalledWith(false) + + await user.click(screen.getByRole('button', { name: /Release 6/ })) + expect(onConfigurationOpenChange).toHaveBeenLastCalledWith(true) + + await user.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onConfigurationOpenChange).toHaveBeenLastCalledWith(false) + + await user.click(screen.getByRole('button', { name: 'Deploy latest' })) + expect(onConfigurationOpenChange).toHaveBeenLastCalledWith(true) + view.unmount() + expect(onConfigurationOpenChange).toHaveBeenLastCalledWith(false) + }) + it('hides the environment variables section when deployment options have no slots', async () => { const user = userEvent.setup() renderFlow() @@ -691,14 +736,26 @@ describe('PublisherEnvironmentFlow', () => { ) view.queryClient.setQueryData(deploymentOptionsQuery.queryKey, { credential_slots: [], - environment_variable_slots: [ + environment_variable_groups: [ { - configured_value: 'production', - description: '', - has_configured_value: true, - has_last_deployed_value: false, - key: 'ENVIRONMENT', - value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING, + environment_variable_slots: [ + { + configured_value: 'production', + description: '', + has_configured_value: true, + has_last_deployed_value: false, + key: 'ENVIRONMENT', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING, + }, + ], + from_app: { + app_id: 'app-1', + icon: '💰', + icon_background: '#FDF2FA', + icon_type: 'emoji', + name: 'Finance APP', + workflow_id: latestVersion.id, + }, }, ], }) diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index d318a19802f..bb9766c54fd 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -1,8 +1,5 @@ /* oxlint-disable typescript/no-explicit-any */ -import { - DeploymentStatus, - EnvironmentStatus, -} from '@dify/contracts/enterprise-app-deploy/types.gen' +import { EnvironmentStatus, RuntimeState } from '@dify/contracts/enterprise-app-deploy/types.gen' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' @@ -458,6 +455,8 @@ describe('AppPublisher', () => { expect(mockUpdateWorkflow).toHaveBeenCalledWith( { + appId: 'app-1', + appMode: AppModeEnum.WORKFLOW, url: '/apps/app-1/workflows/workflow-version-5', title: 'Release 6', releaseNotes: 'Updated notes', @@ -726,7 +725,7 @@ describe('AppPublisher', () => { marked_name: 'Release 5', version: 'v5', }, - status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_STARTING, }, environment: { description: '', diff --git a/web/app/components/app/app-publisher/__tests__/publisher-panel.spec.tsx b/web/app/components/app/app-publisher/__tests__/publisher-panel.spec.tsx new file mode 100644 index 00000000000..5f3a9d7ffd5 --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/publisher-panel.spec.tsx @@ -0,0 +1,118 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { PublisherPanel } from '../publisher-content/publisher-panel' + +vi.mock('../environment-deployment-flow', () => ({ + PublisherEnvironmentFlow: ({ + onConfigurationOpenChange, + }: { + onConfigurationOpenChange?: (open: boolean) => void + }) => ( +
+ Environment publisher + +
+ ), +})) + +function PublisherPanelHarness() { + const [open, setOpen] = useState(true) + + return ( + <> + + '', + handlePublish: vi.fn(), + handleRestore: vi.fn(), + isChatApp: false, + published: false, + upgradeHighlightStyle: {}, + }, + }} + environmentPublisher={{ + appId: 'app-1', + canViewAccessPoint: false, + environmentId: 'staging', + environmentName: 'Staging', + environmentTabs: null, + isEnvironmentInUse: true, + isDeploymentError: false, + isDeploymentLoading: false, + onGoToPublish: vi.fn(), + }} + environmentPublisherKey="staging" + open={open} + showBuiltInPublisher={false} + workflowLaunch={{ + hiddenVariables: [], + open: false, + targetUrl: '', + onOpenChange: vi.fn(), + }} + onOpenChange={setOpen} + /> + + ) +} + +describe('PublisherPanel', () => { + it('keeps the publisher open after an outside press when dismissal is prevented', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Configure deployment' })) + + await user.click(screen.getByRole('button', { name: 'Outside control' })) + + expect(screen.getByText('Environment publisher')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /common\.publish/ })).toHaveAttribute( + 'aria-expanded', + 'true', + ) + }) + + it('keeps the default outside-press dismissal outside deployment configuration', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Outside control' })) + + expect(screen.queryByText('Environment publisher')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /common\.publish/ })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + }) + + it('still closes from the trigger and Escape when outside dismissal is prevented', async () => { + const user = userEvent.setup() + render() + const publishButton = screen.getByRole('button', { name: /common\.publish/ }) + + await user.click(screen.getByRole('button', { name: 'Configure deployment' })) + await user.click(publishButton) + expect(screen.queryByText('Environment publisher')).not.toBeInTheDocument() + + await user.click(publishButton) + expect(screen.getByText('Environment publisher')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Configure deployment' })) + + await user.keyboard('{Escape}') + + expect(screen.queryByText('Environment publisher')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/app-publisher/__tests__/state.spec.tsx b/web/app/components/app/app-publisher/__tests__/state.spec.tsx index 5cdba042d59..624205f869a 100644 --- a/web/app/components/app/app-publisher/__tests__/state.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/state.spec.tsx @@ -1,6 +1,6 @@ import { DeploymentOperationStatus, - DeploymentStatus, + RuntimeState, } from '@dify/contracts/enterprise-app-deploy/types.gen' import { QueryClientProvider } from '@tanstack/react-query' import { screen, render as testingLibraryRender, waitFor } from '@testing-library/react' @@ -32,7 +32,7 @@ type QueryOptions = { id: string status: string } - status: string + runtimeState: string } } } @@ -204,11 +204,11 @@ function renderState(initialOpen = true) { } function environmentDeploymentResponse({ - deploymentStatus, + runtimeState, operationId, operationStatus, }: { - deploymentStatus: string + runtimeState: string operationId: string operationStatus: string }) { @@ -220,7 +220,7 @@ function environmentDeploymentResponse({ }, deployment: { current_version: - deploymentStatus === DeploymentStatus.DEPLOYMENT_STATUS_RUNNING + runtimeState === RuntimeState.RUNTIME_STATE_RUNNING ? { id: 'version-development', marked_comment: '', @@ -239,7 +239,7 @@ function environmentDeploymentResponse({ status: operationStatus, type: 'DEPLOYMENT_OPERATION_TYPE_DEPLOY', }, - status: deploymentStatus, + runtimeState, }, environment: { description: '', @@ -316,7 +316,7 @@ describe('app publisher environment state', () => { marked_name: `Release ${input.params.environment_id}`, version: `2026-07-31.${input.params.environment_id}`, }, - status: 'DEPLOYMENT_STATUS_RUNNING', + runtimeState: 'RUNTIME_STATE_RUNNING', }, environment: { description: '', @@ -361,7 +361,7 @@ describe('app publisher environment state', () => { it('discovers and resumes polling a first deployment while the environment remains not in use', async () => { const user = userEvent.setup() const response = environmentDeploymentResponse({ - deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_STARTING, operationId: 'operation-development', operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, }) @@ -394,7 +394,7 @@ describe('app publisher environment state', () => { it('stops automatic status polling while the deployment query is failing', async () => { const user = userEvent.setup() const response = environmentDeploymentResponse({ - deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_STARTING, operationId: 'operation-staging', operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, }) @@ -465,7 +465,7 @@ describe('app publisher environment state', () => { .mockResolvedValue(appEnvironments(true)) queryMocks.deploymentRequest.mockResolvedValue( environmentDeploymentResponse({ - deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, operationId: 'operation-development', operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED, }), @@ -497,7 +497,7 @@ describe('app publisher environment state', () => { deploymentQueryOptions?.refetchInterval?.({ state: { data: environmentDeploymentResponse({ - deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, operationId: 'operation-development', operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, }), @@ -508,7 +508,7 @@ describe('app publisher environment state', () => { deploymentQueryOptions?.refetchInterval?.({ state: { data: environmentDeploymentResponse({ - deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, operationId: 'operation-development', operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED, }), @@ -525,12 +525,12 @@ describe('app publisher environment state', () => { async (input: { params: { environment_id: string } }) => input.params.environment_id === 'staging' ? environmentDeploymentResponse({ - deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_STARTING, operationId: 'operation-staging', operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, }) : environmentDeploymentResponse({ - deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, operationId: 'operation-development', operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED, }), @@ -551,7 +551,7 @@ describe('app publisher environment state', () => { getDeploymentQueryOptions('staging')?.refetchInterval?.({ state: { data: environmentDeploymentResponse({ - deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_STARTING, operationId: 'operation-staging', operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, }), @@ -577,7 +577,7 @@ describe('app publisher environment state', () => { getDeploymentQueryOptions('development')?.refetchInterval?.({ state: { data: environmentDeploymentResponse({ - deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, operationId: 'operation-development', operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED, }), @@ -608,7 +608,7 @@ describe('app publisher environment state', () => { queryMocks.environmentRequest.mockResolvedValue(appEnvironments(true)) queryMocks.deploymentRequest.mockResolvedValue( environmentDeploymentResponse({ - deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_STARTING, operationId: 'operation-staging', operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, }), diff --git a/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx b/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx index 995f412b27d..4916eb37320 100644 --- a/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx +++ b/web/app/components/app/app-publisher/environment-deployment-flow/index.tsx @@ -2,10 +2,11 @@ import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' import type { ReactNode } from 'react' -import type { DeploymentVersion } from '@/app/components/app/deploy/version' +import type { DeploymentVersion } from '@/app/components/app/deploy/utils/version' import { useAtomValue, useSetAtom } from 'jotai' import { useTranslation } from 'react-i18next' -import { EnvironmentDeploymentFlow } from '@/app/components/app/deploy/environment-deployment-flow' +import { EnvironmentDeploymentFlow } from '@/app/components/app/deploy/shared/environment-deployment-flow' +import Loading from '@/app/components/base/loading' import { publisherEnvironmentDeploymentPollingAtom, startPublisherEnvironmentDeploymentPollingAtom, @@ -24,6 +25,7 @@ type PublisherEnvironmentFlowProps = { isDeploymentError: boolean isDeploymentLoading: boolean latestVersion?: DeploymentVersion | null + onConfigurationOpenChange?: (open: boolean) => void onGoToPublish: () => void } @@ -38,6 +40,7 @@ export function PublisherEnvironmentFlow({ isDeploymentError, isDeploymentLoading, latestVersion, + onConfigurationOpenChange, onGoToPublish, }: PublisherEnvironmentFlowProps) { const { t } = useTranslation() @@ -48,19 +51,16 @@ export function PublisherEnvironmentFlow({ return (
{environmentTabs} -
- {isDeploymentLoading ? ( - <> - - {t(($) => $.loading, { ns: 'common' })} - - ) : ( - t(($) => $['common.loadFailed'], { ns: 'deployments' }) - )} -
+ {isDeploymentLoading ? ( + + ) : ( +
+ {t(($) => $['common.loadFailed'], { ns: 'deployments' })} +
+ )}
) } @@ -72,6 +72,7 @@ export function PublisherEnvironmentFlow({ disabled={deploymentPolling?.environmentId === environmentId} environmentId={environmentId} environmentName={environmentName} + onConfigurationOpenChange={onConfigurationOpenChange} onDeploymentStarted={(operationId) => { startDeploymentPolling({ environmentId, operationId }) }} diff --git a/web/app/components/app/app-publisher/environment-deployment-flow/latest-version-row.tsx b/web/app/components/app/app-publisher/environment-deployment-flow/latest-version-row.tsx index e8d8c8f9e36..cd2e7580353 100644 --- a/web/app/components/app/app-publisher/environment-deployment-flow/latest-version-row.tsx +++ b/web/app/components/app/app-publisher/environment-deployment-flow/latest-version-row.tsx @@ -1,4 +1,4 @@ -import type { DeploymentVersion } from '@/app/components/app/deploy/version' +import type { DeploymentVersion } from '@/app/components/app/deploy/utils/version' import { cn } from '@langgenius/dify-ui/cn' import { useTranslation } from 'react-i18next' import { PublisherDeployingMarker } from '../publisher-deploying-marker' diff --git a/web/app/components/app/app-publisher/environment-deployment-flow/summary-section.tsx b/web/app/components/app/app-publisher/environment-deployment-flow/summary-section.tsx index 051d2d8f5e3..8d7685a69eb 100644 --- a/web/app/components/app/app-publisher/environment-deployment-flow/summary-section.tsx +++ b/web/app/components/app/app-publisher/environment-deployment-flow/summary-section.tsx @@ -1,7 +1,10 @@ import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' import type { ReactNode } from 'react' -import type { DeploymentVersion } from '@/app/components/app/deploy/version' -import { DeploymentStatus } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { DeploymentVersion } from '@/app/components/app/deploy/utils/version' +import { + DeploymentOperationStatus, + DeploymentOperationType, +} from '@dify/contracts/enterprise-app-deploy/types.gen' import { Button } from '@langgenius/dify-ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' @@ -35,8 +38,11 @@ export function PublisherEnvironmentSummarySection({ const { formatTimeFromNow } = useFormatTimeFromNow() const deploymentState = deployment?.deployment const deployedVersion = deploymentState?.current_version - const isDeploying = deploymentState?.status === DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING - const deployingVersion = deploymentState?.latest_operation?.target_version + const latestOperation = deploymentState?.latest_operation + const isDeploying = + latestOperation?.type === DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_DEPLOY && + latestOperation.status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS + const deployingVersion = latestOperation?.target_version const deployingVersionName = deployingVersion ? getWorkflowVersionName( deployingVersion, diff --git a/web/app/components/app/app-publisher/hooks/use-refresh-app-environments-after-deployment-polling.ts b/web/app/components/app/app-publisher/hooks/use-refresh-app-environments-after-deployment-polling.ts index 64f0a45db2e..de262d0263d 100644 --- a/web/app/components/app/app-publisher/hooks/use-refresh-app-environments-after-deployment-polling.ts +++ b/web/app/components/app/app-publisher/hooks/use-refresh-app-environments-after-deployment-polling.ts @@ -4,7 +4,7 @@ import { DeploymentOperationStatus } from '@dify/contracts/enterprise-app-deploy import { useQueryClient } from '@tanstack/react-query' import { useAtomValue, useSetAtom } from 'jotai' import { useEffect, useRef } from 'react' -import { isEnvironmentDeploymentInProgress } from '@/app/components/app/deploy/state' +import { shouldPollEnvironmentDeployment } from '@/app/components/app/deploy/utils/environment-deployment' import { consoleQuery } from '@/service/client' import { appPublisherOpenAtom, @@ -44,7 +44,7 @@ export function useRefreshAppEnvironmentsAfterPublisherDeploymentPolling(appId?: polling.environmentId !== environment.id || operation?.id !== polling.operationId || !isDeploymentOperationTerminal(operation.status) || - isEnvironmentDeploymentInProgress(deployment) + shouldPollEnvironmentDeployment(deployment) ) return @@ -72,7 +72,7 @@ export function useRefreshAppEnvironmentsAfterPublisherDeploymentPolling(appId?: return } - if (isEnvironmentDeploymentInProgress(deployment)) { + if (shouldPollEnvironmentDeployment(deployment)) { if (operationKey) operationsNeedingEnvironmentRefreshRef.current.add(operationKey) return } diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 465e22a4500..49d626233ed 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -22,7 +22,9 @@ export function AppPublisher(props: AppPublisherProps) { resourceMaintainer: appDetail?.maintainer, workspacePermissionKeys, }) - const supportsMultiEnvironment = appDetail?.mode === AppModeEnum.WORKFLOW && canDeploy + const supportsMultiEnvironment = + (appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.ADVANCED_CHAT) && + canDeploy return ( & { builtInPublisher: ComponentProps - environmentPublisher: ComponentProps + environmentPublisher: Omit< + ComponentProps, + 'onConfigurationOpenChange' + > environmentPublisherKey: string open: boolean showBuiltInPublisher: boolean @@ -29,9 +34,21 @@ export function PublisherPanel({ workflowLaunch, }: PublisherPanelProps) { const { t } = useTranslation() + const [deploymentConfigurationOpen, setDeploymentConfigurationOpen] = useState(false) + const handleOpenChange: NonNullable = (nextOpen, eventDetails) => { + const isOutsideDismiss = + eventDetails.reason === 'outside-press' || eventDetails.reason === 'focus-out' + if (!nextOpen && deploymentConfigurationOpen && isOutsideDismiss) { + eventDetails.cancel() + return + } + + if (!nextOpen) setDeploymentConfigurationOpen(false) + onOpenChange(nextOpen) + } return ( - + @@ -50,7 +67,11 @@ export function PublisherPanel({ {showBuiltInPublisher ? ( ) : ( - + )}
diff --git a/web/app/components/app/app-publisher/publisher-content/use-version-info.ts b/web/app/components/app/app-publisher/publisher-content/use-version-info.ts index a9a23b42d9a..25f2ee00cba 100644 --- a/web/app/components/app/app-publisher/publisher-content/use-version-info.ts +++ b/web/app/components/app/app-publisher/publisher-content/use-version-info.ts @@ -1,4 +1,5 @@ import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen' +import type { AppModeEnum } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -12,10 +13,12 @@ type VersionInfoUpdate = { export function useVersionInfo({ appId, + appMode, publishedWorkflow, onClosePublisher, }: { appId?: string + appMode?: AppModeEnum publishedWorkflow?: WorkflowResponse | null onClosePublisher: () => void }) { @@ -36,6 +39,8 @@ export function useVersionInfo({ updateWorkflow( { + appId, + appMode, url: `/apps/${appId}/workflows/${params.id}`, title: params.title, releaseNotes: params.releaseNotes, diff --git a/web/app/components/app/app-publisher/state.ts b/web/app/components/app/app-publisher/state.ts index bf8937f705d..87bbf47eb44 100644 --- a/web/app/components/app/app-publisher/state.ts +++ b/web/app/components/app/app-publisher/state.ts @@ -6,7 +6,7 @@ import { skipToken } from '@tanstack/react-query' import { atom } from 'jotai' import { atomWithQuery } from 'jotai-tanstack-query' import { selectAtom, useHydrateAtoms } from 'jotai/utils' -import { isEnvironmentDeploymentInProgress } from '@/app/components/app/deploy/state' +import { shouldPollEnvironmentDeployment } from '@/app/components/app/deploy/utils/environment-deployment' import { consoleQuery } from '@/service/client' export const BUILT_IN_ENVIRONMENT_ID = 'built-in' @@ -316,7 +316,7 @@ const selectedEnvironmentDeploymentQueryAtom = atomWithQuery((get) => { const deployment = query.state.data?.environment_deployment if (polling?.environmentId !== environmentId) - return isEnvironmentDeploymentInProgress(deployment) + return shouldPollEnvironmentDeployment(deployment) ? PUBLISHER_DEPLOYMENT_POLLING_INTERVAL : false @@ -324,7 +324,7 @@ const selectedEnvironmentDeploymentQueryAtom = atomWithQuery((get) => { const operationFinished = operation?.id === polling.operationId && isDeploymentOperationTerminal(operation.status) - return operationFinished && !isEnvironmentDeploymentInProgress(deployment) + return operationFinished && !shouldPollEnvironmentDeployment(deployment) ? false : PUBLISHER_DEPLOYMENT_POLLING_INTERVAL }, diff --git a/web/app/components/app/deploy/__tests__/index.spec.tsx b/web/app/components/app/deploy/__tests__/index.spec.tsx index 1f1c8f77862..9a60a3b97b3 100644 --- a/web/app/components/app/deploy/__tests__/index.spec.tsx +++ b/web/app/components/app/deploy/__tests__/index.spec.tsx @@ -4,19 +4,20 @@ import type { EnvironmentDeployment, EnvironmentDeploymentOperation, GetWorkflowDeploymentOptionsResponse, + WorkflowReference, WorkflowVersion, } from '@dify/contracts/enterprise-app-deploy/types.gen' import type { QueryClient } from '@tanstack/react-query' -import type { ReactElement } from 'react' +import type { ComponentProps, ReactElement } from 'react' import { DeploymentOperationStatus, DeploymentOperationType, - DeploymentStatus, EnvironmentStatus, EnvVarValueSource, EnvVarValueType, OperatorType, PluginCategory, + RuntimeState, } from '@dify/contracts/enterprise-app-deploy/types.gen' import { toast } from '@langgenius/dify-ui/toast' import { act, screen, waitFor, within } from '@testing-library/react' @@ -30,7 +31,12 @@ import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console import { AppACLPermission } from '@/utils/permission' import AppDeploy from '..' import { EnvironmentTable } from '../environment-table' -import { AppDeployStateBoundary, getEnvironmentDeploymentActions } from '../state' +import { RuntimeStateIndicator } from '../shared/runtime-state' +import { AppDeployStateBoundary } from '../state' +import { + getEnvironmentDeploymentActions, + shouldPollEnvironmentDeployment, +} from '../utils/environment-deployment' const APP_ID = 'app-1' const ACTIVITY_AT = 1_784_941_200 @@ -115,6 +121,24 @@ const SUCCESSFUL_WORKFLOW_DEPLOYMENT_PRECHECK = { unsupported_nodes: [], } +const ROOT_WORKFLOW_REFERENCE: WorkflowReference = { + app_id: APP_ID, + icon: '💰', + icon_background: '#FDF2FA', + icon_type: 'emoji', + name: 'Finance APP', + workflow_id: 'workflow-version-6', +} + +const SUBWORKFLOW_REFERENCE: WorkflowReference = { + app_id: 'app-workflow-tool', + icon: '🐍', + icon_background: '#F3FEE7', + icon_type: 'emoji', + name: 'Workflow as Tool', + workflow_id: 'workflow-tool', +} + const WORKFLOW_DEPLOYMENT_OPTIONS: GetWorkflowDeploymentOptionsResponse = { credential_slots: [ { @@ -151,32 +175,64 @@ const WORKFLOW_DEPLOYMENT_OPTIONS: GetWorkflowDeploymentOptionsResponse = { category: PluginCategory.PLUGIN_CATEGORY_TOOL, last_deployed_credential_id: 'github-oauth', provider_id: 'github', + workflow_as_tool_dependency: { + paths: [{ workflows: [ROOT_WORKFLOW_REFERENCE, SUBWORKFLOW_REFERENCE] }], + }, }, ], - environment_variable_slots: [ + environment_variable_groups: [ { - configured_value: '2', - description: 'Server port', - has_configured_value: true, - has_last_deployed_value: true, - key: 'PORT', - value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER, + environment_variable_slots: [ + { + configured_value: 2, + description: 'Server port', + has_configured_value: true, + has_last_deployed_value: true, + key: 'PORT', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER, + }, + { + configured_value: 'sk-123************bc', + description: 'API credential', + has_configured_value: true, + has_last_deployed_value: true, + key: 'API_KEY', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET, + }, + { + description: '', + has_configured_value: false, + has_last_deployed_value: true, + key: 'name', + last_deployed_value: 'environment variable 01', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING, + }, + ], + from_app: ROOT_WORKFLOW_REFERENCE, }, { - configured_value: 'sk-123************bc', - description: 'API credential', - has_configured_value: true, - has_last_deployed_value: true, - key: 'API_KEY', - value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET, - }, - { - description: '', - has_configured_value: false, - has_last_deployed_value: true, - key: 'name', - last_deployed_value: 'environment variable 01', - value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING, + environment_variable_slots: [ + { + configured_value: 8080, + description: 'Workflow tool port', + has_configured_value: true, + has_last_deployed_value: false, + key: 'PORT', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER, + }, + { + configured_value: 'sk-child********bc', + description: 'Workflow tool API credential', + has_configured_value: true, + has_last_deployed_value: false, + key: 'API_KEY', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET, + }, + ], + from_workflow_as_tool: { + paths: [{ workflows: [ROOT_WORKFLOW_REFERENCE, SUBWORKFLOW_REFERENCE] }], + workflow: SUBWORKFLOW_REFERENCE, + }, }, ], } @@ -208,7 +264,7 @@ function environmentDeployment({ id, latestOperation, name, - status, + runtimeState, versionsBehind, }: { access?: EnvironmentDeployment['access'] @@ -216,7 +272,7 @@ function environmentDeployment({ id: string latestOperation?: EnvironmentDeploymentOperation name: string - status: NonNullable['status'] + runtimeState: NonNullable['runtimeState'] versionsBehind?: number }): EnvironmentDeployment { return { @@ -226,7 +282,7 @@ function environmentDeployment({ deployed_at: currentVersion ? ACTIVITY_AT : undefined, deployed_by: currentVersion ? OPERATOR : undefined, latest_operation: latestOperation, - status, + runtimeState, versions_behind: versionsBehind, }, environment: { @@ -247,7 +303,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [ targetVersion: VERSIONS.sprint42, }), name: 'Staging', - status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_STARTING, }), environmentDeployment({ currentVersion: VERSIONS.sprint42, @@ -257,7 +313,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [ targetVersion: VERSIONS.sprint42, }), name: 'Canary', - status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, versionsBehind: 0, }), environmentDeployment({ @@ -268,7 +324,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [ targetVersion: VERSIONS.version02, }), name: 'Pre-release', - status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, versionsBehind: 1, }), environmentDeployment({ @@ -279,7 +335,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [ targetVersion: VERSIONS.hotfix, }), name: 'Prod', - status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, versionsBehind: 1, }), environmentDeployment({ @@ -292,7 +348,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [ targetVersion: VERSIONS.sprint42, }), name: 'EU-Prod', - status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, versionsBehind: 2, }), environmentDeployment({ @@ -304,7 +360,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [ targetVersion: VERSIONS.qa, }), name: 'QA', - status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, versionsBehind: 0, }), environmentDeployment({ @@ -316,7 +372,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [ targetVersion: VERSIONS.qa, }), name: 'Sandbox', - status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, versionsBehind: 0, }), environmentDeployment({ @@ -328,7 +384,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [ targetVersion: VERSIONS.sprint42, }), name: 'Preview', - status: DeploymentStatus.DEPLOYMENT_STATUS_FAILED, + runtimeState: RuntimeState.RUNTIME_STATE_UNDEPLOYED, }), ] @@ -367,7 +423,7 @@ const ACTION_MATRIX_CASES: Array<{ currentVersion: VERSIONS.sprint42, id: 'latest', name: 'Latest', - status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, versionsBehind: 0, }), }, @@ -383,7 +439,7 @@ const ACTION_MATRIX_CASES: Array<{ currentVersion: VERSIONS.version02, id: 'behind', name: 'Behind', - status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, versionsBehind: 1, }), }, @@ -393,11 +449,17 @@ const ACTION_MATRIX_CASES: Array<{ { disabled: true, kind: 'redeploy' }, { disabled: true, kind: 'undeploy' }, ], - name: 'deploying', + name: 'upgrading while the current version keeps running', row: environmentDeployment({ + currentVersion: VERSIONS.beta, id: 'deploying', + latestOperation: deploymentOperation({ + id: 'deploying', + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + targetVersion: VERSIONS.sprint42, + }), name: 'Deploying', - status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, }), }, { @@ -410,8 +472,13 @@ const ACTION_MATRIX_CASES: Array<{ row: environmentDeployment({ currentVersion: VERSIONS.qa, id: 'undeploying', + latestOperation: deploymentOperation({ + id: 'undeploying', + status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS, + type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_UNDEPLOY, + }), name: 'Undeploying', - status: DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_STOPPING, }), }, { @@ -428,7 +495,7 @@ const ACTION_MATRIX_CASES: Array<{ targetVersion: VERSIONS.sprint42, }), name: 'Failed', - status: DeploymentStatus.DEPLOYMENT_STATUS_FAILED, + runtimeState: RuntimeState.RUNTIME_STATE_UNDEPLOYED, }), }, { @@ -447,7 +514,7 @@ const ACTION_MATRIX_CASES: Array<{ targetVersion: VERSIONS.sprint42, }), name: 'Running failed', - status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING, + runtimeState: RuntimeState.RUNTIME_STATE_RUNNING, versionsBehind: 2, }), }, @@ -461,7 +528,16 @@ const ACTION_MATRIX_CASES: Array<{ currentVersion: VERSIONS.qa, id: 'invalid', name: 'Invalid', - status: DeploymentStatus.DEPLOYMENT_STATUS_INVALID, + runtimeState: RuntimeState.RUNTIME_STATE_ERROR, + }), + }, + { + actions: [{ disabled: false, kind: 'changeVersion' }], + name: 'invalid without a current version', + row: environmentDeployment({ + id: 'invalid-without-version', + name: 'Invalid without version', + runtimeState: RuntimeState.RUNTIME_STATE_ERROR, }), }, { @@ -474,7 +550,7 @@ const ACTION_MATRIX_CASES: Array<{ currentVersion: VERSIONS.qa, id: 'unknown', name: 'Unknown', - status: DeploymentStatus.DEPLOYMENT_STATUS_UNSPECIFIED, + runtimeState: RuntimeState.RUNTIME_STATE_UNKNOWN, }), }, ] @@ -611,10 +687,14 @@ function render( appEnvironments = APP_ENVIRONMENTS, environmentDeployments = APP_ENVIRONMENT_DEPLOYMENTS, publishedWorkflowVersions = PUBLISHED_WORKFLOW_VERSIONS, + seedAppEnvironments = true, + seedLatestPublishedWorkflow = true, }: { appEnvironments?: AppEnvironment[] environmentDeployments?: EnvironmentDeployment[] publishedWorkflowVersions?: WorkflowResponse[] + seedAppEnvironments?: boolean + seedLatestPublishedWorkflow?: boolean } = {}, ) { const queryClient = createConsoleQueryClient() @@ -624,16 +704,20 @@ function render( queryClient.setQueryDefaults(appEnvironmentDeploymentsQueryOptions.queryKey, { staleTime: Infinity, }) - queryClient.setQueryData(appEnvironmentsQueryOptions.queryKey, { - data: appEnvironments, - }) + if (seedAppEnvironments) { + queryClient.setQueryData(appEnvironmentsQueryOptions.queryKey, { + data: appEnvironments, + }) + } queryClient.setQueryData(appEnvironmentDeploymentsQueryOptions.queryKey, { environment_deployments: environmentDeployments, }) - queryClient.setQueryData( - latestPublishedWorkflowQuery.queryKey, - mockBuiltInEnvironment.publishedWorkflow, - ) + if (seedLatestPublishedWorkflow) { + queryClient.setQueryData( + latestPublishedWorkflowQuery.queryKey, + mockBuiltInEnvironment.publishedWorkflow, + ) + } queryClient.setQueryData(appWorkflowVersionsQuery.queryKey, { pageParams: [1], pages: [ @@ -650,12 +734,32 @@ function render( return renderWithConsoleQuery(ui, { queryClient }) } +function environmentTableProps( + overrides: Partial> = {}, +): ComponentProps { + return { + appId: APP_ID, + canViewAccessPoint: true, + onChangeVersion: vi.fn(), + onDeployLatest: vi.fn(), + onDeployToEnvironment: vi.fn(), + onRedeploy: vi.fn(), + onUndeploy: vi.fn(), + ...overrides, + } +} + let appPermissionKeys: string[] = [AppACLPermission.AccessPointView, AppACLPermission.Deploy] let appDetailAvailable = true const mockConsoleState = vi.hoisted(() => ({ workspacePermissionKeys: [] as string[], })) -const mockDocLink = vi.hoisted(() => vi.fn((path: string) => `https://docs.example.com${path}`)) +const mockGetEnterpriseDocUrl = vi.hoisted(() => + vi.fn( + (path: string, docLanguage: string) => + `https://enterprise-docs.example.com/${docLanguage}${path}`, + ), +) vi.mock('react-i18next', async () => { const { createReactI18nextMock } = await import('@/test/i18n-mock') @@ -666,14 +770,23 @@ vi.mock('react-i18next', async () => { 'deployments.studio.undeployConfirmDesc': 'The app will stop running in this environment, and all of its access points will become unavailable.', 'deployments.studio.undeployConfirmTitle': 'Undeploy {{versionName}} from {{envName}}', + 'deployments.status.RUNTIME_INSTANCE_STATUS_DEPLOYING': 'Deploying', + 'deployments.status.RUNTIME_INSTANCE_STATUS_FAILED': 'Deploy failed', + 'deployments.status.RUNTIME_INSTANCE_STATUS_INVALID': 'Invalid', 'deployments.status.RUNTIME_INSTANCE_STATUS_READY': 'Running', + 'deployments.status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED': 'Not deployed', + 'deployments.status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING': 'Undeploying', + 'deployments.status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED': 'Unknown', 'deployments.studio.activity.deploySucceeded': 'Deploy {{target}} succeeded', 'deployments.studio.activity.meta': '{{name}} · {{time}}', 'deployments.studio.versionValue': 'Version value', 'deployments.studio.environmentsInUse': '{{used}} of {{total}} environments in use', 'deployments.studio.environmentVariablesDescription': "Use the value from the version you're deploying, keep the last deployed value, or enter a custom one.", + 'deployments.studio.precheck.from': 'From', + 'deployments.studio.precheck.nodeCount_other': '{{count}} nodes', 'deployments.studio.updatedAtBy': 'Updated at {{time}} by {{name}}', + 'workflow.common.workflowAsTool': 'Workflow as Tool', 'workflow.common.publishedBy': 'Published {{time}} by {{author}}', }) }) @@ -732,7 +845,8 @@ vi.mock('@/context/permission-state', async () => { }) vi.mock('@/context/i18n', () => ({ - useDocLink: () => mockDocLink, + getEnterpriseDocUrl: mockGetEnterpriseDocUrl, + useLocale: () => 'en-US', })) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -784,7 +898,7 @@ describe('AppDeploy', () => { expect(screen.getByRole('link', { name: 'common.operation.learnMore' })).toHaveAttribute( 'href', - 'https://docs.example.com/use/deploy/overview', + 'https://enterprise-docs.example.com/en/use/deploy/overview', ) }) @@ -795,6 +909,86 @@ describe('AppDeploy', () => { }, ) + it.each([ + [RuntimeState.RUNTIME_STATE_UNSPECIFIED, 'Unknown'], + [RuntimeState.RUNTIME_STATE_UNDEPLOYED, 'Not deployed'], + [RuntimeState.RUNTIME_STATE_RUNNING, 'Running'], + [RuntimeState.RUNTIME_STATE_STARTING, 'Deploying'], + [RuntimeState.RUNTIME_STATE_STOPPING, 'Undeploying'], + [RuntimeState.RUNTIME_STATE_ERROR, 'Invalid'], + [RuntimeState.RUNTIME_STATE_UNKNOWN, 'Unknown'], + ] as const)('renders runtime state %s as %s', (runtimeState, label) => { + render() + + expect(screen.getByText(label)).toBeInTheDocument() + }) + + it.each([RuntimeState.RUNTIME_STATE_STARTING, RuntimeState.RUNTIME_STATE_STOPPING])( + 'continues polling transitional state %s without a latest operation', + (runtimeState) => { + expect( + shouldPollEnvironmentDeployment( + environmentDeployment({ + id: 'transitioning', + name: 'Transitioning', + runtimeState, + }), + ), + ).toBe(true) + }, + ) + + it('disables Deploy latest and retries after the latest workflow request fails', async () => { + const user = userEvent.setup() + let requestCount = 0 + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if (!new URL(request.url).pathname.endsWith('/apps/app-1/workflows/publish')) + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + + requestCount += 1 + if (requestCount === 1) { + return new Response(JSON.stringify({ message: 'Failed to load the latest workflow' }), { + headers: { 'Content-Type': 'application/json' }, + status: 500, + }) + } + + return new Response(JSON.stringify(mockBuiltInEnvironment.publishedWorkflow), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }) + }) + + render(, { seedLatestPublishedWorkflow: false }) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('deployments.studio.latestVersionLoadFailed') + + const deployLatestButtons = screen.getAllByRole('button', { + name: 'deployments.studio.deployLatest', + }) + expect(deployLatestButtons.length).toBeGreaterThan(0) + for (const button of deployLatestButtons) expect(button).toBeDisabled() + + await user.click(within(alert).getByRole('button', { name: 'common.operation.retry' })) + + await waitFor(() => { + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + const stagingRow = within(screen.getByRole('row', { name: /Staging/ })) + const preReleaseRow = within(screen.getByRole('row', { name: /Pre-release/ })) + await waitFor(() => { + expect( + preReleaseRow.getByRole('button', { name: 'deployments.studio.deployLatest' }), + ).toBeEnabled() + }) + expect( + stagingRow.getByRole('button', { name: 'deployments.studio.deployLatest' }), + ).toBeDisabled() + expect(requestCount).toBe(2) + }) + it('renders version, status, activity, and access from the deployment contract', () => { render() @@ -963,6 +1157,42 @@ describe('AppDeploy', () => { expect(screen.queryByRole('menuitem')).not.toBeInTheDocument() }) + it('shows a retry entry when the environment list fails to load', async () => { + const user = userEvent.setup() + let requestCount = 0 + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if (!new URL(request.url).pathname.endsWith('/enterprise/app-deploy/apps/app-1/environments')) + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + + requestCount += 1 + if (requestCount === 1) { + return new Response(JSON.stringify({ message: 'Failed to load environments' }), { + headers: { 'Content-Type': 'application/json' }, + status: 500, + }) + } + + return new Response(JSON.stringify({ data: APP_ENVIRONMENTS }), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }) + }) + + render(, { seedAppEnvironments: false }) + await waitFor(() => expect(requestCount).toBe(1)) + + expect(screen.queryByText(/environments in use/)).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' })) + + const menu = await screen.findByRole('menu') + expect(within(menu).getByRole('alert')).toHaveTextContent('deployments.common.loadFailed') + await user.click(within(menu).getByRole('button', { name: 'common.operation.retry' })) + + expect(await screen.findByText('8 of 12 environments in use')).toBeInTheDocument() + expect(requestCount).toBe(2) + }) + it('opens the selected environment version picker from the deploy menu', async () => { const user = userEvent.setup() render() @@ -970,9 +1200,14 @@ describe('AppDeploy', () => { await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' })) await user.click(await screen.findByRole('menuitem', { name: /Dev/ })) - const dialog = await screen.findByRole('dialog', { - name: 'deployments.versions.deployTo:{"name":"Dev"}', - }) + const dialog = await screen.findByRole( + 'dialog', + { + name: 'deployments.versions.deployTo:{"name":"Dev"}', + }, + { timeout: 3000 }, + ) + expect(dialog).toHaveClass('h-[min(44rem,calc(100dvh-32px))]') expect(within(dialog).getByText('deployments.studio.chooseVersionToDeploy')).toBeInTheDocument() expect(within(dialog).getByRole('button', { name: /Release 7/ })).toBeEnabled() expect(within(dialog).getByRole('button', { name: /Sprint-42/ })).toBeEnabled() @@ -1015,11 +1250,21 @@ describe('AppDeploy', () => { const configurationDialog = await screen.findByRole('dialog', { name: 'deployments.studio.deployConfiguration', }) + expect(configurationDialog).not.toHaveClass('h-[min(44rem,calc(100dvh-32px))]') expect(within(configurationDialog).getByText('Release 6')).toBeInTheDocument() expect(within(configurationDialog).getByText('Dev')).toBeInTheDocument() expect( within(configurationDialog).getByRole('combobox', { name: 'Moonshot' }), ).toHaveTextContent('Enterprise deployment key') + expect( + within(configurationDialog).queryByRole('button', { name: /Moonshot: From/ }), + ).not.toBeInTheDocument() + expect( + within(configurationDialog).getByRole('button', { + name: 'Github: From Workflow as Tool', + }), + ).toBeInTheDocument() + expect(within(configurationDialog).getByText('From Workflow as Tool')).toBeInTheDocument() expect( within(configurationDialog).getByRole('button', { name: 'common.appMenus.deploy' }), ).toBeEnabled() @@ -1029,19 +1274,35 @@ describe('AppDeploy', () => { ), ).toBeInTheDocument() - const portSource = within(configurationDialog).getByRole('combobox', { name: /PORT/ }) + const rootVariables = within( + within(configurationDialog).getByRole('group', { name: 'Finance APP' }), + ) + const subworkflowVariables = within( + within(configurationDialog).getByRole('group', { name: 'Workflow as Tool' }), + ) + const portSource = rootVariables.getByRole('combobox', { name: /PORT/ }) expect(portSource).toHaveTextContent('Version value') - const portInput = within(configurationDialog).getByRole('textbox', { name: 'PORT' }) + const portInput = rootVariables.getByRole('textbox', { name: 'PORT' }) expect(portInput).toBeDisabled() expect(portInput).toHaveAttribute('placeholder', '2') - expect(within(configurationDialog).getByRole('textbox', { name: 'API_KEY' })).toHaveAttribute( + expect(rootVariables.getByRole('textbox', { name: 'API_KEY' })).toHaveAttribute( 'placeholder', 'sk-123************bc', ) - expect(within(configurationDialog).getByRole('textbox', { name: 'name' })).toHaveAttribute( + expect(rootVariables.getByRole('textbox', { name: 'name' })).toHaveAttribute( 'placeholder', 'environment variable 01', ) + expect(subworkflowVariables.getByRole('textbox', { name: 'PORT' })).toHaveAttribute( + 'placeholder', + '8080', + ) + + await user.hover(subworkflowVariables.getByRole('button', { name: 'Workflow as Tool' })) + const sourcePreview = await screen.findByRole('dialog', { name: 'Workflow as Tool' }) + expect( + within(sourcePreview).getByRole('link', { name: /Finance APP.*Workflow as Tool/ }), + ).toHaveAttribute('href', '/app/app-workflow-tool/workflow') await user.click(portSource) expect( @@ -1077,6 +1338,150 @@ describe('AppDeploy', () => { ).toBeInTheDocument() }) + it('requires a value after refreshed deployment options reconcile the source to custom', async () => { + const user = userEvent.setup() + const deploymentRequests: Request[] = [] + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if (!request.url.includes('/deployment:deploy')) + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + + deploymentRequests.push(request.clone()) + return new Response(JSON.stringify({ message: 'Stop after capturing the request' }), { + headers: { 'Content-Type': 'application/json' }, + status: 502, + }) + }) + const view = render() + + await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' })) + await user.click(screen.getByRole('menuitem', { name: /Dev/ })) + const versionDialog = await screen.findByRole('dialog', { + name: 'deployments.versions.deployTo:{"name":"Dev"}', + }) + await user.click(within(versionDialog).getByRole('button', { name: /Release 6/ })) + + const configurationDialog = await screen.findByRole('dialog', { + name: 'deployments.studio.deployConfiguration', + }) + const rootVariables = within( + within(configurationDialog).getByRole('group', { name: 'Finance APP' }), + ) + await user.click(rootVariables.getByRole('combobox', { name: /PORT/ })) + await user.click( + await screen.findByRole('option', { + name: 'deployments.deployDrawer.envVarSource.lastDeployment', + }), + ) + + const deploymentOptionsQuery = workflowDeploymentOptionsQueryOptions( + 'workflow-version-6', + 'dev', + ) + act(() => { + view.queryClient.setQueryData(deploymentOptionsQuery.queryKey, { + ...WORKFLOW_DEPLOYMENT_OPTIONS, + environment_variable_groups: WORKFLOW_DEPLOYMENT_OPTIONS.environment_variable_groups.map( + (group) => ({ + ...group, + environment_variable_slots: group.from_app + ? group.environment_variable_slots.map((slot) => + slot.key === 'PORT' + ? { + ...slot, + has_configured_value: false, + has_last_deployed_value: false, + } + : slot, + ) + : group.environment_variable_slots, + }), + ), + }) + }) + + await waitFor(() => { + expect(rootVariables.getByRole('combobox', { name: /PORT/ })).toHaveTextContent( + 'deployments.deployDrawer.envVarSource.literal', + ) + }) + const customPortInput = rootVariables.getByRole('spinbutton', { name: 'PORT' }) + expect(customPortInput).toBeEnabled() + + await user.click( + within(configurationDialog).getByRole('button', { name: 'common.appMenus.deploy' }), + ) + expect(deploymentRequests).toHaveLength(0) + expect(toast.error).toHaveBeenCalledWith('Finance APP · PORT: workflow.env.modal.valueRequired') + + await user.type(customPortInput, '3000') + await user.click( + within(configurationDialog).getByRole('button', { name: 'common.appMenus.deploy' }), + ) + await waitFor(() => expect(deploymentRequests).toHaveLength(1)) + + const body = await deploymentRequests[0]!.json() + expect(body.environment_variable_groups[0].environment_variables).toContainEqual({ + key: 'PORT', + value: 3000, + value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }) + }) + + it('shows a toast instead of submitting when a credential is missing', async () => { + const user = userEvent.setup() + const deploymentRequests: Request[] = [] + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + if (!request.url.includes('/deployment:deploy')) + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + + deploymentRequests.push(request.clone()) + return new Response(JSON.stringify({ message: 'Stop after capturing the request' }), { + headers: { 'Content-Type': 'application/json' }, + status: 502, + }) + }) + const view = render() + const deploymentOptionsQuery = workflowDeploymentOptionsQueryOptions( + 'workflow-version-6', + 'dev', + ) + act(() => { + view.queryClient.setQueryData(deploymentOptionsQuery.queryKey, { + ...WORKFLOW_DEPLOYMENT_OPTIONS, + credential_slots: WORKFLOW_DEPLOYMENT_OPTIONS.credential_slots.map((slot) => + slot.provider_id === 'moonshot' + ? { ...slot, last_deployed_credential_id: undefined } + : slot, + ), + }) + }) + + await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' })) + await user.click(screen.getByRole('menuitem', { name: /Dev/ })) + const versionDialog = await screen.findByRole('dialog', { + name: 'deployments.versions.deployTo:{"name":"Dev"}', + }) + await user.click(within(versionDialog).getByRole('button', { name: /Release 6/ })) + + const configurationDialog = await screen.findByRole('dialog', { + name: 'deployments.studio.deployConfiguration', + }) + const deployButton = within(configurationDialog).getByRole('button', { + name: 'common.appMenus.deploy', + }) + expect( + within(configurationDialog).getByRole('combobox', { name: 'Moonshot' }), + ).toHaveTextContent('deployments.deployDrawer.selectCredential') + expect(deployButton).toBeEnabled() + + await user.click(deployButton) + + expect(deploymentRequests).toHaveLength(0) + expect(toast.error).toHaveBeenCalledWith('Moonshot: deployments.deployDrawer.selectCredential') + }) + it('deploys the selected workflow configuration and refreshes the deployment list', async () => { const user = userEvent.setup() const requests: Request[] = [] @@ -1113,7 +1518,7 @@ describe('AppDeploy', () => { targetVersion: workflowVersion('Release 6', 'workflow-version-6'), }), name: 'Dev', - status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_STARTING, }), ], }), @@ -1154,15 +1559,18 @@ describe('AppDeploy', () => { const configurationDialog = await screen.findByRole('dialog', { name: 'deployments.studio.deployConfiguration', }) + const rootVariables = within( + within(configurationDialog).getByRole('group', { name: 'Finance APP' }), + ) await user.click(within(configurationDialog).getByRole('combobox', { name: 'Moonshot' })) await user.click(await screen.findByRole('option', { name: 'Development key' })) - await user.click(within(configurationDialog).getByRole('combobox', { name: /PORT/ })) + await user.click(rootVariables.getByRole('combobox', { name: /PORT/ })) await user.click( await screen.findByRole('option', { name: 'deployments.deployDrawer.envVarSource.literal', }), ) - await user.type(within(configurationDialog).getByRole('spinbutton', { name: 'PORT' }), '3000') + await user.type(rootVariables.getByRole('spinbutton', { name: 'PORT' }), '3000') await user.click( within(configurationDialog).getByRole('button', { name: 'common.appMenus.deploy' }), ) @@ -1199,19 +1607,37 @@ describe('AppDeploy', () => { provider_id: 'github', }, ], - environment_variables: [ + environment_variable_groups: [ { - key: 'PORT', - value: '3000', - value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + environment_variables: [ + { + key: 'PORT', + value: 3000, + value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }, + { + key: 'API_KEY', + value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CONFIGURED, + }, + { + key: 'name', + value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED, + }, + ], + workflow_id: 'workflow-version-6', }, { - key: 'API_KEY', - value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CONFIGURED, - }, - { - key: 'name', - value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED, + environment_variables: [ + { + key: 'PORT', + value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CONFIGURED, + }, + { + key: 'API_KEY', + value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CONFIGURED, + }, + ], + workflow_id: 'workflow-tool', }, ], }) @@ -1262,7 +1688,7 @@ describe('AppDeploy', () => { type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_UNDEPLOY, }), name: 'Canary', - status: DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_STOPPING, }) : deployment, ), @@ -1402,7 +1828,7 @@ describe('AppDeploy', () => { type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_UNDEPLOY, }), name: 'Canary', - status: DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING, + runtimeState: RuntimeState.RUNTIME_STATE_STOPPING, }), ], }) @@ -1510,6 +1936,45 @@ describe('AppDeploy', () => { ).toBeInTheDocument() }) + it('keeps deployment configuration and entered values open after an outside press', async () => { + const user = userEvent.setup() + render() + + const preReleaseRow = within(screen.getByRole('row', { name: /Pre-release/ })) + await user.click( + preReleaseRow.getByRole('button', { + name: 'deployments.studio.deployLatest', + }), + ) + + const configurationDialog = await screen.findByRole('dialog', { + name: 'deployments.studio.deployConfiguration', + }) + const rootVariables = within( + within(configurationDialog).getByRole('group', { name: 'Finance APP' }), + ) + await user.click(rootVariables.getByRole('combobox', { name: /PORT/ })) + await user.click( + await screen.findByRole('option', { + name: 'deployments.deployDrawer.envVarSource.literal', + }), + ) + const customPortInput = rootVariables.getByRole('spinbutton', { name: 'PORT' }) + await user.type(customPortInput, '3000') + + await user.click(document.body) + + expect(configurationDialog).toBeInTheDocument() + expect(customPortInput).toHaveValue(3000) + + await user.click(within(configurationDialog).getByRole('button', { name: 'Cancel' })) + expect( + screen.queryByRole('dialog', { + name: 'deployments.studio.deployConfiguration', + }), + ).not.toBeInTheDocument() + }) + it('opens the failed version configuration for retry without a version-selection step', async () => { const user = userEvent.setup() render() @@ -1571,7 +2036,10 @@ describe('AppDeploy', () => { const dialog = await screen.findByRole('dialog', { name: 'deployments.studio.changeVersion · Canary', }) - expect(within(dialog).getByRole('button', { name: /Sprint-42/ })).toBeDisabled() + const versionList = within(dialog).getByRole('region', { + name: 'deployments.studio.changeVersion · Canary', + }) + expect(within(versionList).getByRole('button', { name: /Sprint-42/ })).toBeDisabled() expect(within(dialog).getByText('deployments.studio.current')).toBeInTheDocument() }) @@ -1579,7 +2047,7 @@ describe('AppDeploy', () => { const user = userEvent.setup() render( - + , { appEnvironments: APP_ENVIRONMENTS.map((environment) => ({ @@ -1640,10 +2108,14 @@ describe('AppDeploy', () => { queryClient.setQueryData(appEnvironmentsQueryOptions.queryKey, { data: APP_ENVIRONMENTS, }) + queryClient.setQueryData( + latestPublishedWorkflowQuery.queryKey, + mockBuiltInEnvironment.publishedWorkflow, + ) renderWithConsoleQuery( - + , { queryClient }, ) @@ -1745,7 +2217,7 @@ describe('AppDeploy', () => { const stagingRow = within(screen.getByRole('row', { name: /Staging/ })) expect( stagingRow.getByRole('button', { - name: 'deployments.studio.changeVersion', + name: 'deployments.studio.deployLatest', }), ).toBeDisabled() @@ -1756,7 +2228,7 @@ describe('AppDeploy', () => { ) const menuItems = within(await screen.findByRole('menu')).getAllByRole('menuitem') - expect(menuItems).toHaveLength(2) + expect(menuItems).toHaveLength(1) for (const item of menuItems) expect(item).toHaveAttribute('aria-disabled', 'true') }) @@ -1765,7 +2237,7 @@ describe('AppDeploy', () => { const onUndeploy = vi.fn() render( - + , ) @@ -1802,7 +2274,7 @@ describe('AppDeploy', () => { const onUndeploy = vi.fn() render( - + , ) diff --git a/web/app/components/app/deploy/__tests__/version.spec.ts b/web/app/components/app/deploy/__tests__/version.spec.ts index f942df6d088..deb4928e79e 100644 --- a/web/app/components/app/deploy/__tests__/version.spec.ts +++ b/web/app/components/app/deploy/__tests__/version.spec.ts @@ -1,4 +1,4 @@ -import { toDeploymentVersion } from '../version' +import { toDeploymentVersion } from '../utils/version' describe('toDeploymentVersion', () => { it('maps workflow metadata into the shared deployment version shape', () => { diff --git a/web/app/components/app/deploy/assets/github.png b/web/app/components/app/deploy/assets/github.png deleted file mode 100644 index c00ac6ad25f..00000000000 Binary files a/web/app/components/app/deploy/assets/github.png and /dev/null differ diff --git a/web/app/components/app/deploy/assets/moonshot.png b/web/app/components/app/deploy/assets/moonshot.png deleted file mode 100644 index eb28f25f1dc..00000000000 Binary files a/web/app/components/app/deploy/assets/moonshot.png and /dev/null differ diff --git a/web/app/components/app/deploy/assets/slack.png b/web/app/components/app/deploy/assets/slack.png deleted file mode 100644 index cf4aa776d2b..00000000000 Binary files a/web/app/components/app/deploy/assets/slack.png and /dev/null differ diff --git a/web/app/components/app/deploy/built-in-environment-card/index.tsx b/web/app/components/app/deploy/built-in-environment-card/index.tsx index f6eb294b6ac..c288ef06622 100644 --- a/web/app/components/app/deploy/built-in-environment-card/index.tsx +++ b/web/app/components/app/deploy/built-in-environment-card/index.tsx @@ -2,117 +2,122 @@ import type { WorkflowVersion } from '@dify/contracts/enterprise-app-deploy/types.gen' import type { Node } from '@/app/components/workflow/types' -import { DeploymentStatus as DeploymentStatusEnum } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { RuntimeState } from '@dify/contracts/enterprise-app-deploy/types.gen' import { useQuery } from '@tanstack/react-query' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types' import useTimestamp from '@/hooks/use-timestamp' import { useMCPServerDetail } from '@/service/use-tools' import { appWorkflowQueryOptions } from '@/service/workflow-queries' -import { ACCESS_POINT_ORDER, getAccessPointHref } from '../access-point' import { AccessPointIcon } from '../shared/access-point-icon' -import { DeploymentStatus } from '../shared/deployment-status' +import { RuntimeStateIndicator } from '../shared/runtime-state' import { VersionLabel } from '../shared/version-label' +import { ACCESS_POINT_ORDER, getAccessPointHref } from '../utils/access-point' function Divider() { return
} -export function BuiltInEnvironmentCard({ canViewAccessPoint }: { canViewAccessPoint: boolean }) { - const { t } = useTranslation('deployments') - const { formatTime } = useTimestamp() - const appDetail = useAppStore((state) => state.appDetail) - const appId = appDetail?.id ?? '' - const { data: publishedWorkflow } = useQuery(appWorkflowQueryOptions(appId || null)) - const { data: mcpServerDetail } = useMCPServerDetail(appId, Boolean(appId)) - const publishedNodes = Array.isArray(publishedWorkflow?.graph.nodes) - ? (publishedWorkflow.graph.nodes as Node[]) - : [] - const hasStartNode = publishedNodes.some((node) => node.data.type === BlockEnum.Start) - const hasTriggerNode = publishedNodes.some((node) => isTriggerNode(node.data.type)) - const serviceModeAvailable = Boolean(publishedWorkflow && hasStartNode && !hasTriggerNode) - const activeAccessPoints = { - mcp: serviceModeAvailable && mcpServerDetail?.status === 'active', - serviceApi: serviceModeAvailable && Boolean(appDetail?.enable_api), - trigger: Boolean(publishedWorkflow && hasTriggerNode), - webApp: serviceModeAvailable && Boolean(appDetail?.enable_site), - } - const publishedBy = publishedWorkflow?.created_by?.name ?? appDetail?.author_name ?? '--' - const updatedBy = publishedWorkflow?.updated_by?.name ?? publishedBy - const publishedVersion: WorkflowVersion | undefined = publishedWorkflow - ? { - created_at: publishedWorkflow.created_at, - created_by: publishedWorkflow.created_by ?? undefined, - id: publishedWorkflow.id, - marked_comment: publishedWorkflow.marked_comment, - marked_name: publishedWorkflow.marked_name, - version: publishedWorkflow.version, - version_number: publishedWorkflow.version_number ?? undefined, - } - : undefined +export const BuiltInEnvironmentCard = memo( + ({ canViewAccessPoint }: { canViewAccessPoint: boolean }) => { + const { t } = useTranslation('deployments') + const { formatTime } = useTimestamp() + const appDetail = useAppStore((state) => state.appDetail) + const appId = appDetail?.id ?? '' + const { data: publishedWorkflow } = useQuery(appWorkflowQueryOptions(appId || null)) + const { data: mcpServerDetail } = useMCPServerDetail(appId, Boolean(appId)) + const publishedNodes = Array.isArray(publishedWorkflow?.graph.nodes) + ? (publishedWorkflow.graph.nodes as Node[]) + : [] + const hasStartNode = publishedNodes.some((node) => node.data.type === BlockEnum.Start) + const hasTriggerNode = publishedNodes.some((node) => isTriggerNode(node.data.type)) + const serviceModeAvailable = Boolean(publishedWorkflow && hasStartNode && !hasTriggerNode) + const activeAccessPoints = { + mcp: serviceModeAvailable && mcpServerDetail?.status === 'active', + serviceApi: serviceModeAvailable && Boolean(appDetail?.enable_api), + trigger: Boolean(publishedWorkflow && hasTriggerNode), + webApp: serviceModeAvailable && Boolean(appDetail?.enable_site), + } + const publishedBy = publishedWorkflow?.created_by?.name ?? appDetail?.author_name ?? '--' + const updatedBy = publishedWorkflow?.updated_by?.name ?? publishedBy + const publishedVersion: WorkflowVersion | undefined = publishedWorkflow + ? { + created_at: publishedWorkflow.created_at, + created_by: publishedWorkflow.created_by ?? undefined, + id: publishedWorkflow.id, + marked_comment: publishedWorkflow.marked_comment, + marked_name: publishedWorkflow.marked_name, + version: publishedWorkflow.version, + version_number: publishedWorkflow.version_number ?? undefined, + } + : undefined - return ( -
-
- {/* Icon */} -
- -
- {/* Info */} -
-
-

- {t(($) => $['studio.builtInTitle'])} -

-

- {t(($) => $['studio.builtInDescription'])} -

+ return ( +
+
+ {/* Icon */} +
+
- -
-
- {t(($) => $['studio.liveVersion'])} + {/* Info */} +
+
+

+ {t(($) => $['studio.builtInTitle'])} +

+

+ {t(($) => $['studio.builtInDescription'])} +

- -
- -
-
- {t(($) => $['studio.accessPoints'])} + +
+
+ {t(($) => $['studio.liveVersion'])} +
+
-
- {ACCESS_POINT_ORDER.map((accessPoint) => ( - - ))} + +
+
+ {t(($) => $['studio.accessPoints'])} +
+
+ {ACCESS_POINT_ORDER.map((accessPoint) => ( + + ))} +
-
- {/* Status and updated time */} -
- -

- {publishedWorkflow - ? t(($) => $['studio.updatedAtBy'], { - name: updatedBy, - time: formatTime(publishedWorkflow.updated_at, 'MM-DD HH:mm'), - }) - : '--'} -

-
-
- ) -} + {/* Status and updated time */} +
+ +

+ {publishedWorkflow + ? t(($) => $['studio.updatedAtBy'], { + name: updatedBy, + time: formatTime(publishedWorkflow.updated_at, 'MM-DD HH:mm'), + }) + : '--'} +

+
+
+ ) + }, +) + +BuiltInEnvironmentCard.displayName = 'BuiltInEnvironmentCard' diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/content.tsx b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/content.tsx deleted file mode 100644 index 8228026c57a..00000000000 --- a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/content.tsx +++ /dev/null @@ -1,262 +0,0 @@ -'use client' - -import type { Dispatch, SetStateAction } from 'react' -import type { DeploymentVersion } from '../../version' -import type { DeploymentDialogRequest } from '../types' -import type { DeploymentConfigurationQueryState } from './use-deployment-configuration-queries' -import type { DeploymentConfigurationValues } from './use-deployment-configuration-values' -import { cn } from '@langgenius/dify-ui/cn' -import { useTranslation } from 'react-i18next' -import { CredentialField } from './credential-field' -import { DeploymentPrecheckAlert } from './deployment-precheck-alert' -import { EnvironmentVariableField } from './environment-variable-field' -import { - credentialSlotKey, - defaultCredentialId, - defaultEnvironmentVariableSelection, -} from './workflow-deployment-input' - -function SectionHeading({ title, description }: { title: string; description: string }) { - return ( -
-

{title}

-

{description}

-
- ) -} - -function errorMessage(error: unknown, fallback: string) { - if ( - typeof error === 'object' && - error !== null && - 'message' in error && - typeof error.message === 'string' && - error.message.trim() - ) { - return error.message.trim() - } - - return fallback -} - -function ConfigurationError({ messages }: { messages: string[] }) { - const { t } = useTranslation('common') - - return ( -
- -
-

{t(($) => $.error)}

-
    - {messages.map((message) => ( -
  • - {message} -
  • - ))} -
-
-
- ) -} - -function ConfigurationLoading({ label }: { label: string }) { - return ( -
- - {label} -
- ) -} - -export function DeploymentConfigurationContent({ - compact = false, - onValuesChange, - queryState, - request, - values, - version, -}: { - compact?: boolean - onValuesChange: Dispatch> - queryState: DeploymentConfigurationQueryState - request: DeploymentDialogRequest - values: DeploymentConfigurationValues - version: DeploymentVersion -}) { - const { t } = useTranslation('deployments') - const { t: tCommon } = useTranslation('common') - const horizontalPaddingClassName = compact ? 'px-4' : 'px-6' - const { - deploymentOptions, - deploymentOptionsError, - isLoadingDeploymentOptions, - isPrecheckBlocked, - isPrechecking, - precheck, - precheckError, - } = queryState - const unsupportedNodes = precheck?.unsupported_nodes ?? [] - const showPrecheckAlert = !isPrechecking && !precheckError && isPrecheckBlocked - const showConfiguration = Boolean(deploymentOptions) - const credentialSlots = deploymentOptions?.credential_slots ?? [] - const hasCredentialSlots = credentialSlots.length > 0 - const environmentVariableSlots = deploymentOptions?.environment_variable_slots ?? [] - - return ( - <> -
-
-
- - {version.name} -
- -
- - - {request.environment} - -
-
-
- -
- {isPrechecking && ( - $['versions.checkingReleaseContent'])} /> - )} - {!isPrechecking && precheckError && ( -
- $.error), - ), - ]} - /> -
- )} - {showPrecheckAlert && ( -
- -
- )} - {isLoadingDeploymentOptions && $.loading)} />} - {!isLoadingDeploymentOptions && deploymentOptionsError && ( -
- $['deployDrawer.bindingOptionsFailed']), - ), - ]} - /> -
- )} - {showConfiguration && ( - <> - {hasCredentialSlots && ( -
- $['deployDrawer.runtimeCredentials'])} - description={t(($) => $['deployDrawer.bindingSelectionHint'])} - /> - {credentialSlots.map((slot) => { - const slotKey = credentialSlotKey(slot) - - return ( - - onValuesChange((current) => ({ - ...current, - credentials: { - ...current.credentials, - [slotKey]: value, - }, - })) - } - /> - ) - })} -
- )} - - {environmentVariableSlots.length > 0 ? ( -
- $['deployDrawer.envVars'])} - description={t(($) => $['studio.environmentVariablesDescription'])} - /> - {environmentVariableSlots.map((slot) => { - const selection = - values.environmentVariables[slot.key] ?? - defaultEnvironmentVariableSelection(slot) - - return ( - - onValuesChange((current) => ({ - ...current, - environmentVariables: { - ...current.environmentVariables, - [slot.key]: { - ...selection, - source, - }, - }, - })) - } - onCustomValueChange={(customValue) => - onValuesChange((current) => ({ - ...current, - environmentVariables: { - ...current.environmentVariables, - [slot.key]: { - ...selection, - customValue, - }, - }, - })) - } - /> - ) - })} -
- ) : null} - - )} -
- - ) -} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/deployment-precheck-alert.tsx b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/deployment-precheck-alert.tsx deleted file mode 100644 index 632cc32c420..00000000000 --- a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/deployment-precheck-alert.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client' - -import type { UnsupportedNode } from '@dify/contracts/enterprise-app-deploy/types.gen' -import type { Emoji } from '@/app/components/tools/types' -import { useTranslation } from 'react-i18next' -import BlockIcon from '@/app/components/workflow/block-icon' -import { BlockEnum } from '@/app/components/workflow/types' -import { useGetProviderIcon } from './use-provider-icon' - -const WORKFLOW_BLOCK_TYPES = new Set(Object.values(BlockEnum)) - -function isWorkflowBlockType(type: string): type is BlockEnum { - return WORKFLOW_BLOCK_TYPES.has(type) -} - -function UnsupportedNodeIcon({ node, icon }: { node: UnsupportedNode; icon?: string | Emoji }) { - if (!isWorkflowBlockType(node.type)) { - return ( - - - - ) - } - - return -} - -export function DeploymentPrecheckAlert({ nodes }: { nodes: UnsupportedNode[] }) { - const { t } = useTranslation('deployments') - const getProviderIcon = useGetProviderIcon(nodes) - - return ( -
-
- -
-

{t(($) => $['studio.precheck.title'])}

-

- {t(($) => $['studio.precheck.description'])} -

-
    - {nodes.map((node) => ( -
  • - - - {node.title} - -
  • - ))} -
-

- {t(($) => $['studio.precheck.supportMessage'])} -

-
-
- ) -} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/environment-variable-field.tsx b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/environment-variable-field.tsx deleted file mode 100644 index 0bb746540fb..00000000000 --- a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/environment-variable-field.tsx +++ /dev/null @@ -1,129 +0,0 @@ -'use client' - -import type { - EnvironmentVariableSlot, - EnvVarValueSource, -} from '@dify/contracts/enterprise-app-deploy/types.gen' -import { - EnvVarValueSource as EnvVarValueSourceEnum, - EnvVarValueType, -} from '@dify/contracts/enterprise-app-deploy/types.gen' -import { Input } from '@langgenius/dify-ui/input' -import { - Select, - SelectContent, - SelectItem, - SelectItemIndicator, - SelectItemText, - SelectTrigger, -} from '@langgenius/dify-ui/select' -import { useTranslation } from 'react-i18next' - -export function EnvironmentVariableField({ - slot, - source, - customValue, - onSourceChange, - onCustomValueChange, -}: { - slot: EnvironmentVariableSlot - source: EnvVarValueSource - customValue: string - onSourceChange: (source: EnvVarValueSource) => void - onCustomValueChange: (value: string) => void -}) { - const { t } = useTranslation('deployments') - const sourceLabels: Partial> = { - [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED]: t(($) => $['studio.versionValue']), - [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM]: t( - ($) => $['deployDrawer.envVarSource.literal'], - ), - [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED]: t( - ($) => $['deployDrawer.envVarSource.lastDeployment'], - ), - } - const availableSources = [ - ...(slot.has_last_deployed_value - ? [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED] - : []), - ...(slot.has_configured_value ? [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED] : []), - EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM, - ] - const valueTypeLabel = - slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER - ? t(($) => $['deployDrawer.envVarType.number']) - : slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET - ? t(($) => $['deployDrawer.envVarType.secret']) - : t(($) => $['deployDrawer.envVarType.string']) - const inputId = `deployment-env-${slot.key}` - const editable = source === EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM - const inputType = - editable && slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET - ? 'password' - : editable && slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER - ? 'number' - : 'text' - const sourceLabel = sourceLabels[source] ?? t(($) => $['deployDrawer.envVarSource.literal']) - // Secret values arrive masked, so these are safe to show as-is. - const sourceValues: Partial> = { - [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED]: slot.configured_value, - [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED]: slot.last_deployed_value, - } - const placeholder = editable ? undefined : (sourceValues[source] ?? sourceLabel) - - return ( -
-
-
- - - {valueTypeLabel} - {slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET && ( - - )} -
- -
- onCustomValueChange(event.target.value)} - /> - {slot.description && ( -

{slot.description}

- )} -
- ) -} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-values.ts b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-values.ts deleted file mode 100644 index 0157e1f5907..00000000000 --- a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-values.ts +++ /dev/null @@ -1,23 +0,0 @@ -'use client' - -import type { EnvVarValueSource } from '@dify/contracts/enterprise-app-deploy/types.gen' -import { useState } from 'react' - -type EnvironmentVariableSelection = { - customValue: string - source: EnvVarValueSource -} - -export type DeploymentConfigurationValues = { - credentials: Record - environmentVariables: Record -} - -export function useDeploymentConfigurationValues() { - const [values, setValues] = useState({ - credentials: {}, - environmentVariables: {}, - }) - - return [values, setValues] as const -} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/workflow-deployment-input.ts b/web/app/components/app/deploy/deployment-dialog/deployment-configuration/workflow-deployment-input.ts deleted file mode 100644 index e46ccc8981a..00000000000 --- a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/workflow-deployment-input.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { - CredentialSlot, - EnvironmentVariableSlot, - GetWorkflowDeploymentOptionsResponse, - WorkflowDeploymentInput, -} from '@dify/contracts/enterprise-app-deploy/types.gen' -import type { DeploymentConfigurationValues } from './use-deployment-configuration-values' -import { EnvVarValueSource as EnvVarValueSourceEnum } from '@dify/contracts/enterprise-app-deploy/types.gen' - -export function credentialSlotKey(slot: CredentialSlot) { - return `${slot.provider_id}:${slot.category}` -} - -export function defaultCredentialId(slot: CredentialSlot) { - if ( - slot.last_deployed_credential_id && - slot.candidates.some( - (candidate) => candidate.credential_id === slot.last_deployed_credential_id, - ) - ) { - return slot.last_deployed_credential_id - } - - return slot.candidates.length === 1 ? slot.candidates[0]?.credential_id : undefined -} - -export function defaultEnvironmentVariableSelection( - slot: EnvironmentVariableSlot, -): DeploymentConfigurationValues['environmentVariables'][string] { - if (slot.has_configured_value) { - return { - customValue: '', - source: EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED, - } - } - - if (slot.has_last_deployed_value) { - return { - customValue: '', - source: EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED, - } - } - - return { - customValue: '', - source: EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM, - } -} - -export function workflowDeploymentInput( - deploymentOptions: GetWorkflowDeploymentOptionsResponse, - values: DeploymentConfigurationValues, -): WorkflowDeploymentInput | undefined { - const credentials: NonNullable = [] - - for (const slot of deploymentOptions.credential_slots) { - const credentialId = values.credentials[credentialSlotKey(slot)] ?? defaultCredentialId(slot) - if (!credentialId) return - - credentials.push({ - category: slot.category, - credential_id: credentialId, - provider_id: slot.provider_id, - }) - } - - return { - credentials, - environment_variables: deploymentOptions.environment_variable_slots.map((slot) => { - const selection = - values.environmentVariables[slot.key] ?? defaultEnvironmentVariableSelection(slot) - - return { - key: slot.key, - value_source: selection.source, - ...(selection.source === EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM - ? { value: selection.customValue } - : {}), - } - }), - } -} diff --git a/web/app/components/app/deploy/deployment-dialog/index.tsx b/web/app/components/app/deploy/deployment-dialog/index.tsx index 850d02ea176..3b19289a8a7 100644 --- a/web/app/components/app/deploy/deployment-dialog/index.tsx +++ b/web/app/components/app/deploy/deployment-dialog/index.tsx @@ -1,11 +1,12 @@ 'use client' -import type { DeploymentVersion } from '../version' -import type { DeploymentDialogRequest } from './types' +import type { DeploymentDialogRequest } from '../types' +import type { DeploymentVersion } from '../utils/version' +import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { useState } from 'react' -import { DeploymentConfiguration } from './deployment-configuration' -import { VersionSelection } from './version-selection' +import { DeploymentConfiguration } from '../shared/deployment-configuration' +import { VersionSelection } from '../shared/version-selection' type DeploymentDialogProps = { appId: string @@ -27,7 +28,12 @@ function DeploymentDialogSession({ ) return ( - + {selectedVersion ? ( !open && onClose()}> + !open && onClose()} + > {request && ( - {children} - - ) -} - -function VersionTag({ children }: { children: ReactNode }) { - return ( - - {children} - - ) -} - -function VersionChoice({ - version, - current, - disabled = false, - onSelect, -}: { - version: DeploymentVersion - current: boolean - disabled?: boolean - onSelect: (version: DeploymentVersion) => void -}) { - const { t } = useTranslation('deployments') - const { t: tWorkflow } = useTranslation('workflow') - const { formatTimeFromNow } = useFormatTimeFromNow() - - return ( - - ) -} - -function VersionList({ - className, - currentVersionId, - disabled = false, - publishHref, - onSelect, -}: { - className?: string - currentVersionId?: string - disabled?: boolean - publishHref?: string - onSelect: (version: DeploymentVersion) => void -}) { - const { t: tCommon } = useTranslation('common') - const { t } = useTranslation('deployments') - const versions = useAtomValue(appWorkflowVersionsAtom) - const versionsError = useAtomValue(appWorkflowVersionsErrorAtom) - const fetchNextPage = useAtomValue(appWorkflowVersionsFetchNextPageAtom) - const hasNextPage = useAtomValue(appWorkflowVersionsHasNextPageAtom) - const isFetching = useAtomValue(appWorkflowVersionsIsFetchingAtom) - const isFetchingNextPage = useAtomValue(appWorkflowVersionsIsFetchingNextPageAtom) - const isLoading = useAtomValue(appWorkflowVersionsIsLoadingAtom) - const { rootRef, sentinelRef } = useInfiniteScroll({ - error: versionsError, - fetchNextPage, - hasNextPage, - isFetching, - isFetchingNextPage, - isLoading, - }) - - return ( -
-
- {versions.map((version) => ( - - ))} -
- {isLoading && ( -
$.loading)} - className="flex h-20 items-center justify-center" - > - -
- )} - {!isLoading && versionsError && versions.length === 0 && ( -

- {tCommon(($) => $.error)} -

- )} - {!isLoading && !versionsError && versions.length === 0 && ( -
-

- {t(($) => $['studio.accessPoint.noPublishedTitle'])} -

- {publishHref && ( - - {t(($) => $['studio.accessPoint.goToPublish'])} - - - )} -
- )} - {isFetchingNextPage && versions.length > 0 && ( -
$.loading)} - className="flex h-8 items-center justify-center" - > - -
- )} -
-
- ) -} - -function versionSelectionTitle(request: DeploymentDialogRequest, deployTo: string, change: string) { - return request.kind === 'deploy' ? deployTo : `${change} · ${request.environment}` -} - -export function VersionSelection({ - appId, - request, - onSelect, -}: { - appId: string - request: DeploymentDialogRequest - onSelect: (version: DeploymentVersion) => void -}) { - const { t } = useTranslation('deployments') - const { t: tCommon } = useTranslation('common') - const title = versionSelectionTitle( - request, - t(($) => $['versions.deployTo'], { name: request.environment }), - t(($) => $['studio.changeVersion']), - ) - - return ( - <> - $['operation.close'])} - size="lg" - className="absolute top-5 right-5" - type="button" - > - - - } - /> -
- {title} - - {t(($) => $['studio.chooseVersionToDeploy'])} - -
- - - ) -} - -export function EmbeddedVersionSelection({ - disabled, - request, - onBack, - onSelect, -}: { - disabled: boolean - request: DeploymentDialogRequest - onBack: () => void - onSelect: (version: DeploymentVersion) => void -}) { - const { t } = useTranslation('deployments') - const { t: tCommon } = useTranslation('common') - const title = versionSelectionTitle( - request, - t(($) => $['versions.deployTo'], { name: request.environment }), - t(($) => $['studio.changeVersion']), - ) - - return ( -
-
- -

{title}

-

- {t(($) => $['studio.chooseVersionToDeploy'])} -

-
- -
- ) -} diff --git a/web/app/components/app/deploy/environment-table/activity-cell.tsx b/web/app/components/app/deploy/environment-table/activity-cell.tsx index 2469b8fe280..8fda519c2cf 100644 --- a/web/app/components/app/deploy/environment-table/activity-cell.tsx +++ b/web/app/components/app/deploy/environment-table/activity-cell.tsx @@ -31,6 +31,8 @@ export function ActivityCell({ activity }: { activity?: EnvironmentDeploymentOpe if (!activity) return -- const failed = activity.status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_FAILED + const inProgress = + activity.status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS return (
@@ -40,6 +42,12 @@ export function ActivityCell({ activity }: { activity?: EnvironmentDeploymentOpe failed ? 'text-text-warning' : 'text-text-secondary', )} > + {inProgress && ( + + )} {failed && ( )} diff --git a/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx b/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx index fff3d63b53f..7e1b8d9c92d 100644 --- a/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx +++ b/web/app/components/app/deploy/environment-table/deploy-menu/index.tsx @@ -12,11 +12,18 @@ import { } from '@langgenius/dify-ui/dropdown-menu' import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' -import { undeployedAppEnvironmentsAtom } from '../../state' +import Loading from '@/app/components/base/loading' +import { + appEnvironmentsIsErrorAtom, + appEnvironmentsIsLoadingAtom, + appEnvironmentsIsRetryingAtom, + appEnvironmentsRefetchAtom, + undeployedAppEnvironmentsAtom, +} from '../../state' type EnvironmentDeployMenuProps = { appearance?: 'empty' | 'header' - onSelectEnvironment?: (environment: AppEnvironment) => void + onSelectEnvironment: (environment: AppEnvironment) => void } export function EnvironmentDeployMenu({ @@ -25,7 +32,11 @@ export function EnvironmentDeployMenu({ }: EnvironmentDeployMenuProps) { const { t } = useTranslation('deployments') const { t: tCommon } = useTranslation('common') - const undeployedEnvironments = useAtomValue(undeployedAppEnvironmentsAtom) + const undeployedEnvironments = useAtomValue(undeployedAppEnvironmentsAtom) ?? [] + const isLoading = useAtomValue(appEnvironmentsIsLoadingAtom) + const isError = useAtomValue(appEnvironmentsIsErrorAtom) + const isRetrying = useAtomValue(appEnvironmentsIsRetryingAtom) + const refetchEnvironments = useAtomValue(appEnvironmentsRefetchAtom) const isEmptyState = appearance === 'empty' const label = tCommon(($) => $['appMenus.deploy']) @@ -40,28 +51,58 @@ export function EnvironmentDeployMenu({ } /> - + {t(($) => $['card.notDeployed'])} - {undeployedEnvironments.length === 0 && ( + {isLoading ? ( + + ) : isError ? ( +
+

+ {t(($) => $['common.loadFailed'])} +

+ +
+ ) : undeployedEnvironments.length === 0 ? (

{t(($) => $['deployDrawer.noNewEnvironmentAvailable'])}

- )} - {undeployedEnvironments.map((environment) => ( - onSelectEnvironment?.(environment)} - > - - - {environment.display_name} - - - ))} + ) : null} + {!isLoading && + !isError && + undeployedEnvironments.map((environment) => ( + onSelectEnvironment(environment)} + > + + + {environment.display_name} + + + ))}
diff --git a/web/app/components/app/deploy/environment-table/empty-state/index.tsx b/web/app/components/app/deploy/environment-table/empty-state/index.tsx index 40fac6b5cb5..82d47cc7cf0 100644 --- a/web/app/components/app/deploy/environment-table/empty-state/index.tsx +++ b/web/app/components/app/deploy/environment-table/empty-state/index.tsx @@ -9,7 +9,7 @@ import { EmptyTableSkeleton } from './skeleton' type EnvironmentTableEmptyProps = | { state: 'empty' - onSelectEnvironment?: (environment: AppEnvironment) => void + onSelectEnvironment: (environment: AppEnvironment) => void } | { state: 'error' diff --git a/web/app/components/app/deploy/environment-table/index.tsx b/web/app/components/app/deploy/environment-table/index.tsx index 1103233aa34..3711f311c64 100644 --- a/web/app/components/app/deploy/environment-table/index.tsx +++ b/web/app/components/app/deploy/environment-table/index.tsx @@ -4,18 +4,25 @@ import type { AppEnvironment, EnvironmentDeployment, } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { DeploymentVersion } from '../utils/version' import type { UndeployHandler } from './types' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { useAtomValue } from 'jotai' +import { memo } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { appEnvironmentDeploymentsAtom, appEnvironmentDeploymentsIsErrorAtom, - appEnvironmentDeploymentsIsFetchingAtom, appEnvironmentDeploymentsIsLoadingAtom, + appEnvironmentDeploymentsIsRetryingAtom, appEnvironmentDeploymentsRefetchAtom, appEnvironmentUsageAtom, + latestAppWorkflowVersionAtom, + latestAppWorkflowVersionIsErrorAtom, + latestAppWorkflowVersionIsRetryingAtom, + latestAppWorkflowVersionRefetchAtom, } from '../state' import { EnvironmentDeployMenu } from './deploy-menu' import { EnvironmentTableEmpty } from './empty-state' @@ -24,128 +31,175 @@ import { EnvironmentRow } from './row' type EnvironmentTableProps = { appId: string canViewAccessPoint: boolean - onChangeVersion?: (deployment: EnvironmentDeployment) => void - onDeployLatest?: (deployment: EnvironmentDeployment) => void - onDeployToEnvironment?: (environment: AppEnvironment) => void - onRedeploy?: (deployment: EnvironmentDeployment) => void - onUndeploy?: UndeployHandler + onChangeVersion: (deployment: EnvironmentDeployment) => void + onDeployLatest: (deployment: EnvironmentDeployment, version: DeploymentVersion) => void + onDeployToEnvironment: (environment: AppEnvironment) => void + onRedeploy: (deployment: EnvironmentDeployment) => void + onUndeploy: UndeployHandler } -export function EnvironmentTable({ - appId, - canViewAccessPoint, - onChangeVersion, - onDeployLatest, - onDeployToEnvironment, - onRedeploy, - onUndeploy, -}: EnvironmentTableProps) { - const { t } = useTranslation('deployments') - const deployments = useAtomValue(appEnvironmentDeploymentsAtom) ?? [] - const isLoading = useAtomValue(appEnvironmentDeploymentsIsLoadingAtom) - const isError = useAtomValue(appEnvironmentDeploymentsIsErrorAtom) - const isFetching = useAtomValue(appEnvironmentDeploymentsIsFetchingAtom) - const refetchDeployments = useAtomValue(appEnvironmentDeploymentsRefetchAtom) - const usage = useAtomValue(appEnvironmentUsageAtom) - const used = usage?.used ?? deployments.length - const total = usage?.total ?? deployments.length - const showLoadingState = isLoading && deployments.length === 0 - const showErrorState = isError && deployments.length === 0 - const isRetrying = showErrorState && isFetching - const showEmptyState = !isLoading && !isError && deployments.length === 0 - +function EnvironmentTableColumns() { return ( -
-
-
-

- {t(($) => $['studio.environments'])} -

- - · - - - {t(($) => $['studio.environmentsInUse'], { - total, - used, - })} - -
- -
- -
0 ? 'overflow-x-auto' : 'overflow-x-hidden', - )} - > - {showLoadingState ? ( - - ) : showErrorState ? ( - void refetchDeployments()} - /> - ) : showEmptyState ? ( - - ) : ( - - - - - - - - - - - - - - - - - - - - - {deployments.map((row) => ( - - ))} - -
- {t(($) => $['deployTab.col.environment'])} - - {t(($) => $['studio.liveVersion'])} - - {t(($) => $['deployTab.col.status'])} - - {t(($) => $['studio.lastActivity'])} - - {t(($) => $['studio.accessPoints'])} - - {t(($) => $['deployTab.col.actions'])} -
- )} -
-
+ + + + + + + + ) } + +function EnvironmentTableHeader() { + const { t } = useTranslation('deployments') + + return ( + + + + {t(($) => $['deployTab.col.environment'])} + + + {t(($) => $['studio.liveVersion'])} + + + {t(($) => $['deployTab.col.status'])} + + + {t(($) => $['studio.lastActivity'])} + + + {t(($) => $['studio.accessPoints'])} + + + {t(($) => $['deployTab.col.actions'])} + + + + ) +} + +export const EnvironmentTable = memo( + ({ + appId, + canViewAccessPoint, + onChangeVersion, + onDeployLatest, + onDeployToEnvironment, + onRedeploy, + onUndeploy, + }: EnvironmentTableProps) => { + const { t } = useTranslation('deployments') + const { t: tCommon } = useTranslation('common') + const deployments = useAtomValue(appEnvironmentDeploymentsAtom) ?? [] + const isLoading = useAtomValue(appEnvironmentDeploymentsIsLoadingAtom) + const isError = useAtomValue(appEnvironmentDeploymentsIsErrorAtom) + const isRetrying = useAtomValue(appEnvironmentDeploymentsIsRetryingAtom) + const refetchDeployments = useAtomValue(appEnvironmentDeploymentsRefetchAtom) + const usage = useAtomValue(appEnvironmentUsageAtom) + const latestVersion = useAtomValue(latestAppWorkflowVersionAtom) + const latestVersionIsError = useAtomValue(latestAppWorkflowVersionIsErrorAtom) + const latestVersionIsRetrying = useAtomValue(latestAppWorkflowVersionIsRetryingAtom) + const refetchLatestVersion = useAtomValue(latestAppWorkflowVersionRefetchAtom) + const deployableLatestVersion = latestVersionIsError ? undefined : latestVersion + const showLoadingState = isLoading && deployments.length === 0 + const showErrorState = isError && deployments.length === 0 + const showEmptyState = !isLoading && !isError && deployments.length === 0 + + return ( +
+
+
+

+ {t(($) => $['studio.environments'])} +

+ {usage && ( + <> + + · + + + {t(($) => $['studio.environmentsInUse'], usage)} + + + )} +
+ +
+ + {latestVersionIsError && ( +
+ +

+ {t(($) => $['studio.latestVersionLoadFailed'])} +

+ +
+ )} + +
0 ? 'overflow-x-auto' : 'overflow-x-hidden', + )} + > + {showLoadingState ? ( + + ) : showErrorState ? ( + void refetchDeployments()} + /> + ) : showEmptyState ? ( + + ) : ( + + + + + {deployments.map((row) => ( + + ))} + +
+ )} +
+
+ ) + }, +) diff --git a/web/app/components/app/deploy/environment-table/row-actions.tsx b/web/app/components/app/deploy/environment-table/row-actions.tsx index dca7b0f9d6b..976807ceab1 100644 --- a/web/app/components/app/deploy/environment-table/row-actions.tsx +++ b/web/app/components/app/deploy/environment-table/row-actions.tsx @@ -1,5 +1,6 @@ import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' -import type { EnvironmentDeploymentAction } from '../state' +import type { EnvironmentDeploymentAction } from '../utils/environment-deployment' +import type { DeploymentVersion } from '../utils/version' import type { UndeployHandler } from './types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' @@ -11,10 +12,11 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { IconButton } from '@langgenius/dify-ui/icon-button' +import { toast } from '@langgenius/dify-ui/toast' import { Fragment, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { getWorkflowVersionName } from '@/app/components/workflow/utils/version' -import { getEnvironmentDeploymentActions } from '../state' +import { getEnvironmentDeploymentActions } from '../utils/environment-deployment' import { UndeployConfirmDialog } from './undeploy-confirm-dialog' function rowActionLabel( @@ -52,17 +54,19 @@ const ROW_ACTION_ICON_CLASS_NAMES: Record void - onDeployLatest?: (deployment: EnvironmentDeployment) => void - onRedeploy?: (deployment: EnvironmentDeployment) => void - onUndeploy?: UndeployHandler + onChangeVersion: (deployment: EnvironmentDeployment) => void + onDeployLatest: (deployment: EnvironmentDeployment, version: DeploymentVersion) => void + onRedeploy: (deployment: EnvironmentDeployment) => void + onUndeploy: UndeployHandler }) { const { t } = useTranslation('deployments') const { t: tWorkflow } = useTranslation('workflow') @@ -73,7 +77,9 @@ export function EnvironmentRowActions({ ) const [showUndeployConfirm, setShowUndeployConfirm] = useState(false) const [isUndeploying, setIsUndeploying] = useState(false) - const actions = getEnvironmentDeploymentActions(row) + const actions = getEnvironmentDeploymentActions(row, { + deployLatestDisabled: !latestVersion, + }) const primaryAction = actions[0] const moreActions = actions.slice(1) @@ -83,21 +89,27 @@ export function EnvironmentRowActions({ switch (action.kind) { case 'changeVersion': - onChangeVersion?.(row) + onChangeVersion(row) break - case 'deployLatest': - onDeployLatest?.(row) + case 'deployLatest': { + if (!latestVersion) { + toast.error(t(($) => $['studio.latestVersionLoadFailed'])) + return + } + + onDeployLatest(row, latestVersion) break + } case 'redeploy': case 'retry': - onRedeploy?.(row) + onRedeploy(row) break case 'undeploy': setShowUndeployConfirm(true) break } }, - [onChangeVersion, onDeployLatest, onRedeploy, row], + [latestVersion, onChangeVersion, onDeployLatest, onRedeploy, row, t], ) const handleUndeploy = useCallback(async () => { @@ -105,7 +117,7 @@ export function EnvironmentRowActions({ setIsUndeploying(true) try { - await onUndeploy?.(row) + await onUndeploy(row) setShowUndeployConfirm(false) } catch { // The request layer reports the error; keep the dialog open so the user can retry. diff --git a/web/app/components/app/deploy/environment-table/row.tsx b/web/app/components/app/deploy/environment-table/row.tsx index b1d4ce7cd6d..f0bf850a87e 100644 --- a/web/app/components/app/deploy/environment-table/row.tsx +++ b/web/app/components/app/deploy/environment-table/row.tsx @@ -1,85 +1,98 @@ import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' -import type { AccessPoint } from '../access-point' +import type { AccessPoint } from '../utils/access-point' +import type { DeploymentVersion } from '../utils/version' import type { UndeployHandler } from './types' -import { ACCESS_POINT_ORDER, getAccessPointHref } from '../access-point' +import { RuntimeState } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { memo } from 'react' import { AccessPointIcon } from '../shared/access-point-icon' -import { DeploymentStatus } from '../shared/deployment-status' +import { RuntimeStateIndicator } from '../shared/runtime-state' import { VersionLabel } from '../shared/version-label' +import { ACCESS_POINT_ORDER, getAccessPointHref } from '../utils/access-point' import { ActivityCell } from './activity-cell' import { EnvironmentRowActions } from './row-actions' -export function EnvironmentRow({ - appId, - canViewAccessPoint, - row, - onChangeVersion, - onDeployLatest, - onRedeploy, - onUndeploy, -}: { - appId: string - canViewAccessPoint: boolean - row: EnvironmentDeployment - onChangeVersion?: (deployment: EnvironmentDeployment) => void - onDeployLatest?: (deployment: EnvironmentDeployment) => void - onRedeploy?: (deployment: EnvironmentDeployment) => void - onUndeploy?: UndeployHandler -}) { - const isAccessPointActive = (accessPoint: AccessPoint) => { - if (accessPoint === 'webApp') return row.access.enable_site - if (accessPoint === 'serviceApi') return row.access.enable_api - return false - } +export const EnvironmentRow = memo( + ({ + appId, + canViewAccessPoint, + latestVersion, + row, + onChangeVersion, + onDeployLatest, + onRedeploy, + onUndeploy, + }: { + appId: string + canViewAccessPoint: boolean + latestVersion?: DeploymentVersion + row: EnvironmentDeployment + onChangeVersion: (deployment: EnvironmentDeployment) => void + onDeployLatest: (deployment: EnvironmentDeployment, version: DeploymentVersion) => void + onRedeploy: (deployment: EnvironmentDeployment) => void + onUndeploy: UndeployHandler + }) => { + const runtimeState = row.deployment + ? row.deployment.runtimeState + : RuntimeState.RUNTIME_STATE_UNDEPLOYED - return ( - - -
- - - - - {row.environment.display_name} - -
- - - - - - - - - - - -
- {ACCESS_POINT_ORDER.map((accessPoint) => ( - - ))} -
- - - - - - ) -} + const isAccessPointActive = (accessPoint: AccessPoint) => { + if (accessPoint === 'webApp') return row.access.enable_site + if (accessPoint === 'serviceApi') return row.access.enable_api + return false + } + + return ( + + +
+ + + + + {row.environment.display_name} + +
+ + + + + + + + + + + +
+ {ACCESS_POINT_ORDER.map((accessPoint) => ( + + ))} +
+ + + + + + ) + }, +) +EnvironmentRow.displayName = 'EnvironmentRow' diff --git a/web/app/components/app/deploy/use-deploy-workflow.ts b/web/app/components/app/deploy/hooks/use-deploy-workflow.ts similarity index 100% rename from web/app/components/app/deploy/use-deploy-workflow.ts rename to web/app/components/app/deploy/hooks/use-deploy-workflow.ts diff --git a/web/app/components/app/deploy/use-refresh-app-environments-after-deployment-polling.ts b/web/app/components/app/deploy/hooks/use-refresh-app-environments-after-deployment-polling.ts similarity index 91% rename from web/app/components/app/deploy/use-refresh-app-environments-after-deployment-polling.ts rename to web/app/components/app/deploy/hooks/use-refresh-app-environments-after-deployment-polling.ts index 1a58435ee18..b3b02c37f3e 100644 --- a/web/app/components/app/deploy/use-refresh-app-environments-after-deployment-polling.ts +++ b/web/app/components/app/deploy/hooks/use-refresh-app-environments-after-deployment-polling.ts @@ -4,7 +4,7 @@ import type { ListEnvironmentDeploymentsResponse } from '@dify/contracts/enterpr import { hashKey, useQueryClient } from '@tanstack/react-query' import { useEffect } from 'react' import { consoleQuery } from '@/service/client' -import { hasInProgressEnvironmentDeployments } from './state' +import { hasDeploymentsRequiringPolling } from '../utils/environment-deployment' export function useRefreshAppEnvironmentsAfterDeploymentPolling(appId: string) { const queryClient = useQueryClient() @@ -32,7 +32,7 @@ export function useRefreshAppEnvironmentsAfterDeploymentPolling(appId: string) { environmentDeploymentsQuery.queryKey, ) - return hasInProgressEnvironmentDeployments(data?.environment_deployments ?? []) + return hasDeploymentsRequiringPolling(data?.environment_deployments ?? []) } let wasPolling = isPolling() diff --git a/web/app/components/app/deploy/use-undeploy-workflow.ts b/web/app/components/app/deploy/hooks/use-undeploy-workflow.ts similarity index 73% rename from web/app/components/app/deploy/use-undeploy-workflow.ts rename to web/app/components/app/deploy/hooks/use-undeploy-workflow.ts index 2622202ddb2..1dbf3a7705b 100644 --- a/web/app/components/app/deploy/use-undeploy-workflow.ts +++ b/web/app/components/app/deploy/hooks/use-undeploy-workflow.ts @@ -1,11 +1,14 @@ 'use client' import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' export function useUndeployWorkflow(appId: string) { + const { t } = useTranslation('deployments') const { mutateAsync } = useMutation( consoleQuery.enterprise.appDeploy.deploymentService.undeployWorkflow.mutationOptions(), ) @@ -13,7 +16,10 @@ export function useUndeployWorkflow(appId: string) { return useCallback( (deployment: EnvironmentDeployment) => { const workflowId = deployment.deployment?.current_version?.id - if (!workflowId) return + if (!workflowId) { + toast.error(t(($) => $['deployTab.undeployUnavailable'])) + return + } return mutateAsync({ params: { @@ -23,6 +29,6 @@ export function useUndeployWorkflow(appId: string) { }, }).then(() => undefined) }, - [appId, mutateAsync], + [appId, mutateAsync, t], ) } diff --git a/web/app/components/app/deploy/index.tsx b/web/app/components/app/deploy/index.tsx index 11a2d8d458f..8ef0fa7f57e 100644 --- a/web/app/components/app/deploy/index.tsx +++ b/web/app/components/app/deploy/index.tsx @@ -1,26 +1,34 @@ 'use client' -import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' -import type { DeploymentDialogRequest } from './deployment-dialog/types' -import type { DocPathWithoutLang } from '@/types/doc-paths' +import type { + AppEnvironment, + EnvironmentDeployment, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { DeploymentDialogRequest } from './types' +import type { DeploymentVersion } from './utils/version' import { useSuspenseQuery } from '@tanstack/react-query' import { useAtomValue } from 'jotai' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' -import { useDocLink } from '@/context/i18n' +import { getEnterpriseDocUrl, useLocale } from '@/context/i18n' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { userProfileQueryOptions } from '@/features/account-profile/client' +import { getDocLanguage } from '@/i18n-config/language' +import dynamic from '@/next/dynamic' import { AppModeEnum } from '@/types/app' import { getAppACLCapabilities } from '@/utils/permission' import { BuiltInEnvironmentCard } from './built-in-environment-card' -import { DeploymentDialog } from './deployment-dialog' import { EnvironmentTable } from './environment-table' -import { AppDeployStateBoundary, latestAppWorkflowVersionAtom } from './state' -import { useRefreshAppEnvironmentsAfterDeploymentPolling } from './use-refresh-app-environments-after-deployment-polling' -import { useUndeployWorkflow } from './use-undeploy-workflow' -import { toDeploymentVersion } from './version' +import { useRefreshAppEnvironmentsAfterDeploymentPolling } from './hooks/use-refresh-app-environments-after-deployment-polling' +import { useUndeployWorkflow } from './hooks/use-undeploy-workflow' +import { AppDeployStateBoundary } from './state' +import { toDeploymentVersion } from './utils/version' + +const DeploymentDialog = dynamic(() => + import('./deployment-dialog').then((module) => module.DeploymentDialog), +) function AppDeployContent({ appId, @@ -32,42 +40,71 @@ function AppDeployContent({ const { t } = useTranslation('deployments') const { t: tCommon } = useTranslation('common') const { t: tWorkflow } = useTranslation('workflow') - const docLink = useDocLink() - // TODO: Replace useDocLink with the EE-specific generator for the versioned - // `en/3.13.x/use/deploy/overview.mdx` URL once it is available. - const deployOverviewDocUrl = docLink('/use/deploy/overview' as DocPathWithoutLang) + const locale = useLocale() + const docLanguage = getDocLanguage(locale) + const deployOverviewDocUrl = getEnterpriseDocUrl('/use/deploy/overview', docLanguage) const [deploymentRequest, setDeploymentRequest] = useState() - const latestVersion = useAtomValue(latestAppWorkflowVersionAtom) useRefreshAppEnvironmentsAfterDeploymentPolling(appId) const undeployWorkflow = useUndeployWorkflow(appId) - const handleRedeploy = (deployment: EnvironmentDeployment) => { - const deploymentState = deployment.deployment - const version = - deploymentState?.latest_operation?.target_version ?? deploymentState?.current_version - const environment = deployment.environment.display_name - const environmentId = deployment.environment.id - - if (!version) { + const handleDeployToEnvironment = useCallback((environment: AppEnvironment) => { + setDeploymentRequest({ + environment: environment.display_name, + environmentId: environment.id, + kind: 'deploy', + }) + }, []) + const handleChangeVersion = useCallback((deployment: EnvironmentDeployment) => { + setDeploymentRequest({ + currentVersionId: deployment.deployment?.current_version?.id, + environment: deployment.environment.display_name, + environmentId: deployment.environment.id, + kind: 'changeVersion', + }) + }, []) + const handleDeployLatest = useCallback( + (deployment: EnvironmentDeployment, latestVersion: DeploymentVersion) => { setDeploymentRequest({ + currentVersionId: deployment.deployment?.current_version?.id, + environment: deployment.environment.display_name, + environmentId: deployment.environment.id, + initialVersion: latestVersion, + kind: 'deployLatest', + }) + }, + [], + ) + const handleRedeploy = useCallback( + (deployment: EnvironmentDeployment) => { + const deploymentState = deployment.deployment + const version = + deploymentState?.latest_operation?.target_version ?? deploymentState?.current_version + const environment = deployment.environment.display_name + const environmentId = deployment.environment.id + + if (!version) { + setDeploymentRequest({ + environment, + environmentId, + kind: 'changeVersion', + }) + return + } + + setDeploymentRequest({ + currentVersionId: deploymentState?.current_version?.id, environment, environmentId, - kind: 'changeVersion', + initialVersion: toDeploymentVersion( + version, + tWorkflow(($) => $['versionHistory.defaultName']), + ), + kind: 'redeploy', }) - return - } - - setDeploymentRequest({ - currentVersionId: deploymentState?.current_version?.id, - environment, - environmentId, - initialVersion: toDeploymentVersion( - version, - tWorkflow(($) => $['versionHistory.defaultName']), - ), - kind: 'redeploy', - }) - } + }, + [tWorkflow], + ) + const handleCloseDeploymentDialog = useCallback(() => setDeploymentRequest(undefined), []) return ( <> @@ -96,42 +133,21 @@ function AppDeployContent({ - setDeploymentRequest({ - environment: environment.display_name, - environmentId: environment.id, - kind: 'deploy', - }) - } - onChangeVersion={(deployment) => - setDeploymentRequest({ - currentVersionId: deployment.deployment?.current_version?.id, - environment: deployment.environment.display_name, - environmentId: deployment.environment.id, - kind: 'changeVersion', - }) - } - onDeployLatest={(deployment) => { - if (!latestVersion) return - - setDeploymentRequest({ - currentVersionId: deployment.deployment?.current_version?.id, - environment: deployment.environment.display_name, - environmentId: deployment.environment.id, - initialVersion: latestVersion, - kind: 'deployLatest', - }) - }} + onDeployToEnvironment={handleDeployToEnvironment} + onChangeVersion={handleChangeVersion} + onDeployLatest={handleDeployLatest} onRedeploy={handleRedeploy} onUndeploy={undeployWorkflow} />
- setDeploymentRequest(undefined)} - /> + {deploymentRequest && ( + + )} ) } @@ -152,7 +168,11 @@ export default function AppDeploy() { workspacePermissionKeys, }) - if (appDetail.mode !== AppModeEnum.WORKFLOW || !canDeploy) return null + if ( + (appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.ADVANCED_CHAT) || + !canDeploy + ) + return null return ( diff --git a/web/app/components/app/deploy/shared/access-point-icon.tsx b/web/app/components/app/deploy/shared/access-point-icon.tsx index 052a5efee37..a8d4b1daf2b 100644 --- a/web/app/components/app/deploy/shared/access-point-icon.tsx +++ b/web/app/components/app/deploy/shared/access-point-icon.tsx @@ -1,6 +1,6 @@ 'use client' -import type { AccessPoint } from '../access-point' +import type { AccessPoint } from '../utils/access-point' import { cn } from '@langgenius/dify-ui/cn' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/__tests__/credential-field.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.spec.tsx similarity index 52% rename from web/app/components/app/deploy/deployment-dialog/deployment-configuration/__tests__/credential-field.spec.tsx rename to web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.spec.tsx index c10c9f02e38..14491ac40e4 100644 --- a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/__tests__/credential-field.spec.tsx +++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/credential-field.spec.tsx @@ -1,4 +1,8 @@ -import type { CredentialSlot } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { + CredentialSlot, + WorkflowPath, + WorkflowReference, +} from '@dify/contracts/enterprise-app-deploy/types.gen' import { PluginCategory } from '@dify/contracts/enterprise-app-deploy/types.gen' import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' @@ -6,6 +10,14 @@ import { API_PREFIX } from '@/config' import { renderWithConsoleQuery } from '@/test/console/query-data' import { CredentialField } from '../credential-field' +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return createReactI18nextMock({ + 'deployments.studio.precheck.from': 'From', + 'deployments.studio.precheck.nodeCount_other': '{{count}} nodes', + }) +}) + const themeState = vi.hoisted(() => ({ theme: 'light', })) @@ -30,6 +42,21 @@ const credentialSlot: CredentialSlot = { provider_id: 'langgenius/deepseek', } +function workflowReference(name: string, suffix: string): WorkflowReference { + return { + app_id: `app-${suffix}`, + icon: '🤖', + icon_background: '#FFEAD5', + icon_type: 'emoji', + name, + workflow_id: `workflow-${suffix}`, + } +} + +function workflowPath(...workflows: WorkflowReference[]): WorkflowPath { + return { workflows } +} + function renderCredentialField() { return renderWithConsoleQuery( , @@ -89,4 +116,56 @@ describe('CredentialField', () => { ).toBeInTheDocument() expect(within(listbox).queryByRole('option')).not.toBeInTheDocument() }) + + it('shows the only source app name and opens its source preview', async () => { + const user = userEvent.setup() + const root = workflowReference('Deployed app', 'root') + const source = workflowReference('Order fulfillment', 'order') + renderWithConsoleQuery( + , + ) + + const sourceButton = screen.getByRole('button', { + name: 'Deepseek: From Order fulfillment', + }) + await user.hover(sourceButton) + + const preview = await screen.findByRole('dialog', { name: 'Deepseek' }) + expect( + within(preview).getByRole('link', { name: /Deployed app.*Order fulfillment/ }), + ).toHaveAttribute('href', '/app/app-order/workflow') + }) + + it('shows only a count when several source apps use the credential', async () => { + const user = userEvent.setup() + renderWithConsoleQuery( + , + ) + + const sourceButton = screen.getByRole('button', { name: 'Deepseek: From 2 nodes' }) + expect(sourceButton).not.toHaveTextContent('Order fulfillment') + await user.click(sourceButton) + + const preview = await screen.findByRole('dialog', { name: 'Deepseek' }) + expect(within(preview).getAllByRole('link')).toHaveLength(2) + }) }) diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.spec.tsx new file mode 100644 index 00000000000..b0c45aab3d3 --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-configuration-cache.spec.tsx @@ -0,0 +1,171 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor, within } from '@testing-library/react' +import { DeploymentConfiguration } from '../index' + +const APP_ID = 'app-1' +const ENVIRONMENT_ID = 'staging' +const WORKFLOW_ID = 'workflow-1' + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { + retry: false, + staleTime: 5 * 60 * 1000, + }, + }, + }) +} + +function renderConfiguration(queryClient: QueryClient) { + return render( + + + , + ) +} + +describe('DeploymentConfiguration query freshness', () => { + it('fetches precheck and deployment options again whenever the form is reopened', async () => { + const requestCounts = { + deploymentOptions: 0, + precheck: 0, + } + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const pathname = new URL(request.url).pathname + + if ( + pathname.endsWith(`/enterprise/app-deploy/apps/${APP_ID}/workflows/${WORKFLOW_ID}:precheck`) + ) { + requestCounts.precheck += 1 + return new Response(JSON.stringify({ unsupported_nodes: [] }), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }) + } + + if ( + pathname.endsWith( + `/enterprise/app-deploy/apps/${APP_ID}/workflows/${WORKFLOW_ID}/environments/${ENVIRONMENT_ID}/deployment-options`, + ) + ) { + requestCounts.deploymentOptions += 1 + return new Response( + JSON.stringify({ credential_slots: [], environment_variable_groups: [] }), + { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }, + ) + } + + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + }) + + const queryClient = createQueryClient() + const firstRender = renderConfiguration(queryClient) + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'common.appMenus.deploy' })).toBeEnabled() + }) + expect(requestCounts).toEqual({ deploymentOptions: 1, precheck: 1 }) + + firstRender.unmount() + await waitFor(() => { + expect(queryClient.getQueryCache().getAll()).toHaveLength(0) + }) + + renderConfiguration(queryClient) + + await waitFor(() => { + expect(requestCounts).toEqual({ deploymentOptions: 2, precheck: 2 }) + expect(screen.getByRole('button', { name: 'common.appMenus.deploy' })).toBeEnabled() + }) + }) + + it('shows the backend message when precheck fails', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const pathname = new URL(request.url).pathname + + if ( + pathname.endsWith(`/enterprise/app-deploy/apps/${APP_ID}/workflows/${WORKFLOW_ID}:precheck`) + ) { + return new Response( + JSON.stringify({ + code: 422, + message: 'workflow tool dependencies form a cycle', + metadata: {}, + reason: 'APPDEPLOY_WORKFLOW_NOT_DEPLOYABLE', + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 422, + }, + ) + } + + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + }) + + const view = renderConfiguration(createQueryClient()) + + const alert = await within(view.container).findByRole('alert') + expect(alert).toHaveTextContent('workflow tool dependencies form a cycle') + }) + + it('shows the backend message when deployment options fail', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const pathname = new URL(request.url).pathname + + if ( + pathname.endsWith(`/enterprise/app-deploy/apps/${APP_ID}/workflows/${WORKFLOW_ID}:precheck`) + ) { + return new Response(JSON.stringify({ unsupported_nodes: [] }), { + headers: { 'Content-Type': 'application/json' }, + status: 200, + }) + } + + if ( + pathname.endsWith( + `/enterprise/app-deploy/apps/${APP_ID}/workflows/${WORKFLOW_ID}/environments/${ENVIRONMENT_ID}/deployment-options`, + ) + ) { + return new Response( + JSON.stringify({ + code: 422, + message: 'workflow deployment options are invalid', + metadata: {}, + reason: 'APPDEPLOY_WORKFLOW_NOT_DEPLOYABLE', + }), + { + headers: { 'Content-Type': 'application/json' }, + status: 422, + }, + ) + } + + throw new Error(`Unexpected request: ${request.method} ${request.url}`) + }) + + const view = renderConfiguration(createQueryClient()) + + const alert = await within(view.container).findByRole('alert') + expect(alert).toHaveTextContent('workflow deployment options are invalid') + }) +}) diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.spec.tsx new file mode 100644 index 00000000000..623c8fef38b --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/deployment-precheck-alert.spec.tsx @@ -0,0 +1,131 @@ +import type { + UnsupportedNode, + WorkflowAsToolDependency, + WorkflowReference, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { DeploymentPrecheckAlert } from '../deployment-precheck-alert' + +vi.mock('react-i18next', async () => { + const { createReactI18nextMock } = await import('@/test/i18n-mock') + return createReactI18nextMock({ + 'deployments.studio.precheck.description': 'It contains node types that are not yet supported:', + 'deployments.studio.precheck.from': 'From', + 'deployments.studio.precheck.nodeCount_other': '{{count}} nodes', + 'deployments.studio.precheck.supportMessage': + 'Support for these node types is coming in a future release.', + 'deployments.studio.precheck.title': "This version can't be deployed to this environment", + }) +}) + +vi.mock('../use-provider-icon', () => ({ + useGetProviderIcon: () => () => undefined, +})) + +function workflowReference(name: string, suffix: string): WorkflowReference { + return { + app_id: `app-${suffix}`, + icon: '🤖', + icon_background: '#FFEAD5', + icon_type: 'emoji', + name, + workflow_id: `workflow-${suffix}`, + } +} + +function workflowAsToolDependency(...paths: WorkflowReference[][]): WorkflowAsToolDependency { + return { + paths: paths.map((workflows) => ({ workflows })), + } +} + +function unsupportedNode(id: string, owner: Partial = {}): UnsupportedNode { + return { + id, + title: 'Slack', + type: 'human-input', + ...owner, + } +} + +describe('DeploymentPrecheckAlert', () => { + it('does not show a source for an unsupported node from the deployed app', () => { + render() + + expect(screen.getByText('Slack')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /From/ })).not.toBeInTheDocument() + }) + + it('shows the leaf subworkflow name and its complete dependency path', async () => { + const user = userEvent.setup() + const root = workflowReference('Deployed app', 'root') + const translation = workflowReference('Translation', 'translation') + const source = workflowReference('Order fulfillment', 'order') + render( + , + ) + + await user.hover(screen.getByRole('button', { name: 'Slack: From Order fulfillment' })) + + const preview = await screen.findByRole('dialog', { name: 'Slack' }) + const sourceLink = within(preview).getByRole('link', { + name: /Deployed app.*Translation.*Order fulfillment/, + }) + expect(sourceLink).toHaveAttribute('href', '/app/app-order/workflow') + expect(sourceLink).toHaveAttribute('target', '_blank') + }) + + it('includes the deployed app when matching nodes also come from workflow-as-tool paths', async () => { + const user = userEvent.setup() + const root = workflowReference('Deployed app', 'root') + const orderSource = workflowReference('Order fulfillment', 'order') + const auditSource = workflowReference('Audit workflow', 'audit') + const sharedSource = workflowReference('Shared workflow', 'shared') + const orderPath = [root, orderSource, sharedSource] + const auditPath = [root, auditSource, sharedSource] + render( + , + ) + + expect(screen.getAllByText('Slack')).toHaveLength(1) + + await user.click(screen.getByRole('button', { name: 'Slack: From 3 nodes' })) + + const preview = await screen.findByRole('dialog', { name: 'Slack' }) + expect(within(preview).getAllByRole('link')).toHaveLength(3) + expect(within(preview).getByRole('link', { name: 'Deployed app' })).toHaveAttribute( + 'href', + '/app/app-root/workflow', + ) + expect( + within(preview).getByRole('link', { + name: /Deployed app.*Order fulfillment.*Shared workflow/, + }), + ).toBeInTheDocument() + expect( + within(preview).getByRole('link', { + name: /Deployed app.*Audit workflow.*Shared workflow/, + }), + ).toBeInTheDocument() + }) +}) diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.spec.tsx b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.spec.tsx new file mode 100644 index 00000000000..b8dc243de6f --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/environment-variable-field.spec.tsx @@ -0,0 +1,188 @@ +import type { EnvironmentVariableSlot } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal' +import { EnvVarValueSource, EnvVarValueType } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { EnvironmentVariableField } from '../environment-variable-field' + +const activeModelList = vi.hoisted(() => [ + { + label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' }, + models: [ + { + deprecated: false, + has_invalid_load_balancing_configs: false, + label: { en_US: 'Chat model', zh_Hans: 'Chat model' }, + load_balancing_enabled: false, + model: 'chat-model', + model_properties: { mode: 'chat' }, + model_type: 'llm', + status: 'active', + }, + { + deprecated: false, + has_invalid_load_balancing_configs: false, + label: { en_US: 'Chat model v2', zh_Hans: 'Chat model v2' }, + load_balancing_enabled: false, + model: 'chat-model-v2', + model_properties: { mode: 'chat' }, + model_type: 'llm', + status: 'active', + }, + { + deprecated: false, + has_invalid_load_balancing_configs: false, + label: { en_US: 'Completion model', zh_Hans: 'Completion model' }, + load_balancing_enabled: false, + model: 'completion-model', + model_properties: { mode: 'completion' }, + model_type: 'llm', + status: 'active', + }, + ], + provider: 'langgenius/openai/openai', + }, +]) + +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useTextGenerationCurrentProviderAndModelAndModelList: () => ({ + activeTextGenerationModelList: activeModelList, + }), +})) + +vi.mock( + '@/app/components/header/account-setting/model-provider-page/model-parameter-modal', + () => ({ + default: ({ + completionParams, + modelId, + modelList = [], + modelSelectorReadonly, + provider, + readonly, + onCompletionParamsChange, + setModel, + }: ModelParameterModalProps) => { + const selectableModels = modelList.flatMap((providerItem) => + providerItem.models.map((model) => ({ model, provider: providerItem.provider })), + ) + const nextModel = selectableModels.find(({ model }) => model.model === 'chat-model-v2') + + return ( +
+ + + {selectableModels.map(({ model }) => ( + {model.model} + ))} +
+ ) + }, + }), +) + +const llmSlot: EnvironmentVariableSlot = { + configured_value: { + completion_params: { temperature: 0.2 }, + mode: 'chat', + name: 'chat-model', + provider: 'langgenius/openai/openai', + }, + description: 'Chat model used by the workflow', + has_configured_value: true, + has_last_deployed_value: false, + key: 'MODEL', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_LLM, +} + +describe('EnvironmentVariableField', () => { + it('selects a compatible custom LLM and keeps its parameters structured', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render( + undefined} + onChange={onChange} + />, + ) + + const modelSelector = await screen.findByRole('button', { name: 'Select deployment model' }) + expect(modelSelector).toBeDisabled() + expect(modelSelector).toHaveTextContent('langgenius/openai/openai/chat-model') + expect(screen.getByText('workflow.blocks.llm')).toBeInTheDocument() + expect(screen.getByText('chat-model-v2')).toBeInTheDocument() + expect(screen.queryByText('completion-model')).not.toBeInTheDocument() + + await user.click(screen.getByRole('combobox', { name: /MODEL/ })) + await user.click( + await screen.findByRole('option', { + name: 'deployments.deployDrawer.envVarSource.literal', + }), + ) + + expect(modelSelector).toBeEnabled() + await user.click(modelSelector) + await user.click(screen.getByRole('button', { name: 'Set temperature' })) + + expect(onChange).toHaveBeenLastCalledWith('workflow-1', 'MODEL', { + customValue: { + completion_params: { temperature: 0.4 }, + mode: 'chat', + name: 'chat-model-v2', + provider: 'langgenius/openai/openai', + }, + source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }) + }) + + it('shows the last deployed LLM as a read-only source', async () => { + render( + undefined} + onChange={vi.fn()} + />, + ) + + const modelSelector = await screen.findByRole('button', { name: 'Select deployment model' }) + expect(modelSelector).toBeDisabled() + expect(modelSelector).toHaveTextContent('langgenius/openai/openai/chat-model-v2') + expect(screen.getByRole('combobox', { name: /MODEL/ })).toHaveTextContent( + 'deployments.deployDrawer.envVarSource.lastDeployment', + ) + }) +}) diff --git a/web/app/components/app/deploy/shared/deployment-configuration/__tests__/workflow-deployment-input.spec.ts b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/workflow-deployment-input.spec.ts new file mode 100644 index 00000000000..e7873c56f4e --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/__tests__/workflow-deployment-input.spec.ts @@ -0,0 +1,306 @@ +import type { GetWorkflowDeploymentOptionsResponse } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { DeploymentConfigurationValues } from '../use-deployment-configuration-values' +import { + EnvVarValueSource, + EnvVarValueType, + PluginCategory, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import { environmentVariableSelectionKey } from '../use-deployment-configuration-values' +import { + credentialSlotKey, + hasValidDeploymentEnvironmentVariables, + workflowDeploymentInput, +} from '../utils/workflow-deployment-input' + +const deploymentOptions: GetWorkflowDeploymentOptionsResponse = { + credential_slots: [], + environment_variable_groups: [ + { + environment_variable_slots: [ + { + description: 'Chat model used by the workflow', + has_configured_value: false, + has_last_deployed_value: false, + key: 'MODEL', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_LLM, + }, + ], + from_app: { + app_id: 'app-1', + icon: '🤖', + icon_background: '#FFFFFF', + icon_type: 'emoji', + name: 'Primary workflow', + workflow_id: 'workflow-1', + }, + }, + ], +} + +describe('workflowDeploymentInput', () => { + it('keeps a custom LLM value structured inside its workflow group', () => { + const llmValue = { + completion_params: { temperature: 0.2 }, + mode: 'chat', + name: 'chat-model', + provider: 'langgenius/openai/openai', + } + const values: DeploymentConfigurationValues = { + credentials: {}, + environmentVariables: { + [environmentVariableSelectionKey('workflow-1', 'MODEL')]: { + customValue: llmValue, + source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }, + }, + } + + expect(hasValidDeploymentEnvironmentVariables(deploymentOptions, values)).toBe(true) + expect(workflowDeploymentInput(deploymentOptions, values)).toEqual({ + credentials: [], + environment_variable_groups: [ + { + environment_variables: [ + { + key: 'MODEL', + value: llmValue, + value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }, + ], + workflow_id: 'workflow-1', + }, + ], + }) + }) + + it('rejects a custom LLM source without a selected model', () => { + const values: DeploymentConfigurationValues = { + credentials: {}, + environmentVariables: { + [environmentVariableSelectionKey('workflow-1', 'MODEL')]: { + customValue: '', + source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }, + }, + } + + expect(hasValidDeploymentEnvironmentVariables(deploymentOptions, values)).toBe(false) + expect(workflowDeploymentInput(deploymentOptions, values)).toBeUndefined() + }) + + it.each([ + ['String', EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING], + ['Number', EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER], + ['Secret', EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET], + ])('rejects an empty custom %s value', (_, valueType) => { + const options: GetWorkflowDeploymentOptionsResponse = { + ...deploymentOptions, + environment_variable_groups: deploymentOptions.environment_variable_groups.map((group) => ({ + ...group, + environment_variable_slots: group.environment_variable_slots.map((slot) => ({ + ...slot, + key: 'VALUE', + value_type: valueType, + })), + })), + } + const values: DeploymentConfigurationValues = { + credentials: {}, + environmentVariables: { + [environmentVariableSelectionKey('workflow-1', 'VALUE')]: { + customValue: '', + source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }, + }, + } + + expect(hasValidDeploymentEnvironmentVariables(options, values)).toBe(false) + expect(workflowDeploymentInput(options, values)).toBeUndefined() + }) + + it.each([' ', 'not-a-number', 'Infinity'])( + 'rejects an invalid custom Number value: %j', + (customValue) => { + const options: GetWorkflowDeploymentOptionsResponse = { + ...deploymentOptions, + environment_variable_groups: deploymentOptions.environment_variable_groups.map((group) => ({ + ...group, + environment_variable_slots: group.environment_variable_slots.map((slot) => ({ + ...slot, + key: 'PORT', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER, + })), + })), + } + const values: DeploymentConfigurationValues = { + credentials: {}, + environmentVariables: { + [environmentVariableSelectionKey('workflow-1', 'PORT')]: { + customValue, + source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }, + }, + } + + expect(hasValidDeploymentEnvironmentVariables(options, values)).toBe(false) + expect(workflowDeploymentInput(options, values)).toBeUndefined() + }, + ) + + it('normalizes a valid custom Number value before building the deployment payload', () => { + const options: GetWorkflowDeploymentOptionsResponse = { + ...deploymentOptions, + environment_variable_groups: deploymentOptions.environment_variable_groups.map((group) => ({ + ...group, + environment_variable_slots: group.environment_variable_slots.map((slot) => ({ + ...slot, + key: 'PORT', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER, + })), + })), + } + const values: DeploymentConfigurationValues = { + credentials: {}, + environmentVariables: { + [environmentVariableSelectionKey('workflow-1', 'PORT')]: { + customValue: '3000.5', + source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }, + }, + } + + expect(workflowDeploymentInput(options, values)).toEqual({ + credentials: [], + environment_variable_groups: [ + { + environment_variables: [ + { + key: 'PORT', + value: 3000.5, + value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }, + ], + workflow_id: 'workflow-1', + }, + ], + }) + }) + + it.each([ + ['configured', true, false, { configured_value: '' }], + ['last deployed', false, true, { last_deployed_value: '' }], + ])( + 'rejects an empty %s environment variable value', + (_, hasConfiguredValue, hasLastDeployedValue, sourceValue) => { + const options: GetWorkflowDeploymentOptionsResponse = { + ...deploymentOptions, + environment_variable_groups: deploymentOptions.environment_variable_groups.map((group) => ({ + ...group, + environment_variable_slots: group.environment_variable_slots.map((slot) => ({ + ...slot, + ...sourceValue, + has_configured_value: hasConfiguredValue, + has_last_deployed_value: hasLastDeployedValue, + key: 'VALUE', + value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING, + })), + })), + } + const values: DeploymentConfigurationValues = { + credentials: {}, + environmentVariables: {}, + } + + expect(hasValidDeploymentEnvironmentVariables(options, values)).toBe(false) + expect(workflowDeploymentInput(options, values)).toBeUndefined() + }, + ) + + it.each([ + { mode: '', name: '', provider: '' }, + { mode: 'embedding', name: 'embedding-model', provider: 'provider' }, + ])('rejects an invalid custom LLM value', (customValue) => { + const values: DeploymentConfigurationValues = { + credentials: {}, + environmentVariables: { + [environmentVariableSelectionKey('workflow-1', 'MODEL')]: { + customValue, + source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }, + }, + } + + expect(hasValidDeploymentEnvironmentVariables(deploymentOptions, values)).toBe(false) + expect(workflowDeploymentInput(deploymentOptions, values)).toBeUndefined() + }) + + it('rejects a custom LLM value with a different mode from the configured value', () => { + const options: GetWorkflowDeploymentOptionsResponse = { + ...deploymentOptions, + environment_variable_groups: deploymentOptions.environment_variable_groups.map((group) => ({ + ...group, + environment_variable_slots: group.environment_variable_slots.map((slot) => ({ + ...slot, + configured_value: { + mode: 'chat', + name: 'chat-model', + provider: 'provider', + }, + has_configured_value: true, + })), + })), + } + const values: DeploymentConfigurationValues = { + credentials: {}, + environmentVariables: { + [environmentVariableSelectionKey('workflow-1', 'MODEL')]: { + customValue: { + mode: 'completion', + name: 'completion-model', + provider: 'provider', + }, + source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM, + }, + }, + } + + expect(hasValidDeploymentEnvironmentVariables(options, values)).toBe(false) + expect(workflowDeploymentInput(options, values)).toBeUndefined() + }) + + it.each(['', 'removed-credential'])( + 'rejects an empty or unavailable credential selection: %j', + (credentialId) => { + const credentialSlot = { + candidates: [ + { + category: PluginCategory.PLUGIN_CATEGORY_MODEL, + credential_id: 'credential-1', + display_name: 'Credential 1', + from_enterprise: false, + provider_id: 'provider', + }, + { + category: PluginCategory.PLUGIN_CATEGORY_MODEL, + credential_id: 'credential-2', + display_name: 'Credential 2', + from_enterprise: false, + provider_id: 'provider', + }, + ], + category: PluginCategory.PLUGIN_CATEGORY_MODEL, + provider_id: 'provider', + } + const options: GetWorkflowDeploymentOptionsResponse = { + credential_slots: [credentialSlot], + environment_variable_groups: [], + } + const values: DeploymentConfigurationValues = { + credentials: { [credentialSlotKey(credentialSlot)]: credentialId }, + environmentVariables: {}, + } + + expect(workflowDeploymentInput(options, values)).toBeUndefined() + }, + ) +}) diff --git a/web/app/components/app/deploy/shared/deployment-configuration/content.tsx b/web/app/components/app/deploy/shared/deployment-configuration/content.tsx new file mode 100644 index 00000000000..1a725c9ce15 --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/content.tsx @@ -0,0 +1,148 @@ +'use client' + +import type { DeploymentDialogRequest } from '../../types' +import type { DeploymentVersion } from '../../utils/version' +import type { DeploymentConfigurationQueryState } from './use-deployment-configuration-queries' +import type { DeploymentConfigurationValuesController } from './use-deployment-configuration-values' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import { CredentialsSection } from './credentials-section' +import { DeploymentPrecheckAlert } from './deployment-precheck-alert' +import { EnvironmentVariablesSection } from './environment-variables-section' +import { getDeploymentErrorMessage } from './utils/deployment-error' + +function ConfigurationError({ messages }: { messages: string[] }) { + const { t } = useTranslation('common') + + return ( +
+ +
+

{t(($) => $.error)}

+
    + {messages.map((message) => ( +
  • + {message} +
  • + ))} +
+
+
+ ) +} + +export function DeploymentConfigurationContent({ + compact = false, + configurationValues, + queryState, + request, + version, +}: { + compact?: boolean + configurationValues: DeploymentConfigurationValuesController + queryState: DeploymentConfigurationQueryState + request: DeploymentDialogRequest + version: DeploymentVersion +}) { + const { t } = useTranslation('deployments') + const { t: tCommon } = useTranslation('common') + const horizontalPaddingClassName = compact ? 'px-4' : 'px-6' + const { + deploymentOptions, + deploymentOptionsError, + isLoadingDeploymentOptions, + isPrecheckBlocked, + isPrechecking, + precheck, + precheckError, + } = queryState + const { + credentials, + getEnvironmentVariableSelection, + setCredential, + setEnvironmentVariableSelection, + } = configurationValues + const unsupportedNodes = precheck?.unsupported_nodes ?? [] + const showPrecheckAlert = !isPrechecking && !precheckError && isPrecheckBlocked + const showConfiguration = Boolean(deploymentOptions) + const hasCredentialSlots = Boolean(deploymentOptions?.credential_slots.length) + + return ( + <> +
+
+
+ + {version.name} +
+ +
+ + + {request.environment} + +
+
+
+ +
+ {isPrechecking && } + {!isPrechecking && precheckError && ( +
+ $.error)]} + /> +
+ )} + {showPrecheckAlert && ( +
+ +
+ )} + {isLoadingDeploymentOptions && } + {!isLoadingDeploymentOptions && deploymentOptionsError && ( +
+ $['deployDrawer.bindingOptionsFailed']), + ]} + /> +
+ )} + {showConfiguration && ( + <> + + + + )} +
+ + ) +} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/credential-field.tsx b/web/app/components/app/deploy/shared/deployment-configuration/credential-field.tsx similarity index 76% rename from web/app/components/app/deploy/deployment-dialog/deployment-configuration/credential-field.tsx rename to web/app/components/app/deploy/shared/deployment-configuration/credential-field.tsx index 3ea848db8c6..c5bb3d37915 100644 --- a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/credential-field.tsx +++ b/web/app/components/app/deploy/shared/deployment-configuration/credential-field.tsx @@ -1,4 +1,4 @@ -import type { CredentialSlot } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { CredentialSlot, WorkflowPath } from '@dify/contracts/enterprise-app-deploy/types.gen' import { PluginCategory } from '@dify/contracts/enterprise-app-deploy/types.gen' import { Select, @@ -14,23 +14,17 @@ import { useTranslation } from 'react-i18next' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' import useTheme from '@/hooks/use-theme' import { Theme } from '@/types/app' - -function providerName(providerId: string) { - const name = providerId.split('/').filter(Boolean).at(-1) ?? providerId - - return name - .split(/[-_]/) - .filter(Boolean) - .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) - .join(' ') -} +import { credentialProviderName } from './utils/workflow-deployment-input' +import { WorkflowDependencyPreview } from './workflow-source-popover' export function CredentialField({ slot, + paths = [], value, onChange, }: { slot: CredentialSlot + paths?: WorkflowPath[] value?: string onChange: (value: string) => void }) { @@ -39,7 +33,7 @@ export function CredentialField({ const { getIconUrl } = useGetIcon() const { theme } = useTheme() const selectedOption = slot.candidates.find((candidate) => candidate.credential_id === value) - const name = providerName(slot.provider_id) + const name = credentialProviderName(slot.provider_id) const iconFileName = theme === Theme.dark ? slot.icon_dark || slot.icon : slot.icon || slot.icon_dark const iconSrc = iconFileName ? getIconUrl(iconFileName) : undefined @@ -52,19 +46,24 @@ export function CredentialField({ return (
-
- - {iconSrc ? ( - - ) : ( - +
+
+ + {iconSrc ? ( + + ) : ( + + )} + + {name} + {category && ( + {category} )} - - {name} - {category && {category}} +
+
{ + if (!nextSource) return + + const nextSelection = { + ...selection, + source: nextSource, + } + updateSelection(nextSelection) + }} + > + $['deployDrawer.envVarSource.ariaLabel'], { + key: slot.key, + })} + size="small" + className="h-7 w-auto max-w-48 shrink-0 border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 shadow-xs backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:bg-components-button-secondary-bg" + > + {sourceLabel} + + + {availableSources.map((option) => ( + + {sourceLabels[option]} + + + ))} + + +
+ {isLLM ? ( +
+ + updateSelection({ + ...selection, + customValue: nextCustomValue, + }) + } + /> +
+ ) : ( + + updateSelection({ + ...selection, + customValue: event.target.value, + }) + } + /> + )} + {slot.description && ( +

{slot.description}

+ )} +
+ ) + }, +) +EnvironmentVariableField.displayName = 'EnvironmentVariableField' diff --git a/web/app/components/app/deploy/shared/deployment-configuration/environment-variables-section.tsx b/web/app/components/app/deploy/shared/deployment-configuration/environment-variables-section.tsx new file mode 100644 index 00000000000..eae0486fda1 --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/environment-variables-section.tsx @@ -0,0 +1,103 @@ +import type { EnvironmentVariableGroup } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { DeploymentConfigurationValuesController } from './use-deployment-configuration-values' +import { cn } from '@langgenius/dify-ui/cn' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { EnvironmentVariableField } from './environment-variable-field' +import { SectionHeading } from './section-heading' +import { SubworkflowSourceTitle, WorkflowReferenceIcon } from './workflow-source-popover' + +function EnvironmentVariableGroupFields({ + group, + getEnvironmentVariableSelection, + setEnvironmentVariableSelection, +}: { + group: EnvironmentVariableGroup + getEnvironmentVariableSelection: DeploymentConfigurationValuesController['getEnvironmentVariableSelection'] + setEnvironmentVariableSelection: DeploymentConfigurationValuesController['setEnvironmentVariableSelection'] +}) { + const owner = group.from_app ?? group.from_workflow_as_tool?.workflow + if (!owner) return null + + return ( +
+
+ + {group.from_workflow_as_tool ? ( + + ) : ( + + {owner.name} + + )} +
+
+ + + +
+ {group.environment_variable_slots.map((slot) => ( + + ))} +
+
+
+ ) +} + +export const EnvironmentVariablesSection = memo( + ({ + environmentVariableGroups, + getEnvironmentVariableSelection, + hasCredentialSlots, + horizontalPaddingClassName, + setEnvironmentVariableSelection, + }: { + environmentVariableGroups: EnvironmentVariableGroup[] + getEnvironmentVariableSelection: DeploymentConfigurationValuesController['getEnvironmentVariableSelection'] + hasCredentialSlots: boolean + horizontalPaddingClassName: string + setEnvironmentVariableSelection: DeploymentConfigurationValuesController['setEnvironmentVariableSelection'] + }) => { + const { t } = useTranslation('deployments') + const visibleGroups = environmentVariableGroups.filter( + (group) => group.environment_variable_slots.length > 0, + ) + + if (visibleGroups.length === 0) return null + + return ( +
+ $['deployDrawer.envVars'])} + description={t(($) => $['studio.environmentVariablesDescription'])} + /> + {visibleGroups.map((group) => { + const owner = group.from_app ?? group.from_workflow_as_tool?.workflow + if (!owner) return null + + return ( + + ) + })} +
+ ) + }, +) diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/index.tsx b/web/app/components/app/deploy/shared/deployment-configuration/index.tsx similarity index 68% rename from web/app/components/app/deploy/deployment-dialog/deployment-configuration/index.tsx rename to web/app/components/app/deploy/shared/deployment-configuration/index.tsx index ca40aacbae2..aa9afd8cae9 100644 --- a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/index.tsx +++ b/web/app/components/app/deploy/shared/deployment-configuration/index.tsx @@ -1,16 +1,15 @@ 'use client' -import type { DeploymentVersion } from '../../version' -import type { DeploymentDialogRequest } from '../types' +import type { DeploymentDialogRequest } from '../../types' +import type { DeploymentVersion } from '../../utils/version' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { DialogClose, DialogTitle } from '@langgenius/dify-ui/dialog' import { IconButton } from '@langgenius/dify-ui/icon-button' import { useTranslation } from 'react-i18next' -import { useDeployWorkflow } from '../../use-deploy-workflow' import { DeploymentConfigurationContent } from './content' +import { useDeploymentConfigurationForm } from './use-deployment-configuration-form' import { useDeploymentConfigurationQueries } from './use-deployment-configuration-queries' import { useDeploymentConfigurationValues } from './use-deployment-configuration-values' -import { workflowDeploymentInput } from './workflow-deployment-input' export function DeploymentConfiguration({ appId, @@ -35,47 +34,26 @@ export function DeploymentConfiguration({ }) { const { t } = useTranslation('deployments') const { t: tCommon } = useTranslation('common') - const [configurationValues, setConfigurationValues] = useDeploymentConfigurationValues() + const configurationValues = useDeploymentConfigurationValues() const queryState = useDeploymentConfigurationQueries({ appId, environmentId: request.environmentId, workflowId: version.id, }) - const deploymentInput = queryState.deploymentOptions - ? workflowDeploymentInput(queryState.deploymentOptions, configurationValues) - : undefined - const deployMutation = useDeployWorkflow({ + const { canDeploy, handleSubmit, isDeploying } = useDeploymentConfigurationForm({ appId, + configurationValues, + disabled, + environmentId: request.environmentId, invalidateAppEnvironmentsOnSuccess, - onSuccess: (response) => { - onDeploymentStarted?.(response.operation.id) - onClose() - }, + queryState, + workflowId: version.id, + onClose, + onDeploymentStarted, }) - const canDeploy = - Boolean(appId) && - !disabled && - queryState.canDeploy && - Boolean(deploymentInput) && - !deployMutation.isPending return ( -
{ - event.preventDefault() - if (!appId || !canDeploy || !deploymentInput) return - - deployMutation.mutate({ - body: deploymentInput, - params: { - app_id: appId, - environment_id: request.environmentId, - workflow_id: version.id, - }, - }) - }} - > + {!embedded && ( @@ -132,12 +109,7 @@ export function DeploymentConfiguration({ - diff --git a/web/app/components/app/deploy/shared/deployment-configuration/section-heading.tsx b/web/app/components/app/deploy/shared/deployment-configuration/section-heading.tsx new file mode 100644 index 00000000000..80873dc3ebc --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/section-heading.tsx @@ -0,0 +1,8 @@ +export function SectionHeading({ title, description }: { title: string; description: string }) { + return ( +
+

{title}

+

{description}

+
+ ) +} diff --git a/web/app/components/app/deploy/shared/deployment-configuration/use-deployment-configuration-form.ts b/web/app/components/app/deploy/shared/deployment-configuration/use-deployment-configuration-form.ts new file mode 100644 index 00000000000..baa4388d5f1 --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/use-deployment-configuration-form.ts @@ -0,0 +1,110 @@ +'use client' + +import type { FormEventHandler } from 'react' +import type { DeploymentConfigurationQueryState } from './use-deployment-configuration-queries' +import type { DeploymentConfigurationValuesController } from './use-deployment-configuration-values' +import { toast } from '@langgenius/dify-ui/toast' +import { useTranslation } from 'react-i18next' +import { useDeployWorkflow } from '../../hooks/use-deploy-workflow' +import { + credentialProviderName, + findInvalidDeploymentCredential, + findInvalidDeploymentEnvironmentVariable, + hasValidDeploymentEnvironmentVariables, + workflowDeploymentInput, +} from './utils/workflow-deployment-input' + +export function useDeploymentConfigurationForm({ + appId, + configurationValues, + disabled, + environmentId, + invalidateAppEnvironmentsOnSuccess, + queryState, + workflowId, + onClose, + onDeploymentStarted, +}: { + appId?: string + configurationValues: DeploymentConfigurationValuesController + disabled: boolean + environmentId: string + invalidateAppEnvironmentsOnSuccess: boolean + queryState: DeploymentConfigurationQueryState + workflowId: string + onClose: () => void + onDeploymentStarted?: (operationId: string) => void +}) { + const { t: tCommon } = useTranslation('common') + const { t: tDeployments } = useTranslation('deployments') + const { t: tWorkflow } = useTranslation('workflow') + const deployMutation = useDeployWorkflow({ + appId, + invalidateAppEnvironmentsOnSuccess, + onSuccess: (response) => { + onDeploymentStarted?.(response.operation.id) + onClose() + }, + }) + const canDeploy = Boolean(appId) && !disabled && queryState.canDeploy && !deployMutation.isPending + + const handleSubmit: FormEventHandler = (event) => { + event.preventDefault() + if (!appId || !canDeploy || !queryState.deploymentOptions) return + + const values = configurationValues.getValues() + const invalidCredential = findInvalidDeploymentCredential( + queryState.deploymentOptions, + values.credentials, + ) + if (invalidCredential) { + toast.error( + `${credentialProviderName(invalidCredential.provider_id)}: ${ + invalidCredential.candidates.length === 0 + ? tDeployments(($) => $['deployDrawer.noCredentialCandidates']) + : tDeployments(($) => $['deployDrawer.selectCredential']) + }`, + ) + return + } + + const invalidEnvironmentVariable = findInvalidDeploymentEnvironmentVariable( + queryState.deploymentOptions, + values, + ) + if (invalidEnvironmentVariable) { + toast.error( + `${invalidEnvironmentVariable.owner.name} · ${invalidEnvironmentVariable.slot.key}: ${tWorkflow( + ($) => $['env.modal.valueRequired'], + )}`, + ) + return + } + + if (!hasValidDeploymentEnvironmentVariables(queryState.deploymentOptions, values)) { + toast.error(tCommon(($) => $.error)) + return + } + + const deploymentInput = workflowDeploymentInput(queryState.deploymentOptions, values) + if (!deploymentInput) { + toast.error(tCommon(($) => $.error)) + return + } + + deployMutation.mutate({ + body: deploymentInput, + params: { + app_id: appId, + environment_id: environmentId, + workflow_id: workflowId, + }, + }) + } + + return { + canDeploy, + handleSubmit, + isDeploying: deployMutation.isPending, + } +} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-queries.ts b/web/app/components/app/deploy/shared/deployment-configuration/use-deployment-configuration-queries.ts similarity index 71% rename from web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-queries.ts rename to web/app/components/app/deploy/shared/deployment-configuration/use-deployment-configuration-queries.ts index 7bb52bc8f03..2a95046cbda 100644 --- a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-deployment-configuration-queries.ts +++ b/web/app/components/app/deploy/shared/deployment-configuration/use-deployment-configuration-queries.ts @@ -1,7 +1,19 @@ 'use client' +import type { QueryFunction } from '@tanstack/react-query' import { skipToken, useQuery } from '@tanstack/react-query' import { consoleQuery } from '@/service/client' +import { normalizeDeploymentError } from './utils/deployment-error' + +function withNormalizedDeploymentError(queryFn: QueryFunction): QueryFunction { + return async (context) => { + try { + return await queryFn(context) + } catch (error) { + throw await normalizeDeploymentError(error) + } + } +} export function useDeploymentConfigurationQueries({ appId, @@ -12,8 +24,9 @@ export function useDeploymentConfigurationQueries({ environmentId: string workflowId: string }) { - const precheckQuery = useQuery( + const precheckQueryOptions = consoleQuery.enterprise.appDeploy.deploymentService.precheckWorkflowDeployment.queryOptions({ + gcTime: 0, input: appId ? { params: { @@ -23,14 +36,18 @@ export function useDeploymentConfigurationQueries({ } : skipToken, retry: false, - }), - ) + }) + const precheckQuery = useQuery({ + ...precheckQueryOptions, + queryFn: withNormalizedDeploymentError(precheckQueryOptions.queryFn), + }) const precheck = precheckQuery.data const precheckPassed = precheckQuery.isSuccess && !precheckQuery.isFetching && precheck?.unsupported_nodes.length === 0 - const deploymentOptionsQuery = useQuery( + const deploymentOptionsQueryOptions = consoleQuery.enterprise.appDeploy.deploymentService.getWorkflowDeploymentOptions.queryOptions({ + gcTime: 0, input: appId ? { params: { @@ -42,8 +59,11 @@ export function useDeploymentConfigurationQueries({ : skipToken, enabled: precheckPassed, retry: false, - }), - ) + }) + const deploymentOptionsQuery = useQuery({ + ...deploymentOptionsQueryOptions, + queryFn: withNormalizedDeploymentError(deploymentOptionsQueryOptions.queryFn), + }) const isPrechecking = Boolean(appId) && (precheckQuery.isLoading || precheckQuery.isFetching) const isLoadingDeploymentOptions = diff --git a/web/app/components/app/deploy/shared/deployment-configuration/use-deployment-configuration-values.ts b/web/app/components/app/deploy/shared/deployment-configuration/use-deployment-configuration-values.ts new file mode 100644 index 00000000000..3122ad586a1 --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/use-deployment-configuration-values.ts @@ -0,0 +1,84 @@ +'use client' + +import type { EnvVarValueSource } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { LLMEnvironmentVariableValue } from '@/app/components/workflow/types' +import { useCallback, useRef, useState } from 'react' + +export type EnvironmentVariableSelection = { + customValue: string | LLMEnvironmentVariableValue + source: EnvVarValueSource +} + +export type DeploymentConfigurationValues = { + credentials: Record + environmentVariables: Record +} + +export type DeploymentConfigurationValuesController = { + credentials: DeploymentConfigurationValues['credentials'] + getEnvironmentVariableSelection: ( + workflowId: string, + key: string, + ) => EnvironmentVariableSelection | undefined + getValues: () => DeploymentConfigurationValues + setCredential: (key: string, value: string) => void + setEnvironmentVariableSelection: ( + workflowId: string, + key: string, + value: EnvironmentVariableSelection, + ) => void +} + +export function environmentVariableSelectionKey(workflowId: string, key: string) { + return JSON.stringify([workflowId, key]) +} + +export function useDeploymentConfigurationValues() { + const valuesRef = useRef({ + credentials: {}, + environmentVariables: {}, + }) + const [credentials, setCredentials] = useState({}) + + const getEnvironmentVariableSelection = useCallback( + (workflowId: string, key: string) => + valuesRef.current.environmentVariables[environmentVariableSelectionKey(workflowId, key)], + [], + ) + const getValues = useCallback(() => valuesRef.current, []) + const setCredential = useCallback((key: string, value: string) => { + const current = valuesRef.current + if (current.credentials[key] === value) return + + const nextCredentials = { + ...current.credentials, + [key]: value, + } + valuesRef.current = { + ...current, + credentials: nextCredentials, + } + setCredentials(nextCredentials) + }, []) + const setEnvironmentVariableSelection = useCallback( + (workflowId: string, key: string, value: EnvironmentVariableSelection) => { + const current = valuesRef.current + valuesRef.current = { + ...current, + environmentVariables: { + ...current.environmentVariables, + [environmentVariableSelectionKey(workflowId, key)]: value, + }, + } + }, + [], + ) + + return { + credentials, + getEnvironmentVariableSelection, + getValues, + setCredential, + setEnvironmentVariableSelection, + } satisfies DeploymentConfigurationValuesController +} diff --git a/web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-provider-icon.ts b/web/app/components/app/deploy/shared/deployment-configuration/use-provider-icon.ts similarity index 100% rename from web/app/components/app/deploy/deployment-dialog/deployment-configuration/use-provider-icon.ts rename to web/app/components/app/deploy/shared/deployment-configuration/use-provider-icon.ts diff --git a/web/app/components/app/deploy/shared/deployment-configuration/utils/deployment-error.ts b/web/app/components/app/deploy/shared/deployment-configuration/utils/deployment-error.ts new file mode 100644 index 00000000000..90554b305a2 --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/utils/deployment-error.ts @@ -0,0 +1,36 @@ +function messageFrom(value: unknown) { + if (typeof value !== 'object' || value === null) return undefined + + if ('message' in value && typeof value.message === 'string') + return value.message.trim() || undefined + if ('error' in value && typeof value.error === 'string') return value.error.trim() || undefined + + return undefined +} + +export function getDeploymentErrorMessage(error: unknown) { + if (typeof error === 'object' && error !== null && 'data' in error) { + const data = error.data + if (typeof data === 'object' && data !== null && 'body' in data) { + const bodyMessage = messageFrom(data.body) + if (bodyMessage) return bodyMessage + } + + const dataMessage = messageFrom(data) + if (dataMessage) return dataMessage + } + + return messageFrom(error) +} + +export async function normalizeDeploymentError(error: unknown) { + if (error instanceof Response && !error.bodyUsed) { + try { + const response: unknown = await error.clone().json() + const message = getDeploymentErrorMessage(response) + if (message) return new Error(message) + } catch {} + } + + return error +} diff --git a/web/app/components/app/deploy/shared/deployment-configuration/utils/workflow-deployment-input.ts b/web/app/components/app/deploy/shared/deployment-configuration/utils/workflow-deployment-input.ts new file mode 100644 index 00000000000..c73266482f1 --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/utils/workflow-deployment-input.ts @@ -0,0 +1,251 @@ +import type { + CredentialSlot, + EnvironmentVariableSlot, + GetWorkflowDeploymentOptionsResponse, + WorkflowDeploymentInput, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { DeploymentConfigurationValues } from '../use-deployment-configuration-values' +import { + EnvVarValueSource as EnvVarValueSourceEnum, + EnvVarValueType, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import { isLLMEnvironmentVariableValue } from '@/app/components/workflow/llm-environment-variable' +import { environmentVariableSelectionKey } from '../use-deployment-configuration-values' + +export function credentialSlotKey(slot: CredentialSlot) { + return `${slot.provider_id}:${slot.category}` +} + +export function credentialProviderName(providerId: string) { + const name = providerId.split('/').filter(Boolean).at(-1) ?? providerId + + return name + .split(/[-_]/) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(' ') +} + +export function defaultCredentialId(slot: CredentialSlot) { + if ( + slot.last_deployed_credential_id && + slot.candidates.some( + (candidate) => candidate.credential_id === slot.last_deployed_credential_id, + ) + ) { + return slot.last_deployed_credential_id + } + + return slot.candidates.length === 1 ? slot.candidates[0]?.credential_id : undefined +} + +function selectedCredentialId( + slot: CredentialSlot, + credentials: DeploymentConfigurationValues['credentials'], +) { + const credentialId = credentials[credentialSlotKey(slot)] ?? defaultCredentialId(slot) + if (!credentialId) return undefined + + return slot.candidates.some((candidate) => candidate.credential_id === credentialId) + ? credentialId + : undefined +} + +function defaultEnvironmentVariableSelection( + slot: EnvironmentVariableSlot, +): DeploymentConfigurationValues['environmentVariables'][string] { + if (slot.has_configured_value) { + return { + customValue: '', + source: EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED, + } + } + + if (slot.has_last_deployed_value) { + return { + customValue: '', + source: EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED, + } + } + + return { + customValue: '', + source: EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM, + } +} + +function isEnvironmentVariableSelectionAvailable( + slot: EnvironmentVariableSlot, + selection: DeploymentConfigurationValues['environmentVariables'][string], +) { + switch (selection.source) { + case EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED: + return slot.has_configured_value + case EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED: + return slot.has_last_deployed_value + case EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM: + return true + default: + return false + } +} + +export function resolveEnvironmentVariableSelection( + slot: EnvironmentVariableSlot, + selection?: DeploymentConfigurationValues['environmentVariables'][string], +) { + if (selection && isEnvironmentVariableSelectionAvailable(slot, selection)) return selection + + return { + ...defaultEnvironmentVariableSelection(slot), + customValue: selection?.customValue ?? '', + } +} + +export function findInvalidDeploymentCredential( + deploymentOptions: GetWorkflowDeploymentOptionsResponse, + credentials: DeploymentConfigurationValues['credentials'], +) { + return deploymentOptions.credential_slots.find((slot) => !selectedCredentialId(slot, credentials)) +} + +function selectedEnvironmentVariableValue( + slot: EnvironmentVariableSlot, + selection: DeploymentConfigurationValues['environmentVariables'][string], +) { + let value: unknown + + switch (selection.source) { + case EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED: + value = slot.configured_value + break + case EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED: + value = slot.last_deployed_value + break + case EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM: + value = selection.customValue + break + default: + return undefined + } + + if (slot.value_type !== EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER) return value + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined + if (typeof value !== 'string' || value.trim() === '') return undefined + + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : undefined +} + +function hasValidEnvironmentVariableValue(slot: EnvironmentVariableSlot, value: unknown) { + switch (slot.value_type) { + case EnvVarValueType.ENV_VAR_VALUE_TYPE_LLM: + return isLLMEnvironmentVariableValue(value) + case EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER: + return typeof value === 'number' && Number.isFinite(value) + case EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET: + case EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING: + return typeof value === 'string' && value !== '' + default: + return false + } +} + +export function hasValidDeploymentEnvironmentVariables( + deploymentOptions: GetWorkflowDeploymentOptionsResponse, + values: DeploymentConfigurationValues, +) { + if ( + deploymentOptions.environment_variable_groups.some( + (group) => !group.from_app && !group.from_workflow_as_tool, + ) + ) + return false + + return !findInvalidDeploymentEnvironmentVariable(deploymentOptions, values) +} + +export function findInvalidDeploymentEnvironmentVariable( + deploymentOptions: GetWorkflowDeploymentOptionsResponse, + values: DeploymentConfigurationValues, +) { + for (const group of deploymentOptions.environment_variable_groups) { + const owner = group.from_app ?? group.from_workflow_as_tool?.workflow + if (!owner) continue + + for (const slot of group.environment_variable_slots) { + const selection = resolveEnvironmentVariableSelection( + slot, + values.environmentVariables[environmentVariableSelectionKey(owner.workflow_id, slot.key)], + ) + const selectedValue = selectedEnvironmentVariableValue(slot, selection) + if (!hasValidEnvironmentVariableValue(slot, selectedValue)) return { owner, slot } + + if ( + selection.source === EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM && + slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_LLM && + isLLMEnvironmentVariableValue(selectedValue) + ) { + const requiredMode = isLLMEnvironmentVariableValue(slot.configured_value) + ? slot.configured_value.mode + : isLLMEnvironmentVariableValue(slot.last_deployed_value) + ? slot.last_deployed_value.mode + : undefined + if (requiredMode && selectedValue.mode !== requiredMode) return { owner, slot } + } + } + } + + return undefined +} + +export function workflowDeploymentInput( + deploymentOptions: GetWorkflowDeploymentOptionsResponse, + values: DeploymentConfigurationValues, +): WorkflowDeploymentInput | undefined { + if (!hasValidDeploymentEnvironmentVariables(deploymentOptions, values)) return + + const credentials: NonNullable = [] + + for (const slot of deploymentOptions.credential_slots) { + const credentialId = selectedCredentialId(slot, values.credentials) + if (!credentialId) return + + credentials.push({ + category: slot.category, + credential_id: credentialId, + provider_id: slot.provider_id, + }) + } + + const environmentVariableGroups: WorkflowDeploymentInput['environment_variable_groups'] = [] + + for (const group of deploymentOptions.environment_variable_groups) { + const owner = group.from_app ?? group.from_workflow_as_tool?.workflow + if (!owner) return + + environmentVariableGroups.push({ + workflow_id: owner.workflow_id, + environment_variables: group.environment_variable_slots.map((slot) => { + const selection = resolveEnvironmentVariableSelection( + slot, + values.environmentVariables[environmentVariableSelectionKey(owner.workflow_id, slot.key)], + ) + const value = selectedEnvironmentVariableValue(slot, selection) + + return { + key: slot.key, + value_source: selection.source, + ...(selection.source === EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM + ? { value } + : {}), + } + }), + }) + } + + return { + credentials, + environment_variable_groups: environmentVariableGroups, + } +} diff --git a/web/app/components/app/deploy/shared/deployment-configuration/utils/workflow-path.ts b/web/app/components/app/deploy/shared/deployment-configuration/utils/workflow-path.ts new file mode 100644 index 00000000000..53b29a15db5 --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/utils/workflow-path.ts @@ -0,0 +1,15 @@ +import type { WorkflowPath } from '@dify/contracts/enterprise-app-deploy/types.gen' + +export function workflowPathKey(path: WorkflowPath) { + return JSON.stringify(path.workflows.map((workflow) => [workflow.app_id, workflow.workflow_id])) +} + +export function uniqueWorkflowPaths(paths: WorkflowPath[]) { + return [ + ...new Map( + paths + .filter((path) => path.workflows.length > 0) + .map((path) => [workflowPathKey(path), path] as const), + ).values(), + ] +} diff --git a/web/app/components/app/deploy/shared/deployment-configuration/workflow-source-popover.tsx b/web/app/components/app/deploy/shared/deployment-configuration/workflow-source-popover.tsx new file mode 100644 index 00000000000..c1ff3566a8c --- /dev/null +++ b/web/app/components/app/deploy/shared/deployment-configuration/workflow-source-popover.tsx @@ -0,0 +1,172 @@ +'use client' + +import type { + WorkflowAsToolSource, + WorkflowPath, + WorkflowReference, +} from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { AppIconType } from '@/types/app' +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { Fragment } from 'react' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' +import Link from '@/next/link' +import { uniqueWorkflowPaths, workflowPathKey } from './utils/workflow-path' + +function workflowReferenceIconType(reference: WorkflowReference): AppIconType | undefined { + if (reference.icon_type === 'emoji') return 'emoji' + if (reference.icon_type === 'image' || reference.icon_type === 'link') return 'image' + + return undefined +} + +function workflowReferenceImageUrl(reference: WorkflowReference) { + if (reference.icon_type !== 'image' && reference.icon_type !== 'link') return undefined + + return reference.icon_url ?? reference.icon +} + +export function WorkflowReferenceIcon({ reference }: { reference: WorkflowReference }) { + return ( + + + + ) +} + +function WorkflowPathLink({ path }: { path: WorkflowPath }) { + const leafWorkflow = path.workflows.at(-1) + if (!leafWorkflow) return null + + return ( + + + {path.workflows.map((workflow, index) => ( + + {index > 0 && ( + + )} + + + {workflow.name} + + + ))} + + + + ) +} + +function WorkflowSourceContent({ paths, title }: { paths: WorkflowPath[]; title: string }) { + return ( + + {title} +
    + {paths.map((path) => ( +
  • + +
  • + ))} +
+
+ ) +} + +export function WorkflowDependencyPreview({ + paths, + subjectName, +}: { + paths: WorkflowPath[] + subjectName: string +}) { + const { t } = useTranslation('deployments') + const validPaths = uniqueWorkflowPaths(paths) + const firstLeafWorkflow = validPaths[0]?.workflows.at(-1) + + if (!firstLeafWorkflow) return null + + const sourceLabel = + validPaths.length === 1 + ? firstLeafWorkflow.name + : t(($) => $['studio.precheck.nodeCount_other'], { count: validPaths.length }) + + return ( + + + {subjectName}: + + {t(($) => $['studio.precheck.from'])} + + + {sourceLabel} + + + } + /> + + + ) +} + +export function SubworkflowSourceTitle({ source }: { source: WorkflowAsToolSource }) { + const { workflow } = source + const paths = uniqueWorkflowPaths(source.paths) + + if (paths.length === 0) { + return ( + + {workflow.name} + + ) + } + + return ( + + + {workflow.name} + + } + /> + + + ) +} diff --git a/web/app/components/app/deploy/shared/deployment-status.tsx b/web/app/components/app/deploy/shared/deployment-status.tsx deleted file mode 100644 index 4f4aabd2076..00000000000 --- a/web/app/components/app/deploy/shared/deployment-status.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client' - -import type { DeploymentStatus as DeploymentStatusValue } from '@dify/contracts/enterprise-app-deploy/types.gen' -import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot' -import { DeploymentStatus as DeploymentStatusEnum } from '@dify/contracts/enterprise-app-deploy/types.gen' -import { cn } from '@langgenius/dify-ui/cn' -import { StatusDot } from '@langgenius/dify-ui/status-dot' -import { useTranslation } from 'react-i18next' - -const STATUS_TEXT_CLASS_NAMES: Record = { - [DeploymentStatusEnum.DEPLOYMENT_STATUS_UNSPECIFIED]: 'text-text-tertiary', - [DeploymentStatusEnum.DEPLOYMENT_STATUS_UNDEPLOYED]: 'text-text-tertiary', - [DeploymentStatusEnum.DEPLOYMENT_STATUS_DEPLOYING]: 'text-util-colors-blue-light-blue-light-600', - [DeploymentStatusEnum.DEPLOYMENT_STATUS_RUNNING]: 'text-util-colors-green-green-600', - [DeploymentStatusEnum.DEPLOYMENT_STATUS_UNDEPLOYING]: - 'text-util-colors-blue-light-blue-light-600', - [DeploymentStatusEnum.DEPLOYMENT_STATUS_INVALID]: 'text-util-colors-red-red-600', - [DeploymentStatusEnum.DEPLOYMENT_STATUS_FAILED]: 'text-util-colors-red-red-600', -} - -const STATUS_DOT: Partial> = { - [DeploymentStatusEnum.DEPLOYMENT_STATUS_UNSPECIFIED]: 'disabled', - [DeploymentStatusEnum.DEPLOYMENT_STATUS_UNDEPLOYED]: 'disabled', - [DeploymentStatusEnum.DEPLOYMENT_STATUS_RUNNING]: 'success', - [DeploymentStatusEnum.DEPLOYMENT_STATUS_INVALID]: 'error', - [DeploymentStatusEnum.DEPLOYMENT_STATUS_FAILED]: 'error', -} - -function getStatusLabel( - status: DeploymentStatusValue, - t: ReturnType>['t'], -) { - switch (status) { - case DeploymentStatusEnum.DEPLOYMENT_STATUS_DEPLOYING: - return t(($) => $['status.RUNTIME_INSTANCE_STATUS_DEPLOYING']) - case DeploymentStatusEnum.DEPLOYMENT_STATUS_RUNNING: - return t(($) => $['status.RUNTIME_INSTANCE_STATUS_READY']) - case DeploymentStatusEnum.DEPLOYMENT_STATUS_UNDEPLOYING: - return t(($) => $['status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING']) - case DeploymentStatusEnum.DEPLOYMENT_STATUS_FAILED: - return t(($) => $['status.RUNTIME_INSTANCE_STATUS_FAILED']) - case DeploymentStatusEnum.DEPLOYMENT_STATUS_INVALID: - return t(($) => $['status.RUNTIME_INSTANCE_STATUS_INVALID']) - case DeploymentStatusEnum.DEPLOYMENT_STATUS_UNSPECIFIED: - return t(($) => $['status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED']) - default: - return t(($) => $['status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED']) - } -} - -export function DeploymentStatus({ status }: { status?: DeploymentStatusValue }) { - const { t } = useTranslation('deployments') - const resolvedStatus = status ?? DeploymentStatusEnum.DEPLOYMENT_STATUS_UNDEPLOYED - const label = getStatusLabel(resolvedStatus, t) - const isInProgress = - resolvedStatus === DeploymentStatusEnum.DEPLOYMENT_STATUS_DEPLOYING || - resolvedStatus === DeploymentStatusEnum.DEPLOYMENT_STATUS_UNDEPLOYING - - return ( - - {isInProgress ? ( - - ) : ( - - )} - - {label} - - - ) -} diff --git a/web/app/components/app/deploy/environment-deployment-flow/index.tsx b/web/app/components/app/deploy/shared/environment-deployment-flow/index.tsx similarity index 71% rename from web/app/components/app/deploy/environment-deployment-flow/index.tsx rename to web/app/components/app/deploy/shared/environment-deployment-flow/index.tsx index f8181e81b28..19cf49a3195 100644 --- a/web/app/components/app/deploy/environment-deployment-flow/index.tsx +++ b/web/app/components/app/deploy/shared/environment-deployment-flow/index.tsx @@ -2,11 +2,12 @@ import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' import type { ReactNode } from 'react' -import type { DeploymentVersion } from '../version' -import { useState } from 'react' -import { DeploymentConfiguration } from '../deployment-dialog/deployment-configuration' -import { EmbeddedVersionSelection } from '../deployment-dialog/version-selection' -import { AppDeployStateBoundary, isEnvironmentDeploymentInProgress } from '../state' +import type { DeploymentVersion } from '../../utils/version' +import { useEffect, useState } from 'react' +import { AppDeployStateBoundary } from '../../state' +import { shouldPollEnvironmentDeployment } from '../../utils/environment-deployment' +import { DeploymentConfiguration } from '../deployment-configuration' +import { EmbeddedVersionSelection } from '../version-selection' type EnvironmentDeploymentFlowView = 'configuration' | 'overview' | 'versions' @@ -23,6 +24,7 @@ type EnvironmentDeploymentFlowProps = { disabled?: boolean environmentId: string environmentName: string + onConfigurationOpenChange?: (open: boolean) => void onDeploymentStarted: (operationId: string) => void } @@ -33,12 +35,13 @@ function EnvironmentDeploymentFlowContent({ disabled = false, environmentId, environmentName, + onConfigurationOpenChange, onDeploymentStarted, }: EnvironmentDeploymentFlowProps) { const [view, setView] = useState('overview') const [selectedVersion, setSelectedVersion] = useState() const currentVersionId = deployment?.deployment?.current_version?.id - const deploymentActionsDisabled = disabled || isEnvironmentDeploymentInProgress(deployment) + const deploymentActionsDisabled = disabled || shouldPollEnvironmentDeployment(deployment) const request = { currentVersionId, environment: environmentName, @@ -46,15 +49,27 @@ function EnvironmentDeploymentFlowContent({ kind: 'deploy' as const, } + useEffect( + () => () => { + onConfigurationOpenChange?.(false) + }, + [onConfigurationOpenChange], + ) + + const changeView = (nextView: EnvironmentDeploymentFlowView) => { + setView(nextView) + onConfigurationOpenChange?.(nextView === 'configuration') + } + const showVersionSelection = () => { if (deploymentActionsDisabled) return - setView('versions') + changeView('versions') } const deployVersion = (version: DeploymentVersion) => { if (deploymentActionsDisabled) return setSelectedVersion(version) - setView('configuration') + changeView('configuration') } if (view === 'configuration' && selectedVersion) { @@ -67,7 +82,7 @@ function EnvironmentDeploymentFlowContent({ request={request} version={selectedVersion} onBack={showVersionSelection} - onClose={() => setView('overview')} + onClose={() => changeView('overview')} onDeploymentStarted={onDeploymentStarted} /> ) @@ -78,7 +93,7 @@ function EnvironmentDeploymentFlowContent({ setView('overview')} + onBack={() => changeView('overview')} onSelect={deployVersion} /> ) diff --git a/web/app/components/app/deploy/shared/runtime-state.tsx b/web/app/components/app/deploy/shared/runtime-state.tsx new file mode 100644 index 00000000000..7c8918745b3 --- /dev/null +++ b/web/app/components/app/deploy/shared/runtime-state.tsx @@ -0,0 +1,84 @@ +'use client' + +import type { RuntimeState as RuntimeStateValue } from '@dify/contracts/enterprise-app-deploy/types.gen' +import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot' +import { RuntimeState } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { cn } from '@langgenius/dify-ui/cn' +import { StatusDot } from '@langgenius/dify-ui/status-dot' +import { useTranslation } from 'react-i18next' + +type RuntimeStateIndicatorProps = { + runtimeState: RuntimeStateValue +} + +const RUNTIME_STATE_TEXT_CLASS_NAMES: Record = { + [RuntimeState.RUNTIME_STATE_UNSPECIFIED]: 'text-text-tertiary', + [RuntimeState.RUNTIME_STATE_UNDEPLOYED]: 'text-text-tertiary', + [RuntimeState.RUNTIME_STATE_RUNNING]: 'text-util-colors-green-green-600', + [RuntimeState.RUNTIME_STATE_STARTING]: 'text-util-colors-blue-light-blue-light-600', + [RuntimeState.RUNTIME_STATE_STOPPING]: 'text-util-colors-blue-light-blue-light-600', + [RuntimeState.RUNTIME_STATE_ERROR]: 'text-util-colors-red-red-600', + [RuntimeState.RUNTIME_STATE_UNKNOWN]: 'text-text-warning', +} + +const RUNTIME_STATE_DOT: Partial> = { + [RuntimeState.RUNTIME_STATE_UNSPECIFIED]: 'disabled', + [RuntimeState.RUNTIME_STATE_UNDEPLOYED]: 'disabled', + [RuntimeState.RUNTIME_STATE_RUNNING]: 'success', + [RuntimeState.RUNTIME_STATE_ERROR]: 'error', + [RuntimeState.RUNTIME_STATE_UNKNOWN]: 'warning', +} + +function getRuntimeStateLabel( + runtimeState: RuntimeStateValue, + t: ReturnType>['t'], +) { + switch (runtimeState) { + case RuntimeState.RUNTIME_STATE_STARTING: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_DEPLOYING']) + case RuntimeState.RUNTIME_STATE_RUNNING: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_READY']) + case RuntimeState.RUNTIME_STATE_STOPPING: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING']) + case RuntimeState.RUNTIME_STATE_ERROR: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_INVALID']) + case RuntimeState.RUNTIME_STATE_UNKNOWN: + case RuntimeState.RUNTIME_STATE_UNSPECIFIED: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED']) + case RuntimeState.RUNTIME_STATE_UNDEPLOYED: + return t(($) => $['status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED']) + default: { + const exhaustiveState: never = runtimeState + return exhaustiveState + } + } +} + +export function RuntimeStateIndicator({ runtimeState }: RuntimeStateIndicatorProps) { + const { t } = useTranslation('deployments') + const label = getRuntimeStateLabel(runtimeState, t) + const isInProgress = + runtimeState === RuntimeState.RUNTIME_STATE_STARTING || + runtimeState === RuntimeState.RUNTIME_STATE_STOPPING + + return ( + + {isInProgress ? ( + + ) : ( + + )} + + {label} + + + ) +} diff --git a/web/app/components/app/deploy/hooks/__tests__/use-infinite-scroll.spec.ts b/web/app/components/app/deploy/shared/version-selection/__tests__/use-infinite-scroll.spec.ts similarity index 82% rename from web/app/components/app/deploy/hooks/__tests__/use-infinite-scroll.spec.ts rename to web/app/components/app/deploy/shared/version-selection/__tests__/use-infinite-scroll.spec.ts index c0a06a34d25..fd61bacd0e3 100644 --- a/web/app/components/app/deploy/hooks/__tests__/use-infinite-scroll.spec.ts +++ b/web/app/components/app/deploy/shared/version-selection/__tests__/use-infinite-scroll.spec.ts @@ -1,6 +1,6 @@ import type { InfiniteScrollQuery } from '../use-infinite-scroll' import { act, render } from '@testing-library/react' -import { createElement } from 'react' +import { createElement, useCallback } from 'react' import { afterAll, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { useInfiniteScroll } from '../use-infinite-scroll' @@ -10,6 +10,7 @@ let scrollRoot: HTMLDivElement | null = null let scrollSentinel: HTMLDivElement | null = null const observe = vi.fn() const disconnect = vi.fn() +const observerConstructed = vi.fn() const originalIntersectionObserver = globalThis.IntersectionObserver class MockIntersectionObserver implements IntersectionObserver { @@ -19,6 +20,7 @@ class MockIntersectionObserver implements IntersectionObserver { readonly thresholds: ReadonlyArray constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + observerConstructed() intersectionCallback = callback intersectionOptions = options this.root = options?.root ?? null @@ -36,20 +38,28 @@ class MockIntersectionObserver implements IntersectionObserver { function TestInfiniteScroll({ query }: { query: InfiniteScrollQuery }) { const { rootRef, sentinelRef } = useInfiniteScroll(query) + const setRootRef = useCallback( + (node: HTMLDivElement | null) => { + scrollRoot = node + rootRef(node) + }, + [rootRef], + ) + const setSentinelRef = useCallback( + (node: HTMLDivElement | null) => { + scrollSentinel = node + sentinelRef(node) + }, + [sentinelRef], + ) return createElement( 'div', { - ref: (node: HTMLDivElement | null) => { - scrollRoot = node - rootRef(node) - }, + ref: setRootRef, }, createElement('div', { - ref: (node: HTMLDivElement | null) => { - scrollSentinel = node - sentinelRef(node) - }, + ref: setSentinelRef, }), ) } @@ -114,6 +124,20 @@ describe('deploy useInfiniteScroll', () => { expect(query.fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: false }) }) + it('should keep the observer when only the query snapshot changes', () => { + const initialQuery = createQuery() + const nextQuery = createQuery() + const view = render(createElement(TestInfiniteScroll, { query: initialQuery })) + + expect(observerConstructed).toHaveBeenCalledOnce() + + view.rerender(createElement(TestInfiniteScroll, { query: nextQuery })) + triggerIntersection(true) + + expect(observerConstructed).toHaveBeenCalledOnce() + expect(nextQuery.fetchNextPage).toHaveBeenCalledOnce() + }) + it.each([ ['there is no next page', { hasNextPage: false }], ['the first page is loading', { isLoading: true }], diff --git a/web/app/components/app/deploy/shared/version-selection/index.tsx b/web/app/components/app/deploy/shared/version-selection/index.tsx new file mode 100644 index 00000000000..1a1bb208368 --- /dev/null +++ b/web/app/components/app/deploy/shared/version-selection/index.tsx @@ -0,0 +1,108 @@ +'use client' +import type { DeploymentDialogRequest } from '../../types' +import type { DeploymentVersion } from '../../utils/version' +import { Button } from '@langgenius/dify-ui/button' +import { DialogClose, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { useTranslation } from 'react-i18next' +import { VersionList } from './version-list' + +function versionSelectionTitle(request: DeploymentDialogRequest, deployTo: string, change: string) { + return request.kind === 'deploy' ? deployTo : `${change} · ${request.environment}` +} + +export function VersionSelection({ + appId, + request, + onSelect, +}: { + appId: string + request: DeploymentDialogRequest + onSelect: (version: DeploymentVersion) => void +}) { + const { t } = useTranslation('deployments') + const { t: tCommon } = useTranslation('common') + const title = versionSelectionTitle( + request, + t(($) => $['versions.deployTo'], { name: request.environment }), + t(($) => $['studio.changeVersion']), + ) + + return ( + <> + $['operation.close'])} + size="lg" + className="absolute top-5 right-5" + type="button" + > + + + } + /> +
+ {title} + + {t(($) => $['studio.chooseVersionToDeploy'])} + +
+ + + ) +} + +export function EmbeddedVersionSelection({ + disabled, + request, + onBack, + onSelect, +}: { + disabled: boolean + request: DeploymentDialogRequest + onBack: () => void + onSelect: (version: DeploymentVersion) => void +}) { + const { t } = useTranslation('deployments') + const { t: tCommon } = useTranslation('common') + const title = versionSelectionTitle( + request, + t(($) => $['versions.deployTo'], { name: request.environment }), + t(($) => $['studio.changeVersion']), + ) + + return ( +
+
+ +

{title}

+

+ {t(($) => $['studio.chooseVersionToDeploy'])} +

+
+ +
+ ) +} diff --git a/web/app/components/app/deploy/hooks/use-infinite-scroll.ts b/web/app/components/app/deploy/shared/version-selection/use-infinite-scroll.ts similarity index 93% rename from web/app/components/app/deploy/hooks/use-infinite-scroll.ts rename to web/app/components/app/deploy/shared/version-selection/use-infinite-scroll.ts index f66509a3bfe..bd2d9723c1c 100644 --- a/web/app/components/app/deploy/hooks/use-infinite-scroll.ts +++ b/web/app/components/app/deploy/shared/version-selection/use-infinite-scroll.ts @@ -47,8 +47,6 @@ export function useInfiniteScroll< const loadingLockRef = useRef(false) const latestQueryRef = useRef(query) - latestQueryRef.current = query - const disconnectObserver = useCallback(() => { observerRef.current?.disconnect() observerRef.current = null @@ -58,9 +56,10 @@ export function useInfiniteScroll< const connectObserver = useCallback(() => { const root = rootRef.current const sentinel = sentinelRef.current + const latestQuery = latestQueryRef.current if ( - !canFetchNextPage(query) || + !canFetchNextPage(latestQuery) || !root || !sentinel || typeof IntersectionObserver === 'undefined' @@ -111,7 +110,7 @@ export function useInfiniteScroll< observer.observe(sentinel) observerRef.current = observer observedTargetRef.current = { root, sentinel } - }, [disconnectObserver, query]) + }, [disconnectObserver]) const setRootRef = useCallback( (node: TRoot | null) => { @@ -130,10 +129,13 @@ export function useInfiniteScroll< ) useEffect(() => { + latestQueryRef.current = query connectObserver() + }, [connectObserver, query]) + useEffect(() => { return disconnectObserver - }, [connectObserver, disconnectObserver]) + }, [disconnectObserver]) return { rootRef: setRootRef, diff --git a/web/app/components/app/deploy/shared/version-selection/version-list.tsx b/web/app/components/app/deploy/shared/version-selection/version-list.tsx new file mode 100644 index 00000000000..3d2f12c1006 --- /dev/null +++ b/web/app/components/app/deploy/shared/version-selection/version-list.tsx @@ -0,0 +1,215 @@ +import type { ReactNode } from 'react' +import type { DeploymentVersion } from '../../utils/version' +import { buttonVariants } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + ScrollArea, + ScrollAreaContent, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '@langgenius/dify-ui/scroll-area' +import { useAtomValue } from 'jotai' +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' +import Link from '@/next/link' +import { + appWorkflowVersionsAtom, + appWorkflowVersionsErrorAtom, + appWorkflowVersionsFetchNextPageAtom, + appWorkflowVersionsHasNextPageAtom, + appWorkflowVersionsIsFetchingAtom, + appWorkflowVersionsIsFetchingNextPageAtom, + appWorkflowVersionsIsLoadingAtom, +} from '../../state' +import { useInfiniteScroll } from './use-infinite-scroll' + +function VersionBadge({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function VersionTag({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +const VersionChoice = memo( + ({ + version, + current, + disabled = false, + onSelect, + }: { + version: DeploymentVersion + current: boolean + disabled?: boolean + onSelect: (version: DeploymentVersion) => void + }) => { + const { t } = useTranslation('deployments') + const { t: tWorkflow } = useTranslation('workflow') + const { formatTimeFromNow } = useFormatTimeFromNow() + + return ( + + ) + }, +) +VersionChoice.displayName = 'VersionChoice' + +const VersionChoices = memo( + ({ + currentVersionId, + disabled, + onSelect, + versions, + }: { + currentVersionId?: string + disabled: boolean + onSelect: (version: DeploymentVersion) => void + versions: DeploymentVersion[] + }) => { + return versions.map((version) => ( + + )) + }, +) +VersionChoices.displayName = 'VersionChoices' + +export function VersionList({ + className, + currentVersionId, + disabled = false, + label, + publishHref, + onSelect, +}: { + className?: string + currentVersionId?: string + disabled?: boolean + label: string + publishHref?: string + onSelect: (version: DeploymentVersion) => void +}) { + const { t: tCommon } = useTranslation('common') + const { t } = useTranslation('deployments') + const versions = useAtomValue(appWorkflowVersionsAtom) + const versionsError = useAtomValue(appWorkflowVersionsErrorAtom) + const fetchNextPage = useAtomValue(appWorkflowVersionsFetchNextPageAtom) + const hasNextPage = useAtomValue(appWorkflowVersionsHasNextPageAtom) + const isFetching = useAtomValue(appWorkflowVersionsIsFetchingAtom) + const isFetchingNextPage = useAtomValue(appWorkflowVersionsIsFetchingNextPageAtom) + const isLoading = useAtomValue(appWorkflowVersionsIsLoadingAtom) + const { rootRef, sentinelRef } = useInfiniteScroll({ + error: versionsError, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + isLoading, + }) + + return ( + + + +
+ +
+ {isLoading && } + {!isLoading && versionsError && versions.length === 0 && ( +
+

+ {tCommon(($) => $.error)} +

+
+ )} + {!isLoading && !versionsError && versions.length === 0 && ( +
+

+ {t(($) => $['studio.accessPoint.noPublishedTitle'])} +

+ {publishHref && ( + + {t(($) => $['studio.accessPoint.goToPublish'])} + + + )} +
+ )} + {isFetchingNextPage && versions.length > 0 && } +
+ + + + + + + ) +} diff --git a/web/app/components/app/deploy/state.ts b/web/app/components/app/deploy/state.ts index 114b73e8a31..e634cba9839 100644 --- a/web/app/components/app/deploy/state.ts +++ b/web/app/components/app/deploy/state.ts @@ -1,12 +1,6 @@ 'use client' -import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' import type { ReactNode } from 'react' -import { - DeploymentOperationStatus, - DeploymentOperationType, - DeploymentStatus, -} from '@dify/contracts/enterprise-app-deploy/types.gen' import { skipToken } from '@tanstack/react-query' import { atom } from 'jotai' import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query' @@ -17,25 +11,42 @@ import { appWorkflowQueryOptions, appWorkflowVersionsInfiniteQueryOptions, } from '@/service/workflow-queries' -import { toDeploymentVersion } from './version' +import { hasDeploymentsRequiringPolling } from './utils/environment-deployment' +import { toDeploymentVersion } from './utils/version' -const DEPLOYMENT_STATUS_POLLING_INTERVAL = 3000 - -type EnvironmentDeploymentActionKind = - | 'changeVersion' - | 'deployLatest' - | 'redeploy' - | 'retry' - | 'undeploy' - -export type EnvironmentDeploymentAction = { - disabled: boolean - kind: EnvironmentDeploymentActionKind -} +const ENVIRONMENT_DEPLOYMENT_POLLING_INTERVAL = 3000 const appDeployAppIdAtom = atom(null) const defaultWorkflowVersionNameAtom = atom('') +type DeploymentVersionSource = Parameters[0] +type DeploymentVersionCacheEntry = { + defaultName: string + latestWorkflowId?: string + version: ReturnType +} + +const deploymentVersionCache = new WeakMap() + +function toStableDeploymentVersion( + source: DeploymentVersionSource, + defaultName: string, + latestWorkflowId?: string, +) { + const cached = deploymentVersionCache.get(source) + if (cached?.defaultName === defaultName && cached.latestWorkflowId === latestWorkflowId) + return cached.version + + const version = toDeploymentVersion(source, defaultName, latestWorkflowId) + deploymentVersionCache.set(source, { + defaultName, + latestWorkflowId, + version, + }) + + return version +} + export function AppDeployStateBoundary({ appId, children, @@ -71,9 +82,24 @@ export const latestAppWorkflowVersionAtom = atom((get) => { const workflow = get(latestPublishedWorkflowAtom) if (!workflow) return - return toDeploymentVersion(workflow, get(defaultWorkflowVersionNameAtom), workflow.id) + return toStableDeploymentVersion(workflow, get(defaultWorkflowVersionNameAtom), workflow.id) }) +export const latestAppWorkflowVersionIsErrorAtom = selectAtom( + latestPublishedWorkflowQueryAtom, + (query) => query.isError, +) + +export const latestAppWorkflowVersionIsRetryingAtom = selectAtom( + latestPublishedWorkflowQueryAtom, + (query) => query.isError && query.isFetching, +) + +export const latestAppWorkflowVersionRefetchAtom = selectAtom( + latestPublishedWorkflowQueryAtom, + (query) => query.refetch, +) + const appWorkflowVersionsQueryAtom = atomWithInfiniteQuery((get) => { return appWorkflowVersionsInfiniteQueryOptions(get(appDeployAppIdAtom)) }) @@ -88,7 +114,7 @@ export const appWorkflowVersionsAtom = atom((get) => { return pages.flatMap((page) => page.items .filter((workflow) => workflow.version !== 'draft') - .map((workflow) => toDeploymentVersion(workflow, defaultName, latestWorkflowId)), + .map((workflow) => toStableDeploymentVersion(workflow, defaultName, latestWorkflowId)), ) }) @@ -138,6 +164,26 @@ const appEnvironmentsQueryAtom = atomWithQuery((get) => { const appEnvironmentsAtom = selectAtom(appEnvironmentsQueryAtom, (query) => query.data?.data) +export const appEnvironmentsIsErrorAtom = selectAtom( + appEnvironmentsQueryAtom, + (query) => query.isError, +) + +export const appEnvironmentsIsLoadingAtom = selectAtom( + appEnvironmentsQueryAtom, + (query) => query.isLoading, +) + +export const appEnvironmentsIsRetryingAtom = selectAtom( + appEnvironmentsQueryAtom, + (query) => query.isError && query.isFetching, +) + +export const appEnvironmentsRefetchAtom = selectAtom( + appEnvironmentsQueryAtom, + (query) => query.refetch, +) + export const appEnvironmentUsageAtom = atom((get) => { const environments = get(appEnvironmentsAtom) if (!environments) return @@ -148,23 +194,10 @@ export const appEnvironmentUsageAtom = atom((get) => { } }) -export const undeployedAppEnvironmentsAtom = atom( - (get) => get(appEnvironmentsAtom)?.filter((environment) => environment.in_use === false) ?? [], +export const undeployedAppEnvironmentsAtom = atom((get) => + get(appEnvironmentsAtom)?.filter((environment) => environment.in_use === false), ) -export function isEnvironmentDeploymentInProgress(deployment?: EnvironmentDeployment) { - const status = deployment?.deployment?.status - - return ( - status === DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING || - status === DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING - ) -} - -export function hasInProgressEnvironmentDeployments(deployments: EnvironmentDeployment[]) { - return deployments.some(isEnvironmentDeploymentInProgress) -} - const appEnvironmentDeploymentsQueryAtom = atomWithQuery((get) => { const appId = get(appDeployAppIdAtom) @@ -179,8 +212,8 @@ const appEnvironmentDeploymentsQueryAtom = atomWithQuery((get) => { : skipToken, refetchInterval: (query) => { const deployments = query.state.data?.environment_deployments ?? [] - return hasInProgressEnvironmentDeployments(deployments) - ? DEPLOYMENT_STATUS_POLLING_INTERVAL + return hasDeploymentsRequiringPolling(deployments) + ? ENVIRONMENT_DEPLOYMENT_POLLING_INTERVAL : false }, }, @@ -202,67 +235,13 @@ export const appEnvironmentDeploymentsIsErrorAtom = selectAtom( (query) => query.isError, ) -export const appEnvironmentDeploymentsIsFetchingAtom = selectAtom( +export const appEnvironmentDeploymentsIsRetryingAtom = selectAtom( appEnvironmentDeploymentsQueryAtom, - (query) => query.isFetching, + (query) => + query.isError && (query.data?.environment_deployments.length ?? 0) === 0 && query.isFetching, ) export const appEnvironmentDeploymentsRefetchAtom = selectAtom( appEnvironmentDeploymentsQueryAtom, (query) => query.refetch, ) - -function deploymentActions( - kinds: EnvironmentDeploymentActionKind[], - disabled = false, -): EnvironmentDeploymentAction[] { - return kinds.map((kind) => ({ disabled, kind })) -} - -function isLatestDeployOperationFailed(row: EnvironmentDeployment) { - const operation = row.deployment?.latest_operation - - return ( - operation?.type === DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_DEPLOY && - operation.status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_FAILED - ) -} - -export function getEnvironmentDeploymentActions( - row: EnvironmentDeployment, -): EnvironmentDeploymentAction[] { - const deployment = row.deployment - // Currently, this case may not be possible, but we still handle it to avoid potential errors in the future. - if (!deployment || deployment.status === DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYED) { - return deploymentActions(['deployLatest', 'changeVersion']) - } - - const hasCurrentVersion = Boolean(deployment.current_version) - const hasFailedDeploy = - deployment.status === DeploymentStatus.DEPLOYMENT_STATUS_FAILED || - (deployment.status === DeploymentStatus.DEPLOYMENT_STATUS_RUNNING && - isLatestDeployOperationFailed(row)) - - if (hasFailedDeploy) { - return deploymentActions( - hasCurrentVersion ? ['retry', 'changeVersion', 'undeploy'] : ['retry', 'changeVersion'], - ) - } - - if ( - deployment.status === DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING || - deployment.status === DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING - ) { - return deploymentActions(['changeVersion', 'redeploy', 'undeploy'], true) - } - - if (deployment.status === DeploymentStatus.DEPLOYMENT_STATUS_RUNNING) { - if ((deployment.versions_behind ?? 0) > 0) { - return deploymentActions(['deployLatest', 'changeVersion', 'redeploy', 'undeploy']) - } - - return deploymentActions(['changeVersion', 'redeploy', 'undeploy']) - } - - return deploymentActions(['redeploy', 'undeploy']) -} diff --git a/web/app/components/app/deploy/deployment-dialog/types.ts b/web/app/components/app/deploy/types.ts similarity index 87% rename from web/app/components/app/deploy/deployment-dialog/types.ts rename to web/app/components/app/deploy/types.ts index 87c36a34908..e60ec089656 100644 --- a/web/app/components/app/deploy/deployment-dialog/types.ts +++ b/web/app/components/app/deploy/types.ts @@ -1,4 +1,4 @@ -import type { DeploymentVersion } from '../version' +import type { DeploymentVersion } from './utils/version' type VersionSelectionRequest = { currentVersionId?: string diff --git a/web/app/components/app/deploy/access-point.ts b/web/app/components/app/deploy/utils/access-point.ts similarity index 100% rename from web/app/components/app/deploy/access-point.ts rename to web/app/components/app/deploy/utils/access-point.ts diff --git a/web/app/components/app/deploy/utils/environment-deployment.ts b/web/app/components/app/deploy/utils/environment-deployment.ts new file mode 100644 index 00000000000..f0a424c4885 --- /dev/null +++ b/web/app/components/app/deploy/utils/environment-deployment.ts @@ -0,0 +1,119 @@ +import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen' +import { + DeploymentOperationStatus, + DeploymentOperationType, + RuntimeState, +} from '@dify/contracts/enterprise-app-deploy/types.gen' + +type EnvironmentDeploymentActionKind = + | 'changeVersion' + | 'deployLatest' + | 'redeploy' + | 'retry' + | 'undeploy' + +export type EnvironmentDeploymentAction = { + disabled: boolean + kind: EnvironmentDeploymentActionKind +} + +function isDeploymentOperationInProgress(deployment?: EnvironmentDeployment) { + return ( + deployment?.deployment?.latest_operation?.status === + DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS + ) +} + +export function shouldPollEnvironmentDeployment(deployment?: EnvironmentDeployment) { + if (isDeploymentOperationInProgress(deployment)) return true + + const runtimeState = deployment?.deployment?.runtimeState + return ( + runtimeState === RuntimeState.RUNTIME_STATE_STARTING || + runtimeState === RuntimeState.RUNTIME_STATE_STOPPING + ) +} + +export function hasDeploymentsRequiringPolling(deployments: EnvironmentDeployment[]) { + return deployments.some(shouldPollEnvironmentDeployment) +} + +function deploymentActions( + kinds: EnvironmentDeploymentActionKind[], + disabled = false, + deployLatestDisabled = false, +): EnvironmentDeploymentAction[] { + return kinds.map((kind) => ({ + disabled: disabled || (kind === 'deployLatest' && deployLatestDisabled), + kind, + })) +} + +function isLatestDeployOperationFailed(row: EnvironmentDeployment) { + const operation = row.deployment?.latest_operation + + return ( + operation?.type === DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_DEPLOY && + operation.status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_FAILED + ) +} + +export function getEnvironmentDeploymentActions( + row: EnvironmentDeployment, + { deployLatestDisabled = false }: { deployLatestDisabled?: boolean } = {}, +): EnvironmentDeploymentAction[] { + const deployment = row.deployment + const actions = (kinds: EnvironmentDeploymentActionKind[], disabled = false) => + deploymentActions(kinds, disabled, deployLatestDisabled) + + if (!deployment) { + return actions(['deployLatest', 'changeVersion']) + } + + const hasCurrentVersion = Boolean(deployment.current_version) + if (isDeploymentOperationInProgress(row)) { + return actions( + hasCurrentVersion + ? ['changeVersion', 'redeploy', 'undeploy'] + : ['deployLatest', 'changeVersion'], + true, + ) + } + + if (isLatestDeployOperationFailed(row)) { + const hasRetryVersion = Boolean( + deployment.latest_operation?.target_version ?? deployment.current_version, + ) + if (!hasRetryVersion) return actions(['changeVersion']) + + return actions( + hasCurrentVersion ? ['retry', 'changeVersion', 'undeploy'] : ['retry', 'changeVersion'], + ) + } + + if (deployment.runtimeState === RuntimeState.RUNTIME_STATE_UNDEPLOYED) { + return actions(['deployLatest', 'changeVersion']) + } + + if (deployment.runtimeState === RuntimeState.RUNTIME_STATE_RUNNING) { + if ((deployment.versions_behind ?? 0) > 0) { + return actions(['deployLatest', 'changeVersion', 'redeploy', 'undeploy']) + } + + return actions(['changeVersion', 'redeploy', 'undeploy']) + } + + if ( + deployment.runtimeState === RuntimeState.RUNTIME_STATE_STARTING || + deployment.runtimeState === RuntimeState.RUNTIME_STATE_STOPPING + ) { + return actions( + hasCurrentVersion + ? ['changeVersion', 'redeploy', 'undeploy'] + : ['deployLatest', 'changeVersion'], + true, + ) + } + + return hasCurrentVersion ? actions(['redeploy', 'undeploy']) : actions(['changeVersion']) +} diff --git a/web/app/components/app/deploy/version.ts b/web/app/components/app/deploy/utils/version.ts similarity index 100% rename from web/app/components/app/deploy/version.ts rename to web/app/components/app/deploy/utils/version.ts diff --git a/web/app/components/app/access-point/shared/access-point-card.tsx b/web/app/components/base/access-point/card.tsx similarity index 78% rename from web/app/components/app/access-point/shared/access-point-card.tsx rename to web/app/components/base/access-point/card.tsx index be0450c6dde..fcc9e2f65a1 100644 --- a/web/app/components/app/access-point/shared/access-point-card.tsx +++ b/web/app/components/base/access-point/card.tsx @@ -1,12 +1,12 @@ 'use client' import type { ReactNode } from 'react' -import type { AccessPointStatus } from './access-point-status' +import type { AccessPointStatus } from './status' import { cn } from '@langgenius/dify-ui/cn' import { StatusDot, StatusDotSkeleton } from '@langgenius/dify-ui/status-dot' import { Switch } from '@langgenius/dify-ui/switch' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useId } from 'react' -import { useTranslation } from 'react-i18next' type AccessPointCardProps = { actions?: ReactNode @@ -14,12 +14,15 @@ type AccessPointCardProps = { description: string icon: ReactNode | string status: AccessPointStatus + statusLabel: string title: string className?: string + headingLevel?: 2 | 3 highlighted?: boolean onEnabledChange?: (enabled: boolean) => void showStatus?: boolean switchDisabled?: boolean + switchDisabledReason?: string switchLabel?: string switchLoading?: boolean } @@ -29,28 +32,39 @@ export function AccessPointCard({ children, className, description, + headingLevel = 2, highlighted = false, icon, onEnabledChange, showStatus = true, status, + statusLabel, switchDisabled = false, + switchDisabledReason, switchLabel, switchLoading = false, title, }: AccessPointCardProps) { - const { t } = useTranslation() const titleId = useId() const isEnabled = status === 'inService' const isLoading = status === 'loading' const showSwitch = (status === 'disabled' || status === 'inService') && Boolean(onEnabledChange) - const statusLabel: Record = { - disabled: t(($) => $['overview.status.disable'], { ns: 'appOverview' }), - inService: t(($) => $['agentDetail.access.status.inService'], { ns: 'agentV2' }), - loading: t(($) => $.loading, { ns: 'common' }), - unavailable: t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { ns: 'deployments' }), - unsupported: t(($) => $['studio.accessPoint.notSupported'], { ns: 'deployments' }), - } + const hasSwitchDisabledReason = switchDisabled && Boolean(switchDisabledReason) + const Heading = headingLevel === 3 ? 'h3' : 'h2' + const switchControl = ( + { + if (!switchDisabled) onEnabledChange?.(enabled) + }} + /> + ) return (
-

+ {title} -

+ {description} @@ -93,17 +111,17 @@ export function AccessPointCard({ ) : ( )} - {statusLabel[status]} + {statusLabel} - {showSwitch && ( - - )} + {showSwitch && + (hasSwitchDisabledReason ? ( + + + {switchDisabledReason} + + ) : ( + switchControl + ))} )} diff --git a/web/app/components/app/access-point/shared/access-point-status.ts b/web/app/components/base/access-point/status.ts similarity index 100% rename from web/app/components/app/access-point/shared/access-point-status.ts rename to web/app/components/base/access-point/status.ts diff --git a/web/app/components/app/access-point/shared/access-point-url.tsx b/web/app/components/base/access-point/url.tsx similarity index 75% rename from web/app/components/app/access-point/shared/access-point-url.tsx rename to web/app/components/base/access-point/url.tsx index cd90c57c111..678946d64bc 100644 --- a/web/app/components/app/access-point/shared/access-point-url.tsx +++ b/web/app/components/base/access-point/url.tsx @@ -3,38 +3,53 @@ import { Button, buttonVariants } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { IconButton } from '@langgenius/dify-ui/icon-button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' import { CopyFeedback } from '@/app/components/base/copy-feedback' import ShareQRCode from '@/app/components/base/qrcode' -import { AccessPointEndpoint } from './access-point-card' +import { AccessPointEndpoint } from './card' type AccessPointUrlProps = { enabled: boolean label: string unavailableLabel: string value: string + copiedLabel?: string copyDisabled?: boolean + copyLabel?: string loading?: boolean unavailable?: boolean showOpen?: boolean showQrCode?: boolean showRegenerate?: boolean openUrl?: string + onCopyError?: () => void onRegenerate?: () => void + openDisabledReason?: string openLabel?: string + qrCodeDownloadLabel?: string + qrCodeLabel?: string + qrCodeScanLabel?: string regenerateLabel?: string regenerateDisabled?: boolean regenerating?: boolean } export function AccessPointUrl({ + copiedLabel, + copyDisabled = false, + copyLabel, enabled, label, loading = false, - copyDisabled = false, + onCopyError, onRegenerate, + openDisabledReason, openLabel, openUrl, + qrCodeDownloadLabel, + qrCodeLabel, + qrCodeScanLabel, regenerateDisabled = false, regenerateLabel, regenerating = false, @@ -47,6 +62,26 @@ export function AccessPointUrl({ }: AccessPointUrlProps) { const { t } = useTranslation() const detailsAvailable = !loading && !unavailable + const disabledOpenButton = ( + + ) + const disabledOpenAction = openDisabledReason ? ( + + + {openDisabledReason} + + ) : ( + disabledOpenButton + ) const disabledActions = (
@@ -82,7 +117,13 @@ export function AccessPointUrl({
) : ( - + )} {showQrCode && (copyDisabled ? ( @@ -90,7 +131,12 @@ export function AccessPointUrl({
) : ( - + ))} {showRegenerate && ( ) : ( - + disabledOpenAction )} )} diff --git a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx index 56d7a58ded6..12004cb7c28 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx @@ -90,6 +90,7 @@ const createQueryClient = () => defaultOptions: { queries: { retry: false, + retryDelay: 0, }, }, }) @@ -150,11 +151,13 @@ const setConversationIdInfo = (appId: string, conversationId: string) => { describe('useChatWithHistory', () => { beforeEach(() => { vi.clearAllMocks() + window.history.replaceState({}, '', '/') localStorage.removeItem(CONVERSATION_ID_INFO) sessionStorage.removeItem(TAB_CONVERSATION_ID_INFO) localStorage.removeItem('webappSidebarCollapse') mockStoreState.appInfo = { app_id: 'app-1', + end_user_id: 'user-1', custom_config: null, site: { title: 'Test App', @@ -297,6 +300,46 @@ describe('useChatWithHistory', () => { }) expect(mockFetchChatList).toHaveBeenCalledTimes(1) }) + + it('should clear a stale Environment selection when the server returns not found', async () => { + window.history.replaceState({}, '', '/environment/workflow/environment-code') + setConversationIdInfo('environment:environment-code', 'conversation-1') + mockFetchConversations.mockResolvedValue(createConversationData()) + mockFetchChatList.mockRejectedValue( + new Response(JSON.stringify({ reason: 'APPDEPLOY_CONVERSATION_NOT_FOUND' }), { + status: 404, + }), + ) + + const { result } = await renderWithClient(() => useChatWithHistory()) + + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledTimes(1) + }) + await waitFor(() => { + expect(result!.current.currentConversationId).toBe('') + }) + expect(result!.current.clearChatList).toBe(true) + const storedSelections = JSON.parse(localStorage.getItem(CONVERSATION_ID_INFO)!) + expect(storedSelections['environment:environment-code']['user-1']).toBe('') + }) + + it('should keep the selected Environment conversation for other errors', async () => { + window.history.replaceState({}, '', '/environment/workflow/environment-code') + setConversationIdInfo('environment:environment-code', 'conversation-1') + mockFetchConversations.mockResolvedValue(createConversationData()) + const response = new Response(JSON.stringify({ reason: 'OTHER_ERROR' }), { status: 404 }) + mockFetchChatList.mockRejectedValue(response) + + const { result } = await renderWithClient(() => useChatWithHistory()) + + await waitFor(() => { + expect(mockFetchChatList).toHaveBeenCalledTimes(1) + }) + expect(result!.current.currentConversationId).toBe('conversation-1') + expect(result!.current.clearChatList).toBe(false) + expect(localStorage.getItem(CONVERSATION_ID_INFO)).toContain('conversation-1') + }) }) // Scenario: the active conversation is tab-scoped while the last selection is cross-tab. @@ -349,6 +392,76 @@ describe('useChatWithHistory', () => { expect(mockFetchChatList).not.toHaveBeenCalled() }) + it('should isolate built-in and Environment conversations in the same browser', async () => { + window.history.replaceState({}, '', '/environment/chat/environment-code') + localStorage.setItem( + CONVERSATION_ID_INFO, + JSON.stringify({ + 'app-1': { 'user-1': 'built-in-conversation' }, + 'environment:environment-code': { 'user-1': 'environment-conversation' }, + }), + ) + mockFetchConversations.mockResolvedValue(createConversationData()) + mockFetchChatList.mockResolvedValue({ data: [] }) + + const { result } = await renderWithClient(() => useChatWithHistory()) + + expect(result!.current.currentConversationId).toBe('environment-conversation') + expect(mockFetchChatList).toHaveBeenCalledWith( + 'environment-conversation', + AppSourceType.webApp, + 'app-1', + ) + expect(mockFetchChatList).not.toHaveBeenCalledWith( + 'built-in-conversation', + AppSourceType.webApp, + 'app-1', + ) + }) + + it('should select by the current EndUser after access mode changes', async () => { + window.history.replaceState({}, '', '/environment/chat/environment-code') + mockStoreState.appInfo = { + ...mockStoreState.appInfo!, + end_user_id: 'authenticated-end-user', + } + localStorage.setItem( + CONVERSATION_ID_INFO, + JSON.stringify({ + 'environment:environment-code': { + 'anonymous-end-user': 'anonymous-conversation', + 'authenticated-end-user': 'authenticated-conversation', + }, + }), + ) + mockFetchConversations.mockResolvedValue(createConversationData()) + mockFetchChatList.mockResolvedValue({ data: [] }) + + const { result } = await renderWithClient(() => useChatWithHistory()) + + expect(result!.current.currentConversationId).toBe('authenticated-conversation') + expect(mockFetchChatList).not.toHaveBeenCalledWith( + 'anonymous-conversation', + AppSourceType.webApp, + 'app-1', + ) + }) + + it('should not initialize WebApp selection before EndUser is known', async () => { + mockStoreState.appInfo = { ...mockStoreState.appInfo!, end_user_id: undefined } + localStorage.setItem( + CONVERSATION_ID_INFO, + JSON.stringify({ 'app-1': { DEFAULT: 'stale-conversation' } }), + ) + mockFetchConversations.mockResolvedValue(createConversationData()) + mockFetchChatList.mockResolvedValue({ data: [] }) + + const { result } = await renderWithClient(() => useChatWithHistory()) + + expect(result!.current.currentConversationId).toBe('') + expect(mockFetchChatList).not.toHaveBeenCalled() + }) + it('should ignore last conversation updates from another tab', async () => { // Arrange mockFetchConversations.mockResolvedValue(createConversationData()) @@ -403,14 +516,8 @@ describe('useChatWithHistory', () => { const tabStoredValue = sessionStorage.getItem(TAB_CONVERSATION_ID_INFO) const tabConversationIdInfo = tabStoredValue ? JSON.parse(tabStoredValue) : {} - expect([ - lastConversationIdInfo['app-1']?.['user-1'], - lastConversationIdInfo['app-1']?.DEFAULT, - ]).toContain('conversation-new') - expect([ - tabConversationIdInfo['app-1']?.['user-1'], - tabConversationIdInfo['app-1']?.DEFAULT, - ]).toContain('conversation-new') + expect(lastConversationIdInfo['app-1']?.['user-1']).toBe('conversation-new') + expect(tabConversationIdInfo['app-1']?.['user-1']).toBe('conversation-new') }) }) }) @@ -2131,7 +2238,7 @@ describe('useChatWithHistory', () => { expect(localStorage.getItem(CONVERSATION_ID_INFO)).toBe(original) }) - it('should write conversation id under DEFAULT key when user id is missing', async () => { + it('should use the site EndUser when the URL user id is missing', async () => { // Arrange const { getProcessedSystemVariablesFromUrlParams } = await import('../../utils') vi.mocked(getProcessedSystemVariablesFromUrlParams).mockResolvedValueOnce({ @@ -2151,7 +2258,7 @@ describe('useChatWithHistory', () => { await waitFor(() => { const stored = localStorage.getItem(CONVERSATION_ID_INFO) const parsed = stored ? JSON.parse(stored) : {} - expect(parsed['app-1']?.DEFAULT).toBe('conversation-default-user') + expect(parsed['app-1']?.['user-1']).toBe('conversation-default-user') }) }) }) diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index e3bdb0374b2..cea9d1b77aa 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -26,11 +26,13 @@ import { updateFeedback, } from '@/service/share' import { + EnvironmentConversationNotFoundError, useInvalidateShareConversations, useShareChatList, useShareConversationName, useShareConversations, } from '@/service/use-share' +import { getWebAppConversationScopeId, resolveWebAppAddress } from '@/service/webapp-address' import { TransferMethod } from '@/types/app' import { addFileInfos, sortAgentSorts } from '../../../tools/utils' import { enrichSubmittedHumanInputFormData } from '../chat/answer/human-input-content/submitted-utils' @@ -164,6 +166,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledAppResponse) => { return appInfo }, [isInstalledApp, installedAppInfo, appInfo]) const appId = useMemo(() => appData?.app_id, [appData]) + const conversationScopeId = getWebAppConversationScopeId(resolveWebAppAddress(), appId) const [userId, setUserId] = useState() useEffect(() => { getProcessedSystemVariablesFromUrlParams().then(({ user_id }) => { @@ -186,8 +189,8 @@ export const useChatWithHistory = (installedAppInfo?: InstalledAppResponse) => { [appId, setStoredSidebarCollapseState], ) const { currentConversationId, handleConversationIdInfoChange } = useConversationSelection({ - appId, - userId, + scopeId: isInstalledApp || appData?.end_user_id ? conversationScopeId : '', + userId: isInstalledApp ? userId : appData?.end_user_id, }) const [newConversationId, setNewConversationId] = useState('') const chatShouldReloadKey = useMemo(() => { @@ -221,7 +224,11 @@ export const useChatWithHistory = (installedAppInfo?: InstalledAppResponse) => { refetchOnReconnect: false, }, ) - const { data: appChatListData, isLoading: appChatListDataLoading } = useShareChatList( + const { + data: appChatListData, + error: appChatListError, + isLoading: appChatListDataLoading, + } = useShareChatList( { conversationId: chatShouldReloadKey, appSourceType, @@ -236,6 +243,15 @@ export const useChatWithHistory = (installedAppInfo?: InstalledAppResponse) => { const invalidateShareConversations = useInvalidateShareConversations() const [clearChatList, setClearChatList] = useState(false) const [isResponding, setIsResponding] = useState(false) + useEffect(() => { + if (!(appChatListError instanceof EnvironmentConversationNotFoundError)) return + + // oxlint-disable-next-line eslint-react/set-state-in-effect -- A missing Environment conversation resets the active conversation. + setNewConversationId('') + handleConversationIdInfoChange('') + // oxlint-disable-next-line eslint-react/set-state-in-effect -- A missing Environment conversation must clear the rendered chat. + setClearChatList(true) + }, [appChatListError, handleConversationIdInfoChange]) const appPrevChatTree = useMemo( () => currentConversationId && appChatListData?.data.length diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx index 0f022253500..7bed22ea171 100644 --- a/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/__tests__/hooks.spec.tsx @@ -165,6 +165,7 @@ describe('useEmbeddedChatbot', () => { sessionStorage.removeItem(TAB_CONVERSATION_ID_INFO) mockStoreState.appInfo = { app_id: 'app-1', + end_user_id: 'user-1', custom_config: null, site: { title: 'Test App', @@ -396,14 +397,8 @@ describe('useEmbeddedChatbot', () => { const tabStoredValue = sessionStorage.getItem(TAB_CONVERSATION_ID_INFO) const tabConversationIdInfo = tabStoredValue ? JSON.parse(tabStoredValue) : {} - expect([ - lastConversationIdInfo['app-1']?.['embedded-user-1'], - lastConversationIdInfo['app-1']?.DEFAULT, - ]).toContain('conversation-new') - expect([ - tabConversationIdInfo['app-1']?.['embedded-user-1'], - tabConversationIdInfo['app-1']?.DEFAULT, - ]).toContain('conversation-new') + expect(lastConversationIdInfo['app-1']?.['user-1']).toBe('conversation-new') + expect(tabConversationIdInfo['app-1']?.['user-1']).toBe('conversation-new') }) }) }) @@ -452,7 +447,7 @@ describe('useEmbeddedChatbot', () => { const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) act(() => { - result.current.removeConversationIdInfo('app-1') + result.current.removeConversationIdInfo() }) await waitFor(() => { @@ -656,7 +651,7 @@ describe('useEmbeddedChatbot', () => { localStorage.setItem( CONVERSATION_ID_INFO, JSON.stringify({ - 'app-1': { DEFAULT: 'stored-conv-id' }, + 'app-1': { 'user-1': 'stored-conv-id' }, }), ) mockStoreState.embeddedConversationId = null @@ -697,17 +692,13 @@ describe('useEmbeddedChatbot', () => { describe('Language settings', () => { it('should set language from URL parameters', async () => { - const originalSearch = window.location.search - Object.defineProperty(window, 'location', { - writable: true, - value: { search: '?locale=zh-Hans' }, - }) + window.history.replaceState({}, '', '/?locale=zh-Hans') const { changeLanguage } = await import('@/i18n-config/client') await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) expect(changeLanguage).toHaveBeenCalledWith('zh-Hans') - Object.defineProperty(window, 'location', { value: { search: originalSearch } }) + window.history.replaceState({}, '', '/') }) it('should set language from system variables when URL param is missing', async () => { @@ -780,14 +771,11 @@ describe('useEmbeddedChatbot', () => { await waitFor(() => { const stored = JSON.parse(localStorage.getItem(CONVERSATION_ID_INFO) || '{}') const appEntry = stored['app-1'] - // userId may be 'embedded-user-1' or 'DEFAULT' depending on timing; either is valid - const storedId = appEntry?.['embedded-user-1'] ?? appEntry?.DEFAULT - expect(storedId).toBe('new-conv-id') + expect(appEntry?.['user-1']).toBe('new-conv-id') }) }) - it('should use DEFAULT when userId is null', async () => { - // Override userId to be null/empty to exercise the "|| 'DEFAULT'" fallback path + it('should use the site EndUser when embeddedUserId is null', async () => { mockStoreState.embeddedUserId = null const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) @@ -798,8 +786,7 @@ describe('useEmbeddedChatbot', () => { await waitFor(() => { const stored = JSON.parse(localStorage.getItem(CONVERSATION_ID_INFO) || '{}') const appEntry = stored['app-1'] - // Should use DEFAULT key since userId is null - expect(appEntry?.DEFAULT).toBe('default-conv-id') + expect(appEntry?.['user-1']).toBe('default-conv-id') }) }) }) @@ -939,7 +926,7 @@ describe('useEmbeddedChatbot', () => { // Ensure a currentConversationId is set so appChatListData is fetched localStorage.setItem( CONVERSATION_ID_INFO, - JSON.stringify({ 'app-1': { DEFAULT: 'conversation-1' } }), + JSON.stringify({ 'app-1': { 'user-1': 'conversation-1' } }), ) mockFetchConversations.mockResolvedValue( createConversationData({ data: [createConversationItem({ id: 'conversation-1' })] }), @@ -981,7 +968,7 @@ describe('useEmbeddedChatbot', () => { mockFetchChatList.mockResolvedValue({ data: [] }) localStorage.setItem( CONVERSATION_ID_INFO, - JSON.stringify({ 'app-1': { DEFAULT: 'pinned-conv' } }), + JSON.stringify({ 'app-1': { 'user-1': 'pinned-conv' } }), ) const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp)) @@ -1029,7 +1016,7 @@ describe('useEmbeddedChatbot', () => { describe('currentConversationLatestInputs', () => { it('should return inputs from latest chat message when conversation has data', async () => { const convId = 'conversation-with-inputs' - localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({ 'app-1': { DEFAULT: convId } })) + localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({ 'app-1': { 'user-1': convId } })) mockFetchConversations.mockResolvedValue( createConversationData({ data: [createConversationItem({ id: convId })] }), ) diff --git a/web/app/components/base/chat/embedded-chatbot/hooks.tsx b/web/app/components/base/chat/embedded-chatbot/hooks.tsx index 0c7950f562c..6f5a3a32fda 100644 --- a/web/app/components/base/chat/embedded-chatbot/hooks.tsx +++ b/web/app/components/base/chat/embedded-chatbot/hooks.tsx @@ -21,6 +21,7 @@ import { useShareConversations, } from '@/service/use-share' import { useGetTryAppInfo, useGetTryAppParams } from '@/service/use-try-app' +import { getWebAppConversationScopeId, resolveWebAppAddress } from '@/service/webapp-address' import { TransferMethod } from '@/types/app' import { getProcessedFilesFromResponse } from '../../file-uploader/utils' import { @@ -119,8 +120,14 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri setLanguageFromParams() }, [appInfo]) const allowResetChat = !conversationId + const conversationScopeId = getWebAppConversationScopeId(resolveWebAppAddress(), appId) + const endUserId = (appInfo as AppData | undefined)?.end_user_id const { currentConversationId, handleConversationIdInfoChange, removeConversationIdInfo } = - useConversationSelection({ appId, userId, conversationId }) + useConversationSelection({ + scopeId: isTryApp || endUserId ? conversationScopeId : '', + userId: isTryApp ? userId : endUserId, + conversationId, + }) const [newConversationId, setNewConversationId] = useState('') const chatShouldReloadKey = useMemo(() => { if (currentConversationId === newConversationId) return '' diff --git a/web/app/components/base/chat/storage.ts b/web/app/components/base/chat/storage.ts index ea7e65733da..de29c80c413 100644 --- a/web/app/components/base/chat/storage.ts +++ b/web/app/components/base/chat/storage.ts @@ -17,117 +17,120 @@ const [ _useSetWebAppSidebarCollapseState, ] = createLocalStorageState('webappSidebarCollapse', undefined, { raw: true }) -const getAppConversationIds = (conversationIdInfo: ConversationIdInfo | null, appId: string) => { - const appConversationIds = conversationIdInfo?.[appId] - return typeof appConversationIds === 'object' && appConversationIds !== null - ? appConversationIds +const getScopeConversationIds = ( + conversationIdInfo: ConversationIdInfo | null, + scopeId: string, +) => { + const scopeConversationIds = conversationIdInfo?.[scopeId] + return typeof scopeConversationIds === 'object' && scopeConversationIds !== null + ? scopeConversationIds : undefined } const hasConversationId = ( conversationIdInfo: ConversationIdInfo | null, - appId: string, + scopeId: string, userId: string, -) => Object.hasOwn(getAppConversationIds(conversationIdInfo, appId) ?? {}, userId) +) => Object.hasOwn(getScopeConversationIds(conversationIdInfo, scopeId) ?? {}, userId) const getConversationId = ( conversationIdInfo: ConversationIdInfo | null, - appId: string, + scopeId: string, userId: string, -) => getAppConversationIds(conversationIdInfo, appId)?.[userId] ?? '' +) => getScopeConversationIds(conversationIdInfo, scopeId)?.[userId] ?? '' const setConversationId = ( conversationIdInfo: ConversationIdInfo | null, - appId: string, + scopeId: string, userId: string, conversationId: string, ): ConversationIdInfo => ({ ...(conversationIdInfo ?? {}), - [appId]: { - ...getAppConversationIds(conversationIdInfo, appId), + [scopeId]: { + ...getScopeConversationIds(conversationIdInfo, scopeId), [userId]: conversationId, }, }) -const removeAppConversationIds = ( +const removeScopeConversationIds = ( conversationIdInfo: ConversationIdInfo | null, - appId: string, + scopeId: string, ): ConversationIdInfo => { const nextConversationIdInfo = { ...(conversationIdInfo ?? {}) } - delete nextConversationIdInfo[appId] + delete nextConversationIdInfo[scopeId] return nextConversationIdInfo } type UseConversationSelectionOptions = { - appId?: string + scopeId?: string userId?: string conversationId?: string } const useConversationSelection = ({ - appId, + scopeId, userId, conversationId, }: UseConversationSelectionOptions) => { const [lastConversationIdInfo, setLastConversationIdInfo] = useLastConversationIdInfo() const [tabConversationIdInfo, setTabConversationIdInfo] = useTabConversationIdInfo() - const storageAppId = appId ?? '' + const storageScopeId = scopeId ?? '' const storageUserId = userId || 'DEFAULT' const hasTabConversationId = - !!appId && hasConversationId(tabConversationIdInfo, storageAppId, storageUserId) - const lastConversationId = appId - ? getConversationId(lastConversationIdInfo, storageAppId, storageUserId) + !!scopeId && hasConversationId(tabConversationIdInfo, storageScopeId, storageUserId) + const lastConversationId = scopeId + ? getConversationId(lastConversationIdInfo, storageScopeId, storageUserId) : '' - const tabConversationId = appId - ? getConversationId(tabConversationIdInfo, storageAppId, storageUserId) + const tabConversationId = scopeId + ? getConversationId(tabConversationIdInfo, storageScopeId, storageUserId) : '' // Seed this tab once from the cross-tab last selection. Later localStorage updates can change // the fallback without changing the active conversation already owned by this tab. useEffect(() => { - if (!appId || hasTabConversationId) return + if (!scopeId || hasTabConversationId) return setTabConversationIdInfo((currentConversationIdInfo) => { - if (hasConversationId(currentConversationIdInfo, appId, storageUserId)) + if (hasConversationId(currentConversationIdInfo, scopeId, storageUserId)) return currentConversationIdInfo - return setConversationId(currentConversationIdInfo, appId, storageUserId, lastConversationId) + return setConversationId( + currentConversationIdInfo, + scopeId, + storageUserId, + lastConversationId, + ) }) - }, [appId, hasTabConversationId, lastConversationId, setTabConversationIdInfo, storageUserId]) + }, [scopeId, hasTabConversationId, lastConversationId, setTabConversationIdInfo, storageUserId]) const handleConversationIdInfoChange = useCallback( (nextConversationId: string) => { - if (!appId) return + if (!scopeId) return setTabConversationIdInfo((currentConversationIdInfo) => - setConversationId(currentConversationIdInfo, appId, storageUserId, nextConversationId), + setConversationId(currentConversationIdInfo, scopeId, storageUserId, nextConversationId), ) setLastConversationIdInfo((currentConversationIdInfo) => - setConversationId(currentConversationIdInfo, appId, storageUserId, nextConversationId), + setConversationId(currentConversationIdInfo, scopeId, storageUserId, nextConversationId), ) }, - [appId, setLastConversationIdInfo, setTabConversationIdInfo, storageUserId], + [scopeId, setLastConversationIdInfo, setTabConversationIdInfo, storageUserId], ) - const removeConversationIdInfo = useCallback( - (targetAppId: string) => { - setTabConversationIdInfo((currentConversationIdInfo) => { - const nextConversationIdInfo = removeAppConversationIds( - currentConversationIdInfo, - targetAppId, - ) - if (targetAppId !== appId) return nextConversationIdInfo + const removeConversationIdInfo = useCallback(() => { + if (!scopeId) return - // An explicit empty entry keeps this tab on New Chat instead of falling back to a last - // conversation that another tab may write after the reset. - return setConversationId(nextConversationIdInfo, targetAppId, storageUserId, '') - }) - setLastConversationIdInfo((currentConversationIdInfo) => - removeAppConversationIds(currentConversationIdInfo, targetAppId), - ) - }, - [appId, setLastConversationIdInfo, setTabConversationIdInfo, storageUserId], - ) + setTabConversationIdInfo((currentConversationIdInfo) => { + const nextConversationIdInfo = removeScopeConversationIds(currentConversationIdInfo, scopeId) + + // An explicit empty entry keeps this tab on New Chat instead of falling back to a last + // conversation that another tab may write after the reset. + return setConversationId(nextConversationIdInfo, scopeId, storageUserId, '') + }) + setLastConversationIdInfo((currentConversationIdInfo) => + removeScopeConversationIds(currentConversationIdInfo, scopeId), + ) + }, [scopeId, setLastConversationIdInfo, setTabConversationIdInfo, storageUserId]) return { currentConversationId: diff --git a/web/app/components/base/copy-feedback/index.tsx b/web/app/components/base/copy-feedback/index.tsx index 8cbda5df6a4..66b7062f456 100644 --- a/web/app/components/base/copy-feedback/index.tsx +++ b/web/app/components/base/copy-feedback/index.tsx @@ -9,20 +9,29 @@ import { useTranslation } from 'react-i18next' type CopyFeedbackProps = Readonly<{ content: string className?: string + copiedLabel?: string + copyLabel?: string + onCopyError?: () => void }> const prefixEmbedded = 'overview.appInfo.embedded' -export function CopyFeedback({ content, className }: CopyFeedbackProps) { +export function CopyFeedback({ + content, + className, + copiedLabel, + copyLabel, + onCopyError, +}: CopyFeedbackProps) { const { t } = useTranslation() // Rely on useClipboard's own timer to flip `copied` back to false so the // "Copied" tooltip stays visible long enough to be read, matching the // KeyValueItem pattern. Do NOT reset on mouse leave. - const { copied, copy } = useClipboard({ timeout: 2000 }) + const { copied, copy } = useClipboard({ timeout: 2000, onCopyError }) const tooltipText = copied - ? t(($) => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' }) - : t(($) => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' }) + ? (copiedLabel ?? t(($) => $[`${prefixEmbedded}.copied`], { ns: 'appOverview' })) + : (copyLabel ?? t(($) => $[`${prefixEmbedded}.copy`], { ns: 'appOverview' })) /* v8 ignore next -- i18n test mock always returns a non-empty string; runtime fallback is defensive. -- @preserve */ const safeText = tooltipText || '' diff --git a/web/app/components/base/qrcode/__tests__/index.spec.tsx b/web/app/components/base/qrcode/__tests__/index.spec.tsx index ca20ec627b7..7c3441f5033 100644 --- a/web/app/components/base/qrcode/__tests__/index.spec.tsx +++ b/web/app/components/base/qrcode/__tests__/index.spec.tsx @@ -14,6 +14,23 @@ describe('ShareQRCode', () => { vi.clearAllMocks() }) + it('uses caller-provided labels instead of App-specific defaults', async () => { + const user = userEvent.setup() + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'Show QR code' })) + + expect(screen.getByText('Scan to share')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Download QR code' })).toBeInTheDocument() + }) + describe('Rendering', () => { it('renders correctly', () => { render() diff --git a/web/app/components/base/qrcode/index.tsx b/web/app/components/base/qrcode/index.tsx index 0772420a335..ad5227a6c9f 100644 --- a/web/app/components/base/qrcode/index.tsx +++ b/web/app/components/base/qrcode/index.tsx @@ -9,11 +9,14 @@ import { downloadUrl } from '@/utils/download' type Props = Readonly<{ content: string + downloadLabel?: string + scanLabel?: string + triggerLabel?: string }> const prefixEmbedded = 'overview.appInfo.qrcode.title' -const ShareQRCode = ({ content }: Props) => { +const ShareQRCode = ({ content, downloadLabel, scanLabel, triggerLabel }: Props) => { const { t } = useTranslation() const [isShow, setIsShow] = useState(false) const qrCodeRef = useRef(null) @@ -42,10 +45,11 @@ const ShareQRCode = ({ content }: Props) => { downloadUrl({ url: canvas.toDataURL(), fileName: 'qrcode.png' }) } - const tooltipText = t(($) => $[`${prefixEmbedded}`], { ns: 'appOverview' }) + const tooltipText = triggerLabel ?? t(($) => $[`${prefixEmbedded}`], { ns: 'appOverview' }) /* v8 ignore next -- react-i18next returns a non-empty key/string in configured runtime; empty fallback protects against missing i18n payloads. @preserve */ const safeTooltipText = tooltipText || '' - const downloadText = t(($) => $['overview.appInfo.qrcode.download'], { ns: 'appOverview' }) + const downloadText = + downloadLabel ?? t(($) => $['overview.appInfo.qrcode.download'], { ns: 'appOverview' }) return ( @@ -64,6 +68,12 @@ const ShareQRCode = ({ content }: Props) => { >
+ {scanLabel ? ( + <> +
{scanLabel}
+
·
+ + ) : null}