From 89a05c0665d6805779cbea1123c1d723b1437c82 Mon Sep 17 00:00:00 2001 From: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Date: Mon, 11 May 2026 20:18:02 +0800 Subject: [PATCH] promote and rollback --- .../deployments/__tests__/utils.spec.ts | 113 ++++++++++++++++++ .../deployments/components/deploy-drawer.tsx | 14 ++- .../components/deploy-drawer/form.tsx | 47 ++++++-- .../versions-tab/deploy-release-menu.tsx | 14 ++- .../versions-tab/release-history-table.tsx | 4 +- web/features/deployments/utils.ts | 67 +++++++++++ web/i18n/en-US/deployments.json | 9 +- web/i18n/zh-Hans/deployments.json | 15 ++- 8 files changed, 264 insertions(+), 19 deletions(-) create mode 100644 web/features/deployments/__tests__/utils.spec.ts diff --git a/web/features/deployments/__tests__/utils.spec.ts b/web/features/deployments/__tests__/utils.spec.ts new file mode 100644 index 0000000000..8076ec91e5 --- /dev/null +++ b/web/features/deployments/__tests__/utils.spec.ts @@ -0,0 +1,113 @@ +import type { ConsoleRelease, ReleaseRow } from '@dify/contracts/enterprise/types.gen' +import { describe, expect, it } from 'vitest' +import { releaseDeploymentAction } from '../utils' + +function release(overrides: ReleaseRow): ReleaseRow { + return overrides +} + +function currentRelease(overrides: ConsoleRelease): ConsoleRelease { + return overrides +} + +describe('releaseDeploymentAction', () => { + describe('deploy actions', () => { + it('should return deploy when the target environment has no current release', () => { + // Arrange + const releases = [ + release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }), + ] + + // Act + const action = releaseDeploymentAction({ + targetRelease: releases[0], + releaseRows: releases, + }) + + // Assert + expect(action).toBe('deploy') + }) + + it('should return deployExistingRelease when a preset release is deployed to a new environment', () => { + // Arrange + const releases = [ + release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }), + ] + + // Act + const action = releaseDeploymentAction({ + targetRelease: releases[0], + releaseRows: releases, + isExistingRelease: true, + }) + + // Assert + expect(action).toBe('deployExistingRelease') + }) + }) + + describe('release direction', () => { + it('should return promote when the target release is newer than the current release', () => { + // Arrange + const releases = [ + release({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }), + release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }), + ] + + // Act + const action = releaseDeploymentAction({ + targetRelease: releases[0], + currentRelease: currentRelease({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }), + releaseRows: releases, + isExistingRelease: true, + }) + + // Assert + expect(action).toBe('promote') + }) + + it('should return rollback when the target release is older than the current release', () => { + // Arrange + const releases = [ + release({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }), + release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }), + ] + + // Act + const action = releaseDeploymentAction({ + targetRelease: releases[1], + currentRelease: currentRelease({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }), + releaseRows: releases, + isExistingRelease: true, + }) + + // Assert + expect(action).toBe('rollback') + }) + + it('should fall back to release list order when release timestamps are unavailable', () => { + // Arrange + const releases = [ + release({ id: 'release-3' }), + release({ id: 'release-2' }), + release({ id: 'release-1' }), + ] + + // Act + const rollbackAction = releaseDeploymentAction({ + targetRelease: releases[2], + currentRelease: currentRelease({ id: 'release-2' }), + releaseRows: releases, + }) + const promoteAction = releaseDeploymentAction({ + targetRelease: releases[0], + currentRelease: currentRelease({ id: 'release-2' }), + releaseRows: releases, + }) + + // Assert + expect(rollbackAction).toBe('rollback') + expect(promoteAction).toBe('promote') + }) + }) +}) diff --git a/web/features/deployments/components/deploy-drawer.tsx b/web/features/deployments/components/deploy-drawer.tsx index 934a072206..06fdab4f70 100644 --- a/web/features/deployments/components/deploy-drawer.tsx +++ b/web/features/deployments/components/deploy-drawer.tsx @@ -13,7 +13,7 @@ import { deployDrawerOpenAtom, deployDrawerReleaseIdAtom, } from '../store' -import { environmentOptionsFromOptionsReply } from '../utils' +import { deployedRows, environmentOptionsFromOptionsReply } from '../utils' import { DeployForm } from './deploy-drawer/form' export function DeployDrawer() { @@ -38,9 +38,18 @@ export function DeployDrawer() { const { data: environmentOptionsReply } = useQuery(consoleQuery.enterprise.appDeploy.listDeploymentEnvironmentOptions.queryOptions({ enabled: open, })) + const { data: environmentDeployments } = useQuery(consoleQuery.enterprise.appDeploy.listRuntimeInstances.queryOptions({ + input: drawerAppInstanceId + ? { + params: { appInstanceId: drawerAppInstanceId }, + } + : skipToken, + enabled: open && Boolean(drawerAppInstanceId), + })) const environments = environmentOptionsFromOptionsReply(environmentOptionsReply) const releases = releaseHistory?.data?.filter(release => release.id) ?? [] + const deploymentRows = deployedRows(environmentDeployments?.data) const defaultReleaseId = releases[0]?.id const formKey = `${drawerAppInstanceId ?? 'none'}-${drawerEnvironmentId ?? 'any'}-${drawerReleaseId ?? 'new'}-${open ? '1' : '0'}` @@ -53,7 +62,7 @@ export function DeployDrawer() { {!drawerAppInstanceId ?
{t('deployDrawer.notFound')}
- : (!releaseHistory || !environmentOptionsReply) + : (!releaseHistory || !environmentOptionsReply || !environmentDeployments) ? (
@@ -66,6 +75,7 @@ export function DeployDrawer() { appInstanceId={drawerAppInstanceId} environments={environments} releases={releases} + deploymentRows={deploymentRows} defaultReleaseId={defaultReleaseId} lockedEnvId={drawerEnvironmentId} presetReleaseId={drawerReleaseId} diff --git a/web/features/deployments/components/deploy-drawer/form.tsx b/web/features/deployments/components/deploy-drawer/form.tsx index 3bbc43f85b..0c84826c72 100644 --- a/web/features/deployments/components/deploy-drawer/form.tsx +++ b/web/features/deployments/components/deploy-drawer/form.tsx @@ -1,6 +1,6 @@ 'use client' -import type { DeploymentBindingOptionSlot, DeploymentRuntimeBinding, ReleaseRow } from '@dify/contracts/enterprise/types.gen' +import type { DeploymentBindingOptionSlot, DeploymentRuntimeBinding, ReleaseRow, RuntimeInstanceRow } from '@dify/contracts/enterprise/types.gen' import type { EnvironmentOption } from '@/features/deployments/types' import { Button } from '@langgenius/dify-ui/button' import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' @@ -12,9 +12,12 @@ import { useTranslation } from 'react-i18next' import { consoleQuery } from '@/service/client' import { closeDeployDrawerAtom } from '../../store' import { + activeRelease, + environmentId, environmentMode, environmentName, releaseCommit, + releaseDeploymentAction, releaseLabel, } from '../../utils' import { @@ -27,6 +30,7 @@ type DeployFormProps = { appInstanceId: string environments: EnvironmentOption[] releases: ReleaseRow[] + deploymentRows: RuntimeInstanceRow[] defaultReleaseId?: string lockedEnvId?: string presetReleaseId?: string @@ -204,6 +208,7 @@ export function DeployForm({ appInstanceId, environments, releases, + deploymentRows, defaultReleaseId, lockedEnvId, presetReleaseId, @@ -213,7 +218,7 @@ export function DeployForm({ const startDeploy = useMutation(consoleQuery.enterprise.appDeploy.createDeployment.mutationOptions()) const presetRelease = presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined const displayedRelease: ReleaseRow | undefined = presetRelease ?? (presetReleaseId ? { id: presetReleaseId } : undefined) - const isPromote = Boolean(presetReleaseId) + const isExistingRelease = Boolean(presetReleaseId) const [selectedEnvId, setSelectedEnvId] = useState( () => lockedEnvId ?? environments.find(env => !env.disabled)?.id ?? environments[0]?.id ?? '', @@ -225,6 +230,14 @@ export function DeployForm({ ) const selectedRelease = releases.find(release => release.id === selectedReleaseId) const targetReleaseId = displayedRelease?.id ?? selectedRelease?.id ?? selectedReleaseId + const targetRelease = displayedRelease ?? selectedRelease ?? (targetReleaseId ? { id: targetReleaseId } : undefined) + const selectedDeploymentRow = deploymentRows.find(row => environmentId(row.environment) === selectedEnvironmentId) + const action = releaseDeploymentAction({ + targetRelease, + currentRelease: activeRelease(selectedDeploymentRow), + releaseRows: releases, + isExistingRelease, + }) const bindingOptions = useQuery(consoleQuery.enterprise.appDeploy.listDeploymentBindingOptions.queryOptions({ input: appInstanceId && targetReleaseId ? { @@ -254,11 +267,29 @@ export function DeployForm({ ) const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined + const actionTitle = action === 'rollback' + ? t('deployDrawer.rollbackTitle') + : action === 'promote' + ? t('deployDrawer.promoteTitle') + : action === 'deployExistingRelease' + ? t('deployDrawer.deployExistingReleaseTitle') + : t('deployDrawer.title') + const actionDescription = action === 'rollback' + ? t('deployDrawer.rollbackDescription') + : action === 'promote' + ? t('deployDrawer.promoteDescription') + : action === 'deployExistingRelease' + ? t('deployDrawer.deployExistingReleaseDescription') + : t('deployDrawer.description') const submitLabel = isSubmitting ? t('deployDrawer.deploying') - : isPromote - ? t('deployDrawer.promote') - : t('deployDrawer.deploy') + : action === 'rollback' + ? t('deployDrawer.rollback') + : action === 'promote' + ? t('deployDrawer.promote') + : action === 'deployExistingRelease' + ? t('deployDrawer.deployExistingRelease') + : t('deployDrawer.deploy') const handleDeploy = () => { if (!canDeploy || !targetReleaseId) @@ -288,15 +319,15 @@ export function DeployForm({
- {isPromote ? t('deployDrawer.promoteTitle') : t('deployDrawer.title')} + {actionTitle} - {isPromote ? t('deployDrawer.promoteDescription') : t('deployDrawer.description')} + {actionDescription}
- {isPromote && displayedRelease + {isExistingRelease && displayedRelease ? (
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 1ffb7a283f..49890489d4 100644 --- a/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx +++ b/web/features/deployments/detail/versions-tab/deploy-release-menu.tsx @@ -1,5 +1,6 @@ 'use client' +import type { ReleaseRow } from '@dify/contracts/enterprise/types.gen' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenu, @@ -20,11 +21,13 @@ import { environmentId, environmentName, environmentOptionsFromOptionsReply, + releaseDeploymentAction, } from '../../utils' -export function DeployReleaseMenu({ appInstanceId, releaseId }: { +export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows }: { appInstanceId: string releaseId: string + releaseRows: ReleaseRow[] }) { const { t } = useTranslation('deployments') const openDeployDrawer = useSetAtom(openDeployDrawerAtom) @@ -42,6 +45,7 @@ export function DeployReleaseMenu({ appInstanceId, releaseId }: { const environmentOptions = environmentOptionsFromOptionsReply(environmentOptionsReply) const environments = environmentOptions.filter(env => env.id) const deploymentRows = deployedRows(environmentDeployments?.data) + const targetRelease = releaseRows.find(release => release.id === releaseId) ?? { id: releaseId } return ( @@ -63,6 +67,12 @@ export function DeployReleaseMenu({ appInstanceId, releaseId }: { const isCurrent = activeRelease(row)?.id === releaseId const isEnvironmentDeploying = row ? deploymentStatus(row) === 'deploying' : false const disabled = Boolean(env.disabled || isCurrent || isEnvironmentDeploying) + const action = releaseDeploymentAction({ + targetRelease, + currentRelease: activeRelease(row), + releaseRows, + isExistingRelease: true, + }) return ( diff --git a/web/features/deployments/detail/versions-tab/release-history-table.tsx b/web/features/deployments/detail/versions-tab/release-history-table.tsx index 0093d6d288..2e583831e7 100644 --- a/web/features/deployments/detail/versions-tab/release-history-table.tsx +++ b/web/features/deployments/detail/versions-tab/release-history-table.tsx @@ -63,7 +63,7 @@ export function ReleaseHistoryTable({ appInstanceId, releaseRows, deploymentRows
- +
@@ -114,7 +114,7 @@ export function ReleaseHistoryTable({ appInstanceId, releaseRows, deploymentRows ))}
- +
diff --git a/web/features/deployments/utils.ts b/web/features/deployments/utils.ts index 67b8d170ca..f31da17be2 100644 --- a/web/features/deployments/utils.ts +++ b/web/features/deployments/utils.ts @@ -14,6 +14,7 @@ import { PUBLIC_API_PREFIX } from '@/config' import { AppModeEnum } from '@/types/app' export type DeploymentUiStatus = 'ready' | 'deploying' | 'deploy_failed' +export type ReleaseDeploymentAction = 'deploy' | 'deployExistingRelease' | 'promote' | 'rollback' const appModeValues = new Set(Object.values(AppModeEnum)) @@ -68,6 +69,72 @@ export function releaseCommit(release?: ConsoleRelease | ReleaseRow) { return release && 'shortCommitId' in release ? release.shortCommitId || '—' : '—' } +function releaseCreatedAt(release?: ConsoleRelease | ReleaseRow) { + const value = release?.createdAt + if (!value) + return undefined + + const time = Date.parse(value) + return Number.isFinite(time) ? time : undefined +} + +function releaseById(releaseRows: ReleaseRow[], releaseId?: string) { + return releaseRows.find(release => release.id === releaseId) +} + +function releaseOrderIndex(releaseRows: ReleaseRow[], releaseId?: string) { + return releaseRows.findIndex(release => release.id === releaseId) +} + +function compareReleaseOrder(targetRelease: ConsoleRelease | ReleaseRow | undefined, currentRelease: ConsoleRelease, releaseRows: ReleaseRow[]) { + if (!targetRelease?.id || !currentRelease.id) + return undefined + if (targetRelease.id === currentRelease.id) + return 0 + + const normalizedTargetRelease = releaseById(releaseRows, targetRelease.id) ?? targetRelease + const normalizedCurrentRelease = releaseById(releaseRows, currentRelease.id) ?? currentRelease + const targetCreatedAt = releaseCreatedAt(normalizedTargetRelease) + const currentCreatedAt = releaseCreatedAt(normalizedCurrentRelease) + + if (targetCreatedAt !== undefined && currentCreatedAt !== undefined && targetCreatedAt !== currentCreatedAt) + return targetCreatedAt > currentCreatedAt ? 1 : -1 + + const targetIndex = releaseOrderIndex(releaseRows, targetRelease.id) + const currentIndex = releaseOrderIndex(releaseRows, currentRelease.id) + if (targetIndex >= 0 && currentIndex >= 0 && targetIndex !== currentIndex) + return targetIndex < currentIndex ? 1 : -1 + + return undefined +} + +export function releaseDeploymentAction({ + targetRelease, + currentRelease, + releaseRows, + isExistingRelease, +}: { + targetRelease?: ConsoleRelease | ReleaseRow + currentRelease?: ConsoleRelease + releaseRows: ReleaseRow[] + isExistingRelease?: boolean +}): ReleaseDeploymentAction { + if (!currentRelease?.id) + return isExistingRelease ? 'deployExistingRelease' : 'deploy' + + const order = compareReleaseOrder(targetRelease, currentRelease, releaseRows) + if (order === -1) + return 'rollback' + if (order === 1) + return 'promote' + + return targetRelease?.id && targetRelease.id !== currentRelease.id + ? 'promote' + : isExistingRelease + ? 'deployExistingRelease' + : 'deploy' +} + export function runtimeBindingSummary(binding?: ReleaseRuntimeBinding) { return binding?.label || binding?.displayValue || binding?.kind || '—' } diff --git a/web/i18n/en-US/deployments.json b/web/i18n/en-US/deployments.json index 588f490ee1..77294b3fa5 100644 --- a/web/i18n/en-US/deployments.json +++ b/web/i18n/en-US/deployments.json @@ -113,6 +113,9 @@ "deployDrawer.cancel": "Cancel", "deployDrawer.defaultSelect": "Select...", "deployDrawer.deploy": "Deploy", + "deployDrawer.deployExistingRelease": "Deploy existing release", + "deployDrawer.deployExistingReleaseDescription": "Deploy an existing release from the version history to a target environment.", + "deployDrawer.deployExistingReleaseTitle": "Deploy existing release", "deployDrawer.deployFailed": "Failed to start deployment.", "deployDrawer.deploying": "Deploying...", "deployDrawer.description": "Select a release and deploy it to a target environment.", @@ -132,11 +135,14 @@ "deployDrawer.notePlaceholder": "e.g. Ship onboarding copy tweak", "deployDrawer.pluginCreds": "Plugin credentials", "deployDrawer.promote": "Promote", - "deployDrawer.promoteDescription": "Deploy an existing release from the version history to a target environment.", + "deployDrawer.promoteDescription": "Promote a newer release to the target environment.", "deployDrawer.promoteTitle": "Promote release", "deployDrawer.readOnly": "Read-only", "deployDrawer.releaseLabel": "Release", "deployDrawer.requiredBinding": "Required", + "deployDrawer.rollback": "Rollback", + "deployDrawer.rollbackDescription": "Rollback the target environment to a previous release.", + "deployDrawer.rollbackTitle": "Rollback release", "deployDrawer.runtimeCredentials": "Runtime credentials", "deployDrawer.secretPlaceholder": "secret", "deployDrawer.selectCredential": "Select a credential", @@ -312,6 +318,7 @@ "versions.releaseHistory": "Release history", "versions.releaseNameLabel": "Release name", "versions.releaseNamePlaceholder": "Release name", + "versions.rollbackTo": "Rollback to {{name}}", "versions.sourceAppUnavailable": "The source app was deleted. Existing releases are still deployable, but new releases cannot be created.", "versions.viewYaml": "View YAML" } diff --git a/web/i18n/zh-Hans/deployments.json b/web/i18n/zh-Hans/deployments.json index 3331543225..fc75dd2f98 100644 --- a/web/i18n/zh-Hans/deployments.json +++ b/web/i18n/zh-Hans/deployments.json @@ -113,6 +113,9 @@ "deployDrawer.cancel": "取消", "deployDrawer.defaultSelect": "选择...", "deployDrawer.deploy": "部署", + "deployDrawer.deployExistingRelease": "部署已有版本", + "deployDrawer.deployExistingReleaseDescription": "从版本历史中选择一个已有发布版本,部署到目标环境。", + "deployDrawer.deployExistingReleaseTitle": "部署已有版本", "deployDrawer.deployFailed": "启动部署失败。", "deployDrawer.deploying": "部署中...", "deployDrawer.description": "选择一个发布版本,并部署到目标环境。", @@ -132,11 +135,14 @@ "deployDrawer.notePlaceholder": "例如:优化引导文案", "deployDrawer.pluginCreds": "插件凭据", "deployDrawer.promote": "推送", - "deployDrawer.promoteDescription": "从版本历史中选择一个已有发布版本,部署到目标环境。", + "deployDrawer.promoteDescription": "将更新的发布版本推送到目标环境。", "deployDrawer.promoteTitle": "推送发布版本", "deployDrawer.readOnly": "只读", "deployDrawer.releaseLabel": "发布版本", "deployDrawer.requiredBinding": "必填", + "deployDrawer.rollback": "回滚", + "deployDrawer.rollbackDescription": "将目标环境回滚到之前的发布版本。", + "deployDrawer.rollbackTitle": "回滚发布版本", "deployDrawer.runtimeCredentials": "运行时凭据", "deployDrawer.secretPlaceholder": "机密值", "deployDrawer.selectCredential": "选择凭据", @@ -180,7 +186,7 @@ "deployTab.panel.runtimeNote": "运行时备注", "deployTab.panel.targetRelease": "目标版本", "deployTab.panel.unknownError": "部署失败。", - "deployTab.promote": "发布", + "deployTab.promote": "推送", "deployTab.retry": "重试", "deployTab.shortcut": "快捷", "deployTab.status.deployFailed": "部署失败", @@ -305,13 +311,14 @@ "versions.hideYaml": "隐藏 YAML", "versions.moreActions": "更多操作", "versions.optional": "可选", - "versions.promote": "发布", - "versions.promoteTo": "发布到 {{name}}", + "versions.promote": "推送", + "versions.promoteTo": "推送到 {{name}}", "versions.releaseDescriptionLabel": "描述", "versions.releaseDescriptionPlaceholder": "描述这个版本", "versions.releaseHistory": "发布历史", "versions.releaseNameLabel": "版本名称", "versions.releaseNamePlaceholder": "版本名称", + "versions.rollbackTo": "回滚到 {{name}}", "versions.sourceAppUnavailable": "源应用已删除。已有发布版本仍可部署,但无法创建新版本。", "versions.viewYaml": "查看 YAML" }