promote and rollback

This commit is contained in:
Stephen Zhou 2026-05-11 20:18:02 +08:00
parent c053549ece
commit 89a05c0665
No known key found for this signature in database
8 changed files with 264 additions and 19 deletions

View File

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

View File

@ -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() {
<DialogCloseButton />
{!drawerAppInstanceId
? <div className="p-4 text-text-tertiary">{t('deployDrawer.notFound')}</div>
: (!releaseHistory || !environmentOptionsReply)
: (!releaseHistory || !environmentOptionsReply || !environmentDeployments)
? (
<div className="flex items-center gap-2 p-4 system-sm-regular text-text-tertiary">
<span className="size-4 animate-spin rounded-full border-2 border-components-panel-border border-t-transparent" />
@ -66,6 +75,7 @@ export function DeployDrawer() {
appInstanceId={drawerAppInstanceId}
environments={environments}
releases={releases}
deploymentRows={deploymentRows}
defaultReleaseId={defaultReleaseId}
lockedEnvId={drawerEnvironmentId}
presetReleaseId={drawerReleaseId}

View File

@ -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<string>(
() => 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({
<div className="flex flex-col gap-5">
<div>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{isPromote ? t('deployDrawer.promoteTitle') : t('deployDrawer.title')}
{actionTitle}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{isPromote ? t('deployDrawer.promoteDescription') : t('deployDrawer.description')}
{actionDescription}
</DialogDescription>
</div>
<Field label={t('deployDrawer.releaseLabel')}>
{isPromote && displayedRelease
{isExistingRelease && displayedRelease
? (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">

View File

@ -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 (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
@ -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 (
<DropdownMenuItem
key={envId}
@ -81,7 +91,7 @@ export function DeployReleaseMenu({ appInstanceId, releaseId }: {
: isCurrent
? t('versions.currentOn', { name: environmentName(env) })
: row
? t('versions.promoteTo', { name: environmentName(env) })
? t(action === 'rollback' ? 'versions.rollbackTo' : 'versions.promoteTo', { name: environmentName(env) })
: t('versions.deployTo', { name: environmentName(env) })}
</span>
</DropdownMenuItem>

View File

@ -63,7 +63,7 @@ export function ReleaseHistoryTable({ appInstanceId, releaseRows, deploymentRows
</div>
</div>
<div className="flex shrink-0 justify-end gap-1">
<DeployReleaseMenu releaseId={release.id!} appInstanceId={appInstanceId} />
<DeployReleaseMenu releaseId={release.id!} appInstanceId={appInstanceId} releaseRows={releaseRows} />
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
@ -114,7 +114,7 @@ export function ReleaseHistoryTable({ appInstanceId, releaseRows, deploymentRows
))}
</div>
<div className="flex justify-end gap-1">
<DeployReleaseMenu releaseId={release.id!} appInstanceId={appInstanceId} />
<DeployReleaseMenu releaseId={release.id!} appInstanceId={appInstanceId} releaseRows={releaseRows} />
</div>
</div>
</div>

View File

@ -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<string>(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 || '—'
}

View File

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

View File

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