mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
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:
1 parent
8218694691
commit
a14310fc62
177 files changed
+3043
-2055
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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. |
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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)
|
||||
))
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
@@ -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'> }
|
||||
@@ -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,
|
||||
}]
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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([]),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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. |
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -25,9 +25,7 @@ const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
sourceAppsQuery,
|
||||
useInfiniteScroll: vi.fn(() => ({
|
||||
rootEl: null,
|
||||
rootRef: vi.fn(),
|
||||
sentinelEl: null,
|
||||
sentinelRef: vi.fn(),
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. |
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }: {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Deployment Actions
|
||||
|
||||
Action menu and edit/delete dialogs for deployment app instances.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
None.
|
||||
|
||||
## External Modules
|
||||
|
||||
None.
|
||||
+13
-61
@@ -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()
|
||||
})
|
||||
|
||||
+17
-78
@@ -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 () => {
|
||||
+23
-67
@@ -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 })
|
||||
})
|
||||
})
|
||||
+4
-6
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
+12
-46
@@ -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>
|
||||
</>
|
||||
)
|
||||
+9
-17
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { AppInstance } from '@dify/contracts/enterprise/types.gen'
|
||||
|
||||
export type DeploymentActionAppInstance = Pick<AppInstance, 'id' | 'displayName' | 'description'>
|
||||
@@ -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. |
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
-14
@@ -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' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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. |
|
||||
File renamed without changes.
+3
-3
@@ -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'
|
||||
|
||||
File renamed without changes.
+1
-1
@@ -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 />
|
||||
@@ -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. |
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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. |
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
-2
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
@@ -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' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
File renamed without changes.
File renamed without changes.
+2
-4
@@ -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
|
||||
File renamed without changes.
+1
-1
@@ -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}' \\
|
||||
@@ -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.
|
||||
+6
-65
@@ -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[]
|
||||
+2
-2
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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. |
|
||||
File renamed without changes.
+2
-2
@@ -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 />
|
||||
-13
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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. |
|
||||
@@ -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.
|
||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user