refactor(web): organize deployment feature state (#38065)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Stephen Zhou 2026-06-27 15:34:46 +08:00 committed by GitHub
parent 8218694691
commit a14310fc62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
177 changed files with 3043 additions and 2055 deletions

View File

@ -25,6 +25,8 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- 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 choices.
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README.
- Module README whitelist: `@/service/client`, `@/next/*`.
- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities.
- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly.
- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators.

View File

@ -1,5 +1,5 @@
import { AccessTab } from '@/features/deployments/detail/access-tab'
import { DeploymentAccess } from '@/features/deployments/detail/access'
export default function InstanceDetailAccessPage() {
return <AccessTab />
return <DeploymentAccess />
}

View File

@ -1,5 +1,5 @@
import { DeveloperApiTab } from '@/features/deployments/detail/access-tab/developer-api'
import { DeploymentApiTokens } from '@/features/deployments/detail/api-tokens'
export default function InstanceDetailApiTokensPage() {
return <DeveloperApiTab />
return <DeploymentApiTokens />
}

View File

@ -1,5 +1,5 @@
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
import { DeploymentInstances } from '@/features/deployments/detail/instances'
export default function InstanceDetailInstancesPage() {
return <DeployTab />
return <DeploymentInstances />
}

View File

@ -1,5 +1,5 @@
import { OverviewTab } from '@/features/deployments/detail/overview-tab'
import { DeploymentOverview } from '@/features/deployments/detail/overview'
export default function InstanceDetailOverviewPage() {
return <OverviewTab />
return <DeploymentOverview />
}

View File

@ -1,5 +1,5 @@
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
import { DeploymentReleases } from '@/features/deployments/detail/releases'
export default function InstanceDetailReleasesPage() {
return <VersionsTab />
return <DeploymentReleases />
}

View File

@ -32,6 +32,7 @@ function routeParamsKey(params: NextRouteParams) {
}
export const nextParamsAtom = atom(get => get(nextRouteStateAtom).params)
export const nextPathnameAtom = atom(get => get(nextRouteStateAtom).pathname)
export const setNextRouteStateAtom = atom(null, (get, set, routeState: NextRouteState) => {
const nextParams = normalizeNextRouteParams(routeState.params)

View File

@ -0,0 +1,101 @@
# Deployments
Deployment app instance, release, runtime target, access, and developer API feature modules.
## Internal Module Dependency Graph
The graph shows product module dependencies inside `web/features/deployments`. It omits route-state plumbing and shared support modules.
```mermaid
flowchart TD
list["list"]
detail["detail"]
createGuide["create-guide"]
createRelease["create-release"]
deployDrawer["deploy-drawer"]
deploymentActions["deployment-actions"]
detailOverview["detail/overview"]
detailInstances["detail/instances"]
detailAccess["detail/access"]
detailApiTokens["detail/api-tokens"]
detailReleases["detail/releases"]
accessChannels["detail/access/channels"]
accessPermissions["detail/access/permissions"]
accessSubjectSelector["detail/access/permissions/access-subject-selector"]
apiTokenManagement["detail/api-tokens/api-token-management"]
apiKeys["detail/api-tokens/api-keys"]
apiDocs["detail/api-tokens/docs"]
instanceEnvironmentList["detail/instances/environment-list"]
instanceHeaderActions["detail/instances/header-actions"]
instanceRowActions["detail/instances/row-actions"]
overviewAccessSummary["detail/overview/access-summary"]
overviewEnvironmentStatus["detail/overview/environment-status"]
overviewReleaseSummary["detail/overview/release-summary"]
releaseActions["detail/releases/release-actions"]
releaseHistory["detail/releases/release-history"]
list --> createGuide
list --> createRelease
list --> deployDrawer
list --> deploymentActions
list --> detail
detail --> createRelease
detail --> deploymentActions
detail --> detailOverview
detail --> detailInstances
detail --> detailAccess
detail --> detailApiTokens
detail --> detailReleases
detail --> instanceHeaderActions
detailOverview --> overviewAccessSummary
detailOverview --> overviewEnvironmentStatus
detailOverview --> overviewReleaseSummary
overviewEnvironmentStatus --> deployDrawer
overviewReleaseSummary --> createRelease
detailInstances --> detail
detailInstances --> instanceEnvironmentList
detailInstances --> instanceHeaderActions
instanceEnvironmentList --> detailInstances
instanceEnvironmentList --> instanceRowActions
instanceHeaderActions --> deployDrawer
instanceHeaderActions --> detail
instanceRowActions --> deployDrawer
detailAccess --> accessChannels
detailAccess --> accessPermissions
accessChannels --> detailAccess
accessPermissions --> detailAccess
accessPermissions --> accessSubjectSelector
detailApiTokens --> apiTokenManagement
apiTokenManagement --> detailApiTokens
apiTokenManagement --> apiKeys
apiTokenManagement --> apiDocs
apiKeys --> detailApiTokens
detailReleases --> releaseHistory
releaseActions --> deployDrawer
releaseHistory --> detailReleases
releaseHistory --> releaseActions
```
## Internal Modules
| Module | Why this module uses it |
| -------------------- | ------------------------------------------------------------- |
| `list` | Owns the deployment app instance list surface. |
| `detail` | Owns the deployment app instance detail shell and route tabs. |
| `create-guide` | Owns the create deployment guide workflow. |
| `create-release` | Owns release creation entry points and dialog state. |
| `deploy-drawer` | Owns deployment target selection and submit workflow state. |
| `deployment-actions` | Owns app instance edit and delete action surfaces. |
## External Modules
None.

View File

@ -1,42 +0,0 @@
'use client'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { atomWithLazy } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
export const deploymentActionAppInstanceIdAtom = atomWithLazy<string>(() => {
throw new Error('Missing deployment action app instance id.')
})
export const editDeploymentDialogOpenAtom = atom(false)
export const deleteDeploymentDialogOpenAtom = atom(false)
export const deploymentActionAppInstanceQueryOptionsAtom = atom((get) => {
const appInstanceId = get(deploymentActionAppInstanceIdAtom)
return consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: {
params: { appInstanceId },
},
})
})
export const deploymentActionAppInstanceQueryAtom = atomWithQuery((get) => {
return get(deploymentActionAppInstanceQueryOptionsAtom)
})
export const openEditDeploymentDialogAtom = atom(null, (_get, set) => {
set(deleteDeploymentDialogOpenAtom, false)
set(editDeploymentDialogOpenAtom, true)
})
export const openDeleteDeploymentDialogAtom = atom(null, (_get, set) => {
set(editDeploymentDialogOpenAtom, false)
set(deleteDeploymentDialogOpenAtom, true)
})
export const deploymentActionsLocalAtoms = [
editDeploymentDialogOpenAtom,
deleteDeploymentDialogOpenAtom,
] as const

View File

@ -0,0 +1,18 @@
# Deployment Create Guide
Guided deployment creation workflow for selecting a source, defining the initial release, and optionally deploying to an environment.
## Internal Modules
| Module | Why this module uses it |
| -------- | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
## External Modules
| Module | Why this module uses it |
| --------------------------------------------------- | -------------------------------------------------------------------------------- |
| `app/components/app/create-from-dsl-modal/uploader` | Reuses the existing DSL file uploader for the import source step. |
| `app/components/base/app-icon` | Renders app icons consistently in source app options. |
| `app/components/base/skeleton` | Reuses the existing skeleton primitive for source and target loading states. |
| `types/app` | Uses app types and mode enums to narrow selectable source apps to workflow apps. |

View File

@ -0,0 +1,17 @@
'use client'
import type { ComponentProps } from 'react'
import Link from '@/next/link'
const createDeploymentGuideHref = '/deployments/create'
type CreateDeploymentGuideLinkProps = Omit<ComponentProps<typeof Link>, 'href'>
export function CreateDeploymentGuideLink(props: CreateDeploymentGuideLinkProps) {
return (
<Link
{...props}
href={createDeploymentGuideHref}
/>
)
}

View File

@ -1,6 +1,27 @@
import type { Getter } from 'jotai'
import { atom, createStore } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
dslFileAtom,
effectiveMethodAtom,
instanceNameAtom,
methodAtom,
releaseNameAtom,
selectedAppAtom,
stepAtom,
} from '../primitives'
import {
dslDefaultAppNameAtom,
dslReadErrorAtom,
isReadingDslAtom,
sourceAppsQueryAtom,
} from '../source'
import {
continueFromSourceAtom,
selectDslFileAtom,
selectMethodAtom,
sourceCanGoNextAtom,
} from '../workflow'
type QueryOptions = {
enabled?: boolean
@ -119,10 +140,6 @@ vi.mock('@/service/client', () => ({
},
}))
async function loadState() {
return await import('../index')
}
function workflowDsl() {
return [
'app:',
@ -137,30 +154,27 @@ describe('create deployment guide state', () => {
mockQueryResults.current.clear()
})
it('should keep the guide on source app mode when DSL import is disabled', async () => {
const state = await loadState()
it('should keep the guide on source app mode when DSL import is disabled', () => {
const store = createStore()
store.set(state.selectMethodAtom, 'importDsl')
store.set(selectMethodAtom, 'importDsl')
expect(store.get(state.methodAtom)).toBe('bindApp')
expect(store.get(state.effectiveMethodAtom)).toBe('bindApp')
expect(store.get(methodAtom)).toBe('bindApp')
expect(store.get(effectiveMethodAtom)).toBe('bindApp')
})
it('should keep source app loading enabled if stale state points to DSL import', async () => {
const state = await loadState()
it('should keep source app loading enabled if stale state points to DSL import', () => {
const store = createStore()
store.set(state.methodAtom, 'importDsl')
store.set(methodAtom, 'importDsl')
const sourceAppsQuery = store.get(state.sourceAppsQueryAtom) as unknown as { enabled?: boolean }
const sourceAppsQuery = store.get(sourceAppsQueryAtom) as unknown as { enabled?: boolean }
expect(store.get(state.effectiveMethodAtom)).toBe('bindApp')
expect(store.get(effectiveMethodAtom)).toBe('bindApp')
expect(sourceAppsQuery.enabled).toBe(true)
})
it('should continue from source app mode and auto-fill unique release metadata', async () => {
const state = await loadState()
it('should continue from source app mode and auto-fill unique release metadata', () => {
const store = createStore()
mockQueryResults.current.set('sourceApps', {
data: {
@ -208,39 +222,38 @@ describe('create deployment guide state', () => {
isSuccess: true,
})
expect(store.get(state.sourceCanGoNextAtom)).toBe(true)
expect(store.get(sourceCanGoNextAtom)).toBe(true)
store.set(state.continueFromSourceAtom, {
store.set(continueFromSourceAtom, {
defaultDslAppName: 'Imported DSL',
defaultReleaseName: 'Initial Release',
})
expect(store.get(state.selectedAppAtom)).toMatchObject({
expect(store.get(selectedAppAtom)).toMatchObject({
id: 'source-app-1',
name: 'Customer Service',
})
expect(store.get(state.instanceNameAtom)).toMatch(/^Customer Service-[a-z]{4}$/)
expect(store.get(state.releaseNameAtom)).toBe('Initial Release')
expect(store.get(state.stepAtom)).toBe('release')
expect(store.get(instanceNameAtom)).toMatch(/^Customer Service-[a-z]{4}$/)
expect(store.get(releaseNameAtom)).toBe('Initial Release')
expect(store.get(stepAtom)).toBe('release')
})
it('should read selected DSL file content through the file content query', async () => {
const state = await loadState()
it('should read selected DSL file content through the file content query', () => {
const store = createStore()
const text = vi.fn().mockResolvedValue(workflowDsl())
const file = new File([], 'workflow.yml', { type: 'text/yaml' })
Object.defineProperty(file, 'text', { value: text })
store.set(state.selectDslFileAtom, file)
store.set(selectDslFileAtom, file)
mockQueryResults.current.set('createGuideDslFileContent', {
data: workflowDsl(),
isSuccess: true,
})
expect(text).not.toHaveBeenCalled()
expect(store.get(state.dslFileAtom)).toBe(file)
expect(store.get(state.dslDefaultAppNameAtom)).toBe('Imported guide')
expect(store.get(state.isReadingDslAtom)).toBe(false)
expect(store.get(state.dslReadErrorAtom)).toBe(false)
expect(store.get(dslFileAtom)).toBe(file)
expect(store.get(dslDefaultAppNameAtom)).toBe('Imported guide')
expect(store.get(isReadingDslAtom)).toBe(false)
expect(store.get(dslReadErrorAtom)).toBe(false)
})
})

View File

@ -1,933 +0,0 @@
'use client'
import type {
DeployRequest,
EnvVarInput,
} from '@dify/contracts/enterprise/types.gen'
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/shared/domain/error'
import type { App } from '@/types/app'
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
import { keepPreviousData, queryOptions, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { envVarBindingSlotFromContract, envVarBindingValueType } from '@/features/deployments/components/env-var-bindings-utils'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialSlotKey,
selectedDeploymentRuntimeCredentials,
selectedRuntimeCredentialSelections,
} from '@/features/deployments/components/runtime-credential-bindings-utils'
import {
dslAppName,
dslEnvVarSlots,
encodeDslContent,
isWorkflowDsl,
} from '@/features/deployments/shared/domain/dsl'
import { unsupportedDslNodeError } from '@/features/deployments/shared/domain/error'
import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags'
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'
export type WorkflowSourceApp = App & { mode: Extract<AppModeEnum, 'workflow'> }
function deploymentGuideMethod(method: GuideMethod): GuideMethod {
return method === 'importDsl' && !isDeploymentDslImportEnabled
? 'bindApp'
: method
}
const RANDOM_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
const RANDOM_SUFFIX_LENGTH = 4
const RANDOM_SUFFIX_FALLBACK_LENGTH = 6
const RANDOM_SUFFIX_MAX_ATTEMPTS = 16
function randomLetterCombination(length: number) {
const randomValues = new Uint8Array(length)
if (globalThis.crypto) {
globalThis.crypto.getRandomValues(randomValues)
}
else {
randomValues.forEach((_, index) => {
randomValues[index] = Math.floor(Math.random() * 256)
})
}
return Array.from(randomValues, value => RANDOM_SUFFIX_ALPHABET[value % RANDOM_SUFFIX_ALPHABET.length]).join('')
}
function availableInstanceName(sourceName: string, existingNameSet: Set<string>) {
if (!existingNameSet.has(sourceName))
return sourceName
for (let attempt = 0; attempt < RANDOM_SUFFIX_MAX_ATTEMPTS; attempt++) {
const candidate = `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_LENGTH)}`
if (!existingNameSet.has(candidate))
return candidate
}
return `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_FALLBACK_LENGTH)}`
}
function envVarValueSource(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
return selection?.valueSource
?? (slot.hasDefaultValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT
: slot.hasLastValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT
: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL)
}
function envVarSelectionReady(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
const valueSource = envVarValueSource(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))
}
function envVarInput(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined): EnvVarInput[] {
const valueSource = envVarValueSource(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,
}]
}
// Workflow primitives
export const stepAtom = atom<GuideStep>('source')
export const methodAtom = atom<GuideMethod>('bindApp')
export const effectiveMethodAtom = atom(get => deploymentGuideMethod(get(methodAtom)))
// Source primitives
export const sourceSearchTextAtom = atom('')
export const selectedAppAtom = atom<WorkflowSourceApp | undefined>(undefined)
// DSL primitives and derived state
export const dslFileAtom = atom<File | undefined>(undefined)
const dslFileReadVersionAtom = atom(0)
const dslFileContentQueryAtom = atomWithQuery((get) => {
const file = get(dslFileAtom)
const fileReadVersion = get(dslFileReadVersionAtom)
return queryOptions({
queryKey: [
'createGuideDslFileContent',
fileReadVersion,
file,
file?.name ?? '',
file?.size ?? 0,
file?.lastModified ?? 0,
],
queryFn: async () => file ? await file.text() : '',
enabled: Boolean(file),
retry: false,
})
})
const dslContentAtom = atom((get) => {
return get(dslFileContentQueryAtom).data ?? ''
})
export const isReadingDslAtom = atom((get) => {
const file = get(dslFileAtom)
const dslFileContentQuery = get(dslFileContentQueryAtom)
return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching))
})
export const dslReadErrorAtom = atom((get) => {
return Boolean(get(dslFileAtom) && get(dslFileContentQueryAtom).isError)
})
export const dslDefaultAppNameAtom = atom((get) => {
const dslContent = get(dslContentAtom)
return dslContent ? dslAppName(dslContent) : ''
})
export const dslUnsupportedModeAtom = atom((get) => {
const dslContent = get(dslContentAtom)
return get(effectiveMethodAtom) === 'importDsl'
&& Boolean(dslContent.trim())
&& !get(isReadingDslAtom)
&& !get(dslReadErrorAtom)
&& !isWorkflowDsl(dslContent)
})
const importDslReadyAtom = atom((get) => {
return Boolean(get(dslContentAtom).trim())
&& !get(isReadingDslAtom)
&& !get(dslReadErrorAtom)
&& !get(dslUnsupportedModeAtom)
})
// Release primitives
export const instanceNameAtom = atom('')
export const instanceDescriptionAtom = atom('')
export const releaseNameAtom = atom('')
export const releaseDescriptionAtom = atom('')
const autoFilledInstanceNameAtom = atom('')
const autoFilledReleaseNameAtom = atom('')
// Target primitives
export const selectedEnvironmentIdAtom = atom('')
const manualBindingSelectionsAtom = atom<RuntimeCredentialBindingSelections>({})
export const envVarValuesAtom = atom<EnvVarValues>({})
// Submission primitives
const submissionUnsupportedDslNodesAtom = atom<UnsupportedDslNode[]>([])
const isCreatingDeploymentAtom = atom(false)
export const isCreatingReleaseOnlyAtom = atom(false)
export const isSubmittingDeploymentGuideAtom = atom(get => (
get(isCreatingDeploymentAtom) || get(isCreatingReleaseOnlyAtom)
))
// Query and remote data
export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
const sourceSearchText = get(sourceSearchTextAtom)
return consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
limit: SOURCE_APPS_PAGE_SIZE,
name: sourceSearchText,
mode: AppModeEnum.WORKFLOW,
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
enabled: get(effectiveMethodAtom) === 'bindApp',
})
})
export const effectiveSelectedAppAtom = atom((get) => {
const selectedApp = get(selectedAppAtom)
if (selectedApp)
return selectedApp
const sourceAppsQuery = get(sourceAppsQueryAtom)
if (sourceAppsQuery.isPlaceholderData)
return undefined
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
return sourceApps[0]
})
function sourceReady(get: Getter) {
const method = get(effectiveMethodAtom)
return method === 'importDsl'
? get(importDslReadyAtom)
: Boolean(get(effectiveSelectedAppAtom)?.id)
}
const existingInstanceNamesQueryAtom = atomWithInfiniteQuery(() =>
consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
)
const instanceNameConflictQueryAtom = atomWithQuery((get) => {
const submittedInstanceName = get(instanceNameAtom).trim()
return consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({
input: {
query: {
pageNumber: 1,
resultsPerPage: 1,
displayName: submittedInstanceName,
},
},
enabled: Boolean(submittedInstanceName),
})
})
export const deployableEnvironmentsQueryAtom = atomWithQuery((get) => {
return consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({
input: {
query: {
// The guide offers every deployable environment at once; environment
// count is capped well below the 100-per-page maximum.
pageNumber: 1,
resultsPerPage: 100,
},
},
enabled: sourceReady(get),
})
})
const precheckReleaseQueryAtom = atomWithQuery((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const dslContent = get(dslContentAtom)
const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined
const enabled = sourceReady(get)
// PrecheckRelease takes exactly one source arm (dsl | sourceAppId).
const precheckReleaseQueryOptions = method === 'importDsl'
? consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: encodedDslContent
? {
body: {
dsl: encodedDslContent,
},
}
: skipToken,
enabled,
retry: false,
})
: consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: effectiveSelectedApp?.id
? {
body: {
sourceAppId: effectiveSelectedApp.id,
},
}
: skipToken,
enabled: enabled && Boolean(effectiveSelectedApp?.id),
retry: false,
})
return precheckReleaseQueryOptions
})
function precheckReleaseReady(get: Getter) {
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
return sourceReady(get)
&& precheckReleaseQuery.isSuccess
&& Boolean(precheckReleaseQuery.data?.canCreate)
&& (precheckReleaseQuery.data?.unsupportedNodes.length ?? 0) === 0
&& get(submissionUnsupportedDslNodesAtom).length === 0
}
export const deploymentOptionsQueryAtom = atomWithQuery((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const dslContent = get(dslContentAtom)
const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined
const enabled = precheckReleaseReady(get)
// ComputeDeploymentOptions takes exactly one source arm (dsl | sourceAppId | releaseId).
const deploymentOptionsQueryOptions = method === 'importDsl'
? consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
input: encodedDslContent
? {
body: {
dsl: encodedDslContent,
},
}
: skipToken,
enabled,
retry: false,
})
: consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
input: effectiveSelectedApp?.id
? {
body: {
sourceAppId: effectiveSelectedApp.id,
},
}
: skipToken,
enabled: enabled && Boolean(effectiveSelectedApp?.id),
retry: false,
})
return deploymentOptionsQueryOptions
})
// Unsupported DSL state
export const unsupportedDslNodesAtom = atom((get): UnsupportedDslNode[] => {
const submissionUnsupportedDslNodes = get(submissionUnsupportedDslNodesAtom)
if (submissionUnsupportedDslNodes.length > 0)
return submissionUnsupportedDslNodes
if (!sourceReady(get))
return []
return get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? []
})
const precheckReleaseReadyAtom = atom((get) => {
return precheckReleaseReady(get)
})
const deploymentOptionsReadyAtom = atom((get) => {
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
return sourceReady(get)
&& get(precheckReleaseReadyAtom)
&& deploymentOptionsQuery.isSuccess
})
const deploymentOptionsContentCheckedAtom = atom((get) => {
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
const isLoadingOptions = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
const isCheckingReleaseContent = precheckReleaseQuery.isLoading || (precheckReleaseQuery.isFetching && !precheckReleaseQuery.data)
if (!sourceReady(get) || isCheckingReleaseContent || isLoadingOptions)
return false
return get(precheckReleaseReadyAtom) && deploymentOptionsQuery.isSuccess
})
export const sourceCanGoNextAtom = atom((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const importDslReady = method === 'importDsl' && get(importDslReadyAtom)
const bindAppReady = method === 'bindApp' && Boolean(effectiveSelectedApp?.id)
return (importDslReady || bindAppReady) && get(deploymentOptionsContentCheckedAtom)
})
export const setSourceSearchTextAtom = atom(null, (get, set, value: string) => {
if (get(sourceSearchTextAtom) === value)
return
set(sourceSearchTextAtom, value)
set(selectedAppAtom, undefined)
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
})
export const selectSourceAppAtom = atom(null, (_get, set, app: WorkflowSourceApp) => {
set(selectedAppAtom, app)
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
})
export const continueFromSourceAtom = atom(null, (get, set, {
defaultDslAppName,
defaultReleaseName,
}: {
defaultDslAppName: string
defaultReleaseName: string
}) => {
if (!get(sourceCanGoNextAtom))
return
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
if (method === 'bindApp' && effectiveSelectedApp)
set(selectSourceAppAtom, effectiveSelectedApp)
const sourceName = method === 'importDsl'
? get(dslDefaultAppNameAtom) || defaultDslAppName
: effectiveSelectedApp?.name
const nextInstanceName = sourceName?.trim()
if (nextInstanceName) {
const currentInstanceName = get(instanceNameAtom).trim()
const autoFilledInstanceName = get(autoFilledInstanceNameAtom)
const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom)
const existingNameSet = new Set(
existingInstanceNamesQuery.data?.pages.flatMap(page =>
page.appInstances.flatMap((appInstance) => {
const name = appInstance.displayName.trim()
return name ? [name] : []
}),
) ?? [],
)
if (!currentInstanceName || currentInstanceName === autoFilledInstanceName) {
const nextAvailableInstanceName = availableInstanceName(nextInstanceName, existingNameSet)
set(instanceNameAtom, nextAvailableInstanceName)
set(autoFilledInstanceNameAtom, nextAvailableInstanceName)
}
}
const currentReleaseName = get(releaseNameAtom).trim()
const autoFilledReleaseName = get(autoFilledReleaseNameAtom)
if (!currentReleaseName || currentReleaseName === autoFilledReleaseName) {
set(releaseNameAtom, defaultReleaseName)
set(autoFilledReleaseNameAtom, defaultReleaseName)
}
set(stepAtom, 'release')
})
// DSL actions
export const selectDslFileAtom = atom(null, (get, set, dslFile?: File) => {
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
set(dslFileReadVersionAtom, get(dslFileReadVersionAtom) + 1)
set(dslFileAtom, dslFile)
})
// Release derived state and actions
export const hasInstanceNameConflictAtom = atom((get) => {
const submittedInstanceName = get(instanceNameAtom).trim()
const instanceNameConflictQuery = get(instanceNameConflictQueryAtom)
const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom)
const existingInstanceNames = existingInstanceNamesQuery.data?.pages.flatMap(page =>
page.appInstances.flatMap((appInstance) => {
const name = appInstance.displayName.trim()
return name ? [name] : []
}),
) ?? []
return Boolean(
submittedInstanceName
&& (
existingInstanceNames.includes(submittedInstanceName)
|| (instanceNameConflictQuery.data?.appInstances.some(appInstance => appInstance.displayName.trim() === submittedInstanceName) ?? false)
),
)
})
const submittedReleaseReadyAtom = atom((get) => {
return Boolean(sourceReady(get) && get(instanceNameAtom).trim() && get(releaseNameAtom).trim())
})
export const releaseCanGoNextAtom = atom((get) => {
const submittedInstanceName = get(instanceNameAtom).trim()
const instanceNameConflictQuery = get(instanceNameConflictQueryAtom)
return Boolean(get(submittedReleaseReadyAtom))
&& !get(hasInstanceNameConflictAtom)
&& !(Boolean(submittedInstanceName) && instanceNameConflictQuery.isLoading)
&& get(deploymentOptionsContentCheckedAtom)
})
export const setInstanceNameAtom = atom(null, (_get, set, value: string) => {
set(instanceNameAtom, value)
set(autoFilledInstanceNameAtom, '')
set(stepAtom, 'release')
})
export const setInstanceDescriptionAtom = atom(null, (_get, set, value: string) => {
set(instanceDescriptionAtom, value)
set(stepAtom, 'release')
})
export const setReleaseNameAtom = atom(null, (_get, set, value: string) => {
set(releaseNameAtom, value)
set(autoFilledReleaseNameAtom, '')
set(stepAtom, 'release')
})
export const setReleaseDescriptionAtom = atom(null, (_get, set, value: string) => {
set(releaseDescriptionAtom, value)
set(stepAtom, 'release')
})
export const continueFromReleaseAtom = atom(null, (get, set) => {
if (!get(releaseCanGoNextAtom))
return
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(stepAtom, 'target')
})
// Target derived state and actions
export const deployableEnvironmentsAtom = atom((get) => {
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
return sourceReady(get)
? deployableEnvironmentsQuery.data?.environments ?? []
: []
})
const deployableEnvironmentsReadyAtom = atom((get) => {
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
return sourceReady(get) && deployableEnvironmentsQuery.isSuccess
})
export const effectiveSelectedEnvironmentIdAtom = atom((get) => {
return get(selectedEnvironmentIdAtom) || get(deployableEnvironmentsAtom)[0]?.id
})
export const deploymentTargetBindingSlotsAtom = atom((get) => {
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
return sourceReady(get)
? deploymentOptionsQuery.data?.options?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
: []
})
export const deploymentTargetBindingSelectionsAtom = atom((get) => {
return selectedRuntimeCredentialSelections(
get(deploymentTargetBindingSlotsAtom),
get(manualBindingSelectionsAtom),
)
})
const requiredBindingsReadyAtom = atom((get) => {
const bindingSelections = get(deploymentTargetBindingSelectionsAtom)
return get(deploymentTargetBindingSlotsAtom).every(slot =>
!hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]),
)
})
export const deploymentTargetEnvVarSlotsAtom = atom((get) => {
const method = get(effectiveMethodAtom)
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
const slots = sourceReady(get) ? deploymentOptionsQuery.data?.options?.envVarSlots : undefined
const dslContent = get(dslContentAtom)
// Deployment options own the canonical slot list; DSL metadata only enriches import-DSL defaults.
const deploymentOptionEnvVarSlots = slots?.flatMap((slot): EnvVarBindingSlot[] => {
const bindingSlot = envVarBindingSlotFromContract(slot)
return bindingSlot ? [bindingSlot] : []
}) ?? []
const dslEnvVarMetadataSlots = method === 'importDsl' && dslContent
? dslEnvVarSlots(dslContent).flatMap((slot) => {
const key = slot.key.trim()
if (!key)
return []
return [{
key,
...(slot.description ? { description: slot.description } : {}),
...(slot.defaultValue !== undefined ? { defaultValue: slot.defaultValue, hasDefaultValue: true } : {}),
...(slot.valueType ? { valueType: envVarBindingValueType(slot.valueType) } : {}),
}]
})
: []
if (dslEnvVarMetadataSlots.length === 0)
return deploymentOptionEnvVarSlots
const metadataByKey = new Map(
dslEnvVarMetadataSlots.map(slot => [slot.key, slot] as const),
)
return deploymentOptionEnvVarSlots.map((slot) => {
const metadata = metadataByKey.get(slot.key)
if (!metadata)
return slot
const nextSlot = { ...slot }
if (!nextSlot.description && metadata.description)
nextSlot.description = metadata.description
if (!nextSlot.hasDefaultValue && metadata.defaultValue !== undefined) {
nextSlot.defaultValue = metadata.defaultValue
nextSlot.hasDefaultValue = true
}
if (nextSlot.valueType === 'string' && metadata.valueType)
nextSlot.valueType = metadata.valueType
return nextSlot
})
})
const requiredEnvVarsReadyAtom = atom((get) => {
const envVarValues = get(envVarValuesAtom)
return get(deploymentTargetEnvVarSlotsAtom).every(slot =>
envVarSelectionReady(slot, envVarValues[slot.key]),
)
})
export const canDeployAtom = atom((get) => {
const effectiveSelectedEnvironmentId = get(effectiveSelectedEnvironmentIdAtom)
const selectedEnvironment = effectiveSelectedEnvironmentId
? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId))
: undefined
return Boolean(
selectedEnvironment?.id
&& get(deployableEnvironmentsReadyAtom)
&& get(deploymentOptionsReadyAtom)
&& get(requiredBindingsReadyAtom)
&& get(requiredEnvVarsReadyAtom)
&& get(submittedReleaseReadyAtom),
)
})
export const canSkipDeploymentAtom = atom((get) => {
return get(submittedReleaseReadyAtom) && get(deploymentOptionsReadyAtom)
})
export const selectBindingAtom = atom(null, (get, set, slot: string, value: string) => {
set(manualBindingSelectionsAtom, {
...get(manualBindingSelectionsAtom),
[slot]: value,
})
})
export const setEnvVarAtom = atom(null, (get, set, key: string, value: EnvVarValueSelection) => {
set(envVarValuesAtom, {
...get(envVarValuesAtom),
[key]: value,
})
})
// Workflow actions
export const selectMethodAtom = atom(null, (_get, set, method: GuideMethod) => {
set(methodAtom, deploymentGuideMethod(method))
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
set(stepAtom, 'source')
})
// Submission
const createAppInstanceMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions(),
)
const createReleaseMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.releaseService.createRelease.mutationOptions(),
)
const createInitialDeploymentMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.deploymentService.deploy.mutationOptions(),
)
export class CreateDeploymentGuideSubmissionBlockedError extends Error {
reason: 'unsupportedDslMode' | 'deployFailed'
constructor(reason: 'unsupportedDslMode' | 'deployFailed') {
super(reason)
this.reason = reason
this.name = 'CreateDeploymentGuideSubmissionBlockedError'
}
}
export const createDeploymentGuideSubmissionAtom = atom(null, async (get, set, {
deployToEnvironment,
}: {
deployToEnvironment: boolean
}) => {
const method = get(effectiveMethodAtom)
const dslContent = get(dslContentAtom)
const submittedInstanceName = get(instanceNameAtom).trim()
const submittedReleaseName = get(releaseNameAtom).trim()
const submittedReleaseDescription = get(releaseDescriptionAtom).trim()
if (get(isSubmittingDeploymentGuideAtom) || !get(submittedReleaseReadyAtom))
return undefined
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
const deploymentOptions = get(deploymentOptionsQueryAtom).data?.options
const envVarSlots = get(deploymentTargetEnvVarSlotsAtom)
const envVarValues = get(envVarValuesAtom)
const bindingSlots = get(deploymentTargetBindingSlotsAtom)
const bindingSelections = get(deploymentTargetBindingSelectionsAtom)
const selectedEnvironmentId = get(selectedEnvironmentIdAtom)
const effectiveSelectedEnvironmentId = selectedEnvironmentId || get(deployableEnvironmentsAtom)[0]?.id
const selectedEnvironment = effectiveSelectedEnvironmentId
? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId))
: undefined
if (deployToEnvironment && !selectedEnvironment && !selectedEnvironmentId.trim())
return undefined
if (method === 'bindApp' && !effectiveSelectedApp?.id)
return undefined
if (method === 'importDsl' && !dslContent.trim())
return undefined
if (method === 'importDsl' && !isWorkflowDsl(dslContent))
throw new CreateDeploymentGuideSubmissionBlockedError('unsupportedDslMode')
set(submissionUnsupportedDslNodesAtom, [])
try {
if (!deployToEnvironment) {
if (!get(canSkipDeploymentAtom))
return undefined
set(isCreatingReleaseOnlyAtom, true)
try {
const createdAppInstance = await get(createAppInstanceMutationAtom).mutateAsync({
body: {
displayName: submittedInstanceName,
description: get(instanceDescriptionAtom).trim() || undefined,
},
})
const appInstanceId = createdAppInstance.appInstance.id
if (method === 'importDsl') {
await get(createReleaseMutationAtom).mutateAsync({
body: {
appInstanceId,
dsl: encodeDslContent(dslContent),
displayName: submittedReleaseName,
description: submittedReleaseDescription || undefined,
createAppInstance: false,
},
})
return appInstanceId
}
if (!effectiveSelectedApp?.id)
return undefined
await get(createReleaseMutationAtom).mutateAsync({
body: {
appInstanceId,
sourceAppId: effectiveSelectedApp.id,
displayName: submittedReleaseName,
description: submittedReleaseDescription || undefined,
createAppInstance: false,
},
})
return appInstanceId
}
finally {
set(isCreatingReleaseOnlyAtom, false)
}
}
if (!get(canDeployAtom))
return undefined
set(isCreatingDeploymentAtom, true)
try {
const selectedEnvironmentIdentifier = selectedEnvironmentId.trim()
const freshSelectedEnvironment = selectedEnvironment || (
selectedEnvironmentIdentifier
? (await deployableEnvironmentsQuery.refetch()).data?.environments.find(environment =>
environmentMatchesIdentifier(environment, selectedEnvironmentIdentifier),
)
: undefined
)
const targetEnvironmentId = freshSelectedEnvironment?.id
if (!targetEnvironmentId)
throw new CreateDeploymentGuideSubmissionBlockedError('deployFailed')
if (!get(requiredBindingsReadyAtom))
throw new Error('Missing required deployment binding.')
if (!get(requiredEnvVarsReadyAtom))
throw new Error('Missing required deployment environment variable.')
const envVars = envVarSlots.flatMap(slot => envVarInput(slot, envVarValues[slot.key]))
const commonDeploymentRequest = {
newAppInstance: {
displayName: submittedInstanceName,
description: get(instanceDescriptionAtom).trim() || undefined,
},
environmentId: targetEnvironmentId,
releaseName: submittedReleaseName,
releaseDescription: submittedReleaseDescription || undefined,
credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections),
envVars,
idempotencyKey: createDeploymentIdempotencyKey(),
expectedDslDigest: deploymentOptions?.dslDigest,
} satisfies Omit<DeployRequest, 'dsl' | 'sourceAppId'>
const deploymentRequest = method === 'importDsl'
? {
...commonDeploymentRequest,
dsl: encodeDslContent(dslContent),
}
: effectiveSelectedApp?.id
? {
...commonDeploymentRequest,
sourceAppId: effectiveSelectedApp.id,
}
: undefined
if (!deploymentRequest)
return undefined
const response = await get(createInitialDeploymentMutationAtom).mutateAsync({
body: deploymentRequest,
})
return response.appInstance.id
}
finally {
set(isCreatingDeploymentAtom, false)
}
}
catch (error) {
const unsupportedError = await unsupportedDslNodeError(error)
if (unsupportedError?.nodes.length) {
set(submissionUnsupportedDslNodesAtom, unsupportedError.nodes)
return undefined
}
throw error
}
})
// Scoped local state
export const createDeploymentGuideScopedAtoms = [
stepAtom,
methodAtom,
sourceSearchTextAtom,
selectedAppAtom,
dslFileAtom,
dslFileReadVersionAtom,
instanceNameAtom,
instanceDescriptionAtom,
releaseNameAtom,
releaseDescriptionAtom,
autoFilledInstanceNameAtom,
autoFilledReleaseNameAtom,
selectedEnvironmentIdAtom,
manualBindingSelectionsAtom,
envVarValuesAtom,
submissionUnsupportedDslNodesAtom,
isCreatingDeploymentAtom,
isCreatingReleaseOnlyAtom,
]

