mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
feat: app deployment v2 (#41444)
Co-authored-by: zhangx1n <zhangxin@dify.ai> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
8e2058a962
commit
550196e3b8
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
401
api/controllers/files/appdeploy_files.py
Normal file
401
api/controllers/files/appdeploy_files.py
Normal file
@ -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
|
||||
``<img src>`` 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/<uuid:file_id>/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",
|
||||
]
|
||||
57
api/controllers/files/wraps.py
Normal file
57
api/controllers/files/wraps.py
Normal file
@ -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
|
||||
@ -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",
|
||||
|
||||
203
api/controllers/inner_api/app/file_grants.py
Normal file
203
api/controllers/inner_api/app/file_grants.py
Normal file
@ -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",
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
49
api/fields/file_grant_fields.py
Normal file
49
api/fields/file_grant_fields.py
Normal file
@ -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"]
|
||||
@ -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"
|
||||
|
||||
213
api/repositories/file_grant_repository.py
Normal file
213
api/repositories/file_grant_repository.py
Normal file
@ -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"]
|
||||
@ -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)
|
||||
|
||||
139
api/services/entities/file_grant_entities.py
Normal file
139
api/services/entities/file_grant_entities.py
Normal file
@ -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, ...]
|
||||
34
api/services/errors/file_grant.py
Normal file
34
api/services/errors/file_grant.py
Normal file
@ -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
|
||||
327
api/services/file_grant_gateways.py
Normal file
327
api/services/file_grant_gateways.py
Normal file
@ -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",
|
||||
]
|
||||
314
api/services/file_grant_service.py
Normal file
314
api/services/file_grant_service.py
Normal file
@ -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",
|
||||
]
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
833
api/tests/unit_tests/controllers/files/test_appdeploy_files.py
Normal file
833
api/tests/unit_tests/controllers/files/test_appdeploy_files.py
Normal file
@ -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))
|
||||
151
api/tests/unit_tests/controllers/files/test_file_grant_wraps.py
Normal file
151
api/tests/unit_tests/controllers/files/test_file_grant_wraps.py
Normal file
@ -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)}")
|
||||
@ -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",
|
||||
}
|
||||
@ -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"),
|
||||
|
||||
31
api/tests/unit_tests/file_grant_test_utils.py
Normal file
31
api/tests/unit_tests/file_grant_test_utils.py
Normal file
@ -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,
|
||||
)
|
||||
@ -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",
|
||||
|
||||
@ -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]
|
||||
90
api/tests/unit_tests/services/test_file_grant_gateways.py
Normal file
90
api/tests/unit_tests/services/test_file_grant_gateways.py
Normal file
@ -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"
|
||||
147
api/tests/unit_tests/services/test_file_grant_service.py
Normal file
147
api/tests/unit_tests/services/test_file_grant_service.py
Normal file
@ -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()
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
},
|
||||
)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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<EnvironmentPoolShare>
|
||||
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<EnvironmentVariableSlot>
|
||||
}
|
||||
|
||||
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<EnvironmentActivity>
|
||||
}
|
||||
|
||||
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<EnvironmentVariableSlot>
|
||||
environment_variable_groups: Array<EnvironmentVariableGroup>
|
||||
credential_slots: Array<CredentialSlot>
|
||||
}
|
||||
|
||||
@ -686,14 +699,15 @@ export type ListAppEnvironmentsResponse = {
|
||||
data: Array<AppEnvironment>
|
||||
}
|
||||
|
||||
export type ListApplicationInteractionsResponse = {
|
||||
data: Array<ApplicationInteraction>
|
||||
export type ListApplicationInteractionAppsResponse = {
|
||||
data: Array<NamedRef>
|
||||
pagination: Pagination
|
||||
}
|
||||
|
||||
export type ListAppsResponse = {
|
||||
data: Array<DashboardApp>
|
||||
pagination: Pagination
|
||||
export type ListApplicationInteractionsResponse = {
|
||||
data: Array<ApplicationInteraction>
|
||||
nextPageToken?: string
|
||||
previousPageToken?: string
|
||||
}
|
||||
|
||||
export type ListDeploymentOperationsResponse = {
|
||||
@ -707,7 +721,6 @@ export type ListEnvironmentApiKeysResponse = {
|
||||
|
||||
export type ListEnvironmentDeployedAppsResponse = {
|
||||
data: Array<EnvironmentDeployedApp>
|
||||
summary: EnvironmentDeployedAppSummary
|
||||
pagination: Pagination
|
||||
}
|
||||
|
||||
@ -724,11 +737,34 @@ export type ListEnvironmentsResponse = {
|
||||
pagination: Pagination
|
||||
}
|
||||
|
||||
export type ListOperationAppsResponse = {
|
||||
data: Array<OperationApp>
|
||||
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<WorkflowDeploymentEnvironment>
|
||||
environments?: Array<SourceVersionDeploymentEnvironment>
|
||||
}
|
||||
|
||||
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<WorkflowPath>
|
||||
}
|
||||
|
||||
export type WorkflowAsToolSource = {
|
||||
workflow: WorkflowReference
|
||||
paths: Array<WorkflowPath>
|
||||
}
|
||||
|
||||
export type WorkflowDeploymentInput = {
|
||||
environment_variables?: Array<EnvironmentVariableInput>
|
||||
environment_variable_groups: Array<WorkflowEnvironmentVariableInputGroup>
|
||||
credentials?: Array<CredentialSelectionInput>
|
||||
}
|
||||
|
||||
export type WorkflowEnvironmentVariableInputGroup = {
|
||||
workflow_id: string
|
||||
environment_variables: Array<EnvironmentVariableInput>
|
||||
}
|
||||
|
||||
export type WorkflowPath = {
|
||||
workflows: Array<WorkflowReference>
|
||||
}
|
||||
|
||||
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<EnvironmentDeployedAppWritable>
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
|
||||
@ -166,7 +166,9 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (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, {
|
||||
|
||||
@ -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(
|
||||
<Splash>
|
||||
<div>share application</div>
|
||||
</Splash>,
|
||||
)
|
||||
|
||||
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(
|
||||
<Splash>
|
||||
<div>share application</div>
|
||||
</Splash>,
|
||||
)
|
||||
|
||||
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(
|
||||
<Splash>
|
||||
<div>share application</div>
|
||||
</Splash>,
|
||||
)
|
||||
|
||||
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',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
14
web/app/(shareLayout)/environment/chat/[token]/page.tsx
Normal file
14
web/app/(shareLayout)/environment/chat/[token]/page.tsx
Normal file
@ -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 (
|
||||
<AuthenticatedLayout>
|
||||
<ChatWithHistoryWrap />
|
||||
</AuthenticatedLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(EnvironmentChat)
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 (
|
||||
<div className="flex w-100 flex-col gap-3">
|
||||
<div className="inline-flex size-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg">
|
||||
<RiMailSendFill className="size-6 text-2xl text-text-accent-light-mode-only" />
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-mail-send-fill size-6 text-2xl text-text-accent-light-mode-only"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2 pb-4">
|
||||
<h1 className="title-4xl-semi-bold text-text-primary">
|
||||
@ -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"
|
||||
>
|
||||
<span className="bg-background-default-dimm inline-block rounded-full p-1">
|
||||
<RiArrowLeftLine aria-hidden size={12} />
|
||||
<span aria-hidden className="i-ri-arrow-left-line block size-3" />
|
||||
</span>
|
||||
<span className="ml-2 system-xs-regular">{t(($) => $.back, { ns: 'login' })}</span>
|
||||
</button>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(<AppDetailSection />)
|
||||
// Act
|
||||
render(<AppDetailSection />)
|
||||
|
||||
// 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([
|
||||
{
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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(
|
||||
<AccessPointCard
|
||||
title="Web App"
|
||||
description="Web application access"
|
||||
icon="i-ri-robot-2-line"
|
||||
status="disabled"
|
||||
statusLabel="Disabled"
|
||||
switchDisabled
|
||||
switchDisabledReason="Publish first"
|
||||
switchLabel="Toggle Web App"
|
||||
onEnabledChange={onEnabledChange}
|
||||
>
|
||||
Access URL
|
||||
</AccessPointCard>,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(
|
||||
<AccessPointUrl
|
||||
{...endpointProps}
|
||||
enabled={false}
|
||||
showOpen
|
||||
openLabel="Open"
|
||||
openDisabledReason="Publish first"
|
||||
/>,
|
||||
)
|
||||
|
||||
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(<AccessPointUrl {...endpointProps} enabled={false} unavailable />)
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((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(
|
||||
<EnvironmentWebAppCard
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<EnvironmentWebAppCard
|
||||
@ -237,8 +272,8 @@ describe('environment access point cards', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<EnvironmentWebAppCard
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion
|
||||
/>,
|
||||
)
|
||||
|
||||
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<typeof site>()
|
||||
const secondToggle = createDeferredPromise<typeof site>()
|
||||
mocks.updateSite
|
||||
.mockReturnValueOnce(firstToggle.promise)
|
||||
.mockReturnValueOnce(secondToggle.promise)
|
||||
renderCard(
|
||||
<EnvironmentWebAppCard
|
||||
appId="app-1"
|
||||
environmentId="staging"
|
||||
canManageAccessPoint
|
||||
canReleaseAndVersion
|
||||
/>,
|
||||
)
|
||||
|
||||
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<typeof api>()
|
||||
mocks.updateApi.mockReturnValueOnce(toggle.promise)
|
||||
renderCard(
|
||||
<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManageAccessPoint />,
|
||||
)
|
||||
|
||||
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,
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
function renderCard(cardAppInfo: AccessPointAppInfo = appInfo, workflow?: PublishedWorkflow) {
|
||||
const queryClient = createTestQueryClient()
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MCPAccessPointCard
|
||||
appInfo={cardAppInfo}
|
||||
canManageAccessPoint
|
||||
triggerModeDisabled={false}
|
||||
workflow={workflow}
|
||||
workflowLoading={false}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<MCPAccessPointCard
|
||||
appInfo={appInfo}
|
||||
canManageAccessPoint
|
||||
triggerModeDisabled={false}
|
||||
workflow={undefined}
|
||||
workflowLoading={false}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<MCPAccessPointCard
|
||||
appInfo={workflowAppInfo}
|
||||
canManageAccessPoint
|
||||
triggerModeDisabled={false}
|
||||
workflow={publishedWorkflow}
|
||||
workflowLoading={false}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<MCPAccessPointCard
|
||||
appInfo={workflowAppInfo}
|
||||
canManageAccessPoint
|
||||
triggerModeDisabled={false}
|
||||
workflow={publishedWorkflow}
|
||||
workflowLoading={false}
|
||||
/>,
|
||||
)
|
||||
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<void>()
|
||||
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<void>()
|
||||
const secondToggle = createDeferredPromise<void>()
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<AccessPointAppInfo> = {},
|
||||
) {
|
||||
useAppStore.setState({ appDetail: createAppInfo(mode, overrides) })
|
||||
const queryClient = createTestQueryClient()
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StoreConnectedServiceApiCard availability={availability} canManage={canManage} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function StoreConnectedServiceApiCard({
|
||||
availability,
|
||||
canManage,
|
||||
}: {
|
||||
availability: 'available' | 'loading' | 'unavailable'
|
||||
canManage: boolean
|
||||
}) {
|
||||
const appInfo = useAppStore((state) => state.appDetail)
|
||||
if (!appInfo) return null
|
||||
|
||||
return (
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={appInfo}
|
||||
availability={availability}
|
||||
canManage={canManage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((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(
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={createAppInfo(mode)}
|
||||
availability="available"
|
||||
canManage
|
||||
onAppStateChanged={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
|
||||
availability="available"
|
||||
canManage
|
||||
onAppStateChanged={onAppStateChanged}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
|
||||
availability="loading"
|
||||
canManage
|
||||
onAppStateChanged={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={createAppInfo(AppModeEnum.WORKFLOW, { enable_api: false })}
|
||||
availability="available"
|
||||
canManage
|
||||
onAppStateChanged={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
|
||||
availability="available"
|
||||
canManage={false}
|
||||
onAppStateChanged={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
|
||||
availability="unavailable"
|
||||
canManage
|
||||
onAppStateChanged={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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(
|
||||
<TriggerAccessPointCard
|
||||
appInfo={appInfo}
|
||||
availability={availability}
|
||||
canManageAccessPoint
|
||||
onToggleResult={vi.fn()}
|
||||
/>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TriggerAccessPointCard appInfo={appInfo} availability={availability} canManageAccessPoint />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((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<void>()
|
||||
const secondToggle = createDeferredPromise<void>()
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
})
|
||||
|
||||
@ -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<void>
|
||||
onRefreshApp?: () => Promise<void>
|
||||
} = {},
|
||||
) {
|
||||
useAppStore.setState({ appDetail: createAppInfo(mode) })
|
||||
const queryClient = createTestQueryClient()
|
||||
render(
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StoreConnectedWebAppCard
|
||||
availability={availability}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
onRefreshApp={onRefreshApp}
|
||||
workflow={workflow}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function StoreConnectedWebAppCard({
|
||||
availability,
|
||||
canManageAccessPoint,
|
||||
onRefreshApp,
|
||||
workflow,
|
||||
}: {
|
||||
availability: 'available' | 'loading' | 'unavailable'
|
||||
canManageAccessPoint: boolean
|
||||
onRefreshApp: () => Promise<void>
|
||||
workflow?: PublishedWorkflow
|
||||
}) {
|
||||
const appInfo = useAppStore((state) => state.appDetail)
|
||||
if (!appInfo) return null
|
||||
|
||||
return (
|
||||
<WebAppAccessPointCard
|
||||
appInfo={createAppInfo(mode)}
|
||||
appInfo={appInfo}
|
||||
availability={availability}
|
||||
canDeploy
|
||||
canManageAccess
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
showAccessControl
|
||||
onAppStateChanged={onAppStateChanged}
|
||||
onRefreshApp={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshApp={onRefreshApp}
|
||||
onSaveSiteConfig={vi.fn().mockResolvedValue(undefined)}
|
||||
workflow={workflow}
|
||||
/>,
|
||||
{ wrapper: createQueryClientWrapper(queryClient) },
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((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<PublishedWorkflow> = {
|
||||
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<void>((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 })
|
||||
|
||||
|
||||
@ -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'}
|
||||
/>
|
||||
<MCPAccessPointCard
|
||||
@ -138,7 +136,6 @@ export function BuiltInAccessPoints({
|
||||
appInfo={appInfo}
|
||||
availability={triggerAvailability}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
onToggleResult={actions.handleResult}
|
||||
highlighted={highlightedAccessPoint === 'trigger'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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<boolean | null>(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={
|
||||
<Button
|
||||
@ -191,7 +206,6 @@ export function MCPAccessPointCard({
|
||||
latestParams={latestParams}
|
||||
onHide={() => {
|
||||
setShowServerModal(false)
|
||||
setPendingStatus(null)
|
||||
invalidateServerDetail(appInfo.id)
|
||||
}}
|
||||
appInfo={appInfo}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessPointAvailability } from '../shared/access-point-status'
|
||||
import type { AccessPointAppInfo } from '../shared/utils'
|
||||
import type { AccessPointAvailability } from '@/app/components/base/access-point/status'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { getAccessPointStatus } from '@/app/components/base/access-point/status'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { getAccessPointStatus } from '../shared/access-point-status'
|
||||
import { ServiceApiCardView } from '../shared/service-api-card-view'
|
||||
import { getBuiltInAccessUrls } from '../shared/utils'
|
||||
|
||||
@ -15,7 +16,6 @@ type ServiceApiAccessPointCardProps = {
|
||||
availability: AccessPointAvailability
|
||||
canManage: boolean
|
||||
highlighted?: boolean
|
||||
onAppStateChanged: () => Promise<void>
|
||||
}
|
||||
|
||||
export function ServiceApiAccessPointCard({
|
||||
@ -23,27 +23,48 @@ export function ServiceApiAccessPointCard({
|
||||
availability,
|
||||
canManage,
|
||||
highlighted,
|
||||
onAppStateChanged,
|
||||
}: ServiceApiAccessPointCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateApiStatus = useMutation(
|
||||
const setAppDetail = useAppStore((state) => state.setAppDetail)
|
||||
const toggleApiMutation = useMutation(
|
||||
consoleQuery.apps.byAppId.apiEnable.post.mutationOptions({
|
||||
onSuccess: onAppStateChanged,
|
||||
scope: {
|
||||
id: `app-service-api-toggle:${appInfo.id}`,
|
||||
},
|
||||
onSuccess: (updatedApp) => {
|
||||
const currentAppDetail = useAppStore.getState().appDetail
|
||||
if (!currentAppDetail || currentAppDetail.id !== appInfo.id) return
|
||||
|
||||
setAppDetail({
|
||||
...currentAppDetail,
|
||||
enable_api: updatedApp.enable_api,
|
||||
updated_at: updatedApp.updated_at ?? currentAppDetail.updated_at,
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
},
|
||||
}),
|
||||
)
|
||||
const { api: apiUrl } = getBuiltInAccessUrls(appInfo)
|
||||
const running = availability === 'available' && appInfo.enable_api
|
||||
const pendingEnabled = toggleApiMutation.variables?.body.enable_api
|
||||
const optimisticEnabled =
|
||||
toggleApiMutation.isPending && pendingEnabled !== undefined
|
||||
? pendingEnabled
|
||||
: appInfo.enable_api
|
||||
const running = availability === 'available' && optimisticEnabled
|
||||
const status = getAccessPointStatus(availability, running)
|
||||
|
||||
const handleStatusChange = (enabled: boolean) => {
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
if (!canManage) return
|
||||
|
||||
updateApiStatus.mutate({
|
||||
params: { app_id: appInfo.id },
|
||||
body: { enable_api: enabled },
|
||||
toggleApiMutation.mutate({
|
||||
params: {
|
||||
app_id: appInfo.id,
|
||||
},
|
||||
body: {
|
||||
enable_api: enabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -60,8 +81,7 @@ export function ServiceApiAccessPointCard({
|
||||
status={status}
|
||||
highlighted={highlighted}
|
||||
switchDisabled={!canManage}
|
||||
switchLoading={updateApiStatus.isPending}
|
||||
onEnabledChange={availability === 'available' ? handleStatusChange : undefined}
|
||||
onEnabledChange={availability === 'available' ? handleEnabledChange : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,21 +5,21 @@ import type { TriggerWithProvider } from '@/app/components/workflow/block-select
|
||||
import type { AppTrigger } from '@/service/use-tools'
|
||||
import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessPointCard, AccessPointEmptyContent } from '@/app/components/base/access-point/card'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import Link from '@/next/link'
|
||||
import {
|
||||
useAppTriggers,
|
||||
useInvalidateAppTriggers,
|
||||
useUpdateTriggerStatus,
|
||||
} from '@/service/use-tools'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useAppTriggers, useInvalidateAppTriggers } from '@/service/use-tools'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
import { canFindTool } from '@/utils'
|
||||
import { AccessPointCard, AccessPointEmptyContent } from '../shared/access-point-card'
|
||||
import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label'
|
||||
|
||||
function TriggerIcon({
|
||||
trigger,
|
||||
@ -52,12 +52,89 @@ function TriggerIcon({
|
||||
)
|
||||
}
|
||||
|
||||
function TriggerAccessPointItem({
|
||||
appId,
|
||||
canManageAccessPoint,
|
||||
trigger,
|
||||
triggerPlugins,
|
||||
}: {
|
||||
appId: string
|
||||
canManageAccessPoint: boolean
|
||||
trigger: AppTrigger
|
||||
triggerPlugins: TriggerWithProvider[]
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const invalidateTriggers = useInvalidateAppTriggers()
|
||||
const updateTriggerMutation = useMutation(
|
||||
consoleQuery.apps.byAppId.triggerEnable.post.mutationOptions({
|
||||
scope: {
|
||||
id: `app-trigger-toggle:${appId}:${trigger.id}`,
|
||||
},
|
||||
onSuccess: () => invalidateTriggers(appId),
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
},
|
||||
}),
|
||||
)
|
||||
const pendingEnabled = updateTriggerMutation.variables?.body.enable_trigger
|
||||
const enabled =
|
||||
updateTriggerMutation.isPending && pendingEnabled !== undefined
|
||||
? pendingEnabled
|
||||
: trigger.status === 'enabled'
|
||||
const statusLabel = enabled
|
||||
? t(($) => $['agentDetail.access.status.inService'], {
|
||||
ns: 'agentV2',
|
||||
})
|
||||
: t(($) => $['overview.status.disable'], {
|
||||
ns: 'appOverview',
|
||||
})
|
||||
|
||||
const handleEnabledChange = (nextEnabled: boolean) => {
|
||||
if (!canManageAccessPoint) return
|
||||
|
||||
updateTriggerMutation.mutate({
|
||||
params: {
|
||||
app_id: appId,
|
||||
},
|
||||
body: {
|
||||
trigger_id: trigger.id,
|
||||
enable_trigger: nextEnabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-state-base-hover">
|
||||
<TriggerIcon trigger={trigger} triggerPlugins={triggerPlugins} />
|
||||
<span className="w-28 shrink-0 truncate system-sm-medium text-text-secondary">
|
||||
{trigger.title}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
|
||||
{trigger.provider_name}
|
||||
</span>
|
||||
<span
|
||||
className={`flex shrink-0 items-center gap-1 system-xs-semibold-uppercase ${
|
||||
enabled ? 'text-text-success' : 'text-text-tertiary'
|
||||
}`}
|
||||
>
|
||||
<StatusDot size="small" status={enabled ? 'success' : 'disabled'} />
|
||||
{statusLabel}
|
||||
</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={!canManageAccessPoint}
|
||||
aria-label={trigger.title}
|
||||
onCheckedChange={handleEnabledChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TriggerAccessPointCardProps = {
|
||||
appInfo: AccessPointAppInfo
|
||||
availability: 'available' | 'loading' | 'unavailable'
|
||||
canManageAccessPoint: boolean
|
||||
highlighted?: boolean
|
||||
onToggleResult: (error: Error | null) => void
|
||||
}
|
||||
|
||||
export function TriggerAccessPointCard({
|
||||
@ -65,19 +142,17 @@ export function TriggerAccessPointCard({
|
||||
availability,
|
||||
canManageAccessPoint,
|
||||
highlighted,
|
||||
onToggleResult,
|
||||
}: TriggerAccessPointCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const { data: response, isLoading } = useAppTriggers(appInfo.id)
|
||||
const { data: triggerPlugins = [] } = useAllTriggerPlugins()
|
||||
const { mutateAsync: updateTriggerStatus, isPending: statusUpdating } = useUpdateTriggerStatus()
|
||||
const invalidateTriggers = useInvalidateAppTriggers()
|
||||
const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore()
|
||||
const setTriggerStatuses = useTriggerStatusStore((state) => state.setTriggerStatuses)
|
||||
const triggers = useMemo(() => response?.data ?? [], [response?.data])
|
||||
const loading = availability === 'loading' || isLoading
|
||||
const active = availability === 'available' && !loading
|
||||
const status = loading ? 'loading' : active ? 'inService' : 'unavailable'
|
||||
const statusLabel = useAccessPointStatusLabel(status)
|
||||
const enabledCount = triggers.filter((trigger) => trigger.status === 'enabled').length
|
||||
|
||||
useEffect(() => {
|
||||
@ -94,25 +169,6 @@ export function TriggerAccessPointCard({
|
||||
)
|
||||
}, [setTriggerStatuses, triggers])
|
||||
|
||||
const toggleTrigger = async (trigger: AppTrigger, enabled: boolean) => {
|
||||
if (!canManageAccessPoint) return
|
||||
const status = enabled ? 'enabled' : 'disabled'
|
||||
setTriggerStatus(trigger.node_id, status)
|
||||
|
||||
try {
|
||||
await updateTriggerStatus({
|
||||
appId: appInfo.id,
|
||||
triggerId: trigger.id,
|
||||
enableTrigger: enabled,
|
||||
})
|
||||
invalidateTriggers(appInfo.id)
|
||||
onToggleResult(null)
|
||||
} catch (error) {
|
||||
setTriggerStatus(trigger.node_id, enabled ? 'disabled' : 'enabled')
|
||||
onToggleResult(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessPointCard
|
||||
title={t(($) => $['settings.trigger'], { ns: 'common' })}
|
||||
@ -121,6 +177,7 @@ export function TriggerAccessPointCard({
|
||||
})}
|
||||
icon="i-custom-vender-integrations-trigger"
|
||||
status={status}
|
||||
statusLabel={statusLabel}
|
||||
highlighted={highlighted}
|
||||
showStatus={!active}
|
||||
>
|
||||
@ -160,45 +217,15 @@ export function TriggerAccessPointCard({
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{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 (
|
||||
<div
|
||||
key={trigger.id}
|
||||
className="flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
|
||||
>
|
||||
<TriggerIcon trigger={trigger} triggerPlugins={triggerPlugins} />
|
||||
<span className="w-28 shrink-0 truncate system-sm-medium text-text-secondary">
|
||||
{trigger.title}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
|
||||
{trigger.provider_name}
|
||||
</span>
|
||||
<span
|
||||
className={`flex shrink-0 items-center gap-1 system-xs-semibold-uppercase ${
|
||||
enabled ? 'text-text-success' : 'text-text-tertiary'
|
||||
}`}
|
||||
>
|
||||
<StatusDot size="small" status={enabled ? 'success' : 'disabled'} />
|
||||
{statusLabel}
|
||||
</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={!canManageAccessPoint || statusUpdating}
|
||||
aria-label={trigger.title}
|
||||
onCheckedChange={(nextEnabled) => void toggleTrigger(trigger, nextEnabled)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{triggers.map((trigger) => (
|
||||
<TriggerAccessPointItem
|
||||
key={trigger.id}
|
||||
appId={appInfo.id}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
trigger={trigger}
|
||||
triggerPlugins={triggerPlugins}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -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, string> = {
|
||||
[AccessMode.ORGANIZATION]: 'i-ri-building-line',
|
||||
@ -56,7 +61,6 @@ type WebAppAccessPointCardProps = {
|
||||
canManageAccessPoint: boolean
|
||||
highlighted?: boolean
|
||||
showAccessControl: boolean
|
||||
onAppStateChanged: () => Promise<void>
|
||||
onRefreshApp: () => Promise<void>
|
||||
onSaveSiteConfig: (params: ConfigParams) => Promise<void>
|
||||
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 && (
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={!running}
|
||||
disabled={!actionsAvailable || !canManageAccessPoint}
|
||||
onClick={() => setShowWorkflowLaunch(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-settings-2-line size-4" />
|
||||
@ -176,7 +203,7 @@ export function WebAppAccessPointCard({
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={!running || !canManageAccessPoint}
|
||||
disabled={!actionsAvailable || !canManageAccessPoint}
|
||||
onClick={() => setShowEmbedded(true)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-window-line size-4" />
|
||||
@ -186,7 +213,7 @@ export function WebAppAccessPointCard({
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={!running || !canManageAccessPoint}
|
||||
disabled={!actionsAvailable || !canManageAccessPoint}
|
||||
onClick={() => setShowCustomize(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
|
||||
@ -219,7 +246,7 @@ export function WebAppAccessPointCard({
|
||||
showQrCode
|
||||
showRegenerate
|
||||
openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })}
|
||||
openUrl={webAppUrl}
|
||||
openUrl={appInfo.enable_site && !toggleSiteMutation.isPending ? webAppUrl : undefined}
|
||||
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
|
||||
ns: 'appOverview',
|
||||
})}
|
||||
@ -227,16 +254,18 @@ export function WebAppAccessPointCard({
|
||||
regenerating={resetSiteAccessToken.isPending}
|
||||
onRegenerate={() => setShowRegenerate(true)}
|
||||
/>
|
||||
{showAccessControl && (
|
||||
<WebAppAccessControlEntry
|
||||
accessConfigured={accessConfigured}
|
||||
accessIcon={accessIcon}
|
||||
accessLabel={t(accessLabel, { ns: 'app' })}
|
||||
available={availability === 'available'}
|
||||
disabled={!canManageAccess}
|
||||
onClick={() => setShowAccess(true)}
|
||||
/>
|
||||
)}
|
||||
{showAccessControl &&
|
||||
(availability === 'available' ? (
|
||||
<WebAppAccessControlEntry
|
||||
accessConfigured={accessConfigured}
|
||||
accessIcon={accessIcon}
|
||||
accessLabel={t(accessLabel, { ns: 'app' })}
|
||||
disabled={!canManageAccess}
|
||||
onClick={() => setShowAccess(true)}
|
||||
/>
|
||||
) : (
|
||||
<WebAppAccessControlEntrySkeleton loading={availability === 'loading'} />
|
||||
))}
|
||||
</AccessPointCard>
|
||||
|
||||
<SettingsModal
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -21,7 +21,6 @@ export function EnvironmentServiceApiCard({
|
||||
highlighted,
|
||||
}: EnvironmentServiceApiCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const appMode = useAppStore((state) => state.appDetail?.mode)
|
||||
const params = {
|
||||
app_id: appId,
|
||||
@ -35,16 +34,18 @@ export function EnvironmentServiceApiCard({
|
||||
const api = apiQuery.data
|
||||
const apiMutation = useMutation(
|
||||
consoleQuery.enterprise.appDeploy.accessService.updateEnvironmentApi.mutationOptions({
|
||||
onSuccess: (updatedApi) => {
|
||||
queryClient.setQueryData(apiQueryOptions.queryKey, updatedApi)
|
||||
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
|
||||
scope: {
|
||||
id: `environment-service-api-toggle:${appId}:${environmentId}`,
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
},
|
||||
}),
|
||||
)
|
||||
const running = Boolean(apiQuery.isSuccess && api?.enabled)
|
||||
const pendingEnabled = apiMutation.variables?.body.enabled
|
||||
const optimisticEnabled =
|
||||
apiMutation.isPending && pendingEnabled !== undefined ? pendingEnabled : Boolean(api?.enabled)
|
||||
const running = apiQuery.isSuccess && optimisticEnabled
|
||||
const status = apiQuery.isPending
|
||||
? 'loading'
|
||||
: apiQuery.isError
|
||||
@ -78,7 +79,6 @@ export function EnvironmentServiceApiCard({
|
||||
highlighted={highlighted}
|
||||
switchDisabled={!canManageAccessPoint}
|
||||
onEnabledChange={apiQuery.isSuccess ? handleEnabledChange : undefined}
|
||||
switchLoading={apiMutation.isPending}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -18,14 +18,18 @@ import { useTranslation } from 'react-i18next'
|
||||
import CustomizeModal from '@/app/components/app/overview/customize'
|
||||
import SettingsModal from '@/app/components/app/overview/settings'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { AccessPointCard } from '@/app/components/base/access-point/card'
|
||||
import { AccessPointUrl } from '@/app/components/base/access-point/url'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { AccessMode, isAccessMode } from '@/models/access-control'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { AccessPointCard } from '../shared/access-point-card'
|
||||
import { AccessPointUrl } from '../shared/access-point-url'
|
||||
import { useAccessPointActions } from '../shared/use-access-point-actions'
|
||||
import { WebAppAccessControlEntry } from '../shared/web-app-access-control'
|
||||
import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label'
|
||||
import {
|
||||
WebAppAccessControlEntry,
|
||||
WebAppAccessControlEntrySkeleton,
|
||||
} from '../shared/web-app-access-control'
|
||||
import { EnvironmentAccessControl } from './environment-access-control'
|
||||
import { getEnvironmentWebAppUrl } from './environment-web-app-utils'
|
||||
|
||||
@ -94,9 +98,8 @@ export function EnvironmentWebAppCard({
|
||||
subjectsQuery.data.subjects.length > 0
|
||||
const siteMutation = useMutation(
|
||||
consoleQuery.enterprise.appDeploy.accessService.updateEnvironmentSite.mutationOptions({
|
||||
onSuccess: (updatedSite) => {
|
||||
queryClient.setQueryData(siteQueryOptions.queryKey, updatedSite)
|
||||
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
|
||||
scope: {
|
||||
id: `environment-web-app-toggle:${appId}:${environmentId}`,
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
|
||||
@ -117,8 +120,12 @@ export function EnvironmentWebAppCard({
|
||||
},
|
||||
),
|
||||
)
|
||||
const webAppUrl = getEnvironmentWebAppUrl(site)
|
||||
const running = Boolean(siteQuery.isSuccess && site?.enabled)
|
||||
const webAppUrl = getEnvironmentWebAppUrl(site, appInfo?.mode)
|
||||
const pendingEnabled = siteMutation.variables?.body.enabled
|
||||
const optimisticEnabled =
|
||||
siteMutation.isPending && pendingEnabled !== undefined ? pendingEnabled : Boolean(site?.enabled)
|
||||
const running = siteQuery.isSuccess && optimisticEnabled
|
||||
const actionsAvailable = running && !siteMutation.isPending
|
||||
const status = siteQuery.isPending
|
||||
? 'loading'
|
||||
: siteQuery.isError
|
||||
@ -126,6 +133,7 @@ export function EnvironmentWebAppCard({
|
||||
: running
|
||||
? 'inService'
|
||||
: 'disabled'
|
||||
const statusLabel = useAccessPointStatusLabel(status)
|
||||
const accessLabel =
|
||||
accessMode === AccessMode.ORGANIZATION
|
||||
? t(($) => $['accessControlDialog.accessItems.organization'], { ns: 'app' })
|
||||
@ -170,17 +178,17 @@ export function EnvironmentWebAppCard({
|
||||
)
|
||||
}
|
||||
status={status}
|
||||
statusLabel={statusLabel}
|
||||
highlighted={highlighted}
|
||||
switchDisabled={!canManageAccessPoint}
|
||||
switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })}
|
||||
onEnabledChange={siteQuery.isSuccess ? handleEnabledChange : undefined}
|
||||
switchLoading={siteMutation.isPending}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
className="flex items-center gap-1 px-3"
|
||||
variant="secondary"
|
||||
disabled={!running || !apiQuery.isSuccess || !canManageAccessPoint}
|
||||
disabled={!actionsAvailable || !apiQuery.isSuccess || !canManageAccessPoint}
|
||||
onClick={() => setShowCustomize(true)}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
|
||||
@ -213,7 +221,7 @@ export function EnvironmentWebAppCard({
|
||||
showQrCode
|
||||
showRegenerate
|
||||
openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })}
|
||||
openUrl={webAppUrl}
|
||||
openUrl={site?.enabled && !siteMutation.isPending ? webAppUrl : undefined}
|
||||
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
|
||||
ns: 'appOverview',
|
||||
})}
|
||||
@ -221,16 +229,18 @@ export function EnvironmentWebAppCard({
|
||||
regenerating={resetAccessTokenMutation.isPending}
|
||||
onRegenerate={() => setShowRegenerate(true)}
|
||||
/>
|
||||
{systemFeatures.webapp_auth.enabled && (
|
||||
<WebAppAccessControlEntry
|
||||
accessConfigured={accessConfigured}
|
||||
accessIcon={ACCESS_MODE_ICON_MAP[accessMode]}
|
||||
accessLabel={accessLabel}
|
||||
available={siteQuery.isSuccess}
|
||||
disabled={!canReleaseAndVersion}
|
||||
onClick={() => setShowAccess(true)}
|
||||
/>
|
||||
)}
|
||||
{systemFeatures.webapp_auth.enabled &&
|
||||
(siteQuery.isSuccess ? (
|
||||
<WebAppAccessControlEntry
|
||||
accessConfigured={accessConfigured}
|
||||
accessIcon={ACCESS_MODE_ICON_MAP[accessMode]}
|
||||
accessLabel={accessLabel}
|
||||
disabled={!canReleaseAndVersion}
|
||||
onClick={() => setShowAccess(true)}
|
||||
/>
|
||||
) : (
|
||||
<WebAppAccessControlEntrySkeleton loading={siteQuery.isPending} />
|
||||
))}
|
||||
</AccessPointCard>
|
||||
|
||||
{appInfo && (
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import type { EnvironmentSite } from '@dify/contracts/enterprise-app-deploy/types.gen'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
export function getEnvironmentWebAppUrl(site?: EnvironmentSite) {
|
||||
export function getEnvironmentWebAppUrl(site?: EnvironmentSite, mode?: string) {
|
||||
if (!site?.app_base_url || !site.code) return ''
|
||||
|
||||
return `${site.app_base_url.replace(/\/$/, '')}${basePath}/env/workflow/${site.code}`
|
||||
const route = mode === AppModeEnum.ADVANCED_CHAT ? 'chat' : 'workflow'
|
||||
return `${site.app_base_url.replace(/\/$/, '')}${basePath}/environment/${route}/${site.code}`
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessPoint } from '@/app/components/app/deploy/access-point'
|
||||
import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessPointCard, AccessPointEmptyContent } from '../shared/access-point-card'
|
||||
import { AccessPointCard, AccessPointEmptyContent } from '@/app/components/base/access-point/card'
|
||||
import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label'
|
||||
import { EnvironmentServiceApiCard } from './environment-service-api-card'
|
||||
import { EnvironmentWebAppCard } from './environment-web-app-card'
|
||||
|
||||
@ -44,6 +45,7 @@ export function DeployedEnvironmentAccessPoints({
|
||||
highlightedAccessPoint,
|
||||
}: DeployedEnvironmentAccessPointsProps) {
|
||||
const { t } = useTranslation()
|
||||
const unsupportedStatusLabel = useAccessPointStatusLabel('unsupported')
|
||||
|
||||
const title = (accessPoint: (typeof UNSUPPORTED_ACCESS_POINTS)[number]) => {
|
||||
const key = ACCESS_POINT_CONFIG[accessPoint].title
|
||||
@ -85,6 +87,7 @@ export function DeployedEnvironmentAccessPoints({
|
||||
description={description(accessPoint)}
|
||||
icon={ACCESS_POINT_CONFIG[accessPoint].icon}
|
||||
status="unsupported"
|
||||
statusLabel={unsupportedStatusLabel}
|
||||
highlighted={highlightedAccessPoint === accessPoint}
|
||||
>
|
||||
<AccessPointEmptyContent>
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollAreaContent,
|
||||
ScrollAreaScrollbar,
|
||||
ScrollAreaThumb,
|
||||
ScrollAreaViewport,
|
||||
} from '@langgenius/dify-ui/scroll-area'
|
||||
import { Tabs, TabsList, TabsTab } from '@langgenius/dify-ui/tabs'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/access-point'
|
||||
import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
@ -64,7 +71,7 @@ function AccessPointContent({
|
||||
<header className="flex shrink-0 flex-col gap-3 px-6 pt-3 pb-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex h-6 items-center">
|
||||
<h1 className="title-xl-semi-bold text-text-primary">
|
||||
<h1 id="access-point-title" className="title-xl-semi-bold text-text-primary">
|
||||
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
|
||||
</h1>
|
||||
</div>
|
||||
@ -103,28 +110,41 @@ function AccessPointContent({
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto px-6 py-2"
|
||||
data-environment={selectedEnvironment}
|
||||
>
|
||||
{selectedEnvironment === BUILT_IN_ENVIRONMENT_ID ? (
|
||||
<BuiltInAccessPoints
|
||||
appId={appId}
|
||||
canDeploy={canDeploy}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
canReleaseAndVersion={canReleaseAndVersion}
|
||||
highlightedAccessPoint={selectedHighlightedAccessPoint}
|
||||
/>
|
||||
) : (
|
||||
<DeployedEnvironmentAccessPoints
|
||||
appId={appId}
|
||||
environmentId={selectedEnvironment}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
canReleaseAndVersion={canReleaseAndVersion}
|
||||
highlightedAccessPoint={selectedHighlightedAccessPoint}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<ScrollAreaViewport
|
||||
aria-labelledby="access-point-title"
|
||||
className="overscroll-contain"
|
||||
data-environment={selectedEnvironment}
|
||||
role="region"
|
||||
style={{ overflowX: 'hidden' }}
|
||||
>
|
||||
<ScrollAreaContent
|
||||
className="min-h-full w-full max-w-full px-6 py-2"
|
||||
style={{ minWidth: 0 }}
|
||||
>
|
||||
{selectedEnvironment === BUILT_IN_ENVIRONMENT_ID ? (
|
||||
<BuiltInAccessPoints
|
||||
appId={appId}
|
||||
canDeploy={canDeploy}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
canReleaseAndVersion={canReleaseAndVersion}
|
||||
highlightedAccessPoint={selectedHighlightedAccessPoint}
|
||||
/>
|
||||
) : (
|
||||
<DeployedEnvironmentAccessPoints
|
||||
appId={appId}
|
||||
environmentId={selectedEnvironment}
|
||||
canManageAccessPoint={canManageAccessPoint}
|
||||
canReleaseAndVersion={canReleaseAndVersion}
|
||||
highlightedAccessPoint={selectedHighlightedAccessPoint}
|
||||
/>
|
||||
)}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -142,7 +162,8 @@ export default function AccessPoint({ appId }: AccessPointProps) {
|
||||
workspacePermissionKeys,
|
||||
})
|
||||
const showEnvironmentTabs =
|
||||
appDetail?.mode === AppModeEnum.WORKFLOW && capabilities.canViewAccessPoint
|
||||
(appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.ADVANCED_CHAT) &&
|
||||
capabilities.canViewAccessPoint
|
||||
|
||||
return (
|
||||
<AccessPointStateBoundary appId={appId} environmentQueryEnabled={showEnvironmentTabs}>
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { AccessPointStatus } from './access-point-status'
|
||||
import type { AccessPointStatus } from '@/app/components/base/access-point/status'
|
||||
import type { AppModeEnum } from '@/types/app'
|
||||
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessPointCard } from '@/app/components/base/access-point/card'
|
||||
import { AccessPointUrl } from '@/app/components/base/access-point/url'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import Link from '@/next/link'
|
||||
import { AccessPointCard } from './access-point-card'
|
||||
import { AccessPointUrl } from './access-point-url'
|
||||
import { ApiSecretKeyButton } from './api-secret-key-button'
|
||||
import { useAccessPointStatusLabel } from './use-access-point-status-label'
|
||||
import { getAppApiReferencePath } from './utils'
|
||||
|
||||
type ServiceApiCardViewProps = {
|
||||
@ -40,6 +41,7 @@ export function ServiceApiCardView({
|
||||
const docLink = useDocLink()
|
||||
const apiReferencePath = appMode ? getAppApiReferencePath(appMode) : undefined
|
||||
const apiReferenceUrl = apiReferencePath ? docLink(apiReferencePath) : undefined
|
||||
const statusLabel = useAccessPointStatusLabel(status)
|
||||
|
||||
return (
|
||||
<AccessPointCard
|
||||
@ -49,6 +51,7 @@ export function ServiceApiCardView({
|
||||
})}
|
||||
icon="i-custom-vender-knowledge-api-aggregate"
|
||||
status={status}
|
||||
statusLabel={statusLabel}
|
||||
highlighted={highlighted}
|
||||
switchDisabled={switchDisabled}
|
||||
switchLabel={t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' })}
|
||||
|
||||
@ -5,11 +5,9 @@ import type { App } from '@/types/app'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import { fetchAppDetail, updateAppSiteConfig } from '@/service/apps'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
@ -27,41 +25,21 @@ export function useAccessPointActions(appId: string, canManageAccessPoint: boole
|
||||
}
|
||||
}, [appId, setAppDetail])
|
||||
|
||||
const handleAppStateChanged = useCallback(async () => {
|
||||
const refresh = refreshAppDetail()
|
||||
const socket = webSocketClient.getSocket(appId)
|
||||
if (socket) {
|
||||
const timestamp = Date.now()
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'app_state_update',
|
||||
data: { timestamp },
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
await refresh
|
||||
}, [appId, refreshAppDetail])
|
||||
|
||||
const handleResult = useCallback(
|
||||
(error: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
|
||||
const type = error ? 'error' : 'success'
|
||||
const resolvedMessage = message ?? (error ? 'modifiedUnsuccessfully' : 'modifiedSuccessfully')
|
||||
|
||||
if (!error) void handleAppStateChanged()
|
||||
if (!error) {
|
||||
void refreshAppDetail()
|
||||
}
|
||||
|
||||
toast(t(($) => $[`actionMsg.${resolvedMessage}`], { ns: 'common' }) as string, {
|
||||
type,
|
||||
})
|
||||
},
|
||||
[handleAppStateChanged, t],
|
||||
[refreshAppDetail, t],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId) return
|
||||
|
||||
return collaborationManager.onAppStateUpdate(refreshAppDetail)
|
||||
}, [appId, refreshAppDetail])
|
||||
|
||||
const saveSiteConfig = useCallback(
|
||||
async (params: ConfigParams) => {
|
||||
if (!canManageAccessPoint) return
|
||||
@ -87,7 +65,6 @@ export function useAccessPointActions(appId: string, canManageAccessPoint: boole
|
||||
)
|
||||
|
||||
return {
|
||||
handleAppStateChanged,
|
||||
handleResult,
|
||||
refreshAppDetail,
|
||||
saveSiteConfig,
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessPointStatus } from '@/app/components/base/access-point/status'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function useAccessPointStatusLabel(status: AccessPointStatus) {
|
||||
const { t } = useTranslation()
|
||||
const labels: Record<AccessPointStatus, string> = {
|
||||
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' }),
|
||||
}
|
||||
|
||||
return labels[status]
|
||||
}
|
||||
@ -6,16 +6,36 @@ type WebAppAccessControlEntryProps = {
|
||||
accessConfigured: boolean
|
||||
accessIcon: string
|
||||
accessLabel: string
|
||||
available: boolean
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function WebAppAccessControlEntrySkeleton({ loading }: { loading: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="-mt-1 px-4 pb-3">
|
||||
<div
|
||||
role={loading ? 'status' : undefined}
|
||||
aria-label={loading ? t(($) => $.loading, { ns: 'common' }) : undefined}
|
||||
aria-hidden={loading ? undefined : true}
|
||||
className="flex h-9 w-full items-center gap-2 rounded-lg border-[0.5px] border-divider-subtle bg-background-section px-2.5"
|
||||
>
|
||||
<span aria-hidden className="i-ri-global-line size-4 shrink-0 text-text-disabled" />
|
||||
<span aria-hidden className="h-2 w-[42%] rounded-full bg-text-quaternary opacity-10" />
|
||||
<span
|
||||
aria-hidden
|
||||
className="ml-auto i-ri-arrow-right-s-line size-4 shrink-0 text-text-disabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebAppAccessControlEntry({
|
||||
accessConfigured,
|
||||
accessIcon,
|
||||
accessLabel,
|
||||
available,
|
||||
disabled,
|
||||
onClick,
|
||||
}: WebAppAccessControlEntryProps) {
|
||||
@ -23,38 +43,27 @@ export function WebAppAccessControlEntry({
|
||||
|
||||
return (
|
||||
<div className="-mt-1 px-4 pb-3">
|
||||
{available ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg border-[0.5px] border-divider-subtle bg-background-section py-1 pr-2 pl-2.5 text-left outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:hover:bg-background-section"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
|
||||
<span aria-hidden className={`${accessIcon} size-4 shrink-0 text-text-secondary`} />
|
||||
<div className="grow truncate">
|
||||
<span className="system-sm-regular text-text-secondary">{accessLabel}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg border-[0.5px] border-divider-subtle bg-background-section py-1 pr-2 pl-2.5 text-left outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:hover:bg-background-section"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
|
||||
<span aria-hidden className={`${accessIcon} size-4 shrink-0 text-text-secondary`} />
|
||||
<div className="grow truncate">
|
||||
<span className="system-sm-regular text-text-secondary">{accessLabel}</span>
|
||||
</div>
|
||||
{!accessConfigured && (
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['publishApp.notSet'], { ns: 'app' })}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex size-4 shrink-0 items-center justify-center">
|
||||
<span aria-hidden className="i-ri-arrow-right-s-line size-4 text-text-quaternary" />
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex h-9 w-full items-center gap-2 rounded-lg border-[0.5px] border-divider-subtle bg-background-section px-2.5">
|
||||
<span aria-hidden className="i-ri-global-line size-4 shrink-0 text-text-disabled" />
|
||||
<span className="h-2 w-[42%] rounded-full bg-text-quaternary opacity-10" />
|
||||
<span
|
||||
aria-hidden
|
||||
className="ml-auto i-ri-arrow-right-s-line size-4 shrink-0 text-text-disabled"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!accessConfigured && (
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['publishApp.notSet'], { ns: 'app' })}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex size-4 shrink-0 items-center justify-center">
|
||||
<span aria-hidden className="i-ri-arrow-right-s-line size-4 text-text-quaternary" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 <AddMemberOrGroupDialog subjects={subjects} onChange={handleChange} />
|
||||
}
|
||||
|
||||
function DialogInsideAccessOption() {
|
||||
return (
|
||||
<RadioGroup
|
||||
aria-label="Access"
|
||||
value={AccessMode.SPECIFIC_GROUPS_MEMBERS}
|
||||
onValueChange={() => {}}
|
||||
>
|
||||
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
|
||||
<ControlledDialog />
|
||||
</AccessControlItem>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
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(<DialogInsideAccessOption />)
|
||||
|
||||
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,
|
||||
|
||||
@ -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 (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
@ -137,14 +133,16 @@ export default function AddMemberOrGroupDialog({
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
alignOffset={300}
|
||||
className="relative flex max-h-100 w-100 flex-col overflow-hidden bg-components-panel-bg-blur p-0 backdrop-blur-[5px]"
|
||||
className="relative w-100 overflow-hidden bg-components-panel-bg-blur p-0 backdrop-blur-[5px]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<PopoverTitle className="sr-only">{searchLabel}</PopoverTitle>
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
|
||||
<ScrollArea className="max-h-100 overflow-hidden">
|
||||
<ScrollAreaViewport
|
||||
ref={scrollRootRef}
|
||||
role="region"
|
||||
aria-label={searchLabel}
|
||||
className="max-h-100 overscroll-contain"
|
||||
style={{ overflowX: 'hidden' }}
|
||||
>
|
||||
<ScrollAreaContent style={{ minWidth: 0 }}>
|
||||
@ -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 ? (
|
||||
<>
|
||||
<span className="sr-only">{statusText}</span>
|
||||
<div className="w-full" aria-hidden="true">
|
||||
<Loading />
|
||||
</div>
|
||||
</>
|
||||
<div className="w-full" aria-hidden="true">
|
||||
<Loading />
|
||||
</div>
|
||||
) : (
|
||||
statusText
|
||||
)}
|
||||
|
||||
@ -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<EnvironmentDeployment['deployment']>['status']
|
||||
runtimeState?: NonNullable<EnvironmentDeployment['deployment']>['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(
|
||||
<PublisherEnvironmentFlow
|
||||
appId="app-1"
|
||||
canViewAccessPoint
|
||||
environmentId="development"
|
||||
environmentName="Development"
|
||||
environmentTabs={<div>Environment tabs</div>}
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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
|
||||
}) => (
|
||||
<div>
|
||||
Environment publisher
|
||||
<button type="button" onClick={() => onConfigurationOpenChange?.(true)}>
|
||||
Configure deployment
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
function PublisherPanelHarness() {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button">Outside control</button>
|
||||
<PublisherPanel
|
||||
builtInPublisher={{
|
||||
actions: {
|
||||
appDetail: null,
|
||||
appURL: '',
|
||||
canViewAccessPoint: false,
|
||||
disabledFunctionButton: false,
|
||||
workflowToolIsLoading: false,
|
||||
onConfigureWorkflowTool: vi.fn(),
|
||||
},
|
||||
summary: {
|
||||
formatTimeFromNow: () => '',
|
||||
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(<PublisherPanelHarness />)
|
||||
|
||||
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(<PublisherPanelHarness />)
|
||||
|
||||
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(<PublisherPanelHarness />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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 (
|
||||
<div aria-busy={isDeploymentLoading} className="flex min-h-40 flex-col gap-3 p-4">
|
||||
{environmentTabs}
|
||||
<div
|
||||
role={isDeploymentError ? 'alert' : 'status'}
|
||||
className="flex flex-1 items-center justify-center gap-2 system-sm-regular text-text-tertiary"
|
||||
>
|
||||
{isDeploymentLoading ? (
|
||||
<>
|
||||
<span aria-hidden className="i-ri-loader-2-line size-4 animate-spin" />
|
||||
{t(($) => $.loading, { ns: 'common' })}
|
||||
</>
|
||||
) : (
|
||||
t(($) => $['common.loadFailed'], { ns: 'deployments' })
|
||||
)}
|
||||
</div>
|
||||
{isDeploymentLoading ? (
|
||||
<Loading className="flex-1" />
|
||||
) : (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-1 items-center justify-center system-sm-regular text-text-tertiary"
|
||||
>
|
||||
{t(($) => $['common.loadFailed'], { ns: 'deployments' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -72,6 +72,7 @@ export function PublisherEnvironmentFlow({
|
||||
disabled={deploymentPolling?.environmentId === environmentId}
|
||||
environmentId={environmentId}
|
||||
environmentName={environmentName}
|
||||
onConfigurationOpenChange={onConfigurationOpenChange}
|
||||
onDeploymentStarted={(operationId) => {
|
||||
startDeploymentPolling({ environmentId, operationId })
|
||||
}}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<AppPublisherStateBoundary
|
||||
|
||||
@ -2,7 +2,7 @@ import type { AppPublisherProps } from '../types'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toDeploymentVersion } from '@/app/components/app/deploy/version'
|
||||
import { toDeploymentVersion } from '@/app/components/app/deploy/utils/version'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
@ -115,6 +115,7 @@ export function PublisherContent({
|
||||
const marketplace = useMarketplacePublish(appDetail?.id)
|
||||
const versionInfo = useVersionInfo({
|
||||
appId: appDetail?.id,
|
||||
appMode: appDetail?.mode,
|
||||
publishedWorkflow: publish.publishedWorkflow,
|
||||
onClosePublisher: closePublisher,
|
||||
})
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import type { PopoverProps } from '@langgenius/dify-ui/popover'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { AppPublisherProps } from '../../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WorkflowLaunchDialog } from '@/app/components/app/overview/workflow-launch-dialog'
|
||||
import { BuiltInPublisher } from '../../built-in-publisher'
|
||||
@ -9,7 +11,10 @@ import { PublisherEnvironmentFlow } from '../../environment-deployment-flow'
|
||||
|
||||
type PublisherPanelProps = Pick<AppPublisherProps, 'crossAxisOffset' | 'disabled'> & {
|
||||
builtInPublisher: ComponentProps<typeof BuiltInPublisher>
|
||||
environmentPublisher: ComponentProps<typeof PublisherEnvironmentFlow>
|
||||
environmentPublisher: Omit<
|
||||
ComponentProps<typeof PublisherEnvironmentFlow>,
|
||||
'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<PopoverProps['onOpenChange']> = (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 (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="primary" className="py-2 pr-2 pl-3" disabled={disabled}>
|
||||
@ -50,7 +67,11 @@ export function PublisherPanel({
|
||||
{showBuiltInPublisher ? (
|
||||
<BuiltInPublisher {...builtInPublisher} />
|
||||
) : (
|
||||
<PublisherEnvironmentFlow key={environmentPublisherKey} {...environmentPublisher} />
|
||||
<PublisherEnvironmentFlow
|
||||
key={environmentPublisherKey}
|
||||
{...environmentPublisher}
|
||||
onConfigurationOpenChange={setDeploymentConfigurationOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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<EnvironmentDeployment['deployment']>['status']
|
||||
runtimeState: NonNullable<EnvironmentDeployment['deployment']>['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<typeof EnvironmentTable>> = {},
|
||||
): ComponentProps<typeof EnvironmentTable> {
|
||||
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(<RuntimeStateIndicator runtimeState={runtimeState} />)
|
||||
|
||||
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(<AppDeploy />, { 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(<AppDeploy />)
|
||||
|
||||
@ -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(<AppDeploy />, { 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(<AppDeploy />)
|
||||
@ -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(<AppDeploy />)
|
||||
|
||||
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(<AppDeploy />)
|
||||
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(<AppDeploy />)
|
||||
|
||||
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(<AppDeploy />)
|
||||
@ -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(
|
||||
<AppDeployStateBoundary appId={APP_ID}>
|
||||
<EnvironmentTable appId={APP_ID} canViewAccessPoint />
|
||||
<EnvironmentTable {...environmentTableProps()} />
|
||||
</AppDeployStateBoundary>,
|
||||
{
|
||||
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(
|
||||
<AppDeployStateBoundary appId={APP_ID}>
|
||||
<EnvironmentTable appId={APP_ID} canViewAccessPoint />
|
||||
<EnvironmentTable {...environmentTableProps()} />
|
||||
</AppDeployStateBoundary>,
|
||||
{ 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(
|
||||
<AppDeployStateBoundary appId={APP_ID}>
|
||||
<EnvironmentTable appId={APP_ID} canViewAccessPoint onUndeploy={onUndeploy} />
|
||||
<EnvironmentTable {...environmentTableProps({ onUndeploy })} />
|
||||
</AppDeployStateBoundary>,
|
||||
)
|
||||
|
||||
@ -1802,7 +2274,7 @@ describe('AppDeploy', () => {
|
||||
const onUndeploy = vi.fn()
|
||||
render(
|
||||
<AppDeployStateBoundary appId={APP_ID}>
|
||||
<EnvironmentTable appId={APP_ID} canViewAccessPoint onUndeploy={onUndeploy} />
|
||||
<EnvironmentTable {...environmentTableProps({ onUndeploy })} />
|
||||
</AppDeployStateBoundary>,
|
||||
)
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
@ -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 <div className="i-custom-vender-deploy-line-5 h-10 w-3" />
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
aria-labelledby="built-in-environment-title"
|
||||
className="shrink-0 rounded-[14px] bg-background-section p-1"
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 rounded-[10px] border-[0.5px] border-divider-subtle bg-components-panel-bg p-4">
|
||||
{/* Icon */}
|
||||
<div className="flex size-9 items-center justify-center rounded-lg border-[0.5px] border-divider-regular">
|
||||
<span aria-hidden className="i-ri-instance-line size-5 text-text-secondary" />
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div className="flex items-center gap-10">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 id="built-in-environment-title" className="system-md-semibold text-text-primary">
|
||||
{t(($) => $['studio.builtInTitle'])}
|
||||
</h2>
|
||||
<p className="truncate system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['studio.builtInDescription'])}
|
||||
</p>
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="built-in-environment-title"
|
||||
className="shrink-0 rounded-[14px] bg-background-section p-1"
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 rounded-[10px] border-[0.5px] border-divider-subtle bg-components-panel-bg p-4">
|
||||
{/* Icon */}
|
||||
<div className="flex size-9 items-center justify-center rounded-lg border-[0.5px] border-divider-regular">
|
||||
<span aria-hidden className="i-ri-instance-line size-5 text-text-secondary" />
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $['studio.liveVersion'])}
|
||||
{/* Info */}
|
||||
<div className="flex items-center gap-10">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 id="built-in-environment-title" className="system-md-semibold text-text-primary">
|
||||
{t(($) => $['studio.builtInTitle'])}
|
||||
</h2>
|
||||
<p className="truncate system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['studio.builtInDescription'])}
|
||||
</p>
|
||||
</div>
|
||||
<VersionLabel version={publishedVersion} isLatest />
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $['studio.accessPoints'])}
|
||||
<Divider />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $['studio.liveVersion'])}
|
||||
</div>
|
||||
<VersionLabel version={publishedVersion} isLatest />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{ACCESS_POINT_ORDER.map((accessPoint) => (
|
||||
<AccessPointIcon
|
||||
key={accessPoint}
|
||||
accessPoint={accessPoint}
|
||||
active={activeAccessPoints[accessPoint]}
|
||||
href={
|
||||
canViewAccessPoint
|
||||
? getAccessPointHref(appId, 'built-in', accessPoint)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Divider />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $['studio.accessPoints'])}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{ACCESS_POINT_ORDER.map((accessPoint) => (
|
||||
<AccessPointIcon
|
||||
key={accessPoint}
|
||||
accessPoint={accessPoint}
|
||||
active={activeAccessPoints[accessPoint]}
|
||||
href={
|
||||
canViewAccessPoint
|
||||
? getAccessPointHref(appId, 'built-in', accessPoint)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Status and updated time */}
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-2.5">
|
||||
<DeploymentStatus status={DeploymentStatusEnum.DEPLOYMENT_STATUS_RUNNING} />
|
||||
<p className="truncate system-xs-regular text-text-tertiary">
|
||||
{publishedWorkflow
|
||||
? t(($) => $['studio.updatedAtBy'], {
|
||||
name: updatedBy,
|
||||
time: formatTime(publishedWorkflow.updated_at, 'MM-DD HH:mm'),
|
||||
})
|
||||
: '--'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
{/* Status and updated time */}
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-2.5">
|
||||
<RuntimeStateIndicator runtimeState={RuntimeState.RUNTIME_STATE_RUNNING} />
|
||||
<p className="truncate system-xs-regular text-text-tertiary">
|
||||
{publishedWorkflow
|
||||
? t(($) => $['studio.updatedAtBy'], {
|
||||
name: updatedBy,
|
||||
time: formatTime(publishedWorkflow.updated_at, 'MM-DD HH:mm'),
|
||||
})
|
||||
: '--'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
BuiltInEnvironmentCard.displayName = 'BuiltInEnvironmentCard'
|
||||
|
||||
@ -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 (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<h3 className="system-md-semibold text-text-primary">{title}</h3>
|
||||
<p className="system-xs-regular text-text-tertiary">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div role="alert" className="flex gap-2 text-text-destructive">
|
||||
<span aria-hidden className="mt-0.5 i-ri-error-warning-fill size-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="system-sm-semibold">{t(($) => $.error)}</p>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-4 system-xs-regular">
|
||||
{messages.map((message) => (
|
||||
<li key={message} className="wrap-break-word">
|
||||
{message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigurationLoading({ label }: { label: string }) {
|
||||
return (
|
||||
<div role="status" className="flex items-center justify-center gap-2 py-8 text-text-tertiary">
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-loader-2-line size-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
<span className="system-xs-regular">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentConfigurationContent({
|
||||
compact = false,
|
||||
onValuesChange,
|
||||
queryState,
|
||||
request,
|
||||
values,
|
||||
version,
|
||||
}: {
|
||||
compact?: boolean
|
||||
onValuesChange: Dispatch<SetStateAction<DeploymentConfigurationValues>>
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 pt-2',
|
||||
showPrecheckAlert ? 'pb-0' : 'border-b border-divider-regular pb-4',
|
||||
horizontalPaddingClassName,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl bg-background-section p-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span aria-hidden className="i-ri-stack-line size-3.5 shrink-0 text-text-tertiary" />
|
||||
<span className="truncate system-sm-medium text-text-secondary">{version.name}</span>
|
||||
</div>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-arrow-right-line size-3.5 shrink-0 text-text-tertiary"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
|
||||
<span aria-hidden className="i-ri-instance-line size-3.5 shrink-0 text-text-tertiary" />
|
||||
<span className="truncate system-sm-medium text-text-secondary">
|
||||
{request.environment}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-busy={isPrechecking || isLoadingDeploymentOptions}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
{isPrechecking && (
|
||||
<ConfigurationLoading label={t(($) => $['versions.checkingReleaseContent'])} />
|
||||
)}
|
||||
{!isPrechecking && precheckError && (
|
||||
<div className={cn('py-4', horizontalPaddingClassName)}>
|
||||
<ConfigurationError
|
||||
messages={[
|
||||
errorMessage(
|
||||
precheckError,
|
||||
tCommon(($) => $.error),
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showPrecheckAlert && (
|
||||
<div
|
||||
className={cn('border-b border-divider-regular pt-2 pb-4', horizontalPaddingClassName)}
|
||||
>
|
||||
<DeploymentPrecheckAlert nodes={unsupportedNodes} />
|
||||
</div>
|
||||
)}
|
||||
{isLoadingDeploymentOptions && <ConfigurationLoading label={tCommon(($) => $.loading)} />}
|
||||
{!isLoadingDeploymentOptions && deploymentOptionsError && (
|
||||
<div className={cn('py-4', horizontalPaddingClassName)}>
|
||||
<ConfigurationError
|
||||
messages={[
|
||||
errorMessage(
|
||||
deploymentOptionsError,
|
||||
t(($) => $['deployDrawer.bindingOptionsFailed']),
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showConfiguration && (
|
||||
<>
|
||||
{hasCredentialSlots && (
|
||||
<section className={cn('flex flex-col gap-4 py-4', horizontalPaddingClassName)}>
|
||||
<SectionHeading
|
||||
title={t(($) => $['deployDrawer.runtimeCredentials'])}
|
||||
description={t(($) => $['deployDrawer.bindingSelectionHint'])}
|
||||
/>
|
||||
{credentialSlots.map((slot) => {
|
||||
const slotKey = credentialSlotKey(slot)
|
||||
|
||||
return (
|
||||
<CredentialField
|
||||
key={slotKey}
|
||||
slot={slot}
|
||||
value={values.credentials[slotKey] ?? defaultCredentialId(slot)}
|
||||
onChange={(value) =>
|
||||
onValuesChange((current) => ({
|
||||
...current,
|
||||
credentials: {
|
||||
...current.credentials,
|
||||
[slotKey]: value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{environmentVariableSlots.length > 0 ? (
|
||||
<section
|
||||
className={cn(
|
||||
'flex flex-col gap-4 py-4',
|
||||
hasCredentialSlots && 'border-t border-divider-regular',
|
||||
horizontalPaddingClassName,
|
||||
)}
|
||||
>
|
||||
<SectionHeading
|
||||
title={t(($) => $['deployDrawer.envVars'])}
|
||||
description={t(($) => $['studio.environmentVariablesDescription'])}
|
||||
/>
|
||||
{environmentVariableSlots.map((slot) => {
|
||||
const selection =
|
||||
values.environmentVariables[slot.key] ??
|
||||
defaultEnvironmentVariableSelection(slot)
|
||||
|
||||
return (
|
||||
<EnvironmentVariableField
|
||||
key={slot.key}
|
||||
slot={slot}
|
||||
source={selection.source}
|
||||
customValue={selection.customValue}
|
||||
onSourceChange={(source) =>
|
||||
onValuesChange((current) => ({
|
||||
...current,
|
||||
environmentVariables: {
|
||||
...current.environmentVariables,
|
||||
[slot.key]: {
|
||||
...selection,
|
||||
source,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
onCustomValueChange={(customValue) =>
|
||||
onValuesChange((current) => ({
|
||||
...current,
|
||||
environmentVariables: {
|
||||
...current.environmentVariables,
|
||||
[slot.key]: {
|
||||
...selection,
|
||||
customValue,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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<string>(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 (
|
||||
<span className="flex size-4 shrink-0 items-center justify-center rounded-[5px] bg-components-icon-bg-midnight-solid text-white shadow-xs">
|
||||
<span aria-hidden className="i-ri-node-tree size-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <BlockIcon type={node.type} size="xs" toolIcon={icon} />
|
||||
}
|
||||
|
||||
export function DeploymentPrecheckAlert({ nodes }: { nodes: UnsupportedNode[] }) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const getProviderIcon = useGetProviderIcon(nodes)
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="relative flex flex-col gap-2 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-xs backdrop-blur-[5px]"
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-r from-components-badge-status-light-warning-halo to-background-gradient-mask-transparent opacity-40" />
|
||||
<span
|
||||
aria-hidden
|
||||
className="relative i-ri-alert-fill size-4 shrink-0 text-text-warning-secondary"
|
||||
/>
|
||||
<div className="relative flex min-w-0 flex-col gap-1">
|
||||
<p className="system-sm-medium text-text-primary">{t(($) => $['studio.precheck.title'])}</p>
|
||||
<p className="system-xs-regular text-text-secondary">
|
||||
{t(($) => $['studio.precheck.description'])}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-2 py-1">
|
||||
{nodes.map((node) => (
|
||||
<li key={node.id} className="flex min-w-0 items-center gap-2">
|
||||
<UnsupportedNodeIcon node={node} icon={getProviderIcon(node)} />
|
||||
<span className="min-w-0 truncate system-xs-medium text-text-secondary">
|
||||
{node.title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="system-xs-regular text-text-secondary">
|
||||
{t(($) => $['studio.precheck.supportMessage'])}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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<Record<EnvVarValueSource, string>> = {
|
||||
[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<Record<EnvVarValueSource, string>> = {
|
||||
[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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 grow items-center gap-1">
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-line-others-env size-4 shrink-0 text-util-colors-violet-violet-600"
|
||||
/>
|
||||
<label htmlFor={inputId} className="truncate system-sm-medium text-text-primary">
|
||||
{slot.key}
|
||||
</label>
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">{valueTypeLabel}</span>
|
||||
{slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET && (
|
||||
<span aria-hidden className="i-ri-lock-2-line size-3 shrink-0 text-text-tertiary" />
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={source}
|
||||
onValueChange={(nextSource) => {
|
||||
if (nextSource) onSourceChange(nextSource)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t(($) => $['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}
|
||||
</SelectTrigger>
|
||||
<SelectContent placement="bottom-end" className="w-52">
|
||||
{availableSources.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<SelectItemText>{sourceLabels[option]}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
value={editable ? customValue : ''}
|
||||
placeholder={placeholder}
|
||||
disabled={!editable}
|
||||
autoComplete="off"
|
||||
onChange={(event) => onCustomValueChange(event.target.value)}
|
||||
/>
|
||||
{slot.description && (
|
||||
<p className="system-xs-regular text-text-tertiary">{slot.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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<string, string>
|
||||
environmentVariables: Record<string, EnvironmentVariableSelection>
|
||||
}
|
||||
|
||||
export function useDeploymentConfigurationValues() {
|
||||
const [values, setValues] = useState<DeploymentConfigurationValues>({
|
||||
credentials: {},
|
||||
environmentVariables: {},
|
||||
})
|
||||
|
||||
return [values, setValues] as const
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user