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"
}