From 5c58f78cc86fd7f74aceb463dff71a5ced7c0eb9 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 25 May 2026 15:11:15 +0800 Subject: [PATCH] update --- .../contracts/openapi-ts.enterprise.config.ts | 70 ++++++++++- .../__tests__/deploy-release-menu.spec.tsx | 114 ++++++++++++++++++ .../__tests__/release-dsl-export.spec.ts | 74 ++++++++++++ .../versions-tab/deploy-release-menu.tsx | 44 ++++++- .../detail/versions-tab/release-dsl-export.ts | 43 +++++++ web/i18n/en-US/deployments.json | 3 + web/i18n/zh-Hans/deployments.json | 3 + 7 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx create mode 100644 web/features/deployments/detail/versions-tab/__tests__/release-dsl-export.spec.ts create mode 100644 web/features/deployments/detail/versions-tab/release-dsl-export.ts diff --git a/packages/contracts/openapi-ts.enterprise.config.ts b/packages/contracts/openapi-ts.enterprise.config.ts index 187206f88d..2be78fd500 100644 --- a/packages/contracts/openapi-ts.enterprise.config.ts +++ b/packages/contracts/openapi-ts.enterprise.config.ts @@ -10,6 +10,21 @@ type OpenApiDocument = JsonObject & { paths?: Record } +type OpenApiMediaType = JsonObject & { + schema?: unknown +} + +type OpenApiOperation = JsonObject & { + operationId?: string + responses?: Record +} + +type OpenApiPathItem = Record + +type OpenApiResponse = JsonObject & { + content?: Record +} + type ContractOperation = { id: string operationId?: string @@ -21,9 +36,26 @@ const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER ? path.resolve(process.env.DIFY_ENTERPRISE_SERVER) : path.resolve(currentDir, '../../../dify-enterprise/server') const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/enterprise/openapi.yaml') +const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put']) const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/') +const isObject = (value: unknown): value is JsonObject => { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +const asOpenApiOperation = (value: unknown): OpenApiOperation | undefined => { + return isObject(value) ? value as OpenApiOperation : undefined +} + +const asOpenApiResponse = (value: unknown): OpenApiResponse | undefined => { + return isObject(value) ? value as OpenApiResponse : undefined +} + +const asOpenApiMediaType = (value: unknown): OpenApiMediaType | undefined => { + return isObject(value) ? value as OpenApiMediaType : undefined +} + const stripConsoleApiPrefix = (routePath: string) => { if (isConsoleApiPath(routePath)) return routePath.replace('/console/api', '') @@ -59,6 +91,36 @@ const contractPathSegments = (operation: ContractOperation) => { return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)] } +const hasSchemaLessResponseContent = (operation: OpenApiOperation) => { + if (!isObject(operation.responses)) + return false + + return Object.values(operation.responses).some((response) => { + const openApiResponse = asOpenApiResponse(response) + if (!openApiResponse || !isObject(openApiResponse.content)) + return false + + return Object.values(openApiResponse.content).some((mediaType) => { + const openApiMediaType = asOpenApiMediaType(mediaType) + return !!openApiMediaType && !('schema' in openApiMediaType) + }) + }) +} + +// protoc-gen-openapi emits google.api.HttpBody responses as `*/*: {}`. Skip these +// raw download operations until the source OpenAPI exposes an explicit schema. +const stripSchemaLessResponseOperations = (pathItem: OpenApiPathItem) => { + return Object.fromEntries( + Object.entries(pathItem).filter(([method, operation]) => { + if (!operationMethods.has(method.toLowerCase())) + return true + + const openApiOperation = asOpenApiOperation(operation) + return !openApiOperation || !hasSchemaLessResponseContent(openApiOperation) + }), + ) +} + const normalizeEnterpriseOpenApi = () => { const openApi = yaml.load(fs.readFileSync(enterpriseOpenApiPath, 'utf8')) @@ -71,7 +133,13 @@ const normalizeEnterpriseOpenApi = () => { document.paths = Object.fromEntries( Object.entries(paths) .filter(([routePath]) => isConsoleApiPath(routePath)) - .map(([routePath, pathItem]) => [stripConsoleApiPrefix(routePath), pathItem]), + .map(([routePath, pathItem]) => { + if (!isObject(pathItem)) + return [stripConsoleApiPrefix(routePath), pathItem] + + return [stripConsoleApiPrefix(routePath), stripSchemaLessResponseOperations(pathItem)] + }) + .filter(([, pathItem]) => !isObject(pathItem) || Object.keys(pathItem).length > 0), ) return document diff --git a/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx b/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx new file mode 100644 index 0000000000..6b3888d4d2 --- /dev/null +++ b/web/features/deployments/detail/versions-tab/__tests__/deploy-release-menu.spec.tsx @@ -0,0 +1,114 @@ +import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { RUNTIME_INSTANCE_STATUS_READY } from '../../../runtime-status' +import { DeployReleaseMenu } from '../deploy-release-menu' + +const mockUseQuery = vi.fn() +const mockGet = vi.hoisted(() => vi.fn()) +const mockDownloadBlob = vi.hoisted(() => vi.fn()) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: (options: { queryKey?: string[] }) => mockUseQuery(options), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + enterprise: { + appInstanceService: { + getAppInstance: { + queryOptions: () => ({ queryKey: ['app-instance'] }), + }, + }, + deploymentService: { + listEnvironmentDeployments: { + queryOptions: () => ({ queryKey: ['runtime-instances'] }), + }, + }, + }, + }, +})) + +vi.mock('@/service/base', () => ({ + get: (...args: unknown[]) => mockGet(...args), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args), +})) + +function release(overrides: Partial = {}): Release { + return { + id: 'release-1', + name: 'R-001', + createdAt: '2026-05-05T10:00:00Z', + createdBy: { name: 'App-runner-demo' }, + ...overrides, + } +} + +function runtimeInstance(overrides: Partial = {}): EnvironmentDeployment { + return { + currentDeployment: { id: 'deployment-1' }, + environment: { id: 'env-1', name: 'test-1' }, + status: RUNTIME_INSTANCE_STATUS_READY, + currentRelease: { id: 'release-1' }, + ...overrides, + } +} + +describe('DeployReleaseMenu', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseQuery.mockImplementation((options: { queryKey?: string[] }) => { + if (options.queryKey?.[0] === 'app-instance') { + return { + data: { appInstance: { id: 'instance-1', name: 'testchat' } }, + isLoading: false, + isError: false, + } + } + + return { + data: { data: [runtimeInstance()] }, + isLoading: false, + isError: false, + } + }) + mockGet.mockResolvedValue({ + blob: () => Promise.resolve(new Blob(['dsl'], { type: 'application/x-yaml' })), + }) + }) + + // The release action menu should expose the raw DSL export alongside deployment actions. + describe('Export DSL', () => { + it('should export the selected release DSL from the more actions menu', async () => { + // Arrange + const user = userEvent.setup() + render( + , + ) + + // Act + await user.click(screen.getByRole('button', { name: 'deployments.versions.moreActions' })) + await user.click(await screen.findByText('deployments.versions.exportDsl')) + + // Assert + await waitFor(() => { + expect(mockGet).toHaveBeenCalledWith( + 'enterprise/app-deploy/releases/release-1/dsl', + {}, + { needAllResponseContent: true, silent: true }, + ) + }) + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({ + fileName: 'testchat-R-001.yaml', + })) + }) + }) +}) diff --git a/web/features/deployments/detail/versions-tab/__tests__/release-dsl-export.spec.ts b/web/features/deployments/detail/versions-tab/__tests__/release-dsl-export.spec.ts new file mode 100644 index 0000000000..31a8d7d429 --- /dev/null +++ b/web/features/deployments/detail/versions-tab/__tests__/release-dsl-export.spec.ts @@ -0,0 +1,74 @@ +import type { Release } from '@dify/contracts/enterprise/types.gen' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { get } from '@/service/base' +import { downloadBlob } from '@/utils/download' +import { exportReleaseDsl, releaseDslFileName } from '../release-dsl-export' + +vi.mock('@/service/base', () => ({ + get: vi.fn(), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: vi.fn(), +})) + +const mockGet = vi.mocked(get) +const mockDownloadBlob = vi.mocked(downloadBlob) + +function release(overrides: Partial = {}): Release & { id: string } { + return { + id: 'release-1', + name: 'Release 1', + ...overrides, + } +} + +describe('release DSL export', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // The raw DSL export endpoint is skipped from generated contracts, so this helper owns the download request shape. + describe('Download request', () => { + it('should download the release DSL from the raw export endpoint', async () => { + // Arrange + const data = new Blob(['app:\n name: test\n'], { type: 'application/x-yaml' }) + mockGet.mockResolvedValue({ + blob: () => Promise.resolve(data), + } as Response) + + // Act + await exportReleaseDsl({ + release: release({ id: 'release/id', name: 'Release: 1' }), + appInstanceName: 'Project/Test', + }) + + // Assert + expect(mockGet).toHaveBeenCalledWith( + 'enterprise/app-deploy/releases/release%2Fid/dsl', + {}, + { needAllResponseContent: true, silent: true }, + ) + expect(mockDownloadBlob).toHaveBeenCalledWith({ + data, + fileName: 'Project-Test-Release-1.yaml', + }) + }) + }) + + // Exported filenames should combine the project and release labels while staying browser-safe. + describe('File names', () => { + it('should remove duplicated YAML extensions from the release label', () => { + expect(releaseDslFileName({ + release: release({ name: 'prod.yml' }), + appInstanceName: 'Project', + })).toBe('Project-prod.yaml') + }) + + it('should fall back to release id when the release name is empty', () => { + expect(releaseDslFileName({ + release: release({ name: '' }), + })).toBe('release-1.yaml') + }) + }) +}) diff --git a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx index 7b8cfc2c3b..38e142a8b3 100644 --- a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx +++ b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx @@ -12,6 +12,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { toast } from '@langgenius/dify-ui/toast' import { useQuery } from '@tanstack/react-query' import { useSetAtom } from 'jotai' import { useState } from 'react' @@ -22,6 +23,7 @@ import { releaseDeploymentAction } from '../../release-action' import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status' import { openDeployDrawerAtom } from '../../store' import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../table-styles' +import { exportReleaseDsl } from './release-dsl-export' type EnvironmentOption = Environment & { id: string @@ -58,22 +60,47 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows }: { const { t } = useTranslation('deployments') const openDeployDrawer = useSetAtom(openDeployDrawerAtom) const [open, setOpen] = useState(false) + const [isExportingDsl, setIsExportingDsl] = useState(false) const { data: environmentDeployments } = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({ input: { params: { appInstanceId }, }, enabled: open, })) + const { data: appInstanceData } = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({ + input: { + params: { appInstanceId }, + }, + enabled: open, + })) const environments: EnvironmentOption[] = (environmentDeployments?.data ?? []) .map(row => row.environment) .filter((env): env is EnvironmentOption => Boolean(env?.id)) const deploymentRows = environmentDeployments?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? [] - const targetRelease = releaseRows.find(release => release.id === releaseId) + const targetRelease = releaseRows.find((release): release is Release & { id: string } => release.id === releaseId) + const appInstanceName = appInstanceData?.appInstance?.name if (!targetRelease) return null + const handleExportDsl = async () => { + if (isExportingDsl) + return + + setIsExportingDsl(true) + try { + await exportReleaseDsl({ release: targetRelease, appInstanceName }) + setOpen(false) + } + catch { + toast.error(t('versions.exportDslFailed')) + } + finally { + setIsExportingDsl(false) + } + } + const menuRows: DeployMenuRow[] = environments.map((env) => { const envId = env.id const envName = environmentName(env) @@ -142,6 +169,21 @@ export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows }: { {open && ( + + + + {isExportingDsl ? t('versions.exportingDsl') : t('versions.exportDsl')} + + + {groupedRows.length > 0 &&
} {groupedRows.map((section, sectionIndex) => (
{sectionIndex > 0 &&
} diff --git a/web/features/deployments/detail/versions-tab/release-dsl-export.ts b/web/features/deployments/detail/versions-tab/release-dsl-export.ts new file mode 100644 index 0000000000..f5c17a1fb4 --- /dev/null +++ b/web/features/deployments/detail/versions-tab/release-dsl-export.ts @@ -0,0 +1,43 @@ +import type { Release } from '@dify/contracts/enterprise/types.gen' +import { get } from '@/service/base' +import { downloadBlob } from '@/utils/download' + +const YAML_EXTENSION_PATTERN = /\.ya?ml$/i +const INVALID_FILENAME_CHARS_PATTERN = /[\\/:*?"<>|]+/g +const FILENAME_SEPARATOR_PATTERN = /[\s-]+/g + +function sanitizeFileNamePart(value?: string) { + return value + ?.trim() + .replace(YAML_EXTENSION_PATTERN, '') + .replace(INVALID_FILENAME_CHARS_PATTERN, '-') + .replace(FILENAME_SEPARATOR_PATTERN, '-') + .replace(/^-+|-+$/g, '') ?? '' +} + +export function releaseDslFileName({ release, appInstanceName }: { + release: Release + appInstanceName?: string +}) { + const projectName = sanitizeFileNamePart(appInstanceName) + const releaseName = sanitizeFileNamePart(release.name || release.id) || 'release' + const baseName = [projectName, releaseName].filter(Boolean).join('-') + + return `${baseName}.yaml` +} + +export async function exportReleaseDsl({ release, appInstanceName }: { + release: Release & { id: string } + appInstanceName?: string +}) { + const response = await get( + `enterprise/app-deploy/releases/${encodeURIComponent(release.id)}/dsl`, + {}, + { needAllResponseContent: true, silent: true }, + ) + const data = await response.blob() + downloadBlob({ + data, + fileName: releaseDslFileName({ release, appInstanceName }), + }) +} diff --git a/web/i18n/en-US/deployments.json b/web/i18n/en-US/deployments.json index 66ffefd838..e601a546cf 100644 --- a/web/i18n/en-US/deployments.json +++ b/web/i18n/en-US/deployments.json @@ -478,6 +478,9 @@ "versions.dslReading": "Reading DSL file...", "versions.empty": "No releases available yet.", "versions.emptyWithCreate": "No releases yet. Create the first release before deploying.", + "versions.exportDsl": "Export DSL", + "versions.exportDslFailed": "Failed to export DSL.", + "versions.exportingDsl": "Exporting...", "versions.groupHeader.deploy": "Deploy", "versions.groupHeader.promote": "Promote", "versions.groupHeader.rollback": "Rollback", diff --git a/web/i18n/zh-Hans/deployments.json b/web/i18n/zh-Hans/deployments.json index f71139c85a..58db91e115 100644 --- a/web/i18n/zh-Hans/deployments.json +++ b/web/i18n/zh-Hans/deployments.json @@ -478,6 +478,9 @@ "versions.dslReading": "正在读取 DSL 文件...", "versions.empty": "暂无可用发布版本。", "versions.emptyWithCreate": "暂无发布版本,请先创建第一个可部署发布版本。", + "versions.exportDsl": "导出 DSL", + "versions.exportDslFailed": "导出 DSL 失败。", + "versions.exportingDsl": "正在导出...", "versions.groupHeader.deploy": "部署", "versions.groupHeader.promote": "推送", "versions.groupHeader.rollback": "回滚",