Compare commits

..

5 Commits

Author SHA1 Message Date
df5238b488 Fix deployments CI style checks 2026-06-12 16:05:44 +08:00
28cb44d06c Remove obsolete code paths 2026-06-12 15:05:15 +08:00
612adc24bd merge: catch up to 4-27-app-deploy (create-guide consolidation) on appdeploy v1 contract
Resolve modify/delete: environment-section.tsx was consolidated into
target-step.tsx upstream; re-apply the displayName migration there.
2026-06-12 11:54:50 +08:00
632cd60741 Document feature-scoped component and Jotai state patterns 2026-06-12 11:10:46 +08:00
09ea1786f4 Consolidate create guide step components 2026-06-12 10:47:29 +08:00
90 changed files with 1732 additions and 2133 deletions

View File

@ -18,6 +18,14 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- When fixing an invalid pattern, scan the touched feature or branch for equivalent patterns and fix them together.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
## Feature Workflow Layout
- State-heavy wizards, drawers, modals, and secondary workflows work best as a small feature surface with route/entry files, a single feature-local state file, and feature-local UI.
- Keep `ui/` shallow with owner files that map to the workflow's real composition boundaries and major visual regions.
- Owner files contain the section components, field components, skeletons, and one-off helper components that belong to their visual region.
- Folders represent groups of related files with a shared owner and a stable reason to change together.
- The entry file handles route integration, provider wiring, close behavior, and feature surface mounting. The composition owner handles high-level workflow branching, and the closest visual owner handles section branching.
## Ownership
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
@ -31,6 +39,17 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
## Feature-Scoped Jotai State
- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
- Derived atom names read as business facts. Write atom names read as user or workflow commands.
- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms.
- Non-query derived atoms return a narrow value with a clear domain name. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract.
- Write-only atoms own state transitions that update multiple primitives, reset dependent state, guard stale async work, or advance the workflow.
- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface.
- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient.
## Components, Props, And Types
- Type component signatures directly; do not use `FC` or `React.FC`.
@ -104,4 +123,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin
## Navigation And Performance
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
- Before reaching for `memo`, first try moving changing state down to the smallest component that actually uses it so unrelated sibling trees stay untouched.
- If changing state must wrap other content, lift the unchanged content up and pass it as `children` so the stateful wrapper can update without React visiting that subtree.
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.

View File

@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { DeployDrawer } from '@/features/deployments/components/deploy-drawer'
import { DeployDrawer } from '@/features/deployments/deploy-drawer'
export default function DeploymentsLayout({ children }: {
children: ReactNode

View File

@ -1,55 +0,0 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import {
deploymentStatusPollingInterval,
isAvailableDeploymentTarget,
isUndeployedDeploymentRow,
} from '../runtime-status'
const environment = {
id: 'env-undeploying',
displayName: 'Test CPU',
description: '',
mode: 'ENVIRONMENT_MODE_SHARED',
backend: 'RUNTIME_BACKEND_K8S',
namespace: '',
apiServer: '',
status: 'ENVIRONMENT_STATUS_READY',
statusMessage: '',
managedBy: '',
createdAt: '',
updatedAt: '',
runtimeEndpoint: '',
cpuCount: 1,
} satisfies EnvironmentDeployment['environment']
describe('runtime-status', () => {
// Runtime statuses come from the generated enterprise API contract.
describe('deployment status helpers', () => {
it('should not treat undeploying rows as undeployed when release fields are empty', () => {
const row = {
appInstanceId: 'app-instance',
environment,
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING,
updatedAt: '',
} satisfies EnvironmentDeployment
expect(isUndeployedDeploymentRow(row)).toBe(false)
expect(isAvailableDeploymentTarget(row)).toBe(false)
})
})
describe('deployment status polling', () => {
it('should keep polling while a deployment is undeploying', () => {
const rows = [{
appInstanceId: 'app-instance',
environment,
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING,
updatedAt: '',
}] satisfies EnvironmentDeployment[]
expect(deploymentStatusPollingInterval(rows)).toBe(3000)
})
})
})

View File

@ -1,98 +0,0 @@
import type { EnvVarBindingSlot, EnvVarValues } from '../env-var-bindings'
import { EnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { EnvVarBindingsPanel } from '../env-var-bindings'
function createEnvVarSlot(overrides: Partial<EnvVarBindingSlot> = {}): EnvVarBindingSlot {
return {
key: 'API_KEY',
description: 'API key',
defaultValue: 'unused-default',
hasDefaultValue: false,
hasLastValue: false,
lastValue: 'unused-last',
valueType: 'string',
...overrides,
}
}
function renderEnvVarBindingsPanel({
defaultSourcePriority,
slot,
values = {},
}: {
defaultSourcePriority?: 'dslDefault' | 'lastDeployment'
slot: EnvVarBindingSlot
values?: EnvVarValues
}) {
return render(
<EnvVarBindingsPanel
slots={[slot]}
values={values}
title="Environment Variables"
hint="Choose values."
envVarPlaceholder="Enter value"
literalSourceLabel="Custom value"
defaultSourceLabel="App value"
lastDeploymentSourceLabel="Last deployed value"
valueTypeLabels={{
number: 'Number',
secret: 'Secret',
string: 'String',
}}
sourceAriaLabel={key => `Select source for ${key}`}
defaultSourcePriority={defaultSourcePriority}
onChange={vi.fn()}
/>,
)
}
// The panel resolves display values directly from slot metadata when the form state is empty.
describe('EnvVarBindingsPanel', () => {
it('should show the app default value by default when both sources exist', () => {
renderEnvVarBindingsPanel({
slot: createEnvVarSlot({
hasDefaultValue: true,
defaultValue: 'app-value',
hasLastValue: true,
lastValue: 'last-value',
}),
})
expect(screen.getByDisplayValue('app-value')).toBeDisabled()
expect(screen.getByText('App value')).toBeInTheDocument()
expect(screen.getByText('Last deployed value')).toBeInTheDocument()
})
it('should show the last deployed value when that source is prioritized', () => {
renderEnvVarBindingsPanel({
defaultSourcePriority: 'lastDeployment',
slot: createEnvVarSlot({
hasDefaultValue: true,
defaultValue: 'app-value',
hasLastValue: true,
lastValue: 'last-value',
}),
})
expect(screen.getByDisplayValue('last-value')).toBeDisabled()
})
it('should show an existing manual value when the user changed the field', () => {
renderEnvVarBindingsPanel({
slot: createEnvVarSlot({
hasDefaultValue: true,
defaultValue: 'app-value',
}),
values: {
API_KEY: {
value: 'manual-value',
valueSource: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL,
},
},
})
expect(screen.getByDisplayValue('manual-value')).toBeEnabled()
})
})

View File

@ -1,172 +0,0 @@
import type { Environment, EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import {
EnvironmentMode,
EnvironmentStatus,
ReleaseSource,
RuntimeBackend,
RuntimeInstanceStatus,
} from '@dify/contracts/enterprise/types.gen'
import { act, renderHook } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { deployDrawerOpenAtom } from '../../../store'
import {
deployReadyFormConfigAtom,
deployReadyFormLocalAtoms,
useDeployReleaseSubmission,
} from '../use-deploy-ready-form'
type MutationCallbackOptions = {
onError?: (...args: unknown[]) => unknown
onSuccess?: (...args: unknown[]) => unknown
}
const mocks = vi.hoisted(() => ({
mutateCalls: [] as Array<{
options: MutationCallbackOptions
perCallOptions?: MutationCallbackOptions
variables: unknown
}>,
promoteBaseOnSuccess: vi.fn(),
rollbackBaseOnSuccess: vi.fn(),
toastError: vi.fn(),
useMutation: vi.fn(),
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useMutation: mocks.useMutation,
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
deploymentService: {
promote: {
mutationOptions: () => ({
onSuccess: mocks.promoteBaseOnSuccess,
}),
},
rollback: {
mutationOptions: () => ({
onSuccess: mocks.rollbackBaseOnSuccess,
}),
},
},
},
},
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: mocks.toastError,
},
}))
function release(id: string, createdAt: string): Release {
return {
appInstanceId: 'app-instance-1',
createdBy: {
id: 'user-1',
displayName: 'User',
},
description: `${id} description`,
gateCommitId: `${id}-commit`,
id,
displayName: id,
requiredSlots: [],
source: ReleaseSource.RELEASE_SOURCE_UPLOAD,
sourceAppId: 'source-app-1',
createdAt,
}
}
function environment(id = 'env-1'): Environment {
return {
apiServer: 'https://api.example.com',
backend: RuntimeBackend.RUNTIME_BACKEND_EXTERNAL,
cpuCount: 2,
createdAt: '2026-01-01T00:00:00.000Z',
description: 'Production environment',
id,
managedBy: 'system',
mode: EnvironmentMode.ENVIRONMENT_MODE_ISOLATED,
displayName: 'Production',
namespace: 'production',
runtimeEndpoint: 'https://runtime.example.com',
status: EnvironmentStatus.ENVIRONMENT_STATUS_READY,
statusMessage: 'Ready',
updatedAt: '2026-01-01T00:00:00.000Z',
}
}
function runtimeRow(currentRelease: Release): EnvironmentDeployment {
return {
appInstanceId: 'app-instance-1',
environment: environment(),
currentRelease,
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY,
updatedAt: '2026-01-01T00:00:00.000Z',
}
}
describe('useDeployReleaseSubmission', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.mutateCalls = []
mocks.useMutation.mockImplementation((options: MutationCallbackOptions) => ({
isPending: false,
mutate: vi.fn((variables: unknown, perCallOptions?: MutationCallbackOptions) => {
mocks.mutateCalls.push({ options, perCallOptions, variables })
options.onSuccess?.({}, variables, undefined, {})
perCallOptions?.onSuccess?.({}, variables, undefined, {})
}),
}))
})
// Deployment success should close the outer drawer store while form atoms stay scoped.
describe('Deployment success', () => {
it('should close the parent deploy drawer from inside the scoped form store', async () => {
const store = createStore()
const currentRelease = release('release-current', '2026-01-01T00:00:00.000Z')
const targetRelease = release('release-target', '2026-01-02T00:00:00.000Z')
const config = {
appInstanceId: 'app-instance-1',
defaultReleaseId: targetRelease.id,
environments: [environment()],
releases: [targetRelease, currentRelease],
runtimeRows: [runtimeRow(currentRelease)],
}
store.set(deployDrawerOpenAtom, true)
const wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider store={store}>
<ScopeProvider
atoms={[
[deployReadyFormConfigAtom, config],
...deployReadyFormLocalAtoms,
]}
>
{children}
</ScopeProvider>
</JotaiProvider>
)
const { result } = renderHook(() => useDeployReleaseSubmission({
deploymentCredentials: [],
deploymentEnvVars: [],
}), { wrapper })
await act(async () => {
result.current.deployRelease()
})
expect(mocks.mutateCalls).toHaveLength(1)
expect(mocks.promoteBaseOnSuccess).toHaveBeenCalledTimes(1)
expect(store.get(deployDrawerOpenAtom)).toBe(false)
})
})
})

View File