View File

@ -0,0 +1,37 @@
'use client'
import type { GuideMethod, GuideStep, WorkflowSourceApp } from './types'
import type { EnvVarValues } from '@/features/deployments/shared/components/env-var-bindings'
import type { RuntimeCredentialBindingSelections } from '@/features/deployments/shared/components/runtime-credential-bindings-utils'
import type { UnsupportedDslNode } from '@/features/deployments/shared/domain/error'
import { atom } from 'jotai'
import { deploymentGuideMethod } from './utils'
export const stepAtom = atom<GuideStep>('source')
export const methodAtom = atom<GuideMethod>('bindApp')
export const effectiveMethodAtom = atom(get => deploymentGuideMethod(get(methodAtom)))
export const sourceSearchTextAtom = atom('')
export const selectedAppAtom = atom<WorkflowSourceApp | undefined>(undefined)
export const dslFileAtom = atom<File | undefined>(undefined)
export const dslFileReadVersionAtom = atom(0)
export const instanceNameAtom = atom('')
export const instanceDescriptionAtom = atom('')
export const releaseNameAtom = atom('')
export const releaseDescriptionAtom = atom('')
export const autoFilledInstanceNameAtom = atom('')
export const autoFilledReleaseNameAtom = atom('')
export const selectedEnvironmentIdAtom = atom('')
export const manualBindingSelectionsAtom = atom<RuntimeCredentialBindingSelections>({})
export const envVarValuesAtom = atom<EnvVarValues>({})
export const submissionUnsupportedDslNodesAtom = atom<UnsupportedDslNode[]>([])
export const isCreatingDeploymentAtom = atom(false)
export const isCreatingReleaseOnlyAtom = atom(false)
export const isSubmittingDeploymentGuideAtom = atom(get => (
get(isCreatingDeploymentAtom) || get(isCreatingReleaseOnlyAtom)
))

