mirror of
https://github.com/langgenius/dify.git
synced 2026-05-26 03:47:42 +08:00
update
This commit is contained in:
@ -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
|
||||
|
||||
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 />}
|
||||
|
||||
@ -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 }),
|
||||
})
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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": "回滚",
|
||||
|
||||
Reference in New Issue
Block a user