This commit is contained in:
Stephen Zhou
2026-05-25 15:11:15 +08:00
parent 572527ae25
commit 5c58f78cc8
7 changed files with 349 additions and 2 deletions

View File

@ -10,6 +10,21 @@ type OpenApiDocument = JsonObject & {
paths?: Record<string, unknown>
}
type OpenApiMediaType = JsonObject & {
schema?: unknown
}
type OpenApiOperation = JsonObject & {
operationId?: string
responses?: Record<string, OpenApiResponse>
}
type OpenApiPathItem = Record<string, unknown>
type OpenApiResponse = JsonObject & {
content?: Record<string, OpenApiMediaType>
}
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

View File

@ -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> = {}): 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> = {}): 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(
<DeployReleaseMenu
appInstanceId="instance-1"
releaseId="release-1"
releaseRows={[release()]}
/>,
)
// 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',
}))
})
})
})

View File

@ -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> = {}): 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')
})
})
})

View File

@ -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 }: {
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-60">
<DropdownMenuItem
disabled={isExportingDsl}
aria-disabled={isExportingDsl}
className={cn(
'gap-2 px-3',
isExportingDsl && 'cursor-not-allowed opacity-60',
)}
onClick={handleExportDsl}
>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{isExportingDsl ? t('versions.exportingDsl') : t('versions.exportDsl')}
</span>
</DropdownMenuItem>
{groupedRows.length > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
{groupedRows.map((section, sectionIndex) => (
<div key={section.group}>
{sectionIndex > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}

View File

@ -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<Response>(
`enterprise/app-deploy/releases/${encodeURIComponent(release.id)}/dsl`,
{},
{ needAllResponseContent: true, silent: true },
)
const data = await response.blob()
downloadBlob({
data,
fileName: releaseDslFileName({ release, appInstanceName }),
})
}

View File

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

View File

@ -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": "回滚",