@ -1,395 +0,0 @@
'use client'
import type {
CredentialSelectionInput,
CredentialSlot,
Environment,
EnvironmentDeployment,
EnvVarInput,
Release,
} from '@dify/contracts/enterprise/types.gen'
import type { Getter } from 'jotai'
import type {
EnvVarBindingSlot,
EnvVarValues,
EnvVarValueSelection,
} from '../env-var-bindings'
import type { RuntimeCredentialBindingSelections } from '../runtime-credential-bindings-utils'
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
import { toast } from '@langgenius/dify-ui/toast'
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { createDeploymentIdempotencyKey } from '../../idempotency'
import { releaseDeploymentAction } from '../../release-action'
import { closeDeployDrawerAtom } from '../../store'
import { envVarBindingSlotFromContract } from '../env-var-bindings-utils'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialSlotKey,
selectedDeploymentRuntimeCredentials,
selectedRuntimeCredentialSelections,
} from '../runtime-credential-bindings-utils'
export type DeployReadyFormConfig = {
appInstanceId: string
environments: Environment[]
releases: Release[]
runtimeRows: EnvironmentDeployment[]
defaultReleaseId?: string
lockedEnvId?: string
presetReleaseId?: string
releaseEmptyLabel?: string
}
export const deployReadyFormConfigAtom = atom<DeployReadyFormConfig | undefined>(undefined)
const selectedEnvIdAtom = atom<string | undefined>(undefined)
const selectedReleaseIdAtom = atom<string | undefined>(undefined)
const manualBindingsAtom = atom<RuntimeCredentialBindingSelections>({})
const envVarValuesAtom = atom<EnvVarValues>({})
const showValidationErrorsAtom = atom(false)
export const deployReadyFormLocalAtoms = [
selectedEnvIdAtom,
selectedReleaseIdAtom,
manualBindingsAtom,
envVarValuesAtom,
showValidationErrorsAtom,
] as const
function formConfig(get: Getter) {
const config = get(deployReadyFormConfigAtom)
if (!config)
throw new Error('Missing deploy ready form config.')
return config
}
const resetDeployBindingsAtom = atom(null, (_get, set) => {
set(manualBindingsAtom, {})
set(envVarValuesAtom, {})
set(showValidationErrorsAtom, false)
})
const selectDeployEnvironmentAtom = atom(null, (_get, set, environmentId: string) => {
set(selectedEnvIdAtom, environmentId)
set(resetDeployBindingsAtom)
})
const selectDeployReleaseAtom = atom(null, (_get, set, releaseId: string) => {
set(selectedReleaseIdAtom, releaseId)
set(resetDeployBindingsAtom)
})
export const showDeployValidationErrorsAtom = atom(null, (_get, set) => {
set(showValidationErrorsAtom, true)
})
const deployPresetReleaseAtom = atom((get) => {
const config = formConfig(get)
return config.presetReleaseId ? config.releases.find(r => r.id === config.presetReleaseId) : undefined
})
const deployDisplayedReleaseAtom = atom((get): Release | undefined => {
return get(deployPresetReleaseAtom)
})
const deployIsExistingReleaseAtom = atom((get) => {
const config = formConfig(get)
return Boolean(config.presetReleaseId)
})
export const deploySelectedEnvironmentIdAtom = atom((get) => {
const config = formConfig(get)
return get(selectedEnvIdAtom) ?? config.lockedEnvId ?? config.environments[0]?.id
})
export const deploySelectedEnvironmentAtom = atom((get) => {
const config = formConfig(get)
const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom)
return selectedEnvironmentId
? config.environments.find(env => env.id === selectedEnvironmentId)
: undefined
})
const deploySelectedReleaseIdAtom = atom((get) => {
const config = formConfig(get)
const displayedRelease = get(deployDisplayedReleaseAtom)
return get(selectedReleaseIdAtom) ?? displayedRelease?.id ?? config.defaultReleaseId
})
const deploySelectedReleaseAtom = atom((get) => {
const config = formConfig(get)
const selectedReleaseId = get(deploySelectedReleaseIdAtom)
return selectedReleaseId
? config.releases.find(release => release.id === selectedReleaseId)
: undefined
})
const deployTargetReleaseAtom = atom((get) => {
return get(deployDisplayedReleaseAtom) ?? get(deploySelectedReleaseAtom)
})
export const deployTargetReleaseIdAtom = atom((get) => {
const targetRelease = get(deployTargetReleaseAtom)
return targetRelease?.id ?? get(deploySelectedReleaseIdAtom)
})
export const deployHasSelectedEnvironmentAtom = atom((get) => {
return Boolean(get(deploySelectedEnvironmentAtom))
})
function useDeployReadyFormConfig() {
const config = useAtomValue(deployReadyFormConfigAtom)
if (!config)
throw new Error('Missing deploy ready form config.')
return config
}
export function useDeployReleaseField() {
const config = useDeployReadyFormConfig()
const displayedRelease = useAtomValue(deployDisplayedReleaseAtom)
const isExistingRelease = useAtomValue(deployIsExistingReleaseAtom)
const selectedReleaseId = useAtomValue(deploySelectedReleaseIdAtom)
const selectRelease = useSetAtom(selectDeployReleaseAtom)
return {
displayedRelease,
emptyLabel: config.releaseEmptyLabel,
isExistingRelease,
releases: config.releases,
selectedReleaseId,
selectRelease,
}
}
export function useDeployEnvironmentField() {
const config = useDeployReadyFormConfig()
const selectedEnvironmentId = useAtomValue(deploySelectedEnvironmentIdAtom)
const selectEnvironment = useSetAtom(selectDeployEnvironmentAtom)
const lockedEnv = config.lockedEnvId ? config.environments.find(e => e.id === config.lockedEnvId) : undefined
return {
environments: config.environments,
lockedEnv,
lockedEnvId: config.lockedEnvId,
selectedEnvironmentId,
selectEnvironment,
}
}
export function useReleaseDeploymentOptions() {
const hasSelectedEnvironment = useAtomValue(deployHasSelectedEnvironmentAtom)
const releaseId = useAtomValue(deployTargetReleaseIdAtom)
const selectedEnvironmentId = useAtomValue(deploySelectedEnvironmentIdAtom)
const shouldLoadDeploymentOptions = Boolean(releaseId && selectedEnvironmentId && hasSelectedEnvironment)
const deploymentOptionsQuery = useQuery({
...consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
input: shouldLoadDeploymentOptions && releaseId && selectedEnvironmentId
? {
body: {
releaseId,
environmentId: selectedEnvironmentId,
},
}
: skipToken,
}),
retry: false,
})
const bindingSlots = deploymentOptionsQuery.data?.options.credentialSlots.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
const envVarSlots = deploymentOptionsQuery.data?.options.envVarSlots.flatMap((slot): EnvVarBindingSlot[] => {
const bindingSlot = envVarBindingSlotFromContract(slot)
return bindingSlot ? [bindingSlot] : []
}) ?? []
const deploymentOptionsLoading = deploymentOptionsQuery.isLoading || deploymentOptionsQuery.isFetching
const isBindingOptionsLoading = Boolean(
releaseId
&& hasSelectedEnvironment
&& deploymentOptionsLoading,
)
const hasBindingOptionsError = deploymentOptionsQuery.isError
const isBindingOptionsReady = Boolean(
releaseId
&& hasSelectedEnvironment
&& deploymentOptionsQuery.data
&& !isBindingOptionsLoading
&& !hasBindingOptionsError,
)
return {
bindingSlots,
envVarSlots,
hasBindingOptionsError,
isBindingOptionsLoading,
isBindingOptionsReady,
}
}
export function useDeployBindings({
bindingSlots,
envVarSlots,
}: {
bindingSlots: CredentialSlot[]
envVarSlots: EnvVarBindingSlot[]
}) {
const [manualBindings, setManualBindings] = useAtom(manualBindingsAtom)
const [envVarValues, setEnvVarValues] = useAtom(envVarValuesAtom)
const showValidationErrors = useAtomValue(showValidationErrorsAtom)
const showDeploymentValidationErrors = useSetAtom(showDeployValidationErrorsAtom)
const selectedBindings = selectedRuntimeCredentialSelections(bindingSlots, manualBindings)
const deploymentCredentials = selectedDeploymentRuntimeCredentials(bindingSlots, selectedBindings)
const deploymentEnvVars = envVarSlots.flatMap((slot): EnvVarInput[] => {
const selection = envVarValues[slot.key]
const valueSource = selection?.valueSource
?? (slot.hasLastValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT
: slot.hasDefaultValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT
: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL)
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) {
return slot.hasLastValue
? [{ key: slot.key, valueSource }]
: []
}
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) {
return slot.hasDefaultValue
? [{ key: slot.key, valueSource }]
: []
}
if (!selection?.value || (slot.valueType === 'number' && Number.isNaN(Number(selection.value))))
return []
return [{
key: slot.key,
value: selection.value,
valueSource,
}]
})
const requiredBindingsReady = bindingSlots.every(slot => !hasMissingRequiredRuntimeCredentialBinding(slot, selectedBindings[runtimeCredentialSlotKey(slot)]))
const requiredEnvVarsReady = envVarSlots.every((slot) => {
const selection = envVarValues[slot.key]
const valueSource = selection?.valueSource
?? (slot.hasLastValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT
: slot.hasDefaultValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT
: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL)
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT)
return Boolean(slot.hasLastValue)
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT)
return Boolean(slot.hasDefaultValue)
if (!selection?.value)
return false
return slot.valueType !== 'number' || !Number.isNaN(Number(selection.value))
})
function handleBindingChange(slot: string, value: string) {
setManualBindings(prev => ({ ...prev, [slot]: value }))
}
function handleEnvVarChange(key: string, value: EnvVarValueSelection) {
setEnvVarValues(prev => ({ ...prev, [key]: value }))
}
return {
deploymentCredentials,
deploymentEnvVars,
handleBindingChange,
handleEnvVarChange,
envVarValues,
requiredBindingsReady,
requiredEnvVarsReady,
selectedBindings,
showDeploymentValidationErrors,
showValidationErrors,
}
}
export function useDeployReleaseSubmission({
deploymentCredentials,
deploymentEnvVars,
}: {
deploymentCredentials: CredentialSelectionInput[]
deploymentEnvVars: EnvVarInput[]
}) {
const { t } = useTranslation('deployments')
const config = useDeployReadyFormConfig()
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const promoteRelease = useMutation(consoleQuery.enterprise.deploymentService.promote.mutationOptions())
const rollbackRelease = useMutation(consoleQuery.enterprise.deploymentService.rollback.mutationOptions())
const isSubmitting = promoteRelease.isPending || rollbackRelease.isPending
const selectedEnvironmentId = useAtomValue(deploySelectedEnvironmentIdAtom)
const targetRelease = useAtomValue(deployTargetReleaseAtom)
const targetReleaseId = useAtomValue(deployTargetReleaseIdAtom)
function deployRelease() {
if (!targetReleaseId || !selectedEnvironmentId)
return
const idempotencyKey = createDeploymentIdempotencyKey()
const currentRelease = config.runtimeRows.find(row => row.environment.id === selectedEnvironmentId)?.currentRelease
const action = releaseDeploymentAction({
targetRelease,
currentRelease,
releaseRows: config.releases,
isExistingRelease: true,
})
const mutationOptions = {
onSuccess: () => {
closeDeployDrawer()
},
onError: () => {
toast.error(t('deployDrawer.deployFailed'))
},
}
if (action === 'rollback') {
rollbackRelease.mutate(
{
params: {
appInstanceId: config.appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId: config.appInstanceId,
environmentId: selectedEnvironmentId,
targetReleaseId,
idempotencyKey,
},
},
mutationOptions,
)
return
}
promoteRelease.mutate(
{
params: {
appInstanceId: config.appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId: config.appInstanceId,
environmentId: selectedEnvironmentId,
releaseId: targetReleaseId,
credentials: deploymentCredentials,
envVars: deploymentEnvVars.length > 0 ? deploymentEnvVars : undefined,
idempotencyKey,
},
},
mutationOptions,
)
}
return {
deployRelease,
isSubmitting,
}
}

View File

@ -12,7 +12,7 @@ import {
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
@ -132,9 +132,10 @@ export function EditDeploymentDialog({
const { t } = useTranslation('deployments')
const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions())
const instanceQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: open
? { params: { appInstanceId } }
: skipToken,
input: {
params: { appInstanceId },
},
enabled: open,
}))
const app = instanceQuery.data?.appInstance
const formKey = app ? `${app.id}-${app.displayName}-${app.description}` : 'loading'

View File