View File

@ -2,7 +2,7 @@
import type { ReactNode } from 'react'
import { ScopeProvider } from 'jotai-scope'
import { createDeploymentGuideScopedAtoms } from './index'
import { createDeploymentGuideScopedAtoms } from './scoped'
export function CreateDeploymentGuideProvider({ children }: {
children: ReactNode

View File

@ -0,0 +1,169 @@
'use client'
import type { Getter } from 'jotai/vanilla'
import { keepPreviousData, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { encodeDslContent } from '@/features/deployments/shared/domain/dsl'
import { consoleQuery } from '@/service/client'
import { effectiveMethodAtom, instanceNameAtom, submissionUnsupportedDslNodesAtom } from './primitives'
import { dslContentAtom, effectiveSelectedAppAtom, sourceReady } from './source'
import { DEPLOYMENT_PAGE_SIZE, getNextPageParamFromPagination } from './utils'
export const existingInstanceNamesQueryAtom = atomWithInfiniteQuery(() =>
consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
)
export const instanceNameConflictQueryAtom = atomWithQuery((get) => {
const submittedInstanceName = get(instanceNameAtom).trim()
return consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({
input: {
query: {
pageNumber: 1,
resultsPerPage: 1,
displayName: submittedInstanceName,
},
},
enabled: Boolean(submittedInstanceName),
})
})
export const deployableEnvironmentsQueryAtom = atomWithQuery((get) => {
return consoleQuery.enterprise.environmentService.listEnvironments.queryOptions({
input: {
query: {
// The guide offers every deployable environment at once; environment
// count is capped well below the 100-per-page maximum.
pageNumber: 1,
resultsPerPage: 100,
},
},
enabled: sourceReady(get),
})
})
const precheckReleaseQueryAtom = atomWithQuery((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const dslContent = get(dslContentAtom)
const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined
const enabled = sourceReady(get)
// PrecheckRelease takes exactly one source arm (dsl | sourceAppId).
const precheckReleaseQueryOptions = method === 'importDsl'
? consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: encodedDslContent
? {
body: {
dsl: encodedDslContent,
},
}
: skipToken,
enabled,
retry: false,
})
: consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: effectiveSelectedApp?.id
? {
body: {
sourceAppId: effectiveSelectedApp.id,
},
}
: skipToken,
enabled: enabled && Boolean(effectiveSelectedApp?.id),
retry: false,
})
return precheckReleaseQueryOptions
})
function precheckReleaseReady(get: Getter) {
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
return sourceReady(get)
&& precheckReleaseQuery.isSuccess
&& Boolean(precheckReleaseQuery.data?.canCreate)
&& (precheckReleaseQuery.data?.unsupportedNodes.length ?? 0) === 0
&& get(submissionUnsupportedDslNodesAtom).length === 0
}
export const deploymentOptionsQueryAtom = atomWithQuery((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const dslContent = get(dslContentAtom)
const encodedDslContent = dslContent.trim() ? encodeDslContent(dslContent) : undefined
const enabled = precheckReleaseReady(get)
// ComputeDeploymentOptions takes exactly one source arm (dsl | sourceAppId | releaseId).
const deploymentOptionsQueryOptions = method === 'importDsl'
? consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
input: encodedDslContent
? {
body: {
dsl: encodedDslContent,
},
}
: skipToken,
enabled,
retry: false,
})
: consoleQuery.enterprise.releaseService.computeDeploymentOptions.queryOptions({
input: effectiveSelectedApp?.id
? {
body: {
sourceAppId: effectiveSelectedApp.id,
},
}
: skipToken,
enabled: enabled && Boolean(effectiveSelectedApp?.id),
retry: false,
})
return deploymentOptionsQueryOptions
})
export const unsupportedDslNodesAtom = atom((get) => {
const submissionUnsupportedDslNodes = get(submissionUnsupportedDslNodesAtom)
if (submissionUnsupportedDslNodes.length > 0)
return submissionUnsupportedDslNodes
if (!sourceReady(get))
return []
return get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? []
})
const precheckReleaseReadyAtom = atom((get) => {
return precheckReleaseReady(get)
})
export const deploymentOptionsReadyAtom = atom((get) => {
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
return sourceReady(get)
&& get(precheckReleaseReadyAtom)
&& deploymentOptionsQuery.isSuccess
})
export const deploymentOptionsContentCheckedAtom = atom((get) => {
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
const isLoadingOptions = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
const isCheckingReleaseContent = precheckReleaseQuery.isLoading || (precheckReleaseQuery.isFetching && !precheckReleaseQuery.data)
if (!sourceReady(get) || isCheckingReleaseContent || isLoadingOptions)
return false
return get(precheckReleaseReadyAtom) && deploymentOptionsQuery.isSuccess
})

View File

@ -0,0 +1,84 @@
'use client'
import { atom } from 'jotai'
import {
autoFilledInstanceNameAtom,
autoFilledReleaseNameAtom,
envVarValuesAtom,
instanceDescriptionAtom,
instanceNameAtom,
manualBindingSelectionsAtom,
releaseDescriptionAtom,
releaseNameAtom,
selectedEnvironmentIdAtom,
stepAtom,
} from './primitives'
import { deploymentOptionsContentCheckedAtom, existingInstanceNamesQueryAtom, instanceNameConflictQueryAtom } from './queries'
import { sourceReady } from './source'
export const hasInstanceNameConflictAtom = atom((get) => {
const submittedInstanceName = get(instanceNameAtom).trim()
const instanceNameConflictQuery = get(instanceNameConflictQueryAtom)
const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom)
const existingInstanceNames = existingInstanceNamesQuery.data?.pages.flatMap(page =>
page.appInstances.flatMap((appInstance) => {
const name = appInstance.displayName.trim()
return name ? [name] : []
}),
) ?? []
return Boolean(
submittedInstanceName
&& (
existingInstanceNames.includes(submittedInstanceName)
|| (instanceNameConflictQuery.data?.appInstances.some(appInstance => appInstance.displayName.trim() === submittedInstanceName) ?? false)
),
)
})
export const submittedReleaseReadyAtom = atom((get) => {
return Boolean(sourceReady(get) && get(instanceNameAtom).trim() && get(releaseNameAtom).trim())
})
export const releaseCanGoNextAtom = atom((get) => {
const submittedInstanceName = get(instanceNameAtom).trim()
const instanceNameConflictQuery = get(instanceNameConflictQueryAtom)
return Boolean(get(submittedReleaseReadyAtom))
&& !get(hasInstanceNameConflictAtom)
&& !(Boolean(submittedInstanceName) && instanceNameConflictQuery.isLoading)
&& get(deploymentOptionsContentCheckedAtom)
})
export const setInstanceNameAtom = atom(null, (_get, set, value: string) => {
set(instanceNameAtom, value)
set(autoFilledInstanceNameAtom, '')
set(stepAtom, 'release')
})
export const setInstanceDescriptionAtom = atom(null, (_get, set, value: string) => {
set(instanceDescriptionAtom, value)
set(stepAtom, 'release')
})
export const setReleaseNameAtom = atom(null, (_get, set, value: string) => {
set(releaseNameAtom, value)
set(autoFilledReleaseNameAtom, '')
set(stepAtom, 'release')
})
export const setReleaseDescriptionAtom = atom(null, (_get, set, value: string) => {
set(releaseDescriptionAtom, value)
set(stepAtom, 'release')
})
export const continueFromReleaseAtom = atom(null, (get, set) => {
if (!get(releaseCanGoNextAtom))
return
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(stepAtom, 'target')
})

View File

@ -0,0 +1,41 @@
import {
autoFilledInstanceNameAtom,
autoFilledReleaseNameAtom,
dslFileAtom,
dslFileReadVersionAtom,
envVarValuesAtom,
instanceDescriptionAtom,
instanceNameAtom,
isCreatingDeploymentAtom,
isCreatingReleaseOnlyAtom,
manualBindingSelectionsAtom,
methodAtom,
releaseDescriptionAtom,
releaseNameAtom,
selectedAppAtom,
selectedEnvironmentIdAtom,
sourceSearchTextAtom,
stepAtom,
submissionUnsupportedDslNodesAtom,
} from './primitives'
export const createDeploymentGuideScopedAtoms = [
stepAtom,
methodAtom,
sourceSearchTextAtom,
selectedAppAtom,
dslFileAtom,
dslFileReadVersionAtom,
instanceNameAtom,
instanceDescriptionAtom,
releaseNameAtom,
releaseDescriptionAtom,
autoFilledInstanceNameAtom,
autoFilledReleaseNameAtom,
selectedEnvironmentIdAtom,
manualBindingSelectionsAtom,
envVarValuesAtom,
submissionUnsupportedDslNodesAtom,
isCreatingDeploymentAtom,
isCreatingReleaseOnlyAtom,
]

View File