@ -11,8 +11,8 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DeleteDeploymentDialog } from './delete-deployment-dialog'
import { EditDeploymentDialog } from './edit-deployment-dialog'
import { DeleteDeploymentDialog } from './delete-dialog'
import { EditDeploymentDialog } from './edit-dialog'
const ACTION_TRIGGER_CLASS_NAME = cn(
'inline-flex size-8 items-center justify-center rounded-lg bg-components-panel-bg text-text-tertiary shadow-xs outline-hidden',
@ -54,6 +54,7 @@ export function DeploymentActionsMenu({
return (
<div
role="presentation"
className={className}
onClick={event => event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}

View File

@ -1,6 +1,6 @@
'use client'
import type { UnsupportedDslNode } from '../error'
import type { UnsupportedDslNode } from '../shared/domain/error'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'

View File

@ -7,7 +7,7 @@ import type {
import type { Getter } from 'jotai/vanilla'
import type { EnvVarBindingSlot, EnvVarValues, EnvVarValueSelection } from '@/features/deployments/components/env-var-bindings'
import type { RuntimeCredentialBindingSelections } from '@/features/deployments/components/runtime-credential-bindings-utils'
import type { UnsupportedDslNode } from '@/features/deployments/error'
import type { UnsupportedDslNode } from '@/features/deployments/shared/domain/error'
import type { App } from '@/types/app'
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
import { keepPreviousData } from '@tanstack/react-query'
@ -21,22 +21,22 @@ import {
selectedDeploymentRuntimeCredentials,
selectedRuntimeCredentialSelections,
} from '@/features/deployments/components/runtime-credential-bindings-utils'
import {
DEPLOYMENT_PAGE_SIZE,
getNextPageParamFromPagination,
SOURCE_APPS_PAGE_SIZE,
} from '@/features/deployments/data'
import {
dslAppName,
dslEnvVarSlots,
encodeDslContent,
isWorkflowDsl,
} from '@/features/deployments/dsl'
import { environmentMatchesIdentifier } from '@/features/deployments/environment'
import { unsupportedDslNodeError } from '@/features/deployments/error'
import { createDeploymentIdempotencyKey } from '@/features/deployments/idempotency'
} from '@/features/deployments/shared/domain/dsl'
import { unsupportedDslNodeError } from '@/features/deployments/shared/domain/error'
import { createDeploymentIdempotencyKey } from '@/features/deployments/shared/domain/idempotency'
import {
DEPLOYMENT_PAGE_SIZE,
getNextPageParamFromPagination,
SOURCE_APPS_PAGE_SIZE,
} from '@/features/deployments/shared/domain/pagination'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { environmentMatchesIdentifier } from './environment'
export type GuideMethod = 'bindApp' | 'importDsl'
export type GuideStep = 'source' | 'release' | 'target'

View File

@ -1,14 +1,17 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { Input } from '@langgenius/dify-ui/input'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
continueFromReleaseAtom,
dslDefaultAppNameAtom,
hasInstanceNameConflictAtom,
instanceDescriptionAtom,
instanceNameAtom,
methodAtom,
releaseCanGoNextAtom,
releaseDescriptionAtom,
releaseNameAtom,
selectedAppAtom,
@ -16,11 +19,30 @@ import {
setInstanceNameAtom,
setReleaseDescriptionAtom,
setReleaseNameAtom,
stepAtom,
} from '@/features/deployments/create-guide/state'
import { StepShell } from './layout'
const releaseTextareaClassName = 'min-h-16 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs'
export function DeploymentInfoSection() {
export function ReleaseStepContent() {
const { t } = useTranslation('deployments')
return (
<StepShell
title={t('createGuide.release.title')}
description={t('createGuide.release.description')}
hideHeader
>
<div className="flex flex-col gap-6">
<DeploymentInfoSection />
<InitialReleaseSection />
</div>
</StepShell>
)
}
function DeploymentInfoSection() {
const { t } = useTranslation('deployments')
return (
@ -34,7 +56,7 @@ export function DeploymentInfoSection() {
)
}
export function InitialReleaseSection() {
function InitialReleaseSection() {
const { t } = useTranslation('deployments')
return (
@ -167,3 +189,21 @@ function OptionalFieldLabel({ children, htmlFor }: {
</div>
)
}
export function ReleaseActionButtons() {
const { t } = useTranslation('deployments')
const canGoNext = useAtomValue(releaseCanGoNextAtom)
const setStep = useSetAtom(stepAtom)
const continueFromRelease = useSetAtom(continueFromReleaseAtom)
return (
<>
<Button type="button" variant="secondary" onClick={() => setStep('source')}>
{t('createGuide.actions.back')}
</Button>
<Button type="button" variant="primary" disabled={!canGoNext} onClick={continueFromRelease}>
{t('createGuide.actions.next')}
</Button>
</>
)
}

View File

@ -1,50 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
continueFromReleaseAtom,
releaseCanGoNextAtom,
stepAtom,
} from '@/features/deployments/create-guide/state'
import { StepShell } from '../layout'
import {
DeploymentInfoSection,
InitialReleaseSection,
} from './fields'
export function ReleaseStepContent() {
const { t } = useTranslation('deployments')
return (
<StepShell
title={t('createGuide.release.title')}
description={t('createGuide.release.description')}
hideHeader
>
<div className="flex flex-col gap-6">
<DeploymentInfoSection />
<InitialReleaseSection />
</div>
</StepShell>
)
}
export function ReleaseActionButtons() {
const { t } = useTranslation('deployments')
const canGoNext = useAtomValue(releaseCanGoNextAtom)
const setStep = useSetAtom(stepAtom)
const continueFromRelease = useSetAtom(continueFromReleaseAtom)
return (
<>
<Button type="button" variant="secondary" onClick={() => setStep('source')}>
{t('createGuide.actions.back')}
</Button>
<Button type="button" variant="primary" disabled={!canGoNext} onClick={continueFromRelease}>
{t('createGuide.actions.next')}
</Button>
</>
)
}

View File

@ -6,11 +6,11 @@ import { GuideCard, GuideFrame } from './layout'
import {
ReleaseActionButtons,
ReleaseStepContent,
} from './release-step/release-step-content'
} from './release-step'
import {
SourceActionButtons,
SourceStepContent,
} from './source-step/source-step-content'
} from './source-step'
import {
TargetBackButton,
TargetDeployButton,

View File

@ -0,0 +1,360 @@
'use client'
import type { GuideMethod, WorkflowSourceApp } from '@/features/deployments/create-guide/state'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { RadioRoot } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { DeploymentStateMessage } from '@/features/deployments/components/empty-state'
import { TitleTooltip } from '@/features/deployments/components/title-tooltip'
import { UnsupportedDslNodesAlert } from '@/features/deployments/components/unsupported-dsl-nodes-alert'
import {
continueFromSourceAtom,
dslFileAtom,
dslReadErrorAtom,
dslUnsupportedModeAtom,
effectiveSelectedAppAtom,
isReadingDslAtom,
methodAtom,
selectDslFileAtom,
selectMethodAtom,
selectSourceAppAtom,
setSourceSearchTextAtom,
sourceAppsQueryAtom,
sourceCanGoNextAtom,
sourceSearchTextAtom,
unsupportedDslNodesAtom,
} from '@/features/deployments/create-guide/state'
import { StepShell } from './layout'
const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app']
export function SourceStepContent() {
const method = useAtomValue(methodAtom)
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<SourceMethodSection />
{method === 'bindApp' && (
<SourceAppSelectionSection />
)}
{method === 'importDsl' && (
<DslUploadSection />
)}
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
</div>
)
}
function SourceMethodSection() {
const { t } = useTranslation('deployments')
const method = useAtomValue(methodAtom)
const selectMethod = useSetAtom(selectMethodAtom)
return (
<StepShell
title={t('createGuide.steps.method')}
description={t('createGuide.method.description')}
descriptionClassName="lg:hidden"
hideHeader
>
<RadioGroup<GuideMethod>
value={method}
onValueChange={selectMethod}
className="flex flex-col items-stretch gap-2 sm:flex-row"
>
<SourceMethodCard
value="bindApp"
icon="i-ri-stack-line"
title={t('createGuide.methods.bindApp.title')}
description={t('createGuide.methods.bindApp.description')}
/>
<SourceMethodCard
value="importDsl"
icon="i-ri-file-code-line"
title={t('createGuide.methods.importDsl.title')}
description={t('createGuide.methods.importDsl.description')}
/>
</RadioGroup>
</StepShell>
)
}
function SourceMethodCard({ value, icon, title, description, badge }: {
value: GuideMethod
icon: string
title: string
description: string
badge?: string
}) {
return (
<RadioRoot<GuideMethod>
value={value}
variant="unstyled"
className={cn(
`relative box-content h-[84px] w-full cursor-pointer rounded-xl border-[0.5px]
border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3
text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2
focus-visible:ring-state-accent-solid sm:w-[240px]`,
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:shadow-md data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset',
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-md border border-divider-subtle bg-background-default-subtle">
<span className={cn('size-4 text-text-tertiary', icon)} aria-hidden="true" />
</span>
<span className="mt-2 mb-0.5 flex min-w-0 items-center gap-1">
<span className="truncate system-sm-semibold text-text-secondary">{title}</span>
{badge && (
<span className="shrink-0 rounded-md bg-background-default-subtle px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{badge}
</span>
)}
</span>
<span className="flex min-w-0 items-start gap-1">
<TitleTooltip content={description}>
<span className="line-clamp-2 min-w-0 grow system-xs-regular text-text-tertiary">
{description}
</span>
</TitleTooltip>
</span>
</RadioRoot>
)
}
function SourceAppSelectionSection() {
const { t } = useTranslation('deployments')
return (
<StepShell
title={t('createGuide.source.title')}
description={t('createGuide.source.description')}
descriptionClassName="lg:hidden"
hideHeader
className="min-h-0 flex-1"
>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<SourceSearchInput />
<SourceAppList />
</div>
</StepShell>
)
}
function SourceSearchInput() {
const { t } = useTranslation('deployments')
const sourceSearchText = useAtomValue(sourceSearchTextAtom)
const setSourceSearchText = useSetAtom(setSourceSearchTextAtom)
return (
<div className="relative">
<span className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" aria-hidden="true" />
<Input
id="create-guide-source-search"
aria-label={t('createGuide.source.sourceApp')}
value={sourceSearchText}
onChange={event => setSourceSearchText(event.target.value)}
placeholder={t('createGuide.source.searchPlaceholder')}
className="h-9 pr-8 pl-8"
/>
{sourceSearchText && (
<button
type="button"
aria-label={t('createGuide.source.clearSearch')}
onClick={() => setSourceSearchText('')}
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
>
<span className="i-ri-close-circle-fill size-4" aria-hidden="true" />
</button>
)}
</div>
)
}
function SourceAppList() {
const { t } = useTranslation('deployments')
const selectSourceApp = useSetAtom(selectSourceAppAtom)
const effectiveSelectedApp = useAtomValue(effectiveSelectedAppAtom)
const sourceAppsQuery = useAtomValue(sourceAppsQueryAtom)
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
const sourceAppsLoading = sourceAppsQuery.isLoading || sourceAppsQuery.isPlaceholderData || (sourceAppsQuery.isFetching && sourceApps.length === 0)
return (
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default">
{sourceAppsLoading
? <SourceAppSkeleton />
: sourceApps.length === 0
? (
<DeploymentStateMessage variant="embedded">
{t('createGuide.source.empty')}
</DeploymentStateMessage>
)
: (
<div>
{sourceApps.map(app => (
<SourceAppOption
key={app.id}
app={app}
selected={effectiveSelectedApp?.id === app.id}
onSelect={() => selectSourceApp(app)}
/>
))}
{sourceAppsQuery.hasNextPage && (
<div className="flex justify-center border-t border-divider-subtle px-3 py-2">
<Button
type="button"
size="small"
disabled={sourceAppsQuery.isFetchingNextPage}
onClick={() => {
void sourceAppsQuery.fetchNextPage()
}}
>
{sourceAppsQuery.isFetchingNextPage ? t('createModal.loadingApps') : t('createModal.loadMoreApps')}
</Button>
</div>
)}
</div>
)}
</div>
)
}
function SourceAppSkeleton() {
return (
<div className="divide-y divide-divider-subtle">
{sourceAppSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-14 px-3 py-2">
<SkeletonRectangle className="my-0 size-7 animate-pulse rounded-lg" />
<div className="flex min-w-0 grow flex-col gap-1">
<SkeletonRectangle className="my-0 h-3.5 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-2.5 w-1/3 animate-pulse" />
</div>
</SkeletonRow>
))}
</div>
)
}
function SourceAppOption({ app, onSelect, selected }: {
app: WorkflowSourceApp
onSelect: () => void
selected: boolean
}) {
return (
<label
className={cn(
'group flex min-h-14 cursor-pointer items-center gap-3 border-b border-b-divider-subtle px-3 py-2 transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0',
selected
? 'bg-state-accent-hover hover:bg-state-accent-hover'
: 'bg-background-default hover:bg-state-base-hover',
)}
>
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<span className="flex min-w-0 grow">
<span className={cn('truncate system-sm-medium', selected ? 'text-text-accent' : 'text-text-primary')}>{app.name}</span>
</span>
<input
type="radio"
name="source-app"
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full',
selected ? 'bg-primary-600 text-text-primary-on-surface' : 'text-transparent',
)}
aria-hidden="true"
>
<span className="i-ri-check-line size-4" />
</span>
</label>
)
}
function DslUploadSection() {
const { t } = useTranslation('deployments')
const dslFile = useAtomValue(dslFileAtom)
const selectDslFile = useSetAtom(selectDslFileAtom)
return (
<StepShell title={t('createGuide.dsl.title')} description={t('createGuide.dsl.description')} hideHeader>
<div className="flex flex-col gap-4 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-5">
<div className="flex items-start gap-3">
<span className="mt-0.5 i-ri-upload-cloud-2-line size-5 shrink-0 text-text-tertiary" aria-hidden="true" />
<div className="flex min-w-0 flex-col gap-1">
<div className="system-sm-semibold text-text-primary">{t('createGuide.dsl.dropTitle')}</div>
<div className="system-sm-regular text-text-tertiary">{t('createGuide.dsl.dropDescription')}</div>
</div>
</div>
<Uploader
className="mt-0"
file={dslFile}
updateFile={selectDslFile}
/>
<DslReadStatus />
</div>
</StepShell>
)
}
function DslReadStatus() {
const { t } = useTranslation('deployments')
const isReadingDsl = useAtomValue(isReadingDslAtom)
const dslReadError = useAtomValue(dslReadErrorAtom)
const dslUnsupportedMode = useAtomValue(dslUnsupportedModeAtom)
return (
<>
{isReadingDsl && (
<div className="system-xs-regular text-text-tertiary">
{t('createGuide.dsl.reading')}
</div>
)}
{dslReadError && (
<div className="system-xs-regular text-text-destructive">
{t('createGuide.dsl.readFailed')}
</div>
)}
{dslUnsupportedMode && (
<div role="alert" className="system-xs-regular text-text-destructive">
{t('createGuide.dsl.unsupportedMode')}
</div>
)}
</>
)
}
export function SourceActionButtons() {
const { t } = useTranslation('deployments')
const canGoNext = useAtomValue(sourceCanGoNextAtom)
const continueFromSource = useSetAtom(continueFromSourceAtom)
return (
<Button
type="button"
variant="primary"
disabled={!canGoNext}
onClick={() => continueFromSource({
defaultDslAppName: t('createGuide.dsl.defaultAppName'),
defaultReleaseName: t('createGuide.release.defaultName'),
})}
>
{t('createGuide.actions.next')}
</Button>
)
}

View File

@ -1,66 +0,0 @@
'use client'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import {
dslFileAtom,
dslReadErrorAtom,
dslUnsupportedModeAtom,
isReadingDslAtom,
selectDslFileAtom,
} from '@/features/deployments/create-guide/state'
import { StepShell } from '../layout'
export function DslUploadSection() {
const { t } = useTranslation('deployments')
const dslFile = useAtomValue(dslFileAtom)
const selectDslFile = useSetAtom(selectDslFileAtom)
return (
<StepShell title={t('createGuide.dsl.title')} description={t('createGuide.dsl.description')} hideHeader>
<div className="flex flex-col gap-4 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-5">
<div className="flex items-start gap-3">
<span className="mt-0.5 i-ri-upload-cloud-2-line size-5 shrink-0 text-text-tertiary" aria-hidden="true" />
<div className="flex min-w-0 flex-col gap-1">
<div className="system-sm-semibold text-text-primary">{t('createGuide.dsl.dropTitle')}</div>
<div className="system-sm-regular text-text-tertiary">{t('createGuide.dsl.dropDescription')}</div>
</div>
</div>
<Uploader
className="mt-0"
file={dslFile}
updateFile={selectDslFile}
/>
<DslReadStatus />
</div>
</StepShell>
)
}
function DslReadStatus() {
const { t } = useTranslation('deployments')
const isReadingDsl = useAtomValue(isReadingDslAtom)
const dslReadError = useAtomValue(dslReadErrorAtom)
const dslUnsupportedMode = useAtomValue(dslUnsupportedModeAtom)
return (
<>
{isReadingDsl && (
<div className="system-xs-regular text-text-tertiary">
{t('createGuide.dsl.reading')}
</div>
)}
{dslReadError && (
<div className="system-xs-regular text-text-destructive">
{t('createGuide.dsl.readFailed')}
</div>
)}
{dslUnsupportedMode && (
<div role="alert" className="system-xs-regular text-text-destructive">
{t('createGuide.dsl.unsupportedMode')}
</div>
)}
</>
)
}

View File

@ -1,179 +0,0 @@
'use client'
import type { WorkflowSourceApp } from '@/features/deployments/create-guide/state'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { DeploymentStateMessage } from '@/features/deployments/components/empty-state'
import {
effectiveSelectedAppAtom,
selectSourceAppAtom,
setSourceSearchTextAtom,
sourceAppsQueryAtom,
sourceSearchTextAtom,
} from '@/features/deployments/create-guide/state'
import { StepShell } from '../layout'
const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app']
export function SourceAppSelectionSection() {
const { t } = useTranslation('deployments')
return (
<StepShell
title={t('createGuide.source.title')}
description={t('createGuide.source.description')}
descriptionClassName="lg:hidden"
hideHeader
className="min-h-0 flex-1"
>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<SourceSearchInput />
<SourceAppList />
</div>
</StepShell>
)
}
function SourceSearchInput() {
const { t } = useTranslation('deployments')
const sourceSearchText = useAtomValue(sourceSearchTextAtom)
const setSourceSearchText = useSetAtom(setSourceSearchTextAtom)
return (
<div className="relative">
<span className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" aria-hidden="true" />
<Input
id="create-guide-source-search"
aria-label={t('createGuide.source.sourceApp')}
value={sourceSearchText}
onChange={event => setSourceSearchText(event.target.value)}
placeholder={t('createGuide.source.searchPlaceholder')}
className="h-9 pr-8 pl-8"
/>
{sourceSearchText && (
<button
type="button"
aria-label={t('createGuide.source.clearSearch')}
onClick={() => setSourceSearchText('')}
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
>
<span className="i-ri-close-circle-fill size-4" aria-hidden="true" />
</button>
)}
</div>
)
}
function SourceAppList() {
const { t } = useTranslation('deployments')
const selectSourceApp = useSetAtom(selectSourceAppAtom)
const effectiveSelectedApp = useAtomValue(effectiveSelectedAppAtom)
const sourceAppsQuery = useAtomValue(sourceAppsQueryAtom)
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
const sourceAppsLoading = sourceAppsQuery.isLoading || sourceAppsQuery.isPlaceholderData || (sourceAppsQuery.isFetching && sourceApps.length === 0)
return (
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default">
{sourceAppsLoading
? <SourceAppSkeleton />
: sourceApps.length === 0
? (
<DeploymentStateMessage variant="embedded">
{t('createGuide.source.empty')}
</DeploymentStateMessage>
)
: (
<div>
{sourceApps.map(app => (
<SourceAppOption
key={app.id}
app={app}
selected={effectiveSelectedApp?.id === app.id}
onSelect={() => selectSourceApp(app)}
/>
))}
{sourceAppsQuery.hasNextPage && (
<div className="flex justify-center border-t border-divider-subtle px-3 py-2">
<Button
type="button"
size="small"
disabled={sourceAppsQuery.isFetchingNextPage}
onClick={() => {
void sourceAppsQuery.fetchNextPage()
}}
>
{sourceAppsQuery.isFetchingNextPage ? t('createModal.loadingApps') : t('createModal.loadMoreApps')}
</Button>
</div>
)}
</div>
)}
</div>
)
}
function SourceAppSkeleton() {
return (
<div className="divide-y divide-divider-subtle">
{sourceAppSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-14 px-3 py-2">
<SkeletonRectangle className="my-0 size-7 animate-pulse rounded-lg" />
<div className="flex min-w-0 grow flex-col gap-1">
<SkeletonRectangle className="my-0 h-3.5 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-2.5 w-1/3 animate-pulse" />
</div>
</SkeletonRow>
))}
</div>
)
}
function SourceAppOption({ app, onSelect, selected }: {
app: WorkflowSourceApp
onSelect: () => void
selected: boolean
}) {
return (
<label
className={cn(
'group flex min-h-14 cursor-pointer items-center gap-3 border-b border-b-divider-subtle px-3 py-2 transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0',
selected
? 'bg-state-accent-hover hover:bg-state-accent-hover'
: 'bg-background-default hover:bg-state-base-hover',
)}
>
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<span className="flex min-w-0 grow">
<span className={cn('truncate system-sm-medium', selected ? 'text-text-accent' : 'text-text-primary')}>{app.name}</span>
</span>
<input
type="radio"
name="source-app"
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full',
selected ? 'bg-primary-600 text-text-primary-on-surface' : 'text-transparent',
)}
aria-hidden="true"
>
<span className="i-ri-check-line size-4" />
</span>
</label>
)
}

View File