@ -0,0 +1,110 @@
'use client'
import type { Getter } from 'jotai/vanilla'
import type { WorkflowSourceApp } from './types'
import { keepPreviousData, queryOptions } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { dslAppName, isWorkflowDsl } from '@/features/deployments/shared/domain/dsl'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { dslFileAtom, dslFileReadVersionAtom, effectiveMethodAtom, selectedAppAtom, sourceSearchTextAtom } from './primitives'
import { SOURCE_APPS_PAGE_SIZE } from './utils'
const dslFileContentQueryAtom = atomWithQuery((get) => {
const file = get(dslFileAtom)
const fileReadVersion = get(dslFileReadVersionAtom)
return queryOptions({
queryKey: [
'createGuideDslFileContent',
fileReadVersion,
file,
file?.name ?? '',
file?.size ?? 0,
file?.lastModified ?? 0,
],
queryFn: async () => file ? await file.text() : '',
enabled: Boolean(file),
retry: false,
})
})
export const dslContentAtom = atom((get) => {
return get(dslFileContentQueryAtom).data ?? ''
})
export const isReadingDslAtom = atom((get) => {
const file = get(dslFileAtom)
const dslFileContentQuery = get(dslFileContentQueryAtom)
return Boolean(file && (dslFileContentQuery.isLoading || dslFileContentQuery.isFetching))
})
export const dslReadErrorAtom = atom((get) => {
return Boolean(get(dslFileAtom) && get(dslFileContentQueryAtom).isError)
})
export const dslDefaultAppNameAtom = atom((get) => {
const dslContent = get(dslContentAtom)
return dslContent ? dslAppName(dslContent) : ''
})
export const dslUnsupportedModeAtom = atom((get) => {
const dslContent = get(dslContentAtom)
return get(effectiveMethodAtom) === 'importDsl'
&& Boolean(dslContent.trim())
&& !get(isReadingDslAtom)
&& !get(dslReadErrorAtom)
&& !isWorkflowDsl(dslContent)
})
export const importDslReadyAtom = atom((get) => {
return Boolean(get(dslContentAtom).trim())
&& !get(isReadingDslAtom)
&& !get(dslReadErrorAtom)
&& !get(dslUnsupportedModeAtom)
})
export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
const sourceSearchText = get(sourceSearchTextAtom)
return consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
limit: SOURCE_APPS_PAGE_SIZE,
name: sourceSearchText,
mode: AppModeEnum.WORKFLOW,
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
enabled: get(effectiveMethodAtom) === 'bindApp',
})
})
export const effectiveSelectedAppAtom = atom((get) => {
const selectedApp = get(selectedAppAtom)
if (selectedApp)
return selectedApp
const sourceAppsQuery = get(sourceAppsQueryAtom)
if (sourceAppsQuery.isPlaceholderData)
return undefined
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
return sourceApps[0]
})
export function sourceReady(get: Getter) {
const method = get(effectiveMethodAtom)
return method === 'importDsl'
? get(importDslReadyAtom)
: Boolean(get(effectiveSelectedAppAtom)?.id)
}

View File

@ -0,0 +1,221 @@
'use client'
import type { DeployRequest } from '@dify/contracts/enterprise/types.gen'
import { atom } from 'jotai'
import { atomWithMutation } from 'jotai-tanstack-query'
import { selectedDeploymentRuntimeCredentials } from '@/features/deployments/shared/components/runtime-credential-bindings-utils'
import { encodeDslContent, isWorkflowDsl } from '@/features/deployments/shared/domain/dsl'
import { unsupportedDslNodeError } from '@/features/deployments/shared/domain/error'
import { createDeploymentIdempotencyKey } from '@/features/deployments/shared/domain/idempotency'
import { consoleQuery } from '@/service/client'
import { environmentMatchesIdentifier } from './environment'
import {
effectiveMethodAtom,
envVarValuesAtom,
instanceDescriptionAtom,
instanceNameAtom,
isCreatingDeploymentAtom,
isCreatingReleaseOnlyAtom,
isSubmittingDeploymentGuideAtom,
releaseDescriptionAtom,
releaseNameAtom,
selectedEnvironmentIdAtom,
submissionUnsupportedDslNodesAtom,
} from './primitives'
import { deployableEnvironmentsQueryAtom, deploymentOptionsQueryAtom } from './queries'
import { submittedReleaseReadyAtom } from './release'
import { dslContentAtom, effectiveSelectedAppAtom } from './source'
import {
canDeployAtom,
canSkipDeploymentAtom,
deployableEnvironmentsAtom,
deploymentTargetBindingSelectionsAtom,
deploymentTargetBindingSlotsAtom,
deploymentTargetEnvVarSlotsAtom,
requiredBindingsReadyAtom,
requiredEnvVarsReadyAtom,
} from './target'
import { envVarInput } from './utils'
const createAppInstanceMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions(),
)
const createReleaseMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.releaseService.createRelease.mutationOptions(),
)
const createInitialDeploymentMutationAtom = atomWithMutation(() =>
consoleQuery.enterprise.deploymentService.deploy.mutationOptions(),
)
export class CreateDeploymentGuideSubmissionBlockedError extends Error {
reason: 'unsupportedDslMode' | 'deployFailed'
constructor(reason: 'unsupportedDslMode' | 'deployFailed') {
super(reason)
this.reason = reason
this.name = 'CreateDeploymentGuideSubmissionBlockedError'
}
}
export const createDeploymentGuideSubmissionAtom = atom(null, async (get, set, {
deployToEnvironment,
}: {
deployToEnvironment: boolean
}) => {
const method = get(effectiveMethodAtom)
const dslContent = get(dslContentAtom)
const submittedInstanceName = get(instanceNameAtom).trim()
const submittedReleaseName = get(releaseNameAtom).trim()
const submittedReleaseDescription = get(releaseDescriptionAtom).trim()
if (get(isSubmittingDeploymentGuideAtom) || !get(submittedReleaseReadyAtom))
return undefined
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
const deploymentOptions = get(deploymentOptionsQueryAtom).data?.options
const envVarSlots = get(deploymentTargetEnvVarSlotsAtom)
const envVarValues = get(envVarValuesAtom)
const bindingSlots = get(deploymentTargetBindingSlotsAtom)
const bindingSelections = get(deploymentTargetBindingSelectionsAtom)
const selectedEnvironmentId = get(selectedEnvironmentIdAtom)
const effectiveSelectedEnvironmentId = selectedEnvironmentId || get(deployableEnvironmentsAtom)[0]?.id
const selectedEnvironment = effectiveSelectedEnvironmentId
? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId))
: undefined
if (deployToEnvironment && !selectedEnvironment && !selectedEnvironmentId.trim())
return undefined
if (method === 'bindApp' && !effectiveSelectedApp?.id)
return undefined
if (method === 'importDsl' && !dslContent.trim())
return undefined
if (method === 'importDsl' && !isWorkflowDsl(dslContent))
throw new CreateDeploymentGuideSubmissionBlockedError('unsupportedDslMode')
set(submissionUnsupportedDslNodesAtom, [])
try {
if (!deployToEnvironment) {
if (!get(canSkipDeploymentAtom))
return undefined
set(isCreatingReleaseOnlyAtom, true)
try {
const createdAppInstance = await get(createAppInstanceMutationAtom).mutateAsync({
body: {
displayName: submittedInstanceName,
description: get(instanceDescriptionAtom).trim() || undefined,
},
})
const appInstanceId = createdAppInstance.appInstance.id
if (method === 'importDsl') {
await get(createReleaseMutationAtom).mutateAsync({
body: {
appInstanceId,
dsl: encodeDslContent(dslContent),
displayName: submittedReleaseName,
description: submittedReleaseDescription || undefined,
createAppInstance: false,
},
})
return appInstanceId
}
if (!effectiveSelectedApp?.id)
return undefined
await get(createReleaseMutationAtom).mutateAsync({
body: {
appInstanceId,
sourceAppId: effectiveSelectedApp.id,
displayName: submittedReleaseName,
description: submittedReleaseDescription || undefined,
createAppInstance: false,
},
})
return appInstanceId
}
finally {
set(isCreatingReleaseOnlyAtom, false)
}
}
if (!get(canDeployAtom))
return undefined
set(isCreatingDeploymentAtom, true)
try {
const selectedEnvironmentIdentifier = selectedEnvironmentId.trim()
const freshSelectedEnvironment = selectedEnvironment || (
selectedEnvironmentIdentifier
? (await deployableEnvironmentsQuery.refetch()).data?.environments.find(environment =>
environmentMatchesIdentifier(environment, selectedEnvironmentIdentifier),
)
: undefined
)
const targetEnvironmentId = freshSelectedEnvironment?.id
if (!targetEnvironmentId)
throw new CreateDeploymentGuideSubmissionBlockedError('deployFailed')
if (!get(requiredBindingsReadyAtom))
throw new Error('Missing required deployment binding.')
if (!get(requiredEnvVarsReadyAtom))
throw new Error('Missing required deployment environment variable.')
const envVars = envVarSlots.flatMap(slot => envVarInput(slot, envVarValues[slot.key]))
const commonDeploymentRequest = {
newAppInstance: {
displayName: submittedInstanceName,
description: get(instanceDescriptionAtom).trim() || undefined,
},
environmentId: targetEnvironmentId,
releaseName: submittedReleaseName,
releaseDescription: submittedReleaseDescription || undefined,
credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections),
envVars,
idempotencyKey: createDeploymentIdempotencyKey(),
expectedDslDigest: deploymentOptions?.dslDigest,
} satisfies Omit<DeployRequest, 'dsl' | 'sourceAppId'>
const deploymentRequest = method === 'importDsl'
? {
...commonDeploymentRequest,
dsl: encodeDslContent(dslContent),
}
: effectiveSelectedApp?.id
? {
...commonDeploymentRequest,
sourceAppId: effectiveSelectedApp.id,
}
: undefined
if (!deploymentRequest)
return undefined
const response = await get(createInitialDeploymentMutationAtom).mutateAsync({
body: deploymentRequest,
})
return response.appInstance.id
}
finally {
set(isCreatingDeploymentAtom, false)
}
}
catch (error) {
const unsupportedError = await unsupportedDslNodeError(error)
if (unsupportedError?.nodes.length) {
set(submissionUnsupportedDslNodesAtom, unsupportedError.nodes)
return undefined
}
throw error
}
})

View File

@ -0,0 +1,153 @@
'use client'
import type { EnvVarBindingSlot, EnvVarValueSelection } from '@/features/deployments/shared/components/env-var-bindings'
import { atom } from 'jotai'
import { envVarBindingSlotFromContract, envVarBindingValueType } from '@/features/deployments/shared/components/env-var-bindings-utils'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialSlotKey,
selectedRuntimeCredentialSelections,
} from '@/features/deployments/shared/components/runtime-credential-bindings-utils'
import { dslEnvVarSlots } from '@/features/deployments/shared/domain/dsl'
import { environmentMatchesIdentifier } from './environment'
import { effectiveMethodAtom, envVarValuesAtom, manualBindingSelectionsAtom, selectedEnvironmentIdAtom } from './primitives'
import { deployableEnvironmentsQueryAtom, deploymentOptionsQueryAtom, deploymentOptionsReadyAtom } from './queries'
import { submittedReleaseReadyAtom } from './release'
import { dslContentAtom, sourceReady } from './source'
import { envVarSelectionReady } from './utils'
export const deployableEnvironmentsAtom = atom((get) => {
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
return sourceReady(get)
? deployableEnvironmentsQuery.data?.environments ?? []
: []
})
const deployableEnvironmentsReadyAtom = atom((get) => {
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
return sourceReady(get) && deployableEnvironmentsQuery.isSuccess
})
export const effectiveSelectedEnvironmentIdAtom = atom((get) => {
return get(selectedEnvironmentIdAtom) || get(deployableEnvironmentsAtom)[0]?.id
})
export const deploymentTargetBindingSlotsAtom = atom((get) => {
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
return sourceReady(get)
? deploymentOptionsQuery.data?.options?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
: []
})
export const deploymentTargetBindingSelectionsAtom = atom((get) => {
return selectedRuntimeCredentialSelections(
get(deploymentTargetBindingSlotsAtom),
get(manualBindingSelectionsAtom),
)
})
export const requiredBindingsReadyAtom = atom((get) => {
const bindingSelections = get(deploymentTargetBindingSelectionsAtom)
return get(deploymentTargetBindingSlotsAtom).every(slot =>
!hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]),
)
})
export const deploymentTargetEnvVarSlotsAtom = atom((get) => {
const method = get(effectiveMethodAtom)
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
const slots = sourceReady(get) ? deploymentOptionsQuery.data?.options?.envVarSlots : undefined
const dslContent = get(dslContentAtom)
// Deployment options own the canonical slot list; DSL metadata only enriches import-DSL defaults.
const deploymentOptionEnvVarSlots = slots?.flatMap((slot): EnvVarBindingSlot[] => {
const bindingSlot = envVarBindingSlotFromContract(slot)
return bindingSlot ? [bindingSlot] : []
}) ?? []
const dslEnvVarMetadataSlots = method === 'importDsl' && dslContent
? dslEnvVarSlots(dslContent).flatMap((slot) => {
const key = slot.key.trim()
if (!key)
return []
return [{
key,
...(slot.description ? { description: slot.description } : {}),
...(slot.defaultValue !== undefined ? { defaultValue: slot.defaultValue, hasDefaultValue: true } : {}),
...(slot.valueType ? { valueType: envVarBindingValueType(slot.valueType) } : {}),
}]
})
: []
if (dslEnvVarMetadataSlots.length === 0)
return deploymentOptionEnvVarSlots
const metadataByKey = new Map(
dslEnvVarMetadataSlots.map(slot => [slot.key, slot] as const),
)
return deploymentOptionEnvVarSlots.map((slot) => {
const metadata = metadataByKey.get(slot.key)
if (!metadata)
return slot
const nextSlot = { ...slot }
if (!nextSlot.description && metadata.description)
nextSlot.description = metadata.description
if (!nextSlot.hasDefaultValue && metadata.defaultValue !== undefined) {
nextSlot.defaultValue = metadata.defaultValue
nextSlot.hasDefaultValue = true
}
if (nextSlot.valueType === 'string' && metadata.valueType)
nextSlot.valueType = metadata.valueType
return nextSlot
})
})
export const requiredEnvVarsReadyAtom = atom((get) => {
const envVarValues = get(envVarValuesAtom)
return get(deploymentTargetEnvVarSlotsAtom).every(slot =>
envVarSelectionReady(slot, envVarValues[slot.key]),
)
})
export const canDeployAtom = atom((get) => {
const effectiveSelectedEnvironmentId = get(effectiveSelectedEnvironmentIdAtom)
const selectedEnvironment = effectiveSelectedEnvironmentId
? get(deployableEnvironmentsAtom).find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId))
: undefined
return Boolean(
selectedEnvironment?.id
&& get(deployableEnvironmentsReadyAtom)
&& get(deploymentOptionsReadyAtom)
&& get(requiredBindingsReadyAtom)
&& get(requiredEnvVarsReadyAtom)
&& get(submittedReleaseReadyAtom),
)
})
export const canSkipDeploymentAtom = atom((get) => {
return get(submittedReleaseReadyAtom) && get(deploymentOptionsReadyAtom)
})
export const selectBindingAtom = atom(null, (get, set, slot: string, value: string) => {
set(manualBindingSelectionsAtom, {
...get(manualBindingSelectionsAtom),
[slot]: value,
})
})
export const setEnvVarAtom = atom(null, (get, set, key: string, value: EnvVarValueSelection) => {
set(envVarValuesAtom, {
...get(envVarValuesAtom),
[key]: value,
})
})

View File

@ -0,0 +1,5 @@
import type { App, AppModeEnum } from '@/types/app'
export type GuideMethod = 'bindApp' | 'importDsl'
export type GuideStep = 'source' | 'release' | 'target'
export type WorkflowSourceApp = App & { mode: Extract<AppModeEnum, 'workflow'> }

View File

@ -0,0 +1,101 @@
import type { EnvVarInput, Pagination } from '@dify/contracts/enterprise/types.gen'
import type { GuideMethod } from './types'
import type { EnvVarBindingSlot, EnvVarValueSelection } from '@/features/deployments/shared/components/env-var-bindings'
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags'
export const DEPLOYMENT_PAGE_SIZE = 100
export const SOURCE_APPS_PAGE_SIZE = 100
export function getNextPageParamFromPagination(pagination?: Pagination) {
const currentPage = pagination?.currentPage ?? 1
const totalPages = pagination?.totalPages ?? 1
return currentPage < totalPages ? currentPage + 1 : undefined
}
export function deploymentGuideMethod(method: GuideMethod): GuideMethod {
return method === 'importDsl' && !isDeploymentDslImportEnabled
? 'bindApp'
: method
}
const RANDOM_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
const RANDOM_SUFFIX_LENGTH = 4
const RANDOM_SUFFIX_FALLBACK_LENGTH = 6
const RANDOM_SUFFIX_MAX_ATTEMPTS = 16
function randomLetterCombination(length: number) {
const randomValues = new Uint8Array(length)
if (globalThis.crypto) {
globalThis.crypto.getRandomValues(randomValues)
}
else {
randomValues.forEach((_, index) => {
randomValues[index] = Math.floor(Math.random() * 256)
})
}
return Array.from(randomValues, value => RANDOM_SUFFIX_ALPHABET[value % RANDOM_SUFFIX_ALPHABET.length]).join('')
}
export function availableInstanceName(sourceName: string, existingNameSet: Set<string>) {
if (!existingNameSet.has(sourceName))
return sourceName
for (let attempt = 0; attempt < RANDOM_SUFFIX_MAX_ATTEMPTS; attempt++) {
const candidate = `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_LENGTH)}`
if (!existingNameSet.has(candidate))
return candidate
}
return `${sourceName}-${randomLetterCombination(RANDOM_SUFFIX_FALLBACK_LENGTH)}`
}
function envVarValueSource(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
return selection?.valueSource
?? (slot.hasDefaultValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_DSL_DEFAULT
: slot.hasLastValue
? ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYMENT
: ApiEnvVarValueSource.ENV_VAR_VALUE_SOURCE_LITERAL)
}
export function envVarSelectionReady(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined) {
const valueSource = envVarValueSource(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 function envVarInput(slot: EnvVarBindingSlot, selection: EnvVarValueSelection | undefined): EnvVarInput[] {
const valueSource = envVarValueSource(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,
}]
}

View File

@ -0,0 +1,122 @@
'use client'
import type { WorkflowSourceApp } from './types'
import { atom } from 'jotai'
import {
autoFilledInstanceNameAtom,
autoFilledReleaseNameAtom,
dslFileAtom,
dslFileReadVersionAtom,
effectiveMethodAtom,
envVarValuesAtom,
instanceNameAtom,
manualBindingSelectionsAtom,
methodAtom,
releaseNameAtom,
selectedAppAtom,
selectedEnvironmentIdAtom,
sourceSearchTextAtom,
stepAtom,
submissionUnsupportedDslNodesAtom,
} from './primitives'
import { deploymentOptionsContentCheckedAtom, existingInstanceNamesQueryAtom } from './queries'
import { dslDefaultAppNameAtom, effectiveSelectedAppAtom, importDslReadyAtom } from './source'
import { availableInstanceName, deploymentGuideMethod } from './utils'
export const sourceCanGoNextAtom = atom((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const importDslReady = method === 'importDsl' && get(importDslReadyAtom)
const bindAppReady = method === 'bindApp' && Boolean(effectiveSelectedApp?.id)
return (importDslReady || bindAppReady) && get(deploymentOptionsContentCheckedAtom)
})
export const setSourceSearchTextAtom = atom(null, (get, set, value: string) => {
if (get(sourceSearchTextAtom) === value)
return
set(sourceSearchTextAtom, value)
set(selectedAppAtom, undefined)
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
})
export const selectSourceAppAtom = atom(null, (_get, set, app: WorkflowSourceApp) => {
set(selectedAppAtom, app)
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
})
export const continueFromSourceAtom = atom(null, (get, set, {
defaultDslAppName,
defaultReleaseName,
}: {
defaultDslAppName: string
defaultReleaseName: string
}) => {
if (!get(sourceCanGoNextAtom))
return
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
if (method === 'bindApp' && effectiveSelectedApp)
set(selectSourceAppAtom, effectiveSelectedApp)
const sourceName = method === 'importDsl'
? get(dslDefaultAppNameAtom) || defaultDslAppName
: effectiveSelectedApp?.name
const nextInstanceName = sourceName?.trim()
if (nextInstanceName) {
const currentInstanceName = get(instanceNameAtom).trim()
const autoFilledInstanceName = get(autoFilledInstanceNameAtom)
const existingInstanceNamesQuery = get(existingInstanceNamesQueryAtom)
const existingNameSet = new Set(
existingInstanceNamesQuery.data?.pages.flatMap(page =>
page.appInstances.flatMap((appInstance) => {
const name = appInstance.displayName.trim()
return name ? [name] : []
}),
) ?? [],
)
if (!currentInstanceName || currentInstanceName === autoFilledInstanceName) {
const nextAvailableInstanceName = availableInstanceName(nextInstanceName, existingNameSet)
set(instanceNameAtom, nextAvailableInstanceName)
set(autoFilledInstanceNameAtom, nextAvailableInstanceName)
}
}
const currentReleaseName = get(releaseNameAtom).trim()
const autoFilledReleaseName = get(autoFilledReleaseNameAtom)
if (!currentReleaseName || currentReleaseName === autoFilledReleaseName) {
set(releaseNameAtom, defaultReleaseName)
set(autoFilledReleaseNameAtom, defaultReleaseName)
}
set(stepAtom, 'release')
})
export const selectDslFileAtom = atom(null, (get, set, dslFile?: File) => {
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
set(dslFileReadVersionAtom, get(dslFileReadVersionAtom) + 1)
set(dslFileAtom, dslFile)
})
export const selectMethodAtom = atom(null, (_get, set, method: Parameters<typeof deploymentGuideMethod>[0]) => {
set(methodAtom, deploymentGuideMethod(method))
set(selectedEnvironmentIdAtom, '')
set(manualBindingSelectionsAtom, {})
set(envVarValuesAtom, {})
set(submissionUnsupportedDslNodesAtom, [])
set(stepAtom, 'source')
})

View File

@ -16,9 +16,7 @@ const mocks = vi.hoisted(() => {
return {
sourceAppsQuery,
useInfiniteScroll: vi.fn(() => ({
rootEl: null,
rootRef: vi.fn(),
sentinelEl: null,
sentinelRef: vi.fn(),
})),
}
@ -28,29 +26,51 @@ vi.mock('@/features/deployments/shared/hooks/use-infinite-scroll', () => ({
useInfiniteScroll: mocks.useInfiniteScroll,
}))
vi.mock('@/features/deployments/create-guide/state', async () => {
vi.mock('@/features/deployments/create-guide/state/primitives', async () => {
const { atom } = await import('jotai')
const methodAtom = atom<'bindApp' | 'importDsl'>('bindApp')
return {
dslFileAtom: atom<File | undefined>(undefined),
effectiveMethodAtom: atom(get => get(methodAtom)),
methodAtom,
sourceSearchTextAtom: atom(''),
}
})
vi.mock('@/features/deployments/create-guide/state/source', async () => {
const { atom } = await import('jotai')
return {
dslReadErrorAtom: atom(false),
dslUnsupportedModeAtom: atom(false),
effectiveSelectedAppAtom: atom(undefined),
isReadingDslAtom: atom(false),
sourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
}
})
vi.mock('@/features/deployments/create-guide/state/workflow', async () => {
const { atom } = await import('jotai')
const { methodAtom } = await import('@/features/deployments/create-guide/state/primitives')
const emptyActionAtom = atom(null, () => undefined)
return {
continueFromSourceAtom: emptyActionAtom,
dslFileAtom: atom<File | undefined>(undefined),
dslReadErrorAtom: atom(false),
dslUnsupportedModeAtom: atom(false),
effectiveMethodAtom: atom(get => get(methodAtom)),
effectiveSelectedAppAtom: atom(undefined),
isReadingDslAtom: atom(false),
methodAtom,
selectDslFileAtom: emptyActionAtom,
selectMethodAtom: atom(null, (_get, set, value: 'bindApp' | 'importDsl') => {
set(methodAtom, value)
}),
selectSourceAppAtom: emptyActionAtom,
setSourceSearchTextAtom: emptyActionAtom,
sourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
sourceCanGoNextAtom: atom(false),
sourceSearchTextAtom: atom(''),
}
})
vi.mock('@/features/deployments/create-guide/state/queries', async () => {
const { atom } = await import('jotai')
return {
unsupportedDslNodesAtom: atom([]),
}
})

View File

@ -1,11 +1,11 @@
'use client'
import type { ReactNode } from 'react'
import type { GuideStep } from '@/features/deployments/create-guide/state'
import type { GuideStep } from '@/features/deployments/create-guide/state/types'
import { cn } from '@langgenius/dify-ui/cn'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useTranslation } from 'react-i18next'
import { TitleTooltip } from '@/features/deployments/components/title-tooltip'
import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip'
const GUIDE_PROGRESS_STEPS: GuideStep[] = ['source', 'release', 'target']

View File

@ -5,22 +5,24 @@ import { Input } from '@langgenius/dify-ui/input'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
continueFromReleaseAtom,
dslDefaultAppNameAtom,
effectiveMethodAtom,
hasInstanceNameConflictAtom,
instanceDescriptionAtom,
instanceNameAtom,
releaseCanGoNextAtom,
releaseDescriptionAtom,
releaseNameAtom,
selectedAppAtom,
stepAtom,
} from '@/features/deployments/create-guide/state/primitives'
import {
continueFromReleaseAtom,
hasInstanceNameConflictAtom,
releaseCanGoNextAtom,
setInstanceDescriptionAtom,
setInstanceNameAtom,
setReleaseDescriptionAtom,
setReleaseNameAtom,
stepAtom,
} from '@/features/deployments/create-guide/state'
} from '@/features/deployments/create-guide/state/release'
import { dslDefaultAppNameAtom } from '@/features/deployments/create-guide/state/source'
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'

View File

@ -1,7 +1,7 @@
'use client'
import { useAtomValue } from 'jotai'
import { stepAtom } from '@/features/deployments/create-guide/state'
import { stepAtom } from '@/features/deployments/create-guide/state/primitives'
import { GuideCard, GuideFrame } from './layout'
import {
ReleaseActionButtons,

View File

@ -1,6 +1,6 @@
'use client'
import type { GuideMethod, WorkflowSourceApp } from '@/features/deployments/create-guide/state'
import type { GuideMethod, WorkflowSourceApp } from '@/features/deployments/create-guide/state/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
@ -11,26 +11,30 @@ 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,
effectiveMethodAtom,
sourceSearchTextAtom,
} from '@/features/deployments/create-guide/state/primitives'
import { unsupportedDslNodesAtom } from '@/features/deployments/create-guide/state/queries'
import {
dslReadErrorAtom,
dslUnsupportedModeAtom,
effectiveMethodAtom,
effectiveSelectedAppAtom,
isReadingDslAtom,
sourceAppsQueryAtom,
} from '@/features/deployments/create-guide/state/source'
import {
continueFromSourceAtom,
selectDslFileAtom,
selectMethodAtom,
selectSourceAppAtom,
setSourceSearchTextAtom,
sourceAppsQueryAtom,
sourceCanGoNextAtom,
sourceSearchTextAtom,
unsupportedDslNodesAtom,
} from '@/features/deployments/create-guide/state'
} from '@/features/deployments/create-guide/state/workflow'
import { DeploymentStateMessage } from '@/features/deployments/shared/components/empty-state'
import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip'
import { UnsupportedDslNodesAlert } from '@/features/deployments/shared/components/unsupported-dsl-nodes-alert'
import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags'
import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll'
import { StepShell } from './layout'

View File

@ -10,34 +10,40 @@ 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'
envVarValuesAtom,
isCreatingReleaseOnlyAtom,
isSubmittingDeploymentGuideAtom,
selectedEnvironmentIdAtom,
stepAtom,
} from '@/features/deployments/create-guide/state/primitives'
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'
deployableEnvironmentsQueryAtom,
deploymentOptionsQueryAtom,
unsupportedDslNodesAtom,
} from '@/features/deployments/create-guide/state/queries'
import {
createDeploymentGuideSubmissionAtom,
CreateDeploymentGuideSubmissionBlockedError,
} from '@/features/deployments/create-guide/state/submission'
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'
} from '@/features/deployments/create-guide/state/target'
import {
EnvVarBindingsPanel,
} from '@/features/deployments/shared/components/env-var-bindings'
import {
RuntimeCredentialBindingsPanel,
} from '@/features/deployments/shared/components/runtime-credential-bindings'
import { TitleTooltip } from '@/features/deployments/shared/components/title-tooltip'
import { UnsupportedDslNodesAlert } from '@/features/deployments/shared/components/unsupported-dsl-nodes-alert'
import { deploymentErrorMessage } from '@/features/deployments/shared/domain/error'
import { useRouter } from '@/next/navigation'
import { StepShell } from './layout'

View File

@ -0,0 +1,18 @@
# Deployment Create Release
Release creation dialog for selecting a source app or DSL file and creating a new release for an existing deployment app instance.
## Internal Modules
| Module | Why this module uses it |
| -------- | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
## External Modules
| Module | Why this module uses it |
| --------------------------------------------------- | -------------------------------------------------------------------------------- |
| `app/components/app/create-from-dsl-modal/uploader` | Reuses the existing DSL file uploader for the DSL release source. |
| `app/components/base/app-icon` | Renders source app icons consistently in the source app picker. |
| `app/components/base/skeleton` | Reuses skeleton primitives for source app picker loading rows. |
| `types/app` | Uses app types and mode enums to narrow selectable source apps to workflow apps. |

View File