@ -1,86 +0,0 @@
'use client'
import type { GuideMethod } from '@/features/deployments/create-guide/state'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioRoot } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { TitleTooltip } from '@/features/deployments/components/title-tooltip'
import { methodAtom, selectMethodAtom } from '@/features/deployments/create-guide/state'
import { StepShell } from '../layout'
function SourceMethodCard({ value, icon, title, description, badge }: {
value: GuideMethod
icon: string
title: string
description: string
badge?: string
}) {
return (
<RadioRoot<GuideMethod>
value={value}
variant="unstyled"
className={cn(
`relative box-content h-[84px] w-full cursor-pointer rounded-xl border-[0.5px]
border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3
text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2
focus-visible:ring-state-accent-solid sm:w-[240px]`,
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:shadow-md data-checked:ring-[0.5px] data-checked:ring-components-option-card-option-selected-border data-checked:ring-inset',
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-md border border-divider-subtle bg-background-default-subtle">
<span className={cn('size-4 text-text-tertiary', icon)} aria-hidden="true" />
</span>
<span className="mt-2 mb-0.5 flex min-w-0 items-center gap-1">
<span className="truncate system-sm-semibold text-text-secondary">{title}</span>
{badge && (
<span className="shrink-0 rounded-md bg-background-default-subtle px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{badge}
</span>
)}
</span>
<span className="flex min-w-0 items-start gap-1">
<TitleTooltip content={description}>
<span className="line-clamp-2 min-w-0 grow system-xs-regular text-text-tertiary">
{description}
</span>
</TitleTooltip>
</span>
</RadioRoot>
)
}
export function SourceMethodSection() {
const { t } = useTranslation('deployments')
const method = useAtomValue(methodAtom)
const selectMethod = useSetAtom(selectMethodAtom)
return (
<StepShell
title={t('createGuide.steps.method')}
description={t('createGuide.method.description')}
descriptionClassName="lg:hidden"
hideHeader
>
<RadioGroup<GuideMethod>
value={method}
onValueChange={selectMethod}
className="flex flex-col items-stretch gap-2 sm:flex-row"
>
<SourceMethodCard
value="bindApp"
icon="i-ri-stack-line"
title={t('createGuide.methods.bindApp.title')}
description={t('createGuide.methods.bindApp.description')}
/>
<SourceMethodCard
value="importDsl"
icon="i-ri-file-code-line"
title={t('createGuide.methods.importDsl.title')}
description={t('createGuide.methods.importDsl.description')}
/>
</RadioGroup>
</StepShell>
)
}

View File

@ -1,53 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { UnsupportedDslNodesAlert } from '@/features/deployments/components/unsupported-dsl-nodes-alert'
import {
continueFromSourceAtom,
methodAtom,
sourceCanGoNextAtom,
unsupportedDslNodesAtom,
} from '@/features/deployments/create-guide/state'
import { DslUploadSection } from './dsl-upload-section'
import { SourceAppSelectionSection } from './source-app-selection-section'
import { SourceMethodSection } from './source-method-section'
export function SourceStepContent() {
const method = useAtomValue(methodAtom)
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<SourceMethodSection />
{method === 'bindApp' && (
<SourceAppSelectionSection />
)}
{method === 'importDsl' && (
<DslUploadSection />
)}
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
</div>
)
}
export function SourceActionButtons() {
const { t } = useTranslation('deployments')
const canGoNext = useAtomValue(sourceCanGoNextAtom)
const continueFromSource = useSetAtom(continueFromSourceAtom)
return (
<Button
type="button"
variant="primary"
disabled={!canGoNext}
onClick={() => continueFromSource({
defaultDslAppName: t('createGuide.dsl.defaultAppName'),
defaultReleaseName: t('createGuide.release.defaultName'),
})}
>
{t('createGuide.actions.next')}
</Button>
)
}

View File

@ -0,0 +1,356 @@
'use client'
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import {
EnvVarBindingsPanel,
} from '@/features/deployments/components/env-var-bindings'
import {
RuntimeCredentialBindingsPanel,
} from '@/features/deployments/components/runtime-credential-bindings'
import { TitleTooltip } from '@/features/deployments/components/title-tooltip'
import { UnsupportedDslNodesAlert } from '@/features/deployments/components/unsupported-dsl-nodes-alert'
import {
canDeployAtom,
canSkipDeploymentAtom,
createDeploymentGuideSubmissionAtom,
CreateDeploymentGuideSubmissionBlockedError,
deployableEnvironmentsAtom,
deployableEnvironmentsQueryAtom,
deploymentOptionsQueryAtom,
deploymentTargetBindingSelectionsAtom,
deploymentTargetBindingSlotsAtom,
deploymentTargetEnvVarSlotsAtom,
effectiveSelectedEnvironmentIdAtom,
envVarValuesAtom,
isCreatingReleaseOnlyAtom,
isSubmittingDeploymentGuideAtom,
selectBindingAtom,
selectedEnvironmentIdAtom,
setEnvVarAtom,
stepAtom,
unsupportedDslNodesAtom,
} from '@/features/deployments/create-guide/state'
import { deploymentErrorMessage } from '@/features/deployments/shared/domain/error'
import { useRouter } from '@/next/navigation'
import { StepShell } from './layout'
const targetEnvironmentSkeletonKeys = ['first-target-environment', 'second-target-environment']
const targetBindingSkeletonKeys = ['first-target-binding', 'second-target-binding']
export function TargetStepContent() {
const { t } = useTranslation('deployments')
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
return (
<StepShell
title={t('createGuide.target.title')}
description={t('createGuide.target.description')}
hideHeader
>
<div className="flex flex-col gap-6">
<TargetEnvironmentSection />
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
<TargetBindingSection />
<TargetEnvVarSection />
</div>
</StepShell>
)
}
function TargetEnvironmentSection() {
const { t } = useTranslation('deployments')
const environmentsQuery = useAtomValue(deployableEnvironmentsQueryAtom)
const environments = useAtomValue(deployableEnvironmentsAtom)
const effectiveSelectedEnvironmentId = useAtomValue(effectiveSelectedEnvironmentIdAtom)
const isEnvironmentError = environmentsQuery.isError
const isEnvironmentLoading = environmentsQuery.isLoading || (environmentsQuery.isFetching && !environmentsQuery.data)
const selectEnvironment = useSetAtom(selectedEnvironmentIdAtom)
const hasEnvironmentOptions = environments.length > 0
return (
<div className="flex flex-col gap-3">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('createGuide.target.environment')}</div>
{hasEnvironmentOptions
? (
<RadioGroup<string>
value={effectiveSelectedEnvironmentId}
onValueChange={selectEnvironment}
className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-2"
>
{environments.map(environment => (
<EnvironmentOptionRow
key={environment.id}
environment={environment}
/>
))}
</RadioGroup>
)
: isEnvironmentLoading
? <TargetEnvironmentSkeleton />
: (
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{isEnvironmentError
? t('createGuide.target.loadEnvironmentsFailed')
: t('createGuide.target.noEnvironmentOptions')}
</div>
)}
</div>
)
}
function EnvironmentOptionRow({ environment }: {
environment: Environment
}) {
const { t } = useTranslation('deployments')
const summary = environment.description.trim() || `${t(`mode.${environment.mode}`)} · ${t(`backend.${environment.backend}`)}`
return (
<RadioRoot<string>
value={environment.id}
variant="unstyled"
className={cn(
'group flex cursor-pointer items-center gap-3 rounded-xl border p-3 outline-hidden',
'border-components-option-card-option-border bg-components-option-card-option-bg hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-checked:border-state-accent-solid data-checked:bg-state-accent-hover data-checked:shadow-xs',
)}
>
<RadioControl />
<span className="flex min-w-0 grow flex-col gap-1">
<span className="truncate system-sm-semibold text-text-primary group-data-checked:text-text-accent">{environment.displayName}</span>
<TitleTooltip content={summary}>
<span className="line-clamp-1 system-xs-regular text-text-tertiary group-data-checked:text-text-secondary">
{summary}
</span>
</TitleTooltip>
</span>
</RadioRoot>
)
}
function TargetBindingSection() {
const { t } = useTranslation('deployments')
const deploymentOptionsQuery = useAtomValue(deploymentOptionsQueryAtom)
const bindingSlots = useAtomValue(deploymentTargetBindingSlotsAtom)
const bindingSelections = useAtomValue(deploymentTargetBindingSelectionsAtom)
const isBindingError = deploymentOptionsQuery.isError
const isBindingLoading = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
const selectBinding = useSetAtom(selectBindingAtom)
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
const shouldRender = !(isBindingError && unsupportedDslNodes.length > 0)
if (!shouldRender)
return null
if (isBindingLoading || isBindingError) {
return (
<div className="overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg">
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('createGuide.target.bindings')}</div>
<span className="system-xs-regular text-text-tertiary">{t('createGuide.target.bindingHint')}</span>
</div>
{isBindingLoading
? <TargetBindingSkeleton />
: (
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{t('createGuide.target.loadBindingsFailed')}
</div>
)}
</div>
)
}
return (
<RuntimeCredentialBindingsPanel
slots={bindingSlots}
selections={bindingSelections}
title={t('createGuide.target.bindings')}
hint={t('createGuide.target.bindingHint')}
noBindingRequiredLabel={t('createGuide.target.noBindingRequired')}
noCredentialCandidatesLabel={t('createGuide.target.noCredentialCandidates')}
selectCredentialLabel={t('createGuide.target.selectCredential')}
missingRequiredLabel={t('createGuide.target.missingRequiredBinding')}
bindingCountLabel={t('createGuide.target.bindingCount', { count: bindingSlots.length })}
onChange={selectBinding}
listScrollable={false}
className="border-components-option-card-option-border bg-components-option-card-option-bg"
/>
)
}
function TargetEnvVarSection() {
const { t } = useTranslation('deployments')
const setEnvVar = useSetAtom(setEnvVarAtom)
const envVarValues = useAtomValue(envVarValuesAtom)
const deploymentOptionsQuery = useAtomValue(deploymentOptionsQueryAtom)
const envVarSlots = useAtomValue(deploymentTargetEnvVarSlotsAtom)
const isBindingError = deploymentOptionsQuery.isError
const isBindingLoading = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
if (isBindingLoading || isBindingError)
return null
return (
<EnvVarBindingsPanel
slots={envVarSlots}
values={envVarValues}
title={t('createGuide.target.envVars')}
hint={t('createGuide.target.envVarHint')}
envVarPlaceholder={t('createGuide.target.envVarPlaceholder')}
literalSourceLabel={t('createGuide.target.envVarSource.literal')}
defaultSourceLabel={t('createGuide.target.envVarSource.default')}
lastDeploymentSourceLabel={t('createGuide.target.envVarSource.lastDeployment')}
valueTypeLabels={{
string: t('createGuide.target.envVarType.string'),
number: t('createGuide.target.envVarType.number'),
secret: t('createGuide.target.envVarType.secret'),
}}
sourceAriaLabel={key => t('createGuide.target.envVarSource.ariaLabel', { key })}
envVarCountLabel={t('createGuide.target.envVarCount', { count: envVarSlots.length })}
onChange={setEnvVar}
listScrollable={false}
className="border-components-option-card-option-border bg-components-option-card-option-bg"
/>
)
}
function TargetEnvironmentSkeleton() {
return (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{targetEnvironmentSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-17 rounded-xl border border-divider-subtle px-3 py-3">
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-full" />
<div className="flex min-w-0 grow flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3.5 w-1/2 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-2/3 animate-pulse" />
</div>
</SkeletonRow>
))}
</div>
)
}
function TargetBindingSkeleton() {
return (
<div className="border-t border-divider-subtle">
{targetBindingSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-15 px-3 py-3">
<div className="flex min-w-0 grow flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3.5 w-1/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-1/2 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-8 w-48 animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
)
}
export function TargetBackButton() {
const { t } = useTranslation('deployments')
const setStep = useSetAtom(stepAtom)
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
return (
<Button type="button" variant="secondary" onClick={() => setStep('release')} disabled={isSubmitting}>
{t('createGuide.actions.back')}
</Button>
)
}
export function TargetSkipDeploymentButton() {
const { t } = useTranslation('deployments')
const router = useRouter()
const canSkipDeployment = useAtomValue(canSkipDeploymentAtom)
const submitCreateDeploymentGuide = useSetAtom(createDeploymentGuideSubmissionAtom)
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
const isSkippingDeployment = useAtomValue(isCreatingReleaseOnlyAtom)
const label = isSkippingDeployment
? t('createGuide.actions.creating')
: t('createGuide.actions.skipDeploy')
async function handleSkipDeployment() {
if (!canSkipDeployment)
return
try {
const appInstanceId = await submitCreateDeploymentGuide({ deployToEnvironment: false })
if (appInstanceId)
router.push(`/deployments/${appInstanceId}/overview`)
}
catch (error) {
await showSubmissionError({
error,
fallbackMessage: t('createGuide.errors.createReleaseFailed'),
unsupportedDslModeMessage: t('createGuide.dsl.unsupportedMode'),
})
}
}
return (
<Button type="button" variant="secondary" disabled={!canSkipDeployment || isSubmitting} onClick={handleSkipDeployment}>
{label}
</Button>
)
}
export function TargetDeployButton() {
const { t } = useTranslation('deployments')
const router = useRouter()
const canDeploy = useAtomValue(canDeployAtom)
const submitCreateDeploymentGuide = useSetAtom(createDeploymentGuideSubmissionAtom)
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
const isSkippingDeployment = useAtomValue(isCreatingReleaseOnlyAtom)
const label = isSubmitting && !isSkippingDeployment
? t('createGuide.actions.deploying')
: t('createGuide.actions.createAndDeploy')
async function handleDeploy() {
if (!canDeploy)
return
try {
const appInstanceId = await submitCreateDeploymentGuide({ deployToEnvironment: true })
if (appInstanceId)
router.push(`/deployments/${appInstanceId}/overview`)
}
catch (error) {
await showSubmissionError({
error,
fallbackMessage: t('createGuide.errors.deployFailed'),
unsupportedDslModeMessage: t('createGuide.dsl.unsupportedMode'),
})
}
}
return (
<Button type="button" variant="primary" disabled={!canDeploy || isSubmitting} onClick={handleDeploy}>
{label}
</Button>
)
}
async function showSubmissionError({
error,
fallbackMessage,
unsupportedDslModeMessage,
}: {
error: unknown
fallbackMessage: string
unsupportedDslModeMessage: string
}) {
if (error instanceof CreateDeploymentGuideSubmissionBlockedError) {
toast.error(error.reason === 'unsupportedDslMode' ? unsupportedDslModeMessage : fallbackMessage)
return
}
toast.error(await deploymentErrorMessage(error) || fallbackMessage)
}

View File

@ -1,65 +0,0 @@
'use client'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
RuntimeCredentialBindingsPanel,
} from '@/features/deployments/components/runtime-credential-bindings'
import {
deploymentOptionsQueryAtom,
deploymentTargetBindingSelectionsAtom,
deploymentTargetBindingSlotsAtom,
selectBindingAtom,
unsupportedDslNodesAtom,
} from '@/features/deployments/create-guide/state'
import { TargetBindingSkeleton } from './skeletons'
export function TargetBindingSection() {
const { t } = useTranslation('deployments')
const deploymentOptionsQuery = useAtomValue(deploymentOptionsQueryAtom)
const bindingSlots = useAtomValue(deploymentTargetBindingSlotsAtom)
const bindingSelections = useAtomValue(deploymentTargetBindingSelectionsAtom)
const isBindingError = deploymentOptionsQuery.isError
const isBindingLoading = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
const selectBinding = useSetAtom(selectBindingAtom)
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
const shouldRender = !(isBindingError && unsupportedDslNodes.length > 0)
if (!shouldRender)
return null
if (isBindingLoading || isBindingError) {
return (
<div className="overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg">
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('createGuide.target.bindings')}</div>
<span className="system-xs-regular text-text-tertiary">{t('createGuide.target.bindingHint')}</span>
</div>
{isBindingLoading
? <TargetBindingSkeleton />
: (
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{t('createGuide.target.loadBindingsFailed')}
</div>
)}
</div>
)
}
return (
<RuntimeCredentialBindingsPanel
slots={bindingSlots}
selections={bindingSelections}
title={t('createGuide.target.bindings')}
hint={t('createGuide.target.bindingHint')}
noBindingRequiredLabel={t('createGuide.target.noBindingRequired')}
noCredentialCandidatesLabel={t('createGuide.target.noCredentialCandidates')}
selectCredentialLabel={t('createGuide.target.selectCredential')}
missingRequiredLabel={t('createGuide.target.missingRequiredBinding')}
bindingCountLabel={t('createGuide.target.bindingCount', { count: bindingSlots.length })}
onChange={selectBinding}
listScrollable={false}
className="border-components-option-card-option-border bg-components-option-card-option-bg"
/>
)
}

View File

@ -1,49 +0,0 @@
'use client'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
EnvVarBindingsPanel,
} from '@/features/deployments/components/env-var-bindings'
import {
deploymentOptionsQueryAtom,
deploymentTargetEnvVarSlotsAtom,
envVarValuesAtom,
setEnvVarAtom,
} from '@/features/deployments/create-guide/state'
export function TargetEnvVarSection() {
const { t } = useTranslation('deployments')
const setEnvVar = useSetAtom(setEnvVarAtom)
const envVarValues = useAtomValue(envVarValuesAtom)
const deploymentOptionsQuery = useAtomValue(deploymentOptionsQueryAtom)
const envVarSlots = useAtomValue(deploymentTargetEnvVarSlotsAtom)
const isBindingError = deploymentOptionsQuery.isError
const isBindingLoading = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
if (isBindingLoading || isBindingError)
return null
return (
<EnvVarBindingsPanel
slots={envVarSlots}
values={envVarValues}
title={t('createGuide.target.envVars')}
hint={t('createGuide.target.envVarHint')}
envVarPlaceholder={t('createGuide.target.envVarPlaceholder')}
literalSourceLabel={t('createGuide.target.envVarSource.literal')}
defaultSourceLabel={t('createGuide.target.envVarSource.default')}
lastDeploymentSourceLabel={t('createGuide.target.envVarSource.lastDeployment')}
valueTypeLabels={{
string: t('createGuide.target.envVarType.string'),
number: t('createGuide.target.envVarType.number'),
secret: t('createGuide.target.envVarType.secret'),
}}
sourceAriaLabel={key => t('createGuide.target.envVarSource.ariaLabel', { key })}
envVarCountLabel={t('createGuide.target.envVarCount', { count: envVarSlots.length })}
onChange={setEnvVar}
listScrollable={false}
className="border-components-option-card-option-border bg-components-option-card-option-bg"
/>
)
}

View File

@ -1,87 +0,0 @@
'use client'
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { TitleTooltip } from '@/features/deployments/components/title-tooltip'
import {
deployableEnvironmentsAtom,
deployableEnvironmentsQueryAtom,
effectiveSelectedEnvironmentIdAtom,
selectedEnvironmentIdAtom,
} from '@/features/deployments/create-guide/state'
import { TargetEnvironmentSkeleton } from './skeletons'
function EnvironmentOptionRow({ environment }: {
environment: Environment
}) {
const { t } = useTranslation('deployments')
const summary = environment.description.trim() || `${t(`mode.${environment.mode}`)} · ${t(`backend.${environment.backend}`)}`
return (
<RadioRoot<string>
value={environment.id}
variant="unstyled"
className={cn(
'group flex cursor-pointer items-center gap-3 rounded-xl border p-3 outline-hidden',
'border-components-option-card-option-border bg-components-option-card-option-bg hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-checked:border-state-accent-solid data-checked:bg-state-accent-hover data-checked:shadow-xs',
)}
>
<RadioControl />
<span className="flex min-w-0 grow flex-col gap-1">
<span className="truncate system-sm-semibold text-text-primary group-data-checked:text-text-accent">{environment.displayName}</span>
<TitleTooltip content={summary}>
<span className="line-clamp-1 system-xs-regular text-text-tertiary group-data-checked:text-text-secondary">
{summary}
</span>
</TitleTooltip>
</span>
</RadioRoot>
)
}
export function TargetEnvironmentSection() {
const { t } = useTranslation('deployments')
const environmentsQuery = useAtomValue(deployableEnvironmentsQueryAtom)
const environments = useAtomValue(deployableEnvironmentsAtom)
const effectiveSelectedEnvironmentId = useAtomValue(effectiveSelectedEnvironmentIdAtom)
const isEnvironmentError = environmentsQuery.isError
const isEnvironmentLoading = environmentsQuery.isLoading || (environmentsQuery.isFetching && !environmentsQuery.data)
const selectEnvironment = useSetAtom(selectedEnvironmentIdAtom)
const hasEnvironmentOptions = environments.length > 0
return (
<div className="flex flex-col gap-3">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('createGuide.target.environment')}</div>
{hasEnvironmentOptions
? (
<RadioGroup<string>
value={effectiveSelectedEnvironmentId}
onValueChange={selectEnvironment}
className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-2"
>
{environments.map(environment => (
<EnvironmentOptionRow
key={environment.id}
environment={environment}
/>
))}
</RadioGroup>
)
: isEnvironmentLoading
? <TargetEnvironmentSkeleton />
: (
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{isEnvironmentError
? t('createGuide.target.loadEnvironmentsFailed')
: t('createGuide.target.noEnvironmentOptions')}
</div>
)}
</div>
)
}

View File

@ -1,144 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { UnsupportedDslNodesAlert } from '@/features/deployments/components/unsupported-dsl-nodes-alert'
import {
canDeployAtom,
canSkipDeploymentAtom,
createDeploymentGuideSubmissionAtom,
CreateDeploymentGuideSubmissionBlockedError,
isCreatingReleaseOnlyAtom,
isSubmittingDeploymentGuideAtom,
stepAtom,
unsupportedDslNodesAtom,
} from '@/features/deployments/create-guide/state'
import { deploymentErrorMessage } from '@/features/deployments/error'
import { useRouter } from '@/next/navigation'
import { StepShell } from '../layout'
import { TargetBindingSection } from './binding-section'
import { TargetEnvVarSection } from './env-var-section'
import { TargetEnvironmentSection } from './environment-section'
export function TargetStepContent() {
const { t } = useTranslation('deployments')
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
return (
<StepShell
title={t('createGuide.target.title')}
description={t('createGuide.target.description')}
hideHeader
>
<div className="flex flex-col gap-6">
<TargetEnvironmentSection />
<UnsupportedDslNodesAlert nodes={unsupportedDslNodes} />
<TargetBindingSection />
<TargetEnvVarSection />
</div>
</StepShell>
)
}
export function TargetBackButton() {
const { t } = useTranslation('deployments')
const setStep = useSetAtom(stepAtom)
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
return (
<Button type="button" variant="secondary" onClick={() => setStep('release')} disabled={isSubmitting}>
{t('createGuide.actions.back')}
</Button>
)
}
export function TargetSkipDeploymentButton() {
const { t } = useTranslation('deployments')
const router = useRouter()
const canSkipDeployment = useAtomValue(canSkipDeploymentAtom)
const submitCreateDeploymentGuide = useSetAtom(createDeploymentGuideSubmissionAtom)
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
const isSkippingDeployment = useAtomValue(isCreatingReleaseOnlyAtom)
const label = isSkippingDeployment
? t('createGuide.actions.creating')
: t('createGuide.actions.skipDeploy')
async function handleSkipDeployment() {
if (!canSkipDeployment)
return
try {
const appInstanceId = await submitCreateDeploymentGuide({ deployToEnvironment: false })
if (appInstanceId)
router.push(`/deployments/${appInstanceId}/overview`)
}
catch (error) {
await showSubmissionError({
error,
fallbackMessage: t('createGuide.errors.createReleaseFailed'),
unsupportedDslModeMessage: t('createGuide.dsl.unsupportedMode'),
})
}
}
return (
<Button type="button" variant="secondary" disabled={!canSkipDeployment || isSubmitting} onClick={handleSkipDeployment}>
{label}
</Button>
)
}
export function TargetDeployButton() {
const { t } = useTranslation('deployments')
const router = useRouter()
const canDeploy = useAtomValue(canDeployAtom)
const submitCreateDeploymentGuide = useSetAtom(createDeploymentGuideSubmissionAtom)
const isSubmitting = useAtomValue(isSubmittingDeploymentGuideAtom)
const isSkippingDeployment = useAtomValue(isCreatingReleaseOnlyAtom)
const label = isSubmitting && !isSkippingDeployment
? t('createGuide.actions.deploying')
: t('createGuide.actions.createAndDeploy')
async function handleDeploy() {
if (!canDeploy)
return
try {
const appInstanceId = await submitCreateDeploymentGuide({ deployToEnvironment: true })
if (appInstanceId)
router.push(`/deployments/${appInstanceId}/overview`)
}
catch (error) {
await showSubmissionError({
error,
fallbackMessage: t('createGuide.errors.deployFailed'),
unsupportedDslModeMessage: t('createGuide.dsl.unsupportedMode'),
})
}
}
return (
<Button type="button" variant="primary" disabled={!canDeploy || isSubmitting} onClick={handleDeploy}>
{label}
</Button>
)
}
async function showSubmissionError({
error,
fallbackMessage,
unsupportedDslModeMessage,
}: {
error: unknown
fallbackMessage: string
unsupportedDslModeMessage: string
}) {
if (error instanceof CreateDeploymentGuideSubmissionBlockedError) {
toast.error(error.reason === 'unsupportedDslMode' ? unsupportedDslModeMessage : fallbackMessage)
return
}
toast.error(await deploymentErrorMessage(error) || fallbackMessage)
}

View File

@ -1,38 +0,0 @@
'use client'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
const targetEnvironmentSkeletonKeys = ['first-target-environment', 'second-target-environment']
const targetBindingSkeletonKeys = ['first-target-binding', 'second-target-binding']
export function TargetEnvironmentSkeleton() {
return (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{targetEnvironmentSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-17 rounded-xl border border-divider-subtle px-3 py-3">
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-full" />
<div className="flex min-w-0 grow flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3.5 w-1/2 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-2/3 animate-pulse" />
</div>
</SkeletonRow>
))}
</div>
)
}
export function TargetBindingSkeleton() {
return (
<div className="border-t border-divider-subtle">
{targetBindingSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-15 px-3 py-3">
<div className="flex min-w-0 grow flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3.5 w-1/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-1/2 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-8 w-48 animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
)
}

View File

@ -9,8 +9,8 @@ import {
createReleaseConfigAtom,
createReleaseLocalAtoms,
openCreateReleaseDialogAtom,
} from './atoms'
import { CreateReleaseDialog } from './dialog'
} from './state'
import { CreateReleaseDialog } from './ui/dialog'
function CreateReleaseTrigger({
variant,

View File

@ -1,9 +1,9 @@
'use client'
import type { UnsupportedDslNode } from '../../error'
import type { UnsupportedDslNode } from '../../shared/domain/error'
import type { CreateReleaseForm } from './use-create-release-form'
import { atom, useAtomValue } from 'jotai'
import { encodeDslContent, isWorkflowDsl } from '../../dsl'
import { encodeDslContent, isWorkflowDsl } from '../../shared/domain/dsl'
type CreateReleaseConfig = {
appInstanceId: string

View File

@ -1,4 +1,4 @@
import type { SourceAppPickerValue } from './source-app-picker-value'
import type { SourceAppPickerValue } from '../ui/source-app-picker-value'
export type ReleaseSourceMode = 'sourceApp' | 'dsl'

View File

@ -1,13 +1,13 @@
'use client'
import type { CreateReleaseFormValues } from './types'
import type { CreateReleaseFormValues } from '../state/types'
import { Button } from '@langgenius/dify-ui/button'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
closeCreateReleaseDialogAtom,
useCreateReleaseFormApi,
} from './atoms'
} from '../state'
import {
createReleaseReadiness,
useCreateReleaseSourceSelection,

View File

@ -1,13 +1,13 @@
'use client'
import type { CreateReleaseFormValues } from './types'
import type { CreateReleaseFormValues } from '../state/types'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { UnsupportedDslNodesAlert } from '../unsupported-dsl-nodes-alert'
import { UnsupportedDslNodesAlert } from '../../components/unsupported-dsl-nodes-alert'
import {
createReleaseSubmitUnsupportedDslNodesAtom,
useCreateReleaseFormApi,
} from './atoms'
} from '../state'
import {
useCreateReleaseSourceSelection,
useReleaseContentCheck,

View File

@ -1,6 +1,6 @@
'use client'
import type { CreateReleaseFormValues } from './types'
import type { CreateReleaseFormValues } from '../state/types'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
@ -8,7 +8,6 @@ import { ScopeProvider } from 'jotai-scope'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { CreateReleaseActions } from './actions'
import {
closeCreateReleaseDialogAtom,
createReleaseDialogOpenAtom,
@ -16,12 +15,13 @@ import {
openCreateReleaseDialogAtom,
useCreateReleaseConfig,
useCreateReleaseFormApi,
} from './atoms'
} from '../state'
import { useCreateReleaseForm } from '../state/use-create-release-form'
import { CreateReleaseActions } from './actions'
import { ReleaseContentFeedback } from './content-feedback'
import { ReleaseMetadataFields } from './metadata-fields'
import { workflowSourceAppPickerValue } from './source-app-picker-value'
import { ReleaseSourceSection } from './source-section'
import { useCreateReleaseForm } from './use-create-release-form'
import { useCreateReleaseSubmission } from './use-create-release-submission'
const DEFAULT_SOURCE_RELEASE_PAGE_SIZE = 1

View File

@ -3,12 +3,13 @@
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useCreateReleaseFormApi } from './atoms'
import { useCreateReleaseFormApi } from '../state'
import {
RELEASE_NAME_REQUIRED_ERROR,
validateReleaseName,
} from './use-create-release-form'
} from '../state/use-create-release-form'
const DESCRIPTION_MAX_LENGTH = 512
const DESCRIPTION_WARN_THRESHOLD = 460
@ -20,6 +21,11 @@ function hasReleaseNameRequiredError(errors: unknown[]) {
export function ReleaseMetadataFields() {
const { t } = useTranslation('deployments')
const form = useCreateReleaseFormApi()
const releaseNameInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
releaseNameInputRef.current?.focus()
}, [])
return (
<>
@ -37,6 +43,7 @@ export function ReleaseMetadataFields() {
{t('versions.releaseNameLabel')}
</label>
<Input
ref={releaseNameInputRef}
id="release-name"
name="releaseName"
placeholder={t('versions.releaseNamePlaceholder')}
@ -47,7 +54,6 @@ export function ReleaseMetadataFields() {
aria-describedby={hasReleaseNameRequiredError(field.state.meta.errors) ? 'release-name-error' : undefined}
onBlur={field.handleBlur}
onChange={event => field.handleChange(event.target.value)}
autoFocus
className="h-9"
/>
{hasReleaseNameRequiredError(field.state.meta.errors) && (

View File

@ -1,5 +1,5 @@
import type { App } from '@/types/app'
import { isWorkflowAppMode } from '../../app-mode'
import { isWorkflowAppMode } from './source-app-mode'
export type SourceAppPickerValue = Pick<App, 'id' | 'name'> & Partial<Pick<App, 'icon_type' | 'icon' | 'icon_background' | 'icon_url' | 'mode'>>

View File

@ -21,8 +21,8 @@ import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { isWorkflowApp } from '../../app-mode'
import { TitleTooltip } from '../title-tooltip'
import { TitleTooltip } from '../../components/title-tooltip'
import { isWorkflowApp } from './source-app-mode'
const SOURCE_APP_PAGE_SIZE = 20
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']

View File

@ -1,6 +1,6 @@
'use client'
import type { ReleaseSourceMode } from './types'
import type { ReleaseSourceMode } from '../state/types'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
@ -11,7 +11,7 @@ import {
resetCreateReleaseDslFileAtom,
selectCreateReleaseDslFileAtom,
useCreateReleaseFormApi,
} from './atoms'
} from '../state'
import { SourceAppPicker } from './source-app-picker'
function selectedReleaseSourceMode(value: readonly ReleaseSourceMode[] | undefined) {

View File

@ -1,19 +1,19 @@
'use client'
import type { CreateReleaseResponse } from '@dify/contracts/enterprise/types.gen'
import type { CreateReleaseFormValues } from './types'
import type { CreateReleaseFormValues } from '../state/types'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentErrorMessage, unsupportedDslNodeError } from '../../error'
import { deploymentErrorMessage, unsupportedDslNodeError } from '../../shared/domain/error'
import {
clearCreateReleaseSubmissionErrorAtom,
closeCreateReleaseDialogAtom,
createReleaseSubmitUnsupportedDslNodesAtom,
useCreateReleaseConfig,
} from './atoms'
} from '../state'
import {
canCheckReleaseSourceContent,
useCreateReleaseSourceSelection,

View File

@ -1,7 +1,7 @@
'use client'
import type { CreateReleaseDslState } from './atoms'
import type { CreateReleaseFormValues, ReleaseSourceMode } from './types'
import type { CreateReleaseDslState } from '../state'
import type { CreateReleaseFormValues, ReleaseSourceMode } from '../state/types'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { consoleQuery } from '@/service/client'
@ -9,7 +9,7 @@ import {
createReleaseDialogOpenAtom,
createReleaseDslStateAtom,
useCreateReleaseConfig,
} from './atoms'
} from '../state'
export type CreateReleaseSourceSelection = CreateReleaseDslState & {
hasUnsupportedDslMode: boolean

View File

@ -17,8 +17,8 @@ import {
deployDrawerEnvironmentIdAtom,
deployDrawerOpenAtom,
deployDrawerReleaseIdAtom,
} from '../store'
import { DeployForm } from './deploy-drawer/form'
} from './state'
import { DeployForm } from './ui/form'
export function DeployDrawer() {
const { t } = useTranslation('deployments')

View File

@ -0,0 +1,442 @@
'use client'
import type {
Environment,
EnvironmentDeployment,
EnvVarInput,
Release,
} from '@dify/contracts/enterprise/types.gen'
import type { Getter } from 'jotai'
import type {
EnvVarBindingSlot,
EnvVarValues,
EnvVarValueSelection,
} from '../../components/env-var-bindings'
import type { RuntimeCredentialBindingSelections } from '../../components/runtime-credential-bindings-utils'
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
import { toast } from '@langgenius/dify-ui/toast'
import { atom } from 'jotai'
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { envVarBindingSlotFromContract } from '../../components/env-var-bindings-utils'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialSlotKey,
selectedDeploymentRuntimeCredentials,
selectedRuntimeCredentialSelections,
} from '../../components/runtime-credential-bindings-utils'
import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency'
import { releaseDeploymentAction } from '../../shared/domain/release-action'
type OpenDeployDrawerParams = {
appInstanceId: string
environmentId?: string
releaseId?: string
}
export const deployDrawerOpenAtom = atom(false)
export const deployDrawerAppInstanceIdAtom = atom<string | undefined>(undefined)
export const deployDrawerEnvironmentIdAtom = atom<string | undefined>(undefined)
export const deployDrawerReleaseIdAtom = atom<string | undefined>(undefined)
export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => {
set(deployDrawerAppInstanceIdAtom, params.appInstanceId)
set(deployDrawerEnvironmentIdAtom, params.environmentId)
set(deployDrawerReleaseIdAtom, params.releaseId)
set(deployDrawerOpenAtom, true)
})
export const closeDeployDrawerAtom = atom(null, (_get, set) => {
set(deployDrawerOpenAtom, false)
set(deployDrawerAppInstanceIdAtom, undefined)
set(deployDrawerEnvironmentIdAtom, undefined)
set(deployDrawerReleaseIdAtom, undefined)
})
export type DeployReadyFormConfig = {
appInstanceId: string
environments: Environment[]
releases: Release[]
runtimeRows: EnvironmentDeployment[]
defaultReleaseId?: string
lockedEnvId?: string
presetReleaseId?: string
releaseEmptyLabel?: string
}
export const deployReadyFormConfigAtom = atom<DeployReadyFormConfig | undefined>(undefined)
const selectedEnvIdAtom = atom<string | undefined>(undefined)
const selectedReleaseIdAtom = atom<string | undefined>(undefined)
const manualBindingsAtom = atom<RuntimeCredentialBindingSelections>({})
export const deployEnvVarValuesAtom = atom<EnvVarValues>({})
const showValidationErrorsAtom = atom(false)
export const deployReadyFormLocalAtoms = [
selectedEnvIdAtom,
selectedReleaseIdAtom,
manualBindingsAtom,
deployEnvVarValuesAtom,
showValidationErrorsAtom,
] as const
function formConfig(get: Getter) {
const config = get(deployReadyFormConfigAtom)
if (!config)
throw new Error('Missing deploy ready form config.')
return config
}
const resetDeployBindingsAtom = atom(null, (_get, set) => {
set(manualBindingsAtom, {})
set(deployEnvVarValuesAtom, {})
set(showValidationErrorsAtom, false)
})
export const selectDeployEnvironmentAtom = atom(null, (_get, set, environmentId: string) => {
set(selectedEnvIdAtom, environmentId)
set(resetDeployBindingsAtom)
})
export const selectDeployReleaseAtom = atom(null, (_get, set, releaseId: string) => {
set(selectedReleaseIdAtom, releaseId)
set(resetDeployBindingsAtom)
})
export const showDeployValidationErrorsAtom = atom(null, (_get, set) => {
set(showValidationErrorsAtom, true)
})
export const deployShowValidationErrorsAtom = atom((get) => {
return get(showValidationErrorsAtom)
})
const deployPresetReleaseAtom = atom((get) => {
const config = formConfig(get)
return config.presetReleaseId ? config.releases.find(r => r.id === config.presetReleaseId) : undefined
})
export const deployDisplayedReleaseAtom = atom((get): Release | undefined => {
return get(deployPresetReleaseAtom)
})
export const deployIsExistingReleaseAtom = atom((get) => {
const config = formConfig(get)
return Boolean(config.presetReleaseId)
})
export const deployReleaseRowsAtom = atom((get) => {
return formConfig(get).releases
})
export const deployReleaseEmptyLabelAtom = atom((get) => {
return formConfig(get).releaseEmptyLabel
})
export const deployEnvironmentRowsAtom = atom((get) => {
return formConfig(get).environments
})
export const deployLockedEnvironmentIdAtom = atom((get) => {
return formConfig(get).lockedEnvId
})
export const deploySelectedEnvironmentIdAtom = atom((get) => {
const config = formConfig(get)
return get(selectedEnvIdAtom) ?? config.lockedEnvId ?? config.environments[0]?.id
})
const deploySelectedEnvironmentAtom = atom((get) => {
const config = formConfig(get)
const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom)
return selectedEnvironmentId
? config.environments.find(env => env.id === selectedEnvironmentId)
: undefined
})
export const deploySelectedReleaseIdAtom = atom((get) => {
const config = formConfig(get)
const displayedRelease = get(deployDisplayedReleaseAtom)
return get(selectedReleaseIdAtom) ?? displayedRelease?.id ?? config.defaultReleaseId
})
const deploySelectedReleaseAtom = atom((get) => {
const config = formConfig(get)
const selectedReleaseId = get(deploySelectedReleaseIdAtom)
return selectedReleaseId
? config.releases.find(release => release.id === selectedReleaseId)
: undefined
})
const deployTargetReleaseAtom = atom((get) => {
return get(deployDisplayedReleaseAtom) ?? get(deploySelectedReleaseAtom)
})
export const deployLockedEnvironmentAtom = atom((get) => {
const config = formConfig(get)
return config.lockedEnvId
? config.environments.find(environment => environment.id === config.lockedEnvId)
: undefined
})
export const deployTargetReleaseIdAtom = atom((get) => {
const targetRelease = get(deployTargetReleaseAtom)
return targetRelease?.id ?? get(deploySelectedReleaseIdAtom)
})
export const deployHasSelectedEnvironmentAtom = atom((get) => {
return Boolean(get(deploySelectedEnvironmentAtom))
})
const releaseDeploymentOptionsQueryAtom = atomWithQuery((get) => {
const hasSelectedEnvironment = get(deployHasSelectedEnvironmentAtom)
const releaseId = get(deployTargetReleaseIdAtom)
const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom)
const enabled = Boolean(releaseId && selectedEnvironmentId && hasSelectedEnvironment)
return {
...consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
input: {
body: {
releaseId: releaseId ?? '',
environmentId: selectedEnvironmentId ?? '',
},
},
enabled,
}),
retry: false,
}
})
export const deployBindingSlotsAtom = atom((get) => {
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
return deploymentOptionsQuery.data?.options.credentialSlots.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
})
export const deployEnvVarSlotsAtom = atom((get): EnvVarBindingSlot[] => {
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
return deploymentOptionsQuery.data?.options.envVarSlots.flatMap((slot): EnvVarBindingSlot[] => {
const bindingSlot = envVarBindingSlotFromContract(slot)
return bindingSlot ? [bindingSlot] : []
}) ?? []
})
export const deployIsBindingOptionsLoadingAtom = atom((get) => {
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
const releaseId = get(deployTargetReleaseIdAtom)
return Boolean(
releaseId
&& get(deployHasSelectedEnvironmentAtom)
&& (deploymentOptionsQuery.isLoading || deploymentOptionsQuery.isFetching),
)
})
export const deployHasBindingOptionsErrorAtom = atom((get) => {
return get(releaseDeploymentOptionsQueryAtom).isError
})
const deployIsBindingOptionsReadyAtom = atom((get) => {
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
const releaseId = get(deployTargetReleaseIdAtom)
return Boolean(
releaseId
&& get(deployHasSelectedEnvironmentAtom)
&& deploymentOptionsQuery.data
&& !get(deployIsBindingOptionsLoadingAtom)
&& !get(deployHasBindingOptionsErrorAtom),
)
})
function deployEnvVarValueSource(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
return selection?.valueSource
?? (slot.hasLastValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT
: slot.hasDefaultValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT
: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL)
}
function deployEnvVarInput(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined): EnvVarInput[] {
const valueSource = deployEnvVarValueSource(slot, selection)
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT) {
return slot.hasLastValue
? [{ key: slot.key, valueSource }]
: []
}
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT) {
return slot.hasDefaultValue
? [{ key: slot.key, valueSource }]
: []
}
if (!selection?.value || (slot.valueType === 'number' && Number.isNaN(Number(selection.value))))
return []
return [{
key: slot.key,
value: selection.value,
valueSource,
}]
}
function deployEnvVarSelectionReady(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
const valueSource = deployEnvVarValueSource(slot, selection)
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT)
return Boolean(slot.hasLastValue)
if (valueSource === ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT)
return Boolean(slot.hasDefaultValue)
if (!selection?.value)
return false
return slot.valueType !== 'number' || !Number.isNaN(Number(selection.value))
}
export const deploySelectedBindingsAtom = atom((get) => {
return selectedRuntimeCredentialSelections(get(deployBindingSlotsAtom), get(manualBindingsAtom))
})
const deployDeploymentCredentialsAtom = atom((get) => {
return selectedDeploymentRuntimeCredentials(get(deployBindingSlotsAtom), get(deploySelectedBindingsAtom))
})
const deployDeploymentEnvVarsAtom = atom((get) => {
const envVarValues = get(deployEnvVarValuesAtom)
return get(deployEnvVarSlotsAtom).flatMap(slot => deployEnvVarInput(slot, envVarValues[slot.key]))
})
const deployRequiredBindingsReadyAtom = atom((get) => {
const selectedBindings = get(deploySelectedBindingsAtom)
return get(deployBindingSlotsAtom).every(slot =>
!hasMissingRequiredRuntimeCredentialBinding(slot, selectedBindings[runtimeCredentialSlotKey(slot)]),
)
})
const deployRequiredEnvVarsReadyAtom = atom((get) => {
const envVarValues = get(deployEnvVarValuesAtom)
return get(deployEnvVarSlotsAtom).every(slot =>
deployEnvVarSelectionReady(slot, envVarValues[slot.key]),
)
})
export const selectDeployBindingAtom = atom(null, (get, set, slot: string, value: string) => {
set(manualBindingsAtom, {
...get(manualBindingsAtom),
[slot]: value,
})
})
export const setDeployEnvVarAtom = atom(null, (get, set, key: string, value: EnvVarValueSelection) => {
set(deployEnvVarValuesAtom, {
...get(deployEnvVarValuesAtom),
[key]: value,
})
})
const promoteReleaseMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.deploymentService.promote.mutationOptions(),
)
const rollbackReleaseMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.deploymentService.rollback.mutationOptions(),
)
export const isDeployReleaseSubmittingAtom = atom((get) => {
return get(promoteReleaseMutationAtom).isPending || get(rollbackReleaseMutationAtom).isPending
})
export const canAttemptDeployAtom = atom((get) => {
return Boolean(
get(deploySelectedEnvironmentIdAtom)
&& get(deploySelectedEnvironmentAtom)
&& get(deployTargetReleaseIdAtom)
&& get(deployIsBindingOptionsReadyAtom)
&& !get(isDeployReleaseSubmittingAtom),
)
})
export const canSubmitDeployAtom = atom((get) => {
return Boolean(
get(canAttemptDeployAtom)
&& get(deployRequiredBindingsReadyAtom)
&& get(deployRequiredEnvVarsReadyAtom),
)
})
export const deployReleaseSubmissionAtom = atom(null, (get, set, {
deployFailedMessage,
}: {
deployFailedMessage: string
}) => {
const config = formConfig(get)
const selectedEnvironmentId = get(deploySelectedEnvironmentIdAtom)
const targetRelease = get(deployTargetReleaseAtom)
const targetReleaseId = get(deployTargetReleaseIdAtom)
if (!targetReleaseId || !selectedEnvironmentId)
return
const idempotencyKey = createDeploymentIdempotencyKey()
const currentRelease = config.runtimeRows.find(row => row.environment.id === selectedEnvironmentId)?.currentRelease
const action = releaseDeploymentAction({
targetRelease,
currentRelease,
releaseRows: config.releases,
isExistingRelease: true,
})
const mutationOptions = {
onSuccess: () => {
set(closeDeployDrawerAtom)
},
onError: () => {
toast.error(deployFailedMessage)
},
}
if (action === 'rollback') {
get(rollbackReleaseMutationAtom).mutate(
{
params: {
appInstanceId: config.appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId: config.appInstanceId,
environmentId: selectedEnvironmentId,
targetReleaseId,
idempotencyKey,
},
},
mutationOptions,
)
return
}
const deploymentEnvVars = get(deployDeploymentEnvVarsAtom)
get(promoteReleaseMutationAtom).mutate(
{
params: {
appInstanceId: config.appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId: config.appInstanceId,
environmentId: selectedEnvironmentId,
releaseId: targetReleaseId,
credentials: get(deployDeploymentCredentialsAtom),
envVars: deploymentEnvVars.length > 0 ? deploymentEnvVars : undefined,
idempotencyKey,
},
},
mutationOptions,
)
})