@ -1,5 +1,5 @@
import type { Getter } from 'jotai'
import { QueryClient } from '@tanstack/react-query'
import { QueryClient, skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -33,6 +33,10 @@ const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const mockQueryOptions = vi.hoisted(() => ({
current: new Map<string, QueryOptions | InfiniteQueryOptions>(),
}))
const mockCreateReleaseMutation = vi.hoisted<{ current: MutationResult }>(() => ({
current: {
isPending: false,
@ -57,6 +61,8 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => {
? undefined
: mockQueryResults.current.get(queryName)
mockQueryOptions.current.set(queryName, options)
return {
...options,
data: undefined,
@ -75,6 +81,8 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => {
? undefined
: mockQueryResults.current.get(queryName)
mockQueryOptions.current.set(queryName, options)
return {
...options,
data: undefined,
@ -180,6 +188,7 @@ describe('create release state with DSL import enabled', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
mockQueryOptions.current.clear()
mockCreateReleaseMutation.current = {
isPending: false,
mutateAsync: vi.fn(),
@ -207,6 +216,22 @@ describe('create release state with DSL import enabled', () => {
unsubscribe()
})
it('should skip DSL release precheck input until DSL content is ready', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
store.set(state.selectCreateReleaseSourceModeAtom, 'dsl')
store.get(state.isCheckingCreateReleaseContentAtom)
expect(mockQueryOptions.current.get('precheckRelease')).toMatchObject({
enabled: false,
input: skipToken,
})
unsubscribe()
})
it('should submit a DSL release with encoded workflow content', async () => {
const { state, store, unsubscribe } = await mountedStore()
const response = {

View File

@ -1,6 +1,6 @@
import type { Getter } from 'jotai'
import type { CreateReleaseFormValues } from '../index'
import { QueryClient } from '@tanstack/react-query'
import { QueryClient, skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@ -31,6 +31,10 @@ const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const mockQueryOptions = vi.hoisted(() => ({
current: new Map<string, QueryOptions>(),
}))
const mockCreateReleaseMutation = vi.hoisted<{ current: MutationResult }>(() => ({
current: {
isPending: false,
@ -51,6 +55,8 @@ vi.mock('jotai-tanstack-query', async (importOriginal) => {
? undefined
: mockQueryResults.current.get(queryName)
mockQueryOptions.current.set(queryName, options)
return {
...options,
data: undefined,
@ -226,6 +232,7 @@ describe('create release state', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
mockQueryOptions.current.clear()
mockCreateReleaseMutation.current = {
isPending: false,
mutateAsync: vi.fn(),
@ -334,6 +341,37 @@ describe('create release state', () => {
unsubscribe()
})
it('should reset source app search text when opening or closing the dialog', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseSourceAppSearchTextAtom, 'customer')
store.set(state.openCreateReleaseDialogAtom)
expect(store.get(state.createReleaseSourceAppSearchTextAtom)).toBe('')
store.set(state.createReleaseSourceAppSearchTextAtom, 'support')
store.set(state.closeCreateReleaseDialogAtom)
expect(store.get(state.createReleaseSourceAppSearchTextAtom)).toBe('')
unsubscribe()
})
it('should skip release content precheck input until source content is ready', async () => {
const { state, store, unsubscribe } = await mountedStore()
store.set(state.createReleaseAppInstanceIdAtom, 'app-instance-1')
store.set(state.openCreateReleaseDialogAtom)
store.get(state.isCheckingCreateReleaseContentAtom)
expect(mockQueryOptions.current.get('precheckRelease')).toMatchObject({
enabled: false,
input: skipToken,
})
unsubscribe()
})
it('should capture DSL file read failures and clear them when opening or closing the dialog', async () => {
const { state, store, unsubscribe } = await mountedStore()
const file = new File(['broken'], 'broken.yml', { type: 'text/yaml' })

View File

@ -361,7 +361,7 @@ const precheckReleaseQueryAtom = atomWithQuery((get) => {
const canCheck = canCheckReleaseContent(get)
return consoleQuery.enterprise.releaseService.precheckRelease.queryOptions({
input: appInstanceId
input: canCheck && appInstanceId
? releaseSourceMode === 'dsl'
? {
body: {
@ -424,14 +424,20 @@ const resetCreateReleaseDslFileAtom = atom(null, (get, set) => {
set(createReleaseDslFileReadVersionAtom, get(createReleaseDslFileReadVersionAtom) + 1)
})
const resetCreateReleaseSourceAppSearchAtom = atom(null, (_get, set) => {
set(createReleaseSourceAppSearchTextAtom, '')
})
export const openCreateReleaseDialogAtom = atom(null, (_get, set) => {
set(resetCreateReleaseDslFileAtom)
set(resetCreateReleaseSourceAppSearchAtom)
set(createReleaseDialogOpenAtom, true)
})
export const closeCreateReleaseDialogAtom = atom(null, (_get, set) => {
set(createReleaseDialogOpenAtom, false)
set(resetCreateReleaseDslFileAtom)
set(resetCreateReleaseSourceAppSearchAtom)
})
export const requestCloseCreateReleaseDialogAtom = atom(null, (get, set) => {

View File

@ -25,9 +25,7 @@ const mocks = vi.hoisted(() => {
return {
sourceAppsQuery,
useInfiniteScroll: vi.fn(() => ({
rootEl: null,
rootRef: vi.fn(),
sentinelEl: null,
sentinelRef: vi.fn(),
})),
}

View File

@ -2,7 +2,7 @@
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { UnsupportedDslNodesAlert } from '../../components/unsupported-dsl-nodes-alert'
import { UnsupportedDslNodesAlert } from '../../shared/components/unsupported-dsl-nodes-alert'
import {
createReleaseContentCheckFailedAtom,
createReleaseMatchedReleaseAtom,

View File

@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll'
import { TitleTooltip } from '../../components/title-tooltip'
import { TitleTooltip } from '../../shared/components/title-tooltip'
import {
createReleaseSourceAppSearchTextAtom,
createReleaseSourceAppsQueryAtom,

View File

@ -0,0 +1,15 @@
# Deployment Deploy Drawer
Drawer workflow for choosing a release, selecting an environment, binding runtime credentials, and starting a deployment.
## Internal Modules
| Module | Why this module uses it |
| -------- | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
## External Modules
| Module | Why this module uses it |
| ------------------------------ | ------------------------------------------------------------ |
| `app/components/base/skeleton` | Reuses skeleton primitives for drawer form loading sections. |

View File

@ -13,9 +13,21 @@ import {
PluginCategory,
RuntimeInstanceStatus,
} from '@dify/contracts/enterprise/types.gen'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { beforeEach, describe, expect, it, vi } from 'vitest'
type QueryOptions = {
data?: unknown
enabled?: boolean
input?: unknown
isError?: boolean
isFetching?: boolean
isLoading?: boolean
queryKey?: readonly unknown[]
retry?: boolean
}
type QueryResult = {
data?: {
options: DeploymentOptions
@ -62,9 +74,22 @@ const mockRollbackMutation = vi.hoisted<{ current: MutationResult }>(() => ({
}))
vi.mock('jotai-tanstack-query', () => ({
atomWithQuery: (createOptions: (get: Getter) => unknown) => atom((get) => {
createOptions(get)
return mockDeploymentOptionsQuery.current
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
const options = createOptions(get)
if (options.queryKey?.[0] === 'computeDeploymentOptions') {
return {
...options,
...mockDeploymentOptionsQuery.current,
}
}
return {
...options,
data: undefined,
isLoading: false,
isFetching: false,
isError: false,
}
}),
atomWithMutation: (createOptions: () => MutationOptions) => atom(() => {
const options = createOptions()
@ -78,6 +103,13 @@ vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
releaseService: {
computeReleaseDeploymentView: {
queryOptions: ({ enabled, input }: { enabled: boolean, input: unknown }) => ({
enabled,
input,
queryKey: ['computeReleaseDeploymentView', input],
}),
},
computeDeploymentOptions: {
queryOptions: ({ enabled, input }: { enabled: boolean, input: unknown }) => ({
enabled,
@ -256,6 +288,23 @@ describe('deploy drawer state', () => {
expect(store.get(state.deployDrawerReleaseIdAtom)).toBeUndefined()
})
it('should disable release deployment view query with skipToken until form app instance exists', async () => {
const state = await loadState()
const store = createStore()
expect(store.get(state.releaseDeploymentViewQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
store.set(state.deployFormAppInstanceIdAtom, 'app-instance-1')
expect(store.get(state.releaseDeploymentViewQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
})
it('should derive default environment and release selections from config', async () => {
const state = await loadState()
const store = createStore()

View File

@ -11,21 +11,21 @@ import type {
EnvVarBindingSlot,
EnvVarValues,
EnvVarValueSelection,
} from '../../components/env-var-bindings'
import type { RuntimeCredentialBindingSelections } from '../../components/runtime-credential-bindings-utils'
} from '../../shared/components/env-var-bindings'
import type { RuntimeCredentialBindingSelections } from '../../shared/components/runtime-credential-bindings-utils'
import { EnvVarValueSource as ApiEnvVarValueSource } from '@dify/contracts/enterprise/types.gen'
import { toast } from '@langgenius/dify-ui/toast'
import { skipToken } from '@tanstack/react-query'
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 { envVarBindingSlotFromContract } from '../../shared/components/env-var-bindings-utils'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialSlotKey,
selectedDeploymentRuntimeCredentials,
selectedRuntimeCredentialSelections,
} from '../../components/runtime-credential-bindings-utils'
} from '../../shared/components/runtime-credential-bindings-utils'
import { createDeploymentIdempotencyKey } from '../../shared/domain/idempotency'
import { releaseDeploymentAction } from '../../shared/domain/release-action'
@ -39,7 +39,7 @@ 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 deployFormAppInstanceIdAtom = atom('')
export const deployFormAppInstanceIdAtom = atom<string | undefined>(undefined)
export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => {
set(deployDrawerAppInstanceIdAtom, params.appInstanceId)
@ -72,9 +72,11 @@ export const releaseDeploymentViewQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deployFormAppInstanceIdAtom)
return consoleQuery.enterprise.releaseService.computeReleaseDeploymentView.queryOptions({
input: {
params: { appInstanceId },
},
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
})
})

View File

@ -1,13 +1,13 @@
'use client'
import type { CredentialSlot, Environment } from '@dify/contracts/enterprise/types.gen'
import type { RuntimeCredentialBindingSelections } from '../../components/runtime-credential-bindings-utils'
import type { RuntimeCredentialBindingSelections } from '../../shared/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 { DeploymentStateMessage } from '../../components/empty-state'
import { RuntimeCredentialBindingsPanel } from '../../components/runtime-credential-bindings'
import { DeploymentStateMessage } from '../../shared/components/empty-state'
import { RuntimeCredentialBindingsPanel } from '../../shared/components/runtime-credential-bindings'
import { formatDate, releaseCommit } from '../../shared/domain/release'
import {
deployDisplayedReleaseAtom,

View File

@ -9,7 +9,7 @@ import { Button } from '@langgenius/dify-ui/button'
import { useAtomValue, useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { EnvVarBindingsPanel } from '../../components/env-var-bindings'
import { EnvVarBindingsPanel } from '../../shared/components/env-var-bindings'
import { isAvailableDeploymentTarget } from '../../shared/domain/runtime-status'
import { canAttemptDeployAtom, canSubmitDeployAtom, closeDeployDrawerAtom, deployBindingSlotsAtom, deployEnvVarSlotsAtom, deployEnvVarValuesAtom, deployFormAppInstanceIdAtom, deployHasBindingOptionsErrorAtom, deployHasSelectedEnvironmentAtom, deployIsBindingOptionsLoadingAtom, deployReadyFormConfigAtom, deployReadyFormLocalAtoms, deployReleaseSubmissionAtom, deploySelectedBindingsAtom, deployShowValidationErrorsAtom, deployTargetReleaseIdAtom, isDeployReleaseSubmittingAtom, releaseDeploymentViewQueryAtom, selectDeployBindingAtom, setDeployEnvVarAtom, showDeployValidationErrorsAtom } from '../state'
import {

View File

@ -6,7 +6,7 @@ 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 { TitleTooltip } from '../../components/title-tooltip'
import { TitleTooltip } from '../../shared/components/title-tooltip'
import { ModeBadge } from './status-badge'
export function Field({ label, hint, children }: {

View File

@ -0,0 +1,11 @@
# Deployment Actions
Action menu and edit/delete dialogs for deployment app instances.
## Internal Modules
None.
## External Modules
None.

View File

@ -1,42 +1,13 @@
import type { Getter } from 'jotai/vanilla'
import type { DeploymentActionAppInstance } from '../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ScopeProvider } from 'jotai-scope'
import { DeleteDeploymentDialog } from '../delete-dialog'
import {
deleteDeploymentDialogOpenAtom,
deploymentActionAppInstanceIdAtom,
deploymentActionAppInstanceAtom,
} from '../state'
type QueryOptions = {
input?: unknown
queryKey?: readonly unknown[]
}
type QueryResult = {
data?: unknown
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const useQueryMock = vi.hoisted(() =>
vi.fn((options: QueryOptions) => {
const queryName = String(options.queryKey?.[0] ?? 'unknown')
const queryResult = mockQueryResults.current.get(queryName)
return {
...options,
data: queryResult?.data,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: Boolean(queryResult?.data),
}
}),
)
const deleteMutationMock = vi.hoisted(() => ({
isPending: false,
mutate: vi.fn(),
@ -58,16 +29,6 @@ const toastMock = vi.hoisted(() => ({
success: vi.fn(),
}))
vi.mock('jotai-tanstack-query', async () => {
const { atom } = await import('jotai')
return {
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
return useQueryMock(createOptions(get))
}),
}
})
vi.mock('@tanstack/react-query', () => ({
useMutation: useMutationMock,
}))
@ -84,12 +45,6 @@ vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
deleteAppInstance: {
mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }),
},
@ -98,26 +53,26 @@ vi.mock('@/service/client', () => ({
},
}))
function setAppInstance() {
mockQueryResults.current.set('getAppInstance', {
data: {
appInstance: {
id: 'app-instance-1',
displayName: 'Deployment 1',
},
},
})
function createAppInstance(overrides: Partial<DeploymentActionAppInstance> = {}): DeploymentActionAppInstance {
return {
id: 'app-instance-1',
displayName: 'Deployment 1',
description: 'Initial description',
...overrides,
}
}
function renderDialog({
appInstance = createAppInstance(),
open = true,
}: {
appInstance?: DeploymentActionAppInstance
open?: boolean
} = {}) {
render(
<ScopeProvider
atoms={[
[deploymentActionAppInstanceIdAtom, 'app-instance-1'],
[deploymentActionAppInstanceAtom, appInstance],
[deleteDeploymentDialogOpenAtom, open],
]}
name="DeleteDeploymentDialogTest"
@ -130,16 +85,13 @@ function renderDialog({
describe('DeleteDeploymentDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
deleteMutationMock.isPending = false
setAppInstance()
})
describe('Delete action', () => {
it('should not mount the query or delete mutation before the dialog is opened', () => {
it('should not mount the delete mutation before the dialog is opened', () => {
renderDialog({ open: false })
expect(useQueryMock).not.toHaveBeenCalled()
expect(useMutationMock).not.toHaveBeenCalled()
})

View File

@ -1,44 +1,13 @@
import type { Getter } from 'jotai/vanilla'
import type { DeploymentActionAppInstance } from '../types'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ScopeProvider } from 'jotai-scope'
import { EditDeploymentDialog } from '../edit-dialog'
import {
deploymentActionAppInstanceIdAtom,
deploymentActionAppInstanceAtom,
editDeploymentDialogOpenAtom,
} from '../state'
type QueryOptions = {
input?: unknown
queryKey?: readonly unknown[]
}
type QueryResult = {
data?: unknown
isError?: boolean
isLoading?: boolean
}
const mockQueryResults = vi.hoisted(() => ({
current: new Map<string, QueryResult>(),
}))
const useQueryMock = vi.hoisted(() =>
vi.fn((options: QueryOptions) => {
const queryName = String(options.queryKey?.[0] ?? 'unknown')
const queryResult = mockQueryResults.current.get(queryName)
return {
...options,
data: queryResult?.data,
isError: queryResult?.isError ?? false,
isFetching: false,
isLoading: queryResult?.isLoading ?? false,
isSuccess: Boolean(queryResult?.data),
}
}),
)
const updateMutationMock = vi.hoisted(() => ({
isPending: false,
mutate: vi.fn(),
@ -56,16 +25,6 @@ const toastMock = vi.hoisted(() => ({
success: vi.fn(),
}))
vi.mock('jotai-tanstack-query', async () => {
const { atom } = await import('jotai')
return {
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
return useQueryMock(createOptions(get))
}),
}
})
vi.mock('@tanstack/react-query', () => ({
useMutation: useMutationMock,
}))
@ -78,51 +37,34 @@ vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
updateAppInstance: {
mutationOptions: () => ({ mutationKey: ['updateAppInstance'] }),
},
deleteAppInstance: {
mutationOptions: () => ({ mutationKey: ['deleteAppInstance'] }),
},
},
},
},
}))
function setAppInstance(overrides: Record<string, unknown> = {}) {
mockQueryResults.current.set('getAppInstance', {
data: {
appInstance: {
id: 'app-instance-1',
displayName: 'Deployment 1',
description: 'Initial description',
...overrides,
},
},
})
}
function setAppInstanceLoading() {
mockQueryResults.current.set('getAppInstance', {
isLoading: true,
})
function createAppInstance(overrides: Partial<DeploymentActionAppInstance> = {}): DeploymentActionAppInstance {
return {
id: 'app-instance-1',
displayName: 'Deployment 1',
description: 'Initial description',
...overrides,
}
}
function renderDialog({
appInstance = createAppInstance(),
open = true,
}: {
appInstance?: DeploymentActionAppInstance
open?: boolean
} = {}) {
render(
<ScopeProvider
atoms={[
[deploymentActionAppInstanceIdAtom, 'app-instance-1'],
[deploymentActionAppInstanceAtom, appInstance],
[editDeploymentDialogOpenAtom, open],
]}
name="EditDeploymentDialogTest"
@ -135,25 +77,22 @@ function renderDialog({
describe('EditDeploymentDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryResults.current.clear()
updateMutationMock.isPending = false
setAppInstance()
})
describe('Form submission', () => {
it('should not mount the query or update mutation before the dialog is opened', () => {
it('should not mount the update mutation before the dialog is opened', () => {
renderDialog({ open: false })
expect(useQueryMock).not.toHaveBeenCalled()
expect(useMutationMock).not.toHaveBeenCalled()
})
it('should create the update mutation only after the edit form is ready', () => {
setAppInstanceLoading()
it('should render form values from the passed app instance', () => {
renderDialog()
expect(useMutationMock).not.toHaveBeenCalled()
const dialog = screen.getByRole('dialog', { name: 'deployments.card.menu.editInfo' })
expect(within(dialog).getByRole('textbox', { name: 'deployments.settings.name' })).toHaveValue('Deployment 1')
expect(within(dialog).getByRole('textbox', { name: 'deployments.settings.description' })).toHaveValue('Initial description')
})
it('should submit trimmed deployment metadata through the component mutation', async () => {

View File

@ -1,66 +1,51 @@
import type { DeploymentActionAppInstance } from '../types'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DeploymentActionsMenu } from './index'
type QueryOptions = {
input?: unknown
queryKey?: readonly unknown[]
}
import { DeploymentActionsMenu } from '../index'
const editDialogMock = vi.hoisted(() => vi.fn())
const deleteDialogMock = vi.hoisted(() => vi.fn())
const prefetchQueryMock = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
prefetchQuery: prefetchQueryMock,
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstance', options.input],
}),
},
},
},
},
}))
vi.mock('./edit-dialog', async () => {
vi.mock('../edit-dialog', async () => {
const { useAtomValue } = await import('jotai')
const { editDeploymentDialogOpenAtom } = await import('./state')
const { deploymentActionAppInstanceAtom, editDeploymentDialogOpenAtom } = await import('../state')
return {
EditDeploymentDialog: () => {
const open = useAtomValue(editDeploymentDialogOpenAtom)
editDialogMock({ open })
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
editDialogMock({ appInstanceId: appInstance.id, open })
return <div data-testid="edit-dialog" data-open={String(open)} />
},
}
})
vi.mock('./delete-dialog', async () => {
vi.mock('../delete-dialog', async () => {
const { useAtomValue } = await import('jotai')
const { deleteDeploymentDialogOpenAtom } = await import('./state')
const { deleteDeploymentDialogOpenAtom, deploymentActionAppInstanceAtom } = await import('../state')
return {
DeleteDeploymentDialog: () => {
const open = useAtomValue(deleteDeploymentDialogOpenAtom)
deleteDialogMock({ open })
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
deleteDialogMock({ appInstanceId: appInstance.id, open })
return <div data-testid="delete-dialog" data-open={String(open)} />
},
}
})
function createAppInstance(overrides: Partial<DeploymentActionAppInstance> = {}): DeploymentActionAppInstance {
return {
id: 'app-instance-1',
displayName: 'Deployment 1',
description: 'Initial description',
...overrides,
}
}
describe('DeploymentActionsMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -69,7 +54,7 @@ describe('DeploymentActionsMenu', () => {
it('keeps the trigger wrapper visible through uncontrolled menu state', () => {
const { container } = render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
appInstance={createAppInstance()}
placement="bottom-end"
className="pointer-events-none opacity-0"
/>,
@ -84,41 +69,12 @@ describe('DeploymentActionsMenu', () => {
)
})
it('prefetches the app instance when the menu opens', async () => {
const user = userEvent.setup()
render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
placement="bottom-end"
/>,
)
expect(prefetchQueryMock).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'deployments.card.moreActions' }))
await screen.findByRole('menuitem', { name: 'deployments.card.menu.editInfo' })
expect(prefetchQueryMock).toHaveBeenCalledWith(expect.objectContaining({
input: {
params: {
appInstanceId: 'app-instance-1',
},
},
queryKey: ['getAppInstance', {
params: {
appInstanceId: 'app-instance-1',
},
}],
}))
})
it('opens edit and delete dialogs from menu items', async () => {
const user = userEvent.setup()
render(
<DeploymentActionsMenu
appInstanceId="app-instance-1"
appInstance={createAppInstance()}
placement="bottom-end"
/>,
)
@ -137,7 +93,7 @@ describe('DeploymentActionsMenu', () => {
expect(screen.getByTestId('edit-dialog')).toHaveAttribute('data-open', 'false')
expect(screen.getByTestId('delete-dialog')).toHaveAttribute('data-open', 'true')
expect(editDialogMock).toHaveBeenLastCalledWith({ open: false })
expect(deleteDialogMock).toHaveBeenLastCalledWith({ open: true })
expect(editDialogMock).toHaveBeenLastCalledWith({ appInstanceId: 'app-instance-1', open: false })
expect(deleteDialogMock).toHaveBeenLastCalledWith({ appInstanceId: 'app-instance-1', open: true })
})
})

View File

@ -17,24 +17,22 @@ import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import {
deleteDeploymentDialogOpenAtom,
deploymentActionAppInstanceIdAtom,
deploymentActionAppInstanceQueryAtom,
deploymentActionAppInstanceAtom,
} from './state'
function DeleteDeploymentDialogContent() {
const { t } = useTranslation('deployments')
const router = useRouter()
const appInstanceId = useAtomValue(deploymentActionAppInstanceIdAtom)
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
const setOpen = useSetAtom(deleteDeploymentDialogOpenAtom)
const instanceQuery = useAtomValue(deploymentActionAppInstanceQueryAtom)
const deleteInstance = useMutation(consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions())
const displayName = instanceQuery.data?.appInstance.displayName || appInstanceId
const displayName = appInstance.displayName || appInstance.id
function handleDelete() {
deleteInstance.mutate(
{
params: {
appInstanceId,
appInstanceId: appInstance.id,
},
},
{

View File

@ -14,11 +14,9 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import {
deploymentActionAppInstanceIdAtom,
deploymentActionAppInstanceQueryAtom,
deploymentActionAppInstanceAtom,
editDeploymentDialogOpenAtom,
} from './state'
@ -46,33 +44,15 @@ function canSubmitEditDeploymentForm(initialValues: EditDeploymentFormValues, va
)
}
function EditDeploymentFormSkeleton() {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<SkeletonRectangle className="my-0 h-3 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</div>
<div className="flex flex-col gap-2">
<SkeletonRectangle className="my-0 h-3 w-28 animate-pulse" />
<SkeletonRectangle className="my-0 h-24 w-full animate-pulse rounded-lg" />
</div>
<SkeletonRow className="justify-end gap-2">
<SkeletonRectangle className="my-0 h-8 w-16 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
function EditDeploymentForm({
initialValues,
}: {
initialValues: EditDeploymentFormValues
}) {
function EditDeploymentForm() {
const { t } = useTranslation('deployments')
const nameLabel = t('settings.name')
const appInstanceId = useAtomValue(deploymentActionAppInstanceIdAtom)
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
const appInstanceId = appInstance.id
const initialValues = {
name: appInstance.displayName,
description: appInstance.description,
}
const setOpen = useSetAtom(editDeploymentDialogOpenAtom)
const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions())
@ -162,33 +142,19 @@ function EditDeploymentForm({
function EditDeploymentDialogContent() {
const { t } = useTranslation('deployments')
const instanceQuery = useAtomValue(deploymentActionAppInstanceQueryAtom)
const app = instanceQuery.data?.appInstance
const appInstance = useAtomValue(deploymentActionAppInstanceAtom)
return (
<>
{!app && <DialogCloseButton />}
<div className="border-b border-divider-subtle px-6 py-5">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('card.menu.editInfo')}
</DialogTitle>
</div>
<div className="px-6 py-5">
{instanceQuery.isLoading
? <EditDeploymentFormSkeleton />
: instanceQuery.isError
? <div className="system-sm-regular text-text-tertiary">{t('common.loadFailed')}</div>
: app
? (
<EditDeploymentForm
key={`${app.id}-${app.displayName}-${app.description}`}
initialValues={{
name: app.displayName,
description: app.description,
}}
/>
)
: <div className="system-sm-regular text-text-tertiary">{t('detail.notFound')}</div>}
<EditDeploymentForm
key={`${appInstance.id}-${appInstance.displayName}-${appInstance.description}`}
/>
</div>
</>
)

View File

@ -1,6 +1,7 @@
'use client'
import type { ComponentProps } from 'react'
import type { DeploymentActionAppInstance } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@ -9,15 +10,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useQueryClient } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useSetAtom } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { DeleteDeploymentDialog } from './delete-dialog'
import { EditDeploymentDialog } from './edit-dialog'
import {
deploymentActionAppInstanceIdAtom,
deploymentActionAppInstanceQueryOptionsAtom,
deploymentActionAppInstanceAtom,
deploymentActionsLocalAtoms,
openDeleteDeploymentDialogAtom,
openEditDeploymentDialogAtom,
@ -30,7 +29,7 @@ const ACTION_TRIGGER_CLASS_NAME = cn(
)
type DeploymentActionsMenuProps = {
appInstanceId: string
appInstance: DeploymentActionAppInstance
className?: string
triggerClassName?: string
placement: ComponentProps<typeof DropdownMenuContent>['placement']
@ -42,18 +41,11 @@ function DeploymentActionsMenuContent({
triggerClassName,
placement,
sideOffset,
}: Omit<DeploymentActionsMenuProps, 'appInstanceId'>) {
}: Omit<DeploymentActionsMenuProps, 'appInstance'>) {
const { t } = useTranslation('deployments')
const queryClient = useQueryClient()
const appInstanceQueryOptions = useAtomValue(deploymentActionAppInstanceQueryOptionsAtom)
const openEditDialog = useSetAtom(openEditDeploymentDialogAtom)
const openDeleteDialog = useSetAtom(openDeleteDeploymentDialogAtom)
function handleMenuOpenChange(open: boolean) {
if (open)
void queryClient.prefetchQuery(appInstanceQueryOptions)
}
return (
<div
role="presentation"
@ -64,7 +56,7 @@ function DeploymentActionsMenuContent({
onClick={event => event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}
>
<DropdownMenu modal={false} onOpenChange={handleMenuOpenChange}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t('card.moreActions')}
className={cn(ACTION_TRIGGER_CLASS_NAME, triggerClassName)}
@ -94,14 +86,14 @@ function DeploymentActionsMenuContent({
}
export function DeploymentActionsMenu({
appInstanceId,
appInstance,
...props
}: DeploymentActionsMenuProps) {
return (
<ScopeProvider
key={appInstanceId}
key={appInstance.id}
atoms={[
[deploymentActionAppInstanceIdAtom, appInstanceId],
[deploymentActionAppInstanceAtom, appInstance],
...deploymentActionsLocalAtoms,
]}
name="DeploymentActionsMenu"

View File

@ -0,0 +1,27 @@
'use client'
import type { DeploymentActionAppInstance } from './types'
import { atom } from 'jotai'
import { atomWithLazy } from 'jotai/utils'
export const deploymentActionAppInstanceAtom = atomWithLazy<DeploymentActionAppInstance>(() => {
throw new Error('Missing deployment action app instance.')
})
export const editDeploymentDialogOpenAtom = atom(false)
export const deleteDeploymentDialogOpenAtom = atom(false)
export const openEditDeploymentDialogAtom = atom(null, (_get, set) => {
set(deleteDeploymentDialogOpenAtom, false)
set(editDeploymentDialogOpenAtom, true)
})
export const openDeleteDeploymentDialogAtom = atom(null, (_get, set) => {
set(editDeploymentDialogOpenAtom, false)
set(deleteDeploymentDialogOpenAtom, true)
})
export const deploymentActionsLocalAtoms = [
editDeploymentDialogOpenAtom,
deleteDeploymentDialogOpenAtom,
] as const

View File

@ -0,0 +1,3 @@
import type { AppInstance } from '@dify/contracts/enterprise/types.gen'
export type DeploymentActionAppInstance = Pick<AppInstance, 'id' | 'displayName' | 'description'>

View File

@ -0,0 +1,20 @@
# Deployment Detail Shell
Detail shell for the deployment app instance routes, including sidebar navigation, route headers, and route-level action slots.
## Internal Modules
| Module | Why this module uses it |
| --------------------------------- | ------------------------------------------------------------------------------ |
| `create-release` | Opens release creation from the detail header. |
| `deployment-actions` | Reuses app instance action menu behavior in the detail sidebar. |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
| `route-state` | Reads route identity used by detail query and tab atoms. |
| `detail/api-tokens` | Renders the developer API header switch for the API tokens route. |
| `detail/instances/header-actions` | Renders the new deployment header action for the instances route. |
## External Modules
| Module | Why this module uses it |
| ------------------------------------ | ----------------------------------------------------------------- |
| `app/components/goto-anything/atoms` | Opens the global search command from the detail sidebar shortcut. |

View File

@ -1,25 +1,37 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import type { Getter } from 'jotai'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
type QueryOptions = {
data?: unknown
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
refetchInterval?: (query: { state: { data?: unknown } }) => number | false
}
const mockEnvironmentDeploymentsData = vi.hoisted<{
current?: { environmentDeployments: EnvironmentDeployment[] }
}>(() => ({}))
vi.mock('jotai-tanstack-query', () => ({
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
...createOptions(get),
data: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: false,
})),
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom((get) => {
const options = createOptions(get)
return {
...options,
data: options.queryKey?.[0] === 'listEnvironmentDeployments'
? mockEnvironmentDeploymentsData.current
: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: false,
}
}),
}))
vi.mock('@/service/client', () => ({
@ -32,12 +44,6 @@ vi.mock('@/service/client', () => ({
queryKey: ['getAppInstance', options.input],
}),
},
getAppInstanceOverview: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getAppInstanceOverview', options.input],
}),
},
},
deploymentService: {
listEnvironmentDeployments: {
@ -55,14 +61,36 @@ async function loadState() {
return await import('../state')
}
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1', tab = 'overview') {
store.set(setNextRouteStateAtom, {
pathname: `/deployments/${appInstanceId}/overview`,
pathname: `/deployments/${appInstanceId}/${tab}`,
params: { appInstanceId },
})
}
function deploymentRow(id: string, overrides: Partial<EnvironmentDeployment> = {}) {
return {
environment: {
id,
displayName: id,
},
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_READY,
currentRelease: {
id: `release-${id}`,
},
desiredRelease: undefined,
currentDeployment: {
id: `deployment-${id}`,
},
...overrides,
} as EnvironmentDeployment
}
describe('deployment detail state', () => {
beforeEach(() => {
mockEnvironmentDeploymentsData.current = undefined
})
it('should disable detail queries with skipToken until a route app instance exists', async () => {
const state = await loadState()
const store = createStore()
@ -71,10 +99,6 @@ describe('deployment detail state', () => {
enabled: false,
input: skipToken,
})
expect(store.get(state.deploymentDetailOverviewQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
expect(store.get(state.deploymentEnvironmentDeploymentsQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
@ -91,10 +115,6 @@ describe('deployment detail state', () => {
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
expect(store.get(state.deploymentDetailOverviewQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
const environmentDeploymentsQuery = store.get(state.deploymentEnvironmentDeploymentsQueryAtom) as unknown as QueryOptions
expect(environmentDeploymentsQuery).toMatchObject({
@ -103,4 +123,33 @@ describe('deployment detail state', () => {
})
expect(environmentDeploymentsQuery.refetchInterval).toEqual(expect.any(Function))
})
it('should derive active detail tab from route pathname', async () => {
const state = await loadState()
const store = createStore()
setDeploymentRoute(store, 'app-instance-1', 'releases')
expect(store.get(state.deploymentDetailActiveTabAtom)).toBe('releases')
setDeploymentRoute(store, 'app-instance-1', 'unknown')
expect(store.get(state.deploymentDetailActiveTabAtom)).toBe('overview')
})
it('should derive runtime instance rows from environment deployments', async () => {
const state = await loadState()
const store = createStore()
mockEnvironmentDeploymentsData.current = {
environmentDeployments: [
deploymentRow('running'),
deploymentRow('undeployed', {
status: RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_UNSPECIFIED,
currentRelease: undefined,
desiredRelease: undefined,
currentDeployment: undefined,
}),
],
}
expect(store.get(state.deploymentRuntimeInstanceRowsAtom).map(row => row.environment.id)).toEqual(['running'])
})
})

View File

@ -0,0 +1,15 @@
# Deployment Access
Access route composition for webapp channels and environment permission sections.
## Internal Modules
| Module | Why this module uses it |
| --------------------------- | ------------------------------------------------------------------ |
| `route-state` | Reads the route app instance identity for access settings queries. |
| `detail/access/channels` | Renders access channel endpoint settings. |
| `detail/access/permissions` | Renders environment access policy settings. |
## External Modules
None.

View File

@ -31,12 +31,6 @@ vi.mock('@/service/client', () => ({
queryKey: ['getAccessSettings', options.input],
}),
},
getDeveloperApiSettings: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getDeveloperApiSettings', options.input],
}),
},
},
},
},
@ -62,10 +56,6 @@ describe('deployment access state', () => {
enabled: false,
input: skipToken,
})
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
setDeploymentRoute(store)
@ -73,9 +63,5 @@ describe('deployment access state', () => {
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
})
})

View File

@ -0,0 +1,17 @@
# Deployment Access Channels
Access channels section for viewing webapp and developer API endpoints and toggling webapp access.
## Internal Modules
| Module | Why this module uses it |
| --------------- | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
| `route-state` | Reads the route app instance identity for access-channel mutations. |
| `detail/access` | Reads access route query data for channel settings. |
## External Modules
| Module | Why this module uses it |
| ------------------------------ | --------------------------------------------------------------- |
| `app/components/base/skeleton` | Reuses skeleton primitives for channel endpoint loading states. |

View File

@ -8,10 +8,10 @@ import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../components/empty-state'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { Section } from '../../components/section'
import { CopyPill, EndpointRow } from '../components/endpoint'
import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../shared/components/empty-state'
import { CopyPill, EndpointRow } from '../../../shared/components/endpoint'
import { Section } from '../../../shared/components/section'
import { accessSettingsQueryAtom } from '../state'
import { getUrlOrigin } from './url'

View File

@ -3,7 +3,7 @@
import { AccessChannelsSection } from './channels/section'
import { AccessPermissionsSection } from './permissions/section'
export function AccessTab() {
export function DeploymentAccess() {
return (
<div className="flex w-full max-w-[960px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">
<AccessChannelsSection />

View File

@ -0,0 +1,18 @@
# Deployment Access Permissions
Environment permission section for viewing and editing access policies on deployment environments.
## Internal Modules
| Module | Why this module uses it |
| --------------------------------------------------- | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
| `route-state` | Reads the route app instance identity for access-policy mutations. |
| `detail/access` | Reads access route query data for environment access policies. |
| `detail/access/permissions/access-subject-selector` | Selects specific members and groups for access policies. |
## External Modules
| Module | Why this module uses it |
| ------------------------------ | --------------------------------------------------------- |
| `app/components/base/skeleton` | Reuses skeleton primitives for permission loading states. |

View File

@ -0,0 +1,17 @@
# Deployment Access Subject Selector
Subject selector used by access permission dialogs to search, add, and display members or groups.
## Internal Modules
None.
## External Modules
| Module | Why this module uses it |
| -------------------------------------------- | ---------------------------------------------------------------------------------- |
| `app/components/base/loading` | Reuses the inline loading indicator while access-subject search is pending. |
| `app/components/base/skeleton` | Reuses skeleton primitives for selected subject loading rows. |
| `context/app-context` | Reads the current user id to label the current user in selectable subject options. |
| `models/access-control` | Reuses access-control subject types and group models for selector values. |
| `service/access-control/use-access-subjects` | Searches users and groups for access-policy subject selection. |

View File

@ -4,9 +4,9 @@ import type { EnvironmentAccessPolicy } from '@dify/contracts/enterprise/types.g
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../components/empty-state'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { Section } from '../../components/section'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/components/empty-state'
import { Section } from '../../../shared/components/section'
import { accessSettingsQueryAtom } from '../state'
import { EnvironmentPermissionRow } from './environment-permission-row'

View File

@ -0,0 +1,19 @@
'use client'
import { skipToken } from '@tanstack/react-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
export const accessSettingsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
return consoleQuery.enterprise.accessService.getAccessSettings.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
})
})

View File

@ -0,0 +1,14 @@
# Deployment API Tokens
API tokens route for API token management, endpoint display, and API documentation drawer.
## Internal Modules
| Module | Why this module uses it |
| ---------------------------------------- | -------------------------------------------------------------------------- |
| `route-state` | Reads the route app instance identity for API token queries. |
| `detail/api-tokens/api-token-management` | Renders API endpoint display, API key creation, and API docs entry points. |
## External Modules
None.

View File

@ -0,0 +1,67 @@
import type { Getter } from 'jotai'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
type QueryOptions = {
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
}
vi.mock('jotai-tanstack-query', () => ({
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
...createOptions(get),
data: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: false,
})),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
accessService: {
getDeveloperApiSettings: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getDeveloperApiSettings', options.input],
}),
},
},
},
},
}))
async function loadState() {
return await import('../state')
}
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
store.set(setNextRouteStateAtom, {
pathname: `/deployments/${appInstanceId}/api-tokens`,
params: { appInstanceId },
})
}
describe('deployment API tokens state', () => {
it('should gate API token queries until a route app instance exists', async () => {
const state = await loadState()
const store = createStore()
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
setDeploymentRoute(store)
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
})
})

View File

@ -0,0 +1,15 @@
# Deployment API Keys
API key creation, listing, and generated token presentation for the API tokens route.
## Internal Modules
| Module | Why this module uses it |
| ------------------- | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
| `route-state` | Reads the route app instance identity for API key mutations. |
| `detail/api-tokens` | Uses API-token-owned query state and table styles. |
## External Modules
None.

View File

@ -28,10 +28,8 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../components/detail-table'
import {
API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../../components/detail-table-styles'
} from '../../../shared/components/detail-table'
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../table-styles'
function ApiKeyName({ apiKey }: {
apiKey: ApiKey

View File

@ -6,7 +6,7 @@ import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitl
import { toast } from '@langgenius/dify-ui/toast'
import { useClipboard } from 'foxact/use-clipboard'
import { useTranslation } from 'react-i18next'
import { CopyPill } from '../components/endpoint'
import { CopyPill } from '../../../shared/components/endpoint'
function buildCurlExample(apiUrl: string, token: string) {
return `curl -X POST '${apiUrl}' \\

View File

@ -0,0 +1,17 @@
# Deployment API Token Management
API token management section for API endpoint display, token creation, and token listing.
## Internal Modules
| Module | Why this module uses it |
| ---------------------------- | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
| `route-state` | Reads the route app instance identity for API token mutations. |
| `detail/api-tokens` | Reads API tokens route query data and table styles. |
| `detail/api-tokens/api-keys` | Renders API key creation, listing, and created-token surfaces. |
| `detail/api-tokens/docs` | Opens API documentation for the current endpoint. |
## External Modules
None.

View File

@ -1,26 +1,22 @@
'use client'
import type {
AccessChannels,
ApiKey,
Environment,
} from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../components/empty-state'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { CopyPill } from '../components/endpoint'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/components/empty-state'
import { CopyPill } from '../../../shared/components/endpoint'
import { ApiKeyGenerateMenu } from '../api-keys/api-key-generate-menu'
import { ApiKeyList } from '../api-keys/api-key-list'
import { CreatedApiTokenDialog } from '../api-keys/created-token-dialog'
import { DeveloperApiDocsDrawer } from '../docs/docs-drawer'
import { developerApiSettingsQueryAtom } from '../state'
import { ApiKeyGenerateMenu } from './api-key-generate-menu'
import { ApiKeyList } from './api-key-list'
import { CreatedApiTokenDialog } from './created-token-dialog'
import { DeveloperApiDocsDrawer } from './docs-drawer'
import { DeveloperApiSkeleton } from './skeleton'
type CreatedApiToken = {
@ -28,61 +24,6 @@ type CreatedApiToken = {
token: string
}
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
checked: boolean
accessChannels?: AccessChannels
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
return (
<Switch
aria-label={t('access.api.developerTitle')}
checked={checked}
disabled={disabled || !appInstanceId}
loading={toggleDeveloperAPI.isPending}
onCheckedChange={(enabled) => {
if (!appInstanceId)
return
toggleDeveloperAPI.mutate({
params: { appInstanceId },
body: {
appInstanceId,
webAppEnabled: accessChannels?.webAppEnabled ?? false,
developerApiEnabled: enabled,
},
})
}}
/>
)
}
export function DeveloperApiHeaderSwitch() {
const { t } = useTranslation('deployments')
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
if (developerApiSettingsQuery.isLoading)
return <SwitchSkeleton />
return (
<div className="flex items-center gap-2">
<span className="system-xs-medium text-text-tertiary">
{apiEnabled ? t('overview.enabled') : t('overview.disabled')}
</span>
<DeveloperApiSwitch
checked={apiEnabled}
accessChannels={accessChannels}
disabled={developerApiSettingsQuery.isError}
/>
</div>
)
}
function ApiKeyListSection({ apiKeys, environments, action }: {
apiKeys: ApiKey[]
environments: Environment[]

View File

@ -11,8 +11,8 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../components/detail-table'
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../../components/detail-table-styles'
} from '../../../shared/components/detail-table'
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../table-styles'
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']

View File

@ -0,0 +1,65 @@
'use client'
import type { AccessChannels } from '@dify/contracts/enterprise/types.gen'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import { developerApiSettingsQueryAtom } from './state'
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
checked: boolean
accessChannels?: AccessChannels
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
return (
<Switch
aria-label={t('access.api.developerTitle')}
checked={checked}
disabled={disabled || !appInstanceId}
loading={toggleDeveloperAPI.isPending}
onCheckedChange={(enabled) => {
if (!appInstanceId)
return
toggleDeveloperAPI.mutate({
params: { appInstanceId },
body: {
appInstanceId,
webAppEnabled: accessChannels?.webAppEnabled ?? false,
developerApiEnabled: enabled,
},
})
}}
/>
)
}
export function DeveloperApiHeaderSwitch() {
const { t } = useTranslation('deployments')
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
if (developerApiSettingsQuery.isLoading)
return <SwitchSkeleton />
return (
<div className="flex items-center gap-2">
<span className="system-xs-medium text-text-tertiary">
{apiEnabled ? t('overview.enabled') : t('overview.disabled')}
</span>
<DeveloperApiSwitch
checked={apiEnabled}
accessChannels={accessChannels}
disabled={developerApiSettingsQuery.isError}
/>
</div>
)
}

View File

@ -0,0 +1,19 @@
# Deployment API Token Docs
API documentation drawer for the API tokens route.
## Internal Modules
| Module | Why this module uses it |
| ------------- | ------------------------------------------------------------- |
| `route-state` | Reads the route app instance identity for documentation copy. |
## External Modules
| Module | Why this module uses it |
| --------------------------------- | ----------------------------------------------------------- |
| `app/components/develop/template` | Reuses localized workflow API documentation templates. |
| `context/i18n` | Reads the current locale for documentation template choice. |
| `hooks/use-theme` | Reads the current theme for documentation rendering. |
| `i18n-config/language` | Maps the current locale to the documentation language. |
| `types/app` | Uses app and theme enums for documentation rendering. |

View File

@ -1,8 +1,8 @@
'use client'
import { DeveloperApiSection } from './section'
import { DeveloperApiSection } from './api-token-management/section'
export function DeveloperApiTab() {
export function DeploymentApiTokens() {
return (
<div className="flex w-full max-w-[960px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">
<DeveloperApiSection />

View File

@ -5,19 +5,6 @@ import { atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
export const accessSettingsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
return consoleQuery.enterprise.accessService.getAccessSettings.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
})
})
export const developerApiSettingsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)

View File

@ -0,0 +1,6 @@
export const API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
action: 'w-16 py-1.5 whitespace-nowrap',
environment: 'whitespace-nowrap',
key: '',
name: 'whitespace-nowrap',
}

View File

@ -1,31 +0,0 @@
import { cn } from '@langgenius/dify-ui/cn'
export const DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
actions: 'w-14 py-1.5 whitespace-nowrap',
currentRelease: '',
environment: 'whitespace-nowrap',
status: 'whitespace-nowrap',
}
export const RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
action: 'w-14 py-1.5 whitespace-nowrap',
author: 'whitespace-nowrap',
createdAt: 'whitespace-nowrap',
deployedTo: '',
release: 'whitespace-nowrap',
sourceApp: 'whitespace-nowrap',
}
export const API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
action: 'w-16 py-1.5 whitespace-nowrap',
environment: 'whitespace-nowrap',
key: '',
name: 'whitespace-nowrap',
}
export const DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME = cn(
'inline-flex size-8 items-center justify-center rounded-md text-text-tertiary outline-hidden',
'hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
'disabled:cursor-not-allowed disabled:opacity-50',
)

View File

@ -17,9 +17,9 @@ import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skel
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import Link from '@/next/link'
import { usePathname, useRouter } from '@/next/navigation'
import { DeploymentActionsMenu } from '../components/deployment-actions'
import { TitleTooltip } from '../components/title-tooltip'
import { DeploymentActionsMenu } from '../deployment-actions'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
import { TitleTooltip } from '../shared/components/title-tooltip'
import { deploymentDetailAppInstanceQueryAtom } from './state'
type TabDef = {
@ -164,7 +164,7 @@ function DeploymentDetailInstanceInfo({ appInstanceId, expand }: {
)}
</div>
<DeploymentActionsMenu
appInstanceId={appInstanceId}
appInstance={app}
placement="bottom-end"
sideOffset={4}
className="shrink-0"

View File

@ -1,25 +1,24 @@
'use client'
import type { ReactNode } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import { useAtomValue } from 'jotai'
import { ScopeProvider } from 'jotai-scope'
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 '../create-release'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
import { DeveloperApiHeaderSwitch } from './access-tab/developer-api/section'
import { NewDeploymentHeaderAction } from './deploy-tab/new-deployment-button'
import { INSTANCE_DETAIL_TAB_KEYS, isInstanceDetailTabKey } from './tabs'
import { versionsTabLocalAtoms } from './versions-tab/state'
import { DeveloperApiHeaderSwitch } from './api-tokens/developer-api-header-switch'
import { NewDeploymentHeaderAction } from './instances/header-actions/new-deployment-button'
import { deploymentDetailActiveTabAtom } from './state'
import { INSTANCE_DETAIL_TAB_KEYS } from './tabs'
function MobileDetailTabs({ appInstanceId, activeTab }: {
appInstanceId: string
activeTab: InstanceDetailTabKey
}) {
function MobileDetailTabs() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const activeTab = useAtomValue(deploymentDetailActiveTabAtom)
if (!appInstanceId)
return null
return (
<nav
@ -50,9 +49,7 @@ export function InstanceDetail({ children }: {
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const selectedSegment = useSelectedLayoutSegment()
const selectedTab = selectedSegment ?? undefined
const activeTab: InstanceDetailTabKey = isInstanceDetailTabKey(selectedTab) ? selectedTab : 'overview'
const activeTab = useAtomValue(deploymentDetailActiveTabAtom)
useDocumentTitle(t('documentTitle.detail'))
@ -60,45 +57,37 @@ export function InstanceDetail({ children }: {
return null
return (
<ScopeProvider
key={appInstanceId}
atoms={[
...versionsTabLocalAtoms,
]}
name="DeploymentDetail"
>
<div className="relative m-1 ml-0 flex min-h-0 flex-1 overflow-hidden rounded-lg shadow-xs">
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
<div className="h-full min-w-0 overflow-y-auto">
<div className="flex min-h-full w-full flex-col">
<div className="flex w-full flex-col gap-y-0.5 px-4 pt-3 pb-2 sm:px-6">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
{activeTab === 'api-tokens' && (
<div className="shrink-0">
<DeveloperApiHeaderSwitch />
</div>
)}
</div>
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
<div className="relative m-1 ml-0 flex min-h-0 flex-1 overflow-hidden rounded-lg shadow-xs">
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
<div className="h-full min-w-0 overflow-y-auto">
<div className="flex min-h-full w-full flex-col">
<div className="flex w-full flex-col gap-y-0.5 px-4 pt-3 pb-2 sm:px-6">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
{activeTab === 'api-tokens' && (
<div className="shrink-0">
<DeveloperApiHeaderSwitch />
</div>
)}
</div>
{(activeTab === 'instances' || activeTab === 'releases') && (
<div className="w-full shrink-0 pt-1 sm:w-auto sm:pt-1.5 [&_button]:w-full sm:[&_button]:w-auto">
{activeTab === 'instances'
? <NewDeploymentHeaderAction />
: <CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
</div>
)}
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
</div>
{(activeTab === 'instances' || activeTab === 'releases') && (
<div className="w-full shrink-0 pt-1 sm:w-auto sm:pt-1.5 [&_button]:w-full sm:[&_button]:w-auto">
{activeTab === 'instances'
? <NewDeploymentHeaderAction />
: <CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
</div>
)}
</div>
<MobileDetailTabs appInstanceId={appInstanceId} activeTab={activeTab} />
{children}
</div>
<MobileDetailTabs />
{children}
</div>
</div>
</div>
</ScopeProvider>
</div>
)
}

View File

@ -0,0 +1,18 @@
# Deployment Instances
Instances route for listing environment deployments and running deployment row actions.
## Internal Modules
| Module | Why this module uses it |
| ----------------------------------- | ----------------------------------------------------------------------------------- |
| `detail` | Reads detail-owned query state and runtime instance rows before composing sections. |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
| `detail/instances/environment-list` | Renders environment deployment rows. |
| `detail/instances/header-actions` | Renders the new deployment action for empty instances. |
## External Modules
| Module | Why this module uses it |
| ------------------------------ | --------------------------------------------------------------- |
| `app/components/base/skeleton` | Reuses skeleton primitives for deployment table loading states. |

View File

@ -0,0 +1,15 @@
# Deployment Instances Environment List
Environment list section for rendering deployment rows and row-level status summaries on the instances route.
## Internal Modules
| Module | Why this module uses it |
| ------------------------------ | ------------------------------------------------------------------------------ |
| `shared` | Reuses shared deployment domain rules, UI primitives, hooks, or local helpers. |
| `detail/instances` | Uses instances-owned table column class names for deployment rows. |
| `detail/instances/row-actions` | Renders row-level deployment actions for each environment. |
## External Modules
None.

Some files were not shown because too many files have changed in this diff Show More