View File

@ -1,22 +1,32 @@
'use client'
import type { CredentialSlot, Environment } from '@dify/contracts/enterprise/types.gen'
import type { RuntimeCredentialBindingSelections } from '../runtime-credential-bindings-utils'
import type { RuntimeCredentialBindingSelections } from '../../components/runtime-credential-bindings-utils'
import { DrawerDescription, DrawerTitle } from '@langgenius/dify-ui/drawer'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
import { formatDate, releaseCommit } from '../../release'
import { DeploymentStateMessage } from '../empty-state'
import { RuntimeCredentialBindingsPanel } from '../runtime-credential-bindings'
import { DeploymentStateMessage } from '../../components/empty-state'
import { RuntimeCredentialBindingsPanel } from '../../components/runtime-credential-bindings'
import { formatDate, releaseCommit } from '../../shared/domain/release'
import {
deployDisplayedReleaseAtom,
deployEnvironmentRowsAtom,
deployIsExistingReleaseAtom,
deployLockedEnvironmentAtom,
deployLockedEnvironmentIdAtom,
deployReleaseEmptyLabelAtom,
deployReleaseRowsAtom,
deploySelectedEnvironmentIdAtom,
deploySelectedReleaseIdAtom,
selectDeployEnvironmentAtom,
selectDeployReleaseAtom,
} from '../state'
import {
DeploymentSelect,
EnvironmentRow,
Field,
} from './select'
import {
useDeployEnvironmentField,
useDeployReleaseField,
} from './use-deploy-ready-form'
export const DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME = 'max-h-none overflow-visible'
@ -102,14 +112,12 @@ export function DeployFormHeader() {
export function ReleaseField() {
const { t } = useTranslation('deployments')
const {
displayedRelease,
emptyLabel,
isExistingRelease,
releases,
selectedReleaseId,
selectRelease,
} = useDeployReleaseField()
const displayedRelease = useAtomValue(deployDisplayedReleaseAtom)
const emptyLabel = useAtomValue(deployReleaseEmptyLabelAtom)
const isExistingRelease = useAtomValue(deployIsExistingReleaseAtom)
const releases = useAtomValue(deployReleaseRowsAtom)
const selectedReleaseId = useAtomValue(deploySelectedReleaseIdAtom)
const selectRelease = useSetAtom(selectDeployReleaseAtom)
return (
<Field label={t('deployDrawer.releaseLabel')}>
@ -152,13 +160,11 @@ export function ReleaseField() {
export function EnvironmentField() {
const { t } = useTranslation('deployments')
const {
environments,
lockedEnv,
lockedEnvId,
selectedEnvironmentId,
selectEnvironment,
} = useDeployEnvironmentField()
const environments = useAtomValue(deployEnvironmentRowsAtom)
const lockedEnv = useAtomValue(deployLockedEnvironmentAtom)
const lockedEnvId = useAtomValue(deployLockedEnvironmentIdAtom)
const selectedEnvironmentId = useAtomValue(deploySelectedEnvironmentIdAtom)
const selectEnvironment = useSetAtom(selectDeployEnvironmentAtom)
return (
<Field

View File

@ -11,9 +11,13 @@ import { useAtomValue, useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { isAvailableDeploymentTarget } from '../../runtime-status'
import { closeDeployDrawerAtom } from '../../store'
import { EnvVarBindingsPanel } from '../env-var-bindings'
import { EnvVarBindingsPanel } from '../../components/env-var-bindings'
import { isAvailableDeploymentTarget } from '../../shared/domain/runtime-status'
import { canAttemptDeployAtom, canSubmitDeployAtom, closeDeployDrawerAtom, deployBindingSlotsAtom, deployEnvVarSlotsAtom, deployEnvVarValuesAtom, deployHasBindingOptionsErrorAtom, deployHasSelectedEnvironmentAtom, deployIsBindingOptionsLoadingAtom, deployReadyFormConfigAtom, deployReadyFormLocalAtoms, deployReleaseSubmissionAtom, deploySelectedBindingsAtom, deployShowValidationErrorsAtom, deployTargetReleaseIdAtom, isDeployReleaseSubmittingAtom, selectDeployBindingAtom, setDeployEnvVarAtom, showDeployValidationErrorsAtom } from '../state'
import {
currentReleaseIdForEnvironment,
selectableDeployReleases,
} from '../state/release-options'
import {
BindingOptionsPanel,
DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME,
@ -22,22 +26,6 @@ import {
ReleaseField,
} from './form-sections'
import { DeployFormSkeleton } from './form-skeleton'
import {
currentReleaseIdForEnvironment,
selectableDeployReleases,
} from './release-options'
import {
deployHasSelectedEnvironmentAtom,
deployReadyFormConfigAtom,
deployReadyFormLocalAtoms,
deploySelectedEnvironmentAtom,
deploySelectedEnvironmentIdAtom,
deployTargetReleaseIdAtom,
showDeployValidationErrorsAtom,
useDeployBindings,
useDeployReleaseSubmission,
useReleaseDeploymentOptions,
} from './use-deploy-ready-form'
type DeployFormProps = {
appInstanceId: string
@ -55,40 +43,42 @@ type DeployReadyFormProps = DeployFormProps & {
function DeployRuntimeCredentialBindingsSection() {
const { t } = useTranslation('deployments')
const deploymentOptions = useReleaseDeploymentOptions()
const deploymentBindings = useDeployBindings({
bindingSlots: deploymentOptions.bindingSlots,
envVarSlots: deploymentOptions.envVarSlots,
})
const bindingSlots = useAtomValue(deployBindingSlotsAtom)
const selectedBindings = useAtomValue(deploySelectedBindingsAtom)
const isBindingOptionsLoading = useAtomValue(deployIsBindingOptionsLoadingAtom)
const hasBindingOptionsError = useAtomValue(deployHasBindingOptionsErrorAtom)
const showValidationErrors = useAtomValue(deployShowValidationErrorsAtom)
const selectBinding = useSetAtom(selectDeployBindingAtom)
return (
<BindingOptionsPanel
slots={deploymentOptions.bindingSlots}
selections={deploymentBindings.selectedBindings}
isLoading={deploymentOptions.isBindingOptionsLoading}
hasError={deploymentOptions.hasBindingOptionsError}
bindingCountLabel={t('deployDrawer.bindingCount', { count: deploymentOptions.bindingSlots.length })}
showMissingRequired={deploymentBindings.showValidationErrors}
onChange={deploymentBindings.handleBindingChange}
slots={bindingSlots}
selections={selectedBindings}
isLoading={isBindingOptionsLoading}
hasError={hasBindingOptionsError}
bindingCountLabel={t('deployDrawer.bindingCount', { count: bindingSlots.length })}
showMissingRequired={showValidationErrors}
onChange={selectBinding}
/>
)
}
function DeployEnvVarBindingsSection() {
const { t } = useTranslation('deployments')
const deploymentOptions = useReleaseDeploymentOptions()
const deploymentBindings = useDeployBindings({
bindingSlots: deploymentOptions.bindingSlots,
envVarSlots: deploymentOptions.envVarSlots,
})
const envVarSlots = useAtomValue(deployEnvVarSlotsAtom)
const envVarValues = useAtomValue(deployEnvVarValuesAtom)
const isBindingOptionsLoading = useAtomValue(deployIsBindingOptionsLoadingAtom)
const hasBindingOptionsError = useAtomValue(deployHasBindingOptionsErrorAtom)
const showValidationErrors = useAtomValue(deployShowValidationErrorsAtom)
const setDeployEnvVar = useSetAtom(setDeployEnvVarAtom)
if (deploymentOptions.isBindingOptionsLoading || deploymentOptions.hasBindingOptionsError)
if (isBindingOptionsLoading || hasBindingOptionsError)
return null
return (
<EnvVarBindingsPanel
slots={deploymentOptions.envVarSlots}
values={deploymentBindings.envVarValues}
slots={envVarSlots}
values={envVarValues}
title={t('deployDrawer.envVars')}
hint={t('deployDrawer.envVarHint')}
envVarPlaceholder={t('deployDrawer.envVarPlaceholder')}
@ -102,11 +92,11 @@ function DeployEnvVarBindingsSection() {
}}
sourceAriaLabel={key => t('deployDrawer.envVarSource.ariaLabel', { key })}
defaultSourcePriority="lastDeployment"
envVarCountLabel={t('deployDrawer.envVarCount', { count: deploymentOptions.envVarSlots.length })}
envVarCountLabel={t('deployDrawer.envVarCount', { count: envVarSlots.length })}
missingRequiredLabel={t('deployDrawer.missingRequiredEnvVar')}
listClassName={DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME}
showMissingRequired={deploymentBindings.showValidationErrors}
onChange={deploymentBindings.handleEnvVarChange}
showMissingRequired={showValidationErrors}
onChange={setDeployEnvVar}
/>
)
}
@ -141,32 +131,12 @@ function DeployFormBody() {
function DeployFooter() {
const { t } = useTranslation('deployments')
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const selectedEnvironmentId = useAtomValue(deploySelectedEnvironmentIdAtom)
const selectedEnvironment = useAtomValue(deploySelectedEnvironmentAtom)
const targetReleaseId = useAtomValue(deployTargetReleaseIdAtom)
const showValidationErrors = useSetAtom(showDeployValidationErrorsAtom)
const deploymentOptions = useReleaseDeploymentOptions()
const deploymentBindings = useDeployBindings({
bindingSlots: deploymentOptions.bindingSlots,
envVarSlots: deploymentOptions.envVarSlots,
})
const submission = useDeployReleaseSubmission({
deploymentCredentials: deploymentBindings.deploymentCredentials,
deploymentEnvVars: deploymentBindings.deploymentEnvVars,
})
const canAttemptDeploy = Boolean(
selectedEnvironmentId
&& selectedEnvironment
&& targetReleaseId
&& deploymentOptions.isBindingOptionsReady
&& !submission.isSubmitting,
)
const canDeploy = Boolean(
canAttemptDeploy
&& deploymentBindings.requiredBindingsReady
&& deploymentBindings.requiredEnvVarsReady,
)
const submitLabel = submission.isSubmitting ? t('deployDrawer.deploying') : t('deployDrawer.deploy')
const submitDeployRelease = useSetAtom(deployReleaseSubmissionAtom)
const canAttemptDeploy = useAtomValue(canAttemptDeployAtom)
const canDeploy = useAtomValue(canSubmitDeployAtom)
const isSubmitting = useAtomValue(isDeployReleaseSubmittingAtom)
const submitLabel = isSubmitting ? t('deployDrawer.deploying') : t('deployDrawer.deploy')
function handleDeploy() {
showValidationErrors()
@ -174,7 +144,9 @@ function DeployFooter() {
if (!canDeploy)
return
submission.deployRelease()
submitDeployRelease({
deployFailedMessage: t('deployDrawer.deployFailed'),
})
}
return (

View File

@ -6,8 +6,8 @@ import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { ModeBadge } from '../status-badge'
import { TitleTooltip } from '../title-tooltip'
import { TitleTooltip } from '../../components/title-tooltip'
import { ModeBadge } from './status-badge'
export function Field({ label, hint, children }: {
label: string

View File

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentStateMessage } from '../components/empty-state'
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../runtime-status'
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../shared/domain/runtime-status'
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
import { NewDeploymentButton } from './deploy-tab/new-deployment-button'
import {

View File

@ -1,40 +0,0 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { DeploymentStatusSummary } from '../deployment-status-summary'
describe('DeploymentStatusSummary', () => {
// Runtime status should dominate empty release/deployment fields.
describe('Status rendering', () => {
it('should render undeploying instead of unknown when runtime is undeploying', () => {
const row = {
appInstanceId: 'app-instance',
environment: {
id: 'env-undeploying',
displayName: 'Test CPU',
description: '',
mode: 'ENVIRONMENT_MODE_SHARED',
backend: 'RUNTIME_BACKEND_K8S',
namespace: '',
apiServer: '',
status: 'ENVIRONMENT_STATUS_READY',
statusMessage: '',
managedBy: '',
createdAt: '',
updatedAt: '',
runtimeEndpoint: '',
cpuCount: 1,
},
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING,
updatedAt: '',
} satisfies EnvironmentDeployment
render(<DeploymentStatusSummary row={row} />)
expect(screen.getByText('deployments.status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING')).toBeInTheDocument()
expect(screen.queryByText('deployments.status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED')).not.toBeInTheDocument()
expect(screen.queryByText('deployments.status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED')).not.toBeInTheDocument()
})
})
})

View File

@ -2,8 +2,8 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import { useTranslation } from 'react-i18next'
import { releaseCommit } from '../../release'
import { isUndeployedDeploymentRow } from '../../runtime-status'
import { releaseCommit } from '../../shared/domain/release'
import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
import {
DetailTable,
DetailTableBody,

View File

@ -7,9 +7,9 @@ import { useSetAtom } from 'jotai'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { createDeploymentIdempotencyKey } from '../../idempotency'
import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency'
import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
import { DeploymentErrorDialog } from './deployment-error-dialog'
import { DeploymentActionsDropdown } from './deployment-row-actions-menu'
import { UndeployDeploymentDialog } from './undeploy-deployment-dialog'
@ -68,6 +68,7 @@ export function DeploymentRowActions({ appInstanceId, envId, row }: {
return (
<div
role="presentation"
className="flex shrink-0 items-center"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}

View File

@ -3,10 +3,10 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { useTranslation } from 'react-i18next'
import { DeploymentStatusBadge } from '../../deployment-ui'
import {
isUndeployedDeploymentRow,
} from '../../runtime-status'
} from '../../shared/domain/runtime-status'
import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge'
export function DeploymentStatusSummary({ row }: {
row: EnvironmentDeployment

View File

@ -5,8 +5,8 @@ import { useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
export function NewDeploymentButton({ appInstanceId }: {
appInstanceId: string

View File

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import useDocumentTitle from '@/hooks/use-document-title'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { CreateReleaseControl } from '../components/create-release/control'
import { CreateReleaseControl } from '../create-release'
import { NewDeploymentHeaderAction } from './deploy-tab/new-deployment-button'
import { DeploymentSidebar } from './deployment-sidebar'
import { DeveloperApiHeaderActions, DeveloperApiHeaderSwitch } from './settings-tab/access/developer-api-section'

View File

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { DeploymentStateMessage } from '../components/empty-state'
import { hasRuntimeInstanceDeployment } from '../runtime-status'
import { hasRuntimeInstanceDeployment } from '../shared/domain/runtime-status'
import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './overview-tab/access-status-section'
import { EnvironmentStrip, EnvironmentStripSkeleton } from './overview-tab/environment-strip'
import { ReleaseHero, ReleaseHeroSkeleton } from './overview-tab/release-hero'

View File

@ -6,7 +6,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import Link from '@/next/link'
import { DeploymentStatusBadge } from '../../deployment-ui'
import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge'
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME } from './card-styles'
type AccessStatusSectionProps = {

View File

@ -8,8 +8,8 @@ import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import Link from '@/next/link'
import { DeploymentEmptyState } from '../../components/empty-state'
import { hasRuntimeInstanceDeployment } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status'
import { OVERVIEW_CARD_CLASS_NAME } from './card-styles'
import { EnvironmentTile } from './environment-tile'

View File

@ -11,12 +11,12 @@ import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { TitleTooltip } from '../../components/title-tooltip'
import { DeploymentStatusBadge } from '../../deployment-ui'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
import { releaseCommit } from '../../shared/domain/release'
import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge'
import {
deploymentStatusLabelKey,
} from '../../deployment-ui-utils'
import { releaseCommit } from '../../release'
import { openDeployDrawerAtom } from '../../store'
} from '../../shared/ui/deployment-status-style'
import { OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME } from './card-styles'
import {
renderActionLabel,

View File

@ -1,5 +1,5 @@
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import { isUndeployedDeploymentRow } from '../../runtime-status'
import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
export type Drift
= | { kind: 'undeployed' }

View File

@ -10,10 +10,10 @@ import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { CreateReleaseControl } from '../../components/create-release/control'
import { DeploymentEmptyState } from '../../components/empty-state'
import { TitleTooltip } from '../../components/title-tooltip'
import { formatDate, releaseCommit } from '../../release'
import { CreateReleaseControl } from '../../create-release'
import { formatDate, releaseCommit } from '../../shared/domain/release'
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME } from './card-styles'
type ReleaseHeroProps = {

View File

@ -24,7 +24,7 @@ import {
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useId, useState } from 'react'
import { useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { generateApiTokenName } from './api-token-name'
@ -46,6 +46,7 @@ export function ApiKeyGenerateMenu({
}) {
const { t } = useTranslation('deployments')
const nameInputId = useId()
const nameInputRef = useRef<HTMLInputElement>(null)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string>()
const [draftName, setDraftName] = useState('')
@ -58,6 +59,11 @@ export function ApiKeyGenerateMenu({
const disabled = selectableEnvironments.length === 0
const isCreating = generateApiKey.isPending
useEffect(() => {
if (createDialogOpen)
nameInputRef.current?.focus()
}, [createDialogOpen])
function resetCreateDialog() {
setCreateDialogOpen(false)
setSelectedEnvironmentId(undefined)
@ -161,10 +167,10 @@ export function ApiKeyGenerateMenu({
{t('access.api.nameLabel')}
</label>
<Input
ref={nameInputRef}
id={nameInputId}
value={draftName}
disabled={isCreating}
autoFocus
aria-invalid={nameError || undefined}
aria-describedby={nameError ? `${nameInputId}-error` : undefined}
placeholder={t('access.api.namePlaceholder')}

View File

@ -1,100 +0,0 @@
import type { ReleaseSummary } from '@dify/contracts/enterprise/types.gen'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import { getReleaseSummaryDeployments } from '../release-deployments'
function environment(id: string, name: string): ReleaseSummary['deployedEnvironments'][number]['environment'] {
return {
id,
displayName: name,
description: '',
mode: 'ENVIRONMENT_MODE_SHARED',
backend: 'RUNTIME_BACKEND_K8S',
namespace: '',
apiServer: '',
status: 'ENVIRONMENT_STATUS_READY',
statusMessage: '',
managedBy: '',
createdAt: '',
updatedAt: '',
runtimeEndpoint: '',
cpuCount: 1,
}
}
describe('release-deployments', () => {
// Runtime statuses come from the generated enterprise API contract.
describe('release summary deployments', () => {
it('should keep runtime status values on release deployment rows', () => {
const summary = {
release: {
id: 'release',
appInstanceId: 'app-instance',
displayName: 'Release',
description: '',
source: 'RELEASE_SOURCE_UPLOAD',
sourceAppId: '',
gateCommitId: '',
requiredSlots: [],
createdBy: {
id: 'account',
displayName: 'Account',
},
createdAt: '',
},
deployedEnvironments: [
{
environment: environment('env-ready', 'Ready'),
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY,
},
{
environment: environment('env-deploying', 'Deploying'),
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_DEPLOYING,
},
{
environment: environment('env-failed', 'Failed'),
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_FAILED,
},
{
environment: environment('env-invalid', 'Invalid'),
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_INVALID,
},
{
environment: environment('env-undeploying', 'Undeploying'),
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING,
},
],
environmentActions: [],
activeEnvironmentCount: 5,
} satisfies ReleaseSummary
expect(getReleaseSummaryDeployments(summary)).toEqual([
{
environmentId: 'env-ready',
environmentName: 'Ready',
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY,
},
{
environmentId: 'env-deploying',
environmentName: 'Deploying',
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_DEPLOYING,
},
{
environmentId: 'env-failed',
environmentName: 'Failed',
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_FAILED,
},
{
environmentId: 'env-invalid',
environmentName: 'Invalid',
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_INVALID,
},
{
environmentId: 'env-undeploying',
environmentName: 'Undeploying',
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNDEPLOYING,
},
])
})
})
})

View File

@ -5,8 +5,8 @@ import type {
} from '@dify/contracts/enterprise/types.gen'
import type { TFunction } from 'i18next'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { releaseDeploymentAction } from '../../release-action'
import { isUndeployedDeploymentRow } from '../../runtime-status'
import { releaseDeploymentAction } from '../../shared/domain/release-action'
import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
export type DeployMenuRowState = 'deploy' | 'rollback' | 'current' | 'deploying'

View File

@ -15,8 +15,8 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { TitleTooltip } from '../../components/title-tooltip'
import { isUndeployedDeploymentRow } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../table-styles'
import { DeleteReleaseDialog } from './delete-release-dialog'
import {

View File

@ -4,10 +4,10 @@ import type { ReleaseDeployment } from './release-deployments'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { isRuntimeDeploymentInProgress } from '../../shared/domain/runtime-status'
import {
deploymentStatusToneClassNames,
} from '../../deployment-ui-utils'
import { isRuntimeDeploymentInProgress } from '../../runtime-status'
} from '../../shared/ui/deployment-status-style'
export function DeployedToBadge({ item }: {
item: ReleaseDeployment

View File

@ -1,6 +1,6 @@
import type { Release } from '@dify/contracts/enterprise/types.gen'
import { downloadBlob } from '@/utils/download'
import { fetchReleaseDsl } from '../../release-dsl'
import { fetchReleaseDsl } from './release-dsl'
const YAML_EXTENSION_PATTERN = /\.ya?ml$/i
const INVALID_FILENAME_CHARS_PATTERN = /[\\/:*?"<>|]+/g

View File

@ -13,7 +13,7 @@ import { TitleTooltip } from '../../components/title-tooltip'
import {
formatDate,
releaseCommit,
} from '../../release'
} from '../../shared/domain/release'
import {
DetailTable,
DetailTableBody,

View File

@ -6,7 +6,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state'
import { RELEASE_HISTORY_PAGE_SIZE } from '../../data'
import { RELEASE_HISTORY_PAGE_SIZE } from '../../shared/domain/pagination'
import { ReleaseHistoryRows } from './release-history-rows'
import { ReleaseHistoryTableSkeleton } from './release-history-table-skeleton'
import { releaseRowFromSummary } from './release-history-types'

View File

@ -1,242 +1,41 @@
'use client'
import type { ListAppInstanceSummariesResponse } from '@dify/contracts/enterprise/types.gen'
import type { InfiniteData } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import { debounce, useQueryState } from 'nuqs'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentStateMessage } from '../components/empty-state'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
import { deploymentStatusPollingInterval } from '../runtime-status'
import { CreateDeploymentButton } from './create-deployment-button'
import { EnvironmentFilter } from './environment-filter'
import { InstanceCard } from './instance-card'
import { ScopeProvider } from 'jotai-scope'
import { useQueryState } from 'nuqs'
import {
deploymentsListEnvironmentIdAtom,
deploymentsListKeywordsAtom,
envFilterQueryState,
keywordsQueryState,
} from './query-state'
} from './state'
import { DeploymentsListShell } from './ui/shell'
const INSTANCE_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
function listDeploymentStatusPollingInterval(data?: InfiniteData<ListAppInstanceSummariesResponse>) {
const rows = data?.pages?.flatMap(page =>
page.appInstanceSummaries.flatMap(summary => summary.environmentDeployments),
) ?? []
return deploymentStatusPollingInterval(rows)
}
function DeploymentsListState({ children }: {
function DeploymentsListStateBoundary({ children }: {
children: ReactNode
}) {
return <DeploymentStateMessage variant="page">{children}</DeploymentStateMessage>
}
function DeploymentsListEmpty() {
const { t } = useTranslation('deployments')
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
const [envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState)
const hasFilter = Boolean(keywords.trim()) || Boolean(envFilter)
function clearFilters() {
void setKeywords(null)
void setEnvFilter(null)
}
const [envFilter] = useQueryState('env', envFilterQueryState)
const [keywords] = useQueryState('keywords', keywordsQueryState)
const stateKey = `${envFilter ?? 'all'}:${keywords}`
return (
<DeploymentEmptyState
variant="page"
icon={hasFilter ? 'i-ri-search-line' : 'i-ri-rocket-line'}
title={hasFilter ? t('list.emptyFilteredTitle') : t('list.emptyTitle')}
description={hasFilter ? t('list.emptyFilteredDescription') : t('list.emptyDescription')}
action={hasFilter
? (
<Button variant="secondary" size="small" onClick={clearFilters}>
{t('list.clearFilters')}
</Button>
)
: <CreateDeploymentButton />}
/>
)
}
function InstanceCardSkeleton() {
return (
<div className="col-span-1 inline-flex min-h-40 min-w-0 flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs">
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-w-0 px-4 pt-4">
<SkeletonRectangle className="my-0 h-4 w-2/5 animate-pulse" />
<SkeletonRectangle className="mt-2 h-3 w-3/5 animate-pulse" />
</div>
<div className="min-h-8 px-4 pt-4">
<div className="flex min-w-0 items-center gap-1.5">
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
<SkeletonRectangle className="my-0 h-5 w-22 animate-pulse rounded-md" />
</div>
</div>
<div className="mt-auto flex h-11 min-w-0 items-center border-t border-divider-subtle px-4">
<div className="flex min-w-0 grow items-center gap-2">
<SkeletonRectangle className="my-0 size-3.5 animate-pulse rounded-sm" />
<SkeletonRectangle className="my-0 h-3 w-18 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-3 w-24 animate-pulse" />
</div>
</div>
</div>
)
}
function DeploymentsListSkeleton() {
return INSTANCE_CARD_SKELETON_KEYS.map(key => (
<InstanceCardSkeleton key={key} />
))
}
function DeploymentsSearchInput({ className }: {
className?: string
}) {
const { t } = useTranslation('deployments')
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
function handleKeywordsChange(next: string) {
void setKeywords(next.trim() ? next : null, {
limitUrlUpdates: next.trim() ? debounce(300) : undefined,
})
}
return (
<div className={cn('relative w-50', className)}>
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" />
<Input
className="h-8 pr-8 pl-8"
placeholder={t('filter.searchPlaceholder')}
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
/>
{keywords && (
<button
type="button"
aria-label={t('list.clearSearch')}
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
onClick={() => handleKeywordsChange('')}
>
<span aria-hidden className="i-ri-close-circle-fill size-4" />
</button>
)}
</div>
)
}
function DeploymentsListControls() {
return (
<div className="sticky top-0 z-10 flex flex-col gap-3 bg-background-body px-4 pt-5 pb-4 sm:px-6 lg:px-12 lg:pt-7 lg:pb-5">
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<div className="flex min-w-0 items-center justify-between gap-2 sm:justify-start">
<EnvironmentFilter className="min-w-0" />
<CreateDeploymentButton className="shrink-0 sm:hidden" />
</div>
<DeploymentsSearchInput className="w-full sm:w-50 sm:shrink-0" />
<CreateDeploymentButton className="hidden shrink-0 sm:ml-auto sm:inline-flex" />
</div>
</div>
<ScopeProvider
key={stateKey}
atoms={[
[deploymentsListEnvironmentIdAtom, envFilter],
[deploymentsListKeywordsAtom, keywords],
]}
name="DeploymentsList"
>
{children}
</ScopeProvider>
)
}
export function DeploymentsList() {
const { t } = useTranslation('deployments')
const [envFilter] = useQueryState('env', envFilterQueryState)
const [keywords] = useQueryState('keywords', keywordsQueryState)
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const queryKeywords = keywords.trim()
const queryEnvironmentId = envFilter ?? undefined
const {
data,
error,
fetchNextPage,
hasNextPage,
isError,
isFetching,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
...consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
...(queryEnvironmentId ? { environmentId: queryEnvironmentId } : {}),
...(queryKeywords ? { displayName: queryKeywords } : {}),
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
refetchInterval: query => listDeploymentStatusPollingInterval(query.state.data),
})
const pages = data?.pages ?? []
const appInstanceSummaries = pages.flatMap(page => page.appInstanceSummaries)
const showSkeleton = isLoading || (isFetching && pages.length === 0)
const showEmptyState = !showSkeleton && !isError && appInstanceSummaries.length === 0
useEffect(() => {
if (!hasNextPage || isLoading || isFetchingNextPage || error)
return
const anchor = anchorRef.current
const container = containerRef.current
if (!anchor || !container)
return
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting)
void fetchNextPage()
}, {
root: container,
rootMargin: '160px',
threshold: 0.1,
})
observer.observe(anchor)
return () => observer.disconnect()
}, [error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading])
return (
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<DeploymentsListControls />
<div className={cn(
'relative grid grow grid-cols-[repeat(auto-fill,minmax(min(100%,20rem),1fr))] content-start gap-4 px-4 pt-2 sm:px-6 lg:px-12',
showEmptyState && 'overflow-hidden',
)}
>
{showSkeleton
? <DeploymentsListSkeleton />
: isError
? <DeploymentsListState>{t('common.loadFailed')}</DeploymentsListState>
: appInstanceSummaries.length === 0
? <DeploymentsListEmpty />
: appInstanceSummaries.map(summary => (
<InstanceCard
key={summary.appInstance.id}
summary={summary}
/>
))}
{isFetchingNextPage && <DeploymentsListSkeleton />}
</div>
<div ref={anchorRef} className="h-0" />
<div className="py-4" />
</div>
<DeploymentsListStateBoundary>
<DeploymentsListShell />
</DeploymentsListStateBoundary>
)
}

View File

@ -1,4 +0,0 @@
import { parseAsString } from 'nuqs'
export const envFilterQueryState = parseAsString.withOptions({ history: 'push' })
export const keywordsQueryState = parseAsString.withDefault('').withOptions({ history: 'push' })

View File

@ -0,0 +1,87 @@
'use client'
import type { ListAppInstanceSummariesResponse } from '@dify/contracts/enterprise/types.gen'
import type { InfiniteData, QueryKey } from '@tanstack/react-query'
import { keepPreviousData } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { parseAsString } from 'nuqs'
import { consoleQuery } from '@/service/client'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../../shared/domain/pagination'
import { deploymentStatusPollingInterval } from '../../shared/domain/runtime-status'
export const envFilterQueryState = parseAsString.withOptions({ history: 'push' })
export const keywordsQueryState = parseAsString.withDefault('').withOptions({ history: 'push' })
export const deploymentsListKeywordsAtom = atom('')
export const deploymentsListEnvironmentIdAtom = atom<string | null>(null)
function listDeploymentStatusPollingInterval(data?: InfiniteData<ListAppInstanceSummariesResponse>) {
const rows = data?.pages?.flatMap(page =>
page.appInstanceSummaries.flatMap(summary => summary.environmentDeployments),
) ?? []
return deploymentStatusPollingInterval(rows)
}
export const deploymentsListQueryAtom = atomWithInfiniteQuery<
ListAppInstanceSummariesResponse,
Error,
InfiniteData<ListAppInstanceSummariesResponse>,
QueryKey,
number
>((get) => {
const queryKeywords = get(deploymentsListKeywordsAtom).trim()
const queryEnvironmentId = get(deploymentsListEnvironmentIdAtom) ?? undefined
return {
...consoleQuery.enterprise.appInstanceService.listAppInstanceSummaries.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
...(queryEnvironmentId ? { environmentId: queryEnvironmentId } : {}),
...(queryKeywords ? { displayName: queryKeywords } : {}),
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
refetchInterval: query => listDeploymentStatusPollingInterval(query.state.data),
}
})
export const deploymentsListRowsAtom = atom((get) => {
return get(deploymentsListQueryAtom).data?.pages.flatMap(page => page.appInstanceSummaries) ?? []
})
export const deploymentsListShowSkeletonAtom = atom((get) => {
const deploymentsListQuery = get(deploymentsListQueryAtom)
const pages = deploymentsListQuery.data?.pages ?? []
return deploymentsListQuery.isLoading || (deploymentsListQuery.isFetching && pages.length === 0)
})
export const deploymentsListShowEmptyStateAtom = atom((get) => {
return !get(deploymentsListShowSkeletonAtom)
&& !get(deploymentsListQueryAtom).isError
&& get(deploymentsListRowsAtom).length === 0
})
export const deploymentsListHasFilterAtom = atom((get) => {
return Boolean(get(deploymentsListKeywordsAtom).trim() || get(deploymentsListEnvironmentIdAtom))
})
export const environmentsFilterQueryAtom = atomWithQuery(() =>
consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({
input: {
query: {
// The filter lists every deployable environment; environment count is
// capped well below the 100-per-page maximum.
pageNumber: 1,
resultsPerPage: 100,
},
},
}),
)

View File

@ -8,12 +8,14 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { envFilterQueryState } from './query-state'
import {
envFilterQueryState,
environmentsFilterQueryAtom,
} from '../state'
type EnvironmentFilterOption = {
value: string | null
@ -31,16 +33,7 @@ export function EnvironmentFilter({ className }: {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const [envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState)
const environmentsQuery = useQuery(consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({
input: {
query: {
// The filter lists every deployable environment; environment count is
// capped well below the 100-per-page maximum.
pageNumber: 1,
resultsPerPage: 100,
},
},
}))
const environmentsQuery = useAtomValue(environmentsFilterQueryAtom)
const environmentOptions: EnvironmentFilterOption[] = environmentsQuery.data?.environments
?.map(environment => ({
value: environment.id,

View File

@ -14,9 +14,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/too
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import Link from '@/next/link'
import { EnvironmentDeploymentBadge } from '../deployment-ui'
import { deploymentStatusLabelKey } from '../deployment-ui-utils'
import { formatDate } from '../release'
import { formatDate } from '../../shared/domain/release'
import { EnvironmentDeploymentBadge } from '../../shared/ui/deployment-status-badge'
import { deploymentStatusLabelKey } from '../../shared/ui/deployment-status-style'
import { getInstanceTabHref } from './instance-card-utils'
const VISIBLE_ENVIRONMENT_COUNT = 3

View File

@ -2,8 +2,8 @@ import type {
EnvironmentDeployment,
Release,
} from '@dify/contracts/enterprise/types.gen'
import type { InstanceDetailTabKey } from '../detail/tabs'
import { isUndeployedDeploymentRow } from '../runtime-status'
import type { InstanceDetailTabKey } from '../../detail/tabs'
import { isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
export function getInstanceTabHref(appInstanceId: string, tabKey: InstanceDetailTabKey) {
return `/deployments/${appInstanceId}/${tabKey}`

View File

@ -8,10 +8,10 @@ import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { CreateReleaseControl } from '../components/create-release/control'
import { DeploymentActionsMenu } from '../components/deployment-actions'
import { TitleTooltip } from '../components/title-tooltip'
import { openDeployDrawerAtom } from '../store'
import { DeploymentActionsMenu } from '../../components/deployment-actions'
import { TitleTooltip } from '../../components/title-tooltip'
import { CreateReleaseControl } from '../../create-release'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
import {
DeploymentAccessLinks,
DeploymentStatusContent,

View File

@ -0,0 +1,214 @@
'use client'
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { useAtomValue } from 'jotai'
import { debounce, useQueryState } from 'nuqs'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state'
import {
deploymentsListHasFilterAtom,
deploymentsListQueryAtom,
deploymentsListRowsAtom,
deploymentsListShowEmptyStateAtom,
deploymentsListShowSkeletonAtom,
envFilterQueryState,
keywordsQueryState,
} from '../state'
import { CreateDeploymentButton } from './create-deployment-button'
import { EnvironmentFilter } from './environment-filter'
import { InstanceCard } from './instance-card'
const INSTANCE_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
function DeploymentsListState({ children }: {
children: ReactNode
}) {
return <DeploymentStateMessage variant="page">{children}</DeploymentStateMessage>
}
function DeploymentsListEmpty() {
const { t } = useTranslation('deployments')
const hasAtomFilter = useAtomValue(deploymentsListHasFilterAtom)
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
const [envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState)
const hasFilter = hasAtomFilter || Boolean(keywords.trim()) || Boolean(envFilter)
function clearFilters() {
void setKeywords(null)
void setEnvFilter(null)
}
return (
<DeploymentEmptyState
variant="page"
icon={hasFilter ? 'i-ri-search-line' : 'i-ri-rocket-line'}
title={hasFilter ? t('list.emptyFilteredTitle') : t('list.emptyTitle')}
description={hasFilter ? t('list.emptyFilteredDescription') : t('list.emptyDescription')}
action={hasFilter
? (
<Button variant="secondary" size="small" onClick={clearFilters}>
{t('list.clearFilters')}
</Button>
)
: <CreateDeploymentButton />}
/>
)
}
function InstanceCardSkeleton() {
return (
<div className="col-span-1 inline-flex min-h-40 min-w-0 flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs">
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-w-0 px-4 pt-4">
<SkeletonRectangle className="my-0 h-4 w-2/5 animate-pulse" />
<SkeletonRectangle className="mt-2 h-3 w-3/5 animate-pulse" />
</div>
<div className="min-h-8 px-4 pt-4">
<div className="flex min-w-0 items-center gap-1.5">
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
<SkeletonRectangle className="my-0 h-5 w-22 animate-pulse rounded-md" />
</div>
</div>
<div className="mt-auto flex h-11 min-w-0 items-center border-t border-divider-subtle px-4">
<div className="flex min-w-0 grow items-center gap-2">
<SkeletonRectangle className="my-0 size-3.5 animate-pulse rounded-sm" />
<SkeletonRectangle className="my-0 h-3 w-18 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-3 w-24 animate-pulse" />
</div>
</div>
</div>
)
}
function DeploymentsListSkeleton() {
return INSTANCE_CARD_SKELETON_KEYS.map(key => (
<InstanceCardSkeleton key={key} />
))
}
function DeploymentsSearchInput({ className }: {
className?: string
}) {
const { t } = useTranslation('deployments')
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
function handleKeywordsChange(next: string) {
void setKeywords(next.trim() ? next : null, {
limitUrlUpdates: next.trim() ? debounce(300) : undefined,
shallow: false,
})
}
return (
<div className={cn('relative w-50', className)}>
<span aria-hidden className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" />
<Input
className="h-8 pr-8 pl-8"
placeholder={t('filter.searchPlaceholder')}
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
/>
{keywords && (
<button
type="button"
aria-label={t('list.clearSearch')}
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
onClick={() => handleKeywordsChange('')}
>
<span aria-hidden className="i-ri-close-circle-fill size-4" />
</button>
)}
</div>
)
}
function DeploymentsListControls() {
return (
<div className="sticky top-0 z-10 flex flex-col gap-3 bg-background-body px-4 pt-5 pb-4 sm:px-6 lg:px-12 lg:pt-7 lg:pb-5">
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<div className="flex min-w-0 items-center justify-between gap-2 sm:justify-start">
<EnvironmentFilter className="min-w-0" />
<CreateDeploymentButton className="shrink-0 sm:hidden" />
</div>
<DeploymentsSearchInput className="w-full sm:w-50 sm:shrink-0" />
<CreateDeploymentButton className="hidden shrink-0 sm:ml-auto sm:inline-flex" />
</div>
</div>
)
}
export function DeploymentsListShell() {
const { t } = useTranslation('deployments')
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const deploymentsListQuery = useAtomValue(deploymentsListQueryAtom)
const appInstanceSummaries = useAtomValue(deploymentsListRowsAtom)
const showSkeleton = useAtomValue(deploymentsListShowSkeletonAtom)
const showEmptyState = useAtomValue(deploymentsListShowEmptyStateAtom)
const {
error,
fetchNextPage,
hasNextPage,
isError,
isFetchingNextPage,
isLoading,
} = deploymentsListQuery
useEffect(() => {
if (!hasNextPage || isLoading || isFetchingNextPage || error)
return
const anchor = anchorRef.current
const container = containerRef.current
if (!anchor || !container)
return
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting)
void fetchNextPage()
}, {
root: container,
rootMargin: '160px',
threshold: 0.1,
})
observer.observe(anchor)
return () => observer.disconnect()
}, [error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading])
return (
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<DeploymentsListControls />
<div className={cn(
'relative grid grow grid-cols-[repeat(auto-fill,minmax(min(100%,20rem),1fr))] content-start gap-4 px-4 pt-2 sm:px-6 lg:px-12',
showEmptyState && 'overflow-hidden',
)}
>
{showSkeleton
? <DeploymentsListSkeleton />
: isError
? <DeploymentsListState>{t('common.loadFailed')}</DeploymentsListState>
: appInstanceSummaries.length === 0
? <DeploymentsListEmpty />
: appInstanceSummaries.map(summary => (
<InstanceCard
key={summary.appInstance.id}
summary={summary}
/>
))}
{isFetchingNextPage && <DeploymentsListSkeleton />}
</div>
<div ref={anchorRef} className="h-0" />
<div className="py-4" />
</div>
)
}

View File

@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
import Nav from '@/app/components/header/nav'
import { useParams, useRouter, useSelectedLayoutSegment } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../shared/domain/pagination'
function navItemFromListApp(app: AppInstance): NavItem {
const id = app.id

View File

@ -7,11 +7,11 @@ import type {
import type { ComponentPropsWithRef } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { isRuntimeDeploymentInProgress } from '../domain/runtime-status'
import {
deploymentStatusLabelKey,
deploymentStatusToneClassNames,
} from './deployment-ui-utils'
import { isRuntimeDeploymentInProgress } from './runtime-status'
} from './deployment-status-style'
type DeploymentStatusBadgeProps = Omit<ComponentPropsWithRef<'span'>, 'children'> & {
status: RuntimeInstanceStatusValue

View File

@ -1,25 +0,0 @@
import { atom } from 'jotai'
type OpenDeployDrawerParams = {
appInstanceId: string
environmentId?: string
releaseId?: string
}
export const deployDrawerOpenAtom = atom(false)
export const deployDrawerAppInstanceIdAtom = atom<string | undefined>(undefined)
export const deployDrawerEnvironmentIdAtom = atom<string | undefined>(undefined)
export const deployDrawerReleaseIdAtom = atom<string | undefined>(undefined)
export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => {
set(deployDrawerAppInstanceIdAtom, params.appInstanceId)
set(deployDrawerEnvironmentIdAtom, params.environmentId)
set(deployDrawerReleaseIdAtom, params.releaseId)
set(deployDrawerOpenAtom, true)
})
export const closeDeployDrawerAtom = atom(null, (_get, set) => {
set(deployDrawerOpenAtom, false)
set(deployDrawerAppInstanceIdAtom, undefined)
set(deployDrawerEnvironmentIdAtom, undefined)
set(deployDrawerReleaseIdAtom, undefined)
})